Files
stack/packages/cli/tests/org-bus.test.mjs
jason.woltje a8d9eecf65 organization: move packages/business to packages/organization (rename r2 stage B1)
git mv packages/business -> packages/organization (@mosaic/organization). The
old package stays as one-line re-export shims of each moved module, with
src/business.mjs re-exporting src/organization-config.mjs and src/cli.mjs
forwarding to the moved executable. BusinessError is OrganizationConfigError
(one constructor, name kept BusinessError); OrganizationError stays distinct.
The bus adapter gains organization-context.mjs (busOrganization), with
business.mjs (busBusiness) as the same function.

Import consumers, manifests, the lockfile, scripts/mosaic, stack-copy.sh
COPY_PATHS (both roots), seat sandbox RUNNER_FILES and the copy closure
walker (organization-imports.mjs, both roots required) move to the new path.
v1 serialization, v1 writers and the stage A fixtures are unchanged; the stage
A contract tests stay where they were and pass as before.
2026-11-09 11:47:48 -06:00

93 lines
6.3 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { humanEnv, notifyConfig } from './helpers.mjs';
import { busStart, busStatus, busStop } from '../src/org-bus.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
test('two organization buses are isolated and refusals never select a sibling', { timeout: 120000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
env.MOSAIC_ROLES_DIR = rolesCopy(home);
for (const id of ['alpha', 'beta']) {
const org = await reserveOrg(ctx, id);
await activateOrg(ctx, id);
writeJson(join(ctx.configDir, 'businesses', `${id}.json`), businessDoc(home, id));
notifyConfig(org.root, id, null);
}
t.after(async () => {
for (const id of ['alpha', 'beta']) { try { await busStop(ctx, id); } catch {} }
rmSync(home, { recursive: true, force: true });
});
const incompatible = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'start', 'alpha', '--pm', '--no-launch'], { env, encoding: 'utf8' });
assert.equal(incompatible.status, 4);
const start = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'start', 'alpha', '--no-launch', '--detach'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(start.status, 0, start.stderr);
const alpha = JSON.parse(start.stdout);
const beta = await busStart(ctx, 'beta', { detach: true, devLocalOwner: true });
assert.notEqual(alpha.socket, beta.socket);
assert.notEqual(alpha.root, beta.root);
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
const refused = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'recover', 'alpha'], { env, encoding: 'utf8' });
assert.equal(refused.status, 3, refused.stderr);
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
await assert.rejects(busStatus(ctx, 'missing'), e => e.code === 'org-unknown');
const launches = spawnSync(join(REPO, 'scripts/mosaic'), ['launches', 'list', '--business', 'beta', '--json'], { env, encoding: 'utf8' });
assert.equal(launches.status, 0, launches.stderr);
assert.ok(Array.isArray(JSON.parse(launches.stdout)));
const inbox = spawnSync(join(REPO, 'scripts/mosaic'), ['inbox', '--business', 'alpha', '--json'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(inbox.status, 3, inbox.stderr);
assert.match(inbox.stderr, /human-required/); // The existing pinned-tree proof refused after reaching alpha.
const status = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'status', 'alpha', '--json'], { env, encoding: 'utf8' });
assert.equal(status.status, 0, status.stderr);
assert.equal(JSON.parse(status.stdout).socket, alpha.socket);
const restart = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'restart', 'alpha'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(restart.status, 0, restart.stderr);
assert.notEqual(JSON.parse(restart.stdout).pid, alpha.pid);
assert.equal(JSON.parse(readFileSync(join(alpha.root, 'bus-host/host.json'), 'utf8')).options.noLaunch, true);
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
const stop = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'stop', 'alpha'], { env, encoding: 'utf8', timeout: 30000 });
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
assert.equal((await busStatus(ctx, 'alpha')).healthy, false);
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
// Recovery removes dead host and broker locks, and a fresh start can take them.
const staleHost = { pid: 2147483647, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
writeFileSync(join(alpha.root, 'bus-host/host.json'), JSON.stringify(staleHost), { mode: 0o600 });
writeFileSync(join(alpha.root, 'bus-host/host.lock'), JSON.stringify(staleHost), { mode: 0o600 });
writeFileSync(join(alpha.root, 'bus/writer.lock'), JSON.stringify({ pid: 2147483647, at: '2026-01-01T00:00:00Z' }), { mode: 0o600 });
const recovery = spawnSync(join(REPO, 'scripts/mosaic'), ['bus', 'recover', 'alpha'], { env, encoding: 'utf8' });
assert.equal(recovery.status, 0, recovery.stderr);
const restarted = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
assert.ok(restarted.pid > 0);
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
await busStop(ctx, 'alpha');
// A forged alpha state cannot name the running beta host as alpha.
const alphaHost = join(alpha.root, 'bus-host');
const betaState = JSON.parse(readFileSync(join(beta.root, 'bus-host/host.json'), 'utf8'));
const forged = { ...betaState, business: 'alpha' };
writeFileSync(join(alphaHost, 'host.json'), JSON.stringify(forged), { mode: 0o600 });
writeFileSync(join(alphaHost, 'host.lock'), JSON.stringify(forged), { mode: 0o600 });
try { await assert.rejects(busStatus(ctx, 'alpha'), /mismatch|identity/i); }
finally { rmSync(join(alphaHost, 'host.json')); rmSync(join(alphaHost, 'host.lock')); }
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
mkdirSync(join(alpha.root, 'bus'), { recursive: true, mode: 0o700 });
writeFileSync(join(alpha.root, 'bus/writer.lock'), JSON.stringify({ pid: 99999999 }), { mode: 0o600 });
await assert.rejects(busStart(ctx, 'alpha', { detach: true }), /lock/i);
await assert.rejects(busStatus(ctx, 'alpha'), /lock/i);
writeFileSync(join(alpha.root, '.mosaic-org.json'), JSON.stringify({ instance: 's1test', id: 'beta' }), { mode: 0o600 });
await assert.rejects(busStatus(ctx, 'alpha'), e => e.code === 'org-mismatch');
assert.equal((await busStatus(ctx, 'beta')).healthy, true);
});