mirror of
https://github.com/block/buzz.git
synced 2026-10-30 05:51:46 +00:00
122 lines
9.3 KiB
TypeScript
122 lines
9.3 KiB
TypeScript
import { migrateSlots, retireHarnessBinding, addHarnessBinding, installationSlots } from '../src/slots.ts';
|
|
import { bindingFingerprint } from '../src/host.ts';
|
|
import { profileRevision } from '../src/profiles.ts';
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, realpathSync, rmSync, readFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { setTimeout as delay } from 'node:timers/promises';
|
|
import { createGenesis } from '../src/assignment.ts';
|
|
import { provision, host } from '../src/host.ts';
|
|
import { newKey, publicKey, message, open, type Message } from '../src/protocol.ts';
|
|
import { writePrivate } from '../src/storage.ts';
|
|
import { relay } from '../src/relay.ts';
|
|
import { connect } from '../src/client.ts';
|
|
|
|
for (const failure of ['changed-across-target-restart', 'retired-across-target-restart', 'remote-candidate-save'] as const) test(`explicit B binding profiled Move reserved revision; post-grant ${failure} leaves target assigned stopped`, { timeout: 20000 }, async () => {
|
|
const dir = realpathSync(mkdtempSync(join(tmpdir(), 'beehive-move-loss-')));
|
|
const ownerSecret = newKey(), agentSecret = newKey(), agent = publicKey(agentSecret);
|
|
const genesis = createGenesis(publicKey(ownerSecret), agent, 'source');
|
|
for (const name of ['source','target']) provision(join(dir,name), { host: name, ownerSecret, agentSecret, runner: process.execPath, args: [resolve('test/runner.ts')], workspace: dir, mode: 'fixture' },genesis);
|
|
for (const name of ['source', 'target']) {
|
|
const directory = join(dir, name); migrateSlots(directory);
|
|
const { host: _host, ownerSecret: _owner, agentSecret: _agent, ...binding } = installationSlots(directory)[0]!.setup;
|
|
addHarnessBinding(directory, 'B', { ...binding, args: [resolve('test/binding-runner.ts'), name] });
|
|
}
|
|
const targetBinding = installationSlots(join(dir, 'target'))[0]!.bindings.B!;
|
|
const ref = { id: 'B', fingerprint: bindingFingerprint(targetBinding) };
|
|
const server = await relay(0, publicKey(ownerSecret), join(dir,'relay.json'));
|
|
const address = server.address(); assert.ok(address && typeof address !== 'string'); const url = `ws://127.0.0.1:${address.port}`;
|
|
let source = await host(join(dir,'source'),url); let target = await host(join(dir,'target'),url);
|
|
const sourceSocket = [...server.clients][0]!;
|
|
let lostOperation = '', loseReceipt = true;
|
|
const handlers = sourceSocket.listeners('message'); sourceSocket.removeAllListeners('message');
|
|
sourceSocket.on('message', (raw, binary) => {
|
|
const m = open(JSON.parse(String(raw)),ownerSecret);
|
|
if (loseReceipt && m.type === 'receipt' && m.body.operation === lostOperation) return;
|
|
for (const handler of handlers) Reflect.apply(handler, sourceSocket, [raw,binary]);
|
|
});
|
|
const targetSocket = [...server.clients][1]!; const send = targetSocket.send.bind(targetSocket); let drop = true;
|
|
targetSocket.send = ((data: Parameters<typeof send>[0], ...args: unknown[]) => {
|
|
const m = open(JSON.parse(String(data)),ownerSecret);
|
|
if (drop && m.type === 'grant') return;
|
|
return (send as Function)(data,...args);
|
|
}) as typeof targetSocket.send;
|
|
const seen: Message[] = []; const ui = connect(url,ownerSecret,m => seen.push(m)); await ui.ready;
|
|
const journal = (name: string) => JSON.parse(readFileSync(join(dir,name,'journal.json'),'utf8'));
|
|
async function until(predicate: () => boolean) { for (let i=0;i<500;i++) { if (predicate()) return; await delay(10); } throw Error('Missing failure/recovery evidence'); }
|
|
try {
|
|
const behavior = { name: 'Source', parent: null, instructions: 'Reserved named source behavior', revision: profileRevision('Source', null, 'Reserved named source behavior') };
|
|
ui.send(message('profile','profiles','profiles',0,behavior));
|
|
await until(() => seen.some(m => m.type === 'profile'));
|
|
ui.send(message('save','source',agent,0,{ ...journal('source').selected, profile: behavior.revision, behavior }));
|
|
ui.send(message('save','target',agent,0,{ ...journal('target').selected, harnessSetup: ref }));
|
|
await until(() => journal('source').revision === 1 && journal('target').revision === 1);
|
|
const oldCandidate = journal('target').selected;
|
|
const start = message('start','source',agent,1); ui.send(start); await until(() => journal('source').phase === 'running');
|
|
const move = message('move','source',agent,2,{ target:'target',targetRevision:1,selection:{ ...oldCandidate, profile: behavior.revision, behavior } }); lostOperation = move.id; ui.send(move);
|
|
await until(() => journal('source').assignment.assignedHost === 'target');
|
|
assert.equal(journal('source').phase,'stopped'); assert.equal(journal('target').assignment.assignedHost,'source');
|
|
assert.equal(seen.some(m => m.type === 'receipt' && m.body.operation === move.id),false, 'source receipt lost before relay storage');
|
|
const originalPreparation = journal('target').preparations[move.id];
|
|
assert.deepEqual(originalPreparation.candidate, oldCandidate);
|
|
assert.equal(originalPreparation.reservedRevision, 2);
|
|
const consumed = journal('source').assignment.chain.at(-1);
|
|
assert.equal(consumed.selection.configuration.revision, 2);
|
|
assert.equal(journal('target').revision, 2);
|
|
// A stale pre-reservation Save must not consume/redefine the reserved revision.
|
|
const stale = message('save','target',agent,1,oldCandidate); ui.send(stale);
|
|
await until(() => seen.some(m => m.body.operation === stale.id && m.body.result === 'revision-conflict'));
|
|
if (failure !== 'remote-candidate-save') await target.close();
|
|
const targetSetup = join(dir,'target','setup.json');
|
|
if (failure === 'remote-candidate-save') {
|
|
const edit = message('save', 'target', agent, 2, oldCandidate); ui.send(edit);
|
|
await until(() => journal('target').revision === 3);
|
|
assert.equal(journal('target').selected.configuration.revision, 3); assert.equal(journal('target').selected.profile, 'default');
|
|
} else if (failure === 'retired-across-target-restart') {
|
|
const before = JSON.parse(readFileSync(targetSetup, 'utf8'));
|
|
retireHarnessBinding(join(dir, 'target'), ref);
|
|
assert.deepEqual(JSON.parse(readFileSync(targetSetup, 'utf8')).setups, before.setups);
|
|
} else {
|
|
// Fault injection: offline definition changed after consumed preparation.
|
|
const manifest = JSON.parse(readFileSync(targetSetup, 'utf8'));
|
|
manifest.setups.B.args.push('changed-after-preparation'); writePrivate(targetSetup, manifest);
|
|
}
|
|
if (failure !== 'remote-candidate-save') target = await host(join(dir,'target'),url);
|
|
drop = false; loseReceipt = false;
|
|
ui.send(message('inspect','source',agent));
|
|
await until(() => seen.some(m => m.type === 'receipt' && m.body.operation === move.id && m.body.result === 'accepted'));
|
|
await until(() => journal('target').assignment.assignedHost === 'target');
|
|
await until(() => seen.some(m => m.host === 'target' && m.type === 'receipt' && String(m.body.result).includes(failure === 'remote-candidate-save' ? 'Destination candidate changed after preparation' : 'Destination preparation invalidated')));
|
|
assert.equal(journal('target').phase,'stopped'); assert.equal(journal('target').actual,null);
|
|
if (failure === 'remote-candidate-save') { assert.equal(journal('target').selected.configuration.revision, 3); assert.equal(journal('target').selected.profile, 'default'); }
|
|
else { assert.deepEqual(journal('target').configurations.default, consumed.selection); assert.equal(journal('target').selected.behavior.instructions, behavior.instructions); }
|
|
if (failure === 'changed-across-target-restart') {
|
|
const staleStart = message('start', 'target', agent, journal('target').revision);
|
|
ui.send(staleStart); await until(() => seen.some(m => m.body.operation === staleStart.id && m.body.result === 'Binding definition changed'));
|
|
assert.equal(journal('target').actual, null);
|
|
}
|
|
if (failure === 'retired-across-target-restart') {
|
|
for (const action of ['start', 'restart'] as const) {
|
|
const denied = message(action, 'target', agent, journal('target').revision); ui.send(denied);
|
|
await until(() => seen.some(m => m.body.operation === denied.id && String(m.body.result).includes('Binding retired')));
|
|
assert.equal(journal('target').actual, null);
|
|
}
|
|
}
|
|
const denied = message('start','source',agent,3); ui.send(denied);
|
|
await until(() => seen.some(m => m.body.operation === denied.id && m.body.result === 'not-authority'));
|
|
assert.deepEqual(journal('target').preparations[move.id], originalPreparation, 'replayed prepare never replaces consumed grant evidence');
|
|
const state = journal('target');
|
|
for (const grant of seen.filter(m => m.type === 'grant')) ui.send(grant);
|
|
await delay(100); assert.deepEqual(journal('target'),state);
|
|
await source.close(); source = await host(join(dir,'source'),url);
|
|
const restarted = message('start','source',agent,3); ui.send(restarted);
|
|
await until(() => seen.some(m => m.body.operation === restarted.id && m.body.result === 'not-authority'));
|
|
assert.equal(journal('source').assignment.assignedHost,'target', 'service restart cannot resurrect consumed source');
|
|
} finally {
|
|
await source.close(); await target.close(); ui.close(); for (const c of server.clients) c.terminate();
|
|
await new Promise<void>(resolve => server.close(() => resolve())); rmSync(dir,{ recursive:true,force:true });
|
|
}
|
|
});
|