diff --git a/tests/e2e/auth.spec.ts b/tests/e2e/auth.spec.ts new file mode 100644 index 000000000..6bb4776e6 --- /dev/null +++ b/tests/e2e/auth.spec.ts @@ -0,0 +1,223 @@ +import { test, expect } from '@playwright/test'; + +/** + * P3.3: E2E Authentication Flow Tests + * + * Comprehensive tests for the complete authentication flow to prevent regressions. + * Tests cover login, register, logout, session persistence, and token refresh. + */ + +const BASE_URL = 'http://localhost:5173'; +const API_URL = 'http://localhost:8080'; + +// Test credentials +const TEST_USER = { + email: 'test@veza.app', + username: 'testuser', + password: 'test123', +}; + +const NEW_USER = { + email: `test-${Date.now()}@veza.app`, + username: `testuser${Date.now()}`, + password: 'test123', +}; + +test.describe('Authentication Flow', () => { + test.beforeEach(async ({ page }) => { + // Clear cookies and storage before each test + await page.context().clearCookies(); + await page.goto(BASE_URL); + }); + + test('login with valid credentials should succeed', async ({ page }) => { + // Navigate to login page + await page.goto(`${BASE_URL}/login`); + + // Fill in credentials + await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); + await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); + + // Submit form + await page.click('button[type="submit"]'); + + // Should redirect to dashboard or home + await expect(page).toHaveURL(/\/(dashboard|home|library)/); + + // Verify user is authenticated (check for logout button or user menu) + await expect(page.locator('text=/logout/i, [aria-label*="logout"], [data-testid="logout"]')).toBeVisible({ timeout: 5000 }); + }); + + test('login with invalid credentials should fail', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + + // Fill in invalid credentials + await page.fill('input[name="email"], input[type="email"]', 'invalid@example.com'); + await page.fill('input[name="password"], input[type="password"]', 'wrongpassword'); + + // Submit form + await page.click('button[type="submit"]'); + + // Should show error message + await expect(page.locator('text=/invalid|incorrect|failed/i')).toBeVisible({ timeout: 3000 }); + + // Should stay on login page + await expect(page).toHaveURL(/\/login/); + }); + + test('session should persist after page refresh', async ({ page }) => { + // Login first + await page.goto(`${BASE_URL}/login`); + await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); + await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); + await page.click('button[type="submit"]'); + + // Wait for redirect + await expect(page).toHaveURL(/\/(dashboard|home|library)/); + + // Refresh the page + await page.reload(); + + // Should still be authenticated (not redirected to login) + await expect(page).not.toHaveURL(/\/login/); + await expect(page.locator('text=/logout/i, [aria-label*="logout"]')).toBeVisible({ timeout: 5000 }); + }); + + test('logout should clear session and redirect to login', async ({ page }) => { + // Login first + await page.goto(`${BASE_URL}/login`); + await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); + await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); + await page.click('button[type="submit"]'); + + // Wait for redirect + await expect(page).toHaveURL(/\/(dashboard|home|library)/); + + // Click logout + await page.click('text=/logout/i, [aria-label*="logout"], [data-testid="logout"]'); + + // Should redirect to login + await expect(page).toHaveURL(/\/login/); + + // Try to access protected route - should redirect back to login + await page.goto(`${BASE_URL}/dashboard`); + await expect(page).toHaveURL(/\/login/); + }); + + test('register new user should succeed', async ({ page }) => { + await page.goto(`${BASE_URL}/register`); + + // Fill in registration form + await page.fill('input[name="email"], input[type="email"]', NEW_USER.email); + await page.fill('input[name="username"]', NEW_USER.username); + await page.fill('input[name="password"], input[type="password"]', NEW_USER.password); + + // May have password confirmation field + const confirmPasswordField = page.locator('input[name="confirmPassword"], input[name="password_confirmation"]'); + if (await confirmPasswordField.isVisible()) { + await confirmPasswordField.fill(NEW_USER.password); + } + + // Submit form + await page.click('button[type="submit"]'); + + // Should redirect to dashboard or show success message + await expect(page).toHaveURL(/\/(dashboard|home|library|login)/, { timeout: 10000 }); + }); + + test('protected routes should redirect to login when not authenticated', async ({ page }) => { + // Try to access protected routes without authentication + const protectedRoutes = ['/dashboard', '/library', '/playlists', '/profile']; + + for (const route of protectedRoutes) { + await page.goto(`${BASE_URL}${route}`); + + // Should redirect to login + await expect(page).toHaveURL(/\/login/, { timeout: 3000 }); + } + }); + + test('health endpoint should be accessible', async ({ page }) => { + // Test the health endpoint created in P1.6 + const response = await page.request.get(`${API_URL}/api/v1/health`); + + expect(response.ok()).toBeTruthy(); + + const data = await response.json(); + expect(data.status).toBe('ok'); + expect(data.timestamp).toBeDefined(); + }); + + test('CORS headers should be present on API requests', async ({ page }) => { + await page.goto(`${BASE_URL}/login`); + + // Make a request to the API + const response = await page.request.get(`${API_URL}/api/v1/health`, { + headers: { + 'Origin': BASE_URL, + }, + }); + + // Check for CORS headers (P1.1 fix) + const headers = response.headers(); + expect(headers['access-control-allow-origin']).toBeDefined(); + }); +}); + +test.describe('Token Refresh Flow', () => { + test('should handle token refresh gracefully', async ({ page }) => { + // Login + await page.goto(`${BASE_URL}/login`); + await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); + await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL(/\/(dashboard|home|library)/); + + // Wait for potential token refresh (access token expires after 15min in prod) + // In dev, we can't easily test this without mocking time + // But we can verify the app doesn't crash on refresh + await page.reload(); + + // Should still be authenticated + await expect(page).not.toHaveURL(/\/login/); + }); + + test('should logout after max refresh attempts (P1.4)', async ({ page }) => { + // This test would require mocking the backend to return 401 repeatedly + // For now, we just document the expected behavior: + // - After 3 failed refresh attempts, user should be logged out + // - User should see error message: "Session expired after multiple attempts" + + // Placeholder test - would need backend mocking to implement fully + expect(true).toBe(true); + }); +}); + +test.describe('CSRF Protection', () => { + test('mutations should include CSRF token (P1.3)', async ({ page }) => { + // Login first + await page.goto(`${BASE_URL}/login`); + await page.fill('input[name="email"], input[type="email"]', TEST_USER.email); + await page.fill('input[name="password"], input[type="password"]', TEST_USER.password); + + // Intercept POST requests to verify CSRF token + let csrfTokenPresent = false; + page.on('request', (request) => { + if (request.method() === 'POST' && request.url().includes('/api/')) { + const headers = request.headers(); + if (headers['x-csrf-token']) { + csrfTokenPresent = true; + } + } + }); + + await page.click('button[type="submit"]'); + + // Wait for request to complete + await page.waitForTimeout(1000); + + // CSRF token should be present on POST requests + expect(csrfTokenPresent).toBe(true); + }); +});