test(fixtures): capture redacted SimAdmin responses

This commit is contained in:
chick
2026-07-16 06:35:28 +08:00
parent 9dffadbec2
commit c7da857aff
87 changed files with 9673 additions and 0 deletions
@@ -0,0 +1,40 @@
import { readFile, mkdir, writeFile, rm, readdir, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
import { normalizeConfig } from '../../../server/config/schema.js';
import { collectOne, selectReadonlyOperations, validateInstanceOrigin } from './collector.ts';
const here=path.dirname(fileURLToPath(import.meta.url));const pkg=path.resolve(here,'..');const repo=path.resolve(pkg,'../..');
export function loadInstances(config:any){
if(!config||!Array.isArray(config.instances)||config.instances.length!==2)throw new Error('config must contain exactly two instances');
for(const raw of config.instances){
if(!raw||typeof raw!=='object'||typeof raw.url!=='string')throw new Error('invalid collector instance configuration');
validateInstanceOrigin(raw.url);
if(raw.password||raw.auth?.password||!['none',undefined].includes(raw.auth?.mode))throw new Error('collector instances must be password-free');
}
const normalized=normalizeConfig(config,{HOST:'127.0.0.1',PORT:'8788'});
if(normalized.instances.length!==2)throw new Error('config must contain exactly two instances');
return normalized.instances.map((raw:any,i:number)=>{
if(raw.auth.mode!=='none'||raw.auth.password)throw new Error(`instance-${i+1} must be password-free`);
return {origin:validateInstanceOrigin(raw.url),alias:`instance-${i+1}`};
});
}
async function mapLimit<T,R>(xs:T[],limit:number,fn:(x:T)=>Promise<R>){const out:R[]=[];let n=0;async function worker(){while(n<xs.length){const i=n++;out[i]=await fn(xs[i]);}}await Promise.all(Array.from({length:Math.min(limit,xs.length)},worker));return out;}
async function allJson(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 allJson(p));else if(e.name.endsWith('.json'))out.push(p);}return out;}
function safeName(s:string){return s.replace(/[^a-zA-Z0-9_-]/g,'-');}
export async function main(argv=process.argv.slice(2)){
if(argv.length!==1||!['--dry-run','--capture'].includes(argv[0]))throw new Error('usage: collect-readonly-fixtures.ts --dry-run|--capture');
const config=JSON.parse(await readFile(path.join(repo,'config.json'),'utf8'));const instances=loadInstances(config);const {selected,denied}=selectReadonlyOperations(upstream58e2204Operations);
if(argv[0]==='--dry-run'){for(const op of selected)console.log(`registry ${op.operationId}`);console.log(`selected=${selected.length} denied=${denied.length}`);return;}
const shapePath=path.join(pkg,'src/response-shapes-58e2204.json');
// Separately reviewed: capture attests this baseline but never creates it.
const shapeBytes=await readFile(shapePath);
const root=path.join(pkg,'src/simadmin');await rm(root,{recursive:true,force:true});await rm(path.join(pkg,'src/manifest.json'),{force:true});await mkdir(root,{recursive:true});
const jobs=instances.flatMap(instance=>selected.map(op=>({instance,op})));const fixtures=await mapLimit(jobs,2,async({instance,op})=>{const f=await collectOne(instance,op);console.log(`${instance.alias} ${op.operationId} ${f.statusCategory}`);return {f,domain:op.upstreamDomain};});
for(const {f,domain} of fixtures){const dir=path.join(root,safeName(domain));await mkdir(dir,{recursive:true});await writeFile(path.join(dir,`${f.sourceInstanceAlias}--${f.operationId}.json`),JSON.stringify(f,null,2)+'\n',{flag:'wx'});}
const files=[];const coverage:Record<string,number>={};for(const full of await allJson(root)){const text=await readFile(full);const rel=path.relative(pkg,full);const domain=path.basename(path.dirname(full));coverage[domain]=(coverage[domain]||0)+1;files.push({path:rel,size:(await stat(full)).size,sha256:createHash('sha256').update(text).digest('hex')});}
files.sort((a,b)=>a.path.localeCompare(b.path));const shapeBaseline={path:'src/response-shapes-58e2204.json',size:shapeBytes.length,sha256:createHash('sha256').update(shapeBytes).digest('hex')};await writeFile(path.join(pkg,'src/manifest.json'),JSON.stringify({schemaVersion:1,upstreamBaseline:'58e2204',realFixtureCount:files.length,syntheticFixtureCount:0,domainCoverage:Object.fromEntries(Object.entries(coverage).sort()),shapeBaseline,files},null,2)+'\n');
}
if(process.argv[1]===fileURLToPath(import.meta.url))main().catch(()=>{console.error('collector failed');process.exitCode=1;});
@@ -0,0 +1,64 @@
import { redactOperationPayload } from '../src/redactor.ts';
import { upstream58e2204Operations } from '../../operation-registry/src/upstream-58e2204.ts';
export const UPSTREAM_BASELINE='58e2204';
export const DENY_REASONS:Record<string,string>={
'/api/network/operators/scan':'active radio/network scan',
'/api/connectivity':'handler performs active connectivity ping',
'/api/sms/list':'message list can expose body; intentionally not collected',
'/api/sms/conversation':'requires correspondent query and exposes message bodies',
'/api/call/history':'query limit is not encoded in Registry path contract',
'/api/notifications/logs':'query limit is not encoded in Registry path contract',
'/api/notifications/queue':'query limit is not encoded in Registry path contract',
'/api/automation/logs':'query limit is not encoded in Registry path contract',
'/api/device-network/ddns/logs':'no safe limit contract',
'/api/esim/euicc':'query-bearing endpoint omitted',
'/api/esim/profiles':'query-bearing endpoint omitted',
};
const registryById=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,op]));
// Capture the complete imported JSON contract before any caller can mutate it.
// Exact identity alone does not protect mutable Registry objects.
const registrySignatures=new Map(upstream58e2204Operations.map((op:any)=>[op.operationId,JSON.stringify(op)]));
function isPrivateLan(host:string){const p=host.split('.').map(Number);return p.length===4&&p.every(n=>Number.isInteger(n)&&n>=0&&n<=255)&&(p[0]===10||(p[0]===172&&p[1]>=16&&p[1]<=31)||(p[0]===192&&p[1]===168));}
export function validateInstanceOrigin(origin:string){
let u:URL;try{u=new URL(origin);}catch{throw new Error('invalid collector instance origin');}
if(!['http:','https:'].includes(u.protocol)||!isPrivateLan(u.hostname)||u.pathname!=='/'||u.search||u.hash||u.username||u.password)throw new Error('invalid collector instance origin');
return u.origin;
}
function validatePath(path:string){
if(typeof path!=='string'||!path.startsWith('/api/')||path.startsWith('//')||path.includes('\\')||/[?#%]/.test(path)||path.includes('//')) throw new Error('unsafe operation path');
let decoded=path; for(let i=0;i<2;i++){const next=decodeURIComponent(decoded);if(next!==decoded)throw new Error('encoded operation path');decoded=next;}
if(path.split('/').some(x=>x==='.'||x==='..'))throw new Error('dot segment operation path');
return path;
}
export function validateReadonlyOperation(op:any){
const exact=registryById.get(op?.operationId);
if(!exact||exact!==op||registrySignatures.get(op?.operationId)!==JSON.stringify(op))throw new Error('operation violates Registry integrity');
validatePath(op.pathTemplate);
if(op.method!=='GET'||op.riskLevel!=='R0'||/[{}]/.test(op.pathTemplate))throw new Error('operation is not readonly R0');
if(DENY_REASONS[op.pathTemplate])throw new Error('operation is explicitly denied');
return op;
}
export function selectReadonlyOperations(registry:any[]){
const candidates=registry.filter(o=>o.method==='GET'&&o.riskLevel==='R0'&&!/[{}]/.test(o.pathTemplate));
for(const op of candidates) validatePath(op.pathTemplate);
return {selected:candidates.filter(o=>!DENY_REASONS[o.pathTemplate]).map(validateReadonlyOperation),denied:candidates.filter(o=>DENY_REASONS[o.pathTemplate]).map(o=>({...o,denyReason:DENY_REASONS[o.pathTemplate]}))};
}
function latency(ms:number,timedOut=false){if(timedOut)return 'timeout';if(ms<250)return '<250ms';if(ms<1000)return '250ms-1s';if(ms<3000)return '1-3s';return '>3s';}
function statusCategory(status:number,isJson:boolean){if(status===401||status===403)return 'auth-required';if(status===404||status===405||status===501)return 'unsupported';if(status>=200&&status<300)return isJson?'success':'non-json';return 'http-error';}
export async function collectOne(instance:{origin:string,alias:string},op:any,transport:any=fetch){
validateReadonlyOperation(op);
if(!['instance-1','instance-2'].includes(instance?.alias))throw new Error('invalid instance alias');
const origin=validateInstanceOrigin(instance?.origin);
const requestUrl=new URL(op.pathTemplate,origin);
if(requestUrl.origin!==origin||requestUrl.pathname!==op.pathTemplate||requestUrl.search||requestUrl.hash)throw new Error('unsafe collector request URL');
const started=Date.now();const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),6000);
const base={schemaVersion:1,upstreamBaseline:UPSTREAM_BASELINE,capturedAt:new Date().toISOString().slice(0,10),sourceInstanceAlias:instance.alias,operationId:op.operationId,method:'GET',pathTemplate:op.pathTemplate,redacted:true};
try{
validateReadonlyOperation(op);
const response=await transport(requestUrl,{method:'GET',headers:{accept:'application/json'},body:undefined,credentials:'omit',redirect:'manual',signal:controller.signal});
const contentType=(response.headers.get('content-type')||'').split(';')[0].trim().toLowerCase()||null;const claimsJson=contentType==='application/json'||contentType?.endsWith('+json');
let parsedJson=false;let payload:any;if(claimsJson){try{payload=await response.json();parsedJson=true;}catch{payload={error:'invalid-json'};}}else{await response.text();payload={text:'[REDACTED]'};}
return {...base,statusCategory:statusCategory(response.status,parsedJson),httpStatus:response.status,contentType,latencyBucket:latency(Date.now()-started),payload:redactOperationPayload(payload,op)};
}catch(error:any){if(String(error?.message||'').includes('Registry')||String(error?.message||'').includes('operation'))throw error;const timeout=error?.name==='AbortError';return {...base,statusCategory:timeout?'timeout':'network-error',httpStatus:null,contentType:null,latencyBucket:latency(Date.now()-started,timeout),payload:{error:timeout?'timeout':'network-error'}};}finally{clearTimeout(timer);}
}