Files

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