241 lines
6.5 KiB
TypeScript
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 };
|