diff --git a/frontend/e2e/helpers/auth.ts b/frontend/e2e/helpers/auth.ts new file mode 100644 index 0000000..11d3411 --- /dev/null +++ b/frontend/e2e/helpers/auth.ts @@ -0,0 +1,146 @@ +/** + * Authentication & API mocking helper for E2E tests + * Provides mock API responses for testing authenticated pages + * without requiring a real backend + */ + +import { Page, Route } from '@playwright/test'; + +/** + * Mock user data for E2E testing + */ +export const MOCK_USER = { + id: '00000000-0000-0000-0000-000000000001', + email: 'test@example.com', + first_name: 'Test', + last_name: 'User', + phone_number: null, + is_active: true, + is_superuser: false, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), +}; + +/** + * Mock session data for E2E testing + */ +export const MOCK_SESSION = { + id: '00000000-0000-0000-0000-000000000002', + device_type: 'Desktop', + device_name: 'Chrome on Linux', + ip_address: '127.0.0.1', + location: 'Local', + last_used_at: new Date().toISOString(), + created_at: new Date().toISOString(), + is_current: true, +}; + +/** + * Set up API mocking for authenticated E2E tests + * Intercepts backend API calls and returns mock data + * + * @param page Playwright page object + */ +export async function setupAuthenticatedMocks(page: Page): Promise { + const baseURL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'; + + // Mock GET /api/v1/users/me - Get current user + await page.route(`${baseURL}/api/v1/users/me`, async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: MOCK_USER, + }), + }); + }); + + // Mock PATCH /api/v1/users/me - Update user profile + await page.route(`${baseURL}/api/v1/users/me`, async (route: Route) => { + if (route.request().method() === 'PATCH') { + const postData = route.request().postDataJSON(); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: { ...MOCK_USER, ...postData }, + }), + }); + } else { + await route.continue(); + } + }); + + // Mock POST /api/v1/auth/change-password - Change password + await page.route(`${baseURL}/api/v1/auth/change-password`, async (route: Route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + message: 'Password changed successfully', + }), + }); + }); + + // Mock GET /api/v1/sessions - Get user sessions + await page.route(`${baseURL}/api/v1/sessions**`, async (route: Route) => { + if (route.request().method() === 'GET') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + data: [MOCK_SESSION], + }), + }); + } else { + await route.continue(); + } + }); + + // Mock DELETE /api/v1/sessions/:id - Revoke session + await page.route(`${baseURL}/api/v1/sessions/*`, async (route: Route) => { + if (route.request().method() === 'DELETE') { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + success: true, + message: 'Session revoked successfully', + }), + }); + } else { + await route.continue(); + } + }); + + // Navigate to home first to set up auth state + await page.goto('/'); + + // Inject auth state directly into Zustand store + await page.evaluate((mockUser) => { + // Mock encrypted token storage + localStorage.setItem('auth_tokens', 'mock-encrypted-token'); + localStorage.setItem('auth_storage_method', 'localStorage'); + + // Find and inject into the auth store + // Zustand stores are available on window in dev mode + const stores = Object.keys(window).filter(key => key.includes('Store')); + + // Try to find useAuthStore + const authStore = (window as any).useAuthStore; + if (authStore && authStore.getState) { + authStore.setState({ + user: mockUser, + accessToken: 'mock-access-token', + refreshToken: 'mock-refresh-token', + isAuthenticated: true, + isLoading: false, + tokenExpiresAt: Date.now() + 900000, // 15 minutes from now + }); + } + }, MOCK_USER); +} diff --git a/frontend/e2e/settings-navigation.spec.ts b/frontend/e2e/settings-navigation.spec.ts new file mode 100644 index 0000000..6c85eb2 --- /dev/null +++ b/frontend/e2e/settings-navigation.spec.ts @@ -0,0 +1,161 @@ +/** + * E2E Tests for Settings Navigation + * Tests navigation between different settings pages using mocked API + */ + +import { test, expect } from '@playwright/test'; +import { setupAuthenticatedMocks } from './helpers/auth'; + +test.describe('Settings Navigation', () => { + test.beforeEach(async ({ page }) => { + // Set up API mocks for authenticated user + await setupAuthenticatedMocks(page); + + // Navigate to settings + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + }); + + test('should display settings tabs', async ({ page }) => { + // Check all tabs are visible + await expect(page.locator('a:has-text("Profile")')).toBeVisible(); + await expect(page.locator('a:has-text("Password")')).toBeVisible(); + await expect(page.locator('a:has-text("Sessions")')).toBeVisible(); + }); + + test('should highlight active tab', async ({ page }) => { + // Profile tab should be active (check for active styling) + const profileTab = page.locator('a:has-text("Profile")').first(); + + // Check if it has active state (could be via class or aria-current) + const hasActiveClass = await profileTab.evaluate((el) => { + return el.classList.contains('active') || + el.getAttribute('aria-current') === 'page' || + el.classList.contains('bg-muted') || + el.getAttribute('data-state') === 'active'; + }); + + expect(hasActiveClass).toBeTruthy(); + }); + + test('should navigate from Profile to Password', async ({ page }) => { + // Click Password tab + const passwordTab = page.locator('a:has-text("Password")').first(); + + await Promise.all([ + page.waitForURL('/settings/password', { timeout: 10000 }), + passwordTab.click(), + ]); + + await expect(page).toHaveURL('/settings/password'); + await expect(page.locator('h2')).toContainText(/Change Password/i); + }); + + test('should navigate from Profile to Sessions', async ({ page }) => { + // Click Sessions tab + const sessionsTab = page.locator('a:has-text("Sessions")').first(); + + await Promise.all([ + page.waitForURL('/settings/sessions', { timeout: 10000 }), + sessionsTab.click(), + ]); + + await expect(page).toHaveURL('/settings/sessions'); + await expect(page.locator('h2')).toContainText(/Active Sessions/i); + }); + + test('should navigate from Password to Profile', async ({ page }) => { + // Go to password page first + await page.goto('/settings/password'); + await expect(page).toHaveURL('/settings/password'); + + // Click Profile tab + const profileTab = page.locator('a:has-text("Profile")').first(); + + await Promise.all([ + page.waitForURL('/settings/profile', { timeout: 10000 }), + profileTab.click(), + ]); + + await expect(page).toHaveURL('/settings/profile'); + await expect(page.locator('h2')).toContainText(/Profile/i); + }); + + test('should navigate from Sessions to Password', async ({ page }) => { + // Go to sessions page first + await page.goto('/settings/sessions'); + await expect(page).toHaveURL('/settings/sessions'); + + // Click Password tab + const passwordTab = page.locator('a:has-text("Password")').first(); + + await Promise.all([ + page.waitForURL('/settings/password', { timeout: 10000 }), + passwordTab.click(), + ]); + + await expect(page).toHaveURL('/settings/password'); + await expect(page.locator('h2')).toContainText(/Change Password/i); + }); + + test('should maintain layout when navigating between tabs', async ({ page }) => { + // Check header exists + await expect(page.locator('header')).toBeVisible(); + + // Navigate to different tabs + await page.goto('/settings/password'); + await expect(page.locator('header')).toBeVisible(); + + await page.goto('/settings/sessions'); + await expect(page.locator('header')).toBeVisible(); + + // Layout should be consistent + }); + + test('should have working back button navigation', async ({ page }) => { + // Navigate to password page + await page.goto('/settings/password'); + await expect(page).toHaveURL('/settings/password'); + + // Go back + await page.goBack(); + await expect(page).toHaveURL('/settings/profile'); + + // Go forward + await page.goForward(); + await expect(page).toHaveURL('/settings/password'); + }); + + test('should access settings from header dropdown', async ({ page }) => { + // Go to home page + await page.goto('/'); + + // Open user menu (avatar button) + const userMenuButton = page.locator('button[aria-label="User menu"], button:has([class*="avatar"])').first(); + + if (await userMenuButton.isVisible()) { + await userMenuButton.click(); + + // Click Settings option + const settingsLink = page.locator('a:has-text("Settings"), [role="menuitem"]:has-text("Settings")').first(); + + if (await settingsLink.isVisible()) { + await Promise.all([ + page.waitForURL(/\/settings/, { timeout: 10000 }), + settingsLink.click(), + ]); + + // Should navigate to settings (probably profile as default) + await expect(page.url()).toMatch(/\/settings/); + } + } + }); + + test('should redirect /settings to /settings/profile', async ({ page }) => { + // Navigate to base settings URL + await page.goto('/settings'); + + // Should redirect to profile + await expect(page).toHaveURL('/settings/profile'); + }); +}); diff --git a/frontend/e2e/settings-password.spec.ts b/frontend/e2e/settings-password.spec.ts new file mode 100644 index 0000000..2ba0b0a --- /dev/null +++ b/frontend/e2e/settings-password.spec.ts @@ -0,0 +1,135 @@ +/** + * E2E Tests for Password Change Page + * Tests password change functionality using mocked API + */ + +import { test, expect } from '@playwright/test'; +import { setupAuthenticatedMocks } from './helpers/auth'; + +test.describe('Password Change', () => { + test.beforeEach(async ({ page }) => { + // Set up API mocks for authenticated user + await setupAuthenticatedMocks(page); + + // Navigate to password settings + await page.goto('/settings/password'); + await expect(page).toHaveURL('/settings/password'); + }); + + test('should display password change page', async ({ page }) => { + // Check page title + await expect(page.locator('h2')).toContainText(/Change Password/i); + + // Check form fields exist + await expect(page.locator('input[name="current_password"]')).toBeVisible(); + await expect(page.locator('input[name="new_password"]')).toBeVisible(); + await expect(page.locator('input[name="confirm_password"]')).toBeVisible(); + }); + + test('should have submit button disabled when form is pristine', async ({ page }) => { + await page.waitForSelector('input[name="current_password"]'); + + // Submit button should be disabled initially + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeDisabled(); + }); + + test('should enable submit button when all fields are filled', async ({ page }) => { + await page.waitForSelector('input[name="current_password"]'); + + // Fill all password fields + await page.fill('input[name="current_password"]', 'Admin123!'); + await page.fill('input[name="new_password"]', 'NewAdmin123!'); + await page.fill('input[name="confirm_password"]', 'NewAdmin123!'); + + // Submit button should be enabled + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeEnabled(); + }); + + test('should show cancel button when form is dirty', async ({ page }) => { + await page.waitForSelector('input[name="current_password"]'); + + // Fill current password + await page.fill('input[name="current_password"]', 'Admin123!'); + + // Cancel button should appear + const cancelButton = page.locator('button[type="button"]:has-text("Cancel")'); + await expect(cancelButton).toBeVisible(); + }); + + test('should clear form when cancel button is clicked', async ({ page }) => { + await page.waitForSelector('input[name="current_password"]'); + + // Fill fields + await page.fill('input[name="current_password"]', 'Admin123!'); + await page.fill('input[name="new_password"]', 'NewAdmin123!'); + + // Click cancel + const cancelButton = page.locator('button[type="button"]:has-text("Cancel")'); + await cancelButton.click(); + + // Fields should be cleared + await expect(page.locator('input[name="current_password"]')).toHaveValue(''); + await expect(page.locator('input[name="new_password"]')).toHaveValue(''); + }); + + test('should show password strength requirements', async ({ page }) => { + // Check for password requirements text + await expect(page.locator('text=/at least 8 characters/i')).toBeVisible(); + }); + + test('should show validation error for weak password', async ({ page }) => { + await page.waitForSelector('input[name="new_password"]'); + + // Fill with weak password + await page.fill('input[name="current_password"]', 'Admin123!'); + await page.fill('input[name="new_password"]', 'weak'); + await page.fill('input[name="confirm_password"]', 'weak'); + + // Try to submit + const submitButton = page.locator('button[type="submit"]'); + if (await submitButton.isEnabled()) { + await submitButton.click(); + + // Should show validation error + await expect(page.locator('[role="alert"]').first()).toBeVisible(); + } + }); + + test('should show error when passwords do not match', async ({ page }) => { + await page.waitForSelector('input[name="new_password"]'); + + // Fill with mismatched passwords + await page.fill('input[name="current_password"]', 'Admin123!'); + await page.fill('input[name="new_password"]', 'NewAdmin123!'); + await page.fill('input[name="confirm_password"]', 'DifferentPassword123!'); + + // Tab away to trigger validation + await page.keyboard.press('Tab'); + + // Submit button might still be enabled, try to submit + const submitButton = page.locator('button[type="submit"]'); + if (await submitButton.isEnabled()) { + await submitButton.click(); + + // Should show validation error + await expect(page.locator('[role="alert"]').first()).toBeVisible(); + } + }); + + test('should have password inputs with correct type', async ({ page }) => { + // All password fields should have type="password" + await expect(page.locator('input[name="current_password"]')).toHaveAttribute('type', 'password'); + await expect(page.locator('input[name="new_password"]')).toHaveAttribute('type', 'password'); + await expect(page.locator('input[name="confirm_password"]')).toHaveAttribute('type', 'password'); + }); + + test('should display card title for password change', async ({ page }) => { + await expect(page.locator('text=Change Password')).toBeVisible(); + }); + + test('should show description about keeping account secure', async ({ page }) => { + await expect(page.locator('text=/keep your account secure/i')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/settings-profile.spec.ts b/frontend/e2e/settings-profile.spec.ts new file mode 100644 index 0000000..82daf3e --- /dev/null +++ b/frontend/e2e/settings-profile.spec.ts @@ -0,0 +1,124 @@ +/** + * E2E Tests for Profile Settings Page + * Tests profile editing functionality using mocked API + */ + +import { test, expect } from '@playwright/test'; +import { setupAuthenticatedMocks } from './helpers/auth'; + +test.describe('Profile Settings', () => { + test.beforeEach(async ({ page }) => { + // Set up API mocks for authenticated user + await setupAuthenticatedMocks(page); + + // Navigate to profile settings + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + }); + + test('should display profile settings page', async ({ page }) => { + // Check page title + await expect(page.locator('h2')).toContainText('Profile'); + + // Check form fields exist + await expect(page.locator('input[name="first_name"]')).toBeVisible(); + await expect(page.locator('input[name="last_name"]')).toBeVisible(); + await expect(page.locator('input[name="email"]')).toBeVisible(); + }); + + test('should pre-populate form with current user data', async ({ page }) => { + // Wait for form to load + await page.waitForSelector('input[name="first_name"]'); + + // Check that fields are populated + const firstName = await page.locator('input[name="first_name"]').inputValue(); + const email = await page.locator('input[name="email"]').inputValue(); + + expect(firstName).toBeTruthy(); + expect(email).toBeTruthy(); + }); + + test('should have email field disabled', async ({ page }) => { + const emailInput = page.locator('input[name="email"]'); + await expect(emailInput).toBeDisabled(); + }); + + test('should show submit button disabled when form is pristine', async ({ page }) => { + await page.waitForSelector('input[name="first_name"]'); + + // Submit button should be disabled initially + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeDisabled(); + }); + + test('should enable submit button when first name is modified', async ({ page }) => { + await page.waitForSelector('input[name="first_name"]'); + + // Modify first name + const firstNameInput = page.locator('input[name="first_name"]'); + await firstNameInput.fill('TestUser'); + + // Submit button should be enabled + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeEnabled(); + }); + + test('should show reset button when form is dirty', async ({ page }) => { + await page.waitForSelector('input[name="first_name"]'); + + // Modify first name + const firstNameInput = page.locator('input[name="first_name"]'); + await firstNameInput.fill('TestUser'); + + // Reset button should appear + const resetButton = page.locator('button[type="button"]:has-text("Reset")'); + await expect(resetButton).toBeVisible(); + }); + + test('should reset form when reset button is clicked', async ({ page }) => { + await page.waitForSelector('input[name="first_name"]'); + + // Get original value + const firstNameInput = page.locator('input[name="first_name"]'); + const originalValue = await firstNameInput.inputValue(); + + // Modify first name + await firstNameInput.fill('TestUser'); + await expect(firstNameInput).toHaveValue('TestUser'); + + // Click reset + const resetButton = page.locator('button[type="button"]:has-text("Reset")'); + await resetButton.click(); + + // Should revert to original value + await expect(firstNameInput).toHaveValue(originalValue); + }); + + test('should show validation error for empty first name', async ({ page }) => { + await page.waitForSelector('input[name="first_name"]'); + + // Clear first name + const firstNameInput = page.locator('input[name="first_name"]'); + await firstNameInput.fill(''); + + // Tab away to trigger validation + await page.keyboard.press('Tab'); + + // Try to submit (if button is enabled) + const submitButton = page.locator('button[type="submit"]'); + if (await submitButton.isEnabled()) { + await submitButton.click(); + + // Should show validation error + await expect(page.locator('[role="alert"]').first()).toBeVisible(); + } + }); + + test('should display profile information card title', async ({ page }) => { + await expect(page.locator('text=Profile Information')).toBeVisible(); + }); + + test('should show description about email being read-only', async ({ page }) => { + await expect(page.locator('text=/cannot be changed/i')).toBeVisible(); + }); +}); diff --git a/frontend/e2e/settings-sessions.spec.ts b/frontend/e2e/settings-sessions.spec.ts new file mode 100644 index 0000000..8e65cf9 --- /dev/null +++ b/frontend/e2e/settings-sessions.spec.ts @@ -0,0 +1,172 @@ +/** + * E2E Tests for Sessions Management Page + * Tests session viewing and revocation functionality using mocked API + */ + +import { test, expect } from '@playwright/test'; +import { setupAuthenticatedMocks } from './helpers/auth'; + +test.describe('Sessions Management', () => { + test.beforeEach(async ({ page }) => { + // Set up API mocks for authenticated user + await setupAuthenticatedMocks(page); + + // Navigate to sessions settings + await page.goto('/settings/sessions'); + await expect(page).toHaveURL('/settings/sessions'); + }); + + test('should display sessions management page', async ({ page }) => { + // Check page title + await expect(page.locator('h2')).toContainText(/Active Sessions/i); + + // Wait for sessions to load (either sessions or empty state) + await page.waitForSelector('text=/Current Session|No other active sessions/i', { + timeout: 10000, + }); + }); + + test('should show current session badge', async ({ page }) => { + // Wait for sessions to load + await page.waitForSelector('text=/Current Session/i', { timeout: 10000 }); + + // Current session badge should be visible + await expect(page.locator('text=Current Session')).toBeVisible(); + }); + + test('should display session information', async ({ page }) => { + // Wait for session card to load + await page.waitForSelector('[data-testid="session-card"], text=Current Session', { + timeout: 10000, + }); + + // Check for session details (these might vary, but device/IP should be present) + const sessionInfo = page.locator('text=/Monitor|Unknown Device|Desktop/i').first(); + await expect(sessionInfo).toBeVisible(); + }); + + test('should have revoke button disabled for current session', async ({ page }) => { + // Wait for sessions to load + await page.waitForSelector('text=Current Session', { timeout: 10000 }); + + // Find the revoke button near the current session badge + const currentSessionCard = page.locator('text=Current Session').locator('..'); + const revokeButton = currentSessionCard.locator('button:has-text("Revoke")').first(); + + // Revoke button should be disabled + await expect(revokeButton).toBeDisabled(); + }); + + test('should show empty state when no other sessions exist', async ({ page }) => { + // Wait for page to load + await page.waitForTimeout(2000); + + // Check if empty state is shown (if no other sessions) + const emptyStateText = page.locator('text=/No other active sessions/i'); + const hasOtherSessions = await page.locator('button:has-text("Revoke All Others")').isVisible(); + + // If there are no other sessions, empty state should be visible + if (!hasOtherSessions) { + await expect(emptyStateText).toBeVisible(); + } + }); + + test('should show security tip', async ({ page }) => { + // Check for security tip at bottom + await expect(page.locator('text=/security tip/i')).toBeVisible(); + }); + + test('should show bulk revoke button if multiple sessions exist', async ({ page }) => { + // Wait for sessions to load + await page.waitForSelector('text=Current Session', { timeout: 10000 }); + + // Check if "Revoke All Others" button exists (only if multiple sessions) + const bulkRevokeButton = page.locator('button:has-text("Revoke All Others")'); + const buttonCount = await bulkRevokeButton.count(); + + // If button exists, it should be enabled (assuming there are other sessions) + if (buttonCount > 0) { + await expect(bulkRevokeButton).toBeVisible(); + } + }); + + test('should show loading state initially', async ({ page }) => { + // Reload the page to see loading state + await page.reload(); + + // Loading skeleton or text should appear briefly + const loadingIndicator = page.locator('text=/Loading|Fetching/i, [class*="animate-pulse"]').first(); + + // This might be very fast, so we use a short timeout + const hasLoading = await loadingIndicator.isVisible().catch(() => false); + + // It's okay if this doesn't show (loading is very fast in tests) + // This test documents the expected behavior + }); + + test('should display last activity timestamp', async ({ page }) => { + // Wait for sessions to load + await page.waitForSelector('text=Current Session', { timeout: 10000 }); + + // Check for relative time stamp (e.g., "2 minutes ago", "just now") + const timestamp = page.locator('text=/ago|just now|seconds|minutes|hours/i').first(); + await expect(timestamp).toBeVisible(); + }); + + test('should navigate to sessions page from settings tabs', async ({ page }) => { + // Navigate to profile first + await page.goto('/settings/profile'); + await expect(page).toHaveURL('/settings/profile'); + + // Click on Sessions tab + const sessionsTab = page.locator('a:has-text("Sessions")'); + await sessionsTab.click(); + + // Should navigate to sessions page + await expect(page).toHaveURL('/settings/sessions'); + }); +}); + +test.describe('Sessions Management - Revocation', () => { + test.beforeEach(async ({ page }) => { + // Set up API mocks for authenticated user + await setupAuthenticatedMocks(page); + + // Navigate to sessions settings + await page.goto('/settings/sessions'); + await expect(page).toHaveURL('/settings/sessions'); + }); + + test('should show confirmation dialog before individual revocation', async ({ page }) => { + // Wait for sessions to load + await page.waitForSelector('text=Current Session', { timeout: 10000 }); + + // Check if there are other sessions with enabled revoke buttons + const enabledRevokeButtons = page.locator('button:has-text("Revoke"):not([disabled])'); + const count = await enabledRevokeButtons.count(); + + if (count > 0) { + // Click first enabled revoke button + await enabledRevokeButtons.first().click(); + + // Confirmation dialog should appear + await expect(page.locator('text=/Are you sure|confirm|revoke this session/i')).toBeVisible(); + } + }); + + test('should show confirmation dialog before bulk revocation', async ({ page }) => { + // Wait for sessions to load + await page.waitForSelector('text=Current Session', { timeout: 10000 }); + + // Check if bulk revoke button exists + const bulkRevokeButton = page.locator('button:has-text("Revoke All Others")'); + + if (await bulkRevokeButton.isVisible()) { + // Click bulk revoke + await bulkRevokeButton.click(); + + // Confirmation dialog should appear + await expect(page.locator('text=/Are you sure|confirm|revoke all/i')).toBeVisible(); + } + }); +});