Files
jobtrackingapp/job-tracker-ui/e2e/phase7-admin-users.spec.ts
T

133 lines
15 KiB
TypeScript

import AxeBuilder from '@axe-core/playwright';
import { expect, test as base, type APIRequestContext, type APIResponse, type Locator, type Page, 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 Headers = Record<string, string>;
type Owner = { id: string; email: string; headers: Headers };
type RequestRecord = { channel: 'browser' | 'api'; method: string; path: string; query: string; status?: number };
type Session = { owner: Owner; admin: APIRequestContext; adminHeaders: Headers; requests: RequestRecord[] };
const boundary = 'Actual isolated API/Identity/CSRF admin and ordinary accounts exercise Users create/read/role persistence, target-session revocation, final-admin conflict, non-admin denial and deletion-disabled 503 without deleting an account. Only the explicitly named first GET503 is controlled. Read Retry does not replay writes. No password-reset mail, provider/model/billing/restore/probe/maintenance, production or Phase8 action occurs. Google Fonts css2 stylesheets are aborted and all other nonlocal requests rejected. Desktop grid horizontal scrolling is intentional and its action reachability is keyboard checked. Chromium viewport/keyboard evidence does not establish physical touch, real zoom or human screen-reader acceptance.';
function copy(key: string, language: Language = 'en') { return (translations[language] as Record<string, string>)[key] ?? key; }
function browserRequests(session: Session, path: string, method?: string) { return session.requests.filter(value => value.channel === 'browser' && value.path === `/api${path}` && (!method || value.method === method)); }
async function call(records: RequestRecord[], api: APIRequestContext, method: 'GET' | 'POST' | 'PUT', path: string, headers?: Headers, data?: unknown) {
const result = await api.fetch(`${apiUrl}${path}`, { method, headers, data });
const url = new URL(result.url()); records.push({ channel: 'api', method, path: url.pathname, query: url.search, status: result.status() }); return result;
}
async function json<T>(response: Pick<APIResponse, 'status' | 'url' | 'json'>, status = 200): Promise<T> { expect(response.status(), response.url()).toBe(status); return response.json(); }
async function login(records: RequestRecord[], api: APIRequestContext, email: string): Promise<Owner> {
expect((await call(records, api, 'POST', '/auth/login', undefined, { email, password })).status()).toBe(200);
const me = await json<{ id: string; sessionContext: string }>(await call(records, api, 'GET', '/auth/me'));
const token = (await api.storageState()).cookies.find(value => value.name === 'XSRF-TOKEN'); expect(token).toBeDefined();
return { id: me.id, email, headers: { 'X-CSRF-TOKEN': token!.value, 'X-Session-Context': me.sessionContext } };
}
async function createOwner(records: RequestRecord[], admin: APIRequestContext, headers: Headers) {
const email = `p710-admin-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
expect((await call(records, admin, 'POST', '/users', headers, { email, password, roles: [] })).status()).toBe(200); return email;
}
const test = base.extend<{ session: Session }>({
session: async ({ page, context, playwright }, provide, info) => {
expect(process.env.PLAYWRIGHT_REUSE_SERVERS).not.toBe('true'); expect(info.config.metadata.isolatedDataRoot).toEqual(expect.any(String));
const records: RequestRecord[] = []; const forbidden: string[] = []; const fonts: string[] = []; const admin = await playwright.request.newContext();
context.on('request', request => { const url = new URL(request.url()); records.push({ channel: 'browser', method: request.method(), path: url.pathname, query: url.search }); });
context.on('response', result => { const url = new URL(result.url()); const record = [...records].reverse().find(value => value.channel === 'browser' && value.status === undefined && value.method === result.request().method() && value.path === url.pathname && value.query === url.search); if (record) record.status = result.status(); });
await context.route('**/*', route => {
const request = route.request(); const url = new URL(request.url());
if (url.protocol === 'https:' && url.hostname === 'fonts.googleapis.com' && url.port === '' && url.pathname === '/css2' && request.method() === 'GET' && request.resourceType() === 'stylesheet') { fonts.push(url.href); return route.abort(); }
const mailbox = /^\/api\/(gmail|microsoft-graph|imap)(?:\/|$)/.test(url.pathname);
const statusRead = request.method() === 'GET' && /^\/api\/(gmail|microsoft-graph|imap)\/status$/.test(url.pathname);
const ai = /^\/api\/ai(?:\/|$)/.test(url.pathname); const accountAiRead = request.method() === 'GET' && ['/api/ai/settings', '/api/ai/usage'].includes(url.pathname) || request.method() === 'PUT' && url.pathname === '/api/ai/settings';
if (mailbox && !statusRead || ai && !accountAiRead || /\/api\/(?:email\/send|billing\/(?:checkout|portal)|account-lifecycle\/delete|backup\/restore)(?:\/|$)/.test(url.pathname) || !['blob:', 'data:', 'about:'].includes(url.protocol) && (!['localhost', '127.0.0.1'].includes(url.hostname) || !['3300', '5302'].includes(url.port))) { forbidden.push(`${request.method()} ${url.origin}${url.pathname}`); return route.abort(); }
return route.continue();
});
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light'); });
try {
const administrator = await login(records, admin, 'e2e@example.test'); const email = await createOwner(records, admin, administrator.headers); const owner = await login(records, page.request, email);
expect((await json<{ entitlements: { ai: boolean } }>(await call(records, page.request, 'GET', '/auth/me'))).entitlements.ai).toBe(false);
await provide({ owner, admin, adminHeaders: administrator.headers, requests: records }); expect(forbidden).toEqual([]);
} finally { await info.attach('settings-preferences-boundary-and-requests', { body: JSON.stringify({ boundary, requests: records, forbiddenRequests: forbidden, abortedFontStylesheets: fonts }), contentType: 'application/json' }); await admin.dispose(); }
},
});
async function start(page: Page, path = '/settings', width = 1440) { await page.setViewportSize({ width, height: width < 768 ? 844 : 900 }); await page.emulateMedia({ reducedMotion: 'reduce' }); await page.goto(path); await expect(page.getByRole('main').getByRole('heading', { level: 1 })).toBeVisible(); }
async function keyboard(control: Locator) { await expect(control).toBeVisible(); await expect(control).toBeEnabled(); await control.focus(); await expect(control).toBeFocused(); await control.press('Enter'); }
async function capture(page: Page, info: TestInfo, name: string) {
const geometry = await page.getByRole('main').evaluate(main => {
const bounds = main.getBoundingClientRect(); const targets = Array.from(main.querySelectorAll('h1,h2,h3,p,button,input,[role="combobox"],[role="status"],.MuiFormControlLabel-root'));
// MUI Switch uses a transparent 300%-wide native input; its painted label/control remains in the checked targets.
// The named desktop grid intentionally scrolls in two dimensions; its clipped cells are checked by keyboard separately.
return { width: innerWidth, rootOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, outside: targets.filter(node => !node.closest('.MuiDataGrid-root') && node.getClientRects().length && getComputedStyle(node).visibility !== 'hidden' && !(node instanceof HTMLInputElement && getComputedStyle(node).opacity === '0')).map(node => ({ text: node.textContent?.slice(0, 90), left: node.getBoundingClientRect().left, right: node.getBoundingClientRect().right })).filter(rect => rect.left < bounds.left - 1 || rect.right > Math.min(bounds.right, innerWidth) + 1) };
});
await info.attach(`${name}-reflow`, { body: JSON.stringify(geometry), contentType: 'application/json' }); expect(geometry.rootOverflow).toBeLessThanOrEqual(1); expect(geometry.outside).toEqual([]);
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
const axe = await new AxeBuilder({ page }).analyze(); await info.attach(`${name}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete, qualification: 'All enabled axe rules; raw incomplete contexts retained for root triage, not human screen-reader acceptance.' }), contentType: 'application/json' }); expect(axe.violations).toEqual([]);
}
function response(page: Page, method: string, path: string) { return page.waitForResponse(value => value.request().method()===method && new URL(value.url()).pathname===`/api${path}`); }
test('[P7-10 Users A01 E09] real admin creation/role persistence revoke sessions while final-admin and disabled deletion remain protected', async ({ page, playwright, session }) => {
const administrator=await login(session.requests, page.request, 'e2e@example.test');
expect((await call(session.requests, page.request, 'PUT', `/users/${administrator.id}/roles`, administrator.headers, { roles: [] })).status()).toBe(409);
await start(page, '/admin/users', 390);
const email=`p710-created-${Date.now()}@example.test`;
await page.getByRole('textbox', { name: copy('profileEmail'), exact: true }).fill(email);
await page.getByLabel(copy('profileNewPassword'), { exact: true }).fill(password);
const createdResponse=response(page, 'POST', '/users');
await page.getByLabel(copy('profileNewPassword'), { exact: true }).press('Enter');
const created=await json<{ id: string; email: string; roles: string[] }>(await createdResponse);
expect(created.email).toBe(email); expect(created.roles).toEqual([]);
await expect(page.getByText(email, { exact: true }).first()).toBeVisible();
const other=await playwright.request.newContext();
try {
const before=await login(session.requests, other, email);
expect((await call(session.requests, other, 'GET', '/users')).status()).toBe(403);
expect((await call(session.requests, other, 'PUT', `/users/${administrator.id}/roles`, before.headers, { roles: [] })).status()).toBe(403);
const card=page.locator('.MuiPaper-root').filter({ has: page.getByText(email, { exact: true }).first() }).last();
const changed=response(page, 'PUT', `/users/${created.id}/roles`);
await keyboard(card.getByRole('button', { name: copy('adminUsersMakeAdmin'), exact: true })); expect((await changed).status()).toBe(204);
await expect.poll(async () => (await json<Array<{ id: string; roles: string[] }>>(await call(session.requests, page.request, 'GET', '/users'))).find(value => value.id===created.id)?.roles).toEqual(['Admin']);
expect((await call(session.requests, other, 'GET', '/auth/me')).status()).toBe(401);
expect((await page.request.delete(`${apiUrl}/users/${created.id}`, { headers: { ...administrator.headers, 'X-Confirm-Account-Deletion': email } })).status()).toBe(503);
expect((await json<Array<{ id: string }>>(await call(session.requests, page.request, 'GET', '/users'))).some(value => value.id===created.id)).toBe(true);
} finally { await other.dispose(); }
});
test('[P7-10 Users A01 E08] unavailable user list recovers by GET without fabricating empty state', async ({ page, session }, info) => {
await login(session.requests, page.request, 'e2e@example.test'); let failed=false;
await page.route(`${apiUrl}/users`, route => { if (route.request().method()==='GET' && !failed) { failed=true; return route.fulfill({ status: 503, json: { title: 'Controlled Users read failure' } }); } return route.continue(); });
await start(page, '/admin/users', 390);
await expect(page.getByRole('alert').filter({ hasText: copy('adminUsersLoadFailed') })).toBeVisible();
await expect(page.getByText(copy('adminUsersNoUsers'), { exact: true })).toHaveCount(0);
const before=session.requests.filter(value => value.channel==='browser' && value.method!=='GET').length;
await keyboard(page.getByRole('button', { name: copy('retry'), exact: true }));
await expect(page.getByRole('group', { name: copy('adminUsersTitle'), exact: true })).toBeFocused();
await expect(page.getByText('e2e@example.test', { exact: true }).first()).toBeVisible();
expect(session.requests.filter(value => value.channel==='browser' && value.method!=='GET')).toHaveLength(before);
await capture(page, info, 'users-actual-get-recovery-en-light-390');
});
test('[P7-10 Users A01] finite bilingual light/dark widths retain long identities and protected admin controls', async ({ page, session }, info) => {
test.setTimeout(180_000); const administrator=await login(session.requests, page.request, 'e2e@example.test');
const email=`long-administrative-identity-${Date.now()}@example.test`;
expect((await call(session.requests, page.request, 'POST', '/users', administrator.headers, { email, password, roles: [] })).status()).toBe(200);
const samples=[{ width: 320, language: 'nb', mode: 'dark' }, { width: 390, language: 'en', mode: 'light' }, { width: 768, language: 'nb', mode: 'light' }, { width: 1024, language: 'en', mode: 'dark' }, { width: 1440, language: 'en', mode: 'light' }, { width: 1920, language: 'nb', mode: 'dark' }] as const;
for (const sample of samples) {
expect((await call(session.requests, page.request, 'PUT', '/auth/preferences/language', administrator.headers, { language: sample.language==='nb' ? 'nb-NO' : 'en' })).status()).toBe(204);
await page.addInitScript(value => { localStorage.setItem('uiLanguage', value.language); localStorage.setItem('jobtracker.themeMode', value.mode); }, sample);
await start(page, '/admin/users', sample.width);
await expect(page.getByRole('heading', { level: 3, name: copy('adminUsersCreateUser', sample.language), exact: true })).toBeVisible();
await expect(page.getByText(email, { exact: true }).first()).toBeVisible();
if (sample.width>=768) {
const region=page.getByRole('region', { name: copy('adminUsersTitle', sample.language), exact: true }); await expect(region).toHaveAttribute('tabindex', '0');
const action=region.getByRole('button', { name: copy('adminUsersSendReset', sample.language), exact: true }).first(); await action.focus(); await expect(action).toBeFocused();
const box=await action.boundingBox(); expect(box).not.toBeNull(); expect(box!.x).toBeGreaterThanOrEqual(0); expect(box!.x+box!.width).toBeLessThanOrEqual(sample.width);
const clipped=await region.locator('.MuiDataGrid-row').evaluateAll(rows => rows.flatMap(row => {
const bounds=row.getBoundingClientRect(); return Array.from(row.querySelectorAll('button')).filter(button => { const rect=button.getBoundingClientRect(); return rect.top < bounds.top-1 || rect.bottom > bounds.bottom+1; }).map(button => button.textContent);
})); expect(clipped).toEqual([]);
}
await capture(page, info, `users-long-identity-${sample.width}-${sample.language}-${sample.mode}`);
}
expect(session.requests.filter(value => value.channel==='browser' && value.method!=='GET')).toEqual([]);
});