// Row 54 (#1543): the Queue, Business and Settings reads over the seeded // fixture in reads-fixture.mjs. No response may carry a secret, an id or // the temporary directory. import { test } from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { startServer } from '../src/serve.mjs'; import { consoleReads, credentialState, queueReader, redactor } from '../src/reads.mjs'; import { writeJson, REPO_ROLES } from '../../business/tests/helpers.mjs'; import { SRC, SNOWFLAKES, NOTE_SHOWN, day, queueRepo, seeded, clean } from './reads-fixture.mjs'; async function serve(t, reads) { const server = await startServer({ port: 0, reads }); t.after(() => server.close()); return `http://127.0.0.1:${server.address().port}`; } const get = async (base, path, init) => { const r = await fetch(base + path, init); return { status: r.status, text: await r.text() }; }; test('redactor: config, dataRoot, any absolute path, Discord ids and the JSON parser quote', () => { const r = redactor({ configDir: '/home/u/.config/mosaic-dev', dataRoot: '/home/u/.mosaic-dev' }); assert.equal(r('business file not found: /home/u/.config/mosaic-dev/businesses/a.json'), 'business file not found: /businesses/a.json'); assert.equal(r('no notifier config at /home/u/.mosaic-dev/notify/a/notify.json; write'), 'no notifier config at /notify/a/notify.json; write'); assert.equal(r('file /run/x.token, see https://git.example/a'), 'file , see https://git.example/a'); assert.equal(r('/home/u/.config/mosaic-devX/y'), ''); assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user ok'); // ~/ paths, and a path after `:` or `<` (Filbert R1 and Sage, #1543 comment 27185). A URL keeps its path. assert.equal(r('tokens 0600 under ~/.config/mosaic-dev/secrets/mosaic-stack (lead decision 74)'), 'tokens 0600 under (lead decision 74)'); assert.equal(r('no `~/.mosaic` changes; key=~/k'), 'no `` changes; key='); assert.equal(r('file:/x, file:///srv/y and '), 'file:, file: and <>'); assert.equal(r('https://git.example/a, http://127.0.0.1:3456 and http://h:80/p'), 'https://git.example/a, http://127.0.0.1:3456 and http://h:80/p'); assert.equal(r('a~/b, ~ and agents/sage/work/'), 'a~/b, ~ and agents/sage/work/'); const quoted = r('business file is not valid JSON (/home/u/.config/mosaic-dev/businesses/a.json): Unexpected token \'s\', "s3cret-value" is not valid JSON'); assert.equal(quoted.includes('s3cret-value'), false); assert.match(quoted, /^business file is not valid JSON \(\/businesses\/a\.json\): the parser quoted/); }); test('credential state from the date alone, as the bus states it', () => { const now = Date.parse('2026-10-10T12:00:00Z'); assert.equal(credentialState('gitea', '2026-10-10', now), 'rotation-due'); assert.equal(credentialState('gitea', '2026-10-11', now), 'valid'); assert.equal(credentialState('vikunja', '2026-10-10', now), 'expired'); assert.equal(credentialState('vikunja', '2026-10-16', now), 'expiring'); assert.equal(credentialState('vikunja', '2026-10-18', now), 'valid'); }); test('queue: rows, one row, ids and methods, notes; no path or id from a row', async t => { const repo = queueRepo(t); const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES })); const list = await get(base, '/api/queue'); assert.equal(list.status, 200, list.text); const body = JSON.parse(list.text); assert.deepEqual(body.rows.map(r => r.id), [1, 6, 8, 9, 11]); const six = body.rows.find(r => r.id === 6); assert.deepEqual([six.state, six.owner, six.reviewers, six.brief], ['in-progress', 'darkwing', ['filbert'], { path: 'docs/plans/brief-a.md', anchor: 'Row six' }]); assert.equal(six.note, NOTE_SHOWN); assert.equal(Object.hasOwn(six, 'gate'), false); assert.ok(Array.isArray(body.notes)); clean(list.text, repo.base); const one = await get(base, '/api/queue/9'); assert.equal(one.status, 200); const nine = JSON.parse(one.text).row; assert.deepEqual([nine.id, nine.gate, nine.after], [9, 'filbert approves', [{ id: 6, when: 'settled' }]]); clean(one.text, repo.base); assert.deepEqual(JSON.parse((await get(base, '/api/queue/11')).text).row.after, [{ id: 9, when: 'done' }]); assert.equal((await get(base, '/api/queue/7')).status, 404); for (const bad of ['abc', '0', '01', '1234567', '9/x', '-1']) assert.equal((await get(base, `/api/queue/${bad}`)).status, 400, bad); for (const path of ['/api/queue', '/api/queue/9', '/api/business', '/api/settings']) { assert.equal((await get(base, path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' })).status, 405, path); } assert.equal((await get(base, '/api/queue', { headers: { origin: 'https://evil.example' } })).status, 403); }); test('queue: a refused read fails closed with the store\'s text, redacted', async t => { const repo = queueRepo(t); const base = await serve(t, consoleReads({ root: repo.root, env: { ...repo.env, GIT_DIR: join(repo.root, '.git') }, rolesDir: REPO_ROLES })); const r = await get(base, '/api/queue'); assert.equal(r.status, 503); const body = JSON.parse(r.text); assert.equal(body.error, 'queue-refused'); assert.match(body.message, /GIT_DIR/); clean(r.text, repo.base); assert.equal((await get(base, '/api/queue/6')).status, 503); }); test('queue: a store that fails to load, or a child that dies, gives a fixed message with no path or stack', { timeout: 60000 }, async t => { // Darkwing 27186: a half-written store.mjs used to send Node's own error, file:// path and stack included. for (const [name, breakStore] of [['syntax error', p => writeFileSync(p, 'export const rows = (;\n')], ['missing', p => rmSync(p)]]) { const repo = queueRepo(t); breakStore(join(repo.root, 'packages/queue/src/store.mjs')); const child = spawnSync(process.execPath, [join(repo.root, 'packages/webui/src/queue-read.mjs')], { cwd: repo.root, env: repo.env, encoding: 'utf8' }); assert.deepEqual([child.status, child.stdout, child.stderr], [1, '', 'the queue read failed\n'], name); const base = await serve(t, consoleReads({ root: repo.root, env: repo.env, rolesDir: REPO_ROLES })); const r = await get(base, '/api/queue'); assert.equal(r.status, 503, name); assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: 'the queue read failed (exit 1)' }, name); clean(r.text, repo.base, repo.root); } // Only exit 2 forwards the child's text; any other exit is a fixed message. const repo = queueRepo(t), script = join(repo.root, 'packages/webui/src/queue-read.mjs'); const fake = (body, opts = {}) => { writeFileSync(script, body); return queueReader({ root: repo.root, env: repo.env, ...opts })(); }; await assert.rejects(fake('process.stderr.write("Error: boom\\n at main (file:///srv/q/x.mjs:1:1)\\n"); process.exitCode = 3;\n'), { code: 'read-failed', message: 'the queue read failed (exit 3)' }); await assert.rejects(fake('setInterval(() => {}, 1000);\n', { timeoutMs: 300 }), { code: 'read-failed', message: 'the queue read did not finish in time' }); await assert.rejects(fake('process.stdout.write("x".repeat(65536));\n', { maxBytes: 1024 }), { code: 'invalid-response', message: 'the queue read printed too much' }); }); test('business: names, vars on the allowlist, authority, credential metadata only', async t => { const s = seeded(t); const base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' })); const r = await get(base, '/api/business'); assert.equal(r.status, 200, r.text); clean(r.text, s.base); const b = JSON.parse(r.text).business; assert.deepEqual([b.id, b.human, b.arbiters, b.projects], ['acme', 'jason', { delivery: 'pm', technical: 'cto' }, ['stack']]); assert.deepEqual(b.vars, { 'tracker.baseUrl': 'http://127.0.0.1:3456', 'gitea.baseUrl': 'https://git.example', 'tracker.pollSeconds': 60 }); assert.deepEqual(b.instances.map(i => [i.instance, i.definition, i.holder]), [['pm', 'pm', 'sage'], ['cto', 'cto', 'darkwing'], ['coder', 'coder', null], ['reviewer', 'reviewer', null]]); assert.deepEqual(b.instances.find(i => i.instance === 'coder').vars, { harness: 'pi', 'limits.network': 'none' }); // An allowlisted var is scrubbed too (Filbert T1). assert.deepEqual(b.instances.find(i => i.instance === 'reviewer').vars, { model: '' }); for (const i of b.instances) { assert.deepEqual(Object.keys(i.authority), b.actions); assert.ok(Object.values(i.authority).every(v => ['within', 'cross', 'gated'].includes(v))); } assert.equal(b.instances.find(i => i.instance === 'pm').authority['role.launch'], 'within'); assert.deepEqual(b.launch, { by: 'pm', instances: ['coder', 'reviewer'], max: { opus: 2, sonnet: 4 } }); const cred = (role, service) => b.credentials.find(c => c.role === role && c.service === service); assert.deepEqual(Object.keys(cred('pm', 'gitea')).sort(), ['account', 'date', 'dateKind', 'role', 'service', 'state']); assert.deepEqual(cred('coder', 'gitea'), { service: 'gitea', account: null, role: 'coder', dateKind: 'rotateBy', date: day(-1), state: 'rotation-due' }); assert.deepEqual(cred('coder', 'vikunja'), { service: 'vikunja', account: 'bot-acme-coder', role: 'coder', dateKind: 'expires', date: day(3), state: 'expiring' }); assert.equal(cred('reviewer', 'vikunja').state, 'expired'); assert.deepEqual(cred('tracker sync', 'vikunja'), { service: 'vikunja', account: 'bot-acme-sync', role: 'tracker sync', dateKind: 'expires', date: '2099-01-01', state: 'valid' }); assert.equal(b.credentials.length, 9); }); test('business: missing or invalid file refuses with the module\'s text, redacted', async t => { const s = seeded(t, { business: false }); const reads = consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' }); const base = await serve(t, reads); let r = await get(base, '/api/business'); assert.equal(r.status, 503); assert.deepEqual(JSON.parse(r.text), { error: 'not-configured', message: 'business file not found: /businesses/acme.json' }); // Invalid JSON whose parse error would quote a secret-looking value. writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}'); r = await get(base, '/api/business'); assert.equal(r.status, 503); assert.match(JSON.parse(r.text).message, /^business file is not valid JSON \(\/businesses\/acme\.json\)/); assert.equal(r.text.includes('s3cret-value-xyz'), false); clean(r.text, s.base); // Valid JSON, invalid business: the validator's message names the file. writeJson(join(s.configDir, 'businesses', 'acme.json'), { ...s.doc, launch: { ...s.doc.launch, by: 'nobody' } }); r = await get(base, '/api/business'); assert.equal(r.status, 503); assert.match(JSON.parse(r.text).message, /launch\.by names nobody/); clean(r.text, s.base); }); test('business without --business: the live bus host, else no-bus-host; no system config: not-configured', async t => { const s = seeded(t); let base = await serve(t, consoleReads({ root: SRC, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES })); let r = await get(base, '/api/business'); assert.equal(r.status, 503); assert.equal(JSON.parse(r.text).error, 'no-bus-host'); base = await serve(t, consoleReads({ root: SRC, system: null, configDir: s.configDir, rolesDir: REPO_ROLES })); r = await get(base, '/api/business'); assert.equal(JSON.parse(r.text).error, 'not-configured'); base = await serve(t, null); for (const path of ['/api/business', '/api/queue', '/api/queue/1']) assert.equal(JSON.parse((await get(base, path)).text).error, 'not-configured', path); }); test('settings: release, board, loopback address, notifier binding name only', async t => { const s = seeded(t); const repo = queueRepo(t); let base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: s.system, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' })); let r = await get(base, '/api/settings'); assert.equal(r.status, 200); const body = JSON.parse(r.text); assert.deepEqual({ ...body, at: undefined }, { release: '0.0.99', business: 'acme', notifier: { binding: 'jason-dm' }, board: 'http://127.0.0.1:7331', address: `${base}/`, at: undefined }); clean(r.text, s.base, repo.base); // No notifier config: still 200, so appearance works; the reason is redacted. const bare = seeded(t, { notify: false }); base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: bare.system, configDir: bare.configDir, rolesDir: REPO_ROLES, business: 'acme' })); r = await get(base, '/api/settings'); assert.equal(r.status, 200); const n = JSON.parse(r.text).notifier; assert.equal(n.refused, 'not-configured'); assert.match(n.message, /^no notifier config at \/notify\/acme\/notify\.json/); clean(r.text, bare.base, repo.base); base = await serve(t, null); r = await get(base, '/api/settings'); assert.equal(r.status, 200); assert.equal(JSON.parse(r.text).notifier.refused, 'not-configured'); // --business with no system config: refused in Console's words, not Node's (Filbert N1). base = await serve(t, consoleReads({ root: repo.root, env: repo.env, system: null, configDir: s.configDir, rolesDir: REPO_ROLES, business: 'acme' })); r = await get(base, '/api/settings'); assert.equal(r.status, 200); assert.deepEqual(JSON.parse(r.text).notifier, { refused: 'not-configured', message: 'the Console has no system config, so it knows no notifier config' }); });