The Tasks board takes the selected organization's board: its labels and column order over the five fixed states, and its default layout unless the viewer saved one. The all-organizations view keeps the fixed labels. The organization page gains the owner's starter pack picker (list, preview with marks, flagged lines, credential lines and noHire marks, apply by digest) and a workflow list and editor that marks a step role naming no current role. Every organization- or pack-supplied string goes through esc or textContent. The workspace read carries each organization's and project's board. GET /api/orgs/<id>/workflows is the owner's read of the workflows, the role names a step may resolve to and the file revision. packsServes pins HEAD and GET /api/board to the Board read, never the pack route.
375 lines
26 KiB
JavaScript
375 lines
26 KiB
JavaScript
// 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, writeFileSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { createServer, 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 { organizationWriter } from '../../organization/src/organization.mjs';
|
|
import { templateHashes } from '../../organization/src/definitions.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, packsServes } 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, workflows = 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 = [];
|
|
// SP6: the owner's workflow read, as cli.mjs builds it: the writer's read of the file and the V2 template names.
|
|
const flowReads = [];
|
|
let flowFails = false;
|
|
const workflowsRead = () => { flowReads.push(1); if (flowFails) throw new Error('unreadable'); return { ...organizationWriter({ dir: root, rolesDir }).read('demo'), templates: templateHashes(rolesDir).map(x => x.template) }; };
|
|
// A stand-in Board upstream: it counts what reaches it, so a test can tell the Board read from the pack route.
|
|
const boardHits = [];
|
|
const board = createServer((req, res) => { boardHits.push([req.method, req.url]); res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify({ from: 'board' })); });
|
|
await new Promise(resolve => board.listen(0, '127.0.0.1', resolve));
|
|
undo.push(() => close(board));
|
|
const upstream = `http://127.0.0.1:${board.address().port}`;
|
|
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, workflowsRead: typeof workflows === 'function' ? workflows : workflows ? workflowsRead : null, upstream,
|
|
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, root, calls, onDisk, flowReads, boardHits, failFlows: () => { flowFails = true; }, 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);
|
|
});
|
|
|
|
// SP6: a HEAD carries no body, so it reports only the status.
|
|
function head(f, path) {
|
|
return new Promise((resolve, reject) => {
|
|
const req = request(f.base + path, { method: 'HEAD' }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
|
|
req.on('error', reject); req.setTimeout(10000, () => req.destroy(new Error('fixture request timed out'))); req.end();
|
|
});
|
|
}
|
|
|
|
test('SP6 GET /workflows returns the org and code-project workflows, the role names and the file revision a save takes', async t => {
|
|
const f = await fixture(t);
|
|
// One workflow per scope, written straight into the file. The step role `ghost` names no definition or
|
|
// template: the load checks only the shape of a role name, so the file holds it and the read shows it as written.
|
|
const doc = f.onDisk();
|
|
doc.definitions = { triage: { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Sort new work.' } };
|
|
doc.workflows = { intake: { title: 'Intake <img src=x onerror=window.pwn=1>', description: 'New work.', steps: [{ title: 'Sort', state: 'todo', roles: ['triage', 'human'] }, { title: 'Hand off', state: 'in-progress', roles: ['ghost'] }] } };
|
|
doc.projects.stack.workflows = { ship: { steps: [{ title: 'Build', state: 'in-progress', roles: ['coder'] }] } };
|
|
writeFileSync(f.file, `${JSON.stringify(doc, null, 2)}\n`);
|
|
const [status, body] = await call(f, `${ORG}/workflows`);
|
|
assert.equal(status, 200, JSON.stringify(body));
|
|
assert.deepEqual(Object.keys(body).sort(), ['projects', 'revision', 'roles', 'workflows', 'writable']);
|
|
assert.deepEqual(body.workflows, doc.workflows);
|
|
assert.deepEqual(body.projects, Object.fromEntries(Object.entries(doc.projects).map(([id, p]) => [id, p.workflows ?? {}])));
|
|
assert.deepEqual(body.projects.stack, doc.projects.stack.workflows);
|
|
assert.deepEqual(body.roles.definitions, ['triage']);
|
|
assert.deepEqual(body.roles.templates, templateHashes(join(f.root, 'roles')).map(x => x.template).sort());
|
|
assert.ok(body.roles.templates.includes('coder') && !body.roles.templates.includes('ghost'));
|
|
assert.equal(body.revision, createHash('sha256').update(readFileSync(f.file)).digest('hex'), 'the revision is the writer\'s: the hash of the file bytes');
|
|
assert.equal(body.writable, true);
|
|
assert.equal(JSON.stringify(body).includes(f.root), false, 'no host path leaves');
|
|
// The revision is the one a save sends back.
|
|
const [saved, receipt] = await call(f, `${ORG}/workflows/review`, 'POST', { revision: body.revision, workflow: FLOW('Check') });
|
|
assert.equal(saved, 200, JSON.stringify(receipt));
|
|
const [, again] = await call(f, `${ORG}/workflows`);
|
|
assert.equal(again.revision, receipt.revision);
|
|
assert.deepEqual(Object.keys(again.workflows).sort(), ['intake', 'review']);
|
|
// A project save and delete through the same revision chain.
|
|
const [projectSaved, projectReceipt] = await call(f, `${ORG}/workflows/review`, 'POST', { revision: again.revision, workflow: FLOW('Check'), project: 'stack' });
|
|
assert.equal(projectSaved, 200, JSON.stringify(projectReceipt));
|
|
const [deleted] = await call(f, `${ORG}/workflows/review`, 'DELETE', { revision: projectReceipt.revision, project: 'stack' });
|
|
assert.equal(deleted, 200);
|
|
assert.deepEqual(Object.keys((await call(f, `${ORG}/workflows`))[1].projects.stack).sort(), ['ship']);
|
|
});
|
|
|
|
test('SP6 GET /workflows leaves out a library project and answers outcome-unknown for a read it cannot trust', async t => {
|
|
const REV = 'a'.repeat(64);
|
|
const read = { document: { projects: { code: { workflows: { w: FLOW('W') } }, lib: { kind: 'library', workflows: { never: FLOW('N') } }, bare: {} } }, revision: REV, templates: ['coder', 7] };
|
|
const f = await fixture(t, { workflows: () => read });
|
|
const [status, body] = await call(f, `${ORG}/workflows`);
|
|
assert.equal(status, 200, JSON.stringify(body));
|
|
assert.deepEqual(body, { workflows: {}, projects: { code: { w: FLOW('W') }, bare: {} }, roles: { definitions: [], templates: ['coder'] }, revision: REV, writable: true });
|
|
for (const bad of [{ ...read, revision: 'stale' }, { ...read, templates: null }, { ...read, document: [] }, null]) {
|
|
const g = await fixture(t, { workflows: () => bad });
|
|
assert.deepEqual((await call(g, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [503, 'outcome-unknown'], JSON.stringify(bad));
|
|
}
|
|
});
|
|
|
|
test('SP6 GET /workflows is the owner\'s: non-owner, sign-out, no reader, an unreadable file, a write method and a cross-site read', async t => {
|
|
const f = await fixture(t);
|
|
const before = readFileSync(f.file);
|
|
assert.deepEqual((await call(f, `${ORG}/workflows`, 'POST', { revision: '0'.repeat(64) })).map((x, i) => i ? x.error : x), [405, 'method-not-allowed']);
|
|
assert.deepEqual((await call(f, `${ORG}/workflows`, 'DELETE', { revision: '0'.repeat(64) })).map((x, i) => i ? x.error : x), [405, 'method-not-allowed']);
|
|
assert.deepEqual((await call(f, `${ORG}/workflows`, 'GET', undefined, { 'sec-fetch-site': 'cross-site' })).map((x, i) => i ? x.error : x), [403, 'cross-origin']);
|
|
assert.equal(f.flowReads.length, 0, 'no refused request read the file');
|
|
f.failFlows();
|
|
assert.deepEqual((await call(f, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [503, 'org-unavailable']);
|
|
const reads = f.flowReads.length;
|
|
f.revokeOwner();
|
|
assert.deepEqual((await call(f, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [403, 'owner-only']);
|
|
assert.equal(f.flowReads.length, reads, 'a non-owner reads nothing');
|
|
assert.deepEqual(readFileSync(f.file), before);
|
|
|
|
const g = await fixture(t, { workflows: false });
|
|
assert.deepEqual((await call(g, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [503, 'not-configured']);
|
|
g.signOut();
|
|
assert.deepEqual((await call(g, `${ORG}/workflows`)).map((x, i) => i ? x.error : x), [401, 'sign-in-required']);
|
|
|
|
const h = await fixture(t, { send: false });
|
|
const [status, body] = await call(h, `${ORG}/workflows`);
|
|
assert.equal(status, 200, JSON.stringify(body));
|
|
assert.equal(body.writable, false, 'a Console without send reads workflows but cannot save them');
|
|
});
|
|
|
|
test('SP6 HEAD /api/board: serve sends GET and HEAD to the Board read, never the pack route', async t => {
|
|
// The predicate serve.mjs dispatches on (`packsPath = packsServes(path, req.method)`).
|
|
for (const method of ['GET', 'HEAD']) assert.equal(packsServes('/api/board', method), false, method);
|
|
for (const method of ['POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH']) assert.equal(packsServes('/api/board', method), true, method);
|
|
assert.equal(packsServes('/api/workflows', 'HEAD'), true, 'only /api/board is carved out');
|
|
assert.equal(packsServes('/api/packs', 'HEAD'), true);
|
|
|
|
// On an organization: GET /api/orgs/<id>/board reaches the organization's Board upstream; HEAD is the Board
|
|
// read's own 405 (it serves GET only) and nothing reaches the upstream or the pack route's bus.
|
|
const f = await fixture(t);
|
|
const [got, board] = await call(f, `${ORG}/board`);
|
|
assert.deepEqual([got, board], [200, { from: 'board' }]);
|
|
assert.deepEqual(f.boardHits, [['GET', '/api/board']]);
|
|
const asked = f.calls.length;
|
|
assert.equal(await head(f, `${ORG}/board`), 405);
|
|
assert.deepEqual(f.boardHits, [['GET', '/api/board']], 'the HEAD reached no upstream');
|
|
assert.equal(f.calls.length, asked, 'the HEAD reached no bus');
|
|
// POST stays the pack route's: a board write with a stale revision is the bus's revision-conflict.
|
|
const [posted, postBody] = await call(f, `${ORG}/board`, 'POST', { revision: '0'.repeat(64), board: BOARD });
|
|
assert.deepEqual([posted, postBody.error], [409, 'revision-conflict']);
|
|
assert.deepEqual(f.calls.at(-1), ['write', 'board.set.console', ['board', 'revision']]);
|
|
|
|
// On a root Console: GET /api/board is the Board read through its upstream, HEAD its 405, POST the pack route's owner-only.
|
|
const hits = [];
|
|
const upstream = createServer((req, res) => { hits.push(req.method); res.writeHead(200, { 'content-type': 'application/json' }); res.end('{"from":"root-board"}'); });
|
|
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
|
|
t.after(() => close(upstream));
|
|
const web = await startServer({ port: 0, board: `http://127.0.0.1:${upstream.address().port}`, log: () => {} });
|
|
t.after(() => close(web));
|
|
const r = { base: `http://127.0.0.1:${web.address().port}` };
|
|
assert.deepEqual(await call(r, '/api/board'), [200, { from: 'root-board' }]);
|
|
assert.equal(await head(r, '/api/board'), 405);
|
|
assert.deepEqual(hits, ['GET']);
|
|
assert.deepEqual((await call(r, '/api/board', 'POST', { revision: '0'.repeat(64), board: BOARD })).map((x, i) => i ? x.error : x), [403, 'owner-only']);
|
|
});
|
|
|
|
test('SP6 the pack picker and workflow files are the owner\'s: the tester listener gets 403, the owner 200', async t => {
|
|
const { createDevOwner } = await import('../src/dev-owner.mjs');
|
|
const organizations = { list: async () => [], resolve: async () => null };
|
|
const root = mkdtempSync(join(tmpdir(), 'pkt-')), socket = join(root, 't.sock');
|
|
const tester = await startServer({ tester: true, testerSocket: socket, board: 'http://127.0.0.1:9', organizations, log: () => {} });
|
|
t.after(async () => { await close(tester); rmSync(root, { recursive: true, force: true }); });
|
|
const viaTester = path => new Promise((resolve, reject) => {
|
|
const req = request({ socketPath: socket, path, headers: { host: 'mosaic-tester.invalid' } }, res => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
|
|
req.on('error', reject); req.end();
|
|
});
|
|
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations, log: () => {} });
|
|
t.after(() => close(owner));
|
|
for (const [path, type] of [['/packs.js', /javascript/], ['/workflows.js', /javascript/], ['/packs.css', /css/]]) {
|
|
assert.equal(await viaTester(path), 403, path);
|
|
const res = await fetch(`http://127.0.0.1:${owner.address().port}${path}`);
|
|
assert.equal(res.status, 200, path);
|
|
assert.match(res.headers.get('content-type'), type, path);
|
|
assert.ok((await res.text()).length > 100, path);
|
|
}
|
|
});
|