169 lines
14 KiB
JavaScript
169 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Deterministic, dependency-free frontend final acceptance harness.
|
|
*
|
|
* Source checks run by default. HTTP checks are opt-in:
|
|
* node scripts/final-acceptance.mjs --base-url http://127.0.0.1:8080
|
|
* node scripts/final-acceptance.mjs --start-server [--port 0]
|
|
*
|
|
* stdout is one machine-readable JSON document. Exit codes:
|
|
* 0 = all requested checks passed
|
|
* 1 = one or more acceptance checks failed
|
|
* 2 = invalid CLI/configuration or unreadable source
|
|
*/
|
|
import { createHash } from 'node:crypto';
|
|
import { readFile, stat } from 'node:fs/promises';
|
|
import { spawn } from 'node:child_process';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const args = process.argv.slice(2);
|
|
const valueFor = flag => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
|
|
const startServer = args.includes('--start-server');
|
|
const baseArg = valueFor('--base-url') || valueFor('--url');
|
|
const portArg = valueFor('--port') || '8080';
|
|
if (args.includes('--help') || args.includes('-h')) {
|
|
console.log(JSON.stringify({ usage: 'node scripts/final-acceptance.mjs [--base-url URL | --start-server] [--port N]', exit_codes: { pass: 0, failed_checks: 1, usage_or_io_error: 2 } }, null, 2));
|
|
process.exit(0);
|
|
}
|
|
if (baseArg && startServer) {
|
|
console.log(JSON.stringify({ schema: 1, harness: 'prospectos-frontend-final-acceptance', pass: false, error: '--base-url and --start-server are mutually exclusive' }, null, 2));
|
|
process.exit(2);
|
|
}
|
|
const baseUrl = baseArg?.replace(/\/$/, '') || null;
|
|
const sourceNames = ['index.html', 'app.js', 'styles.css', 'config.js', 'health.html', 'error.html', 'healthz', 'smoke-test.html', 'asset-manifest.json', 'README.md'];
|
|
const results = [];
|
|
const check = (id, description, pass, details = '') => results.push({ id, description, pass: Boolean(pass), ...(details ? { details } : {}) });
|
|
const all = (items, predicate) => items.every(predicate);
|
|
const listMissing = (items, predicate) => items.filter(item => !predicate(item));
|
|
const sha256 = bytes => `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
|
|
let files = {};
|
|
let manifest;
|
|
try {
|
|
files = Object.fromEntries(await Promise.all(sourceNames.map(async name => [name, await readFile(resolve(webRoot, name))])));
|
|
manifest = JSON.parse(files['asset-manifest.json'].toString('utf8'));
|
|
check('files.readable', 'all frontend acceptance inputs are readable', true);
|
|
} catch (error) {
|
|
check('files.readable', 'all frontend acceptance inputs are readable', false, error.message);
|
|
}
|
|
const text = name => files[name]?.toString('utf8') || '';
|
|
const html = text('index.html');
|
|
const js = text('app.js');
|
|
const css = text('styles.css');
|
|
const config = text('config.js');
|
|
|
|
const expectedIds = [
|
|
'loginScreen', 'loginForm', 'loginEmail', 'loginPassword', 'dashboardShell', 'logoutBtn', 'apiStatus',
|
|
'explorer', 'detailPanel', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect',
|
|
'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel',
|
|
'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'crmPipeline', 'pipelineBoard', 'crmActivity',
|
|
'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport',
|
|
'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel',
|
|
'scoreRules', 'scoreRulesPanel', 'scoreDistributionPanel', 'mergeDialog', 'confirmMergeBtn'
|
|
];
|
|
const htmlIds = new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map(m => m[1]));
|
|
check('dom.critical-ids', 'critical operator DOM IDs are present', all(expectedIds, id => htmlIds.has(id)), listMissing(expectedIds, id => htmlIds.has(id)).join(', '));
|
|
const expectedMarkers = ['saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution'];
|
|
const smokeMarkers = new Set([...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(m => m[1]));
|
|
check('dom.smoke-markers', 'critical sections expose stable smoke markers', all(expectedMarkers, marker => smokeMarkers.has(marker)), listMissing(expectedMarkers, marker => smokeMarkers.has(marker)).join(', '));
|
|
const dynamicMarkers = ['score-breakdown', 'deduplication'];
|
|
const dynamicMarkerPresent = marker => js.includes(`dataset.smoke = '${marker}'`) || js.includes(`dataset.smoke='${marker}'`) || js.includes(`data-smoke="${marker}"`) || js.includes(`data-smoke='${marker}'`);
|
|
check('dom.dynamic-markers', 'detail safety panels define dynamic smoke markers', all(dynamicMarkers, dynamicMarkerPresent), listMissing(dynamicMarkers, dynamicMarkerPresent).join(', '));
|
|
const routeContracts = [
|
|
'/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/dashboard/summary', '/api/v1/businesses',
|
|
'/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review', '/api/v1/jobs', '/api/v1/sources',
|
|
'/api/v1/source-records', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules',
|
|
'/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity',
|
|
'/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config',
|
|
'/matches', '/domains/check', '/domain-candidates', '/websites/scan', '/contacts/extract', '/score/recalculate', '/pipeline', '/verify'
|
|
];
|
|
check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', '));
|
|
check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest'));
|
|
check('routes.no-arbitrary-target-fetch', 'browser does not fetch arbitrary target URLs directly', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js));
|
|
|
|
const manifestAssets = manifest && [...new Set([...(manifest.entrypoints || []), ...(manifest.publicAssets || [])])];
|
|
check('manifest.schema', 'asset manifest has a supported schema and asset lists', Boolean(manifest && manifest.schema === 1 && Array.isArray(manifest.entrypoints) && Array.isArray(manifest.publicAssets) && manifest.integrity && manifestAssets.length), manifest ? '' : 'manifest unavailable');
|
|
if (manifestAssets) {
|
|
check('manifest.assets-exist', 'every manifest asset exists and is non-empty', all(manifestAssets, asset => files[asset]?.length > 0), listMissing(manifestAssets, asset => files[asset]?.length > 0).join(', '));
|
|
check('manifest.integrity-complete', 'manifest integrity keys exactly cover manifest assets', manifestAssets.length === Object.keys(manifest.integrity).length && all(manifestAssets, asset => Object.hasOwn(manifest.integrity, asset)), `assets=${manifestAssets.length}, integrity=${Object.keys(manifest.integrity).length}`);
|
|
const integrityFailures = manifestAssets.filter(asset => sha256(files[asset] || Buffer.alloc(0)) !== manifest.integrity[asset]);
|
|
check('manifest.integrity', 'manifest SHA-256 digests match local release assets', integrityFailures.length === 0, integrityFailures.join(', '));
|
|
check('manifest.hash-format', 'manifest integrity values use sha256 hex format', all(manifestAssets, asset => /^sha256-[0-9a-f]{64}$/.test(manifest.integrity[asset] || '')), listMissing(manifestAssets, asset => /^sha256-[0-9a-f]{64}$/.test(manifest.integrity[asset] || '')).join(', '));
|
|
}
|
|
|
|
const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )];
|
|
check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', '));
|
|
check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board'], selector => css.includes(selector)));
|
|
check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector)));
|
|
|
|
const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`;
|
|
const safetyCopy = ['No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.', 'Approval does not send a message.', 'no outreach will be sent', 'AUTOMATED_OUTREACH_ENABLED=false'];
|
|
check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', all(safetyCopy, phrase => combined.includes(phrase)), listMissing(safetyCopy, phrase => combined.includes(phrase)).join(' | '));
|
|
const buttonLabels = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/gi)].map(m => m[1].replace(/<[^>]+>/g, ' '));
|
|
check('safety.no-send-controls', 'static HTML exposes no send, delivery, or campaign control', !buttonLabels.some(label => /\b(send|deliver|campaign|schedule delivery)\b/i.test(label)));
|
|
const approvalTokens = ['window.confirm', 'human_approval:true', 'send:false', 'autonomous_action:false'];
|
|
check('safety.approval-gated', 'approval actions require explicit human confirmation and remain non-delivering', all(approvalTokens, token => js.includes(token)), listMissing(approvalTokens, token => js.includes(token)).join(', '));
|
|
const suppressionTokens = ['statusOf', "st==='suppressed'", "renderAiState('suppressed')", "renderOutreachState('suppressed')", 'suppression', 'Do not contact'];
|
|
check('safety.suppression-precedence', 'suppression disables AI/outreach and preserves do-not-contact state', all(suppressionTokens, token => combined.includes(token)), listMissing(suppressionTokens, token => combined.includes(token)).join(', '));
|
|
check('safety.source-disabled', 'discovery remains disabled until an approved source is enabled', all(['Discovery is disabled by default.', 'enabled:false', 'This source is disabled. Enable it only after review.'], token => combined.includes(token)));
|
|
check('safety.no-external-network', 'client has no direct provider or target network origins', !/(?:fetch|XMLHttpRequest|WebSocket)\s*\(\s*[`'\"]https?:\/\//i.test(js));
|
|
const secretPatterns = [
|
|
/-----BEGIN(?: RSA| EC| OPENSSH)? PRIVATE KEY-----/i,
|
|
/(?:api[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|private[_-]?key|authorization)\s*[:=]\s*["'][^"']{8,}/i,
|
|
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
|
|
/\beyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\b/
|
|
];
|
|
const secretHits = secretPatterns.flatMap(pattern => [...combined.matchAll(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`))].map(m => m[0].slice(0, 80)));
|
|
check('safety.no-hardcoded-secrets', 'frontend source has no obvious hardcoded secrets', secretHits.length === 0, secretHits.join(' | '));
|
|
|
|
async function httpChecks(url) {
|
|
for (const asset of [...new Set([...(manifestAssets || []), ...linkedAssets])].sort()) {
|
|
try {
|
|
const response = await fetch(`${url}/${asset}`);
|
|
const body = Buffer.from(await response.arrayBuffer());
|
|
const expected = asset.endsWith('.html') ? 'text/html' : asset.endsWith('.css') ? 'text/css' : asset.endsWith('.js') ? 'javascript' : null;
|
|
const contentType = response.headers.get('content-type') || '';
|
|
const integrityPass = !manifest?.integrity?.[asset] || sha256(body) === manifest.integrity[asset];
|
|
check(`http.${asset}`, `HTTP delivery and integrity: ${asset}`, response.ok && body.length > 0 && (!expected || contentType.includes(expected)) && integrityPass, `${response.status} ${contentType || 'missing content-type'}${integrityPass ? '' : '; integrity mismatch'}`);
|
|
} catch (error) {
|
|
check(`http.${asset}`, `HTTP delivery and integrity: ${asset}`, false, error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
let child;
|
|
try {
|
|
let httpUrl = baseUrl;
|
|
if (startServer) {
|
|
const port = Number(portArg);
|
|
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`invalid --port: ${portArg}`);
|
|
child = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '--directory', webRoot], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
const actualPort = await new Promise((resolvePort, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('timed out waiting for static server')), 5000);
|
|
const probe = async () => { const candidate = port; try { await fetch(`http://127.0.0.1:${candidate}/healthz`); clearTimeout(timer); resolvePort(candidate); } catch { setTimeout(probe, 50); } };
|
|
child.once('error', reject); probe();
|
|
});
|
|
httpUrl = `http://127.0.0.1:${actualPort}`;
|
|
}
|
|
if (httpUrl) await httpChecks(httpUrl.replace(/\/$/, ''));
|
|
} catch (error) {
|
|
check('http.server', 'optional static server is reachable', false, error.message);
|
|
} finally {
|
|
if (child) child.kill();
|
|
}
|
|
|
|
const failed = results.filter(result => !result.pass);
|
|
const report = {
|
|
schema: 1,
|
|
harness: 'prospectos-frontend-final-acceptance',
|
|
root: webRoot,
|
|
http_checks: Boolean(baseUrl || startServer),
|
|
base_url: baseUrl || (startServer ? 'managed static server' : null),
|
|
pass: failed.length === 0,
|
|
totals: { checks: results.length, passed: results.length - failed.length, failed: failed.length },
|
|
checks: results
|
|
};
|
|
console.log(JSON.stringify(report, null, 2));
|
|
process.exitCode = failed.length ? 1 : 0;
|