Files
stack/packages/bus/tests/library-sync.test.mjs

146 lines
9.6 KiB
JavaScript

// C2-03a: library.sync on the bus and the bus host's 60-second timer, with made-up local data.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createServer } from 'node:net';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { Broker, LIBRARY_VERBS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { gitEnv } from '../../seat/src/checkout.mjs';
import { mappingEntry, repo } from '../../library/tests/helpers.mjs';
import * as library from '../../library/src/index.mjs';
const TOKEN = 'fixture-token-9b2e-made-up-0043';
const STATE = { version: 1, branch: 'main', served: 'a'.repeat(40), lastFetch: '2026-10-21T12:00:00.000Z', lastError: null, diverged: null, problems: [] };
function world(t, adapter) {
const root = mkdtempSync(join(tmpdir(), 'bus-library-sync-')), store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = { id: 'acme', human: 'jason', vars: { 'bus.tester': true },
projects: { books: { ...mappingEntry(), user: 'books-bot' } }, arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } } };
const broker = new Broker({ store, businesses: { acme: business }, library: adapter });
const human = broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
const agent = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'library-sync-agent', harness: 'pi' });
const reader = broker.bindReader({ business: 'acme' }), tester = broker.bindTester({ business: 'acme' });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => broker.request(cap, { verb, args }));
const refusals = () => store.all("SELECT body FROM events WHERE kind='action.refused'").map(row => JSON.parse(row.body).code);
return { store, broker, human, agent, reader, tester, call, refusals };
}
const adapter = (sync) => ({ readFile: () => {}, status: () => {}, sync, LibraryError: library.LibraryError });
test('C2-03a library.sync: every agent, the tester and a reader are refused before the adapter runs', async t => {
let touched = 0;
const f = world(t, adapter(async () => { touched++; return { state: STATE, code: null }; }));
await assert.rejects(f.call(f.agent, 'library.sync', { project: 'books' }), { code: 'human-required' });
await assert.rejects(f.call(f.tester, 'library.sync', { project: 'books' }), { code: 'human-required' });
await assert.rejects(f.call(f.reader, 'library.sync', { project: 'books' }), { code: 'read-only' });
assert.equal(touched, 0);
assert.ok(LIBRARY_VERBS.includes('library.sync'), 'the server awaits it like the other library verbs');
});
test('C2-03a library.sync: the human fetches, accept passes through, and a held sync is a recorded refusal', async t => {
const seen = [];
let code = null;
const f = world(t, adapter(async (context, options) => { seen.push({ context, options }); return { state: STATE, code }; }));
assert.deepEqual(await f.call(f.human, 'library.sync', { project: 'books' }), { project: 'books', ...STATE });
assert.equal(seen[0].context.business.id, 'acme');
assert.equal(seen[0].context.project, 'books');
assert.deepEqual(seen[0].options, { accept: null });
await f.call(f.human, 'library.sync', { project: 'books', accept: 'b'.repeat(40) });
assert.deepEqual(seen[1].options, { accept: 'b'.repeat(40) });
for (const held of ['library-diverged', 'library-fetch-failed', 'library-credential-missing']) {
code = held;
await assert.rejects(f.call(f.human, 'library.sync', { project: 'books' }), { code: held });
}
assert.deepEqual(f.refusals(), ['library-diverged', 'library-fetch-failed', 'library-credential-missing']);
for (const args of [{}, { project: 'books', extra: 1 }, { project: 'books', commit: 'b'.repeat(40) }])
await assert.rejects(f.call(f.human, 'library.sync', args), { code: 'invalid-request' });
});
test('C2-03a library.sync: a LibraryError keeps its code and a host without sync is not configured', async t => {
const f = world(t, adapter(async () => { throw new library.LibraryError('library-accept-stale', 'made up'); }));
await assert.rejects(f.call(f.human, 'library.sync', { project: 'books', accept: 'c'.repeat(40) }), { code: 'library-accept-stale' });
const none = world(t, adapter(undefined));
await assert.rejects(none.call(none.human, 'library.sync', { project: 'books' }), { code: 'project-not-configured' });
});
test('C2-03a runtime: a librarySync from a boot config, with no function, is refused', async t => {
const root = mkdtempSync(join(tmpdir(), 'bus-library-boot-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
for (const librarySync of [{}, { intervalMs: 1 }, { command: 'git' }, { command: () => {}, intervalMs: 0 }, { command: () => {}, configDir: 7 }])
await assert.rejects(startBroker({ dataRoot: root, businesses: {}, librarySync }), { code: 'invalid-host-request' });
});
test('C2-03a runtime: the timer syncs each entry with a user at start and on each tick, logs codes only, and stops on close', { timeout: 20000 }, async t => {
const f = repo(t), dataRoot = join(f.root, 'data'), configDir = join(f.root, 'config');
mkdirSync(dataRoot, { mode: 0o700 });
mkdirSync(join(configDir, 'secrets', 'acme'), { recursive: true, mode: 0o700 });
writeFileSync(join(configDir, 'secrets', 'acme', library.SLOT), TOKEN + '\n', { mode: 0o600 });
const business = { id: 'acme', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: {chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: { authority: { withinRole: [], crossRole: [] } } },
projects: { books: { ...mappingEntry(), user: 'books-bot' }, quiet: mappingEntry() } };
const calls = [];
// A git that succeeds without fetching: the remote-tracking ref stays absent, so each pass fails.
const command = (kind, url, opts) => { calls.push({ kind, url, opts }); return { argv: ['--version'], env: gitEnv() }; };
const lines = [], write = process.stderr.write;
process.stderr.write = function (chunk, ...rest) {
if (String(chunk).startsWith('library: ')) { lines.push(String(chunk)); return true; }
return write.call(this, chunk, ...rest);
};
t.after(() => { process.stderr.write = write; });
const runtime = await startBroker({ dataRoot, businesses: { acme: business }, librarySync: { command, intervalMs: 50, configDir } });
const deadline = Date.now() + 10000;
while (calls.length < 2 && Date.now() < deadline) await new Promise(r => setTimeout(r, 10));
await runtime.close();
const after = calls.length;
assert.ok(after >= 2, 'a pass at start and at least one tick');
assert.ok(calls.every(c => c.kind === 'fetch' && c.opts.slot === join(configDir, 'secrets', 'acme', library.SLOT) && c.opts.user === 'books-bot'));
assert.equal(new Set(calls.map(c => c.opts.mirror)).size, 1, 'the entry with no user is never synced');
await new Promise(r => setTimeout(r, 200));
assert.equal(calls.length, after, 'no pass after close');
assert.ok(lines.length >= 1);
for (const line of lines) {
assert.deepEqual(JSON.parse(line.slice('library: '.length)), { event: 'library-sync-held', business: 'acme', project: 'books', code: 'library-fetch-failed' });
assert.equal(line.includes(TOKEN), false);
}
const mirror = library.mirrorPath({ business, project: 'books', dataRoot });
assert.equal((await library.readSyncState(mirror, { branch: 'main' })).lastError.code, 'library-fetch-failed');
});
// Row 254 (#1759): the human transport waits past the server's 120 s library hold for library.sync
// only. A stand-in bus answers each request after 6 s, past the 5 s default every other verb keeps.
test('row 254 human-cli: library.sync outlasts the 5 s client default, and library.status does not', { timeout: 20000 }, async t => {
const root = mkdtempSync(join(tmpdir(), 'bus-library-cli-'));
const socket = join(root, 'bus.sock'), timers = new Set();
const server = createServer(conn => {
let input = '';
conn.on('error', () => {});
conn.on('data', b => {
input += b;
if (!input.includes('\n')) return;
const { verb } = JSON.parse(input);
timers.add(setTimeout(() => { if (!conn.destroyed) conn.end(JSON.stringify({ ok: true, result: { verb } }) + '\n'); }, 6000));
});
});
await new Promise(r => server.listen(socket, r));
t.after(() => { for (const timer of timers) clearTimeout(timer); server.close(); rmSync(root, { recursive: true, force: true }); });
const cli = fileURLToPath(new URL('../src/human-cli.mjs', import.meta.url));
const run = verb => new Promise(resolve => {
const child = spawn(process.execPath, [cli, socket], { stdio: ['pipe', 'pipe', 'pipe'] });
let out = '', err = '';
child.stdout.on('data', b => { out += b; });
child.stderr.on('data', b => { err += b; });
child.on('close', status => resolve({ status, out, err }));
child.stdin.end(JSON.stringify({ business: 'acme', verb, args: { project: 'books' } }) + '\n');
});
const [sync, status] = await Promise.all([run('library.sync'), run('library.status')]);
assert.equal(sync.status, 0, sync.err);
assert.deepEqual(JSON.parse(sync.out), { verb: 'library.sync' });
assert.equal(status.status, 2);
assert.equal(status.err.trim(), 'outcome-unknown');
});