The chat.mjs header comment now says the seam takes the bus transport it is given: the human one from the signed-in listener, the tester one from the row 176 listener (row 177). The TypeError for a missing or non-function call says 'chat needs a bus transport function' and no longer says human. No behavior changes. The webui README doesn't repeat either phrase and is unchanged. A new chat.test.mjs test passes undefined, null, a string, a number, an object, an array and no call, and reads the new TypeError text. Putting the old text back fails it. Dewey approved round 1 in #1681 comment 29376. Their notes (the tester line names no file, and the test title says both transports pass when only a stand-in function does) need no change.
330 lines
20 KiB
JavaScript
330 lines
20 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, []);
|
|
});
|
|
|
|
test('the chat seam names the transport it is given: the human one and the tester one both pass, anything else is a TypeError', () => {
|
|
// Row 177 hands chat the tester transport too, so the refusal must not call it a human transport.
|
|
for (const call of [undefined, null, 'call', 42, {}, []]) {
|
|
assert.throws(() => createChat({ call }), err => err instanceof TypeError && err.message === 'chat needs a bus transport function', String(call));
|
|
}
|
|
assert.throws(() => createChat({}), { name: 'TypeError', message: 'chat needs a bus transport function' });
|
|
assert.equal(typeof createChat({ call: async () => ({}) }).projects, 'function');
|
|
});
|