GET /api/workspace on the dev-local-owner Console: one snapshot of projects, sessions, inbox, tasks and per-org metrics across every organization the owner belongs to. Membership reuses the org list's admission (organizations.members); each org is read over the same per-org options the org pages use, with one deadline per org (4000 ms) and a state (ok, down, starting, timeout, invalid) with a time. Every row carries its org id; decide commands carry --org. Granted users and the tester get 403 owner-only. Polling at 10 s, no push. mosaic console restart and mosaic org reload <id> call the Console's F3 routes (POST /api/console/restart, POST /api/orgs/<id>/restart/bus) so gating, proof and trail are the Console's own; both refuse inside an agent run first. The cross-org assistant read is designed and stopped: it needs a new broker read verb or role authority.
188 lines
12 KiB
JavaScript
188 lines
12 KiB
JavaScript
// 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: '<host path>', root: '<data 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);
|
|
});
|