Stabilize diagnostic export and preserve authorized policy continuity

This commit is contained in:
Karl committed 2026-10-14 01:18:23 +02:00
1 parent 0a5df7398e
commit 09bf616d98
18 files changed
+364 -57

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
import test from 'node:test';import assert from 'node:assert/strict';
import {mkdtempSync,rmSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';
import {protectCollectorDirectory} from '../scripts/debug-collector-acl.mjs';
test('ACL proof rejects process failures and unverified or malformed success without exposing errors',()=>{
for(const result of [{status:1,stdout:'{"verified":true,"changed":false}',stderr:'private path/account'},{status:0,stdout:'{"verified":false,"changed":false}'},{status:0,stdout:'{}'},{status:0,stdout:'private error'}])assert.throws(()=>protectCollectorDirectory('unused',{run:()=>result}),error=>error.message.startsWith('Collector directory protection')&&!error.message.includes('private'));
});
test('actual Windows exact private ACL is verified without being reapplied on restart',{skip:process.platform!=='win32'},t=>{
const directory=mkdtempSync(join(tmpdir(),'safetynet-collector-acl-'));t.after(()=>{assert.ok(resolve(directory).startsWith(resolve(tmpdir())));rmSync(directory,{recursive:true,force:true});});
const initial=protectCollectorDirectory(directory);assert.equal(initial.verified,true);
assert.deepEqual(protectCollectorDirectory(directory),{verified:true,changed:false});
});
+24
View File
@@ -0,0 +1,24 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {collectorAdmission} from '../scripts/debug-collector-admission.mjs';
test('unfinished requests and accumulated body bytes have independent global bounds',()=>{
const gate=collectorAdmission({maxRequests:2,maxBufferedBytes:16,maxRequestBytes:12}),a=gate.acquire(),b=gate.acquire();assert.equal(gate.acquire(),null);
assert.equal(a.add(10),null);assert.equal(b.add(7),'buffer-limit');assert.equal(b.add(6),null);assert.equal(a.add(3),'body-limit');
assert.equal(gate.summary().activeRequests,2);assert.equal(gate.summary().bufferedBytes,16);a.release();a.release();assert.equal(gate.summary().bufferedBytes,6);b.release();
assert.equal(gate.summary().activeRequests,0);assert.equal(gate.summary().bufferedBytes,0);assert.equal(gate.summary().requestLimitRejected,1);assert.equal(gate.summary().bufferLimitRejected,1);assert.equal(gate.summary().bodyLimitRejected,1);
});
test('commit jobs serialize and acknowledge only after the held commit completes',async()=>{
const gate=collectorAdmission({maxRequests:2}),a=gate.acquire(),b=gate.acquire();let release;const held=new Promise(resolve=>{release=resolve;}),steps=[];
const first=a.enqueue(async()=>{steps.push('first-start');await held;steps.push('first-committed');return 'committed';});const second=b.enqueue(()=>{steps.push('second-committed');return 'second';});
await new Promise(resolve=>setImmediate(resolve));assert.deepEqual(steps,['first-start']);assert.equal(gate.summary().queuedCommits,1);release();assert.equal(await first,'committed');assert.equal(await second,'second');assert.deepEqual(steps,['first-start','first-committed','second-committed']);
a.release();b.release();const summary=gate.summary();assert.equal(summary.commits,2);assert.equal(summary.commitFailures,0);assert.ok(summary.maxQueueWaitMs>=0);assert.ok(summary.maxCommitMs>=0);
});
test('aborted queued requests cannot grow the waiting queue or execute cancelled work',async()=>{
const scheduled=[],gate=collectorAdmission({maxRequests:1,defer:fn=>scheduled.push(fn)});
for(let n=0;n<100;n++){const lease=gate.acquire();const rejected=assert.rejects(lease.enqueue(()=>assert.fail('Cancelled batch committed')));lease.release();await rejected;assert.equal(gate.summary().queuedCommits,0);}
for(const run of scheduled)await run();assert.equal(gate.summary().activeRequests,0);assert.equal(gate.summary().commits,0);
});
test('failed commits stay failed and record bounded numeric timings without raw errors',async()=>{
const gate=collectorAdmission(),lease=gate.acquire();await assert.rejects(lease.enqueue(()=>{throw Error('private-body');}));lease.release();const summary=gate.summary();assert.equal(summary.commits,0);assert.equal(summary.commitFailures,1);assert.equal(JSON.stringify(summary).includes('private-body'),false);
});
+12 -2
View File
@@ -1,7 +1,7 @@
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,rmSync,readdirSync,readFileSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';import {spawn} from 'node:child_process';import {createServer} from 'node:net';
import test from 'node:test';import assert from 'node:assert/strict';import {mkdtempSync,rmSync,readdirSync,readFileSync} from 'node:fs';import {tmpdir} from 'node:os';import {join,resolve} from 'node:path';import {spawn} from 'node:child_process';import {createServer} from 'node:net';import {request} from 'node:http';
test('actual Windows protected collector rotates encrypted exports and rejects oversized storage without acknowledgement',{skip:process.platform!=='win32'},async t=>{
const root=mkdtempSync(join(tmpdir(),'safetynet-collector-http-'));const probe=createServer();await new Promise(r=>probe.listen(0,'127.0.0.1',r));const port=probe.address().port;await new Promise(r=>probe.close(r));
const child=spawn(process.execPath,[resolve('scripts/debug-collector.mjs')],{windowsHide:true,stdio:'pipe',env:{...process.env,DEBUG_COLLECTOR_DIRECTORY:root,DEBUG_COLLECTOR_PORT:String(port),DEBUG_COLLECTOR_SEGMENT_BYTES:'1024',DEBUG_COLLECTOR_RETIRED_SEGMENTS:'1'}});
let child=spawn(process.execPath,[resolve('scripts/debug-collector.mjs')],{windowsHide:true,stdio:'pipe',env:{...process.env,DEBUG_COLLECTOR_DIRECTORY:root,DEBUG_COLLECTOR_PORT:String(port),DEBUG_COLLECTOR_SEGMENT_BYTES:'1024',DEBUG_COLLECTOR_RETIRED_SEGMENTS:'1',DEBUG_COLLECTOR_MAX_REQUESTS:'2',DEBUG_COLLECTOR_MAX_BUFFERED_BYTES:'4096'}});
let output='';child.stdout.on('data',b=>output+=b);child.stderr.on('data',b=>output+=b);t.after(async()=>{if(child.exitCode===null){child.kill();await new Promise(r=>child.once('exit',r));}assert.ok(root.startsWith(join(tmpdir(),'safetynet-collector-http-')));rmSync(root,{recursive:true,force:true});});
const base=`http://127.0.0.1:${port}`;let health;
for(let i=0;i<80;i++){if(child.exitCode!==null)throw Error('Collector test process failed: '+output);try{health=await (await fetch(base+'/health')).json();break;}catch{await new Promise(r=>setTimeout(r,100));}}
@@ -9,6 +9,12 @@ test('actual Windows protected collector rotates encrypted exports and rejects o
for(let i=0;i<12;i++)assert.equal((await post({resourceLogs:[{scopeLogs:[{logRecords:[{body:{stringValue:marker}}]}]}]})).status,200);
health=await (await fetch(base+'/api/summary')).json();assert.equal(health.requests,12);assert.equal(health.counts.logs,12);assert.ok(health.rotations>1);assert.ok(health.droppedSegments>0);assert.equal(health.retiredSegments,1);assert.ok(health.activeBytes+health.retiredBytes<=2048);
for(const name of readdirSync(root).filter(n=>n.endsWith('.ndjson'))){const content=readFileSync(join(root,name),'utf8');assert.equal(content.includes(marker),false);for(const line of content.trim().split('\n')){const envelope=JSON.parse(line);assert.equal(envelope.v,1);assert.ok(envelope.nonce&&envelope.tag&&envelope.ciphertext);}}
const waitAdmission=async check=>{for(let n=0;n<80;n++){const value=await (await fetch(base+'/health')).json();if(check(value.admission))return value;await new Promise(resolve=>setTimeout(resolve,10));}throw Error('Collector admission state timed out');};
const hold=()=>{let respond;const response=new Promise(resolve=>{respond=resolve;}),req=request(base+'/v1/logs',{method:'POST',headers:{'content-type':'application/json','transfer-encoding':'chunked'}},res=>{let body='';res.on('data',chunk=>body+=chunk);res.on('end',()=>respond({status:res.statusCode,body}));});req.on('error',()=>{});req.write(' ');return {req,response};};
const a=hold(),b=hold();await waitAdmission(value=>value.activeRequests===2);
const busy=await post({resourceLogs:[]});assert.equal(busy.status,503);assert.deepEqual(await busy.json(),{error:'Collector busy'});a.req.destroy();b.req.destroy();await waitAdmission(value=>value.activeRequests===0&&value.bufferedBytes===0);
const c=hold();c.req.write(' '.repeat(2999));await waitAdmission(value=>value.bufferedBytes===3000);const d=hold();d.req.write(' '.repeat(1099));const overflow=await d.response;assert.equal(overflow.status,503);assert.deepEqual(JSON.parse(overflow.body),{error:'Collector busy'});c.req.destroy();d.req.destroy();
health=await waitAdmission(value=>value.activeRequests===0&&value.bufferedBytes===0);assert.equal(health.requests,12);assert.equal(health.admission.requestLimitRejected,1);assert.equal(health.admission.bufferLimitRejected,1);assert.ok(health.admission.peakBufferedBytes<=4096);assert.ok(health.admission.maxQueueWaitMs>=0);assert.ok(health.admission.maxCommitMs>=0);assert.ok(health.eventLoopLag.maxMs>=0);
assert.equal((await post({large:'x'.repeat(2048)})).status,507);health=await (await fetch(base+'/health')).json();assert.equal(health.requests,12);assert.equal(health.rejectedWrites,1);
const before=structuredClone(health);const tracePost=body=>fetch(base+'/v1/traces',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
const span={traceId:'1234567890abcdef1234567890abcdef',spanId:'1234567890abcdef',name:'synthetic.stage',startTimeUnixNano:'10',endTimeUnixNano:'11'};
@@ -16,4 +22,8 @@ test('actual Windows protected collector rotates encrypted exports and rejects o
assert.equal((await tracePost({resourceSpans:{}})).status,400);assert.equal((await post({resourceLogs:[{scopeLogs:{}}]})).status,400);
health=await (await fetch(base+'/health')).json();for(const field of ['requests','storedBytes','activeBytes','legacyBytes','retiredBytes','rotations','droppedSegments','droppedBytes','rejectedWrites','retainedSpans'])assert.equal(health[field],before[field]);assert.deepEqual(health.counts,before.counts);
assert.equal((await tracePost({resourceSpans:[]})).status,200);assert.equal((await tracePost({resourceSpans:[{scopeSpans:[{spans:[span]}]}]})).status,200);const trace=await (await fetch(base+'/api/trace/'+span.traceId)).json();assert.equal(trace.spans[0].durationMs,0.000001);
const beforeRestart=await (await fetch(base+'/health')).json();child.kill();await new Promise(resolve=>child.once('exit',resolve));
child=spawn(process.execPath,[resolve('scripts/debug-collector.mjs')],{windowsHide:true,stdio:'pipe',env:{...process.env,DEBUG_COLLECTOR_DIRECTORY:root,DEBUG_COLLECTOR_PORT:String(port),DEBUG_COLLECTOR_SEGMENT_BYTES:'1024',DEBUG_COLLECTOR_RETIRED_SEGMENTS:'1',DEBUG_COLLECTOR_MAX_REQUESTS:'2',DEBUG_COLLECTOR_MAX_BUFFERED_BYTES:'4096'}});child.stdout.on('data',buffer=>output+=buffer);child.stderr.on('data',buffer=>output+=buffer);
let restarted;for(let n=0;n<80;n++){if(child.exitCode!==null)throw Error('Protected collector restart failed');try{restarted=await (await fetch(base+'/health')).json();break;}catch{await new Promise(resolve=>setTimeout(resolve,100));}}assert.ok(restarted);assert.equal(restarted.requests,0);for(const field of ['storedBytes','activeBytes','legacyBytes','retiredBytes','retiredSegments'])assert.equal(restarted[field],beforeRestart[field]);
});
+30 -2
View File
@@ -17,6 +17,34 @@ test('inflight retirement cancellation remains a failure with bounded facts and
test('failure history is bounded while lifetime reason totals retain every failed export',{timeout:15000},async t=>{
const endpoint=await receiver(t,(req,res)=>{req.resume();res.writeHead(400,'Synthetic error body');res.end('PRIVATE_BODY_NOT_RETAINED');});const d=createDiagnostics();t.after(()=>d.shutdown());d.configure(config(endpoint));for(let i=0;i<18;i++)await produce(d);for(let i=0;i<100&&d.status().lifetimeFailedExports<2;i++)await delay(10);const status=d.status();assert.equal(status.lifetimeFailedExports,36);assert.equal(status.failureReasons['http-client'],36);assert.equal(status.failureFacts.length,32);assert.ok(!JSON.stringify(status).includes('PRIVATE_BODY_NOT_RETAINED'));
});
test('concurrent rejected flush cannot erase held native export attribution',{timeout:15000},async t=>{
let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),exportTimeoutMs:5000},{policyVersion:1});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);await d.span('browser.request',{},async()=>{});const second=d.flush();for(let i=0;i<100&&!d.status().failureReasons['concurrency-limit'];i++)await delay(10);assert.ok(d.status().failureReasons['concurrency-limit']>=1);d.configure({enabled:false});await Promise.all([first,second]);const status=d.status();assert.equal(received,2);assert.equal(status.failureReasons.cancelled,2);assert.equal(status.lifetimeFailedExports,status.failureReasons.cancelled+status.failureReasons['concurrency-limit']);assert.ok(status.failureFacts.filter(f=>f.reason==='cancelled').every(f=>f.cancelSignalled&&f.retirementOverlapped&&f.generation===1));
test('concurrent flush shares held native exports and cancellation attribution',{timeout:15000},async t=>{
let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),exportTimeoutMs:5000},{policyVersion:1});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);await d.span('browser.request',{},async()=>{});const second=d.flush();assert.equal(second,first);await delay(50);assert.equal(d.status().failureReasons['concurrency-limit'],undefined);d.configure({enabled:false});await Promise.all([first,second]);const status=d.status();assert.equal(received,2);assert.equal(status.failureReasons.cancelled,2);assert.equal(status.lifetimeFailedExports,status.failureReasons.cancelled);assert.ok(status.failureFacts.filter(f=>f.reason==='cancelled').every(f=>f.cancelSignalled&&f.retirementOverlapped&&f.generation===1));
});
test('scheduled batches queue behind held HTTP exports, expire explicitly and cancel without release',{timeout:15000},async t=>{
let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),maxQueueSize:16,maxRecordAgeMs:1000,flushIntervalMs:250,exportTimeoutMs:5000});
await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);
// Batch size triggers immediate processor export while the first real requests remain held.
for(let i=0;i<48;i++)await d.span('browser.request',{},async()=>{});
for(let i=0;i<200&&!d.status().failureReasons['queue-expired'];i++)await delay(10);
const waiting=d.status();assert.equal(received,2,'A queued batch overlapped a held native HTTP request');assert.ok(waiting.failureReasons['queue-expired']>=1);assert.equal(waiting.failureReasons['concurrency-limit'],undefined);assert.ok(waiting.failureFacts.filter(f=>f.reason==='queue-expired').every(f=>f.queueAgeMs>0&&f.durationMs>=f.queueAgeMs));
// Fresh pending batch must fail immediately on admin disable, without a network attempt.
for(let i=0;i<16;i++)await d.span('browser.request',{},async()=>{});await delay(10);const before=d.status().lifetimeFailedExports;d.configure({enabled:false});await first;const final=d.status();assert.equal(received,2);assert.ok(final.lifetimeFailedExports>=before+2);assert.ok(final.failureReasons.cancelled>=2);assert.equal(final.failedExports,0);
});
test('bounded export FIFO reports overflow and cancels queued callbacks using real held HTTP',{timeout:15000},async t=>{
const {readFileSync}=await import('node:fs'),{runInNewContext}=await import('node:vm');const nativeRequire=createRequire(import.meta.url),captured=[];const sdk=nativeRequire('@opentelemetry/sdk-trace');
class CapturingProcessor extends sdk.BatchSpanProcessor{constructor(options){const original=options.exporter.export;options.exporter.export=function(records,callback){captured.push({exporter:options.exporter,records});return original.call(this,records,callback);};super(options);}}
const sandbox={module:{exports:{}},require:name=>name==='@opentelemetry/sdk-trace'?{...sdk,BatchSpanProcessor:CapturingProcessor}:name.startsWith('../../')?nativeRequire('../apps/browser/'+name):nativeRequire(name),setTimeout,clearTimeout,setImmediate,URL,console};runInNewContext(readFileSync(new URL('../apps/browser/diagnostics.cjs',import.meta.url),'utf8'),sandbox);
let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=sandbox.module.exports.createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),maxQueueSize:16,exportTimeoutMs:5000});await d.span('browser.request',{},async()=>{});const pending=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);
const {exporter,records}=captured[0],results=[];exporter.export(Array.from({length:16},()=>records[0]),r=>results.push(r));exporter.export([records[0]],r=>results.push(r));assert.equal(d.status().failureReasons['queue-overflow'],1);assert.equal(results.length,1);assert.equal(results[0].code,1);assert.equal(received,2);d.configure({enabled:false});await pending;assert.equal(results.length,2);assert.ok(results.every(r=>r.code===1));assert.equal(d.status().failureReasons.cancelled,3);assert.equal(d.status().lifetimeFailedExports,4);assert.equal(received,2);
});
test('held HTTP success drains scheduled FIFO with one request per signal',{timeout:15000},async t=>{
let received=0,released=false;const held=[],active=new Map(),maximum=new Map();const endpoint=await receiver(t,(req,res)=>{const signal=req.url;received++;active.set(signal,(active.get(signal)||0)+1);maximum.set(signal,Math.max(maximum.get(signal)||0,active.get(signal)));res.once('finish',()=>active.set(signal,active.get(signal)-1));req.resume();if(released){res.writeHead(200,{'content-type':'application/json'});res.end('{}');}else held.push(res);});
const d=createDiagnostics();t.after(()=>d.shutdown());d.configure({...config(endpoint),maxQueueSize:16,exportTimeoutMs:5000});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);for(let i=0;i<16;i++)await d.span('browser.request',{},async()=>{});await delay(30);assert.equal(received,2);released=true;for(const res of held){res.writeHead(200,{'content-type':'application/json'});res.end('{}');}await first;await d.flush();for(let i=0;i<100&&received<4;i++)await delay(10);assert.equal(received,4,JSON.stringify({status:d.status(),signals:[...maximum.keys()]}));assert.equal(d.status().lifetimeFailedExports,0);assert.deepEqual([...maximum.values()],[1,1]);
});
test('identity and exporter settings changes still immediately cancel real held requests',{timeout:15000},async t=>{
for(const kind of ['identity','settings']){let received=0;const endpoint=await receiver(t,req=>{req.resume();received++;}),d=createDiagnostics();t.after(()=>d.shutdown());const settings={...config(endpoint),exportTimeoutMs:5000},deviceId='00000000-0000-4000-8000-000000000001';d.configure(settings,{deviceId,policyVersion:1});await d.span('browser.request',{},async()=>{});const pending=d.flush();for(let i=0;i<100&&received<2;i++)await delay(10);assert.equal(received,2);const next=kind==='identity'?settings:{...settings,maxQueueSize:16},context={deviceId:kind==='identity'?'00000000-0000-4000-8000-000000000002':deviceId,policyVersion:2};assert.equal(d.canContinue(next,context),false);d.configure(next,context);await pending;assert.equal(received,2);assert.equal(d.status().lifetimeFailedExports,2);assert.equal(d.status().failedExports,0);assert.equal(d.status().failureReasons.cancelled,2);assert.ok(d.status().failureFacts.every(f=>f.cancelSignalled&&!f.activeAtCompletion&&f.retirementOverlapped));d.configure({enabled:false});}
});
+9
View File
@@ -85,3 +85,12 @@ test('invalid persisted IDs clear unrelated parents without getters, retries or
const count=spans(requests).length;diagnostics.configure({enabled:false});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);
diagnostics.configure({...config(endpoint),debugSampleRate:0});await diagnostics.withCorrelation(good,()=>diagnostics.span('browser.management',{operation:'audit.deliver'},async()=>{calls++;}));await diagnostics.flush();assert.equal(spans(requests).length,count);assert.equal(calls,invalid.length+3);
});
test('compatible policy metadata updates preserve held exports and snapshot old operation context',{timeout:15000},async t=>{
const requests=[],held=[];let releaseHTTP=false;const server=createServer((req,res)=>{const chunks=[];req.on('data',c=>chunks.push(c));req.on('end',()=>{requests.push({path:req.url,payload:JSON.parse(Buffer.concat(chunks).toString())});if(releaseHTTP){res.setHeader('content-type','application/json');res.end('{}');}else held.push(res);});}).listen(0,'127.0.0.1');await new Promise(r=>server.once('listening',r));t.after(()=>new Promise(r=>{server.closeAllConnections();server.close(r);}));
const d=createDiagnostics();t.after(()=>d.shutdown());const settings=config(`http://127.0.0.1:${server.address().port}`),deviceId='00000000-0000-4000-8000-000000000001';d.configure(settings,{policyVersion:1,deviceId});await d.span('browser.request',{},async()=>{});const first=d.flush();for(let i=0;i<100&&held.length<2;i++)await delay(10);assert.equal(held.length,2);
let finishOld,oldEntered;const oldReady=new Promise(r=>oldEntered=r),oldGate=new Promise(r=>finishOld=r);const old=d.span('browser.navigation',{},async()=>{oldEntered();await oldGate;await d.span('browser.clipboard',{},async()=>d.event('browser.security',{}));d.spanSync('browser.policy',{},()=>{});});await oldReady;
assert.equal(d.canContinue(settings,{deviceId,policyVersion:2}),true);assert.equal(d.configure(settings,{deviceId,policyVersion:2}),true);assert.equal(d.status().lifetimeFailedExports,0);releaseHTTP=true;for(const res of held){res.setHeader('content-type','application/json');res.end('{}');}await first;finishOld();await old;await d.span('browser.tab',{},async()=>{});await d.span('browser.agent',{policyVersion:9},async()=>{});await d.flush();
const policy=row=>Number(row.attributes.find(a=>a.key==='safetynet.policy.version').value.intValue);for(const row of spans(requests)){assert.equal(policy(row),row.name==='browser.tab'?2:row.name==='browser.agent'?9:1);}for(const row of logs(requests)){const name=row.body.stringValue;assert.equal(policy(row),name==='browser.tab'?2:name==='browser.agent'?9:1);}assert.equal(d.status().lifetimeFailedExports,0);
for(const input of [{...settings,enabled:false},{...settings,mode:'operational'},{...settings,debugSampleRate:0},{...settings,maxQueueSize:16},{...settings,endpoint:settings.endpoint+'/other'}])assert.equal(d.canContinue(input,{deviceId,policyVersion:3}),false);assert.equal(d.canContinue(settings,{deviceId:'00000000-0000-4000-8000-000000000002',policyVersion:3}),false);
});
+17 -2
View File
@@ -16,11 +16,11 @@ const extracted=source.slice(source.indexOf('function applyPolicy(envelope)'),so
async function fixture(t){
const payloads=[],server=createServer((req,res)=>{const chunks=[];req.on('data',v=>chunks.push(v));req.on('end',()=>{payloads.push(JSON.parse(Buffer.concat(chunks).toString()));res.setHeader('content-type','application/json');res.end('{}');});}).listen(0,'127.0.0.1');await new Promise(resolve=>server.once('listening',resolve));
const diagnostics=createDiagnostics(),config=DiagnosticsSchema.parse({enabled:true,mode:'debug',endpoint:`http://127.0.0.1:${server.address().port}`,sampleRate:1,debugSampleRate:1,flushIntervalMs:10000});
t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});diagnostics.configure(config);
t.after(async()=>{await diagnostics.shutdown();server.closeAllConnections();await new Promise(resolve=>server.close(resolve));});diagnostics.configure(config,{policyVersion:1,deviceId:'00000000-0000-4000-8000-000000000001'});
const calls=[],next=defaultPolicy();next.diagnostics=config;const keys=generateKeyPairSync('ed25519'),privateKey=keys.privateKey.export({type:'pkcs8',format:'pem'}),publicKey=keys.publicKey.export({type:'spki',format:'pem'}),deviceId='00000000-0000-4000-8000-000000000001';
const envelope=()=>({...signPolicy(next,privateKey),bookmarks:signBookmarkSnapshot({deviceId,policyVersion:next.version,revision:0,expiresAt:next.expiresAt,items:[]},privateKey)});
const tracked=name=>async()=>{calls.push({name,enabled:diagnostics.status().enabled});};
const c=vm.createContext({require,process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v);},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')});
const c=vm.createContext({require,diagnostics,diagnosticContext:extra=>({deviceId,policyVersion:c.policy?.version||0,...extra}),process:{env:{}},core:{verifyPolicy},assertPolicyAssignment:require('../apps/browser/policy-assignment.cjs').assertPolicyAssignment,credentialBroker:{revoke:()=>calls.push({name:'credential-revoke',enabled:diagnostics.status().enabled})},device:{id:deviceId,publicKey,minimumVersion:0},policy:next,personalBookmarks:{revision:0,items:[]},bookmarkSnapshotJSON:'',bookmarkModule:{verifyBookmarkSnapshot},saveDevice:()=>calls.push({name:'save',enabled:diagnostics.status().enabled}),diagnosticSpan:(name,attributes,fn)=>diagnostics.span(name,attributes,fn),configureDiagnostics:v=>{calls.push({name:'configure',enabled:v.enabled});diagnostics.configure(v,{deviceId,policyVersion:c.policy?.version||0});},reconcileAgentPolicy:tracked('agent'),enforceEvidencePolicy:()=>{},emit:()=>{},quarantine:()=>{},browserSession:{closeAllConnections:tracked('browser-close'),setProxy:tracked('browser-proxy'),clearHostResolverCache:tracked('browser-dns')},managementSession:{closeAllConnections:tracked('management-close'),setProxy:tracked('management-proxy'),clearHostResolverCache:tracked('management-dns')},app:{configureHostResolver:()=>calls.push({name:'resolver',enabled:diagnostics.status().enabled})},resolverOptions:v=>v,recentRequests:new Map(),tabs:{all:()=>[{security:{developerToolsGuard:{close:()=>{}}},view:{webContents:{closeDevTools:()=>{}}}}]},configureVisualPage:tracked('page'),audit:tracked('audit')});
c.localRelay={proxyRules:'127.0.0.1:45678',proxyBypassRules:'<-loopback>',invalidate:tracked('relay-invalidate')};
const originalSetProxy=c.browserSession.setProxy;c.browserSession.setProxy=async settings=>{assert.equal(settings.mode,'fixed_servers');assert.equal(settings.proxyRules,c.localRelay.proxyRules);assert.equal(settings.proxyBypassRules,'<-loopback>');await originalSetProxy(settings);};
vm.runInContext(extracted,c);return {c,next,envelope,calls,diagnostics,payloads,spans:()=>payloads.flatMap(v=>v.resourceSpans||[]).flatMap(v=>v.scopeSpans||[]).flatMap(v=>v.spans||[])};
@@ -43,3 +43,18 @@ test('enabled new policy exports only post-configuration stages after protected
test('signed verification failure prevents bookmark, proxy and policy mutations',async t=>{
const f=await fixture(t);const tampered=f.envelope();tampered.payload+='A';await assert.rejects(f.c.applyPolicy(tampered),/signature rejected/);await f.diagnostics.flush();assert.equal(f.calls.length,0);assert.equal(f.c.policy.version,1);assert.ok(f.spans().some(s=>s.status.code===2));
});
test('verified policy-only update keeps identical telemetry authorization throughout the transition',async t=>{
const f=await fixture(t);f.c.policy=structuredClone(f.next);f.next.version=2;
await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();
assert.ok(f.calls.filter(v=>v.name==='configure').every(v=>v.enabled===true));
for(const name of ['browser-close','browser-proxy','management-close','management-proxy','browser-dns','management-dns','credential-revoke','resolver','save','page','agent','audit'])assert.equal(f.calls.find(v=>v.name===name).enabled,true,name);
assert.equal(f.c.policy.version,2);assert.equal(f.diagnostics.status().lifetimeFailedExports,0);
});
test('expired previous policy cannot preserve telemetry authorization even with identical collector settings',async t=>{
const f=await fixture(t);f.c.policy={...structuredClone(f.next),expiresAt:new Date(Date.now()-1000).toISOString()};f.next.version=2;
await f.c.applyPolicy(f.envelope());await f.diagnostics.flush();
assert.equal(f.calls.find(v=>v.name==='browser-proxy').enabled,false);
assert.deepEqual(f.calls.filter(v=>v.name==='configure').map(v=>v.enabled),[false,true]);
});