337 lines
20 KiB
TypeScript
337 lines
20 KiB
TypeScript
import AxeBuilder from '@axe-core/playwright';
|
|
import {
|
|
expect, test as base, type APIRequestContext, type BrowserContext,
|
|
type Page, type Request, type TestInfo,
|
|
} from '@playwright/test';
|
|
import { translations } from '../src/i18n/translations';
|
|
|
|
const apiUrl = 'http://localhost:5302/api';
|
|
const password = 'E2ePassword123!';
|
|
type Language = 'en' | 'nb';
|
|
type Theme = 'light' | 'dark';
|
|
type Sample = { language: Language; theme: Theme; width: number; height: number };
|
|
type Owner = { email: string; id: string; headers: Record<string, string> };
|
|
type Session = {
|
|
id: string; deviceLabel: string | null; lastSeenAtUtc: string;
|
|
expiresAtUtc: string; isCurrentSession: boolean;
|
|
};
|
|
|
|
async function login(api: APIRequestContext, email: string) {
|
|
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
|
const response = await api.get(`${apiUrl}/auth/me`);
|
|
expect(response.status()).toBe(200);
|
|
const me = await response.json();
|
|
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
|
|
expect(csrf).toBeDefined();
|
|
expect(me.sessionContext).toEqual(expect.any(String));
|
|
return { id: me.id as string, headers: {
|
|
'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext as string,
|
|
} };
|
|
}
|
|
|
|
async function localOnly(context: BrowserContext) {
|
|
await context.route('**/*', route => {
|
|
const url = new URL(route.request().url());
|
|
return ['localhost', '127.0.0.1'].includes(url.hostname)
|
|
|| ['data:', 'blob:', 'about:'].includes(url.protocol)
|
|
? route.continue() : route.abort();
|
|
});
|
|
}
|
|
|
|
// Normal public login and administrator user creation in the runner's disposable local API.
|
|
// No alternative authentication endpoint, role elevation or provider request is introduced.
|
|
const test = base.extend<{ owner: Owner }>({
|
|
owner: async ({ page, context, playwright }, provide) => {
|
|
const admin = await playwright.request.newContext();
|
|
try {
|
|
const adminIdentity = await login(admin, 'e2e@example.test');
|
|
const email = `p7-account-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
|
expect((await admin.post(`${apiUrl}/users`, {
|
|
headers: adminIdentity.headers, data: { email, password, roles: [] },
|
|
})).status()).toBe(200);
|
|
const identity = await login(page.request, email);
|
|
await localOnly(context);
|
|
await context.addInitScript(() => {
|
|
if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en');
|
|
if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light');
|
|
});
|
|
await provide({ ...identity, email });
|
|
} finally {
|
|
await admin.dispose();
|
|
}
|
|
},
|
|
});
|
|
|
|
async function read<T>(api: APIRequestContext, path: string): Promise<T> {
|
|
const response = await api.get(`${apiUrl}${path}`);
|
|
expect(response.status()).toBe(200);
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
async function enterProfile(page: Page, owner: Owner, sample: Sample) {
|
|
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, {
|
|
headers: owner.headers, data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' },
|
|
})).status()).toBe(204);
|
|
await page.setViewportSize({ width: sample.width, height: sample.height });
|
|
await page.goto('/dashboard');
|
|
await expect(page.getByRole('main')).toBeVisible();
|
|
await page.evaluate(({ language, theme }) => {
|
|
localStorage.setItem('uiLanguage', language);
|
|
localStorage.setItem('jobtracker.themeMode', theme);
|
|
}, sample);
|
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
|
await page.goto('/profile');
|
|
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
|
|
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
|
await expect(page.getByLabel(translations[sample.language].profileDisplayName, { exact: true })).toBeEnabled();
|
|
}
|
|
|
|
function observeWrites(page: Page) {
|
|
const writes: Array<{ method: string; path: string }> = [];
|
|
const listener = (request: Request) => {
|
|
const url = new URL(request.url());
|
|
if (url.pathname.startsWith('/api/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(request.method())) {
|
|
writes.push({ method: request.method(), path: url.pathname });
|
|
}
|
|
};
|
|
page.on('request', listener);
|
|
return { writes, stop: () => page.off('request', listener) };
|
|
}
|
|
|
|
async function collect(page: Page, info: TestInfo, sample: Sample, state: string) {
|
|
await page.evaluate(async () => {
|
|
await document.fonts.ready;
|
|
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())));
|
|
});
|
|
const geometry = await page.evaluate(() => ({
|
|
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth),
|
|
width: innerWidth, height: innerHeight,
|
|
focus: document.activeElement?.getAttribute('aria-label') || document.activeElement?.tagName,
|
|
regions: Array.from(document.querySelectorAll('main section[aria-labelledby]')).map(element => {
|
|
const bounds = element.getBoundingClientRect();
|
|
const title = document.getElementById(element.getAttribute('aria-labelledby') || '');
|
|
return { name: title?.textContent, left: bounds.left, right: bounds.right, width: bounds.width };
|
|
}),
|
|
}));
|
|
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
|
const name = `p7-account-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
|
await info.attach(`${name}-audit`, { body: JSON.stringify({
|
|
state, sample, url: page.url(), geometry,
|
|
ariaSnapshot: await page.locator('body').ariaSnapshot(),
|
|
axe: { scope: 'All enabled rules, including explicit target-size; violations/incomplete require contextual triage',
|
|
violations: axe.violations, incomplete: axe.incomplete,
|
|
passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
|
limits: ['No human screen reader', 'No physical touch/software keyboard', 'No real provider or production qualification'],
|
|
}), contentType: 'application/json' });
|
|
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
|
expect(geometry.rootOverflow, 'ordinary account chrome reflows without document-root horizontal scrolling').toBe(0);
|
|
}
|
|
|
|
const matrix: Sample[] = [
|
|
{ language: 'nb', theme: 'dark', width: 320, height: 844 },
|
|
{ language: 'en', theme: 'light', width: 390, height: 844 },
|
|
{ language: 'nb', theme: 'light', width: 768, height: 1024 },
|
|
{ language: 'en', theme: 'dark', width: 1440, height: 900 },
|
|
];
|
|
|
|
for (const sample of matrix) {
|
|
test(`[P7-01 PRES-E06 E08 E09 C01 C07] ${sample.language}/${sample.theme} ${sample.width}px account identity save reload preserves canonical and sibling CVs`, async ({ page, owner }, info) => {
|
|
test.setTimeout(120_000);
|
|
const t = translations[sample.language];
|
|
const canonical = {
|
|
version: '1', contact: { fullName: 'Åse Canonical', headline: 'Canonical career headline', location: 'Oslo' },
|
|
jobs: [{ title: 'Recorded engineer', company: 'Source-only employer', bullets: ['Recorded work, never an account field'] }],
|
|
skills: ['C#', 'Norsk'],
|
|
};
|
|
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: canonical } })).status()).toBe(200);
|
|
const cvIds: number[] = [];
|
|
for (const name of ['First protected CV', 'Sibling protected CV']) {
|
|
const created = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers,
|
|
data: { name, settings: { themeId: 'minimal', language: 'en', headline: `${name} override` } } });
|
|
expect(created.status()).toBe(200);
|
|
cvIds.push((await created.json()).id);
|
|
}
|
|
const beforeProfile = await read<{ profile: unknown }>(page.request, '/career/profile');
|
|
const beforeVariants = await Promise.all(cvIds.map(id => read(page.request, `/cv/variants/${id}`)));
|
|
const beforeMe = await read<{ userName: string }>(page.request, '/auth/me');
|
|
await enterProfile(page, owner, sample);
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1);
|
|
await expect(page.getByText(t.profileCvTextMissing, { exact: true })).toBeVisible();
|
|
await expect(page.getByText(t.profileCvMissing, { exact: true })).toHaveCount(0);
|
|
await expect(page.getByText(/NEXT_PUBLIC_/)).toHaveCount(0);
|
|
for (const title of [t.authStatusTitle, t.twoFactorSectionTitle, t.sessionsSectionTitle]) {
|
|
await expect(page.getByRole('region', { name: title, exact: true })).toBeVisible();
|
|
}
|
|
const status = page.getByRole('region', { name: t.twoFactorSectionTitle, exact: true });
|
|
await expect(status.getByText(t.twoFactorStatusDisabled, { exact: true })).toBeVisible();
|
|
const changed = {
|
|
userName: beforeMe.userName, firstName: 'Åse', lastName: 'Account identity',
|
|
displayName: 'Åse — documented account identity with a deliberately long personal display name',
|
|
};
|
|
await page.getByLabel(t.profileDisplayName, { exact: true }).fill(changed.displayName);
|
|
await page.getByLabel(t.profileFirstName, { exact: true }).fill(changed.firstName);
|
|
await page.getByLabel(t.profileLastName, { exact: true }).fill(changed.lastName);
|
|
const writes = observeWrites(page);
|
|
const saved = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/profile' && response.request().method() === 'PUT');
|
|
const save = page.getByRole('button', { name: t.profileSaveChanges, exact: true });
|
|
await save.focus();
|
|
await save.press('Enter');
|
|
const response = await saved;
|
|
expect(response.status()).toBe(204);
|
|
expect(response.request().postDataJSON()).toEqual(changed);
|
|
await expect(page.getByLabel(t.profileDisplayName, { exact: true })).toHaveValue(changed.displayName);
|
|
await expect(save).toBeEnabled();
|
|
expect(await read(page.request, '/auth/me')).toMatchObject(changed);
|
|
await page.reload();
|
|
await expect(page.getByLabel(t.profileDisplayName, { exact: true })).toHaveValue(changed.displayName);
|
|
await expect(page.getByLabel(t.profileFirstName, { exact: true })).toHaveValue(changed.firstName);
|
|
await expect(page.getByLabel(t.profileLastName, { exact: true })).toHaveValue(changed.lastName);
|
|
expect((await read<{ profile: unknown }>(page.request, '/career/profile')).profile).toEqual(beforeProfile.profile);
|
|
expect(await Promise.all(cvIds.map(id => read(page.request, `/cv/variants/${id}`)))).toEqual(beforeVariants);
|
|
expect(writes.writes.filter(write => write.path.startsWith('/api/career/') || write.path.startsWith('/api/cv/'))).toEqual([]);
|
|
expect(writes.writes.filter(write => write.path === '/api/auth/profile')).toEqual([{ method: 'PUT', path: '/api/auth/profile' }]);
|
|
writes.stop();
|
|
await info.attach('identity-only-readback', { body: JSON.stringify({
|
|
submittedFieldNames: Object.keys(changed), identityReadback: true, reloadReadback: true,
|
|
canonicalUnchanged: true, bothCvVariantsUnchanged: true, browserCanonicalOrCvWrites: 0,
|
|
}), contentType: 'application/json' });
|
|
await collect(page, info, sample, 'identity-persisted');
|
|
});
|
|
}
|
|
|
|
test('[P7-01 PRES-E04 E08] failed 2FA status is read-only and deliberate retry restores the actual disabled state', async ({ page, owner }, info) => {
|
|
test.setTimeout(90_000);
|
|
const sample: Sample = { language: 'nb', theme: 'dark', width: 320, height: 844 };
|
|
const t = translations[sample.language];
|
|
let failedReads = 0;
|
|
const fail = async (route: import('@playwright/test').Route) => {
|
|
expect(route.request().method()).toBe('GET');
|
|
failedReads += 1;
|
|
return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
|
};
|
|
await page.route('**/api/auth/2fa/status', fail);
|
|
const writes = observeWrites(page);
|
|
await enterProfile(page, owner, sample);
|
|
const region = page.getByRole('region', { name: t.twoFactorSectionTitle, exact: true });
|
|
const alert = region.getByRole('alert');
|
|
await expect(alert).toBeVisible();
|
|
expect(failedReads).toBeGreaterThanOrEqual(1);
|
|
await expect(region.getByText(t.twoFactorStatusDisabled, { exact: true })).toHaveCount(0);
|
|
// Hidden or disabled setup controls are both honest unknown-state presentations.
|
|
expect(await region.getByRole('button', { name: t.twoFactorEnableButton, exact: true }).evaluateAll(buttons =>
|
|
buttons.every(button => button.hasAttribute('disabled') || button.getAttribute('aria-disabled') === 'true'))).toBe(true);
|
|
expect(writes.writes.filter(write => write.path.startsWith('/api/auth/2fa/'))).toEqual([]);
|
|
await collect(page, info, sample, 'status-failed-read-only');
|
|
await page.unroute('**/api/auth/2fa/status', fail);
|
|
const loaded = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/2fa/status' && response.request().method() === 'GET');
|
|
await alert.getByRole('button', { name: t.retry, exact: true }).press('Enter');
|
|
expect((await loaded).status()).toBe(200);
|
|
await expect(region.getByText(t.twoFactorStatusDisabled, { exact: true })).toBeVisible();
|
|
await expect(region.getByRole('button', { name: t.twoFactorEnableButton, exact: true })).toBeEnabled();
|
|
expect(await read(page.request, '/auth/2fa/status')).toMatchObject({ enabled: false });
|
|
expect(writes.writes.filter(write => write.path.startsWith('/api/auth/2fa/'))).toEqual([]);
|
|
writes.stop();
|
|
await collect(page, info, sample, 'status-read-recovered');
|
|
});
|
|
|
|
test('[P7-01 PRES-E05 E08 E09] native sessions cancel no-write, failed confirmed revoke stays in the dialog, retry revokes others and current logout disposes account', async ({ page, owner, playwright }, info) => {
|
|
test.setTimeout(120_000);
|
|
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
|
|
const t = translations[sample.language];
|
|
const other = await playwright.request.newContext({ userAgent:
|
|
'P7 synthetic remote browser - a deliberately long workstation device description for responsive account metadata and named actions' });
|
|
try {
|
|
await login(other, owner.email);
|
|
const before = await read<Session[]>(page.request, '/auth/sessions');
|
|
expect(before).toHaveLength(2);
|
|
const current = before.find(session => session.isCurrentSession)!;
|
|
const remote = before.find(session => !session.isCurrentSession)!;
|
|
expect(current).toBeDefined();
|
|
expect(remote.deviceLabel).toContain('deliberately long workstation');
|
|
await enterProfile(page, owner, sample);
|
|
const region = page.getByRole('region', { name: t.sessionsSectionTitle, exact: true });
|
|
await expect(region.getByText(remote.deviceLabel!, { exact: true })).toBeVisible();
|
|
const trigger = region.getByRole('button', { name: t.sessionsRevokeOthers, exact: true });
|
|
await expect(trigger).toBeVisible();
|
|
const writes = observeWrites(page);
|
|
await trigger.focus();
|
|
await trigger.press('Enter');
|
|
const dialog = page.getByRole('dialog', { name: t.sessionsRevokeOthersConfirmTitle, exact: true });
|
|
await expect(dialog).toBeVisible();
|
|
// MUI initially focuses its trap container; the next Tab must enter the named dialog.
|
|
await expect.poll(() => dialog.evaluate(element =>
|
|
element.closest('.MuiDialog-container')?.contains(document.activeElement))).toBe(true);
|
|
await info.attach('initial-dialog-focus', { body: JSON.stringify(await dialog.evaluate(element => ({
|
|
focusedTag: document.activeElement?.tagName,
|
|
focusedRole: document.activeElement?.getAttribute('role'),
|
|
inTrapContainer: element.closest('.MuiDialog-container')?.contains(document.activeElement),
|
|
inNamedSurface: element.contains(document.activeElement),
|
|
}))), contentType: 'application/json' });
|
|
await page.keyboard.press('Tab');
|
|
await expect.poll(() => dialog.evaluate(element => element.contains(document.activeElement))).toBe(true);
|
|
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
|
|
await page.keyboard.press('Tab');
|
|
await expect(dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true })).toBeFocused();
|
|
await page.keyboard.press('Tab');
|
|
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
|
|
await page.keyboard.press('Shift+Tab');
|
|
await expect(dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true })).toBeFocused();
|
|
await dialog.getByRole('button', { name: t.cancel, exact: true }).press('Enter');
|
|
await expect(dialog).toBeHidden();
|
|
await expect(trigger).toBeFocused();
|
|
expect(writes.writes.filter(write => write.path.startsWith('/api/auth/sessions'))).toEqual([]);
|
|
expect((await read<Session[]>(page.request, '/auth/sessions')).map(session => session.id).sort()).toEqual(before.map(session => session.id).sort());
|
|
expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(200);
|
|
let failedAttempts = 0;
|
|
const fail = async (route: import('@playwright/test').Route) => {
|
|
expect(route.request().method()).toBe('POST');
|
|
failedAttempts += 1;
|
|
return route.fulfill({ status: 500, contentType: 'application/json', body: '{}' });
|
|
};
|
|
await page.route('**/api/auth/sessions/revoke-others', fail);
|
|
await trigger.press('Enter');
|
|
const confirm = dialog.getByRole('button', { name: t.sessionsRevokeOthers, exact: true });
|
|
await confirm.press('Enter');
|
|
await expect(dialog.getByRole('alert')).toBeVisible();
|
|
expect(await dialog.getByRole('alert').evaluate(element => Boolean(element.closest('[aria-hidden="true"]')))).toBe(false);
|
|
await expect(dialog).toBeVisible();
|
|
expect(failedAttempts).toBe(1);
|
|
await page.keyboard.press('Tab');
|
|
await expect(dialog.getByRole('button', { name: t.cancel, exact: true })).toBeFocused();
|
|
await page.keyboard.press('Shift+Tab');
|
|
await expect(confirm).toBeFocused();
|
|
expect((await read<Session[]>(page.request, '/auth/sessions')).map(session => session.id).sort()).toEqual(before.map(session => session.id).sort());
|
|
expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(200);
|
|
await collect(page, info, sample, 'revoke-others-failed-modal');
|
|
await page.unroute('**/api/auth/sessions/revoke-others', fail);
|
|
const revoked = page.waitForResponse(response => new URL(response.url()).pathname === '/api/auth/sessions/revoke-others' && response.request().method() === 'POST');
|
|
await confirm.press('Enter');
|
|
expect((await revoked).status()).toBe(204);
|
|
await expect(dialog).toBeHidden();
|
|
await expect(trigger).toHaveCount(0);
|
|
expect(await read<Session[]>(page.request, '/auth/sessions')).toEqual([expect.objectContaining({ id: current.id, isCurrentSession: true })]);
|
|
expect((await other.get(`${apiUrl}/auth/me`)).status()).toBe(401);
|
|
await collect(page, info, sample, 'only-current-session');
|
|
const currentRevoke = region.getByRole('button', { name: t.sessionsRevoke, exact: true });
|
|
await expect(currentRevoke).toHaveCount(1);
|
|
const disposed = page.waitForResponse(response => new URL(response.url()).pathname === `/api/auth/sessions/${current.id}` && response.request().method() === 'DELETE');
|
|
await currentRevoke.press('Enter');
|
|
expect((await disposed).status()).toBe(204);
|
|
await expect(page).toHaveURL(/\/login\?sessionChanged=1$/);
|
|
await expect(page.getByRole('region', { name: t.sessionsSectionTitle, exact: true })).toHaveCount(0);
|
|
await expect(page.getByText(remote.deviceLabel!, { exact: true })).toHaveCount(0);
|
|
expect((await page.request.get(`${apiUrl}/auth/me`)).status()).toBe(401);
|
|
expect(writes.writes.filter(write => write.path === '/api/auth/sessions/revoke-others')).toHaveLength(2);
|
|
expect(writes.writes.filter(write => write.method === 'DELETE' && write.path.startsWith('/api/auth/sessions/')))
|
|
.toEqual([{ method: 'DELETE', path: `/api/auth/sessions/${current.id}` }]);
|
|
writes.stop();
|
|
await info.attach('native-session-confirmation-readback', { body: JSON.stringify({
|
|
initialSessions: 2, canceledRevokeWrites: 0, failedAttempt: 1, explicitRetry: 1,
|
|
remoteSessionRejectedAfterRetry: true, currentSessionKeptUntilExplicitRevoke: true,
|
|
currentSessionRevokeDisposedPrivateUi: true, noExternalProviderCalls: true,
|
|
}), contentType: 'application/json' });
|
|
} finally {
|
|
await other.dispose();
|
|
}
|
|
});
|