0954876295
Implement B01-B06 with regression coverage and retained validation evidence. Preserve the production ingress preflight; F04 runtime verification remains open.
222 lines
15 KiB
JavaScript
222 lines
15 KiB
JavaScript
// Local-only observational audit. Product failures are recorded, not blessed as regression expectations.
|
|
// Run from the repository root after Release API and static UI builds (see verification.md).
|
|
// Browser requests are restricted to loopback; integrations exercised here use local synthetic fixtures.
|
|
import { createRequire } from 'node:module';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
|
|
import { createWriteStream } from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
|
|
const root = process.cwd();
|
|
const 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 evidence = path.join(root, '.artifacts', 'expanded-audit');
|
|
await mkdir(evidence, { recursive: true });
|
|
const dataRoot = await mkdtemp(path.join(os.tmpdir(), 'jobjakt-audit-'));
|
|
const apiOrigin = 'http://localhost:5402';
|
|
const uiOrigin = 'http://localhost:3400';
|
|
const password = 'SyntheticAudit123!';
|
|
const findings = [];
|
|
const consoleErrors = [];
|
|
const networkErrors = [];
|
|
const timings = [];
|
|
const blockedExternalHosts = new Set();
|
|
const children = [];
|
|
let browser;
|
|
const record = (scenario, meetsExpectation, observation) => findings.push({ scenario, meetsExpectation, observation });
|
|
|
|
async function waitReady(url) {
|
|
for (let i = 0; i < 120; i++) {
|
|
try { if ((await fetch(url, { signal: AbortSignal.timeout(1000) })).ok) return; } catch {}
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
}
|
|
throw new Error(`Local audit server did not start: ${url}`);
|
|
}
|
|
function start(command, args, cwd, env, logName) {
|
|
const child = spawn(command, args, { cwd, env: { ...process.env, ...env }, windowsHide: true });
|
|
const log = createWriteStream(path.join(evidence, logName));
|
|
child.stdout.pipe(log); child.stderr.pipe(log);
|
|
children.push(child);
|
|
return child;
|
|
}
|
|
async function stop(child) {
|
|
if (child.exitCode !== null) return;
|
|
if (process.platform === 'win32') {
|
|
await new Promise(resolve => {
|
|
const killer = spawn('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
killer.on('exit', resolve);
|
|
});
|
|
} else child.kill('SIGTERM');
|
|
}
|
|
async function client(email) {
|
|
const client = await request.newContext({ baseURL: apiOrigin });
|
|
const login = await client.post('/api/auth/login', { data: { email, password } });
|
|
if (!login.ok()) throw new Error(`Synthetic login failed: ${login.status()}`);
|
|
const cookie = (await client.storageState()).cookies.find(c => c.name === 'XSRF-TOKEN');
|
|
return { client, headers: { 'X-CSRF-TOKEN': cookie.value } };
|
|
}
|
|
async function body(response) {
|
|
if (!response.ok()) throw new Error(`Fixture request failed: ${response.status()} ${response.url()}`);
|
|
return response.json();
|
|
}
|
|
try {
|
|
// Override outbound integrations and storage even if the developer shell has provider settings.
|
|
start(process.env.DOTNET_HOST_PATH || path.join(os.homedir(), '.dotnet', 'dotnet.exe'),
|
|
[path.join(root, 'JobTrackerApi/bin/Release/net9.0/JobTrackerApi.dll'), '--urls', apiOrigin],
|
|
path.join(root, 'JobTrackerApi'), {
|
|
ASPNETCORE_ENVIRONMENT: 'Development', DOTNET_CLI_TELEMETRY_OPTOUT: '1',
|
|
Data__Root: dataRoot, Database__Provider: 'sqlite', ConnectionStrings__JobTracker: `Data Source=${path.join(dataRoot, 'jobtracker.db')}`,
|
|
AccountLifecycle__TombstonesRoot: path.join(dataRoot, 'tombstones'),
|
|
Auth__Require: 'true', Auth__JwtKey: 'audit-synthetic-signing-key-only-32-chars-minimum',
|
|
Auth__AdminEmail: 'audit-admin@example.test', Auth__AdminPassword: password,
|
|
Auth__TurnstileSecretKey: '', Auth__GoogleClientId: '', Auth__MicrosoftClientId: '',
|
|
Auth__AllowRegistration: 'false', Auth__MicrosoftTenant: 'common',
|
|
App__PublicBaseUrl: uiOrigin, Cors__Origins__0: uiOrigin, HttpsRedirection__Enabled: 'false',
|
|
Ai__BaseUrl: 'http://127.0.0.1:1', Email__Enabled: 'false', Stripe__SecretKey: '',
|
|
Workers__RulesEnabled: 'false', Workers__FollowUpRemindersEnabled: 'false',
|
|
Workers__DailyExportEnabled: 'false', Workers__JobEnrichmentEnabled: 'false',
|
|
Backups__Enabled: 'false', CV_PDF_BROWSER_PATH: chromium.executablePath(),
|
|
}, 'break-api.log');
|
|
await waitReady(`${apiOrigin}/health`);
|
|
const admin = await client('audit-admin@example.test');
|
|
for (const email of ['audit-a@example.test', 'audit-b@example.test']) {
|
|
await body(await admin.client.post('/api/users', { headers: admin.headers, data: { email, password, roles: [] } }));
|
|
}
|
|
const a = await client('audit-a@example.test');
|
|
const b = await client('audit-b@example.test');
|
|
await body(await a.client.put('/api/career/profile', { headers: a.headers, data: { profile: {
|
|
version: '1', contact: { fullName: 'Åse Audit 🧪', email: 'audit-a@example.test', headline: 'Software developer' },
|
|
summary: ['Synthetic profile for local audit only.'],
|
|
jobs: [{ company: 'Fjord Example', title: 'Developer', start: '2021', end: '2025', bullets: ['Built reliable APIs.'] }],
|
|
skills: ['C#', 'TypeScript', 'Norsk'],
|
|
} } }));
|
|
const cv = await body(await a.client.post('/api/cv/variants', { headers: a.headers, data: { name: 'Audit CV' } }));
|
|
const stale = await body(await a.client.get(`/api/cv/variants/${cv.id}`));
|
|
const first = await a.client.put(`/api/cv/variants/${cv.id}`, { headers: a.headers, data: { name: 'Newer edit', settings: cv.settings, version: cv.version } });
|
|
const second = await a.client.put(`/api/cv/variants/${cv.id}`, { headers: a.headers, data: { name: 'Stale tab edit', settings: stale.settings, version: stale.version } });
|
|
const final = await body(await a.client.get(`/api/cv/variants/${cv.id}`));
|
|
record('stale CV save', second.status() === 409 || second.status() === 412, { first: first.status(), staleSave: second.status(), overwritten: final.name === 'Stale tab edit', version: final.version });
|
|
const concurrent = await Promise.all(['Concurrent A', 'Concurrent B'].map(name => a.client.put(`/api/cv/variants/${cv.id}`, { headers: a.headers, data: { name, settings: cv.settings } })));
|
|
record('simultaneous CV saves detect conflict', concurrent.some(r => [409, 412].includes(r.status())), { statuses: concurrent.map(r => r.status()) });
|
|
for (const method of ['get', 'put', 'delete']) {
|
|
const response = await b.client[method](`/api/cv/variants/${cv.id}`, { headers: b.headers, ...(method === 'put' ? { data: { name: 'Cross-owner', settings: cv.settings } } : {}) });
|
|
record(`CV cross-owner ${method}`, [403, 404].includes(response.status()), { status: response.status() });
|
|
}
|
|
const company = await body(await a.client.post('/api/companies', { headers: a.headers, data: { name: 'Åse & Sons 🧪', location: 'Oslo' } }));
|
|
const input = { companyId: company.id, jobTitle: '=1+1', status: 'Saved', notes: '<img src=x onerror=alert(1)>' };
|
|
const created = await Promise.all([0, 1].map(() => a.client.post('/api/jobapplications', { headers: a.headers, data: input })));
|
|
const jobs = await Promise.all(created.map(body));
|
|
record('duplicate job creation deduplicated', jobs[0].id === jobs[1].id, { statuses: created.map(r => r.status()), distinctIds: jobs[0].id !== jobs[1].id });
|
|
for (const [stamp, expectedUtc] of [
|
|
['2026-03-28T09:00:00+01:00', '20260328T080000Z'],
|
|
['2026-03-29T09:00:00+02:00', '20260329T070000Z'],
|
|
['2026-10-25T02:30:00+02:00', '20261025T003000Z'],
|
|
['2026-10-25T02:30:00+01:00', '20261025T013000Z'],
|
|
]) {
|
|
const dated = await body(await a.client.post('/api/jobapplications', { headers: a.headers, data: { companyId: company.id, jobTitle: 'Synthetic DST probe', status: 'Saved', followUpAt: stamp } }));
|
|
const ics = await a.client.get(`/api/calendar/jobs/${dated.id}/events.ics?kind=follow-up`);
|
|
const content = await ics.text();
|
|
const actual = content.match(/^DTSTART:(.+)$/m)?.[1]?.trim();
|
|
record(`DST calendar instant ${stamp}`, actual === expectedUtc, { status: ics.status(), expectedUtc, actual });
|
|
}
|
|
const csv = await a.client.get('/api/export/jobs?format=csv');
|
|
const csvText = await csv.text();
|
|
record('CSV formula neutralized', !csvText.includes(',=1+1,'), { formulaCellPresent: csvText.includes(',=1+1,') });
|
|
for (const route of ['/api/auth/me', `/api/cv/variants/${cv.id}`, '/api/export/jobs?format=csv']) {
|
|
const response = await a.client.get(route);
|
|
record(`sensitive response cache ${route.split('?')[0]}`, /no-store/i.test(response.headers()['cache-control'] || ''), { cacheControl: response.headers()['cache-control'] || null });
|
|
}
|
|
const adminAccess = await b.client.get('/api/admin/audit');
|
|
record('standard user admin denial', adminAccess.status() === 403, { status: adminAccess.status() });
|
|
for (let i = 0; i < 2; i++) await a.client.post('/api/export/account', { headers: a.headers, data: {} });
|
|
const otherExport = await b.client.post('/api/export/account', { headers: b.headers, data: {} });
|
|
record('account export limiter isolation', otherExport.status() === 200, { otherAccountStatusAfterTwoExports: otherExport.status() });
|
|
|
|
start(process.execPath, [path.join(ui, 'scripts/serve-export.mjs')], ui,
|
|
{ PORT: '3400' }, 'break-ui.log');
|
|
await waitReady(uiOrigin);
|
|
browser = await chromium.launch({ headless: true });
|
|
const context = await browser.newContext({ viewport: { width: 1440, height: 900 } });
|
|
await context.route('**/*', route => {
|
|
const url = new URL(route.request().url());
|
|
if (['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'about:', 'blob:'].includes(url.protocol)) return route.continue();
|
|
blockedExternalHosts.add(url.hostname);
|
|
return route.abort();
|
|
});
|
|
await context.addInitScript(() => { if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en'); });
|
|
const page = await context.newPage();
|
|
page.on('pageerror', error => consoleErrors.push(error.message));
|
|
page.on('console', message => { if (message.type() === 'error' || message.type() === 'warning') consoleErrors.push(message.text().slice(0, 500)); });
|
|
page.on('response', response => { if (response.status() >= 400) networkErrors.push({ path: new URL(response.url()).pathname, status: response.status() }); });
|
|
await page.goto(`${uiOrigin}/login`);
|
|
await page.getByLabel('Email').fill('audit-a@example.test');
|
|
await page.getByLabel('Current password').fill(password);
|
|
await page.getByRole('button', { name: 'Sign in', exact: true }).click();
|
|
await page.waitForURL('**/dashboard');
|
|
for (const route of ['/jobs', '/career', `/career/builder/${cv.id}`]) {
|
|
const started = Date.now();
|
|
await page.goto(`${uiOrigin}${route}`);
|
|
await page.locator('main').waitFor();
|
|
timings.push({ route, navigationToMainMs: Date.now() - started });
|
|
}
|
|
await page.getByLabel('CV name', { exact: true }).waitFor();
|
|
for (const language of ['en', 'nb']) {
|
|
await a.client.put('/api/auth/preferences/language', { headers: a.headers, data: { language } });
|
|
for (const theme of ['light', 'dark']) {
|
|
await page.evaluate(({ language, theme }) => { localStorage.setItem('uiLanguage', language); localStorage.setItem('jobtracker.themeMode', theme); }, { language, theme });
|
|
await page.reload();
|
|
await page.locator('main').waitFor();
|
|
await page.waitForFunction(() => document.querySelector('[data-cv-page] iframe')?.getAttribute('srcdoc')?.includes('Synthetic profile'));
|
|
await page.waitForTimeout(400);
|
|
for (const width of [375, 1440]) {
|
|
await page.setViewportSize({ width, height: 900 });
|
|
await page.screenshot({ path: path.join(evidence, `builder-${language}-${theme}-${width}.png`), fullPage: true });
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
|
const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa', 'wcag21aa']).analyze();
|
|
record(`builder layout ${language}/${theme}/${width}`, overflow <= 1 && axe.violations.length === 0, { overflow,
|
|
actualLanguage: await page.locator('html').getAttribute('lang'), actualTheme: await page.locator('html').getAttribute('data-color-scheme'),
|
|
violations: axe.violations.map(v => ({ id: v.id, impact: v.impact, nodes: v.nodes.map(n => ({ target: n.target, failureSummary: n.failureSummary })) })) });
|
|
}
|
|
}
|
|
}
|
|
await a.client.put('/api/auth/preferences/language', { headers: a.headers, data: { language: 'en' } });
|
|
await page.evaluate(() => { localStorage.setItem('uiLanguage', 'en'); localStorage.setItem('jobtracker.themeMode', 'light'); });
|
|
await page.reload();
|
|
await page.getByLabel('CV name', { exact: true }).waitFor();
|
|
await context.setOffline(true);
|
|
await page.getByLabel('CV name', { exact: true }).fill('Offline synthetic draft');
|
|
await page.waitForTimeout(1600);
|
|
record('offline save reports failure', await page.getByText(/save failed|not saved|could not save/i).count() > 0, { matchingFailureMessages: await page.getByText(/save failed|not saved|could not save/i).count() });
|
|
await context.setOffline(false);
|
|
page.once('dialog', dialog => dialog.accept());
|
|
await page.reload();
|
|
const recoveredName = await page.getByLabel('CV name', { exact: true }).inputValue();
|
|
record('offline draft survives confirmed refresh', recoveredName === 'Offline synthetic draft', { draftRecovered: recoveredName === 'Offline synthetic draft' });
|
|
const secondTab = await context.newPage();
|
|
await secondTab.goto(`${uiOrigin}/career/builder/${cv.id}`);
|
|
await secondTab.getByLabel('CV name', { exact: true }).waitFor();
|
|
await secondTab.waitForTimeout(1000);
|
|
await page.evaluate(async () => {
|
|
await fetch('http://localhost:5402/api/auth/logout', { method: 'POST', credentials: 'include' });
|
|
localStorage.removeItem('authUserKey');
|
|
window.dispatchEvent(new Event('auth-changed'));
|
|
});
|
|
await secondTab.waitForTimeout(1000);
|
|
record('logout clears other tab immediately', !(await secondTab.getByLabel('CV name', { exact: true }).count()), { privateEditorStillMounted: (await secondTab.getByLabel('CV name', { exact: true }).count()) > 0 });
|
|
await context.close();
|
|
} catch (error) {
|
|
if (browser) for (const context of browser.contexts()) for (const page of context.pages()) {
|
|
await page.screenshot({ path: path.join(evidence, 'harness-failure.png'), fullPage: true }).catch(() => {});
|
|
}
|
|
findings.push({ scenario: 'harness', meetsExpectation: false, observation: String(error.stack || error) });
|
|
process.exitCode = 2;
|
|
} finally {
|
|
if (browser) await browser.close();
|
|
for (const child of children.reverse()) await stop(child);
|
|
await writeFile(path.join(evidence, 'break-results.json'), JSON.stringify({ generatedAt: new Date().toISOString(), scope: 'isolated SQLite and local Chromium; synthetic data; outbound browser requests blocked', findings, consoleErrors, networkErrors, blockedExternalHosts: [...blockedExternalHosts], timings }, null, 2));
|
|
console.log(JSON.stringify({ observations: findings.length, unmet: findings.filter(f => !f.meetsExpectation).length, evidence: '.artifacts/expanded-audit/break-results.json' }));
|
|
}
|