// Starter packs SP4 (#1873): the owner's pack, board and workflow routes on a selected organization, // end to end. An ephemeral organization with the real runtime writer and the broker's console socket // for every call: the read and the fenced write both bind the business human's console cap, as the // Console's consoleCall does. The SSO session is a fixture authority. import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs'; import { request } from 'node:http'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs'; import { loadBusiness } from '../../organization/src/organization-config.mjs'; import { resolveInstance } from '../../organization/src/resolve.mjs'; import { busOrganization } from '../../bus/src/organization-context.mjs'; import { startBroker } from '../../bus/src/runtime.mjs'; import { serve } from '../../bus/src/server.mjs'; import { Client } from '../../bus/src/client.mjs'; import { startServer } from '../src/serve.mjs'; import { PACK_WRITE_VERBS, packsCaller } from '../src/packs-http.mjs'; import { close } from './fixture.mjs'; const FLOW = (title) => ({ description: `${title}.`, steps: [{ title, state: 'todo', roles: ['human'] }] }); const BOARD = { columns: [{ state: 'todo', label: 'Backlog' }, { state: 'in-progress', label: 'Building' }, { state: 'in-review', label: 'Review' }, { state: 'blocked', label: 'Blocked' }, { state: 'done', label: 'Merged' }], layout: 'kanban' }; async function fixture(t, { send = true } = {}) { const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); }); const root = mkdtempSync(join(tmpdir(), 'pk-')); const rolesDir = rolesCopy(root), system = systemFor(root); const doc = businessDoc(root, 'demo'); doc.plugins = { ...(doc.plugins ?? {}), harness: ['pi'] }; const file = writeJson(join(root, 'businesses/demo.json'), doc); mkdirSync(join(root, 'data'), { mode: 0o700 }); const business = loadBusiness('demo', { dir: root, rolesDir }); const resolved = Object.fromEntries(Object.keys(business.roles).map(instance => [instance, resolveInstance({ system, business, instance })])); undo.push(() => rmSync(root, { recursive: true, force: true })); const runtime = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busOrganization(business, resolved) }, organization: { dir: root, rolesDir, system } }); undo.push(() => runtime.close()); const path = join(root, 'b.sock'); const socketServer = await serve({ broker: runtime.broker, path, peerReader: { read: async () => ({}), close: () => {} }, authenticateConsole: proof => runtime.broker.bindConsole({ business: 'demo', human: 'jason', subject: proof.subject }) }); undo.push(() => socketServer.close()); const calls = []; const consoleCall = kind => async (verb, args, ctx) => { calls.push([kind, verb, Object.keys(args).sort()]); return new Client({ path, console: { subject: ctx.subject } }).call(verb, args); }; const requestRead = consoleCall('read'); const packsWrite = packsCaller(consoleCall('write')); const session = { provider: 'fixture', iss: 'https://issuer.example', sub: 'owner', name: 'jason', rootOwner: true }; let active = true; const sso = { publicOrigin: '', publicHost: '', formTargets: () => [], session: () => active ? session : null, handle: () => {}, health: () => ({}) }; const organizations = { list: async () => [{ id: 'demo', label: 'Demo', busHealthy: true }], resolve: async id => id !== 'demo' ? null : { busHealthy: true, send: send ? async () => ({}) : null, requestRead, packsWrite: send ? packsWrite : null, reads: { business: async () => ({ business: { id: 'demo' } }) }, bus: {}, chat: { projects: async () => [], threads: async () => [] } }, }; const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso, send: async () => ({}), organizations, log: () => {} }); const base = `http://127.0.0.1:${web.address().port}`; sso.publicOrigin = base; sso.publicHost = new URL(base).host; undo.push(() => close(web)); const onDisk = () => JSON.parse(readFileSync(file, 'utf8')); return { base, file, calls, onDisk, signOut: () => { active = false; }, revokeOwner: () => { session.rootOwner = false; session.user = 'legacy-user'; } }; } function call(f, path, method = 'GET', body, headers = {}) { return new Promise((resolve, reject) => { const data = body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body); const req = request(f.base + path, { method, headers: { ...(data !== undefined ? { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } : {}), ...headers } }, res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); }); req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end(data); }); } const ORG = '/api/orgs/demo'; test('SP4 the owner lists the packs, previews one (nothing written) and applies it with its digest', async t => { const f = await fixture(t); const [listed, list] = await call(f, `${ORG}/packs`); assert.equal(listed, 200, JSON.stringify(list)); assert.equal(list.writable, true); assert.ok(list.packs.some(p => p.id === 'coding' && p.origin === 'shipped')); const before = readFileSync(f.file); const [previewed, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }); assert.equal(previewed, 200, JSON.stringify(preview)); assert.match(preview.digest, /^[a-f0-9]{64}$/); assert.deepEqual(readFileSync(f.file), before, 'preview writes nothing'); const [stale, staleBody] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: '0'.repeat(64) }); assert.deepEqual([stale, staleBody.error], [409, 'pack-plan-stale']); assert.deepEqual(readFileSync(f.file), before); const [applied, receipt] = await call(f, `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest }); assert.equal(applied, 200, JSON.stringify(receipt)); assert.equal(receipt.pack, 'coding'); assert.match(receipt.revision, /^[a-f0-9]{64}$/); assert.deepEqual(f.onDisk().packs.map(p => [p.id, p.origin]), [['coding', 'shipped']]); // The two reads go over the read call; only apply over the fenced writer, and only the .console verbs. assert.deepEqual(f.calls.map(c => [c[0], c[1]]), [['read', 'packs.console'], ['read', 'pack.preview.console'], ['write', 'pack.apply.console'], ['write', 'pack.apply.console']]); }); test('SP4 DELETE of a workflow removes only that workflow; board POST changes only the board', async t => { const f = await fixture(t); const rev = async () => (await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }))[1].revision; for (const name of ['one', 'two']) { const [status, body] = await call(f, `${ORG}/workflows/${name}`, 'POST', { revision: await rev(), workflow: FLOW(name) }); assert.equal(status, 200, JSON.stringify(body)); } const [boarded, boardBody] = await call(f, `${ORG}/board`, 'POST', { revision: await rev(), board: BOARD }); assert.equal(boarded, 200, JSON.stringify(boardBody)); const before = f.onDisk(); assert.deepEqual(Object.keys(before.workflows).sort(), ['one', 'two']); assert.deepEqual(before.board, BOARD); const [removed, receipt] = await call(f, `${ORG}/workflows/one`, 'DELETE', { revision: await rev() }); assert.equal(removed, 200, JSON.stringify(receipt)); const after = f.onDisk(); assert.deepEqual(after.workflows, { two: before.workflows.two }); const { workflows: _a, ...restBefore } = before; const { workflows: _b, ...restAfter } = after; assert.deepEqual(restAfter, restBefore, 'nothing but the one workflow changed'); assert.deepEqual(f.calls.at(-1), ['write', 'workflow.set.console', ['name', 'revision', 'workflow']]); // A stale revision is refused and writes nothing. const [stale, staleBody] = await call(f, `${ORG}/workflows/two`, 'DELETE', { revision: receipt.revision.replace(/^./, c => c === 'a' ? 'b' : 'a') }); assert.deepEqual([stale, staleBody.error], [409, 'revision-conflict']); assert.deepEqual(f.onDisk(), after); }); test('SP4 the admission checks refuse before any bus call; a read-only Console still lists and previews', async t => { const f = await fixture(t); const revisionNow = (await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }))[1].revision; const asked = f.calls.length; for (const [label, path, method, body, headers, expected] of [ ['business field', `${ORG}/packs/preview`, 'POST', { pack: 'coding', business: 'other' }, {}, [400, 'invalid-request']], ['origin field', `${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: 'a'.repeat(64), origin: 'shipped' }, {}, [400, 'invalid-request']], ['apply without digest', `${ORG}/packs/apply`, 'POST', { pack: 'coding' }, {}, [400, 'invalid-request']], ['bad pack name', `${ORG}/packs/preview`, 'POST', { pack: '../x' }, {}, [400, 'invalid-request']], ['bad workflow name', `${ORG}/workflows/Bad%20Name`, 'POST', { revision: revisionNow, workflow: FLOW('x') }, {}, [400, 'invalid-request']], ['workflow not an object', `${ORG}/workflows/three`, 'POST', { revision: revisionNow, workflow: 'x' }, {}, [400, 'invalid-request']], ['delete with a workflow', `${ORG}/workflows/three`, 'DELETE', { revision: revisionNow, workflow: FLOW('x') }, {}, [400, 'invalid-request']], ['no revision', `${ORG}/board`, 'POST', { board: BOARD }, {}, [400, 'invalid-request']], ['not JSON', `${ORG}/board`, 'POST', '{', {}, [400, 'invalid-request']], ['too large', `${ORG}/board`, 'POST', JSON.stringify({ revision: revisionNow, board: { pad: 'x'.repeat(70000) } }), {}, [413, 'invalid-request']], ['form post', `${ORG}/board`, 'POST', { revision: revisionNow, board: BOARD }, { 'content-type': 'text/plain' }, [400, 'invalid-request']], ['cross site', `${ORG}/board`, 'POST', { revision: revisionNow, board: BOARD }, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']], ['preview by GET', `${ORG}/packs/preview`, 'GET', undefined, {}, [405, 'method-not-allowed']], ['board by DELETE', `${ORG}/board`, 'DELETE', { revision: revisionNow }, {}, [405, 'method-not-allowed']], ['board by PUT', `${ORG}/board`, 'PUT', { revision: revisionNow }, {}, [405, 'method-not-allowed']], ]) { const [status, result] = await call(f, path, method, body, headers); assert.deepEqual([status, result.error], expected, label); } assert.equal(f.calls.length, asked, 'no refused request reached the bus'); // The bus's own refusals arrive as their codes: an unknown pack, and a workflow naming no role. assert.deepEqual((await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'no-such-pack' })).map((x, i) => i ? x.error : x), [404, 'starter-pack-not-found']); const [badRole, badRoleBody] = await call(f, `${ORG}/workflows/three`, 'POST', { revision: revisionNow, workflow: { description: 'x.', steps: [{ title: 'x', state: 'todo', roles: ['ghost'] }] } }); assert.deepEqual([badRole, badRoleBody.error], [400, 'invalid-request']); // The fenced writer calls nothing but the three write verbs. assert.deepEqual(PACK_WRITE_VERBS, ['pack.apply.console', 'board.set.console', 'workflow.set.console']); await assert.rejects(packsCaller(async () => ({}))('packs.console', {}, {}), /does not call packs\.console/); f.signOut(); assert.deepEqual((await call(f, `${ORG}/packs`))[0], 401); }); test('SP4 without --send the writes are read-only; the list and preview still answer', async t => { const f = await fixture(t, { send: false }); const [listed, list] = await call(f, `${ORG}/packs`); assert.equal(listed, 200, JSON.stringify(list)); assert.equal(list.writable, false); const [previewed, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }); assert.equal(previewed, 200, JSON.stringify(preview)); const before = readFileSync(f.file); for (const [path, method, body] of [[`${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest }], [`${ORG}/board`, 'POST', { revision: preview.revision, board: BOARD }], [`${ORG}/workflows/one`, 'DELETE', { revision: preview.revision }]]) { const [status, result] = await call(f, path, method, body); assert.deepEqual([status, result.error], [403, 'read-only'], `${method} ${path}`); } assert.deepEqual(readFileSync(f.file), before); }); test('SP4 a session that is not the root owner reaches no pack route', async t => { const f = await fixture(t); const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }); const asked = f.calls.length; const before = readFileSync(f.file); f.revokeOwner(); for (const [path, method, body] of [[`${ORG}/packs`, 'GET'], [`${ORG}/packs/preview`, 'POST', { pack: 'coding' }], [`${ORG}/packs/apply`, 'POST', { pack: 'coding', digest: preview.digest }], [`${ORG}/workflows/one`, 'POST', { revision: preview.revision, workflow: FLOW('one') }], [`${ORG}/board`, 'POST', { revision: preview.revision, board: BOARD }]]) { // revokeOwner leaves a granted user's session: the route refuses it owner-only, POST /api/board included. const [status, result] = await call(f, path, method, body); assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`); } assert.equal(f.calls.length, asked, 'nothing reached the bus'); assert.deepEqual(readFileSync(f.file), before); }); test('SP4 a sign-out while the body is still arriving dispatches nothing', async t => { const f = await fixture(t); const [, preview] = await call(f, `${ORG}/packs/preview`, 'POST', { pack: 'coding' }); const asked = f.calls.length; const before = readFileSync(f.file); const data = JSON.stringify({ pack: 'coding', digest: preview.digest }); const answer = new Promise((resolve, reject) => { const req = request(`${f.base}${ORG}/packs/apply`, { method: 'POST', headers: { origin: f.base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) } }, res => { let text = ''; res.on('data', chunk => { text += chunk; }); res.on('end', () => resolve([res.statusCode, JSON.parse(text)])); }); req.on('error', reject); req.write(data.slice(0, 5)); setTimeout(() => { f.signOut(); req.end(data.slice(5)); }, 150); }); const [status, body] = await answer; assert.deepEqual([status, body.error], [401, 'sign-in-required']); assert.equal(f.calls.length, asked); assert.deepEqual(readFileSync(f.file), before); }); test('SP4 a Console without sign-in serves no pack route; a method the route never serves stays 405', async t => { const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {} }); t.after(() => close(web)); const f = { base: `http://127.0.0.1:${web.address().port}` }; for (const [path, method, body] of [['/api/packs', 'GET'], ['/api/packs/preview', 'POST', { pack: 'coding' }], ['/api/board', 'POST', { revision: '0'.repeat(64), board: BOARD }]]) { const [status, result] = await call(f, path, method, body); assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`); } for (const method of ['OPTIONS', 'PUT', 'DELETE']) assert.equal((await call(f, '/api/board', method))[0], 405, method); });