139 lines
4.0 KiB
TypeScript
139 lines
4.0 KiB
TypeScript
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<void>;
|
|
};
|
|
|
|
const tenantAdminEmail = process.env.E2E_TENANT_EMAIL ?? 'hello@lumen-moments.demo';
|
|
const tenantAdminPassword = process.env.E2E_TENANT_PASSWORD ?? 'Demo1234!';
|
|
|
|
export const test = base.extend<TenantAdminFixtures>({
|
|
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<StoredTokenPayload> {
|
|
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');
|
|
}
|