import test from 'node:test'; import assert from 'node:assert/strict'; import { request } from 'node:http'; import { startServer } from '../src/serve.mjs'; import { createChat } from '../src/chat.mjs'; import { BusReadError } from '../src/bus.mjs'; import { createSsoSession } from './helpers/sso-session.mjs'; // #797 MVP3: the Console's project archive routes (src/project-archive-http.mjs), served ahead // of chatHTTP. Three shapes: GET /api/chat/projects/archived, POST .../archive, POST .../unarchive. const P = '10000000-0000-4000-8000-000000000001'; const ROW = { id: P, at: '2026-11-06T00:00:00.000Z', name: 'demo', repo: null, archived: true }; async function setup(t, { enabled = true } = {}) { const calls = [], state = { value: null }; const auth = await createSsoSession(t, { host: 'console.example' }); const chat = createChat({ call: async (verb, args) => { if (verb === 'plugins') return { plugins: { channel: ['webui'] } }; calls.push([verb, args]); if (state.value instanceof Error) throw state.value; return state.value; } }); const server = await startServer({ port: 0, sso: auth.sso, send: enabled ? async () => ({}) : null, chat, log: () => {} }); t.after(() => new Promise(r => { server.close(r); server.closeIdleConnections(); })); const session = await auth.signIn(server.address().port), headers = auth.writeHeaders(session); const call = (path, { method = 'GET', value, hs = headers, query = '' } = {}) => new Promise((resolve, reject) => { const req = request({ host: '127.0.0.1', port: server.address().port, path: `/api/chat/projects/${path}${query}`, method, headers: hs }, res => { let text = ''; res.on('data', c => text += c); res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) })); }); req.on('error', reject); req.end(value === undefined ? undefined : JSON.stringify(value)); }); return { calls, state, call, chat, headers }; } test('archive seam exposes the three fixed calls', async t => { const f = await setup(t); f.state.value = []; await f.chat.projects({ includeArchived: true }); assert.deepEqual(f.calls.at(-1), ['project.list', { includeArchived: true }]); await f.chat.projects(); assert.deepEqual(f.calls.at(-1), ['project.list', {}]); f.state.value = { project: P, archived: true }; await f.chat.archiveProject({ project: P }); assert.deepEqual(f.calls.at(-1), ['project.archive', { project: P }]); f.state.value = { project: P, archived: false }; await f.chat.unarchiveProject({ project: P }); assert.deepEqual(f.calls.at(-1), ['project.unarchive', { project: P }]); }); test('GET archived lists flagged rows; archive and unarchive answer their receipts', async t => { const f = await setup(t); f.state.value = [ROW]; const list = await f.call('archived'); assert.equal(list.status, 200); assert.deepEqual(list.body.rows, [ROW]); assert.ok(typeof list.body.at === 'string'); assert.deepEqual(f.calls.at(-1), ['project.list', { includeArchived: true }]); f.state.value = { project: P, archived: true }; const archived = await f.call(`${P}/archive`, { method: 'POST', value: {} }); assert.equal(archived.status, 200); assert.equal(archived.body.project, P); assert.equal(archived.body.archived, true); assert.ok(typeof archived.body.at === 'string'); assert.deepEqual(f.calls.at(-1), ['project.archive', { project: P }]); f.state.value = { project: P, archived: false }; const back = await f.call(`${P}/unarchive`, { method: 'POST', value: {} }); assert.equal(back.status, 200); assert.deepEqual([back.body.project, back.body.archived], [P, false]); assert.deepEqual(f.calls.at(-1), ['project.unarchive', { project: P }]); }); test('routes enforce methods, payloads and query strictness before the transport', async t => { const f = await setup(t); const cases = [ ['archived', { method: 'POST', value: {} }, 405], [`${P}/archive`, {}, 405], [`${P}/unarchive`, {}, 405], ['archived', { query: '?limit=1' }, 400], [`${P}/archive`, { method: 'POST', value: {}, query: '?x=1' }, 400], [`${P}/archive`, { method: 'POST', value: { project: P } }, 400], [`${P}/archive`, { method: 'POST', value: [] }, 400], ]; for (const [path, opts, status] of cases) assert.equal((await f.call(path, opts)).status, status, path); assert.deepEqual(f.calls, []); }); test('write fences refuse before the transport, like chatHTTP', async t => { const f = await setup(t), off = await setup(t, { enabled: false }); assert.equal((await off.call(`${P}/archive`, { method: 'POST', value: {} })).status, 403); for (const hs of [ { ...f.headers, origin: 'https://evil.example' }, { ...f.headers, 'sec-fetch-site': 'cross-site' }, Object.fromEntries(Object.entries(f.headers).filter(([key]) => key !== 'cookie')), ]) assert.ok([401, 403].includes((await f.call(`${P}/archive`, { method: 'POST', value: {}, hs })).status), JSON.stringify(hs)); assert.deepEqual(f.calls, []); assert.deepEqual(off.calls, []); }); test('known refusals keep their codes, fixed messages and never transport prose', async t => { const f = await setup(t); const cases = [ ['human-required', 403, `${P}/archive`], ['project-not-found', 404, `${P}/archive`], ['project-already-archived', 409, `${P}/archive`], ['project-not-archived', 409, `${P}/unarchive`], ]; for (const [code, status, path] of cases) { f.state.value = new BusReadError(code, 'private transport reason'); const r = await f.call(path, { method: 'POST', value: {} }); assert.equal(r.status, status, code); assert.equal(r.body.error, code); assert.equal(r.body.sent, false); assert.ok(r.body.message.length > 0); assert.equal(JSON.stringify(r.body).includes('private transport reason'), false); } assert.equal(f.calls.length, cases.length); }); test('malformed or lost receipts are outcome-unknown and never retried', async t => { const f = await setup(t); for (const value of [null, { project: P }, { project: P, archived: null }, { project: P, archived: 'yes' }, new Error('socket closed')]) { f.state.value = value; const before = f.calls.length, r = await f.call(`${P}/archive`, { method: 'POST', value: {} }); assert.equal(r.status, 503); assert.equal(r.body.error, 'outcome-unknown'); assert.equal(r.body.sent, 'unknown'); assert.equal(f.calls.length, before + 1); } // A malformed list read answers invalid-response, also without prose. f.state.value = [{ id: 'bad' }]; assert.equal((await f.call('archived')).status, 502); f.state.value = new BusReadError('outcome-unknown'); assert.equal((await f.call('archived')).body.error, 'invalid-response'); });