import test from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { startServer } from '../src/serve.mjs'; import { createChat } from '../src/chat.mjs'; import { BusReadError } from '../src/bus.mjs'; import { createSsoSession } from './helpers/sso-session.mjs'; const P = '10000000-0000-4000-8000-000000000001', T = '20000000-0000-4000-8000-000000000001'; const M = '30000000-0000-4000-8000-000000000001', N = '30000000-0000-4000-8000-000000000002'; const lines = { 'unknown-addressee': 'Unknown addressee. Choose a room member.', 'addressee-ambiguous': "That addressee is ambiguous. Use a room member's instance name.", 'room-thread-name-taken': 'A room thread name is already in use. Resolve the conflicting thread before opening the room.', 'room-not-open': 'This room is not open. Open the room before sending.', }; async function setup(t, { enabled = true } = {}) { const calls = [], state = { value: null }; const auth = await createSsoSession(t, { host: 'console.example' }); const chat = createChat({ call: async (verb, args) => { if (verb === 'plugins') return { plugins: { channel: ['webui'] } }; calls.push([verb, args]); if (state.value instanceof Error) throw state.value; return state.value; } }); const server = await startServer({ port: 0, sso: auth.sso, send: enabled ? async () => ({}) : null, chat, log: () => {} }); t.after(() => new Promise(r => { server.close(r); server.closeIdleConnections(); })); const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session); const call = (action, { method = 'GET', value, hs = headers, query = '' } = {}) => new Promise((resolve, reject) => { const req = request({ host: '127.0.0.1', port: server.address().port, path: `/api/chat/rooms/${P}/${action}${query}`, method, headers: hs }, res => { let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) })); }); req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value)); }); return { calls, state, call, chat, headers }; } test('C2-10c seam exposes three fixed room calls without thread.messages fanout', async t => { const f = await setup(t); for (const [method, args, verb] of [['roomOpen', { project: P }, 'room.open'], ['roomRead', { project: P, after: M, limit: 2 }, 'room.read'], ['roomSend', { project: P, body: '@bean exact\ntext ' }, 'room.send']]) { assert.equal(typeof f.chat[method], 'function'); f.state.value = []; await f.chat[method](args); assert.deepEqual(f.calls.at(-1), [verb, args]); } assert.deepEqual(f.calls.map(c => c[0]), ['room.open', 'room.read', 'room.send']); }); test('C2-10c HTTP preserves original send, member receipt and the last grouped paging id', async t => { const f = await setup(t); f.state.value = { project: P, threads: [{ id: T, instance: 'bean', name: 'bean' }] }; const open = await f.call('open', { method: 'POST', value: {} }); assert.equal(open.status, 200); assert.equal(open.body.project, P); assert.deepEqual(f.calls.at(-1), ['room.open', { project: P }]); const body = ' \t@bean\n exact text '; f.state.value = { ids: [M, N], to: ['bean', 'chet'] }; const sent = await f.call('messages', { method: 'POST', value: { body } }); assert.equal(sent.status, 200); assert.deepEqual(sent.body.ids, [M, N]); assert.deepEqual(f.calls.at(-1), ['room.send', { project: P, body }]); const check = { citations: [{ scope: 'supporting', agent: 'demo/bean', file: 'a'.repeat(40), last: 'b'.repeat(40) }] }; f.state.value = [{ id: M, ids: [M, N], to: ['bean', 'chet'], body: 'one', check }]; const page = await f.call('messages', { query: `?after=${T}&limit=1` }); assert.equal(page.status, 200); assert.equal(page.body.after, N); assert.equal(page.body.hasMore, true); assert.deepEqual(page.body.rows[0].check, check); assert.deepEqual(f.calls.at(-1), ['room.read', { project: P, after: T, limit: 1 }]); }); test('C2-10c room write fences and strict keys reject before transport', async t => { const f = await setup(t), off = await setup(t, { enabled: false }); assert.equal((await off.call('open', { method: 'POST', value: {} })).status, 403); for (const value of [{ project: P }, { owner: 'other' }, null, [], { body: 'x' }]) assert.equal((await f.call('open', { method: 'POST', value })).status, 400); for (const value of [{}, { body: '' }, { body: 'x', to: ['bean'] }, { body: 'x', project: P }]) assert.equal((await f.call('messages', { method: 'POST', value })).status, 400); for (const query of ['?limit=0', '?limit=201', '?after=x', '?project=other', `?after=${M}&after=${N}`]) assert.equal((await f.call('messages', { query })).status, 400); for (const hs of [{ ...f.headers, origin: 'https://evil.example' }, { ...f.headers, 'sec-fetch-site': 'cross-site' }, Object.fromEntries(Object.entries(f.headers).filter(([key]) => key !== 'cookie'))]) assert.ok([401, 403].includes((await f.call('open', { method: 'POST', value: {}, hs })).status)); assert.deepEqual(f.calls, []); assert.deepEqual(off.calls, []); }); test('C2-10c all four room refusals are409 with fixed text, never transport prose', async t => { const f = await setup(t); for (const [code, message] of Object.entries(lines)) { f.state.value = new BusReadError(code, 'private transport reason'); const r = await f.call(code === 'room-thread-name-taken' ? 'open' : 'messages', { method: 'POST', value: code === 'room-thread-name-taken' ? {} : { body: '@unknown hello' } }); assert.equal(r.status, 409, code); assert.equal(r.body.error, code); assert.equal(r.body.message, message); assert.equal(r.body.sent, false); assert.equal(JSON.stringify(r.body).includes('private transport reason'), false); } assert.equal(f.calls.length, 4); }); test('C2-10c malformed/lost write receipts are unknown and never retried', async t => { const f = await setup(t); for (const value of [null, { ids: [M], to: [] }, { ids: ['bad'], to: ['bean'] }, new Error('socket closed')]) { f.state.value = value; const before = f.calls.length, r = await f.call('messages', { method: 'POST', value: { body: 'hi' } }); assert.equal(r.status, 503); assert.equal(r.body.error, 'outcome-unknown'); assert.equal(r.body.sent, 'unknown'); assert.equal(f.calls.length, before + 1); } }); test('C2-10c room receipts reject coerced member keys and duplicate grouped ids', async t => { const f = await setup(t); f.state.value = { project: P, threads: [{ id: T, instance: 17, name: 17 }] }; const opened = await f.call('open', { method: 'POST', value: {} }); assert.equal(opened.status, 503); assert.equal(opened.body.sent, 'unknown'); for (const row of [{ id: M, ids: [M, M], to: ['bean', 'chet'], body: 'x' }, { id: M, ids: [M, N], to: ['bean', 'bean'], body: 'x' }]) { f.state.value = [row]; const read = await f.call('messages'); assert.equal(read.status, 502); assert.equal(read.body.error, 'invalid-response'); } }); test('C2-10c granted-user room calls never borrow the owner transport', async () => { const owner = [], user = []; const chat = createChat({ owner: async (...a) => { owner.push(a); return { plugins: { channel: ['webui'] } }; }, call: async (...a) => { user.push(a); throw new BusReadError('human-required'); } }); await assert.rejects(chat.roomRead({ project: P, limit: 100 }), { code: 'human-required' }); assert.deepEqual(owner, [['plugins', {}]]); assert.deepEqual(user, [['room.read', { project: P, limit: 100 }]]); });