import { test, expect } from '@playwright/test'; /** * E2E Tests for Authentication Flow with Keycloak OIDC and Club Switching * * Prerequisites: * - Docker Compose stack running: postgres, keycloak, dotnet-api, nextjs * - Keycloak realm imported with test users * - Frontend accessible at http://localhost:3000 * * Test User Credentials (from Task 3): * - admin@test.com / testpass123 (member of club-1 AND club-2) */ /** * Robust club selection helper with fallback locators */ async function selectClubIfPresent(page: import('@playwright/test').Page) { const isOnSelectClub = page.url().includes('/select-club'); if (!isOnSelectClub) { return; } let clubClicked = false; // Strategy 1: Click Card with "Sunrise Tennis Club" text const sunriseCard = page.locator('div:has-text("Sunrise Tennis Club")').first(); if ((await sunriseCard.count()) > 0) { await sunriseCard.click(); clubClicked = true; } // Strategy 2: Click Card with "Valley Cycling Club" text if (!clubClicked) { const valleyCard = page.locator('div:has-text("Valley Cycling Club")').first(); if ((await valleyCard.count()) > 0) { await valleyCard.click(); clubClicked = true; } } // Strategy 3: Click first clickable Card (cursor-pointer) if (!clubClicked) { const clickableCard = page.locator('[class*="cursor-pointer"]').first(); if ((await clickableCard.count()) > 0) { await clickableCard.click(); clubClicked = true; } } // Wait for navigation away from select-club if (clubClicked) { await page.waitForURL(/\/(dashboard|tasks|shifts|login)/, { timeout: 10000 }); await page.waitForLoadState('domcontentloaded'); } } test.describe('Authentication Flow', () => { test.beforeEach(async ({ context }) => { // Clear all cookies and storage before each test await context.clearCookies(); await context.clearPermissions(); }); test('Scenario 1: Full auth flow E2E - redirect → Keycloak → club picker → dashboard', async ({ page }) => { await page.goto('/dashboard'); await page.waitForURL(/\/login/, { timeout: 10000 }); await expect(page).toHaveURL(/\/login/); await page.click('button:has-text("Sign in with Keycloak")'); await page.waitForURL(/localhost:8080.*realms\/workclub/, { timeout: 15000 }); await expect(page).toHaveURL(/keycloak/); await page.fill('#username', 'admin@test.com'); await page.fill('#password', 'testpass123'); await page.click('#kc-login'); await page.waitForURL(/localhost:3000/, { timeout: 30000 }); await selectClubIfPresent(page); await expect(page).toHaveURL(/\/dashboard/); const dashboardContent = page.locator('body'); await expect(dashboardContent).toBeVisible({ timeout: 10000 }); await page.screenshot({ path: '.sisyphus/evidence/task-26-auth-flow.png', fullPage: true }); console.log('✅ Full auth flow completed successfully'); }); test('Scenario 2: Club switching refreshes data', async ({ page }) => { await authenticateUser(page, 'admin@test.com', 'testpass123'); await page.goto('/tasks'); await page.waitForURL(/\/(tasks|select-club)/, { timeout: 10000 }); await selectClubIfPresent(page); await page.waitForLoadState('domcontentloaded'); const pageContent = page.locator('main, [role="main"], body'); await expect(pageContent).toBeVisible(); }); test('Scenario 3: Logout flow - clears session and blocks protected routes', async ({ page }) => { await authenticateUser(page, 'admin@test.com', 'testpass123'); await page.goto('/dashboard'); await page.waitForURL(/\/dashboard/, { timeout: 10000 }); const logoutButton = page.locator('button:has-text("Sign Out"), button:has-text("Logout"), [aria-label="Sign out"]'); const hasLogout = await logoutButton.isVisible().catch(() => false); if (hasLogout) { await logoutButton.click({ timeout: 5000 }); await page.waitForURL(/\/(login|select-club)/, { timeout: 10000 }); } await page.goto('/dashboard'); await page.waitForURL(/\/(login|select-club)/, { timeout: 10000 }); console.log('✅ Logout flow completed successfully'); }); test('Unauthenticated user blocked from protected routes', async ({ page }) => { // Attempt to access protected route directly await page.goto('/tasks'); // Assert redirected to login with callbackUrl await page.waitForURL(/\/login/, { timeout: 10000 }); await expect(page).toHaveURL(/\/login/); // Verify callbackUrl query param exists const url = new URL(page.url()); expect(url.searchParams.get('callbackUrl')).toBe('/tasks'); console.log('✅ Protected route correctly blocked'); }); test('Single-club user bypasses club picker', async ({ page }) => { await page.goto('/login'); await page.click('button:has-text("Sign in with Keycloak")'); await page.waitForURL(/localhost:8080.*realms\/workclub/, { timeout: 15000 }); await page.fill('#username', 'manager@test.com'); await page.fill('#password', 'testpass123'); await page.click('#kc-login'); await page.waitForURL(/localhost:3000/, { timeout: 30000 }); // Handle club selection if present await selectClubIfPresent(page); await expect(page).toHaveURL(/\/(dashboard|select-club)/); console.log('✅ Single-club user flow completed'); }); test('Keycloak login with invalid credentials fails', async ({ page }) => { await page.goto('/login'); await page.click('button:has-text("Sign in with Keycloak")'); await page.waitForURL(/localhost:8080.*realms\/workclub/, { timeout: 15000 }); // Enter invalid credentials await page.fill('#username', 'invalid@test.com'); await page.fill('#password', 'wrongpassword'); await page.click('#kc-login'); // Assert error message from Keycloak - use deterministic selector await expect(page.locator('#input-error-username').first()).toBeVisible({ timeout: 5000 }); console.log('✅ Invalid credentials correctly rejected'); }); }); async function authenticateUser(page: import('@playwright/test').Page, email: string, password: string) { await page.goto('/login'); await page.click('button:has-text("Sign in with Keycloak")'); await page.waitForURL(/localhost:8080.*realms\/workclub/, { timeout: 15000 }); await page.fill('#username', email); await page.fill('#password', password); await page.click('#kc-login'); await page.waitForURL(/localhost:3000/, { timeout: 30000 }); // Handle club selection with robust helper await selectClubIfPresent(page); }