import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs'; import { get, request } from 'node:http'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs'; import { humanEnv } from './helpers.mjs'; import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs'; const REPO = new URL('../../../', import.meta.url).pathname; test('detached console reports its live loopback URL and stops', { timeout: 60000 }, 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 }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); }); mkdirSync(join(ctx.dataRoot, 'console'), { mode: 0o700 }); writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/' }), { mode: 0o600 }); const warnings = []; const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) }); assert.equal(warnings.length, 1); assert.equal(readdirSync(join(ctx.dataRoot, 'console')).filter(x => x.startsWith('console.json.stale-')).length, 1); await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), /state exists/); const url = new URL(result.url); assert.equal(url.hostname, '127.0.0.1'); assert.ok(Number.isSafeInteger(result.pid)); // A selector producer (rename r2): the Console child gets the active organization as --business. const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1); assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']); assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port))); const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject)); assert.equal(status, 200); assert.equal((await consoleStop(ctx)).stopped, true); }); test('mosaic console start and stop dispatch through the CLI', { timeout: 60000 }, async t => { const home = mkdtempSync(join(tmpdir(), 's1-')); const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') }); assert.equal(spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env }).status, 0); const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); }); const start = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto'], { env, encoding: 'utf8', timeout: 30000 }); assert.equal(start.status, 0, start.stderr); assert.match(JSON.parse(start.stdout).url, /^http:\/\/127\.0\.0\.1:\d+\/$/); const stop = spawnSync(join(REPO, 'scripts/mosaic'), ['console', 'stop'], { env, encoding: 'utf8', timeout: 30000 }); assert.equal(stop.status, 0, stop.stderr); assert.equal(JSON.parse(stop.stdout).stopped, true); }); // org-lan-bind: --dev-insecure-bind and --dev-allow-host. The Console binds 0.0.0.0 and the test // reaches it on 127.0.0.1 with an allowed Host name, as a LAN browser would. const hostGet = (port, host) => new Promise((resolve, reject) => { request({ host: '127.0.0.1', port, path: '/', headers: { host } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject).end(); }); async function instance(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 }; await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha'); t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); }); const mosaic = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env, encoding: 'utf8', timeout: 30000 }); return { ctx, mosaic }; } test('a LAN console warns, records its bind and answers only its allowed names', { timeout: 60000 }, async t => { const { ctx, mosaic } = await instance(t); const warnings = []; const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'], warn: s => warnings.push(s) }); const url = new URL(result.url), port = url.port; assert.equal(url.hostname, '127.0.0.1'); assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(port))); const warning = `WARNING: dev owner Console listens on 0.0.0.0:${port} with no sign-in; anyone who can reach it acts as the owner.\n`; assert.deepEqual(warnings, [warning]); assert.equal(result.bind, '0.0.0.0'); assert.deepEqual(result.allowHosts, ['lan.test', '10.1.10.37']); const saved = JSON.parse(readFileSync(join(ctx.dataRoot, 'console/console.json'), 'utf8')); assert.deepEqual({ url: saved.url, bind: saved.bind, allowHosts: saved.allowHosts }, { url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] }); // The serve process prints the same warning on its own stderr, which the Console log keeps. const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^console-\d+-\d+\.log$/.test(x)); assert.equal(logs.length, 1); assert.ok(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8').includes(warning), 'serve log has the warning'); assert.equal(await hostGet(port, `lan.test:${port}`), 200); assert.equal(await hostGet(port, `10.1.10.37:${port}`), 200); assert.equal(await hostGet(port, `127.0.0.1:${port}`), 200); assert.equal(await hostGet(port, `other.test:${port}`), 403); assert.equal(await hostGet(port, `lan.test:${Number(port) + 1}`), 403); const statusWarnings = []; const status = consoleStatus(ctx, { warn: s => statusWarnings.push(s) }); assert.deepEqual(status, { running: true, pid: result.pid, url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] }); assert.deepEqual(statusWarnings, [warning]); const cli = mosaic(['console', 'status']); assert.equal(cli.status, 0, cli.stderr); assert.deepEqual(JSON.parse(cli.stdout), status); assert.equal(cli.stderr, warning); assert.equal((await consoleStop(ctx)).stopped, true); assert.deepEqual(consoleStatus(ctx), { running: false }); // A state file whose LAN fields do not validate is refused, not trusted. for (const bad of [{ bind: '0.0.0.0' }, { bind: '0.0.0.0', allowHosts: [] }, { bind: '0.0.0.0', allowHosts: ['a.test,b.test'] }, { bind: 'lan.test', allowHosts: ['lan.test'] }, { allowHosts: ['lan.test'] }]) { writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/', ...bad }), { mode: 0o600 }); assert.throws(() => consoleStatus(ctx), /console state is invalid/, JSON.stringify(bad)); } rmSync(join(ctx.dataRoot, 'console/console.json')); }); test('mosaic console start refuses the LAN flags without the dev owner and allowed names', { timeout: 60000 }, async t => { const { ctx, mosaic } = await instance(t); for (const [args, reason] of [ [['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/], [['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/], [['--dev-local-owner', '--dev-insecure-bind', '10.1.10.37', '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/], [['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'a.test', '--dev-allow-host', 'b.test'], /--dev-allow-host needs one value/], ]) { const r = mosaic(['console', 'start', '--detach', '--port', 'auto', ...args]); assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, reason, args.join(' ')); assert.doesNotMatch(r.stderr, /WARNING: dev owner/); assert.equal(r.stdout, ''); assert.equal(existsSync(join(ctx.dataRoot, 'console')), false, args.join(' ')); } await assert.rejects(consoleStart(ctx, { detach: true, bind: '0.0.0.0', allowHosts: ['lan.test'] }), /--dev-insecure-bind needs --dev-local-owner/); assert.equal(existsSync(join(ctx.dataRoot, 'console')), false); const none = mosaic(['console', 'status']); assert.equal(none.status, 0, none.stderr); assert.deepEqual(JSON.parse(none.stdout), { running: false }); assert.equal(mosaic(['console', 'status', '--json']).status, 4); }); // F3: the owner restarts the Console from Settings. A foreground `mosaic console start` runs the // Console with --supervised and relaunches it on the same arguments and address when it exits 75. const WEBUI = join(REPO, 'packages/webui/src/cli.mjs'); const sleep = ms => new Promise(r => setTimeout(r, ms)); const argvOf = pid => readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').slice(0, -1); function call(port, path, { method = 'GET', body = null, headers = {} } = {}) { const h = { host: `127.0.0.1:${port}`, ...(method === 'GET' ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers }; for (const k of Object.keys(h)) if (h[k] === null) delete h[k]; return new Promise((resolve, reject) => { const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => { let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; }); res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, body: json }); }); }); r.on('error', reject); r.end(body ?? undefined); }); } const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner', name: 'Local operator' }; test('a foreground console relaunches on the same address when the owner restarts it, and a stop ends it', { timeout: 90000 }, async t => { const { ctx } = 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(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port)); assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']); const before = (await call(port, '/api/config')).body; assert.equal(before.consoleRestart, true); assert.equal(typeof before.boot, 'string'); const stateFile = join(ctx.dataRoot, 'console/console.json'); const trailFile = join(ctx.dataRoot, 'console/actions.jsonl'); // Another origin or site restarts nothing. for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) { assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers)); } assert.equal(JSON.parse(readFileSync(stateFile, 'utf8')).pid, result.pid); assert.equal(existsSync(trailFile), false); const answer = await call(port, '/api/console/restart', { method: 'POST', body: '{}' }); assert.equal(answer.status, 202); assert.deepEqual(answer.body, { outcome: 'restarting', boot: before.boot }); let saved; for (const deadline = Date.now() + 30000; ; await sleep(100)) { saved = JSON.parse(readFileSync(stateFile, 'utf8')); if (saved.pid !== result.pid) break; if (Date.now() > deadline) assert.fail('the Console was not relaunched'); } // Same address, same arguments; the auto port is now the port it had. assert.equal(saved.url, result.url); assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']); assert.equal(statSync(stateFile).mode & 0o777, 0o600); const after = (await call(port, '/api/config')).body; assert.notEqual(after.boot, before.boot); assert.equal(after.consoleRestart, true); const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l)); assert.deepEqual(more, []); assert.ok(Number.isFinite(Date.parse(line.at))); assert.deepEqual({ ...line, at: undefined }, { at: undefined, 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']); assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' }); result.stop(); assert.equal(await result.done, 0); assert.equal(consoleStatus(ctx).running, false); }); test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => { const { ctx } = 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(!argvOf(result.pid).includes('--supervised')); assert.equal((await call(port, '/api/config')).body.consoleRestart, false); const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' }); assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised'); assert.equal((await call(port, '/api/config')).status, 200); assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false); assert.equal((await consoleStop(ctx)).stopped, true); });