Files
stack/packages/webui/tests/template-http.test.mjs
jason.woltje 65f7288cea fix(webui): template save maps the new template-exists refusal to 409, not outcome-unknown (#1873)
f6c added template-exists for a save under a taken name. The template
route had no entry for it, so a duplicate save answered 503
outcome-unknown. Map it to 409 and move the SP8 pin.
2026-11-13 20:14:49 -06:00

174 lines
12 KiB
JavaScript

// Starter packs SP8 (#1873): the owner's template routes on a selected organization, end to end. An
// ephemeral organization with the real runtime 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 { existsSync, 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 { TEMPLATE_WRITE_VERBS, TEMPLATES_ROUTE, templatesCaller } from '../src/template-http.mjs';
import { close } from './fixture.mjs';
async function fixture(t, { send = true } = {}) {
const undo = []; t.after(async () => { for (const step of undo.reverse()) await step(); });
const root = mkdtempSync(join(tmpdir(), 'th-'));
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 templatesWrite = templatesCaller(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, templatesWrite: send ? templatesWrite : 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 saved = name => join(root, 'templates', 'jason', `${name}.json`);
return { base, file, root, calls, saved, 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('SP8 the owner previews a template (nothing written), saves it with the digest, and lists it', async t => {
const f = await fixture(t);
const [empty, none] = await call(f, `${ORG}/templates`);
assert.deepEqual([empty, none], [200, { templates: [], writable: true }]);
const before = readFileSync(f.file);
const [previewed, preview] = await call(f, `${ORG}/templates/preview`, 'POST', { name: 'demo-shape' });
assert.equal(previewed, 200, JSON.stringify(preview));
assert.match(preview.digest, /^[a-f0-9]{64}$/);
assert.ok(typeof preview.json === 'string' && Array.isArray(preview.freeText));
assert.ok(!existsSync(f.saved('demo-shape')), 'preview writes nothing');
const [stale, staleBody] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: '0'.repeat(64) });
assert.deepEqual([stale, staleBody.error], [409, 'pack-plan-stale']);
const [savedStatus, out] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: preview.digest });
assert.deepEqual([savedStatus, out], [200, { name: 'demo-shape', scope: 'org', digest: preview.digest }]);
assert.equal(readFileSync(f.saved('demo-shape'), 'utf8'), preview.json);
assert.deepEqual(readFileSync(f.file), before, 'the organization file is unchanged');
const [again, againBody] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: preview.digest });
assert.deepEqual([again, againBody.error], [409, 'template-exists']);
const [, list] = await call(f, `${ORG}/templates`);
assert.deepEqual(list.templates.map(x => [x.name, x.scope]), [['demo-shape', 'org']]);
// The preview and the list went over the read; only the saves used the fenced writer.
assert.deepEqual(f.calls.map(([kind, verb, keys]) => `${kind} ${verb} ${keys.join(',')}`), [
'read template.list.console ', 'read template.save.console name,preview',
'write template.save.console digest,name', 'write template.save.console digest,name', 'write template.save.console digest,name',
'read template.list.console ',
]);
});
test('SP8 the admission checks refuse before any bus call; the writer is fenced to a digest save', async t => {
const f = await fixture(t);
const digest = 'a'.repeat(64);
for (const [label, path, method, body, headers, expected] of [
['account field', `${ORG}/templates/preview`, 'POST', { name: 'x', account: 'alex' }, {}, [400, 'invalid-request']],
['preview field on save', `${ORG}/templates`, 'POST', { name: 'x', digest, preview: true }, {}, [400, 'invalid-request']],
['save without digest', `${ORG}/templates`, 'POST', { name: 'x' }, {}, [400, 'invalid-request']],
['bad digest', `${ORG}/templates`, 'POST', { name: 'x', digest: 'zz' }, {}, [400, 'invalid-request']],
['dot-dot name', `${ORG}/templates/preview`, 'POST', { name: '..' }, {}, [400, 'invalid-request']],
['path name', `${ORG}/templates/preview`, 'POST', { name: 'a/b' }, {}, [400, 'invalid-request']],
['bad project', `${ORG}/templates/preview`, 'POST', { name: 'x', project: '../p' }, {}, [400, 'invalid-request']],
['not JSON', `${ORG}/templates/preview`, 'POST', '{', {}, [400, 'invalid-request']],
['too large', `${ORG}/templates/preview`, 'POST', JSON.stringify({ name: 'x', project: 'p'.repeat(5000) }), {}, [413, 'invalid-request']],
['form post', `${ORG}/templates`, 'POST', { name: 'x', digest }, { 'content-type': 'text/plain' }, [400, 'invalid-request']],
['cross site', `${ORG}/templates`, 'POST', { name: 'x', digest }, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
['cross site read', `${ORG}/templates`, 'GET', undefined, { 'sec-fetch-site': 'cross-site' }, [403, 'cross-origin']],
['preview by GET', `${ORG}/templates/preview`, 'GET', undefined, {}, [405, 'method-not-allowed']],
['list by DELETE', `${ORG}/templates`, 'DELETE', { name: 'x' }, {}, [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, 0, 'no refused request reached the bus');
assert.ok(!existsSync(join(f.root, 'templates')));
assert.deepEqual(TEMPLATE_WRITE_VERBS, ['template.save.console']);
await assert.rejects(templatesCaller(async () => ({}))('template.list.console', {}, {}), /does not call template\.list\.console/);
await assert.rejects(templatesCaller(async () => ({}))('template.save.console', { name: 'x', preview: true }, {}), /does not preview/);
assert.ok(TEMPLATES_ROUTE.test('/api/templates') && TEMPLATES_ROUTE.test('/api/templates/preview') && !TEMPLATES_ROUTE.test('/api/templates/x'));
f.signOut();
assert.deepEqual((await call(f, `${ORG}/templates`))[0], 401);
});
test('SP8 without --send the save is read-only; the list and preview still answer', async t => {
const f = await fixture(t, { send: false });
const [listed, list] = await call(f, `${ORG}/templates`);
assert.deepEqual([listed, list.writable], [200, false]);
const [previewed, preview] = await call(f, `${ORG}/templates/preview`, 'POST', { name: 'demo-shape' });
assert.equal(previewed, 200, JSON.stringify(preview));
const [status, result] = await call(f, `${ORG}/templates`, 'POST', { name: 'demo-shape', digest: preview.digest });
assert.deepEqual([status, result.error], [403, 'read-only']);
assert.ok(!existsSync(join(f.root, 'templates')));
});
test('SP8 a session that is not the root owner reaches no template route; neither does a Console without sign-in', async t => {
const f = await fixture(t);
f.revokeOwner();
for (const [path, method, body] of [[`${ORG}/templates`, 'GET'], [`${ORG}/templates/preview`, 'POST', { name: 'x' }], [`${ORG}/templates`, 'POST', { name: 'x', digest: 'a'.repeat(64) }]]) {
const [status, result] = await call(f, path, method, body);
assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`);
}
assert.equal(f.calls.length, 0);
const web = await startServer({ port: 0, board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(web));
const bare = { base: `http://127.0.0.1:${web.address().port}` };
for (const [path, method, body] of [['/api/templates', 'GET'], ['/api/templates/preview', 'POST', { name: 'x' }]]) {
const [status, result] = await call(bare, path, method, body);
assert.deepEqual([status, result.error], [403, 'owner-only'], `${method} ${path}`);
}
});
test('SP8 a sign-out while the body is still arriving dispatches nothing', async t => {
const f = await fixture(t);
const data = JSON.stringify({ name: 'demo-shape', digest: 'a'.repeat(64) });
const answer = new Promise((resolve, reject) => {
const req = request(`${f.base}${ORG}/templates`, { 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, 0);
});