assistant/tools/learning/test-request-diagnostics.mjs

208 lines
19 KiB
JavaScript

// Request diagnostics regression: official URLs are pure metadata only.
// Every browser navigation and page request uses our own isolated loopback page.
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import path from 'node:path';
import {createServer} from 'node:http';
import {fileURLToPath} from 'node:url';
import {setTimeout as delay} from 'node:timers/promises';
import {beginSearchRequestPolicy,beginNoteOpenRequestPolicy,beginReadonlyRequestPolicy,
evaluatePlatformRequest,matchPlatformRequest,endPlatformRequestPolicy,
SEARCH_POLICY_VERSION,NOTE_POLICY_VERSION,READONLY_POLICY_VERSION,SEARCH_SCROLL_POLICY_VERSION} from './platform-policy.mjs';
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'../..');
await fs.mkdir(path.join(root,'.tmp'),{recursive:true});
const output=await fs.mkdtemp(path.join(root,'.tmp','request-diagnostics-'));
process.env.ASSISTANT_LEARNING_HOME=path.join(output,'learning');
process.env.ASSISTANT_BROWSER_CHANNEL=process.platform==='win32' ? 'msedge' : 'chromium';
process.env.ASSISTANT_BROWSER_HEADLESS='1';
const {dispatch,verify}=await import('./browser-worker.mjs');
const results=[],tasks=new Set(),sockets=new Set();
let assertions=0,fixtureRequests=0,fixtureWrites=0,fontRequests=0,serial=0;
const eq=(actual,expected,message)=>{assert.deepEqual(actual,expected,message);assertions+=1;};
const ok=(actual,message)=>{assert.ok(actual,message);assertions+=1;};
const save=()=>fs.writeFile(path.join(output,'report.json'),JSON.stringify({passed:results.length,assertions,results,
live_platform_requests:0,official_browser_navigation:false,application_api_calls:0,model_calls:0,
fixture_requests:fixtureRequests,fixture_writes:fixtureWrites,font_requests:fontRequests},null,2));
const passed=async(name,details={})=>{results.push({name,...details});await save();};
const official='https://www.xiaohongshu.com',pageUrl=official+'/search_result_ai?keyword=synthetic';
const now=10_000,originPage=value=>{try{const url=new URL(value);return url.origin===official && !url.username && !url.password;}catch{return false;}};
// Independent copy of the pre-diagnostic matcher. Rules and authorization
// tracking are test-owned, so this does not use the new evaluator as its oracle.
const baselineRules={
search:[['collect','https://t2.xiaohongshu.com','/api/v2/collect','xhr'],['search_history','https://so.xiaohongshu.com','/api/sns/web/search/history/sync','xhr'],['search_notes','https://so.xiaohongshu.com','/api/sns/web/v2/search/notes','xhr'],['search_onebox','https://edith.xiaohongshu.com','/api/sns/web/v1/search/onebox','xhr'],['search_dqa','https://so.xiaohongshu.com','/api/sns/web/v1/dqa/instant','xhr'],['apm_data','https://apm-fe.xiaohongshu.com','/api/data','fetch'],['apm_data_xhr','https://apm-fe.xiaohongshu.com','/api/data','xhr']],
note_open:[['collect','https://t2.xiaohongshu.com','/api/v2/collect','xhr'],['note_share_code','https://edith.xiaohongshu.com','/api/sns/web/share/code','xhr'],['note_widgets','https://edith.xiaohongshu.com','/api/sns/web/v2/widgets','xhr'],['note_feed','https://edith.xiaohongshu.com','/api/sns/web/v1/feed','xhr'],['note_seo','https://edith.xiaohongshu.com','/api/sns/web/worldcup/note/seo','xhr'],['note_metrics','https://edith.xiaohongshu.com','/api/sns/web/v1/note/metrics_report','xhr'],['note_history_report','https://edith.xiaohongshu.com','/api/sns/v1/history/report_web','xhr'],['apm_data','https://apm-fe.xiaohongshu.com','/api/data','fetch']],
readonly_ui:[['collect','https://t2.xiaohongshu.com','/api/v2/collect','xhr'],['apm_data','https://apm-fe.xiaohongshu.com','/api/data','fetch']],
search_scroll:[['collect','https://t2.xiaohongshu.com','/api/v2/collect','xhr'],['apm_data','https://apm-fe.xiaohongshu.com','/api/data','fetch'],['search_notes','https://so.xiaohongshu.com','/api/sns/web/v2/search/notes','xhr']],
};
const issued=new WeakMap();
const remember=policy=>{ok(policy,'fixture mints an actual issued policy');issued.set(policy,{pageUrl,startsAt:now});return policy;};
function baselineMatch({policy,automating,sessionOrigin,pageUrl:page,activeMainFrame,method,resourceType,url,now:time}){
if(!policy || !issued.has(policy) || time>policy.expiresAt || !Number.isFinite(time) || automating!==true || sessionOrigin!==official || !originPage(page) || activeMainFrame!==true || method!=='POST')return null;
if(policy.scope==='search_scroll'){const bound=issued.get(policy);if(page!==bound.pageUrl || time<bound.startsAt || time>=policy.expiresAt)return null;}
let parsed;try{parsed=new URL(url);}catch{return null;}
if(parsed.protocol!=='https:' || parsed.username || parsed.password)return null;
const rule=(baselineRules[policy.scope] || []).find(([,origin,pathname,type])=>parsed.origin===origin && parsed.pathname===pathname && resourceType===type);
return rule ? {policy_version:policy.version,policy_rule:rule[0]} : null;
}
const input={kind:'fill',tag:'input',unique:true,search_input:true,sensitive:false};
const inputActual={tag:'input',search_input:true,sensitive:false,visible:true,in_viewport:true,unobscured:true};
const link=official+'/explore/'+'a'.repeat(24),observedLink={kind:'click',tag:'a',unique:true,sensitive:false};
const ordinary={tag:'section',readonly_root:true,sensitive:false,forbidden:false,visible:true,in_viewport:true};
const readContext={sessionOrigin:official,pageUrl,kind:'wait',selector:'#global',selectorObserved:true,
observed:{kind:'read',tag:'section',unique:true,sensitive:false},actualRoots:[ordinary],now};
const policies=[
remember(beginSearchRequestPolicy({sessionOrigin:official,pageUrl,kind:'fill',observed:input,actual:inputActual,matchCount:1,now})),
remember(beginNoteOpenRequestPolicy({sessionOrigin:official,pageUrl,kind:'click',observed:observedLink,actual:{...observedLink,visible:true,in_viewport:true,unobscured:true,href:link},matchCount:1,href:link,now})),
remember(beginReadonlyRequestPolicy(readContext)),
remember(beginReadonlyRequestPolicy({...readContext,kind:'scroll',scrollScope:'viewport',untilTarget:{selector:'#global a.title',field:'text',value:'Synthetic title'}})),
];
const request={policy:policies[2],automating:true,sessionOrigin:official,pageUrl,activeMainFrame:true,method:'POST',resourceType:'xhr',url:'https://t2.xiaohongshu.com/api/v2/collect',now:now+1};
const deny=(patch,reason,extra={})=>{
const context={...request,...patch};
eq(evaluatePlatformRequest(context),{allowed:false,reason,...(context.policy && issued.has(context.policy) ? {policy_version:context.policy.version} : {}),...extra});
eq(matchPlatformRequest(context),null);
};
const server=createServer((req,res)=>{
fixtureRequests+=1;
if(req.method!=='GET'){fixtureWrites+=1;res.writeHead(405).end();return;}
const url=new URL(req.url,'http://127.0.0.1');
if(url.pathname==='/pending-font'){fontRequests+=1;return;}
if(url.pathname!=='/fixture'){res.writeHead(404).end();return;}
const mode=url.searchParams.get('mode');
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8'});
res.end(`<!doctype html><html><head><title>Local request diagnostic fixture</title><style>body{margin:16px;font:18px sans-serif}#global{min-height:100px;width:620px;border:1px solid #888;padding:8px}.title{display:block;margin:8px}</style></head><body><p id="detail">Already read synthetic detail</p><section id="global">Synthetic result container${['success','final-font'].includes(mode) ? '<a class="title" href="/?item=1">Synthetic title one</a><a class="title" href="/?item=2">Synthetic title two</a>' : ''}</section><script>
const mode=${JSON.stringify(mode)},root=document.querySelector('#global'),query=root.querySelectorAll.bind(root);let once=false;
root.querySelectorAll=function(selector){
if(!once && selector==='#global a.title'){
once=true;
if(mode==='blocked')for(let i=0;i<16;i++)fetch('/unknown-write/'+i+'?token=LOCAL_PRIVATE_QUERY',{method:'POST',headers:{'X-Test-Private':'LOCAL_PRIVATE_HEADER'},body:'LOCAL_PRIVATE_BODY'}).catch(()=>{});
if(mode==='poll-dom-hang'){const until=Date.now()+7000;while(Date.now()<until){}}
if(mode==='final-font'){const font=new FontFace('diagnostic-wait','url(/pending-font)');document.fonts.add(font);font.load().catch(()=>{});const span=document.createElement('span');span.textContent='Pending final screenshot';span.style.fontFamily='diagnostic-wait';document.body.append(span);}
}
return query(selector);
};
</script></body></html>`);
});
server.on('connection',socket=>{sockets.add(socket);socket.once('close',()=>sockets.delete(socket));});
let listening=false;
const open=async mode=>{
const task='request-diagnostics-'+(++serial),url=`http://127.0.0.1:${server.address().port}/fixture?mode=${mode}`;
tasks.add(task);
await dispatch('open',{task_id:task,account_key:'isolated-request-diagnostics',entry_url:url});
await dispatch('ready',{task_id:task,identity_label:'Local request diagnostic fixture'});
await dispatch('capture',{task_id:task});return {task,url};
};
const close=async task=>{await dispatch('close',{task_id:task});tasks.delete(task);};
const waiting={kind:'wait',selector:'#global a.title',wait_scope:'#global',state:'visible',min_count:2,timeout_ms:8000};
const read={kind:'read',selector:'#detail',output_name:'detail'};
const noPrivate=value=>{const text=JSON.stringify(value);for(const sentinel of ['LOCAL_PRIVATE_QUERY','LOCAL_PRIVATE_BODY','LOCAL_PRIVATE_HEADER'])eq(text.includes(sentinel),false,'diagnostics omit query, body and headers');};
const png=async capture=>{ok(capture?.screenshot_path,'actual capture exists');const bytes=await fs.readFile(capture.screenshot_path);eq(bytes.subarray(1,4).toString(),'PNG');};
const unchanged=async value=>{const before=JSON.stringify(value);await delay(180);eq(JSON.stringify(value),before,'returned failure and evidence remain immutable after late callbacks');};
const blockedSummary=diagnostics=>{
eq(diagnostics.phase,'execute');eq(diagnostics.blocked_request_count,16);eq(diagnostics.blocked_requests.length,12,'bounded unique diagnostic requests');
for(const summary of diagnostics.blocked_requests){eq(summary.policy_reason,'missing_policy');eq(summary.active_policy_version,undefined);eq(summary.expected_resource_types,undefined);}
noPrivate(diagnostics);
};
try{
eq(policies.map(policy=>policy.version),[SEARCH_POLICY_VERSION,NOTE_POLICY_VERSION,READONLY_POLICY_VERSION,SEARCH_SCROLL_POLICY_VERSION]);
const endpoints=[...new Set(Object.values(baselineRules).flat().map(([,origin,pathname])=>origin+pathname)),
'https://as.xiaohongshu.com/api/sec/v1/shield/webprofile','https://example.invalid/unknown-write','http://t2.xiaohongshu.com/api/v2/collect',
'https://t2.xiaohongshu.com.evil.invalid/api/v2/collect','https://t2.xiaohongshu.com/api/v2/collect/','https://user:pass@t2.xiaohongshu.com/api/v2/collect','invalid'];
let comparisons=0;
for(const policy of policies){
const allowedRules=new Set();
for(const url of endpoints)for(const resourceType of ['xhr','fetch','document','other'])for(const method of ['POST','GET','PUT'])for(const time of [now-1,now+1,now+15000,now+15001]){
const context={...request,policy,url,resourceType,method,now:time};
const expected=baselineMatch(context);eq(matchPlatformRequest(context),expected,'pre-diagnostic allow behavior is unchanged');
const decision=evaluatePlatformRequest(context);eq(decision.allowed,expected!==null);
if(expected){eq(decision,{allowed:true,...expected});if(time===now+1)allowedRules.add(decision.policy_rule);}
comparisons+=1;
}
eq([...allowedRules].sort(),baselineRules[policy.scope].map(rule=>rule[0]).sort(),'the exact original scope rule set is retained');
}
await passed('independent pre-change matcher equivalence across all four scopes',{comparisons,rules:[7,8,2,3]});
deny({policy:null},'missing_policy');deny({policy:{...policies[2]}},'inactive_policy');
deny({now:NaN},'invalid_time');deny({now:now+15001},'policy_expired');deny({automating:false},'automation_inactive');
deny({sessionOrigin:'https://example.invalid'},'session_out_of_scope');deny({pageUrl:'https://example.invalid'},'page_out_of_scope');
deny({activeMainFrame:false},'not_main_frame');deny({method:'PUT'},'method_not_allowed');
deny({policy:policies[3],pageUrl:pageUrl+'&changed=yes'},'page_changed');deny({policy:policies[3],now:now-1},'policy_not_started');
deny({policy:policies[3],now:now+15000},'policy_expired');deny({url:'not-a-url'},'invalid_url');deny({url:'http://t2.xiaohongshu.com/api/v2/collect'},'invalid_url');
deny({url:'https://user:pass@t2.xiaohongshu.com/api/v2/collect'},'invalid_url');
deny({url:'https://as.xiaohongshu.com/api/sec/v1/shield/webprofile'},'request_out_of_scope');
deny({url:'https://apm-fe.xiaohongshu.com/api/data'},'resource_type_not_allowed',{expected_resource_types:['fetch']});
const privateRequest={...request,url:request.url+'?token=LOCAL_PRIVATE_QUERY#LOCAL_PRIVATE_QUERY'};
Object.defineProperties(privateRequest,{headers:{get(){throw new Error('headers must not be inspected');}},body:{get(){throw new Error('body must not be inspected');}}});
eq(evaluatePlatformRequest(privateRequest),{allowed:true,policy_version:READONLY_POLICY_VERSION,policy_rule:'collect'});noPrivate(evaluatePlatformRequest(privateRequest));
const revoked=remember(beginReadonlyRequestPolicy(readContext));endPlatformRequestPolicy(revoked);issued.delete(revoked);
deny({policy:revoked},'inactive_policy');
await passed('all fixed rejection reasons, inactive-policy privacy and private input exclusion');
const saved=await fs.readFile(path.join(root,'.tmp','peer-scoped-loading-evidence.json'),'utf8').then(JSON.parse).catch(error=>{if(error.code==='ENOENT')return null;throw error;});
if(saved){
const requests=[...new Map(saved.flatMap(item=>item.request_observations || []).filter(item=>item.blocked).map(item=>[item.sequence,item])).values()];
eq(requests.length,2,'preserved run contains two distinct blocked requests');
for(const observed of requests){const decision=evaluatePlatformRequest({...request,method:observed.method,resourceType:observed.resource_type,url:observed.origin+observed.pathname});
eq(decision.allowed,false);eq(decision.reason,observed.resource_type==='xhr' && observed.origin==='https://apm-fe.xiaohongshu.com' ? 'resource_type_not_allowed' : 'request_out_of_scope');}
await passed('preserved actual request metadata remains rejected under unchanged policy',{synthetic_metadata_only:true});
}
if(process.argv.includes('--pure')){
await save();console.log(JSON.stringify({passed:results.length,assertions,output,pure_only:true,live_platform_requests:0,application_api_calls:0,model_calls:0}));
}else{
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});listening=true;
const actionFixture=await open('blocked');let actionError;
try{await dispatch('action',{task_id:actionFixture.task,...waiting});}catch(error){actionError=error;}
eq(actionError?.code,'WRITE_REQUEST_BLOCKED');blockedSummary(actionError.diagnostics);
const observations=actionError.after.request_observations.filter(item=>item.blocked);
eq(observations.length,16);for(const observed of observations)eq(observed.policy_reason,'missing_policy');
for(const capture of [actionError.before,actionError.after])await png(capture);
const actionRecord={code:actionError.code,diagnostics:actionError.diagnostics,before:actionError.before,after:actionError.after};
noPrivate(actionRecord);await unchanged(actionRecord);await fs.writeFile(path.join(output,'action-blocked.json'),JSON.stringify(actionRecord,null,2));await close(actionFixture.task);
await passed('real loopback action refusal preserves bounded reasons and request observation diagnostics');
const failing=await open('blocked');
const result=await verify({task_id:failing.task,entry_url:failing.url,parameters:{},steps:[read,waiting],expected_outputs:{detail:'Already read synthetic detail'}});
eq(result.passed,false);eq(result.error_codes,['WRITE_REQUEST_BLOCKED']);eq(result.failure_diagnostics.step_position,2);eq(result.failure_diagnostics.kind,'wait');eq(result.failure_diagnostics.code,'WRITE_REQUEST_BLOCKED');
blockedSummary(result.failure_diagnostics.diagnostics);ok(result.outputs.detail?.[0]?.text.includes('Already read synthetic detail'));
for(const capture of result.evidence)await png(capture);
noPrivate(result);await unchanged(result);await fs.writeFile(path.join(output,'verify-blocked.json'),JSON.stringify(result,null,2));await close(failing.task);
await passed('verify captures actual failed step, bounded reasons, real evidence and completed prefix');
const success=await open('success');
const completed=await verify({task_id:success.task,entry_url:success.url,parameters:{},steps:[read,waiting],expected_outputs:{detail:'Already read synthetic detail'}});
eq(completed.passed,true,JSON.stringify(completed.errors));eq(Object.hasOwn(completed,'failure_diagnostics'),false,'success never fabricates a failure diagnostic');
await fs.writeFile(path.join(output,'verify-success.json'),JSON.stringify(completed,null,2));await close(success.task);
await passed('successful verification has no failure_diagnostics field');
for(const mode of ['poll-dom-hang','final-font']){
const fixture=await open(mode),started=Date.now();
const cutoff=await verify({task_id:fixture.task,entry_url:fixture.url,parameters:{},steps:[read,waiting],expected_outputs:{detail:'Already read synthetic detail'}},{budgetMs:3500,cleanupReserveMs:500});
const elapsed=Date.now()-started;
eq(cutoff.passed,false);eq(cutoff.error_codes,['VERIFY_BUDGET']);eq(cutoff.session_closed,true);eq((await dispatch('status',{task_id:fixture.task})).status,'closed');
eq(cutoff.failure_diagnostics.step_position,2);eq(cutoff.failure_diagnostics.kind,'wait');eq(cutoff.failure_diagnostics.code,'VERIFY_BUDGET');
eq(cutoff.failure_diagnostics.diagnostics.phase,mode==='final-font' ? 'capture_after' : 'execute');
ok(elapsed<6000,'diagnostic cloning adds no post-deadline browser wait');
ok(cutoff.step_timings.at(-1).phase_ms[mode==='final-font' ? 'capture_after' : 'execute']>0);
if(mode==='final-font')ok(fontRequests>0,'real final capture waits for controlled local font');
for(const capture of cutoff.evidence)await png(capture);
await unchanged(cutoff);await fs.writeFile(path.join(output,mode+'.json'),JSON.stringify(cutoff,null,2));await close(fixture.task);
await passed(mode+' diagnostics remain inside the actual global cutoff',{elapsed_ms:elapsed});
}
eq(fixtureWrites,0,'no unknown POST reaches our fixture server');await save();
console.log(JSON.stringify({passed:results.length,assertions,output,live_platform_requests:0,application_api_calls:0,model_calls:0,fixture_writes:fixtureWrites}));
}
}catch(error){
await save();const report=JSON.parse(await fs.readFile(path.join(output,'report.json'),'utf8'));
report.error={message:error.message,code:error.code,stack:error.stack};await fs.writeFile(path.join(output,'report.json'),JSON.stringify(report,null,2));
console.error(JSON.stringify({passed:results.length,assertions,output,error:error.message,code:error.code}));process.exitCode=1;
}finally{
for(const policy of policies)endPlatformRequestPolicy(policy);
await Promise.allSettled([...tasks].map(task=>dispatch('close',{task_id:task})));
for(const socket of sockets)socket.destroy();
if(listening)await new Promise(resolve=>server.close(resolve));
}