Files
buzz/beehive/test/credential-slots.test.ts
2026-09-10 14:08:26 -04:00

200 lines
14 KiB
TypeScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { provisionCredentialSlot, orphanCredentialSlots } from '../src/credential-slots.ts';
import { installationSlots, removeSlotKey, importSlotKey, addSlot, reconcileSlot, migrateSlots } from '../src/slots.ts';
import { setupOwner, validateSetup, initialState, provision } from '../src/host.ts';
import { createGenesis } from '../src/assignment.ts';
import { newKey, publicKey } from '../src/protocol.ts';
import { memoryCredentials } from './credential-fixture.ts';
import { readPrivate, writePrivate } from '../src/storage.ts';
function fixture() {
const directory = mkdtempSync(join(tmpdir(), 'beehive-credential-slots-'));
const owner = newKey(), secret = newKey(), agent = publicKey(secret), backend = memoryCredentials();
const setup = validateSetup({ host: 'host-a', ownerPublic: publicKey(owner), mode: 'fixture', runner: process.execPath, args: [], workspace: directory });
const genesis = createGenesis(publicKey(owner), agent, setup.host);
return { directory, owner, secret, agent, backend, setup, genesis };
}
test('owner-public provisioning, retained deletion/retry and explicit matching import never persist identity secrets', () => {
const f = fixture();
try {
provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend);
const manifest = readFileSync(join(f.directory, 'setup.json'), 'utf8');
const path = join(f.directory, 'agents', f.agent, 'journal.json'), journal = readFileSync(path, 'utf8');
assert.ok(!manifest.includes(f.secret) && !manifest.includes(f.owner));
assert.ok(!journal.includes(f.secret) && !journal.includes(f.owner));
const loaded = installationSlots(f.directory, f.backend)[0]!;
assert.equal(loaded.setup.ownerSecret, undefined);
assert.equal(setupOwner(loaded.setup), publicKey(f.owner));
assert.equal(loaded.setup.agentSecret, f.secret);
removeSlotKey(f.directory, f.agent, f.backend);
removeSlotKey(f.directory, f.agent, f.backend);
const removed = installationSlots(f.directory, f.backend)[0]!;
assert.equal(removed.keyPresent, false);
assert.equal(removed.setup.agentSecret, undefined);
assert.throws(() => importSlotKey(f.directory, f.agent, newKey(), f.backend), /does not match/);
importSlotKey(f.directory, f.agent, f.secret, f.backend);
assert.equal(installationSlots(f.directory, f.backend)[0]!.setup.agentSecret, f.secret);
assert.equal(readFileSync(path, 'utf8'), journal);
assert.equal(readFileSync(join(f.directory, 'setup.json'), 'utf8'), manifest);
} finally { rmSync(f.directory, { recursive: true, force: true }); }
});
test('preserved-state validation precedes deletion; unavailable differs from missing; partial creation never resets', () => {
const f = fixture();
try {
provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend);
const unavailable = { ...f.backend, read() { throw Error('locked: unlock explicitly'); } };
assert.throws(() => installationSlots(f.directory, unavailable), /locked/);
assert.throws(() => removeSlotKey(f.directory, f.agent, unavailable), /locked/);
const path = join(f.directory, 'agents', f.agent, 'journal.json');
const state = readPrivate(path) as any;
state.binding.owner = publicKey(newKey()); writePrivate(path, state);
assert.throws(() => removeSlotKey(f.directory, f.agent, { ...f.backend, remove() { assert.fail('must validate before OS delete'); } }), /mismatch/);
assert.throws(() => provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend), /refusing identity reset/);
} finally { rmSync(f.directory, { recursive: true, force: true }); }
const partial = fixture();
try {
assert.throws(() => provisionCredentialSlot(partial.directory, partial.setup, partial.secret, partial.genesis, { ...partial.backend, create() { throw Error('denied'); } }), /denied/);
assert.throws(() => installationSlots(partial.directory, partial.backend), /ENOENT/);
assert.throws(() => provisionCredentialSlot(partial.directory, partial.setup, partial.secret, partial.genesis, partial.backend), /partial provision/);
} finally { rmSync(partial.directory, { recursive: true, force: true }); }
});
test('public owner validation rejects absent and conflicting identity', () => {
const owner = newKey();
assert.throws(() => setupOwner({}), /required/);
assert.throws(() => setupOwner({ ownerPublic: 'bad' }), /Invalid/);
assert.throws(() => setupOwner({ ownerPublic: publicKey(newKey()), ownerSecret: owner }), /Conflicting/);
});
test('explicit exact first-provision recovery verifies orphan key; v3 additions preserve siblings and standby genesis', async () => {
const { reconcileCredentialProvision } = await import('../src/credential-slots.ts');
const { addSlot } = await import('../src/slots.ts');
for (const failure of ['before-store', 'after-store'] as const) {
const f = fixture();
try {
assert.throws(() => provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, {
...f.backend, create(ref, secret) { if (failure === 'after-store') f.backend.create(ref, secret); throw Error('injected store boundary'); },
}), /injected/);
const path = join(f.directory, 'agents', f.agent, 'journal.json'), journal = readFileSync(path, 'utf8');
assert.throws(() => reconcileCredentialProvision(f.directory, f.setup, newKey(), f.genesis, f.backend), /mismatch/);
reconcileCredentialProvision(f.directory, f.setup, f.secret, f.genesis, f.backend);
assert.equal(readFileSync(path, 'utf8'), journal);
const second = newKey(), key = publicKey(second), root = createGenesis(publicKey(f.owner), key, 'another-host');
addSlot(f.directory, second, root, 'default', undefined, f.backend);
assert.equal(installationSlots(f.directory, f.backend).length, 2);
assert.equal((readPrivate(join(f.directory, 'agents', key, 'journal.json')) as any).assignment.assignedHost, 'another-host');
assert.equal(readFileSync(path, 'utf8'), journal);
const manifest = readFileSync(join(f.directory, 'setup.json'), 'utf8');
for (const secret of [f.secret, f.owner, second]) assert.ok(!manifest.includes(secret));
removeSlotKey(f.directory, f.agent, f.backend);
assert.throws(() => reconcileCredentialProvision(f.directory, f.setup, f.secret, f.genesis, f.backend), /Active installation/);
assert.throws(() => addSlot(f.directory, f.secret, f.genesis, 'default', undefined, f.backend), /Slot exists/);
assert.equal(installationSlots(f.directory, f.backend).find(s => s.agent === f.agent)!.keyPresent, false);
} finally { rmSync(f.directory, { recursive: true, force: true }); }
}
});
test('explicit added-slot recovery reconciles interrupted additions without reset or key replacement', () => {
for (const failure of ['before-store', 'after-store'] as const) {
const f = fixture();
try {
provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend);
const siblingJournal = readFileSync(join(f.directory, 'agents', f.agent, 'journal.json'), 'utf8');
const second = newKey(), key = publicKey(second);
const standby = createGenesis(publicKey(f.owner), key, 'retained-source');
assert.throws(() => addSlot(f.directory, second, standby, 'default', undefined, {
...f.backend, create(ref, secret) { if (failure === 'after-store') f.backend.create(ref, secret); throw Error('injected store boundary'); },
}), /injected/);
const orphanPath = join(f.directory, 'agents', key, 'journal.json');
assert.ok(existsSync(orphanPath), 'interrupted add retains an inert orphan journal');
const orphanJournal = readFileSync(orphanPath, 'utf8'), manifestBefore = readFileSync(join(f.directory, 'setup.json'), 'utf8');
const discovered = orphanCredentialSlots(f.directory);
assert.equal(discovered.orphans.length, 1);
assert.equal(discovered.host, f.setup.host);
assert.equal(discovered.ownerPublic, publicKey(f.owner));
const found = discovered.orphans[0]!;
assert.equal(found.agent, key);
assert.deepEqual(found.candidates.map(c => c.setupId), ['default']);
assert.equal(found.genesis.initialHost, 'retained-source');
const fingerprint = found.candidates[0]!.fingerprint;
// Every wrong input refuses before any store or manifest mutation.
assert.throws(() => reconcileSlot(f.directory, 'default', second, standby, '0'.repeat(64), f.backend), /Binding definition changed/);
assert.throws(() => reconcileSlot(f.directory, 'missing', second, standby, undefined, f.backend), /Unknown host harness setup/);
assert.throws(() => reconcileSlot(f.directory, 'default', second, createGenesis(publicKey(f.owner), key, 'another-host'), fingerprint, f.backend), /differs from exact inert state/);
// A wrong key refuses against the supplied identity before any store or journal effect.
assert.throws(() => reconcileSlot(f.directory, 'default', newKey(), standby, fingerprint, f.backend), /Genesis ownership mismatch/);
assert.throws(() => reconcileSlot(f.directory, 'default', second, createGenesis(publicKey(newKey()), key, 'retained-source'), fingerprint, f.backend), /Genesis ownership mismatch/);
assert.equal(readFileSync(orphanPath, 'utf8'), orphanJournal);
assert.equal(readFileSync(join(f.directory, 'setup.json'), 'utf8'), manifestBefore, 'refusals leave the manifest inert');
// Matching reconcile: an existing orphan key is adopted, a missing one is created exactly once.
reconcileSlot(f.directory, 'default', second, standby, fingerprint, failure === 'after-store'
? { ...f.backend, create() { throw Error('must adopt the existing orphan key'); } }
: f.backend);
assert.equal(readFileSync(orphanPath, 'utf8'), orphanJournal, 'recovery never rewrites the retained journal');
assert.equal(readFileSync(join(f.directory, 'agents', f.agent, 'journal.json'), 'utf8'), siblingJournal, 'sibling journals are untouched');
const loaded = installationSlots(f.directory, f.backend);
assert.equal(loaded.length, 2);
const added = loaded.find(s => s.agent === key)!;
assert.equal(added.setupId, 'default');
assert.equal(added.keyPresent, true);
assert.equal(added.setup.agentSecret, second);
assert.equal((readPrivate(orphanPath) as any).assignment.assignedHost, 'retained-source');
const manifest = readFileSync(join(f.directory, 'setup.json'), 'utf8');
for (const secret of [f.secret, f.owner, second]) assert.ok(!manifest.includes(secret), 'manifest must not persist secrets');
// Active and public-only retained slots never enter recovery again.
assert.throws(() => reconcileSlot(f.directory, 'default', second, standby, fingerprint, f.backend), /Retained slot exists; use explicit import-agent-key/);
removeSlotKey(f.directory, key, f.backend);
assert.throws(() => reconcileSlot(f.directory, 'default', second, standby, fingerprint, f.backend), /Retained slot exists; use explicit import-agent-key/);
importSlotKey(f.directory, key, second, f.backend);
} finally { rmSync(f.directory, { recursive: true, force: true }); }
}
});
test('added-slot discovery is public-only and refuses foreign or absent partial state', () => {
const f = fixture();
try {
assert.throws(() => orphanCredentialSlots(f.directory), /ENOENT/);
provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend);
assert.deepEqual(orphanCredentialSlots(f.directory).orphans, []);
const foreign = newKey(), foreignKey = publicKey(foreign), otherOwner = newKey();
const setup = validateSetup({ host: 'host-a', ownerPublic: publicKey(otherOwner), mode: 'fixture', runner: process.execPath, args: [], workspace: f.directory, agentSecret: foreign });
writePrivate(join(f.directory, 'agents', foreignKey, 'journal.json'), initialState(setup, createGenesis(publicKey(otherOwner), foreignKey, 'host-a')), true);
assert.throws(() => orphanCredentialSlots(f.directory), /not owned by this installation/);
} finally { rmSync(f.directory, { recursive: true, force: true }); }
});
test('fresh-assignment recovery, canonical genesis equality and v3-only wrapper', () => {
const f = fixture();
try {
provisionCredentialSlot(f.directory, f.setup, f.secret, f.genesis, f.backend);
const second = newKey(), key = publicKey(second), fresh = createGenesis(publicKey(f.owner), key, f.setup.host);
assert.throws(() => addSlot(f.directory, second, fresh, 'default', undefined, { ...f.backend, create() { throw Error('injected'); } }), /injected/);
const found = orphanCredentialSlots(f.directory).orphans[0]!;
assert.equal(found.genesis.initialHost, f.setup.host);
reconcileSlot(f.directory, 'default', second, fresh, found.candidates[0]!.fingerprint, f.backend);
assert.equal((readPrivate(join(f.directory, 'agents', key, 'journal.json')) as any).assignment.assignedHost, f.setup.host);
// A semantically identical genesis with reordered fields reconciles the same journal.
const third = newKey(), thirdKey = publicKey(third);
const root = createGenesis(publicKey(f.owner), thirdKey, f.setup.host);
assert.throws(() => addSlot(f.directory, third, root, 'default', undefined, { ...f.backend, create() { throw Error('injected'); } }), /injected/);
const reordered = { initialHost: root.initialHost, agent: root.agent, owner: root.owner, id: root.id, v: 1 as const };
reconcileSlot(f.directory, 'default', third, reordered, orphanCredentialSlots(f.directory).orphans[0]!.candidates[0]!.fingerprint, f.backend);
assert.equal((readPrivate(join(f.directory, 'agents', thirdKey, 'journal.json')) as any).assignment.assignedHost, f.setup.host);
assert.equal(installationSlots(f.directory, f.backend).length, 3);
} finally { rmSync(f.directory, { recursive: true, force: true }); }
const directory = mkdtempSync(join(tmpdir(), 'beehive-legacy-reconcile-'));
try {
const owner = newKey(), secret = newKey();
const setup = validateSetup({ host: 'legacy', ownerSecret: owner, agentSecret: secret, mode: 'fixture', runner: process.execPath, args: [], workspace: directory });
provision(directory, setup, createGenesis(publicKey(owner), publicKey(secret), 'legacy'));
migrateSlots(directory);
assert.throws(() => reconcileSlot(directory, 'default', secret, createGenesis(publicKey(owner), publicKey(secret), 'legacy'), undefined, memoryCredentials()), /migrate-slots/);
} finally { rmSync(directory, { recursive: true, force: true }); }
});