feat(contracts): share notification, organization and device capability models
Move the Hub channel table, organization tags and device capability definitions into the workspace packages so the control plane and the console validate the same contract, and refresh the frozen upstream evidence for the new paths.
This commit is contained in:
@@ -4,98 +4,316 @@ 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 {
|
||||
collectOne,
|
||||
selectReadonlyOperations,
|
||||
validateReadonlyOperation,
|
||||
DENY_REASONS,
|
||||
} from '../scripts/collector.ts';
|
||||
import { redact } from '../src/redactor.ts';
|
||||
import { payloadShape, validateShapeNode, validateSensitivePayload, validateStatusContract } from '../src/verifier.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'];
|
||||
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/,
|
||||
/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;}
|
||||
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'); }
|
||||
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');
|
||||
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]');
|
||||
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('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('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('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());
|
||||
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(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,90 +1,311 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
|
||||
import { collectOne, selectReadonlyOperations, validateReadonlyOperation, validateInstanceOrigin } from '../scripts/collector.ts';
|
||||
import {
|
||||
collectOne,
|
||||
selectReadonlyOperations,
|
||||
validateReadonlyOperation,
|
||||
validateInstanceOrigin,
|
||||
} from '../scripts/collector.ts';
|
||||
import { loadInstances } from '../scripts/collect-readonly-fixtures.ts';
|
||||
import { redact, redactOperationPayload, parseResponseSensitivePath } from '../src/redactor.ts';
|
||||
|
||||
const health=upstream58e2204Operations.find((x:any)=>x.operationId==='getHealth')!;
|
||||
const fakeResponse=async()=>new Response('{"ok":true}',{headers:{'content-type':'application/json'}});
|
||||
const health = upstream58e2204Operations.find((x: any) => x.operationId === 'getHealth')!;
|
||||
const fakeResponse = async () =>
|
||||
new Response('{"ok":true}', { headers: { 'content-type': 'application/json' } });
|
||||
|
||||
test('operation validator rejects path and registry identity bypasses', async()=>{
|
||||
const attacks=['https://example.invalid/api/health','//example.invalid/api/health','/api/health?scan=1','/api/health#x','/api/%73can','/api/%252e%252e/health','/api/a/../health','/api\\health','/api//health'];
|
||||
for(const pathTemplate of attacks) assert.throws(()=>validateReadonlyOperation({...health,pathTemplate}));
|
||||
assert.throws(()=>validateReadonlyOperation({...health}));
|
||||
assert.throws(()=>validateReadonlyOperation({operationId:health.operationId,method:'GET',riskLevel:'R0',pathTemplate:health.pathTemplate}));
|
||||
await assert.rejects(()=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},{...health},fakeResponse));
|
||||
test('operation validator rejects path and registry identity bypasses', async () => {
|
||||
const attacks = [
|
||||
'https://example.invalid/api/health',
|
||||
'//example.invalid/api/health',
|
||||
'/api/health?scan=1',
|
||||
'/api/health#x',
|
||||
'/api/%73can',
|
||||
'/api/%252e%252e/health',
|
||||
'/api/a/../health',
|
||||
'/api\\health',
|
||||
'/api//health',
|
||||
];
|
||||
for (const pathTemplate of attacks)
|
||||
assert.throws(() => validateReadonlyOperation({ ...health, pathTemplate }));
|
||||
assert.throws(() => validateReadonlyOperation({ ...health }));
|
||||
assert.throws(() =>
|
||||
validateReadonlyOperation({
|
||||
operationId: health.operationId,
|
||||
method: 'GET',
|
||||
riskLevel: 'R0',
|
||||
pathTemplate: health.pathTemplate,
|
||||
}),
|
||||
);
|
||||
await assert.rejects(() =>
|
||||
collectOne({ origin: 'http://192.168.1.2', alias: 'instance-1' }, { ...health }, fakeResponse),
|
||||
);
|
||||
});
|
||||
|
||||
test('all path bypass classes fail before transport',async()=>{
|
||||
let calls=0;const transport=async()=>{calls++;return new Response('{}',{headers:{'content-type':'application/json'}})};
|
||||
const attacks=['https://evil.invalid/api/health','//evil.invalid/api/health','/api/health?q=1','/api/health#x','/api/%68ealth','/api/%252e%252e/x','/api/a/../x','/api\\health','/api//health'];
|
||||
for(const pathTemplate of attacks) await assert.rejects(()=>collectOne({origin:'http://192.168.1.2',alias:'instance-1'},{...health,pathTemplate},transport));
|
||||
assert.equal(calls,0);
|
||||
assert.throws(()=>selectReadonlyOperations([{...health}]));
|
||||
test('all path bypass classes fail before transport', async () => {
|
||||
let calls = 0;
|
||||
const transport = async () => {
|
||||
calls++;
|
||||
return new Response('{}', { headers: { 'content-type': 'application/json' } });
|
||||
};
|
||||
const attacks = [
|
||||
'https://evil.invalid/api/health',
|
||||
'//evil.invalid/api/health',
|
||||
'/api/health?q=1',
|
||||
'/api/health#x',
|
||||
'/api/%68ealth',
|
||||
'/api/%252e%252e/x',
|
||||
'/api/a/../x',
|
||||
'/api\\health',
|
||||
'/api//health',
|
||||
];
|
||||
for (const pathTemplate of attacks)
|
||||
await assert.rejects(() =>
|
||||
collectOne(
|
||||
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||
{ ...health, pathTemplate },
|
||||
transport,
|
||||
),
|
||||
);
|
||||
assert.equal(calls, 0);
|
||||
assert.throws(() => selectReadonlyOperations([{ ...health }]));
|
||||
});
|
||||
|
||||
test('collectOne validates RFC1918 origin before transport',()=>{
|
||||
for(const origin of ['http://127.0.0.1','http://169.254.1.1','http://100.100.100.200','http://8.8.8.8','http://router.local','http://u:p@192.168.1.2','http://192.168.1.2/x','http://192.168.1.2?q=1','ftp://192.168.1.2']) assert.throws(()=>validateInstanceOrigin(origin));
|
||||
assert.equal(validateInstanceOrigin('https://172.16.2.3:8443'),'https://172.16.2.3:8443');
|
||||
test('collectOne validates RFC1918 origin before transport', () => {
|
||||
for (const origin of [
|
||||
'http://127.0.0.1',
|
||||
'http://169.254.1.1',
|
||||
'http://100.100.100.200',
|
||||
'http://8.8.8.8',
|
||||
'http://router.local',
|
||||
'http://u:p@192.168.1.2',
|
||||
'http://192.168.1.2/x',
|
||||
'http://192.168.1.2?q=1',
|
||||
'ftp://192.168.1.2',
|
||||
])
|
||||
assert.throws(() => validateInstanceOrigin(origin));
|
||||
assert.equal(validateInstanceOrigin('https://172.16.2.3:8443'), 'https://172.16.2.3:8443');
|
||||
});
|
||||
|
||||
test('passive selection explicitly denies connectivity and active scan',()=>{
|
||||
const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
|
||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
||||
assert.ok(denied.some((x:any)=>x.pathTemplate==='/api/network/operators/scan'));
|
||||
assert.ok(!selected.some((x:any)=>x.pathTemplate==='/api/connectivity'));
|
||||
test('passive selection explicitly denies connectivity and active scan', () => {
|
||||
const { selected, denied } = selectReadonlyOperations(upstream58e2204Operations);
|
||||
assert.ok(denied.some((x: any) => x.pathTemplate === '/api/connectivity'));
|
||||
assert.ok(denied.some((x: any) => x.pathTemplate === '/api/network/operators/scan'));
|
||||
assert.ok(!selected.some((x: any) => x.pathTemplate === '/api/connectivity'));
|
||||
});
|
||||
|
||||
test('config uses formal validation plus collector LAN/auth/origin constraints',()=>{
|
||||
const good={instances:[{id:'a',url:'http://192.168.1.2',auth:{mode:'none'}},{id:'b',url:'https://10.0.0.2',auth:{mode:'none'}}]};
|
||||
assert.deepEqual(loadInstances(good).map((x:any)=>x.alias),['instance-1','instance-2']);
|
||||
const bad=['http://127.0.0.1','http://169.254.1.2','http://100.100.100.200','http://8.8.8.8','http://router.local','http://u:***@192.168.1.2','http://192.168.1.2/path','http://192.168.1.2?q=1','http://192.168.1.2#x','http://[::1]'];
|
||||
for(const url of bad) assert.throws(()=>loadInstances({instances:[{id:'a',url},{id:'b',url:'http://10.0.0.2'}]}));
|
||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2',auth:{password:'x'}},{id:'b',url:'http://10.0.0.2'}]}));
|
||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2'}]}));
|
||||
assert.throws(()=>loadInstances({instances:[{id:'a',url:'http://192.168.1.2'},{id:'b',url:'http://10.0.0.2'},{id:'c',url:'http://10.0.0.3'}]}));
|
||||
test('config uses formal validation plus collector LAN/auth/origin constraints', () => {
|
||||
const good = {
|
||||
instances: [
|
||||
{ id: 'a', url: 'http://192.168.1.2', auth: { mode: 'none' } },
|
||||
{ id: 'b', url: 'https://10.0.0.2', auth: { mode: 'none' } },
|
||||
],
|
||||
};
|
||||
assert.deepEqual(
|
||||
loadInstances(good).map((x: any) => x.alias),
|
||||
['instance-1', 'instance-2'],
|
||||
);
|
||||
const bad = [
|
||||
'http://127.0.0.1',
|
||||
'http://169.254.1.2',
|
||||
'http://100.100.100.200',
|
||||
'http://8.8.8.8',
|
||||
'http://router.local',
|
||||
'http://u:***@192.168.1.2',
|
||||
'http://192.168.1.2/path',
|
||||
'http://192.168.1.2?q=1',
|
||||
'http://192.168.1.2#x',
|
||||
'http://[::1]',
|
||||
];
|
||||
for (const url of bad)
|
||||
assert.throws(() =>
|
||||
loadInstances({
|
||||
instances: [
|
||||
{ id: 'a', url },
|
||||
{ id: 'b', url: 'http://10.0.0.2' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.throws(() =>
|
||||
loadInstances({
|
||||
instances: [
|
||||
{ id: 'a', url: 'http://192.168.1.2', auth: { password: 'x' } },
|
||||
{ id: 'b', url: 'http://10.0.0.2' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
assert.throws(() => loadInstances({ instances: [{ id: 'a', url: 'http://192.168.1.2' }] }));
|
||||
assert.throws(() =>
|
||||
loadInstances({
|
||||
instances: [
|
||||
{ id: 'a', url: 'http://192.168.1.2' },
|
||||
{ id: 'b', url: 'http://10.0.0.2' },
|
||||
{ id: 'c', url: 'http://10.0.0.3' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('directional response JSONPaths redact every declared path and preserve shape/types',()=>{
|
||||
const ops=upstream58e2204Operations.filter((op:any)=>op.sensitiveFields.some((f:any)=>f.direction==='response'));
|
||||
assert.ok(ops.length>0);
|
||||
for(const op of ops) for(const field of op.sensitiveFields.filter((f:any)=>f.direction==='response')){
|
||||
const parts=parseResponseSensitivePath(field.path); assert.ok(parts.length>0,`${op.operationId} unsupported response path`);
|
||||
let leaf:any={s:'private',n:42,b:true,z:null,a:['x',{q:9}]};
|
||||
let payload:any=leaf;
|
||||
for(let i=parts.length-1;i>=0;i--){const p=parts[i]; payload=p==='*'?[payload]:{[p]:payload};}
|
||||
const out:any=redactOperationPayload(payload,op);
|
||||
let hit=out; for(const p of parts) hit=p==='*'?hit[0]:hit[p];
|
||||
assert.deepEqual(hit,{s:'[REDACTED]',n:0,b:false,z:null,a:['[REDACTED]',{q:0}]});
|
||||
}
|
||||
test('directional response JSONPaths redact every declared path and preserve shape/types', () => {
|
||||
const ops = upstream58e2204Operations.filter((op: any) =>
|
||||
op.sensitiveFields.some((f: any) => f.direction === 'response'),
|
||||
);
|
||||
assert.ok(ops.length > 0);
|
||||
for (const op of ops)
|
||||
for (const field of op.sensitiveFields.filter((f: any) => f.direction === 'response')) {
|
||||
const parts = parseResponseSensitivePath(field.path);
|
||||
assert.ok(parts.length > 0, `${op.operationId} unsupported response path`);
|
||||
let leaf: any = { s: 'private', n: 42, b: true, z: null, a: ['x', { q: 9 }] };
|
||||
let payload: any = leaf;
|
||||
for (let i = parts.length - 1; i >= 0; i--) {
|
||||
const p = parts[i];
|
||||
payload = p === '*' ? [payload] : { [p]: payload };
|
||||
}
|
||||
const out: any = redactOperationPayload(payload, op);
|
||||
let hit = out;
|
||||
for (const p of parts) hit = p === '*' ? hit[0] : hit[p];
|
||||
assert.deepEqual(hit, {
|
||||
s: '[REDACTED]',
|
||||
n: 0,
|
||||
b: false,
|
||||
z: null,
|
||||
a: ['[REDACTED]', { q: 0 }],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('deep sanitizer handles reviewed key/value bypasses without schema destruction',()=>{
|
||||
const input:any={credential:'x',api_key:'x',access_key:'x',device_key:'x',private_key:'x',pin:12,puk:34,username:'x',user:'x',account:'x',serial:'x',revision:'x',path:'/private/a',template:'user=x token=abc123 path=/private/a',config:{access_token:'tiny'},phone_number_is_manual:true,sms_center_is_manual:false,message:'API is healthy',nested:'{"username":"x","enabled":true}',host:'private.internal:8080',short_number:'12345678',split_id:'12-34-56-78-90-12-34',ipv6:'::1%lo0',latitude:1.234567,lon:2.345678,cell_id:99,lac:2,tac:3,pci:4,enb:5,gnb:6,uptime:777,traffic:888,timestamp:'2026-07-16T12:34:56Z',unknown:'AKIAIOSFODNN7EXAMPLE'};
|
||||
const x:any=redact(input);
|
||||
for(const k of ['credential','api_key','access_key','device_key','private_key','username','user','account','serial','revision','path','template','host','short_number','split_id','ipv6','timestamp','unknown']) assert.equal(x[k],'[REDACTED]');
|
||||
for(const k of ['pin','puk','latitude','lon','cell_id','lac','tac','pci','enb','gnb','uptime','traffic']) assert.equal(x[k],0);
|
||||
assert.equal(typeof x.nested,'string'); assert.deepEqual(JSON.parse(x.nested),{username:'[REDACTED]',enabled:true});
|
||||
assert.deepEqual(x.config,{access_token:'[REDACTED]'}); assert.equal(x.phone_number_is_manual,true); assert.equal(x.sms_center_is_manual,false); assert.equal(x.message,'API is healthy');
|
||||
test('deep sanitizer handles reviewed key/value bypasses without schema destruction', () => {
|
||||
const input: any = {
|
||||
credential: 'x',
|
||||
api_key: 'x',
|
||||
access_key: 'x',
|
||||
device_key: 'x',
|
||||
private_key: 'x',
|
||||
pin: 12,
|
||||
puk: 34,
|
||||
username: 'x',
|
||||
user: 'x',
|
||||
account: 'x',
|
||||
serial: 'x',
|
||||
revision: 'x',
|
||||
path: '/private/a',
|
||||
template: 'user=x token=abc123 path=/private/a',
|
||||
config: { access_token: 'tiny' },
|
||||
phone_number_is_manual: true,
|
||||
sms_center_is_manual: false,
|
||||
message: 'API is healthy',
|
||||
nested: '{"username":"x","enabled":true}',
|
||||
host: 'private.internal:8080',
|
||||
short_number: '12345678',
|
||||
split_id: '12-34-56-78-90-12-34',
|
||||
ipv6: '::1%lo0',
|
||||
latitude: 1.234567,
|
||||
lon: 2.345678,
|
||||
cell_id: 99,
|
||||
lac: 2,
|
||||
tac: 3,
|
||||
pci: 4,
|
||||
enb: 5,
|
||||
gnb: 6,
|
||||
uptime: 777,
|
||||
traffic: 888,
|
||||
timestamp: '2026-07-16T12:34:56Z',
|
||||
unknown: 'AKIAIOSFODNN7EXAMPLE',
|
||||
};
|
||||
const x: any = redact(input);
|
||||
for (const k of [
|
||||
'credential',
|
||||
'api_key',
|
||||
'access_key',
|
||||
'device_key',
|
||||
'private_key',
|
||||
'username',
|
||||
'user',
|
||||
'account',
|
||||
'serial',
|
||||
'revision',
|
||||
'path',
|
||||
'template',
|
||||
'host',
|
||||
'short_number',
|
||||
'split_id',
|
||||
'ipv6',
|
||||
'timestamp',
|
||||
'unknown',
|
||||
])
|
||||
assert.equal(x[k], '[REDACTED]');
|
||||
for (const k of [
|
||||
'pin',
|
||||
'puk',
|
||||
'latitude',
|
||||
'lon',
|
||||
'cell_id',
|
||||
'lac',
|
||||
'tac',
|
||||
'pci',
|
||||
'enb',
|
||||
'gnb',
|
||||
'uptime',
|
||||
'traffic',
|
||||
])
|
||||
assert.equal(x[k], 0);
|
||||
assert.equal(typeof x.nested, 'string');
|
||||
assert.deepEqual(JSON.parse(x.nested), { username: '[REDACTED]', enabled: true });
|
||||
assert.deepEqual(x.config, { access_token: '[REDACTED]' });
|
||||
assert.equal(x.phone_number_is_manual, true);
|
||||
assert.equal(x.sms_center_is_manual, false);
|
||||
assert.equal(x.message, 'API is healthy');
|
||||
});
|
||||
|
||||
test('schema shapes and primitive types survive conservative redaction',()=>{
|
||||
const x:any=redact({phone_numbers:['12345678','87654321'],ip_addresses:[{v4:'192.168.1.9',active:true}],urls:['private.example/x'],webhook:{url:'//private.example/h',enabled:true},config:{s:'secret',n:42,b:true,z:null,a:['x',{n:9}]}});
|
||||
assert.ok(Array.isArray(x.phone_numbers));assert.equal(x.phone_numbers.length,2);
|
||||
assert.ok(Array.isArray(x.ip_addresses));assert.equal(typeof x.ip_addresses[0],'object');
|
||||
assert.ok(Array.isArray(x.urls));assert.equal(typeof x.webhook,'object');
|
||||
assert.deepEqual(x.config,{s:'[REDACTED]',n:0,b:false,z:null,a:['[REDACTED]',{n:0}]});
|
||||
test('schema shapes and primitive types survive conservative redaction', () => {
|
||||
const x: any = redact({
|
||||
phone_numbers: ['12345678', '87654321'],
|
||||
ip_addresses: [{ v4: '192.168.1.9', active: true }],
|
||||
urls: ['private.example/x'],
|
||||
webhook: { url: '//private.example/h', enabled: true },
|
||||
config: { s: 'secret', n: 42, b: true, z: null, a: ['x', { n: 9 }] },
|
||||
});
|
||||
assert.ok(Array.isArray(x.phone_numbers));
|
||||
assert.equal(x.phone_numbers.length, 2);
|
||||
assert.ok(Array.isArray(x.ip_addresses));
|
||||
assert.equal(typeof x.ip_addresses[0], 'object');
|
||||
assert.ok(Array.isArray(x.urls));
|
||||
assert.equal(typeof x.webhook, 'object');
|
||||
assert.deepEqual(x.config, {
|
||||
s: '[REDACTED]',
|
||||
n: 0,
|
||||
b: false,
|
||||
z: null,
|
||||
a: ['[REDACTED]', { n: 0 }],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid declared JSON and non-JSON bodies are non-json and discarded',async()=>{
|
||||
const invalid=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},health,async()=>new Response('{bad',{status:200,headers:{'content-type':'application/json'}}));
|
||||
assert.equal(invalid.statusCategory,'non-json');assert.deepEqual(invalid.payload,{error:'invalid-json'});
|
||||
const text=await collectOne({origin:'http://192.168.1.2',alias:'instance-1'},health,async()=>new Response('private body',{status:200,headers:{'content-type':'text/plain'}}));
|
||||
assert.equal(text.statusCategory,'non-json');assert.deepEqual(text.payload,{text:'[REDACTED]'});
|
||||
test('invalid declared JSON and non-JSON bodies are non-json and discarded', async () => {
|
||||
const invalid = await collectOne(
|
||||
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||
health,
|
||||
async () =>
|
||||
new Response('{bad', { status: 200, headers: { 'content-type': 'application/json' } }),
|
||||
);
|
||||
assert.equal(invalid.statusCategory, 'non-json');
|
||||
assert.deepEqual(invalid.payload, { error: 'invalid-json' });
|
||||
const text = await collectOne(
|
||||
{ origin: 'http://192.168.1.2', alias: 'instance-1' },
|
||||
health,
|
||||
async () =>
|
||||
new Response('private body', { status: 200, headers: { 'content-type': 'text/plain' } }),
|
||||
);
|
||||
assert.equal(text.statusCategory, 'non-json');
|
||||
assert.deepEqual(text.payload, { text: '[REDACTED]' });
|
||||
});
|
||||
|
||||
test('collector errors never echo rejected origin values',async()=>{
|
||||
const secret='http://user:password@evil.invalid/private?token=x';
|
||||
try{await collectOne({origin:secret,alias:'instance-1'},health,fakeResponse);assert.fail('expected rejection');}catch(error:any){assert.ok(!String(error.message).includes(secret));assert.ok(!String(error.message).includes('password'));}
|
||||
test('collector errors never echo rejected origin values', async () => {
|
||||
const secret = 'http://user:password@evil.invalid/private?token=x';
|
||||
try {
|
||||
await collectOne({ origin: secret, alias: 'instance-1' }, health, fakeResponse);
|
||||
assert.fail('expected rejection');
|
||||
} catch (error: any) {
|
||||
assert.ok(!String(error.message).includes(secret));
|
||||
assert.ok(!String(error.message).includes('password'));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user