// U1 (#1873): `mosaic console restart` and `mosaic org reload ID` are the F3 page controls from a // terminal. Each test runs the real scripts/mosaic on a scratch instance made by bootstrap.sh, and // checks the refusal before any read, the request to the Console's own route and the action trail // that route writes. The supervised Console is the real one; the org reload success path runs // against an in-process Console whose bus service is a fake, so no bus host starts. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawn, spawnSync } from 'node:child_process'; import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; import { request } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs'; import { processState } from '../../seat/src/process-state.mjs'; import { startServer } from '../../webui/src/serve.mjs'; import { createDevOwner } from '../../webui/src/dev-owner.mjs'; import { createOrgAdmin } from '../../webui/src/org-admin.mjs'; import { actionTrail } from '../../webui/src/action-trail.mjs'; import { close } from '../../webui/tests/fixture.mjs'; import { humanEnv } from './helpers.mjs'; import { consoleStart, consoleStop } from '../src/org-console.mjs'; const REPO = new URL('../../../', import.meta.url).pathname; const MOSAIC = join(REPO, 'scripts/mosaic'); const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350]; const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' }; // A scratch instance, as bootstrap.sh makes one, with org alpha active. async function instance(t) { const home = mkdtempSync(join(tmpdir(), 'u1-')); const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-u1test/config.json') }); const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'u1test'], { env, encoding: 'utf8' }); assert.equal(setup.status, 0, setup.stderr); const ctx = { configDir: join(home, '.config/mosaic-u1test'), dataRoot: join(home, '.mosaic-u1test'), instance: 'u1test', env }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); }); return { ctx, env, trailFile: join(ctx.dataRoot, 'console/actions.jsonl') }; } // The CLI as a child, without blocking this process: the in-process Console must keep answering. function mosaic(env, args, timeout = 60000) { return new Promise((resolve, reject) => { const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = '', stderr = ''; child.stdout.setEncoding('utf8').on('data', c => { stdout += c; }); child.stderr.setEncoding('utf8').on('data', c => { stderr += c; }); const timer = setTimeout(() => child.kill('SIGKILL'), timeout); child.on('error', reject); child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); }); }); } const trailLines = file => existsSync(file) ? readFileSync(file, 'utf8').split('\n').filter(Boolean).map(l => { const { at, ...e } = JSON.parse(l); assert.ok(Number.isFinite(Date.parse(at))); return e; }) : []; function get(port, path) { return new Promise((resolve, reject) => { request({ host: '127.0.0.1', port, path, headers: { host: `127.0.0.1:${port}` } }, res => { let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) })); }).on('error', reject).end(); }); } // console.json naming a Console that runs in this test process (same checks as a real one). function recordConsole(ctx, url, { pid = process.pid, startTime = processState(process.pid).startTime } = {}) { const dir = join(ctx.dataRoot, 'console'); mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700); const file = join(dir, 'console.json'); writeFileSync(file, JSON.stringify({ pid, startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600); } test('both verbs refuse inside an agent run before any config is read', async () => { // The config path is not an instance config, so a read would refuse with another message. // humanEnv strips agent markers, so the marker goes in after it. const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/u1/config.json' }), CLAUDECODE: '1' }; for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) { const r = await mosaic(env, args); assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/); assert.equal(r.stdout, ''); } }); test('with no live Console both verbs refuse and nothing is written', { timeout: 90000 }, async t => { const { ctx, env, trailFile } = await instance(t); for (const args of [['console', 'restart'], ['org', 'reload', 'alpha']]) { const r = await mosaic(env, args); assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /no Console runs for this instance/); } recordConsole(ctx, 'http://127.0.0.1:1/', { pid: 2147483647, startTime: '1' }); const stale = await mosaic(env, ['console', 'restart']); assert.equal(stale.status, 3, stale.stderr); assert.match(stale.stderr, /is not running/); const bad = await mosaic(env, ['org', 'reload', 'Bad/Id']); assert.equal(bad.status, 4, bad.stderr); assert.match(bad.stderr, /invalid organization id/); assert.equal((await mosaic(env, ['console', 'restart', 'extra'])).status, 4); assert.equal(existsSync(trailFile), false); }); test('mosaic console restart relaunches a supervised Console and the trail records it', { timeout: 120000 }, async t => { const { ctx, env, trailFile } = await instance(t); const warnings = []; const result = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: s => warnings.push(s) }); t.after(async () => { result.stop(); await result.done; }); const port = Number(new URL(result.url).port); assert.ok(!FORBIDDEN.includes(port)); const before = (await get(port, '/api/config')).body; assert.equal(before.consoleRestart, true); const r = await mosaic(env, ['console', 'restart']); assert.equal(r.status, 0, r.stderr); const answer = JSON.parse(r.stdout); assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot); assert.equal(typeof answer.boot, 'string'); assert.notEqual(answer.boot, before.boot); assert.equal((await get(port, '/api/config')).body.boot, answer.boot); assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]); assert.equal(statSync(trailFile).mode & 0o777, 0o600); assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']); }); test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => { const { ctx, env, trailFile } = await instance(t); const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }); const port = Number(new URL(result.url).port); assert.ok(!FORBIDDEN.includes(port)); const r = await mosaic(env, ['console', 'restart']); assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, /console restart refused \(not-supervised\)/); // The real organization service refuses an org that does not exist before any trail line. const unknown = await mosaic(env, ['org', 'reload', 'nosuch']); assert.equal(unknown.status, 3, unknown.stderr); assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/); assert.equal((await get(port, '/api/config')).status, 200); assert.equal(existsSync(trailFile), false); assert.equal((await consoleStop(ctx)).stopped, true); }); // The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts. function fakeAdmin(ctx) { const calls = []; let running = false; const known = id => { if (id !== 'acme') throw Object.assign(new Error('unknown organization'), { code: 'org-unknown' }); }; const service = { showOrg: async (c, id) => { known(id); return { id, root: join(ctx.dataRoot, 'org-runtime', id), revision: 1, doc: {}, principal: c.principal }; } }; const bus = { busStatus: async (c, id) => { known(id); return { org: id, instance: ctx.instance, root: join(ctx.dataRoot, 'org-runtime', id), socket: '/run/u1.sock', healthy: running, pid: running ? 4242 : null }; }, busStart: async (c, id, opts) => { calls.push(['busStart', id, opts]); running = true; return { org: id, pid: 4242, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; }, busRestart: async (c, id) => { calls.push(['busRestart', id]); return { org: id, pid: 4343, socket: '/run/u1.sock', root: join(ctx.dataRoot, 'org-runtime', id) }; }, }; const registry = { listOrgs: async () => [{ id: 'acme', state: 'active' }] }; return { calls, admin: createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance }) }) }; } test('mosaic org reload starts or restarts the bus through the Console route and the trail records each', { timeout: 90000 }, async t => { const { ctx, env, trailFile } = await instance(t); const f = fakeAdmin(ctx); const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) }); t.after(() => close(server)); const port = server.address().port; assert.ok(!FORBIDDEN.includes(port)); recordConsole(ctx, `http://127.0.0.1:${port}/`); let r = await mosaic(env, ['org', 'reload', 'acme']); assert.equal(r.status, 0, r.stderr); assert.deepEqual(JSON.parse(r.stdout), { outcome: 'done', step: 'bus-restart', restarted: false, healthy: true, receipt: { org: 'acme', pid: 4242, socket: '', root: '/org-runtime/acme' }, verb: 'mosaic bus start acme --dev-local-owner --detach' }); r = await mosaic(env, ['org', 'reload', 'acme']); assert.equal(r.status, 0, r.stderr); const second = JSON.parse(r.stdout); assert.equal(second.restarted, true); assert.equal(second.verb, 'mosaic bus restart acme'); assert.equal(second.receipt.pid, 4343); assert.deepEqual(f.calls, [['busStart', 'acme', { detach: true, devLocalOwner: true, pm: false }], ['busRestart', 'acme']]); // An unknown org is refused by the route before the trail (as org-unavailable, which does not say // whether it exists); extra arguments never reach the Console. const unknown = await mosaic(env, ['org', 'reload', 'other']); assert.equal(unknown.status, 3, unknown.stderr); assert.match(unknown.stderr, /org reload refused \(org-unavailable\)/); assert.equal((await mosaic(env, ['org', 'reload', 'acme', 'extra'])).status, 4); assert.deepEqual(trailLines(trailFile), [ { by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: false }, { by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' }, { by: DEV, action: 'bus.restart', org: 'acme', outcome: 'requested', running: true }, { by: DEV, action: 'bus.restart', org: 'acme', outcome: 'done' }, ]); // A read-only Console (no --send) refuses, and the CLI reports the Console's own reason. await close(server); const readonly = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), orgAdmin: f.admin, log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) }); t.after(() => close(readonly)); recordConsole(ctx, `http://127.0.0.1:${readonly.address().port}/`); const denied = await mosaic(env, ['org', 'reload', 'acme']); assert.equal(denied.status, 3, denied.stderr); assert.match(denied.stderr, /org reload refused \(read-only\): start the Console with --send/); assert.equal(trailLines(trailFile).length, 4); });