- cutover-orchestrator: restart via 'node --import tsx' (matching install.sh) instead of a hardcoded .pnpm/tsx@4.22.4 loader path that breaks on tsx bumps - production-gateway: use process.execPath for the legacy-start fixture; POSIX-only 0600 mode assertion now win32-guarded - cli-runtime + installer suites: skip where they require POSIX-only process-group SIGTERM, lsof, or /bin/sh - fixture-safety: fall back to config.example.json when config.json is absent on a fresh clone
327 lines
12 KiB
TypeScript
327 lines
12 KiB
TypeScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
import { createHash } from 'node:crypto';
|
|
import path from 'node:path';
|
|
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
|
import {
|
|
collectOne,
|
|
selectReadonlyOperations,
|
|
validateReadonlyOperation,
|
|
DENY_REASONS,
|
|
} from '../scripts/collector.ts';
|
|
import { redact } from '../src/redactor.ts';
|
|
import {
|
|
payloadShape,
|
|
validateShapeNode,
|
|
validateSensitivePayload,
|
|
validateStatusContract,
|
|
} from '../src/verifier.ts';
|
|
|
|
const root = path.resolve(import.meta.dirname, '..');
|
|
const fixtureRoot = path.join(root, 'src/simadmin');
|
|
const allowedEnvelope = [
|
|
'schemaVersion',
|
|
'upstreamBaseline',
|
|
'capturedAt',
|
|
'sourceInstanceAlias',
|
|
'operationId',
|
|
'method',
|
|
'pathTemplate',
|
|
'statusCategory',
|
|
'httpStatus',
|
|
'contentType',
|
|
'latencyBucket',
|
|
'redacted',
|
|
'payload',
|
|
];
|
|
// Preserve business-schema keys such as `url` and channel `headers`; sensitive values
|
|
// are typed-redacted. The exact top-level envelope already excludes transport metadata.
|
|
const forbiddenKeys = /^(raw(response)?|instance(id|name))$/i;
|
|
const DENIED_PATHS = new Set(Object.keys(DENY_REASONS));
|
|
const leakPatterns = [
|
|
/https?:\/\//i,
|
|
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i,
|
|
/\b(?:\d{1,3}\.){3}\d{1,3}\b/,
|
|
/(?:[0-9a-f]{2}:){5}[0-9a-f]{2}/i,
|
|
/\+?\d(?:[ ()-]*\d){9,14}/,
|
|
/\b\d{14,22}\b/,
|
|
/\b(?:bearer\s+)?[A-Za-z0-9_-]{32,}\b/i,
|
|
];
|
|
|
|
async function jsonFiles(dir: string): Promise<string[]> {
|
|
const out: string[] = [];
|
|
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
const p = path.join(dir, e.name);
|
|
if (e.isDirectory()) out.push(...(await jsonFiles(p)));
|
|
else if (e.name.endsWith('.json')) out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
function walkKeys(v: any, cb: (k: string) => void) {
|
|
if (Array.isArray(v)) return v.forEach((x) => walkKeys(x, cb));
|
|
if (v && typeof v === 'object')
|
|
for (const [k, x] of Object.entries(v)) {
|
|
cb(k);
|
|
walkKeys(x, cb);
|
|
}
|
|
}
|
|
function stringLeaves(v: any, out: string[] = []) {
|
|
if (Array.isArray(v)) v.forEach((x) => stringLeaves(x, out));
|
|
else if (v && typeof v === 'object') Object.values(v).forEach((x) => stringLeaves(x, out));
|
|
else if (typeof v === 'string' && v.length >= 3) out.push(v);
|
|
return out;
|
|
}
|
|
|
|
test('selector is registry-derived passive GET R0 only with explicit denials', () => {
|
|
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
|
assert.ok(selected.length > 20);
|
|
assert.ok(
|
|
denied.some((x: any) => x.pathTemplate === '/api/network/operators/scan' && x.denyReason),
|
|
);
|
|
for (const op of selected) {
|
|
assert.equal(op.method, 'GET');
|
|
assert.equal(op.riskLevel, 'R0');
|
|
assert.doesNotMatch(op.pathTemplate, /[{}]/);
|
|
assert.notEqual(op.pathTemplate, '/api/network/operators/scan');
|
|
}
|
|
});
|
|
|
|
test('collector source has no write-method or authentication escape hatch', async () => {
|
|
const source = await readFile(path.join(root, 'scripts/collect-readonly-fixtures.ts'), 'utf8');
|
|
assert.doesNotMatch(source, /ensureAuthenticated|login\s*\(|--method|--path|--origin/i);
|
|
assert.doesNotMatch(source, /['"](?:POST|PUT|PATCH|DELETE)['"]/);
|
|
});
|
|
|
|
test('transport can only receive credential-free GET, manual redirect and timeout', async () => {
|
|
let init: any;
|
|
const fake = async (_url: any, i: any) => {
|
|
init = i;
|
|
return new Response(
|
|
JSON.stringify({ phone: '+15551234567', message: 'private', ip: '10.1.2.3' }),
|
|
{ status: 200, headers: { 'content-type': 'application/json', 'set-cookie': 'bad=1' } },
|
|
);
|
|
};
|
|
const op = upstream58e2204Operations.find((x) => x.operationId === 'getHealth')!;
|
|
const f = await collectOne(
|
|
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
|
op,
|
|
fake as any,
|
|
);
|
|
assert.deepEqual(
|
|
{
|
|
method: init.method,
|
|
body: init.body,
|
|
redirect: init.redirect,
|
|
credentials: init.credentials,
|
|
},
|
|
{ method: 'GET', body: undefined, redirect: 'manual', credentials: 'omit' },
|
|
);
|
|
assert.deepEqual(init.headers, { accept: 'application/json' });
|
|
assert.ok(init.signal);
|
|
assert.equal(f.payload.phone, '[REDACTED]');
|
|
assert.equal(f.payload.message, 'private');
|
|
assert.equal(f.payload.ip, '[REDACTED]');
|
|
});
|
|
|
|
test('redactor covers sensitive keys and value patterns deterministically', () => {
|
|
const x = redact({
|
|
password: 'p',
|
|
TOKEN: 't',
|
|
content: 'body',
|
|
phone: 'x',
|
|
other: [
|
|
'+155****4567',
|
|
'+155****4567',
|
|
'a@b.example',
|
|
'https://private.example/x',
|
|
'aa:bb:cc:dd:ee:ff',
|
|
'2001:db8::1',
|
|
],
|
|
});
|
|
assert.equal(x.password, '[REDACTED]');
|
|
assert.equal(x.TOKEN, '[REDACTED]');
|
|
assert.equal(x.content, '[REDACTED]');
|
|
assert.equal(x.phone, '[REDACTED]');
|
|
for (const p of leakPatterns) assert.doesNotMatch(JSON.stringify(x), p);
|
|
});
|
|
|
|
test('Registry operation identity also rejects in-place structural mutation', () => {
|
|
const op: any = upstream58e2204Operations.find((x) => x.operationId === 'getHealth')!;
|
|
const original = structuredClone(op);
|
|
const mutations = [
|
|
(x: any) => x.sensitiveFields.push({ direction: 'response', path: '$.response.data.secret' }),
|
|
(x: any) => {
|
|
delete x.pathTemplate;
|
|
},
|
|
(x: any) => {
|
|
x.method = 'POST';
|
|
},
|
|
(x: any) => {
|
|
x.sensitiveFields = 'changed';
|
|
},
|
|
];
|
|
for (const mutate of mutations) {
|
|
try {
|
|
mutate(op);
|
|
assert.throws(() => validateReadonlyOperation(op), /Registry integrity/);
|
|
} finally {
|
|
for (const key of Object.keys(op)) delete op[key];
|
|
Object.assign(op, structuredClone(original));
|
|
}
|
|
}
|
|
validateReadonlyOperation(op);
|
|
});
|
|
|
|
test('collector categorizes status and JSON parse outcomes consistently', async () => {
|
|
const op = upstream58e2204Operations.find((x) => x.operationId === 'getHealth')!;
|
|
const run = (status: number, type: string, body: string | null) =>
|
|
collectOne(
|
|
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
|
op,
|
|
async () => new Response(body, { status, headers: { 'content-type': type } }),
|
|
);
|
|
for (const [status, type, body, category] of [
|
|
[200, 'application/problem+json', '{}', 'success'],
|
|
[200, 'application/json', 'bad', 'non-json'],
|
|
[204, 'text/plain', null, 'non-json'],
|
|
[401, 'application/json', '{}', 'auth-required'],
|
|
[405, 'application/json', '{}', 'unsupported'],
|
|
[500, 'application/json', '{}', 'http-error'],
|
|
] as const) {
|
|
const f = await run(status, type, body);
|
|
assert.equal(f.statusCategory, category);
|
|
validateStatusContract(f);
|
|
}
|
|
const redirect = await run(302, 'application/json', '{}');
|
|
assert.equal(redirect.statusCategory, 'http-error');
|
|
validateStatusContract(redirect);
|
|
assert.throws(() =>
|
|
validateStatusContract({
|
|
statusCategory: 'success',
|
|
httpStatus: 401,
|
|
contentType: 'application/json',
|
|
payload: {},
|
|
latencyBucket: '<250ms',
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('real fixtures satisfy envelope, registry, leak scan, and manifest integrity', async () => {
|
|
const manifest = JSON.parse(await readFile(path.join(root, 'src/manifest.json'), 'utf8'));
|
|
assert.deepEqual(Object.keys(manifest).sort(), [
|
|
'domainCoverage',
|
|
'files',
|
|
'realFixtureCount',
|
|
'schemaVersion',
|
|
'shapeBaseline',
|
|
'syntheticFixtureCount',
|
|
'upstreamBaseline',
|
|
]);
|
|
const files = (await jsonFiles(fixtureRoot)).filter((x) => !x.includes('/synthetic-errors/'));
|
|
const srcEntries = await readdir(path.join(root, 'src'));
|
|
assert.ok(!srcEntries.includes('synthetic-errors'));
|
|
// config.json is gitignored; on a fresh clone scan the committed example config
|
|
// instead so the leak scan still exercises real instance-shaped data.
|
|
const configPath = path.resolve(root, '../../config.json');
|
|
const configText = await readFile(configPath, 'utf8').catch((error) => {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
return readFile(path.resolve(root, '../../config.example.json'), 'utf8');
|
|
});
|
|
const config = JSON.parse(configText);
|
|
const privateValues = stringLeaves(config);
|
|
const expected = selectReadonlyOperations(upstream58e2204Operations).selected;
|
|
assert.equal(files.length, expected.length * 2);
|
|
assert.equal(manifest.realFixtureCount, files.length);
|
|
assert.equal(manifest.syntheticFixtureCount, 0);
|
|
assert.equal(manifest.schemaVersion, 1);
|
|
assert.equal(manifest.upstreamBaseline, '58e2204');
|
|
const shapePath = path.join(root, 'src/response-shapes-58e2204.json');
|
|
const shapeText = await readFile(shapePath, 'utf8');
|
|
const shapeBaseline = JSON.parse(shapeText);
|
|
assert.deepEqual(Object.keys(shapeBaseline).sort(), [
|
|
'schemaVersion',
|
|
'shapes',
|
|
'upstreamBaseline',
|
|
]);
|
|
assert.equal(shapeBaseline.schemaVersion, 1);
|
|
assert.equal(shapeBaseline.upstreamBaseline, '58e2204');
|
|
assert.equal(Object.keys(shapeBaseline.shapes).length, 78);
|
|
assert.deepEqual(Object.keys(manifest.shapeBaseline).sort(), ['path', 'sha256', 'size']);
|
|
assert.equal(manifest.shapeBaseline.path, 'src/response-shapes-58e2204.json');
|
|
assert.equal(manifest.shapeBaseline.size, (await stat(shapePath)).size);
|
|
assert.equal(manifest.shapeBaseline.sha256, createHash('sha256').update(shapeText).digest('hex'));
|
|
assert.ok(!manifest.files.some((x: any) => x.path === manifest.shapeBaseline.path));
|
|
for (const node of Object.values(shapeBaseline.shapes)) validateShapeNode(node);
|
|
const registry = new Map(upstream58e2204Operations.map((x) => [x.operationId, x]));
|
|
const domains = new Map<string, number>();
|
|
const pairs = new Set<string>();
|
|
const disk = files.map((file) => path.relative(root, file)).sort();
|
|
const listed = manifest.files.map((x: any) => x.path).sort();
|
|
assert.deepEqual(listed, disk);
|
|
assert.equal(new Set(listed).size, listed.length);
|
|
for (const file of files) {
|
|
const text = await readFile(file, 'utf8');
|
|
const f = JSON.parse(text);
|
|
assert.deepEqual(Object.keys(f).sort(), [...allowedEnvelope].sort());
|
|
assert.equal(f.schemaVersion, 1);
|
|
assert.equal(f.upstreamBaseline, '58e2204');
|
|
assert.match(f.capturedAt, /^20(?:2[4-9]|[3-9]\d)-\d{2}-\d{2}$/);
|
|
assert.ok(!Number.isNaN(Date.parse(`${f.capturedAt}T00:00:00Z`)));
|
|
assert.ok(['instance-1', 'instance-2'].includes(f.sourceInstanceAlias));
|
|
assert.equal(f.redacted, true);
|
|
assert.equal(f.method, 'GET');
|
|
assert.ok(!('synthetic' in f));
|
|
assert.ok(
|
|
[
|
|
'success',
|
|
'non-json',
|
|
'auth-required',
|
|
'unsupported',
|
|
'http-error',
|
|
'timeout',
|
|
'network-error',
|
|
].includes(f.statusCategory),
|
|
);
|
|
if (['timeout', 'network-error'].includes(f.statusCategory)) assert.equal(f.httpStatus, null);
|
|
else assert.ok(Number.isInteger(f.httpStatus));
|
|
const op: any = registry.get(f.operationId);
|
|
assert.ok(op);
|
|
validateStatusContract(f);
|
|
validateSensitivePayload(f.payload, op);
|
|
const shapeKey = `${f.sourceInstanceAlias}::${f.operationId}`;
|
|
assert.deepEqual(payloadShape(f.payload), shapeBaseline.shapes[shapeKey]);
|
|
assert.equal(op?.riskLevel, 'R0');
|
|
assert.equal(op?.method, 'GET');
|
|
assert.equal(op?.pathTemplate, f.pathTemplate);
|
|
assert.ok(!DENIED_PATHS.has(f.pathTemplate));
|
|
domains.set(op.upstreamDomain, (domains.get(op.upstreamDomain) || 0) + 1);
|
|
const pair = `${f.sourceInstanceAlias}:${f.operationId}`;
|
|
assert.ok(!pairs.has(pair));
|
|
pairs.add(pair);
|
|
walkKeys(f, (k) => assert.doesNotMatch(k, forbiddenKeys));
|
|
for (const p of leakPatterns) assert.doesNotMatch(text, p);
|
|
for (const value of privateValues)
|
|
assert.ok(!text.includes(value), 'fixture contains configured value');
|
|
const rel = path.relative(root, file);
|
|
const m = manifest.files.find((x: any) => x.path === rel);
|
|
assert.ok(m);
|
|
assert.equal(m.size, (await stat(file)).size);
|
|
assert.match(m.sha256, /^[a-f0-9]{64}$/);
|
|
assert.equal(m.sha256, createHash('sha256').update(text).digest('hex'));
|
|
}
|
|
assert.deepEqual(Object.fromEntries([...domains].sort()), manifest.domainCoverage);
|
|
assert.deepEqual(
|
|
Object.keys(shapeBaseline.shapes).sort(),
|
|
[...pairs].map((x) => x.replace(':', '::')).sort(),
|
|
);
|
|
assert.deepEqual(
|
|
[...pairs].sort(),
|
|
expected
|
|
.flatMap((op: any) =>
|
|
['instance-1', 'instance-2'].map((alias) => `${alias}:${op.operationId}`),
|
|
)
|
|
.sort(),
|
|
);
|
|
});
|