feat(admin): add safe processing monitor and operation correlation
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { expect, test, type APIRequestContext, type TestInfo } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(client: APIRequestContext, email: string) {
|
||||
expect((await client.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await client.get(`${api}/auth/me`)).json();
|
||||
return { id: me.id as string, email, headers: { 'X-CSRF-TOKEN': (await client.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value, 'X-Session-Context': me.sessionContext as string } };
|
||||
}
|
||||
function seed(info: TestInfo, owner: Awaited<ReturnType<typeof login>>) {
|
||||
const root = info.config.metadata.isolatedDataRoot;
|
||||
if (typeof root !== 'string' || process.env.PLAYWRIGHT_REUSE_SERVERS === 'true') throw new Error('Explicit isolated non-reused test data required.');
|
||||
const dataRoot = realpathSync(root);
|
||||
expect(path.dirname(dataRoot)).toBe(realpathSync(os.tmpdir()));
|
||||
const result = spawnSync(process.env.PLAYWRIGHT_PYTHON_PATH || (process.platform === 'win32' ? 'python' : 'python3'), ['-X', 'utf8', path.resolve('e2e/helpers/seed-operation.py')],
|
||||
{ input: JSON.stringify({ dataRoot, ownerId: owner.id, ownerEmail: owner.email }), encoding: 'utf8', timeout: 15000, maxBuffer: 1024 * 1024 });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
return (JSON.parse(result.stdout) as { operationId: string }).operationId;
|
||||
}
|
||||
|
||||
test('[D] Real administrator/owner boundaries, actual cancellation history and responsive EN/NB themes', async ({ page, playwright }, info) => {
|
||||
test.setTimeout(180000);
|
||||
const admin = await playwright.request.newContext();
|
||||
const ownerClient = await playwright.request.newContext();
|
||||
const otherClient = await playwright.request.newContext();
|
||||
const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const administrator = await login(admin, 'e2e@example.test');
|
||||
const email = `processing-owner-${Date.now()}@example.test`;
|
||||
const otherEmail = `processing-other-${Date.now()}@example.test`;
|
||||
for (const userEmail of [email, otherEmail]) expect((await admin.post(`${api}/users`, { headers: administrator.headers, data: { email: userEmail, password, roles: [] } })).status()).toBe(200);
|
||||
const owner = await login(ownerClient, email);
|
||||
const other = await login(otherClient, otherEmail);
|
||||
const id = seed(info, owner);
|
||||
for (const endpoint of ['/admin/processing', `/admin/processing/${id}`, `/admin/processing/${id}/events`]) {
|
||||
expect((await anonymous.get(`${api}${endpoint}`)).status()).toBe(401);
|
||||
expect((await ownerClient.get(`${api}${endpoint}`)).status()).toBe(403);
|
||||
}
|
||||
expect((await otherClient.get(`${api}/operations/${id}`)).status()).toBe(404);
|
||||
expect((await otherClient.post(`${api}/operations/${id}/cancel`, { headers: other.headers })).status()).toBe(404);
|
||||
const pageAdmin = await login(page.request, 'e2e@example.test');
|
||||
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
await page.goto('/admin/processing');
|
||||
await page.getByLabel('Owner ID', { exact: true }).fill(owner.id);
|
||||
await page.getByRole('button', { name: 'Apply filters', exact: true }).click();
|
||||
await page.getByRole('button', { name: `View processing details ${id}`, exact: true }).click();
|
||||
await expect(page.getByLabel('Correlation ID', { exact: true })).toHaveValue(id);
|
||||
await expect(page.getByText('No retained events. Older jobs may predate event recording.', { exact: true })).toBeVisible();
|
||||
const cancellation = await ownerClient.post(`${api}/operations/${id}/cancel`, { headers: owner.headers });
|
||||
expect(cancellation.status()).toBe(200);
|
||||
expect((await cancellation.json()).status).toBe('cancelled');
|
||||
await page.getByRole('button', { name: 'Refresh', exact: true }).click();
|
||||
await expect(page.getByRole('list').filter({ has: page.getByText(/^Processing cancelled/) })).toBeVisible();
|
||||
const history = await (await admin.get(`${api}/admin/processing/${id}/events`)).json();
|
||||
expect(history.items.map((item: { kind: string }) => item.kind)).toEqual(['Cancelled']);
|
||||
expect(history.items[0].atUtc).toMatch(/Z$/);
|
||||
const detail = await (await admin.get(`${api}/admin/processing/${id}`)).json();
|
||||
expect(detail.owner.id).toBe(owner.id);
|
||||
for (const field of ['leaseToken', 'resultReference', 'failureMessage', 'subjectId', 'idempotencyKey']) expect(detail).not.toHaveProperty(field);
|
||||
expect(detail.worker).toBe('untracked');
|
||||
for (const language of ['en', 'nb'] as const) for (const mode of ['light', 'dark'] as const) {
|
||||
const t = translations[language];
|
||||
expect((await page.request.put(`${api}/auth/preferences/language`, { headers: pageAdmin.headers, data: { language: language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.evaluate(({ language, mode }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', mode); }, { language, mode });
|
||||
await page.reload();
|
||||
await page.getByLabel(t.processingOwnerFilter, { exact: true }).fill(owner.id);
|
||||
await page.getByRole('button', { name: t.processingApplyFilters, exact: true }).click();
|
||||
await page.getByRole('button', { name: t.processingViewDetails.replace('{id}', id), exact: true }).click();
|
||||
await expect(page.getByLabel(t.processingCorrelation, { exact: true })).toHaveValue(id);
|
||||
for (const width of [1440, 768, 390]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
await page.screenshot({ path: info.outputPath(`processing-${language}-${mode}-${width}.png`), fullPage: true });
|
||||
}
|
||||
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
||||
await info.attach(`processing-${language}-${mode}-axe`, { body: JSON.stringify({ violations: axe.violations, incomplete: axe.incomplete }), contentType: 'application/json' });
|
||||
expect(axe.violations).toEqual([]);
|
||||
}
|
||||
await info.attach('fixture-evidence-limit', { body: 'Guarded insertion of one inert unregistered task; actual native cancel creates its only event. No historical Created event, model job, inference, human accessibility or physical-device acceptance is fabricated.', contentType: 'text/plain' });
|
||||
} finally { await Promise.all([admin.dispose(), ownerClient.dispose(), otherClient.dispose(), anonymous.dispose()]); }
|
||||
});
|
||||
|
||||
test('[D] Controlled unavailable/forbidden reads recover without exposing raw failures or cached metadata', async ({ page }) => {
|
||||
const administrator = await login(page.request, 'e2e@example.test');
|
||||
expect((await page.request.put(`${api}/auth/preferences/language`, { headers: administrator.headers, data: { language: 'en' } })).status()).toBe(204);
|
||||
await page.addInitScript(() => localStorage.setItem('uiLanguage', 'en'));
|
||||
await page.context().route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
let failure: number | null = 503;
|
||||
await page.route('**/api/admin/processing?**', route => failure ? route.fulfill({ status: failure, json: { message: 'SECRET_EXCEPTION_DOCUMENT_CONTENT' } }) : route.continue());
|
||||
await page.goto('/admin/processing');
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Processing information could not be loaded.' })).toBeVisible();
|
||||
await expect(page.getByText(/SECRET_EXCEPTION/)).toHaveCount(0);
|
||||
failure = null;
|
||||
await page.getByRole('button', { name: 'Retry', exact: true }).click();
|
||||
await expect(page.getByRole('region', { name: 'Processing jobs', exact: true })).toBeVisible();
|
||||
let calls = 0; page.on('request', request => { if (new URL(request.url()).pathname === '/api/admin/processing') calls++; });
|
||||
await page.getByRole('button', { name: 'Pause updates', exact: true }).click();
|
||||
const paused = calls;
|
||||
await page.waitForTimeout(5500); // Negative assertion across one real polling interval.
|
||||
expect(calls).toBe(paused);
|
||||
failure = 403;
|
||||
await page.getByRole('button', { name: 'Resume updates', exact: true }).click();
|
||||
await expect(page.getByRole('alert').filter({ hasText: 'Administrator access is required.' })).toBeVisible();
|
||||
await expect(page.getByRole('region', { name: 'Processing jobs', exact: true })).toHaveCount(0);
|
||||
await expect(page.getByText(/SECRET_EXCEPTION/)).toHaveCount(0);
|
||||
});
|
||||
Reference in New Issue
Block a user