mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
116 lines
7.9 KiB
TypeScript
116 lines
7.9 KiB
TypeScript
import { isolatedFileCredentials } from './isolated-file-credentials.ts';
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtempSync, readFileSync, readdirSync, rmSync, copyFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { newKey, publicKey } from '../src/protocol.ts';
|
|
import { readHostIdentity } from '../src/host-identity.ts';
|
|
import { setupPath } from '../src/setup-path.ts';
|
|
import { homedir } from 'node:os';
|
|
import { createGenesis } from '../src/assignment.ts';
|
|
import { writePrivate } from '../src/storage.ts';
|
|
import { pairingFingerprint, registerHost } from '../src/host-registration.ts';
|
|
|
|
async function wizard(directory: string, steps: [string, string][], credentialFile: string, expectedCode = 0, home?: string, command = 'legacy-enrollment', extra: string[] = []): Promise<string> {
|
|
const env: NodeJS.ProcessEnv = { ...process.env, ...(home ? { HOME: home } : {}), BEEHIVE_TEST_CREDENTIAL_FILE: credentialFile };
|
|
delete env.BUZZ_PRIVATE_KEY; delete env.BUZZ_AUTH_TAG; delete env.BUZZ_RELAY_URL;
|
|
const child = spawn(process.execPath, ['--import', fileURLToPath(new URL('./isolated-credentials-loader.ts', import.meta.url)), fileURLToPath(new URL('../src/cli.ts', import.meta.url)), command, directory, ...extra], { env, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
let output = '', cursor = 0, next = 0;
|
|
const timer = setTimeout(() => child.kill(), 10000);
|
|
child.stdout.on('data', data => {
|
|
output += data.toString();
|
|
if (next < steps.length) {
|
|
const [prompt, answer] = steps[next]!;
|
|
const index = output.indexOf(prompt, cursor);
|
|
if (index !== -1) { cursor = index + prompt.length; next++; child.stdin.write(`${answer}\n`); }
|
|
}
|
|
});
|
|
child.stderr.on('data', data => { output += data.toString(); });
|
|
try {
|
|
const code = await new Promise<number | null>((resolve, reject) => { child.on('error', reject); child.on('exit', resolve); });
|
|
assert.equal(code, expectedCode, output); assert.equal(next, steps.length, output);
|
|
return output;
|
|
} finally { clearTimeout(timer); child.stdin.end(); }
|
|
}
|
|
|
|
test('actual standalone CLI private pairing, existing-owner approval and bootstrap return stay offline', async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'beehive-pairing-cli-'));
|
|
const host = join(root, 'host'), ownerContext = join(root, 'owner-context');
|
|
let request: string, approval: string;
|
|
const ownerSecret = newKey();
|
|
const credentialFile = join(root, 'isolated-fixture-secrets.json');
|
|
const credentials = isolatedFileCredentials(credentialFile);
|
|
try {
|
|
const created = await wizard(host, [
|
|
['Enrollment [', 'create'], ['Computer name [', ''], ['Owner PUBLIC key (hex,', publicKey(ownerSecret)],
|
|
['Management relay URL (', 'wss://example.invalid'],
|
|
], credentialFile);
|
|
request = join(host, 'exchange', readdirSync(join(host, 'exchange'))[0]!);
|
|
assert.match(created, /Request saved:/);
|
|
assert.ok(!created.includes('output path (~ supported):'));
|
|
const original = readHostIdentity(host, credentials);
|
|
const identityBytes = readFileSync(join(host, 'host-identity.json'));
|
|
// Re-export reads only retained public state, not even the isolated credential store.
|
|
const poison = join(root, 'must-not-read.json'); writeFileSync(poison, 'invalid json');
|
|
const resumed = await wizard(host, [['Enrollment [', '']], poison);
|
|
assert.match(resumed, /Existing computer identity retained/);
|
|
assert.equal(readdirSync(join(host, 'exchange')).length, 2);
|
|
assert.deepEqual(readFileSync(join(host, 'host-identity.json')), identityBytes);
|
|
assert.deepEqual(JSON.parse(readFileSync(request, 'utf8')), original.pairing);
|
|
const explicit = await wizard(host, [['Enrollment [', 'export-to'], ['output path (~ supported): ', '~/manual-request.json']], poison, 0, root);
|
|
assert.match(explicit, /manual-request.json/);
|
|
const manualBytes = readFileSync(join(root, 'manual-request.json'));
|
|
const refused = await wizard(host, [['Enrollment [', 'export-to'], ['output path (~ supported): ', '~/manual-request.json']], poison, 1, root);
|
|
assert.match(refused, /EEXIST/);
|
|
assert.deepEqual(readFileSync(join(root, 'manual-request.json')), manualBytes);
|
|
const missing = await wizard(host, [['Enrollment [', 'import'], ['Private approval file (', '']], poison, 1);
|
|
assert.match(missing, /Transfer the owner-signed approval/);
|
|
assert.ok(created.includes(pairingFingerprint(original.pairing)));
|
|
assert.equal(original.registration, null);
|
|
const signed = await wizard(ownerContext, [
|
|
['Enrollment [', 'approve'], ['Private pairing file (', '~/manual-request.json'], ['[yes/no]: ', 'yes'],
|
|
['Approval validity (', '7d'], ['Owner private key (64 hex; hidden, never passed as an argument): ', ownerSecret],
|
|
], credentialFile, 0, root);
|
|
approval = join(ownerContext, 'exchange', readdirSync(join(ownerContext, 'exchange'))[0]!);
|
|
assert.ok(signed.includes(pairingFingerprint(original.pairing)));
|
|
assert.ok(!signed.includes(ownerSecret));
|
|
const catalogOutput = await wizard(approval, [], credentialFile, 0, undefined, 'catalog');
|
|
assert.match(catalogOutput, /Next on owner computer: beehive tui/);
|
|
const defaultApproval = join(host, 'exchange', `approval-${pairingFingerprint(original.pairing)}.json`);
|
|
// A matching filename does not authorize anything.
|
|
writePrivate(defaultApproval, registerHost({ ...original.pairing, nonce: newKey() }, ownerSecret, Math.floor(Date.now() / 1000) + 300), true);
|
|
await wizard(host, [['Enrollment [', 'import'], ['Private approval file (', '']], credentialFile, 1);
|
|
assert.deepEqual(readFileSync(join(host, 'host-identity.json')), identityBytes);
|
|
copyFileSync(approval, defaultApproval);
|
|
const imported = await wizard('~/host', [['Enrollment [', 'import'], ['Private approval file (', '']], credentialFile, 0, root);
|
|
assert.ok(imported.includes('Host registered offline'));
|
|
const enrolled = readHostIdentity(host, credentials);
|
|
assert.equal(enrolled.secret, original.secret);
|
|
assert.ok(!readFileSync(join(host, 'host-identity.json'), 'utf8').includes(original.secret));
|
|
assert.deepEqual(enrolled.pairing, original.pairing);
|
|
assert.deepEqual(readdirSync(host), ['credential-attempts.json', 'exchange', 'host-identity.json']);
|
|
assert.equal(statSync(join(host, 'exchange')).mode & 0o777, 0o700);
|
|
for (const file of [request, approval, defaultApproval]) assert.equal(statSync(file).mode & 0o777, 0o600);
|
|
const ownerOnHost = await wizard(host, [['Enrollment [', 'approve']], poison, 1);
|
|
assert.match(ownerOnHost, /Approve on the trusted owner computer/);
|
|
assert.ok(!ownerOnHost.includes('Owner private key'));
|
|
const agentSecret = newKey();
|
|
writePrivate(join(root, 'binding.json'), { mode: 'fixture', runner: process.execPath, args: [fileURLToPath(new URL('./runner.ts', import.meta.url))], workspace: root }, true);
|
|
writePrivate(join(root, 'genesis.json'), createGenesis(publicKey(ownerSecret), publicKey(agentSecret), original.pairing.host), true);
|
|
const provisioned = await wizard('~/host', [['Agent private key (64 hex; hidden, never passed as an argument): ', agentSecret]], credentialFile, 0, root, 'provision-agent', ['~/binding.json', '~/genesis.json']);
|
|
assert.match(provisioned, /Provisioned STOPPED/);
|
|
assert.ok(!provisioned.includes(agentSecret));
|
|
assert.ok(!readFileSync(join(host, 'setup.json'), 'utf8').includes(agentSecret));
|
|
for (const path of [request, approval, join(host, 'host-identity.json')]) assert.ok(!readFileSync(path, 'utf8').includes(ownerSecret));
|
|
} finally { rmSync(root, { recursive: true, force: true }); }
|
|
});
|
|
|
|
test('setup paths expand only conventional home spelling', () => {
|
|
assert.equal(setupPath('~'), homedir());
|
|
assert.equal(setupPath('~/a b'), join(homedir(), 'a b'));
|
|
assert.throws(() => setupPath('~someone/file'), /~user/);
|
|
});
|