102 lines
10 KiB
TypeScript
102 lines
10 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'));
|
|
const config=JSON.parse(await readFile(path.resolve(root,'../../config.json'),'utf8'));
|
|
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());
|
|
});
|