Files
stack/packages/webui/tests/project-archive-http.test.mjs
jason.woltje 3e21d239b3 feat(bus): owner project archive and unarchive (#797)
Jason's owner report 2026-11-06: no way to archive or delete projects.
Archive is the reversible half; delete stays out of scope because project
and run records are write-once evidence.

- project.archive / project.unarchive: human-only (project.create's gate
  minus the tester carve-out; tester, user and agent refused). State is the
  latest row of a new append-only project_archives journal (3s->3t, additive;
  the rows are the audit trail), so projects itself never changes and nothing
  is deleted. Repeats refuse project-already-archived / project-not-archived.
- project.list hides archived projects until {includeArchived:true}, which
  flags each with archived:true; a user still lists granted projects only.
- While archived, thread.create, thread.send, room.open and room.send refuse
  project-archived; every existing record stays readable and unarchive
  restores everything.
- Console: new public/project-archive.js (+css) and project-archive-http.mjs
  routes (chatHTTP's write fence); chat.js gains two one-line hooks,
  index.html one script and one link tag; styling only through the shell's
  .project-archive-control / .project-archived-filter hooks.
- CLI: mosaic project archive|unarchive <name-or-id> and list --archived.
- stack-release COUNTED/LISTED gain project_archives (schema order); schema
  step-count pins and migration fixtures updated for 3t.

bus 1601/1601 (Node 24; Node 26 has the pre-existing project-file socket
stall), webui 582/582, plugins 132/132, cli 1191/1194 (two pre-existing
load flakes of the row253 C4 and land_work families; both pass isolated and
the base commit's full suite fails four of the same family).
2026-11-05 23:50:51 -06:00

137 lines
6.6 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { request } from 'node:http';
import { startServer } from '../src/serve.mjs';
import { createChat } from '../src/chat.mjs';
import { BusReadError } from '../src/bus.mjs';
import { createSsoSession } from './helpers/sso-session.mjs';
// #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');
});