184 lines
5.8 KiB
TypeScript
184 lines
5.8 KiB
TypeScript
import type { Page, BrowserContext } from '@playwright/test';
|
|
import type { RouteAccess } from './websiteRouteInventory';
|
|
|
|
export type WebsiteAuthContext = 'public' | 'auth' | 'admin' | 'sponsor';
|
|
|
|
export type WebsiteSessionDriftMode = 'invalid-cookie' | 'expired' | 'missing-sponsor-id';
|
|
export type WebsiteFaultMode = 'null-array' | 'missing-field' | 'invalid-date';
|
|
|
|
type DemoLoginRole =
|
|
| 'driver'
|
|
| 'sponsor'
|
|
| 'league-owner'
|
|
| 'league-steward'
|
|
| 'league-admin'
|
|
| 'system-owner'
|
|
| 'super-admin';
|
|
|
|
const demoSessionCookieCache = new Map<DemoLoginRole, string>();
|
|
|
|
export function authContextForAccess(access: RouteAccess): WebsiteAuthContext {
|
|
if (access === 'public') return 'public';
|
|
if (access === 'auth') return 'auth';
|
|
if (access === 'admin') return 'admin';
|
|
return 'sponsor';
|
|
}
|
|
|
|
function getWebsiteBaseUrl(): string {
|
|
const configured = process.env.WEBSITE_BASE_URL ?? process.env.PLAYWRIGHT_BASE_URL;
|
|
if (configured && configured.trim()) {
|
|
return configured.trim().replace(/\/$/, '');
|
|
}
|
|
|
|
return 'http://localhost:3100';
|
|
}
|
|
|
|
function demoLoginRoleForAuthContext(auth: WebsiteAuthContext): DemoLoginRole | null {
|
|
switch (auth) {
|
|
case 'public':
|
|
return null;
|
|
case 'auth':
|
|
return 'driver';
|
|
case 'sponsor':
|
|
return 'sponsor';
|
|
case 'admin':
|
|
// Website "admin" pages need an elevated role; use the strongest demo role.
|
|
return 'super-admin';
|
|
default: {
|
|
const exhaustive: never = auth;
|
|
return exhaustive;
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractCookieValue(setCookieHeader: string, cookieName: string): string | null {
|
|
// set-cookie header value: "name=value; Path=/; HttpOnly; ..."
|
|
// Do not split on comma (Expires contains commas). Just regex out the first cookie value.
|
|
const match = setCookieHeader.match(new RegExp(`(?:^|\\s)${cookieName}=([^;]+)`));
|
|
return match?.[1] ?? null;
|
|
}
|
|
|
|
async function ensureDemoSessionCookie(role: DemoLoginRole): Promise<string> {
|
|
const cached = demoSessionCookieCache.get(role);
|
|
if (cached) return cached;
|
|
|
|
const baseUrl = getWebsiteBaseUrl();
|
|
const url = `${baseUrl}/api/auth/demo-login`;
|
|
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ role }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const body = await response.text().catch(() => '');
|
|
throw new Error(`Smoke demo-login failed for role=${role}. ${response.status} ${response.statusText}. ${body}`);
|
|
}
|
|
|
|
// In Node (playwright runner) `headers.get('set-cookie')` returns a single comma-separated string.
|
|
// Parse cookies by splitting on `, ` and taking the first `name=value` segment.
|
|
const rawSetCookie = response.headers.get('set-cookie') ?? '';
|
|
const cookieHeaderPairs = rawSetCookie
|
|
? rawSetCookie
|
|
.split(', ')
|
|
.map((chunk) => chunk.split(';')[0]?.trim())
|
|
.filter(Boolean)
|
|
: [];
|
|
|
|
const gpSessionPair = cookieHeaderPairs.find((pair) => pair.startsWith('gp_session='));
|
|
if (!gpSessionPair) {
|
|
throw new Error(
|
|
`Smoke demo-login did not return gp_session cookie for role=${role}. set-cookie header: ${rawSetCookie}`,
|
|
);
|
|
}
|
|
|
|
const gpSessionValue = extractCookieValue(gpSessionPair, 'gp_session');
|
|
if (!gpSessionValue) {
|
|
throw new Error(
|
|
`Smoke demo-login returned a gp_session cookie, but it could not be parsed for role=${role}. Pair: ${gpSessionPair}`,
|
|
);
|
|
}
|
|
|
|
demoSessionCookieCache.set(role, gpSessionValue);
|
|
return gpSessionValue;
|
|
}
|
|
|
|
export async function setWebsiteAuthContext(
|
|
context: BrowserContext,
|
|
auth: WebsiteAuthContext,
|
|
options: { sessionDrift?: WebsiteSessionDriftMode; faultMode?: WebsiteFaultMode } = {},
|
|
): Promise<void> {
|
|
const domain = 'localhost';
|
|
const base = { domain, path: '/' };
|
|
|
|
const driftCookie =
|
|
options.sessionDrift != null ? [{ ...base, name: 'gridpilot_session_drift', value: String(options.sessionDrift) }] : [];
|
|
|
|
const faultCookie =
|
|
options.faultMode != null ? [{ ...base, name: 'gridpilot_fault_mode', value: String(options.faultMode) }] : [];
|
|
|
|
await context.clearCookies();
|
|
|
|
if (auth === 'public') {
|
|
// Public access: no session cookie, only drift/fault cookies if specified
|
|
await context.addCookies([...driftCookie, ...faultCookie]);
|
|
return;
|
|
}
|
|
|
|
const demoRole = demoLoginRoleForAuthContext(auth);
|
|
if (!demoRole) {
|
|
throw new Error(`Expected a demo role for auth context ${auth}`);
|
|
}
|
|
|
|
const gpSessionValue = await ensureDemoSessionCookie(demoRole);
|
|
|
|
// Only set gp_session cookie (no demo mode or sponsor cookies)
|
|
// For Docker/local testing, ensure cookies work with localhost
|
|
const sessionCookie = [{
|
|
...base,
|
|
name: 'gp_session',
|
|
value: gpSessionValue,
|
|
httpOnly: true,
|
|
secure: false, // Localhost doesn't need HTTPS
|
|
sameSite: 'Lax' as const // Ensure compatibility
|
|
}];
|
|
|
|
await context.addCookies([...sessionCookie, ...driftCookie, ...faultCookie]);
|
|
}
|
|
|
|
export type ConsoleCapture = {
|
|
consoleErrors: string[];
|
|
pageErrors: string[];
|
|
};
|
|
|
|
export function attachConsoleErrorCapture(page: Page): ConsoleCapture {
|
|
const consoleErrors: string[] = [];
|
|
const pageErrors: string[] = [];
|
|
|
|
page.on('pageerror', (err) => {
|
|
pageErrors.push(String(err));
|
|
});
|
|
|
|
page.on('console', (msg) => {
|
|
const type = msg.type();
|
|
if (type !== 'error') return;
|
|
|
|
const text = msg.text();
|
|
|
|
// Filter known benign warnings (keep small + generic).
|
|
if (text.includes('Download the React DevTools')) return;
|
|
|
|
// Next/Image accessibility warning (not a runtime failure for smoke coverage).
|
|
if (text.includes('Image is missing required "alt" property')) return;
|
|
|
|
// React controlled <select> warning (still renders fine; treat as non-fatal for route coverage).
|
|
if (text.includes('Use the `defaultValue` or `value` props on <select> instead of setting `selected` on <option>.')) return;
|
|
|
|
consoleErrors.push(`[${type}] ${text}`);
|
|
});
|
|
|
|
return { consoleErrors, pageErrors };
|
|
} |