Row 61. packages/tasks/fields.json holds the slice 1 field table in two
layers (decision 113): each broker task verb's writer check, and each
field's writing verbs and Vikunja calls. packages/tasks/src/fields.mjs
checks the shape as code and loads it frozen. The tasks test reads the
writer layer from the table and authority from roles/*.json and drives
every task verb against the fake Vikunja. The Console serves the table
at GET /api/task-fields, and the task page shows a "Who may write"
section. The slice 1 doc's field table points to the file.
Darkwing's round 1, candidate manifest 33b96986, base 4f8ac1e7. Approved
by Dewey (27776) and Filbert (27785). Sage's gate green 18:57:31Z to
19:01:58Z.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
236 lines
16 KiB
JavaScript
236 lines
16 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 { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
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';
|
|
import { FIELDS_FILE } from '../../tasks/src/fields.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' });
|
|
});
|
|
|
|
// Row 61 (#1550): the field table is repository data, so it needs no business configured. A
|
|
// table that fails its check is refused whole, with the loader's words and no path, and a fixed
|
|
// file is read again on the next request.
|
|
test('task fields: the committed table with no reads configured, GET only; a bad table refuses with no path', async t => {
|
|
const table = JSON.parse(readFileSync(FIELDS_FILE, 'utf8'));
|
|
let base = await serve(t, null);
|
|
let r = await get(base, '/api/task-fields');
|
|
assert.equal(r.status, 200, r.text);
|
|
assert.deepEqual(JSON.parse(r.text), table);
|
|
for (const method of ['POST', 'PUT', 'DELETE', 'HEAD']) assert.equal((await get(base, '/api/task-fields', { method })).status, 405, method);
|
|
assert.equal((await get(base, '/api/task-fields', { headers: { origin: 'https://evil.example' } })).status, 403);
|
|
const dir = mkdtempSync(join(tmpdir(), 'webui-fields-')), file = join(dir, 'fields.json');
|
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
const server = await startServer({ port: 0, fieldsFile: file });
|
|
t.after(() => server.close());
|
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
const bad = [
|
|
[null, 'fields.json: ENOENT'],
|
|
['{"source":', 'fields.json: not JSON'],
|
|
[JSON.stringify({ ...table, verbs: { ...table.verbs, 'task.close': 'reviewer' } }), 'verbs.task.close: writer must be pm, assigned, any'],
|
|
[JSON.stringify({ ...table, fields: table.fields.filter(f => !['title', 'description'].includes(f.field)) }), 'verbs.task.scope.change: writes no field'],
|
|
];
|
|
for (const [content, said] of bad) {
|
|
if (content !== null) writeFileSync(file, content);
|
|
r = await get(base, '/api/task-fields');
|
|
assert.equal(r.status, 503, said);
|
|
assert.deepEqual(JSON.parse(r.text), { error: 'read-failed', message: `the task field table did not load: ${said}` });
|
|
assert.equal(r.text.includes(dir), false);
|
|
}
|
|
writeFileSync(file, JSON.stringify(table));
|
|
r = await get(base, '/api/task-fields');
|
|
assert.equal(r.status, 200);
|
|
assert.deepEqual(JSON.parse(r.text), table);
|
|
});
|