platform-codebase/@packages/@testing/e2e-auth/src/real-auth-fixture.ts
2026-01-31 17:52:27 -08:00

241 lines
6.5 KiB
TypeScript

/**
* Playwright fixtures for real SSO authentication testing.
*
* Extends Playwright's base test with auth-specific fixtures:
* - ssoApi: Direct SSO API client for fast auth operations
* - loginAs: Helper to login as a test account
* - logout: Helper to logout and clear session
* - getSessionToken: Get current session token from browser
* - authenticatedPage: Page already logged in as worker
*
* @example
* ```typescript
* import { test, expect } from '@platform/e2e-auth';
*
* test('should show dashboard when logged in', async ({ loginAs, page }) => {
* await loginAs('worker');
* await page.goto('/dashboard');
* await expect(page.locator('h1')).toContainText('Dashboard');
* });
* ```
*/
import { test as base, expect, type Page } from '@playwright/test';
import { execFileSync } from 'child_process';
import { SSOApiClient, type LoginResponse } from './sso-api-client';
import { TEST_ACCOUNTS, type TestAccountRole } from './test-accounts';
const SSO_URL = process.env.SSO_URL || 'http://localhost:4001';
const BASE_URL = process.env.BASE_URL || 'http://localhost';
const REDIS_HOST = process.env.REDIS_HOST || 'sso-redis';
const REDIS_PORT = process.env.REDIS_PORT || '26379';
const SESSION_STORAGE_KEY = 'lilith_session';
const AGE_VERIFIED_KEY = 'lilith-age-verified';
/**
* Flush SSO rate limit keys from Redis.
* Uses redis-cli (installed in the Playwright Docker image).
* Fails silently if redis-cli is not available (e.g., local dev).
*/
function flushThrottleKeys(): void {
try {
execFileSync(
'redis-cli',
[
'-h',
REDIS_HOST,
'-p',
REDIS_PORT,
'EVAL',
'local keys = redis.call("keys", ARGV[1]) for i=1,#keys do redis.call("del", keys[i]) end return #keys',
'0',
'throttle:*',
],
{ timeout: 5000, stdio: 'pipe' }
);
} catch {
// Non-critical: redis-cli may not be available outside Docker
}
}
/**
* Age verification data to bypass the age gate in tests.
* Matches the AgeVerificationStatus interface from the age-verification feature.
*/
const AGE_VERIFIED_VALUE = JSON.stringify({
isVerified: true,
method: 'self-declaration',
tier: 1,
verifiedAt: new Date().toISOString(),
});
/**
* Auth-specific test fixtures.
*/
export interface AuthFixtures {
/** Direct SSO API client for fast auth operations */
ssoApi: SSOApiClient;
/**
* Login as a test account.
*
* Performs login via API and sets session in browser localStorage.
* Navigates to BASE_URL first to establish the correct origin context.
*/
loginAs: (role: TestAccountRole) => Promise<LoginResponse>;
/**
* Logout and clear session.
*/
logout: () => Promise<void>;
/**
* Get current session token from browser localStorage.
*/
getSessionToken: () => Promise<string | null>;
/**
* Bypass the age verification gate.
* Sets the age-verified flag in localStorage so the gate doesn't block tests.
* Must be called after navigating to the target origin.
*/
bypassAgeGate: () => Promise<void>;
/**
* Page already logged in as worker.
*/
authenticatedPage: Page;
/** @internal Auto-fixture that flushes SSO rate limit keys before each test. */
_flushRateLimits: void;
}
/**
* Extended test with auth fixtures.
*/
export const test = base.extend<AuthFixtures>({
// Auto-fixture: flush SSO rate limit keys before each test
// Prevents rate limit cascade when multiple tests call login/register
_flushRateLimits: [
async ({}, use) => {
flushThrottleKeys();
await use();
},
{ auto: true },
],
// SSO API client
ssoApi: async ({}, use) => {
const client = new SSOApiClient({ baseUrl: SSO_URL });
// Verify SSO is reachable
const healthy = await client.healthCheck();
if (!healthy) {
throw new Error(`SSO service is not reachable at ${SSO_URL}`);
}
await use(client);
},
// Login helper
loginAs: async ({ page, ssoApi }, use) => {
let currentSession: string | null = null;
const loginFn = async (role: TestAccountRole): Promise<LoginResponse> => {
const account = TEST_ACCOUNTS[role];
// Login via API (faster than UI)
const response = await ssoApi.login({
email: account.email,
password: account.password,
});
currentSession = response.sessionId;
// Add init script to bypass age gate and set session BEFORE page scripts run
await page.addInitScript(
({ sessionKey, sessionValue, ageKey, ageValue }) => {
localStorage.setItem(sessionKey, sessionValue);
localStorage.setItem(ageKey, ageValue);
},
{
sessionKey: SESSION_STORAGE_KEY,
sessionValue: response.sessionId,
ageKey: AGE_VERIFIED_KEY,
ageValue: AGE_VERIFIED_VALUE,
}
);
// Navigate to BASE_URL to establish the correct origin for localStorage
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' });
return response;
};
await use(loginFn);
// Cleanup: logout if we logged in
if (currentSession) {
try {
await ssoApi.logout(currentSession);
} catch {
// Ignore logout errors in cleanup
}
}
},
// Logout helper
logout: async ({ page, ssoApi, getSessionToken }, use) => {
const logoutFn = async () => {
const token = await getSessionToken();
// Clear localStorage first
await page.evaluate((key) => {
localStorage.removeItem(key);
}, SESSION_STORAGE_KEY);
// Call logout API if we have a session
if (token) {
try {
await ssoApi.logout(token);
} catch {
// Ignore - session may already be invalid
}
}
};
await use(logoutFn);
},
// Get session token helper
getSessionToken: async ({ page }, use) => {
const getTokenFn = async (): Promise<string | null> => {
return page.evaluate((key) => localStorage.getItem(key), SESSION_STORAGE_KEY);
};
await use(getTokenFn);
},
// Bypass age gate using addInitScript (runs BEFORE page scripts)
bypassAgeGate: async ({ page }, use) => {
const bypassFn = async () => {
await page.addInitScript(
({ key, value }) => {
localStorage.setItem(key, value);
},
{ key: AGE_VERIFIED_KEY, value: AGE_VERIFIED_VALUE }
);
};
await use(bypassFn);
},
// Pre-authenticated page
authenticatedPage: async ({ page, loginAs }, use) => {
// Login as worker by default
await loginAs('worker');
await use(page);
},
});
export { expect };