117 lines
7.5 KiB
JavaScript
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 }]]);
|
|
});
|