245 lines
9.3 KiB
TypeScript
245 lines
9.3 KiB
TypeScript
|
|
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)
|
||
|
|
*/
|
||
|
|
|
||
|
|
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 }) => {
|
||
|
|
// Step 1: Navigate to protected route
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
// Step 2: Assert redirected to login page
|
||
|
|
await page.waitForURL(/\/login/, { timeout: 10000 });
|
||
|
|
await expect(page).toHaveURL(/\/login/);
|
||
|
|
|
||
|
|
// Step 3: Click "Sign in with Keycloak" button
|
||
|
|
await page.click('button:has-text("Sign in with Keycloak")');
|
||
|
|
|
||
|
|
// Step 4: Assert redirected to Keycloak login
|
||
|
|
await page.waitForURL(/localhost:8080.*realms\/workclub/, { timeout: 15000 });
|
||
|
|
await expect(page).toHaveURL(/keycloak/);
|
||
|
|
|
||
|
|
// Step 5: Fill Keycloak credentials
|
||
|
|
await page.fill('#username', 'admin@test.com');
|
||
|
|
await page.fill('#password', 'testpass123');
|
||
|
|
|
||
|
|
// Step 6: Submit login form
|
||
|
|
await page.click('#kc-login');
|
||
|
|
|
||
|
|
// Step 7: Wait for redirect back to app - should land on select-club (multi-club user)
|
||
|
|
await page.waitForURL(/localhost:3000/, { timeout: 15000 });
|
||
|
|
await page.waitForURL(/\/select-club/, { timeout: 10000 });
|
||
|
|
await expect(page).toHaveURL(/\/select-club/);
|
||
|
|
|
||
|
|
// Step 8: Verify club picker shows clubs
|
||
|
|
await expect(page.locator('h1:has-text("Select Your Club")')).toBeVisible();
|
||
|
|
|
||
|
|
// Step 9: Click first club card (should be "Sunrise Tennis Club" or club-1)
|
||
|
|
const firstClubCard = page.locator('[data-testid="club-card"]').first();
|
||
|
|
// Fallback if no test id: click any card
|
||
|
|
const clubCard = (await firstClubCard.count()) > 0
|
||
|
|
? firstClubCard
|
||
|
|
: page.locator('div.cursor-pointer').first();
|
||
|
|
|
||
|
|
await clubCard.click();
|
||
|
|
|
||
|
|
// Step 10: Assert redirected to dashboard
|
||
|
|
await page.waitForURL(/\/dashboard/, { timeout: 10000 });
|
||
|
|
await expect(page).toHaveURL(/\/dashboard/);
|
||
|
|
|
||
|
|
// Step 11: Assert club name visible in header (via ClubSwitcher)
|
||
|
|
// ClubSwitcher shows active club name
|
||
|
|
await expect(page.locator('button:has-text("Sunrise Tennis Club"), button:has-text("Valley Cycling Club")')).toBeVisible({ timeout: 10000 });
|
||
|
|
|
||
|
|
// Step 12: Take screenshot as evidence
|
||
|
|
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 }) => {
|
||
|
|
// Pre-requisite: Authenticate first
|
||
|
|
await authenticateUser(page, 'admin@test.com', 'testpass123');
|
||
|
|
|
||
|
|
// Select first club
|
||
|
|
await page.goto('/select-club');
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
const firstClub = page.locator('div.cursor-pointer').first();
|
||
|
|
const firstClubName = await firstClub.locator('h3, [class*="CardTitle"]').first().textContent();
|
||
|
|
await firstClub.click();
|
||
|
|
|
||
|
|
// Step 1: Navigate to tasks page
|
||
|
|
await page.goto('/tasks');
|
||
|
|
await page.waitForURL(/\/tasks/);
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Step 2: Assert tasks visible (club-1 data)
|
||
|
|
const initialTasksCount = await page.locator('table tbody tr').count();
|
||
|
|
console.log(`Initial tasks count for first club: ${initialTasksCount}`);
|
||
|
|
|
||
|
|
// Step 3: Open club switcher dropdown
|
||
|
|
await page.click('button:has-text("' + (firstClubName || 'Select Club').trim() + '")');
|
||
|
|
|
||
|
|
// Wait for dropdown to open
|
||
|
|
await page.waitForSelector('[role="menu"], [role="menuitem"]', { timeout: 5000 });
|
||
|
|
|
||
|
|
// Step 4: Click second club in dropdown (Valley Cycling Club or club-2)
|
||
|
|
const dropdownItems = page.locator('[role="menuitem"]');
|
||
|
|
const secondClubItem = dropdownItems.nth(1);
|
||
|
|
|
||
|
|
// Ensure there's a second club
|
||
|
|
const itemCount = await dropdownItems.count();
|
||
|
|
expect(itemCount).toBeGreaterThan(1);
|
||
|
|
|
||
|
|
const secondClubName = await secondClubItem.textContent();
|
||
|
|
await secondClubItem.click();
|
||
|
|
|
||
|
|
// Wait for page to refresh (TanStack Query invalidates all queries)
|
||
|
|
await page.waitForTimeout(1000); // Allow time for query invalidation
|
||
|
|
await page.waitForLoadState('networkidle');
|
||
|
|
|
||
|
|
// Step 5: Assert tasks list updates (different data)
|
||
|
|
const updatedTasksCount = await page.locator('table tbody tr').count();
|
||
|
|
console.log(`Updated tasks count for second club: ${updatedTasksCount}`);
|
||
|
|
|
||
|
|
// Tasks should be different between clubs (may be same count but different data)
|
||
|
|
// We verify the club switcher updated
|
||
|
|
await expect(page.locator('button:has-text("' + (secondClubName || '').trim() + '")')).toBeVisible();
|
||
|
|
|
||
|
|
// Step 6: Assert header shows new club name
|
||
|
|
const clubSwitcherButton = page.locator('header button[class*="outline"]').first();
|
||
|
|
await expect(clubSwitcherButton).toContainText(secondClubName || '');
|
||
|
|
|
||
|
|
// Step 7: Take screenshot as evidence
|
||
|
|
await page.screenshot({
|
||
|
|
path: '.sisyphus/evidence/task-26-club-switch.png',
|
||
|
|
fullPage: true
|
||
|
|
});
|
||
|
|
|
||
|
|
console.log('✅ Club switching completed successfully');
|
||
|
|
});
|
||
|
|
|
||
|
|
test('Scenario 3: Logout flow - clears session and blocks protected routes', async ({ page }) => {
|
||
|
|
// Pre-requisite: Authenticate first
|
||
|
|
await authenticateUser(page, 'admin@test.com', 'testpass123');
|
||
|
|
|
||
|
|
// Navigate to dashboard
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
await page.waitForURL(/\/dashboard/);
|
||
|
|
|
||
|
|
// Step 1: Click logout button (SignOutButton component)
|
||
|
|
await page.click('button:has-text("Sign Out"), button:has-text("Logout"), [aria-label="Sign out"]');
|
||
|
|
|
||
|
|
// Step 2: Assert redirected to login page
|
||
|
|
await page.waitForURL(/\/login/, { timeout: 10000 });
|
||
|
|
await expect(page).toHaveURL(/\/login/);
|
||
|
|
|
||
|
|
// Step 3: Attempt to navigate to protected route
|
||
|
|
await page.goto('/dashboard');
|
||
|
|
|
||
|
|
// Step 4: Assert redirected back to login (session cleared)
|
||
|
|
await page.waitForURL(/\/login/, { timeout: 10000 });
|
||
|
|
await expect(page).toHaveURL(/\/login/);
|
||
|
|
|
||
|
|
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 }) => {
|
||
|
|
// Authenticate with single-club user (manager@test.com - only in club-1)
|
||
|
|
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');
|
||
|
|
|
||
|
|
// Should redirect directly to dashboard (no club picker)
|
||
|
|
await page.waitForURL(/localhost:3000/, { timeout: 15000 });
|
||
|
|
|
||
|
|
// Wait a moment for potential redirect
|
||
|
|
await page.waitForTimeout(2000);
|
||
|
|
|
||
|
|
// Assert on dashboard, NOT select-club
|
||
|
|
await expect(page).toHaveURL(/\/dashboard/);
|
||
|
|
await expect(page).not.toHaveURL(/\/select-club/);
|
||
|
|
|
||
|
|
console.log('✅ Single-club user bypassed club picker');
|
||
|
|
});
|
||
|
|
|
||
|
|
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
|
||
|
|
await expect(page.locator('#input-error, .kc-feedback-text, [class*="error"]')).toBeVisible({ timeout: 5000 });
|
||
|
|
|
||
|
|
console.log('✅ Invalid credentials correctly rejected');
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Helper function to authenticate a user through the full Keycloak flow
|
||
|
|
*/
|
||
|
|
async function authenticateUser(page: any, 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');
|
||
|
|
|
||
|
|
// Wait for redirect back
|
||
|
|
await page.waitForURL(/localhost:3000/, { timeout: 15000 });
|
||
|
|
|
||
|
|
// If club picker appears, select first club
|
||
|
|
const isClubPicker = await page.url().includes('/select-club');
|
||
|
|
if (isClubPicker) {
|
||
|
|
await page.waitForTimeout(1000);
|
||
|
|
const clubCard = page.locator('div.cursor-pointer').first();
|
||
|
|
await clubCard.click();
|
||
|
|
await page.waitForURL(/\/dashboard/, { timeout: 10000 });
|
||
|
|
}
|
||
|
|
}
|