import { test } from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { createDevOwner } from '../src/dev-owner.mjs'; import { BusReadError } from '../src/bus.mjs'; import { close } from './fixture.mjs'; const thread = '20000000-0000-4000-8000-000000000002'; const message = '30000000-0000-4000-8000-000000000003'; test('scoped chat project creation reaches only the selected organization', async t => { const calls = []; const organizations = { list: async () => [], resolve: async id => ({ busHealthy: true, send: async () => ({}), chat: { createProject: async args => { calls.push([id, args]); return { id: thread }; } } }) }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, log: () => {} }); t.after(() => close(server)); const base = `http://127.0.0.1:${server.address().port}`; const response = await fetch(`${base}/api/orgs/alpha/chat/projects`, { method: 'POST', headers: { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: JSON.stringify({ name: 'Work', repo: 'work' }) }); assert.equal(response.status, 200); assert.deepEqual(calls, [['alpha', { name: 'Work', repo: 'work' }]]); }); test('organization routes bind reads and writes to the authorized organization', async t => { const calls = []; const organizations = { list: async () => [{ id: 'alpha', label: 'Alpha', theme: null, busHealthy: true }, { id: 'beta', label: 'Beta', theme: null, busHealthy: false }], resolve: async (id, principal) => { assert.equal(principal.sub, 'local-dev-owner'); if (!['alpha', 'beta'].includes(id)) throw Error('unavailable'); return { busHealthy: id === 'alpha', bus: { inbox: async () => { if (id === 'beta') throw Error('down'); return [{ id }]; } }, chat: { send: async args => { calls.push([id, args]); return { id: message }; } }, send: async () => ({}), }; }, }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, requestRead: async () => { throw Error('a sibling transport must never be used'); }, log: () => {} }); t.after(() => close(server)); const base = `http://127.0.0.1:${server.address().port}`; const get = async path => { const r = await fetch(base + path); return [r.status, await r.json()]; }; const post = async (path, body, extra = {}) => { const r = await fetch(base + path, { method: 'POST', headers: { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', ...extra }, body: JSON.stringify(body) }); return [r.status, await r.json()]; }; assert.deepEqual((await get('/api/orgs'))[1].map(o => o.id), ['alpha', 'beta']); assert.equal((await get('/api/orgs/alpha/bus/inbox'))[1].rows[0].id, 'alpha'); const config = (await get('/api/orgs/alpha/config'))[1]; assert.equal(config.send, false); assert.equal(config.chatSend, true); assert.equal((await get('/api/orgs/beta/config'))[1].chatSend, false, 'offline organization cannot offer live writes'); assert.notEqual((await get('/api/orgs/beta/bus/inbox'))[0], 200); assert.equal((await get('/api/orgs/alpha/bus/inbox'))[0], 200); assert.equal((await get('/api/orgs/alpha/board'))[1].error, 'not-configured'); assert.equal((await get('/api/orgs/alpha/requests/request1'))[0], 503); assert.deepEqual(await get('/api/orgs/missing/bus/inbox'), [404, { error: 'org-unavailable' }]); assert.deepEqual(await get('/api/orgs/unauthorized/bus/inbox'), [404, { error: 'org-unavailable' }]); assert.equal((await post(`/api/orgs/alpha/chat/threads/${thread}/messages`, { body: 'hello' }))[0], 200); assert.deepEqual(calls, [['alpha', { thread, body: 'hello' }]]); assert.equal((await post(`/api/orgs/beta/chat/threads/${thread}/messages`, { body: 'hello' }, { origin: 'http://evil.example' }))[0], 403); assert.equal(calls.length, 1); // F3: the owner's settings writes now reach the selected organization; this one offers no // settings writer, so the route itself answers read-only and nothing is written. assert.equal((await post('/api/orgs/alpha/settings', {}))[1].error, 'read-only'); assert.equal((await post('/api/orgs/alpha/settings', {}, { origin: 'http://evil.example' }))[0], 403); assert.equal((await post('/api/settings', {}))[1].error, 'org-unavailable'); }); test('registered hooks run behind the existing sign-in and write fences', async t => { const calls = []; const organizations = { list: async () => [], resolve: async (id) => id === 'alpha' ? {} : null }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, routes: [table => { table.add('GET', '/api/themes', (_req, res) => { calls.push('themes'); res.end('themes'); }); table.add('POST', '/api/orgs', (_req, res) => { calls.push('create'); res.end('created'); }); table.add('POST', /^\/api\/orgs\/([^/]+)\/setup\/start$/, (_req, res, context) => { calls.push(context.orgId); res.end('started'); }); }] }); t.after(() => close(server)); const base = `http://127.0.0.1:${server.address().port}`; assert.equal(await (await fetch(base + '/api/themes')).text(), 'themes'); const post = (path, origin, site) => fetch(base + path, { method: 'POST', headers: { origin, 'sec-fetch-site': site } }); assert.equal((await post('/api/orgs', base, 'cross-site')).status, 403); assert.equal((await post('/api/orgs', base, 'same-origin')).status, 200); assert.equal((await post('/api/orgs/unknown/setup/start', base, 'same-origin')).status, 404); assert.equal((await post('/api/orgs/alpha/setup/start', base, 'same-origin')).status, 200); assert.deepEqual(calls, ['themes', 'create', 'alpha']); }); test('decision routes use only the selected organization and reject a sibling decision', async t => { const betaId = '20000000-0000-4000-8000-000000000003'; const digest = 'a'.repeat(64), calls = [], beta = { resolved: false }; const organizations = { list: async () => [{ id: 'alpha' }, { id: 'beta' }], resolve: async id => ({ busHealthy: true, send: async () => ({}), decisionWrite: async (verb, args) => { calls.push([id, verb]); if (id !== 'beta' || args.id !== betaId) throw new BusReadError('decision-not-found'); if (verb === 'decision.preview.console') return { id: betaId, business: 'beta', digest, question: 'Choose?', kind: 'decision', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }] }; beta.resolved = true; return { id: betaId, op: 'resolved', choice: args.choice }; }, }), }; const server = await startServer({ port: 0, sso: createDevOwner(), organizations, send: async () => ({}), log: () => {} }); t.after(() => close(server)); const base = `http://127.0.0.1:${server.address().port}`; const post = async (org, step, body) => { const r = await fetch(`${base}/api/orgs/${org}/decisions/${betaId}/${step}`, { method: 'POST', headers: { origin: base, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }, body: JSON.stringify(body) }); return { status: r.status, body: await r.json() }; }; const wrong = await post('alpha', 'confirmation', { choice: 'yes' }); assert.equal(wrong.status, 404); assert.equal(wrong.body.error, 'decision-not-found'); assert.equal(beta.resolved, false); assert.deepEqual(calls, [['alpha', 'decision.preview.console']]); const confirmation = await post('beta', 'confirmation', { choice: 'yes' }); assert.equal(confirmation.status, 200); const crossResolve = await post('alpha', 'resolve', { choice: 'yes', confirmation: confirmation.body.confirmation }); assert.notEqual(crossResolve.status, 200); assert.equal(beta.resolved, false); const second = await post('beta', 'confirmation', { choice: 'yes' }); const resolve = await post('beta', 'resolve', { choice: 'yes', confirmation: second.body.confirmation }); assert.equal(resolve.status, 200); assert.equal(beta.resolved, true); });