2026-03-05 10:34:03 +01:00
|
|
|
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)
|
|
|
|
|
*/
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
/**
|
|
|
|
|
* Robust club selection helper with fallback locators
|
|
|
|
|
*/
|
2026-03-06 22:26:55 +01:00
|
|
|
async function selectClubIfPresent(page: import('@playwright/test').Page) {
|
2026-03-06 16:03:03 +01:00
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 10:34:03 +01:00
|
|
|
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');
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForURL(/localhost:3000/, { timeout: 30000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await selectClubIfPresent(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
|
|
|
|
await expect(page).toHaveURL(/\/dashboard/);
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
const dashboardContent = page.locator('body');
|
|
|
|
|
await expect(dashboardContent).toBeVisible({ timeout: 10000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
|
|
|
|
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');
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForURL(/\/(tasks|select-club)/, { timeout: 10000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await selectClubIfPresent(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForLoadState('domcontentloaded');
|
|
|
|
|
|
|
|
|
|
const pageContent = page.locator('main, [role="main"], body');
|
|
|
|
|
await expect(pageContent).toBeVisible();
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('Scenario 3: Logout flow - clears session and blocks protected routes', async ({ page }) => {
|
|
|
|
|
await authenticateUser(page, 'admin@test.com', 'testpass123');
|
|
|
|
|
|
|
|
|
|
await page.goto('/dashboard');
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForURL(/\/dashboard/, { timeout: 10000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
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 });
|
|
|
|
|
}
|
2026-03-05 10:34:03 +01:00
|
|
|
|
|
|
|
|
await page.goto('/dashboard');
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForURL(/\/(login|select-club)/, { timeout: 10000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForURL(/localhost:3000/, { timeout: 30000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
// Handle club selection if present
|
|
|
|
|
await selectClubIfPresent(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await expect(page).toHaveURL(/\/(dashboard|select-club)/);
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
console.log('✅ Single-club user flow completed');
|
2026-03-05 10:34:03 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
// Assert error message from Keycloak - use deterministic selector
|
|
|
|
|
await expect(page.locator('#input-error-username').first()).toBeVisible({ timeout: 5000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
|
|
|
|
console.log('✅ Invalid credentials correctly rejected');
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-03-06 22:26:55 +01:00
|
|
|
async function authenticateUser(page: import('@playwright/test').Page, email: string, password: string) {
|
2026-03-05 10:34:03 +01:00
|
|
|
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');
|
|
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
await page.waitForURL(/localhost:3000/, { timeout: 30000 });
|
2026-03-05 10:34:03 +01:00
|
|
|
|
2026-03-06 16:03:03 +01:00
|
|
|
// Handle club selection with robust helper
|
|
|
|
|
await selectClubIfPresent(page);
|
2026-03-05 10:34:03 +01:00
|
|
|
}
|