Files
stack/packages/webui/tests/reads.test.mjs
T
jason.woltjeandClaude Opus 5.5 cbd79cf666 feat(webui): read-only Queue, Business and Settings views (#1543, row 54)
The Console gains three read-only views. Queue lists every row from
rows() in packages/queue (lead decision 83: the same lock-free read as
`queue list`, writing nothing), run in a child with a timeout and an
output cap; a row page shows the full row. Business shows names, an
allowlist of vars, arbiters, authority per action and credential
metadata (service, account, role, date, never a value, file or
variable). Settings shows RELEASE, the board origin and the notifier
binding. Every route is GET only, and every string in a body or
refusal passes a redactor: the config directory and dataRoot become
<config> and <dataRoot>, other absolute, ~/ and file:// paths <path>,
and 17 to 20 digit runs <id>. A queue-read that fails to start sends a
fixed message, never node's stderr.

Four fixes to HEAD behaviour, each with a test: the bus view's refresh
timer is cleared at render, a same-page refresh keeps focus on the H1,
#conv-pick is emptied when a conversation opens, and error() returns
focus to <main> only when something had focus. Filbert's T8 tests the
failed first read.

Dewey built it in two rounds. Round 1 (dba2429e) got changes from
Filbert (27185: After rendered [object Object], ~/ and :/ paths leaked)
and Darkwing (27186: Arbiters always none, queue-read import failure
leaked a file:// path and stack). Round 2 (afb2ae0e) was approved by
Filbert (27190) and Darkwing (27191, correction 27192). Sage's gate on
d64f434f plus the candidate: 13 package node suites 0 failed (queue
149, webui 39), every scripts/test-*.sh 0 failed (task 98/0 with
Docker, 26/0 without; release 14/0), build-tokens --check current.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 17:11:17 -05:00

199 lines
14 KiB
JavaScript

// 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: <config>/businesses/a.json');
assert.equal(r('no notifier config at /home/u/.mosaic-dev/notify/a/notify.json; write'), 'no notifier config at <dataRoot>/notify/a/notify.json; write');
assert.equal(r('file /run/x.token, see https://git.example/a'), 'file <path>, see https://git.example/a');
assert.equal(r('/home/u/.config/mosaic-devX/y'), '<path>');
assert.equal(r(`user ${SNOWFLAKES[0]} ok`), 'user <id> 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 <path> (lead decision 74)');
assert.equal(r('no `~/.mosaic` changes; key=~/k'), 'no `<path>` changes; key=<path>');
assert.equal(r('file:/x, file:///srv/y and </srv/z.token>'), 'file:<path>, file:<path> and <<path>>');
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 \(<config>\/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: '<path>' });
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: <config>/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 \(<config>\/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 <dataRoot>\/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' });
});