Files
buzz/beehive/test/private-move.test.ts
2026-09-16 09:48:34 -04:00

164 lines
13 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, realpathSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { registerHooks } from 'node:module';
const stopFault = { active:false };
Object.assign(globalThis,{__beehivePrivateMoveStopFault:stopFault});
registerHooks({load(url,context,next){
const result=next(url,context);
if (!url.endsWith('/src/owned.ts')) return result;
const source=String(result.source), marker='async stop() {';
if(!source.includes(marker)) throw Error('Owned Stop test seam changed');
return {...result,source:source.replace(marker,marker+" if ((globalThis as any).__beehivePrivateMoveStopFault.active) throw Error('Synthetic owned Stop failure');")};
}});
const {registrationFixture}=await import('./registration-fixture.ts');
import { managementClient } from '../src/intents.ts';
import { message, newKey, publicKey, type Message } from '../src/protocol.ts';
import { signSuccessor, verifySuccessors, privateExchange } from '../src/private-handoff.ts';
import { createGenesis } from '../src/assignment.ts';
import { hash, type Assignment, type Grant } from '../src/handoff.ts';
async function until(fn:()=>boolean) { for(let n=0;n<400;n++) { if(fn()) return; await delay(20); } throw Error('Private Move evidence missing'); }
test('private two-host owned-process transfer, exact replay and reverse signed successor',async t=>{
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-')));
const f=await registrationFixture(home,()=>{},true,true).catch(error=>{console.error(error);throw error;});
const inventory=new Map<string,Message>();
const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
t.after(async()=>{c.close();await f.close();rmSync(home,{recursive:true,force:true});});
await c.ready; await until(()=>inventory.size===2);
const source=()=>inventory.get(f.host)!, dest=()=>inventory.get(f.destination!.host)!;
const send=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;};
assert.equal((await send(message('start',f.host,f.agent,source().revision))).state,'completed');
await until(()=>source().body.phase==='running');
const original=source().body.actualRun;
const prior=message('save',dest().host,f.agent,dest().revision,dest().body.selectedNext as Record<string,unknown>);
assert.equal((await send(prior)).state,'completed');await until(()=>dest().revision>prior.revision);
const duplicate=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});duplicate.id=prior.id;
const other=managementClient(join(home,'other-controller'),f.url,f.ownerSecret,()=>{},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
t.after(()=>other.close());await other.ready;await delay(100);other.submit(duplicate);
await until(()=>other.status().some(o=>o.request.id===duplicate.id&&o.state==='failed'));
assert.deepEqual(source().body.actualRun,original,'cross-controller destination ID conflict cannot stop source');
const move=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});
assert.equal((await send(move)).state,'completed');
await until(()=>dest().body.phase==='running'&&source().body.phase==='stopped');
assert.equal(source().body.actualRun,null);assert.notDeepEqual(dest().body.actualRun,original);
assert.equal(dest().body.assignedHost,dest().host);
const chain=dest().body.assignmentChain as Grant[];assert.equal(chain.length,1);assert.ok(chain[0]!.proof);
const before=JSON.stringify(dest().body.actualRun);
c.reconcile();await delay(250);assert.equal(JSON.stringify(dest().body.actualRun),before);
assert.throws(()=>c.submit(move),/already prepared/);
assert.equal((await send(message('start',f.host,f.agent,source().revision))).state,'failed');
const reverse=message('move',dest().host,f.agent,dest().revision,{target:f.host,targetRevision:source().revision,selection:source().body.selectedNext});
assert.equal((await send(reverse)).state,'completed');
await until(()=>source().body.phase==='running'&&dest().body.phase==='stopped');
assert.equal((source().body.assignmentChain as Grant[]).length,2);
assert.equal((await send(message('stop',f.host,f.agent,source().revision))).state,'completed');
assert.equal(c.status().find(s=>s.request.id===move.id)!.state,'completed','later Stop/Move cannot rewrite historical completion');
c.close();
const reopened=managementClient(join(home,'client'),f.url,f.ownerSecret,()=>{},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
t.after(()=>reopened.close());await reopened.ready;
assert.equal(reopened.status().find(s=>s.request.id===move.id)!.state,'completed','durable destination receipt survives controller reopen');
assert.ok(f.relay.independentWraps>0);assert.deepEqual(f.errors,[]);
});
test('unseen successors require exact source/relay signature and authenticated pair',()=>{
const secret=newKey(),source=publicKey(secret),owner=publicKey(newKey()),agent=publicKey(newKey()),target=publicKey(newKey());
const genesis=createGenesis(owner,agent,source), current:Assignment={genesis,assignedHost:source};
const selection={model:'fixture',workspace:'/tmp',profile:'default'};
const op=message('move',source,agent,0,{target,targetRevision:0,selection});
const unsigned:Grant={root:hash(genesis),predecessor:hash(genesis),source,target,agent,operation:op,prepared:'token',selection,targetRevision:0,sourceRun:null};
const grant=signSuccessor(unsigned,secret,'wss://fixture.invalid');
const next:Assignment={genesis,assignedHost:target,chain:[grant]};
verifySuccessors(current,next,'wss://fixture.invalid');
assert.throws(()=>verifySuccessors(current,{...next,chain:[unsigned]},'wss://fixture.invalid'),/Unsigned/);
assert.throws(()=>verifySuccessors(current,next,'wss://other.invalid'),/invalid successor/);
assert.throws(()=>verifySuccessors(current,{...next,chain:[{...grant,sourceRun:'forged'}]},'wss://fixture.invalid'),/invalid successor/);
const m=message('grant',target,agent,0,{assignment:next});
assert.equal(privateExchange({sender:source,message:m},owner,target),m);
assert.throws(()=>privateExchange({sender:target,message:m},owner,target),/authority/);
assert.throws(()=>privateExchange({sender:source,message:m},publicKey(newKey()),target),/authority/);
});
test('private destination failure and stale CAS preserve source; cancelled late preparation cannot transfer; lost grant recovers once',async t=>{
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-faults-')));
const f=await registrationFixture(home,()=>{},true,true);
const inventory=new Map<string,Message>();
const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
t.after(async()=>{c.close();await f.close();rmSync(home,{recursive:true,force:true});});
await c.ready;await until(()=>inventory.size===2);
const source=()=>inventory.get(f.host)!,dest=()=>inventory.get(f.destination!.host)!;
const outcome=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;};
await outcome(message('start',f.host,f.agent,source().revision));await until(()=>source().body.phase==='running');
const actual=source().body.actualRun;
const move=()=>message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});
const stale=move();stale.body.targetRevision=100;
assert.equal((await outcome(stale)).state,'failed');assert.deepEqual(source().body.actualRun,actual);
const {credentialReference}=await import('../src/credential-store.ts');
const key=credentialReference('agent',f.agent),secret=f.destination!.credentials.read(key)!;
f.destination!.credentials.remove(key);
assert.equal((await outcome(move())).state,'failed');assert.deepEqual(source().body.actualRun,actual);
f.destination!.credentials.create(key,secret);
f.setHoldPrepared(true);const cancelled=move();c.submit(cancelled);await until(()=>f.heldPrepared>0);
await outcome(message('stop',f.host,f.agent,source().revision));f.releasePrepared();await until(()=>source().body.phase==='stopped');await delay(100);
assert.equal(source().body.assignedHost,f.host);assert.equal(dest().body.phase,'stopped');
assert.equal(c.status().find(s=>s.request.id===cancelled.id)!.state,'failed');
await until(()=>dest().revision>0);
f.setDropGrant(true);const recover=move();c.submit(recover);
await until(()=>source().body.assignedHost===dest().host);
assert.equal(dest().body.phase,'stopped','lost grant does not manufacture destination authority');
assert.equal(c.status().find(s=>s.request.id===recover.id)!.state,'pending');
f.setDropGrant(false);c.reconcile();await until(()=>dest().body.phase==='running');
const run=dest().body.actualRun;c.reconcile();await delay(200);assert.deepEqual(dest().body.actualRun,run);
f.setDropGrant(true);
const reverse=message('move',dest().host,f.agent,dest().revision,{target:source().host,targetRevision:source().revision,selection:source().body.selectedNext});
c.submit(reverse);await until(()=>dest().body.assignedHost===source().host);
f.credentials.remove(key);f.setDropGrant(false);c.reconcile();
await until(()=>c.status().find(s=>s.request.id===reverse.id)?.state==='failed');
await until(()=>source().body.assignedHost===source().host&&source().body.phase==='stopped');
assert.equal(source().body.actualRun,null);assert.equal(dest().body.actualRun,null);
f.credentials.create(key,secret);
assert.equal((await outcome(message('start',source().host,f.agent,source().revision))).state,'completed');
});
test('relay-listed agent Move uses typed submission and observes destination before completion',async t=>{
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-manager-')));
let snapshot: import('../src/manager-controller.ts').ManagerSnapshot;
const f=await registrationFixture(home,s=>{snapshot=s;},true,true);
t.after(async()=>{await f.close();rmSync(home,{recursive:true,force:true});});
let id=0;const request=(action:string,values?:Record<string,string>,target?:string,revision?:number)=>f.controller.request({id:++id,action,values,target,revision});
await request('signin');const row=()=>snapshot!.agents.find(a=>a.id===f.agent)!;
await until(()=>!!row()?.target&&!!row()?.destinations?.length);
f.setDropGrant(true);
const d=row().destinations![0]!;
const r=await request('move',{destination:d.target,destinationRevision:String(d.revision)},row().target,row().revision);
assert.equal(r.state,'submitted',snapshot!.status);if(r.state!=='submitted')return;
await until(()=>snapshot!.diagnostics!.some(o=>o.id===`operation:${r.operationId}`&&o.label.includes('Pending')));
f.setDropGrant(false);await request('reconcile');
await until(()=>row().label.includes('running')&&row().target===d.target);
await until(()=>snapshot!.diagnostics!.some(o=>o.id===`operation:${r.operationId}`&&o.detail.includes('Move complete; destination running')));
assert.ok(!JSON.stringify(snapshot!).includes(f.nsec));
});
test('failed owned source Stop retains quarantine, actual run and source authority; explicit repaired Stop recovers without destination execution',async t=>{
const home=realpathSync(mkdtempSync(join(tmpdir(),'beehive-pairing-cli-private-move-stop-')));
const f=await registrationFixture(home,()=>{},true,true),inventory=new Map<string,Message>();
const c=managementClient(join(home,'client'),f.url,f.ownerSecret,m=>{if(m.type==='inventory')inventory.set(m.host,m);},()=>{},{catalog:{version:1,owner:publicKey(f.ownerSecret),relay:f.url,registrations:[]}});
t.after(async()=>{stopFault.active=false;c.close();await f.close();rmSync(home,{recursive:true,force:true});});
await c.ready;await until(()=>inventory.size===2);
const source=()=>inventory.get(f.host)!,dest=()=>inventory.get(f.destination!.host)!;
const outcome=async(m:Message)=>{c.submit(m);await until(()=>c.status().some(s=>s.request.id===m.id&&s.state!=='pending'));return c.status().find(s=>s.request.id===m.id)!;};
await outcome(message('start',f.host,f.agent,source().revision));await until(()=>source().body.phase==='running');
const actual=source().body.actualRun;stopFault.active=true;
const move=message('move',f.host,f.agent,source().revision,{target:dest().host,targetRevision:dest().revision,selection:dest().body.selectedNext});
assert.equal((await outcome(move)).state,'failed');
await until(()=>source().body.phase==='quarantined');
assert.deepEqual(source().body.actualRun,actual);assert.equal(source().body.assignedHost,f.host);assert.equal(dest().body.phase,'stopped');
stopFault.active=false;assert.equal((await outcome(message('stop',f.host,f.agent,source().revision))).state,'completed');
await until(()=>source().body.phase==='stopped');assert.equal(source().body.actualRun,null);assert.equal(dest().body.actualRun,null);
});