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