Files
stack/packages/webui/tests/chat.test.mjs
T
jason.woltjeandClaude Opus 5.5 008746b6ed plugins: channel kind for the CLI and Console chat paths (row 164, #1661)
Adds plugin kind `channel` (interfaceVersion 1, dispatch(request, {call}))
with cli and webui modules. Project and thread surfaces and thread
talk/follow in the CLI and the Console chat go through the selected
channel. An empty or absent channel selection is off: the CLI exits 3 and
the Console answers 503, each naming `mosaic plugins` and `mosaic plugins
set` with the business selector. Management plugins and plugins.set stay
on the core transport. The loader gains a synchronous snapshot(kind)
getter; loaded(), modules() and last-good handling are unchanged. No lock
is held across dispatch. serve.mjs, public/ and the bus are untouched.

Owner Rocko. Reviewer Dewey: r1 changes (comment 28980, B1: the recovery
sentence was never asserted), r2 approve (comment 28984) on candidate
manifest ae2fcfda...1fc8, the B1 assert killing mutant D1. Design accepted
by Mos (mos-0768, mos-0773). Fixture rewrite at project.test.mjs:47 acked
by Mos (mos-0789) on Dewey's line-by-line check: lines 48-51 identical,
one added assert. Sage gate on e5b10d275: 25 steps green, 2081 package tests, 156 queue node tests, 422 script selftests, 0 failed.

Live stays as pinned. The re-pin that takes this live puts `mosaic
plugins set` in Jason's window right after activate (mos-0768).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-18 14:24:42 -05:00

321 lines
19 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { createChat } from '../src/chat.mjs';
import { startServer } from '../src/serve.mjs';
import { BusReadError } from '../src/bus.mjs';
import { close } from './fixture.mjs';
const P = '10000000-0000-4000-8000-000000000001';
const T = '20000000-0000-4000-8000-000000000002';
const M = '30000000-0000-4000-8000-000000000003';
const preview = { host: 'chat.example.com', header: 'remote-user', user: 'jason' };
const headers = { 'remote-user': 'jason', origin: 'https://chat.example.com', 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
const posts = [
['/api/chat/projects', { name: 'work', repo: 'stack' }, 'project.create', { name: 'work', repo: 'stack' }, { id: P }],
['/api/chat/threads', { project: P, name: 'discussion' }, 'thread.create', { project: P, name: 'discussion' }, { id: T, instance: 'assistant' }],
[`/api/chat/threads/${T}/messages`, { body: ' hi\nthere ' }, 'thread.send', { thread: T, body: ' hi\nthere ', class: 'REQUEST' }, { id: M }],
[`/api/chat/threads/${T}/open`, {}, 'thread.open', { thread: T }, { opened: T }],
];
function http(port, path, { method = 'GET', value, raw, extra = {}, host = preview.host } = {}) {
const data = raw ?? (value === undefined ? undefined : JSON.stringify(value));
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, method, path, headers: {
host, ...headers, ...(data === undefined ? {} : { 'content-length': Buffer.byteLength(data) }), ...extra,
} }, res => {
let text = '';
res.on('data', c => { text += c; });
res.on('end', () => resolve({ status: res.statusCode, data: JSON.parse(text), cache: res.headers['cache-control'] }));
});
req.on('error', reject); req.end(data);
});
}
async function setup(t, options = {}) {
const calls = [], logs = [];
const state = { answer: [] };
const managementReads = [];
const chat = createChat({ call: async (verb, args) => {
if (verb === 'plugins') {
managementReads.push([verb, args]);
return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
}
calls.push([verb, args]);
if (state.answer instanceof Error) throw state.answer;
return typeof state.answer === 'function' ? state.answer(verb, args) : state.answer;
} });
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: l => logs.push(l), ...options });
t.after(() => close(server));
return { calls, logs, state, get: (path, opt) => http(server.address().port, path, opt), port: server.address().port };
}
test('chat reads map only fixed verbs, strict forward paging and pure GET', async t => {
const c = await setup(t);
for (const [path, verb, args] of [
['/api/chat/projects', 'project.list', {}],
['/api/chat/threads', 'thread.list', {}],
[`/api/chat/threads?project=${P}`, 'thread.list', { project: P }],
[`/api/chat/threads/${T}/messages`, 'thread.messages', { thread: T, limit: 100 }],
[`/api/chat/threads/${T}/messages?after=${M}&limit=2`, 'thread.messages', { thread: T, after: M, limit: 2 }],
]) {
const result = await c.get(path);
assert.equal(result.status, 200); assert.deepEqual(result.data.rows, []); assert.equal(result.cache, 'no-store');
assert.deepEqual(c.calls.at(-1), [verb, args]);
}
c.state.answer = [{ id: M }, { id: T }];
const page = await c.get(`/api/chat/threads/${T}/messages?limit=2`);
assert.equal(page.data.after, T); assert.equal(page.data.hasMore, true);
assert.ok(c.calls.every(([v]) => ['project.list', 'thread.list', 'thread.messages'].includes(v)));
});
test('chat refuses malformed and repeated query/id values before transport', async t => {
const c = await setup(t);
for (const path of [
'/api/chat/projects?owner=jason', '/api/chat/threads?project=no',
`/api/chat/threads?project=${P}&project=${P}`, `/api/chat/threads/${T}/messages?limit=0`,
`/api/chat/threads/${T}/messages?limit=201`, `/api/chat/threads/${T}/messages?limit=1e2`,
`/api/chat/threads/${T}/messages?limit=1&limit=2`, `/api/chat/threads/${T}/messages?after=no`,
`/api/chat/threads/${T}/messages?owner=jason`, '/api/chat/threads/not-a-uuid/messages',
]) assert.equal((await c.get(path)).status, 400, path);
assert.deepEqual(c.calls, []);
});
for (const [path, value, verb, args, answer] of posts) {
test(`chat ${verb}: one verified write, fixed identity/class and private log`, async t => {
const c = await setup(t); c.state.answer = answer;
const result = await c.get(path, { method: 'POST', value });
assert.equal(result.status, 200); assert.deepEqual(c.calls, [[verb, args]]);
for (const [key, v] of Object.entries(answer)) assert.equal(result.data[key], v);
assert.equal(result.cache, 'no-store'); assert.equal(c.logs.length, 1);
assert.doesNotMatch(c.logs[0], /discussion|there|stack|capability/);
});
test(`chat ${verb}: each existing Console write fence makes zero bus calls`, async t => {
const c = await setup(t);
for (const extra of [
{ 'remote-user': '' }, { 'remote-user': 'other' }, { 'remote-user': ['jason', 'jason'] },
{ origin: '' }, { origin: 'https://evil.example' }, { origin: ['https://chat.example.com', 'https://chat.example.com'] },
{ 'sec-fetch-site': '' }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': ['same-origin', 'same-origin'] },
{ 'content-type': 'text/plain' },
]) assert.ok((await c.get(path, { method: 'POST', value, extra })).status >= 400);
for (const raw of ['[]', 'null', '{', JSON.stringify({ ...value, owner: 'other' }), JSON.stringify({ body: 'x'.repeat(17000) })]) {
assert.equal((await c.get(path, { method: 'POST', raw })).status, 400);
}
assert.equal((await c.get(path + '?x=1', { method: 'POST', value })).status, 400);
assert.deepEqual(c.calls, []);
const ro = await setup(t, { send: null });
assert.equal((await ro.get(path, { method: 'POST', value })).status, 403); assert.deepEqual(ro.calls, []);
assert.equal((await c.get(path, { method: 'POST', value, host: `127.0.0.1:${c.port}`, extra: { 'remote-user': '', origin: '' } })).status, 403);
assert.deepEqual(c.calls, []);
});
test(`chat ${verb}: refusal or lost/malformed receipt never retries or invents success`, async t => {
const c = await setup(t);
for (const [answer_, status, error, sent] of [
[new BusReadError('thread-not-owned', 'SENTINEL private'), 403, 'thread-not-owned', false],
[new BusReadError('thread-not-found'), 404, 'thread-not-found', false],
[new BusReadError('outcome-unknown'), 503, 'outcome-unknown', 'unknown'],
[new Error('SENTINEL'), 503, 'outcome-unknown', 'unknown'],
[{}, 503, 'outcome-unknown', 'unknown'],
]) {
c.state.answer = answer_; const before = c.calls.length;
const result = await c.get(path, { method: 'POST', value });
assert.equal(result.status, status); assert.equal(result.data.error, error); assert.equal(result.data.sent, sent);
assert.doesNotMatch(JSON.stringify(result.data), /SENTINEL/); assert.equal(c.calls.length, before + 1);
}
});
}
test('chat createThread receipt requires an instance even when its id is valid', async t => {
const c = await setup(t);
for (const answer of [{ id: T }, { id: T, instance: 'invalid/instance' }, { id: T, instance: 3 }]) {
c.state.answer = answer;
const before = c.calls.length;
const result = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'discussion' } });
assert.equal(result.status, 503);
assert.equal(result.data.error, 'outcome-unknown');
assert.equal(result.data.sent, 'unknown');
assert.equal(c.calls.length, before + 1);
}
});
test('chat write receipts expose only the fields belonging to their operation', async t => {
for (const [path, value, , , answer] of posts) {
const c = await setup(t);
c.state.answer = { owner: 'private owner', capability: 'not-a-capability', body: 'private body', ...answer };
const result = await c.get(path, { method: 'POST', value });
assert.equal(result.status, 200);
const { at, ...receipt } = result.data;
assert.equal(typeof at, 'string');
assert.deepEqual(receipt, answer);
}
});
test('chat validates names, body, project and strict empty open payload', async t => {
const c = await setup(t);
for (const [path, value] of [
['/api/chat/projects', { name: '' }], ['/api/chat/projects', { name: 'x'.repeat(201) }],
['/api/chat/projects', { name: 'a\nb' }], ['/api/chat/projects', { name: 'a\0b' }],
['/api/chat/projects', { name: 'x', repo: '/private/path' }],
['/api/chat/threads', { name: 'x', project: 'bad' }], ['/api/chat/threads', { project: P }],
[`/api/chat/threads/${T}/messages`, { body: '' }], [`/api/chat/threads/${T}/messages`, { body: ' ' }],
[`/api/chat/threads/${T}/messages`, { body: 'x', class: 'INFO' }],
[`/api/chat/threads/${T}/open`, { thread: T }],
]) assert.equal((await c.get(path, { method: 'POST', value })).status, 400);
assert.deepEqual(c.calls, []);
});
test('chat read failures redact ownership and refuse invalid transport arrays', async t => {
const c = await setup(t);
for (const [answer, status, error] of [
[new BusReadError('thread-not-owned', 'private-name'), 403, 'thread-not-owned'],
[new BusReadError('thread-not-found'), 404, 'thread-not-found'],
[new BusReadError('human-required'), 403, 'human-required'],
[new BusReadError('no-bus-host'), 503, 'no-bus-host'], [{ rows: [] }, 502, 'invalid-response'],
]) {
c.state.answer = answer; const r = await c.get(`/api/chat/threads/${T}/messages`);
assert.equal(r.status, status); assert.equal(r.data.error, error); assert.doesNotMatch(JSON.stringify(r.data), /private-name/);
}
c.state.answer = { rows: [] };
for (const path of ['/api/chat/projects', '/api/chat/threads']) {
const result = await c.get(path);
assert.equal(result.status, 502); assert.equal(result.data.error, 'invalid-response');
}
});
test('REQ-USER-2 real broker: owned catalog, create/name/filter, isolated history and 403/404 without mutations', async t => {
const { Store } = await import('../../bus/src/store.mjs');
const { Broker } = await import('../../bus/src/broker.mjs');
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const root = mkdtempSync(join(tmpdir(), 'chat-broker-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const roles = { assistant: { definition: 'assistant', authority: { withinRole: ['message.send'], crossRole: [] } }, chief: { definition: 'chief-of-staff', authority: { withinRole: ['message.send'], crossRole: [] } } };
const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles, definitions: { assistant: {} }, projects: { stack: {} } } };
let b = new Broker({ store, businesses });
let cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const calls = [];
const managementReads = [];
const chat = createChat({ call: async (verb, args) => {
if (verb === 'plugins') {
managementReads.push([verb, args]);
return { business: 'demo', revision: 'a'.repeat(64), plugins: { channel: ['cli', 'webui'] } };
}
calls.push(verb);
try { return structuredClone(await b.request(cap, { verb, args })); }
catch (err) { throw new BusReadError(err.code); }
} });
const server = await startServer({ port: 0, chat, preview, send: async () => ({}), log: () => {} });
t.after(() => close(server));
const get = (p, opt) => http(server.address().port, p, opt);
const write = (p, value) => get(p, { method: 'POST', value });
const project = (await write('/api/chat/projects', { name: 'Project one', repo: 'stack' })).data.id;
assert.match(project, /^[a-f0-9-]{36}$/);
assert.equal((await write('/api/chat/projects', { name: 'Project one' })).status, 409);
assert.equal((await write('/api/chat/projects', { name: 'bad repo', repo: 'unknown' })).status, 400);
const a = (await write('/api/chat/threads', { project, name: 'alpha' })).data.id;
const bb = (await write('/api/chat/threads', { project, name: 'beta' })).data.id;
assert.equal((await write('/api/chat/threads', { project, name: 'alpha' })).status, 409);
assert.deepEqual((await get(`/api/chat/threads?project=${project}`)).data.rows.map(x => x.id), [a, bb]);
assert.deepEqual((await get(`/api/chat/threads?project=${P}`)).data.rows, []);
const other = '40000000-0000-4000-8000-000000000004';
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
other, new Date().toISOString(), 'demo', project, 'assistant', 'someone-else', 'PRIVATE-NAME');
store.run('INSERT INTO messages(id,at,business,from_role,from_run,to_role,class,body) VALUES(?,?,?,?,?,?,?,?)',
M, new Date().toISOString(), 'demo', 'human', 'human-cli', 'assistant', 'INFO', 'PRIVATE-BODY');
store.run('INSERT INTO message_threads(message,thread) VALUES(?,?)', M, other);
const foreignProject = '50000000-0000-4000-8000-000000000005';
const foreignThread = '60000000-0000-4000-8000-000000000006';
store.run('INSERT INTO projects(id,at,business,name,repo,created_by) VALUES(?,?,?,?,?,?)',
foreignProject, new Date().toISOString(), 'foreign', 'FOREIGN-PROJECT', null, 'jason');
store.run('INSERT INTO threads(id,at,business,project,instance,owner,name) VALUES(?,?,?,?,?,?,?)',
foreignThread, new Date().toISOString(), 'foreign', foreignProject, 'assistant', 'jason', 'FOREIGN-THREAD');
assert.equal((await get('/api/chat/threads')).data.rows.length, 2);
const before = ['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n);
for (const id of [other, T, foreignThread]) for (const suffix of ['messages', 'open']) {
const methods = suffix === 'messages' ? ['GET', 'POST'] : ['POST'];
for (const method of methods) {
const r = await get(`/api/chat/threads/${id}/${suffix}`, { method, value: method === 'GET' ? undefined : suffix === 'open' ? {} : { body: 'not allowed' } });
assert.equal(r.status, id === other ? 403 : 404);
assert.equal(r.data.error, id === other ? 'thread-not-owned' : 'thread-not-found');
assert.doesNotMatch(JSON.stringify(r.data), /PRIVATE|FOREIGN|someone-else/);
}
}
assert.deepEqual(['messages', 'thread_opens', "events WHERE kind='action.allowed'"].map(table => store.get(`SELECT count(*) n FROM ${table}`).n), before);
const one = (await write(`/api/chat/threads/${a}/messages`, { body: 'only alpha' })).data.id;
const two = (await write(`/api/chat/threads/${a}/messages`, { body: 'second alpha' })).data.id;
await write(`/api/chat/threads/${bb}/messages`, { body: 'only beta' });
const opens = store.get('SELECT count(*) n FROM thread_opens').n;
const deliveries = store.get('SELECT count(*) n FROM deliveries').n;
assert.deepEqual((await get(`/api/chat/threads/${a}/messages?limit=1`)).data.rows.map(x => x.id), [one]);
assert.deepEqual((await get(`/api/chat/threads/${a}/messages?limit=1&after=${one}`)).data.rows.map(x => x.id), [two]);
assert.equal((await get(`/api/chat/threads/${bb}/messages?after=${one}`)).status, 400);
assert.equal(store.get('SELECT count(*) n FROM deliveries').n, deliveries);
assert.equal(store.get('SELECT count(*) n FROM thread_opens').n, opens);
assert.equal((await write(`/api/chat/threads/${a}/open`, {})).data.opened, a);
assert.equal(store.get('SELECT count(*) n FROM thread_opens').n, opens + 1);
for (const [roles_, code] of [[{ chief: roles.chief }, 'thread-instance-missing'], [{ ...roles, second: roles.assistant }, 'thread-instance-ambiguous']]) {
b = new Broker({ store, businesses: { demo: { ...businesses.demo, roles: roles_ } } });
cap = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
assert.equal((await write('/api/chat/threads', { project, name: code })).data.error, code);
}
});
test('chat repeats every POST boundary even when called behind an admitted request', async () => {
const { Readable } = await import('node:stream');
const { chatHTTP } = await import('../src/chat-http.mjs');
for (const [path, value] of posts) for (const fence of ['send', 'named', 'identity', 'origin', 'site']) {
const req = Readable.from([Buffer.from(JSON.stringify(value))]);
req.method = 'POST'; req.headers = { ...headers };
req.headersDistinct = Object.fromEntries(Object.entries(headers).map(([k,v]) => [k,[v]]));
if (fence === 'identity') req.headersDistinct['remote-user'] = ['jason', 'jason'];
if (fence === 'origin') req.headersDistinct.origin = [];
if (fence === 'site') req.headersDistinct['sec-fetch-site'] = [];
let status, result, calls = 0;
const chat = createChat({ call: async () => { calls++; return { id: M }; } });
const res = { writeHead(code) { status = code; }, end(bytes) { result = JSON.parse(bytes); } };
await chatHTTP(req, res, { path, search: '', chat, named: fence !== 'named',
send: fence === 'send' ? null : async () => ({}), preview, log: () => {} });
assert.equal(status, fence === 'identity' ? 401 : 403, path + ' ' + fence);
assert.equal(result.sent, false); assert.equal(calls, 0);
}
});
test('configured Console process injects chat through its existing human transport', { timeout: 15000 }, async t => {
const { spawn } = await import('node:child_process');
const { tmp, fixture } = await import('../../cli/tests/helpers.mjs');
const { fileURLToPath } = await import('node:url');
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
const child = spawn(process.execPath, [cli, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9',
'--public-host', preview.host, '--auth-header', preview.header, '--allow-user', preview.user, '--send'],
{ env, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => {
if (child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
});
let output = '', errors = '';
child.stderr.on('data', c => { errors += c; });
const port = await new Promise((resolve, reject) => {
child.stdout.on('data', c => { output += c; const m = output.match(/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//); if(m) resolve(Number(m[1])); });
child.once('exit', code => reject(Error(`Console fixture exited ${code}: ${errors}`)));
});
// With a private fixture config but no fixture host, reaching the configured human
// transport gives no-bus-host; missing adapter injection would give not-configured.
for (const [path, options] of [['/api/chat/projects', {}], ['/api/chat/projects', { method:'POST', value:{name:'fixture'} }]]) {
const r = await http(port, path, options);
assert.equal(r.status, 503); assert.equal(r.data.error, 'no-bus-host');
}
});
test('chat assets, GET purity, method and unknown-route refusals are explicit', async t => {
const c = await setup(t);
for (const name of ['chat.js', 'chat.css']) {
const result = await fetch(`http://127.0.0.1:${c.port}/${name}`);
assert.equal(result.status, 200);
assert.match(result.headers.get('content-type'), name.endsWith('.js') ? /javascript/ : /css/);
}
assert.equal((await c.get(`/api/chat/threads/${T}/open`)).status, 404);
assert.equal((await c.get('/api/chat/private')).status, 404);
assert.equal((await c.get('/api/chat/projects', { method:'DELETE' })).status, 405);
assert.deepEqual(c.calls, []);
});