Files

91 lines
7.7 KiB
TypeScript

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 { 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'}});
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('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('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('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('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'));}
});