import 'dotenv/config'; import { test as base, expect, Page, APIRequestContext } from '@playwright/test'; import { randomBytes, createHash } from 'node:crypto'; export type TenantCredentials = { email: string; password: string; }; export type TenantAdminFixtures = { tenantAdminCredentials: TenantCredentials | null; signInTenantAdmin: () => Promise; }; const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.demo'; const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!'; export const test = base.extend({ tenantAdminCredentials: async ({}, use) => { if (!tenantAdminEmail || !tenantAdminPassword) { await use(null); return; } await use({ email: tenantAdminEmail, password: tenantAdminPassword, }); }, signInTenantAdmin: async ({ page, tenantAdminCredentials }, use) => { if (!tenantAdminCredentials) { await use(async () => { throw new Error('Tenant admin credentials missing. Provide E2E_TENANT_EMAIL and E2E_TENANT_PASSWORD.'); }); return; } await use(async () => { await performTenantSignIn(page, tenantAdminCredentials); }); }, }); export const expectFixture = expect; const clientId = process.env.VITE_OAUTH_CLIENT_ID ?? 'tenant-admin-app'; const redirectUri = new URL('/event-admin/auth/callback', process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:8000').toString(); const scopes = (process.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write'; async function performTenantSignIn(page: Page, _credentials: TenantCredentials) { const tokens = await exchangeTokens(page.request); await page.addInitScript(({ stored }) => { localStorage.setItem('tenant_oauth_tokens.v1', JSON.stringify(stored)); }, { stored: tokens }); await page.goto('/event-admin'); await page.waitForLoadState('domcontentloaded'); } type StoredTokenPayload = { accessToken: string; refreshToken: string; expiresAt: number; scope?: string; clientId?: string; }; async function exchangeTokens(request: APIRequestContext): Promise { const verifier = generateCodeVerifier(); const challenge = generateCodeChallenge(verifier); const state = randomBytes(12).toString('hex'); const params = new URLSearchParams({ response_type: 'code', client_id: clientId, redirect_uri: redirectUri, scope: scopes, state, code_challenge: challenge, code_challenge_method: 'S256', }); const authResponse = await request.get(`/api/v1/oauth/authorize?${params.toString()}`, { maxRedirects: 0, headers: { 'x-playwright-test': 'tenant-admin', }, }); if (authResponse.status() >= 400) { throw new Error(`OAuth authorize failed: ${authResponse.status()} ${await authResponse.text()}`); } const location = authResponse.headers()['location']; if (!location) { throw new Error('OAuth authorize did not return redirect location'); } const code = new URL(location).searchParams.get('code'); if (!code) { throw new Error('OAuth authorize response missing code'); } const tokenResponse = await request.post('/api/v1/oauth/token', { form: { grant_type: 'authorization_code', code, client_id: clientId, redirect_uri: redirectUri, code_verifier: verifier, }, }); if (!tokenResponse.ok()) { throw new Error(`OAuth token exchange failed: ${tokenResponse.status()} ${await tokenResponse.text()}`); } const body = await tokenResponse.json(); const expiresIn = typeof body.expires_in === 'number' ? body.expires_in : 3600; return { accessToken: body.access_token, refreshToken: body.refresh_token, expiresAt: Date.now() + Math.max(expiresIn - 30, 0) * 1000, scope: body.scope, clientId, }; } function generateCodeVerifier(): string { return randomBytes(32).toString('base64url'); } function generateCodeChallenge(verifier: string): string { return createHash('sha256').update(verifier).digest('base64url'); }