0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
182 lines
12 KiB
JavaScript
182 lines
12 KiB
JavaScript
// Independent audit validation. Synthetic local data only; no product behavior changes.
|
|
// Requires existing Release API build and static UI export targeting http://localhost:5402/api.
|
|
import { createRequire } from 'node:module';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
|
import { createWriteStream } from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
const root = process.cwd(), ui = path.join(root, 'job-tracker-ui');
|
|
const require = createRequire(path.join(ui, 'package.json'));
|
|
const { chromium, request } = require('@playwright/test');
|
|
const AxeBuilder = require('@axe-core/playwright').default;
|
|
const output = path.join(root, '.artifacts/audit-validation');
|
|
await mkdir(output, { recursive: true });
|
|
const data = await mkdtemp(path.join(os.tmpdir(), 'jobjakt-independent-'));
|
|
const api = 'http://localhost:5402', site = 'http://localhost:3400';
|
|
const password = 'ValidationSynthetic123!';
|
|
const observations = [], children = [];
|
|
let browser;
|
|
const record = (caseName, facts) => { observations.push({ caseName, ...facts }); console.log(JSON.stringify({ caseName, ...facts })); };
|
|
function start(exe, args, cwd, env, log) {
|
|
const child = spawn(exe, args, { cwd, env: { ...process.env, ...env }, windowsHide: true });
|
|
const sink = createWriteStream(path.join(output, log));
|
|
child.stdout.pipe(sink); child.stderr.pipe(sink);
|
|
children.push(child);
|
|
}
|
|
async function ready(url) {
|
|
for (let i = 0; i < 100; i++) {
|
|
try { if ((await fetch(url, { signal: AbortSignal.timeout(500) })).ok) return; } catch {}
|
|
await new Promise(r => setTimeout(r, 400));
|
|
}
|
|
throw Error('Owned test service unavailable: ' + url);
|
|
}
|
|
async function json(response) {
|
|
if (!response.ok()) throw Error('Fixture HTTP ' + response.status() + ': ' + response.url());
|
|
return response.json();
|
|
}
|
|
async function client(email) {
|
|
const c = await request.newContext({ baseURL: api });
|
|
await json(await c.post('/api/auth/login', { data: { email, password } }));
|
|
const csrf = (await c.storageState()).cookies.find(x => x.name === 'XSRF-TOKEN').value;
|
|
return { c, h: { 'X-CSRF-TOKEN': csrf } };
|
|
}
|
|
async function uiLogin(page, email) {
|
|
await page.goto(site + '/login');
|
|
await page.getByLabel('Email').fill(email);
|
|
await page.getByLabel('Current password').fill(password);
|
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
await page.waitForURL('**/dashboard');
|
|
}
|
|
try {
|
|
for (const origin of [api, site]) {
|
|
try {
|
|
await fetch(origin + '/health', { signal: AbortSignal.timeout(300) });
|
|
throw Error('Refusing to reuse occupied audit port ' + origin);
|
|
} catch (e) { if (String(e).includes('Refusing')) throw e; }
|
|
}
|
|
start(process.env.DOTNET_HOST_PATH || path.join(os.homedir(), '.dotnet/dotnet.exe'),
|
|
[path.join(root, 'JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll'), '--urls', api],
|
|
path.join(root, 'JobTrackerApi'), {
|
|
ASPNETCORE_ENVIRONMENT: 'Development', DOTNET_CLI_TELEMETRY_OPTOUT: '1',
|
|
Data__Root: data, Database__Provider: 'sqlite', ConnectionStrings__JobTracker: 'Data Source=' + path.join(data, 'jobtracker.db'),
|
|
AccountLifecycle__TombstonesRoot: path.join(data, 'tombstones'),
|
|
Auth__Require: 'true', Auth__JwtKey: 'independent-validation-synthetic-key-at-least32',
|
|
Auth__AdminEmail: 'validation-admin@example.test', Auth__AdminPassword: password,
|
|
Auth__TurnstileSecretKey: '', Auth__GoogleClientId: '', Auth__MicrosoftClientId: '',
|
|
Auth__AllowRegistration: 'false', App__PublicBaseUrl: site, Cors__Origins__0: site,
|
|
Ai__BaseUrl: 'http://127.0.0.1:1', Email__Enabled: 'false', Stripe__SecretKey: '',
|
|
Workers__AiOperationsEnabled: 'false', Workers__RulesEnabled: 'false',
|
|
Workers__FollowUpRemindersEnabled: 'false', Workers__DailyExportEnabled: 'false',
|
|
Workers__JobEnrichmentEnabled: 'false', Backups__Enabled: 'false',
|
|
CV_PDF_BROWSER_PATH: chromium.executablePath(),
|
|
}, 'api.log');
|
|
await ready(api + '/health');
|
|
const admin = await client('validation-admin@example.test');
|
|
for (const email of ['validation-a@example.test', 'validation-b@example.test']) {
|
|
await json(await admin.c.post('/api/users', { headers: admin.h, data: { email, password, roles: [] } }));
|
|
}
|
|
const a = await client('validation-a@example.test'), b = await client('validation-b@example.test');
|
|
for (const [c, name] of [[a, 'Private profile A'], [b, 'Private profile B']]) {
|
|
await json(await c.c.put('/api/career/profile', { headers: c.h, data: { profile: {
|
|
version: '1', contact: { fullName: name }, summary: [name + ' factual summary'], skills: ['C#'],
|
|
} } }));
|
|
}
|
|
const variant = await json(await a.c.post('/api/cv/variants', { headers: a.h, data: { name: 'Independent variant' } }));
|
|
const first = await json(await a.c.put('/api/cv/variants/' + variant.id, { headers: a.h, data: { name: 'First writer', settings: { ...variant.settings, titleOverride: 'New content' }, version: variant.version } }));
|
|
const secondResponse = await a.c.put('/api/cv/variants/' + variant.id, { headers: a.h, data: { name: 'Stale writer', settings: variant.settings, version: variant.version } });
|
|
const second = await json(secondResponse);
|
|
record('stale revision', { firstRevision: first.version, staleStatus: secondResponse.status(), resultingName: second.name, resultingRevision: second.version });
|
|
const wrongOwner = [];
|
|
for (const route of ['', '/preview', '/versions']) {
|
|
const response = await b.c.get('/api/cv/variants/' + variant.id + route);
|
|
wrongOwner.push({ route, status: response.status(), body: await response.text() });
|
|
}
|
|
wrongOwner.push({ route: '/export-pdf', status: (await b.c.post('/api/cv/variants/' + variant.id + '/export-pdf', { headers: b.h })).status() });
|
|
record('document owner boundaries', { results: wrongOwner });
|
|
const company = await json(await a.c.post('/api/companies', { headers: a.h, data: { name: 'Synthetic validation company' } }));
|
|
const ids = [];
|
|
for (let i = 0; i < 2; i++) ids.push((await json(await a.c.post('/api/jobapplications', {
|
|
headers: { ...a.h, 'Idempotency-Key': 'same-intent-synthetic' },
|
|
data: { companyId: company.id, jobTitle: '=1+1', status: 'Saved' },
|
|
}))).id);
|
|
record('repeated POST with same key', { ids, deduplicated: ids[0] === ids[1] });
|
|
for (const stamp of ['2026-03-29T09:00:00+02:00', '2026-03-29T07:00:00Z', '2026-03-29', '2026-10-25T02:30:00+02:00', '2026-10-25T02:30:00+01:00']) {
|
|
const job = await json(await a.c.post('/api/jobapplications', { headers: a.h, data: { companyId: company.id, jobTitle: 'Date control', status: 'Saved', followUpAt: stamp } }));
|
|
const reload = await json(await a.c.get('/api/jobapplications/' + job.id));
|
|
const ics = await (await a.c.get('/api/calendar/jobs/' + job.id + '/events.ics?kind=follow-up')).text();
|
|
record('date round trip', { input: stamp, createValue: job.followUpAt, reloadValue: reload.followUpAt, start: ics.match(/^DTSTART[^\r\n]*/m)?.[0] });
|
|
}
|
|
const csv = await (await a.c.get('/api/export/jobs?format=csv')).text();
|
|
record('CSV arithmetic payload', { containsUnneutralizedFormula: csv.includes(',=1+1,') });
|
|
const exports = [];
|
|
for (let i = 0; i < 2; i++) exports.push((await a.c.post('/api/export/account', { headers: a.h, data: {} })).status());
|
|
exports.push((await b.c.post('/api/export/account', { headers: b.h, data: {} })).status());
|
|
record('owner rate isolation', { accountA: exports.slice(0, 2), firstAccountB: exports[2] });
|
|
start(process.execPath, [path.join(ui, 'scripts/serve-export.mjs')], ui, { PORT: '3400' }, 'ui.log');
|
|
await ready(site);
|
|
browser = await chromium.launch({ headless: true });
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
|
await context.route('**/*', route => {
|
|
const url = new URL(route.request().url());
|
|
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'about:', 'blob:'].includes(url.protocol) ? route.continue() : route.abort();
|
|
});
|
|
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); });
|
|
const pageA = await context.newPage();
|
|
await uiLogin(pageA, 'validation-a@example.test');
|
|
await pageA.goto(site + '/career?section=profile');
|
|
const nameA = pageA.getByLabel('Full name', { exact: true });
|
|
await nameA.waitFor();
|
|
await nameA.fill('A edit must never enter B account');
|
|
const pageB = await context.newPage();
|
|
await pageB.goto(site + '/dashboard');
|
|
await pageB.getByRole('button', { name: 'User', exact: true }).click();
|
|
await pageB.getByRole('menuitem', { name: 'Sign out', exact: true }).click();
|
|
await pageB.waitForURL('**/login');
|
|
await uiLogin(pageB, 'validation-b@example.test');
|
|
const oldVisible = await nameA.count() ? await nameA.inputValue() : null;
|
|
if (oldVisible === 'A edit must never enter B account') {
|
|
const savedResponse = pageA.waitForResponse(r => r.url().endsWith('/api/career/profile') && r.request().method() === 'PUT');
|
|
await pageA.getByRole('button', { name: 'Save changes', exact: true }).click();
|
|
const saveStatus = (await savedResponse).status();
|
|
const actualB = await json(await b.c.get('/api/career/profile'));
|
|
const actualA = await json(await a.c.get('/api/career/profile'));
|
|
record('real UI cross-account stale form save', { oldVisible, saveStatus, profileB: actualB.profile.contact.fullName, profileA: actualA.profile.contact.fullName });
|
|
} else record('real UI cross-account stale form save', { oldVisible, protectedByClearing: true });
|
|
await pageB.screenshot({ path: path.join(output, 'account-b-after-switch.png'), fullPage: true });
|
|
// Reauthenticate A after the intentional UI logout revoked only its browser session.
|
|
const editor = await context.newPage();
|
|
await uiLogin(editor, 'validation-a@example.test');
|
|
await editor.goto(site + '/career/builder/' + variant.id);
|
|
await editor.getByLabel('CV name', { exact: true }).waitFor();
|
|
for (const theme of ['light', 'dark']) {
|
|
await editor.evaluate(theme => localStorage.setItem('jobtracker.themeMode', theme), theme);
|
|
await editor.reload();
|
|
await editor.getByLabel('CV name', { exact: true }).waitFor();
|
|
await editor.waitForFunction(() => [...document.querySelectorAll('[data-cv-page] iframe')].some(frame => frame.contentDocument?.body?.textContent?.includes('Private profile A')));
|
|
for (const [width, height] of [[1440, 1000], [1440, 900], [375, 900]]) {
|
|
await editor.setViewportSize({ width, height });
|
|
const axe = await new AxeBuilder({ page: editor }).withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']).analyze();
|
|
record('builder axe ' + theme, { width, height, violations: axe.violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.map(n => ({ target: n.target, summary: n.failureSummary })) })) });
|
|
for (const node of axe.violations.filter(v => v.id === 'scrollable-region-focusable').flatMap(v => v.nodes)) {
|
|
if (node.target.length === 1 && typeof node.target[0] === 'string') record('scroll region structure', await editor.locator(node.target[0]).evaluate(el => ({
|
|
tag: el.tagName, tabIndex: el.tabIndex, overflow: getComputedStyle(el).overflow,
|
|
maxHeight: getComputedStyle(el).maxHeight, clientHeight: el.clientHeight, scrollHeight: el.scrollHeight,
|
|
previewPages: el.querySelectorAll('[data-cv-page]').length, frames: el.querySelectorAll('iframe').length,
|
|
})));
|
|
}
|
|
}
|
|
}
|
|
await context.close();
|
|
} catch (e) {
|
|
record('probe failure', { error: String(e.stack || e) });
|
|
process.exitCode = 2;
|
|
} finally {
|
|
if (browser) await browser.close();
|
|
for (const child of children.reverse()) if (child.exitCode === null) {
|
|
if (process.platform === 'win32') await new Promise(resolve => spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on('exit', resolve));
|
|
else child.kill('SIGTERM');
|
|
}
|
|
await writeFile(path.join(output, 'observations.json'), JSON.stringify({ date: new Date().toISOString(), syntheticDataRoot: data, observations }, null, 2));
|
|
}
|