Files

117 lines
7.5 KiB
JavaScript

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 }]]);
});