61 lines
5.9 KiB
JavaScript
61 lines
5.9 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import http from 'node:http';
|
|
import {generateKeyPairSync} from 'node:crypto';
|
|
import channel from '../packages/core/policy-channel.cjs';
|
|
import control from '../apps/browser/agent-control.cjs';
|
|
import transport from '../apps/browser/agent-transport-client.cjs';
|
|
const input={action:'bookmarks:mutate',args:{action:'delete',id:'fixture',expectedRevision:7,policyVersion:9}};
|
|
const busy=()=>Object.assign(Error('Management update in progress. Try again.'),{status:409,code:'browser-management-busy'});
|
|
async function fixture(t,fn,authorize=async({scope})=>({allowed:true,role:'agent',scopes:[scope]})){
|
|
let attempts=0,mutations=0,challenges=0;const seen=[];
|
|
const api=await control.startAgentControl({authorize,audit:async()=>{},traceOperation:async(name,run)=>{if(name==='agent.challenge')challenges++;return run();},actions:{'bookmarks:mutate':async args=>{attempts++;seen.push(args);await fn(attempts);mutations++;return {completed:true};}}});
|
|
t.after(()=>api.close());return {api,stats:()=>({attempts,mutations,challenges,seen})};
|
|
}
|
|
test('authenticated busy retries use fresh challenges and preserve CAS inputs, mutation executes once',async t=>{
|
|
const x=await fixture(t,n=>{if(n<=2)throw busy();});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,200);
|
|
const s=x.stats();assert.equal(s.attempts,3);assert.equal(s.mutations,1);assert.equal(s.challenges,3);for(const args of s.seen)assert.deepEqual(args,input.args);
|
|
});
|
|
test('busy retries stop after three retries',async t=>{
|
|
const x=await fixture(t,()=>{throw busy();});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,409);assert.equal((await response.json()).code,'browser-management-busy');
|
|
assert.equal(x.stats().attempts,4);assert.equal(x.stats().challenges,4);assert.equal(x.stats().mutations,0);
|
|
});
|
|
test('CAS conflicts and denial never retry',async t=>{
|
|
const x=await fixture(t,()=>{throw Object.assign(Error('Bookmarks changed. Refresh before editing.'),{status:409});});assert.equal((await transport.requestAgent(x.api,input)).status,409);assert.equal(x.stats().attempts,1);
|
|
const denied=await fixture(t,()=>{},async()=>({allowed:false}));assert.equal((await transport.requestAgent(denied.api,input)).status,403);assert.equal(denied.stats().challenges,1);assert.equal(denied.stats().attempts,0);
|
|
});
|
|
test('unauthenticated plaintext busy code cannot trigger retry',async t=>{
|
|
let calls=0;const server=http.createServer((req,res)=>{calls++;res.writeHead(409,{'content-type':'application/json'});res.end(JSON.stringify({error:'Management update in progress. Try again.',code:'browser-management-busy'}));});await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r)));
|
|
const response=await transport.requestAgent({address:`http://127.0.0.1:${server.address().port}`,token:'synthetic',protocol:'safetynet-policy-mlkem1024-mldsa87-v1'},input);assert.equal(response.status,409);assert.equal(calls,1);assert.equal((await response.json()).code,undefined);
|
|
});
|
|
|
|
test('busy-looking message or code alone does not authorize retry',async t=>{
|
|
for(const error of [Object.assign(Error('Management update in progress. Try again.'),{status:409}),Object.assign(Error('Unrelated conflict'),{status:409,code:'browser-management-busy'})]){
|
|
const x=await fixture(t,()=>{throw error;});const response=await transport.requestAgent(x.api,input);assert.equal(response.status,409);assert.equal((await response.json()).code,undefined);assert.equal(x.stats().attempts,1);
|
|
}
|
|
});
|
|
|
|
async function rewriteOuterStatus(api,status,run){
|
|
const original=globalThis.fetch;
|
|
globalThis.fetch=async(url,options)=>{const response=await original(url,options);if(String(url)===api.address+'/v1/actions')return new Response(await response.text(),{status,headers:{'content-type':'application/json'}});return response;};
|
|
try{return await run();}finally{globalThis.fetch=original;}
|
|
}
|
|
test('authenticated busy 409 still retries when outer HTTP status is changed to 200',async t=>{
|
|
const x=await fixture(t,n=>{if(n<=2)throw busy();});
|
|
const response=await rewriteOuterStatus(x.api,200,()=>transport.requestAgent(x.api,input));assert.equal(response.status,200);assert.equal(x.stats().attempts,3);assert.equal(x.stats().mutations,1);
|
|
});
|
|
test('authenticated success 200 never retries or fails when outer HTTP status is changed to 409',async t=>{
|
|
const x=await fixture(t,()=>{});const response=await rewriteOuterStatus(x.api,409,()=>transport.requestAgent(x.api,input));assert.equal(response.ok,true);assert.equal(response.status,200);assert.equal(x.stats().attempts,1);assert.equal(x.stats().mutations,1);
|
|
});
|
|
test('missing or invalid authenticated status refuses the encrypted response',async t=>{
|
|
const ed=generateKeyPairSync('ed25519'),pq=generateKeyPairSync('ml-dsa-87');let item,status;
|
|
const server=http.createServer(async(req,res)=>{
|
|
const chunks=[];for await(const chunk of req)chunks.push(chunk);res.writeHead(200,{'content-type':'application/json'});
|
|
if(req.url==='/v1/challenge'){item=channel.challenge('local-agent',ed.privateKey,pq.privateKey);res.end(JSON.stringify(item.bundle));return;}
|
|
const wire=JSON.parse(Buffer.concat(chunks)),context=channel.receive(wire.handshake,item.bundle,item.privateKey);
|
|
try{res.end(JSON.stringify(channel.response({kind:'agent-response',...(status===undefined?{}:{status}),result:{completed:true}},item.bundle,context)));}finally{context.responseKey.fill(0);}
|
|
});await new Promise(r=>server.listen(0,'127.0.0.1',r));t.after(()=>new Promise(r=>server.close(r)));
|
|
const credential={address:`http://127.0.0.1:${server.address().port}`,token:'synthetic',protocol:channel.VERSION,edPublic:ed.publicKey.export({type:'spki',format:'pem'}),pqPublic:pq.publicKey.export({type:'spki',format:'pem'})};
|
|
for(const invalid of [undefined,199,600,409.5,'409']){status=invalid;await assert.rejects(transport.requestAgent(credential,input),/Protected agent response refused/);}
|
|
});
|