Files
buzz/beehive/test/host-availability.test.ts

220 lines
14 KiB
TypeScript

import { npubEncode } from 'nostr-tools/nip19';
import { ownerPublicInput } from '../src/host-setup.ts';
import { readHostIdentityPublic } from '../src/host-identity.ts';
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdtempSync, writeFileSync, readFileSync, existsSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
import { newKey, publicKey, message, parseMessage } from '../src/protocol.ts';
import { bootstrapHostIdentity } from '../src/host-identity.ts';
import { credentialReference } from '../src/credential-store.ts';
import { isolatedFileCredentials } from './isolated-file-credentials.ts';
import { nostrFixture } from './nostr-fixture.ts';
import { connectNostr } from '../src/nostr-client.ts';
import { catalogTransport } from '../src/catalog-transport.ts';
import { HostOffers, HostInventory, catalogResponse, type HostCatalog } from '../src/host-catalog.ts';
import { createGenesis } from '../src/assignment.ts';
import { writePrivate } from '../src/storage.ts';
async function wait(predicate: () => boolean, detail: () => string = () => '') {
for (let n = 0; n < 300; n++) { if (predicate()) return; await delay(20); }
throw Error(`Timed out: ${detail()}`);
}
test(`actual zero-agent host CLI advertises privately; file-free owner TUI discovery is not Start authority`, async () => {
const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-availability-'));
const owner = newKey(), stranger = newKey();
const file = join(root, 'credentials.json'), credentials = isolatedFileCredentials(file);
const directory = join(root, 'host');
const access = { open: true, denyAuth: false };
const relay = await nostrFixture(publicKey(owner), new Set(), access);
const reads = join(root, 'reads.jsonl');
const helper = join(root, 'credential-child.mjs');
writeFileSync(helper, `import {readFileSync,appendFileSync} from 'node:fs';let input='';for await(const b of process.stdin)input+=b;appendFileSync(${JSON.stringify(reads)},input+'\\n');const secret=JSON.parse(readFileSync(${JSON.stringify(file)},'utf8'))[JSON.stringify(JSON.parse(input))];process.stdout.write(JSON.stringify(secret?{status:'present',secret}:{status:'missing'}));`);
const loader = join(root, 'helper-loader.mjs');
writeFileSync(loader, `import {registerHooks} from 'node:module';registerHooks({load(url,ctx,next){const r=next(url,ctx);if(!url.endsWith('/src/credential-helper.ts'))return r;const s=String(r.source);if(!s.includes("new URL('./credential-helper-child.ts', import.meta.url)"))throw Error('fixture injection failed');return {...r,source:s.replace("new URL('./credential-helper-child.ts', import.meta.url)",${JSON.stringify(`new URL(${JSON.stringify('file://' + helper)})`)})};}});`);
const children: ReturnType<typeof spawn>[] = [];
function cli(args: string[]) {
const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), '--import', loader, 'src/cli.ts', ...args], { env: { PATH: '/usr/bin:/bin', HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: file }, stdio: ['pipe', 'pipe', 'pipe'] });
children.push(child);
let output = ''; child.stdout!.on('data', b => output += b); child.stderr!.on('data', b => output += b);
const exit = new Promise<number | null>(done => child.on('close', done));
return { child, output: () => output, exit };
}
async function setup(steps: [string, string][], code = 0) {
const run = cli(['setup', directory]);
let cursor = 0;
for (const [prompt, answer] of steps) {
await wait(() => run.output().slice(cursor).includes(prompt), run.output);
cursor = run.output().length; run.child.stdin!.write(answer + '\n');
}
assert.equal(await run.exit, code, run.output());
assert.ok(!run.output().includes('Host online'));
return run.output();
}
await setup([['Owner PUBLIC npub', npubEncode(publicKey(owner))], ['Management relay URL', relay.url], ['Create host identity', 'yes']]);
const identity = readHostIdentityPublic(directory);
assert.equal(identity.pairing.owner, publicKey(owner));
assert.equal(identity.registration, null);
const retained = readFileSync(join(directory, 'host-identity.json'));
const retainedKeys = readFileSync(file);
writeFileSync(file, 'credential access during retained setup must fail');
const resumed = await setup([]);
assert.equal(readFileSync(file, 'utf8'), 'credential access during retained setup must fail');
writeFileSync(file, retainedKeys);
assert.match(resumed, /Host configured, NOT serving/);
assert.equal(relay.httpRequests.length, 0);
assert.equal(relay.connectionsOpened, 0);
assert.equal(existsSync(reads), false, 'setup/resume never opens credential helper');
assert.deepEqual(readFileSync(join(directory, 'host-identity.json')), retained);
assert.deepEqual(readFileSync(file), retainedKeys);
const host = cli(['host', directory, '--owner-present']);
let ownerWire: ReturnType<ReturnType<typeof catalogTransport>> | undefined;
let wrong: ReturnType<typeof connectNostr> | undefined;
try {
await wait(() => host.output().includes('Host online'), host.output);
assert.ok(!existsSync(join(directory, 'setup.json')));
const catalog: HostCatalog = { version: 1, owner: publicKey(owner), relay: relay.url, registrations: [] };
const reports: ReturnType<typeof message>[] = [];
ownerWire = catalogTransport(catalog)(relay.url, owner, m => reports.push(m));
await ownerWire.ready;
await wait(() => reports.some(m => m.type === 'availability'));
assert.equal(reports.filter(m => m.type === 'inventory').length, 0);
assert.equal(relay.httpRequests.length, 0);
assert.ok(relay.history.every(e => JSON.stringify(e.tags) === JSON.stringify([['p', publicKey(owner)]])));
assert.ok(relay.history.every(e => !e.content.includes(identity.pairing.label) && !e.content.includes(identity.pairing.host)));
const leaked: unknown[] = [];
wrong = connectNostr(relay.url, Buffer.from(stranger, 'hex'), m => leaked.push(m), () => {});
await wrong.ready;
const invalid = message('start', identity.pairing.host, publicKey(newKey()));
await wrong.publish(invalid, identity.pairing.host);
const start = message('start', identity.pairing.host, invalid.agent);
ownerWire.send(start);
await wait(() => reports.some(m => m.type === 'receipt' && m.body.operation === start.id));
assert.equal(reports.find(m => m.body.operation === start.id)!.body.result, 'not-authority');
await delay(150);
assert.ok(!reports.some(m => m.body.operation === invalid.id));
assert.equal(leaked.length, 0);
const ui = cli(['tui', 'discover', relay.url, join(root, 'owner-state')]);
await wait(() => ui.output().includes('Owner private key'), ui.output);
ui.child.stdin!.write(owner + '\n');
await wait(() => ui.output().includes('beehive> '), ui.output);
await delay(150);
ui.child.stdin!.write('hosts\n');
await wait(() => ui.output().includes('available infrastructure (not agent authorization)'), ui.output);
assert.ok(ui.output().includes(identity.pairing.host));
ui.child.stdin!.write('agents\n');
await wait(() => ui.output().includes('Per-host reports'), ui.output);
assert.ok(!ui.output().includes(`Agent ${invalid.agent}`));
ui.child.stdin!.write('quit\n'); assert.equal(await ui.exit, 0, ui.output());
host.child.kill('SIGTERM'); assert.equal(await host.exit, 0, host.output());
assert.deepEqual(readFileSync(join(directory, 'host-identity.json')), retained);
assert.deepEqual(readFileSync(reads, 'utf8').trim().split('\n').map(s => JSON.parse(s)), [credentialReference('host', identity.pairing.host)]);
assert.ok(!existsSync(join(directory, 'agents')));
// Availability cannot repair a missing placement grant. The actual CLI must
// reject a forged assignment before it asks the credential helper for an agent.
const agent = newKey();
const bindingFile = join(root, 'binding.json'), genesisFile = join(root, 'genesis.json');
writePrivate(bindingFile, { runner: process.execPath, args: [resolve('test/runner.ts')], workspace: root, mode: 'fixture' });
writePrivate(genesisFile, createGenesis(publicKey(owner), publicKey(agent), 'source-host'));
const provision = cli(['provision-agent', directory, bindingFile, genesisFile]);
await wait(() => provision.output().includes('Agent private key'), provision.output);
provision.child.stdin!.write(agent + '\n');
assert.equal(await provision.exit, 0, provision.output());
assert.match(provision.output(), /Provisioned STOPPED public slot/);
const journalFile = join(directory, 'agents', publicKey(agent), 'journal.json');
const forged = JSON.parse(readFileSync(journalFile, 'utf8'));
forged.assignment.assignedHost = identity.pairing.host; // No owner-authorized transfer chain.
writePrivate(journalFile, forged);
const rejected = cli(['host', directory, '--owner-present']);
assert.notEqual(await rejected.exit, 0);
assert.ok(!rejected.output().includes('Host online'));
assert.deepEqual(JSON.parse(readFileSync(journalFile, 'utf8')), forged);
assert.ok(readFileSync(reads, 'utf8').trim().split('\n').every(s => JSON.parse(s).role === 'host'));
// An actual server AUTH refusal fails startup, never launches enrollment.
access.denyAuth = true;
const pending = bootstrapHostIdentity(join(root, 'pending'), 'Pending', publicKey(owner), relay.url, credentials);
const denied = cli(['host', join(root, 'pending'), '--owner-present']);
assert.notEqual(await denied.exit, 0);
assert.match(denied.output(), /authentication denied/);
assert.ok(!denied.output().includes('Host online'));
assert.equal(relay.httpRequests.length, 0);
// Missing retained host credential is not repaired or regenerated by resume.
credentials.remove(credentialReference('host', identity.pairing.host));
const missingKeys = readFileSync(file);
const missing = await setup([]);
assert.match(missing, /Host configured, NOT serving/);
assert.deepEqual(readFileSync(file), missingKeys);
assert.deepEqual(readFileSync(join(directory, 'host-identity.json')), retained);
} finally {
ownerWire?.close(); wrong?.close();
for (const child of children) if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
await Promise.all(children.map(c => c.exitCode !== null || c.signalCode !== null ? Promise.resolve() : new Promise(done => c.on('close', done))));
await relay.close(); rmSync(root, { recursive: true, force: true });
}
});
test('availability schema and catalog fences: scope, signer, freshness, no fabricated approval', () => {
const owner = publicKey(newKey()), host = publicKey(newKey()), relay = 'wss://example.invalid';
const catalog: HostCatalog = { version: 1, owner, relay, registrations: [] };
const now = Math.floor(Date.now() / 1000) * 1000;
const configuration = { version: 1, purpose: 'beehive-host-registration', owner, host, relay, label: 'host', nonce: newKey() };
const offer = message('availability', host, '', 0, { configuration, observedAt: now });
const offers = new HostOffers();
assert.throws(() => parseMessage({ ...offer, agent: 'fake-agent' }));
assert.throws(() => catalogResponse(catalog, { sender: owner, message: offer }, now / 1000, offers));
assert.throws(() => catalogResponse(catalog, { sender: host, message: { ...offer, body: { ...offer.body, configuration: { ...configuration, owner: host } } } }, now / 1000, offers));
assert.throws(() => catalogResponse(catalog, { sender: host, message: offer }, now / 1000 + 7, offers));
catalogResponse(catalog, { sender: host, message: offer }, now / 1000, offers);
assert.equal(catalog.registrations.length, 0);
const view = new HostInventory(catalog); view.receive({ sender: host, message: offer }, now);
assert.equal(view.rows(now)[0]!.agent, undefined);
assert.match(view.rows(now)[0]!.status, /available infrastructure/);
assert.equal(view.rows(now + 7000)[0]!.status, 'unreachable/unknown');
});
test('normal public owner inputs reject secrets', () => {
const owner = publicKey(newKey());
assert.equal(ownerPublicInput(npubEncode(owner)), owner);
assert.equal(ownerPublicInput(owner.toUpperCase()), owner);
assert.throws(() => ownerPublicInput('nsec1invalid'));
});
test('default manager partitions unsigned Local Host from persisted owner Agents sign-in', async () => {
const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-manager-partition-'));
const owner = newKey();
const file = join(root, 'credentials.json');
const directory = join(root, '.beehive', 'host');
bootstrapHostIdentity(directory, 'Local machine', publicKey(owner), 'ws://127.0.0.1:1', isolatedFileCredentials(file));
const child = spawn(process.execPath, ['--import', resolve('test/isolated-credentials-loader.ts'), 'src/cli.ts'], {
env: { ...process.env, HOME: root, BEEHIVE_TEST_CREDENTIAL_FILE: file }, stdio: ['pipe', 'pipe', 'pipe'],
});
let output = ''; child.stdout!.on('data', b => output += b); child.stderr!.on('data', b => output += b);
const exit = new Promise<number | null>(done => child.on('close', done));
try {
await wait(() => output.includes('Beehive | Local Host'), () => output);
child.stdin!.write('local\n');
await wait(() => output.includes('Local Host Local machine'), () => output);
assert.ok(!output.includes('Owner private key'), 'local host inspection never asks for the owner signer');
child.stdin!.write('agents\n');
await wait(() => output.includes('Owner PUBLIC npub'), () => output);
child.stdin!.write('\n');
await wait(() => output.includes('Management relay URL'), () => output);
child.stdin!.write('\n');
await wait(() => output.includes('Owner private key'), () => output);
child.stdin!.write(owner + '\n');
await wait(() => existsSync(join(root, '.beehive', 'owner', 'controller.json')));
await wait(() => JSON.parse(readFileSync(file, 'utf8'))[JSON.stringify(credentialReference('owner', publicKey(owner)))] === owner);
assert.deepEqual(JSON.parse(readFileSync(join(root, '.beehive', 'owner', 'controller.json'), 'utf8')), {
version: 1, owner: publicKey(owner), relay: 'ws://127.0.0.1:1',
});
} finally {
child.kill('SIGKILL'); await exit; rmSync(root, { recursive: true, force: true });
}
});