489 lines
31 KiB
JavaScript
489 lines
31 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';
|
|
import { createSsoSession } from './helpers/sso-session.mjs';
|
|
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
|
// Row 264a (#1769): a thread made by hand gets the default session the 3k-3l migration gives a
|
|
// legacy thread, with the thread's id.
|
|
const DEFAULT_SESSION = [
|
|
'INSERT INTO sessions(at,business,thread,id,instance,owner,name,op) SELECT at,business,id,id,instance,owner,\'default\',\'legacy-3l\' FROM threads WHERE id=?',
|
|
'INSERT INTO session_defaults(at,thread,session,by) SELECT at,id,id,\'migration-3l\' FROM threads WHERE id=?',
|
|
];
|
|
|
|
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, asText = false, extra = {}, host = preview.host, baseHeaders = headers } = {}) {
|
|
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, ...baseHeaders, ...(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: text ? (asText ? text : JSON.parse(text)) : null, cache: res.headers['cache-control'], headers: res.headers }));
|
|
});
|
|
req.on('error', reject); req.end(data);
|
|
});
|
|
}
|
|
async function setup(t, options = {}) {
|
|
const calls = [], logs = [];
|
|
const auth = await createSsoSession(t, { host: preview.host });
|
|
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, sso: auth.sso, send: async () => ({}), log: l => logs.push(l), ...options });
|
|
t.after(() => close(server));
|
|
const session = await auth.signIn(server.address().port), baseHeaders = auth.writeHeaders(session);
|
|
return { calls, logs, state, get: (path, opt = {}) => http(server.address().port, path, { ...opt, baseHeaders }), 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 [
|
|
{ cookie: '' }, { cookie: '__Host-mosaic_session=invalid' }, { cookie: ['invalid', 'invalid'] },
|
|
{ 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: { 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 auth = await createSsoSession(t, { host: preview.host });
|
|
const server = await startServer({ port: 0, chat, sso: auth.sso, send: async () => ({}), log: () => {} });
|
|
t.after(() => close(server));
|
|
const session = await auth.signIn(server.address().port), baseHeaders = auth.writeHeaders(session);
|
|
const get = (p, opt = {}) => http(server.address().port, p, { ...opt, baseHeaders });
|
|
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');
|
|
for (const sql of DEFAULT_SESSION) store.run(sql, other);
|
|
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,session) VALUES(?,?,?)', M, other, 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');
|
|
for (const sql of DEFAULT_SESSION) store.run(sql, foreignThread);
|
|
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 reachable POST boundary even when called behind an admitted SSO request', async () => {
|
|
const { Readable } = await import('node:stream');
|
|
const { chatHTTP } = await import('../src/chat-http.mjs');
|
|
const signer = { sso: true, user: 'jason', host: preview.host, origin: `https://${preview.host}` };
|
|
for (const [path, value] of posts) for (const fence of ['send', 'named', '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 === '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: signer, log: () => {} });
|
|
assert.equal(status, 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 { chmodSync, mkdirSync, readFileSync, writeFileSync } = await import('node:fs');
|
|
const { dirname, join } = await import('node:path');
|
|
const { fileURLToPath } = await import('node:url');
|
|
const root = tmp(t, 'webui-chat-cli-'), { env } = fixture(root);
|
|
const op = await fakeOidc(); t.after(() => op.close());
|
|
const issuer = op.issuer('writer');
|
|
const config = JSON.parse(readFileSync(env.MOSAIC_CONFIG, 'utf8'));
|
|
config.console = { sso: { publicOrigin: `https://${preview.host}`, providers: [{
|
|
id: issuer.name, issuer: issuer.issuer, clientId: issuer.clientId,
|
|
clientSecret: { slot: issuer.name }, algs: ['RS256'], subjects: [{ sub: 'owner-sub', name: 'jason' }],
|
|
}] } };
|
|
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify(config));
|
|
writeFileSync(join(dirname(env.MOSAIC_CONFIG), 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: [{ id: 'u-000000000001', subjects: [{ provider: issuer.name, sub: 'owner-sub' }], name: 'jason', instanceRole: 'operator', state: 'active', authGen: 1, created: '2026-11-10T00:00:00Z' }], memberships: [], invites: [] }), { mode: 0o600 });
|
|
const secrets = join(dirname(env.MOSAIC_CONFIG), 'secrets', 'mosaic-stack');
|
|
mkdirSync(secrets, { recursive: true });
|
|
const secret = join(secrets, issuer.name); writeFileSync(secret, `${issuer.clientSecret}\n`, { mode: 0o600 }); chmodSync(secret, 0o600);
|
|
const ca = join(root, 'fake-oidc-ca.pem'); writeFileSync(ca, op.cert);
|
|
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',
|
|
'--sso', '--send'],
|
|
{ env: { ...env, NODE_EXTRA_CA_CERTS: ca }, 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}`)));
|
|
});
|
|
const login = await http(port, '/auth/login', { baseHeaders: {} });
|
|
assert.equal(login.status, 303);
|
|
const loginCookie = [login.headers['set-cookie'] ?? []].flat()[0].split(';')[0];
|
|
const grant = op.grant(issuer, login.headers.location, { sub: 'owner-sub' });
|
|
const state = new URL(login.headers.location).searchParams.get('state');
|
|
const callback = await http(port, `/auth/callback?${new URLSearchParams({ code: grant.code, state })}`, { baseHeaders: { cookie: loginCookie } });
|
|
assert.equal(callback.status, 303);
|
|
const session = [callback.headers['set-cookie'] ?? []].flat().find(value => value.startsWith('__Host-mosaic_session=') && !value.includes('Max-Age=0')).split(';')[0];
|
|
const signed = { cookie: session, origin: `https://${preview.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' };
|
|
// 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, baseHeaders: signed });
|
|
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);
|
|
// Under --sso the human listener answers only the public Host, and the page assets sit behind the session.
|
|
for (const name of ['chat.js', 'chat.css']) {
|
|
const result = await c.get(`/${name}`, { asText: true });
|
|
assert.equal(result.status, 200);
|
|
assert.match(result.headers['content-type'], name.endsWith('.js') ? /javascript/ : /css/);
|
|
assert.ok(result.data.length > 0, name);
|
|
const loopback = await c.get(`/${name}`, { extra: { host: `127.0.0.1:${c.port}` } });
|
|
assert.equal(loopback.status, 403); assert.equal(loopback.data.error, 'public Host required');
|
|
}
|
|
assert.equal((await c.get('/chat.js', { extra: { cookie: '' } })).status, 303);
|
|
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');
|
|
});
|
|
|
|
test('C2-09a createThread forwards an explicit instance through the existing channel', async t => {
|
|
const c = await setup(t);
|
|
c.state.answer = { id: T, instance: 'bean' };
|
|
const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'Bean work', instance: 'bean' } });
|
|
assert.equal(answer.status, 200);
|
|
assert.deepEqual(c.calls, [['thread.create', { project: P, name: 'Bean work', instance: 'bean' }]]);
|
|
assert.equal(answer.data.instance, 'bean');
|
|
});
|
|
test('C2-09a malformed instance and extra thread fields refuse before the channel write', async t => {
|
|
const c = await setup(t);
|
|
for (const value of [null, 7, {}, [], '', 'bad name', 'bean/../../other']) {
|
|
const answer = await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: value } });
|
|
assert.equal(answer.status, 400, JSON.stringify(value));
|
|
}
|
|
assert.equal((await c.get('/api/chat/threads', { method: 'POST', value: { project: P, name: 'work', instance: 'bean', extra: true } })).status, 400);
|
|
assert.deepEqual(c.calls, []);
|
|
});
|
|
|
|
test('264e session catalog/create and explicit selectors keep the human transport and strict keys', async t => {
|
|
const c = await setup(t);
|
|
c.state.answer = [{ id: M, name: 'second', default: false }];
|
|
const list = await c.get(`/api/chat/threads/${T}/sessions`);
|
|
assert.equal(list.status, 200);
|
|
assert.deepEqual(c.calls.at(-1), ['session.list', { thread: T }]);
|
|
c.state.answer = { id: M, thread: T, name: 'second' };
|
|
const made = await c.get(`/api/chat/threads/${T}/sessions`, { method: 'POST', value: { name: 'second', op: 'new-second' } });
|
|
assert.equal(made.status, 200);
|
|
assert.deepEqual(c.calls.at(-1), ['session.create', { thread: T, name: 'second', op: 'new-second' }]);
|
|
c.state.answer = [];
|
|
assert.equal((await c.get(`/api/chat/threads/${T}/messages?session=${M}`)).status, 200);
|
|
assert.deepEqual(c.calls.at(-1), ['thread.messages', { thread: T, session: M, limit: 100 }]);
|
|
c.state.answer = { opened: T };
|
|
assert.equal((await c.get(`/api/chat/threads/${T}/open`, { method: 'POST', value: { session: M } })).status, 200);
|
|
assert.deepEqual(c.calls.at(-1), ['thread.open', { thread: T, session: M }]);
|
|
const before = c.calls.length;
|
|
for (const path of [`messages?session=no`, `messages?session=${M}&session=${M}`, `sessions?owner=other`]) {
|
|
assert.equal((await c.get(`/api/chat/threads/${T}/${path}`)).status, 400);
|
|
}
|
|
for (const value of [{name:'x'}, {name:'x',op:'key',owner:'other'}, {name:'x',op:'../escape'}]) {
|
|
assert.equal((await c.get(`/api/chat/threads/${T}/sessions`, { method: 'POST', value })).status, 400);
|
|
}
|
|
assert.equal(c.calls.length, before);
|
|
});
|
|
test('264e session writes retain Origin/identity fences and explicit selector errors', async t => {
|
|
const c = await setup(t);
|
|
for (const extra of [{origin:'https://evil.example'}, {cookie:'__Host-mosaic_session=invalid'}, {'sec-fetch-site':'cross-site'}]) {
|
|
assert.ok((await c.get(`/api/chat/threads/${T}/sessions`, { method:'POST', value:{name:'x',op:'new-x'}, extra })).status >= 400);
|
|
}
|
|
assert.deepEqual(c.calls, []);
|
|
c.state.answer = new BusReadError('session-not-found');
|
|
const failed = await c.get(`/api/chat/threads/${T}/messages?session=${M}`);
|
|
assert.equal(failed.status, 404);
|
|
assert.equal(failed.data.error, 'session-not-found');
|
|
assert.equal(c.calls.length, 1);
|
|
});
|
|
|
|
test('264e clear envelope is projected, pending is preserved and GET never repeats the clear', async t => {
|
|
const c = await setup(t);
|
|
const envelope = {op:'clear-one',thread:T,session:M,successor:P,state:'uncertain',start:null,refusal:null,at:null};
|
|
c.state.answer = {...envelope, native:'/private/store', proof:'secret'};
|
|
const post = await c.get(`/api/chat/threads/${T}/clear`, {method:'POST',value:{session:M,revision:2,confirm:true,op:'clear-one'}});
|
|
assert.equal(post.status, 200);
|
|
assert.deepEqual(post.data, envelope);
|
|
assert.deepEqual(c.calls.at(-1), ['session.clear',{thread:T,session:M,revision:2,confirm:true,op:'clear-one'}]);
|
|
const get = await c.get(`/api/chat/threads/${T}/operation?op=clear-one`);
|
|
assert.equal(get.status, 200); assert.deepEqual(get.data, envelope);
|
|
assert.deepEqual(c.calls.at(-1), ['session.operation',{thread:T,op:'clear-one'}]);
|
|
assert.equal(c.calls.filter(([v])=>v==='session.clear').length, 1);
|
|
for (const value of [{session:M,revision:2,confirm:false,op:'clear-one'},{session:M,revision:-1,confirm:true,op:'clear-one'},{session:M,revision:2,confirm:true,op:'clear-one',pid:1}]) {
|
|
assert.equal((await c.get(`/api/chat/threads/${T}/clear`,{method:'POST',value})).status,400);
|
|
}
|
|
c.state.answer = new BusReadError('unknown-verb');
|
|
const missing = await c.get(`/api/chat/threads/${T}/clear`,{method:'POST',value:{session:M,revision:2,confirm:true,op:'clear-one'}});
|
|
assert.equal(missing.status,503);assert.equal(missing.data.sent,false);assert.match(missing.data.message,/unavailable/);
|
|
});
|
|
|
|
test('264e cancel is an authenticated, confirmed human write and retains retirement distinction', async t => {
|
|
const c=await setup(t), path=`/api/chat/threads/${T}/cancel`;
|
|
for(const retired of [null,true]) {
|
|
const envelope={op:'clear-one',thread:T,session:M,successor:P,state:'cancelled',start:retired?'held':null,retired,refusal:null,at:null};
|
|
c.state.answer={...envelope,proof:'private'};
|
|
const cancelled=await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true}});
|
|
assert.equal(cancelled.status,200);assert.deepEqual(cancelled.data,envelope);
|
|
assert.deepEqual(c.calls.at(-1),['session.cancel',{thread:T,op:'clear-one',confirm:true}]);
|
|
assert.deepEqual((await c.get(`/api/chat/threads/${T}/operation?op=clear-one`)).data,envelope);
|
|
}
|
|
const before=c.calls.length;
|
|
for(const value of [{op:'clear-one'}, {op:'clear-one',confirm:false}, {op:'clear-one',confirm:true,user:'other'}]) assert.equal((await c.get(path,{method:'POST',value})).status,400);
|
|
for(const extra of [{origin:'https://evil.example'}, {cookie:'__Host-mosaic_session=invalid'}, {'sec-fetch-site':'cross-site'}]) assert.ok((await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true},extra})).status>=400);
|
|
assert.equal(c.calls.length,before);
|
|
for(const code of ['session-clear-complete','session-name-too-long','session-op-conflict']) {
|
|
c.state.answer=new BusReadError(code);
|
|
const result=await c.get(path,{method:'POST',value:{op:'clear-one',confirm:true}});
|
|
assert.equal(result.status,409);assert.equal(result.data.sent,false);assert.equal(result.data.error,code);
|
|
}
|
|
});
|
|
|
|
test('272 N1: malformed clear/cancel write receipts get one redacted outcome-unknown audit line', async t => {
|
|
const c=await setup(t);
|
|
for(const [suffix,value,action] of [
|
|
['clear',{session:M,revision:0,confirm:true,op:'audit-clear'},'clearSession'],
|
|
['cancel',{confirm:true,op:'audit-cancel'},'cancelSession'],
|
|
]) {
|
|
c.state.answer={private:'SENTINEL',op:value.op,thread:T};
|
|
const before=c.logs.length;
|
|
const result=await c.get(`/api/chat/threads/${T}/${suffix}`,{method:'POST',value});
|
|
assert.equal(result.status,503);assert.equal(result.data.sent,'unknown');
|
|
assert.equal(c.logs.length,before+1);
|
|
assert.match(c.logs.at(-1),new RegExp(`action=${action} .*error=outcome-unknown`));
|
|
assert.doesNotMatch(c.logs.at(-1),/SENTINEL|private/);
|
|
}
|
|
const before=c.logs.length;
|
|
assert.equal((await c.get(`/api/chat/threads/${T}/operation?op=audit-clear`)).status,502);
|
|
assert.equal(c.logs.length,before,'pure reads do not add write audit lines');
|
|
});
|