site-finish-flow-run-kn-b.test.mjs
454 lines 17.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * SITE-FINISH-FLOW-RUN-KN-b — seven-tier coverage (§FR.0.4).
3 * Frozen: ~/scooling/docs/SITE-FINISH-FLOW-RUN-FREEZE.md (pass digest sha256:24f10167…)
4 *
5 * Proves gateway→bridge proxies for Hub run/consent family, bridge registration,
6 * env-off refuse path, and no FLIP of FLOW_RUN_WRITES_ENABLED /
7 * FLOW_AUTOMATABLE_EXECUTION_ENABLED.
8 *
9 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
10 */
11
12 import fs from 'node:fs';
13 import os from 'node:os';
14 import { describe, it } from 'node:test';
15 import assert from 'node:assert/strict';
16 import http from 'node:http';
17 import express from 'express';
18 import crypto from 'node:crypto';
19 import path from 'node:path';
20 import { performance } from 'node:perf_hooks';
21 import { fileURLToPath, pathToFileURL } from 'node:url';
22
23 import {
24 bridgeFlowRunHandlerRole,
25 createRunOutcomeProposalOnCanister,
26 } from '../hub/bridge/flow-run-routes.mjs';
27 import {
28 getFlowRunWritesEnabled,
29 getFlowAutomatableExecutionEnabled,
30 handleFlowRunStartRequest,
31 } from '../lib/flow/flow-execution.mjs';
32
33 const __dirname = path.dirname(fileURLToPath(import.meta.url));
34 const projectRoot = path.resolve(__dirname, '..');
35
36 const SECRET = 'gateway-flow-run-kn-b-test-secret-32chars!';
37
38 function signTestJwt(payload) {
39 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
40 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
41 const data = `${header}.${body}`;
42 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
43 return `${data}.${sig}`;
44 }
45
46 function startMockBridge(mockBridge) {
47 const srv = http.createServer(mockBridge);
48 return new Promise((resolve, reject) => {
49 srv.listen(0, '127.0.0.1', (err) => {
50 if (err) return reject(err);
51 const port = srv.address().port;
52 resolve({
53 bridgeUrl: `http://127.0.0.1:${port}`,
54 close: () => new Promise((r) => srv.close(() => r())),
55 });
56 });
57 });
58 }
59
60 function readRepo(rel) {
61 return fs.readFileSync(path.join(projectRoot, rel), 'utf8');
62 }
63
64 async function bootGateway(t, bridgeUrl, cacheBust) {
65 process.env.NETLIFY = '1';
66 process.env.CANISTER_URL = 'http://canister.placeholder.test';
67 process.env.SESSION_SECRET = SECRET;
68 process.env.BRIDGE_URL = bridgeUrl;
69
70 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
71 const { app: gwApp } = await import(`${gwEntry}?gwrun=${cacheBust}`);
72
73 const gwSrv = http.createServer(gwApp);
74 await new Promise((resolve, reject) => {
75 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
76 });
77 t.after(() => new Promise((r) => gwSrv.close(() => r())));
78 return gwSrv.address().port;
79 }
80
81 const FLOW_ID = 'flow_run_knb_1';
82 const RUN_ID = 'frun_run_knb_aaaaaaaa';
83
84 describe('SITE-FINISH-FLOW-RUN-KN-b — unit', () => {
85 it('bridgeFlowRunHandlerRole maps member → editor', () => {
86 assert.equal(bridgeFlowRunHandlerRole('member'), 'editor');
87 assert.equal(bridgeFlowRunHandlerRole('admin'), 'admin');
88 assert.equal(bridgeFlowRunHandlerRole('viewer'), 'viewer');
89 });
90
91 it('gateway + bridge source register §FR.0.4 run/consent proxies', () => {
92 const gw = readRepo('hub/gateway/server.mjs');
93 const bridge = readRepo('hub/bridge/server.mjs');
94 const routes = readRepo('hub/bridge/flow-run-routes.mjs');
95 assert.match(gw, /SITE-FINISH-FLOW-RUN-KN-b/);
96 assert.match(gw, /app\.get\('\/api\/v1\/flow-runs\/:run_id'/);
97 assert.match(gw, /app\.get\('\/api\/v1\/flows\/:id\/runs'/);
98 assert.match(gw, /app\.post\('\/api\/v1\/flows\/:id\/runs'/);
99 assert.match(gw, /app\.post\('\/api\/v1\/flows\/:id\/runs\/:run_id\/advance'/);
100 assert.match(gw, /app\.post\('\/api\/v1\/flows\/:id\/runs\/:run_id\/evidence'/);
101 assert.match(gw, /app\.post\('\/api\/v1\/flows\/:id\/runs\/:run_id\/execute-automatable'/);
102 assert.match(gw, /app\.post\('\/api\/v1\/flows\/:id\/runs\/:run_id\/submit-review'/);
103 assert.match(gw, /app\.post\('\/api\/v1\/flows\/:id\/runs\/:run_id\/consent'/);
104 assert.match(bridge, /registerBridgeFlowRunRoutes/);
105 assert.match(routes, /handleFlowRunStartRequest/);
106 assert.match(routes, /handleFlowExecutionConsentMintRequest/);
107 assert.match(routes, /withExternalProtocolBlobSync/);
108 assert.match(routes, /resolveStarterFlowsDir/);
109 assert.match(routes, /starterDir:\s*BRIDGE_STARTER_FLOWS_DIR/);
110 // Bridge Netlify site uses deploy/bridge/netlify.toml (Package directory), not root only.
111 const bridgeToml = fs.readFileSync(
112 path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'deploy', 'bridge', 'netlify.toml'),
113 'utf8',
114 );
115 assert.match(bridgeToml, /flows\/starter\/\*\.json/);
116 // Authoring + capture still present (no silent deletion).
117 assert.match(gw, /FLOW-WRITE-LIVE-GATEWAY-PROXY/);
118 assert.match(gw, /FLOW-CAPTURE-LIVE-KN-b/);
119 });
120
121 it('createRunOutcomeProposalOnCanister refuses missing CANISTER_URL', async () => {
122 await assert.rejects(
123 () =>
124 createRunOutcomeProposalOnCanister({
125 canisterUrl: '',
126 headers: {},
127 input: { intent: 'x', body: '{}' },
128 }),
129 (err) => err && err.code === 'NOT_AVAILABLE',
130 );
131 });
132 });
133
134 describe('SITE-FINISH-FLOW-RUN-KN-b — integration', () => {
135 it('gateway proxies all §FR.0.4 routes to BRIDGE_URL (env-off 403 codes)', async (t) => {
136 const calls = [];
137 const mockBridge = express();
138 mockBridge.use(express.json());
139
140 mockBridge.get('/api/v1/flow-runs/:run_id', (req, res) => {
141 calls.push({ path: 'get-run', id: req.params.run_id, auth: req.headers.authorization });
142 res.status(403).json({ error: 'Run writes are disabled', code: 'FLOW_RUN_WRITES_DISABLED' });
143 });
144 mockBridge.get('/api/v1/flows/:id/runs', (req, res) => {
145 calls.push({ path: 'list', id: req.params.id });
146 res.status(403).json({ error: 'Run writes are disabled', code: 'FLOW_RUN_WRITES_DISABLED' });
147 });
148 mockBridge.post('/api/v1/flows/:id/runs', (req, res) => {
149 calls.push({ path: 'start', id: req.params.id, body: req.body });
150 res.status(403).json({ error: 'Run writes are disabled', code: 'FLOW_RUN_WRITES_DISABLED' });
151 });
152 mockBridge.post('/api/v1/flows/:id/runs/:run_id/advance', (req, res) => {
153 calls.push({ path: 'advance', run: req.params.run_id });
154 res.status(403).json({ error: 'Run writes are disabled', code: 'FLOW_RUN_WRITES_DISABLED' });
155 });
156 mockBridge.post('/api/v1/flows/:id/runs/:run_id/evidence', (req, res) => {
157 calls.push({ path: 'evidence', run: req.params.run_id });
158 res.status(403).json({ error: 'Run writes are disabled', code: 'FLOW_RUN_WRITES_DISABLED' });
159 });
160 mockBridge.post('/api/v1/flows/:id/runs/:run_id/execute-automatable', (req, res) => {
161 calls.push({ path: 'execute', run: req.params.run_id });
162 res.status(403).json({
163 error: 'Automatable execution is disabled',
164 code: 'FLOW_AUTOMATABLE_EXECUTION_DISABLED',
165 });
166 });
167 mockBridge.post('/api/v1/flows/:id/runs/:run_id/submit-review', (req, res) => {
168 calls.push({ path: 'submit', run: req.params.run_id });
169 res.status(403).json({ error: 'Run writes are disabled', code: 'FLOW_RUN_WRITES_DISABLED' });
170 });
171 mockBridge.post('/api/v1/flows/:id/runs/:run_id/consent', (req, res) => {
172 calls.push({ path: 'consent', run: req.params.run_id });
173 res.status(403).json({
174 error: 'Automatable execution is disabled',
175 code: 'FLOW_AUTOMATABLE_EXECUTION_DISABLED',
176 });
177 });
178
179 const { bridgeUrl, close } = await startMockBridge(mockBridge);
180 t.after(close);
181 const port = await bootGateway(t, bridgeUrl, `int-${Date.now()}`);
182 const token = signTestJwt({ sub: 'user-run-proxy', role: 'editor', type: 'session' });
183 const headers = {
184 authorization: `Bearer ${token}`,
185 'content-type': 'application/json',
186 'x-vault-id': 'default',
187 };
188
189 const getRun = await fetch(
190 `http://127.0.0.1:${port}/api/v1/flow-runs/${encodeURIComponent(RUN_ID)}`,
191 { headers: { authorization: `Bearer ${token}`, 'x-vault-id': 'default' } },
192 );
193 assert.equal(getRun.status, 403);
194 assert.equal((await getRun.json()).code, 'FLOW_RUN_WRITES_DISABLED');
195
196 const list = await fetch(
197 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs`,
198 { headers: { authorization: `Bearer ${token}`, 'x-vault-id': 'default' } },
199 );
200 assert.equal(list.status, 403);
201
202 const start = await fetch(
203 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs`,
204 {
205 method: 'POST',
206 headers,
207 body: JSON.stringify({ flow_version: '1.0.0' }),
208 },
209 );
210 assert.equal(start.status, 403);
211
212 const advance = await fetch(
213 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs/${encodeURIComponent(RUN_ID)}/advance`,
214 {
215 method: 'POST',
216 headers,
217 body: JSON.stringify({ step_id: `${FLOW_ID}#1`, to_status: 'done' }),
218 },
219 );
220 assert.equal(advance.status, 403);
221
222 const evidence = await fetch(
223 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs/${encodeURIComponent(RUN_ID)}/evidence`,
224 {
225 method: 'POST',
226 headers,
227 body: JSON.stringify({
228 step_id: `${FLOW_ID}#1`,
229 evidence_ref: 'note:meta/x.md',
230 pointer_kind: 'note_path',
231 }),
232 },
233 );
234 assert.equal(evidence.status, 403);
235
236 const execute = await fetch(
237 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs/${encodeURIComponent(RUN_ID)}/execute-automatable`,
238 {
239 method: 'POST',
240 headers,
241 body: JSON.stringify({
242 step_id: `${FLOW_ID}#1`,
243 consent_id: 'fcons_deadbeef',
244 dry_run: true,
245 }),
246 },
247 );
248 assert.equal(execute.status, 403);
249 assert.equal((await execute.json()).code, 'FLOW_AUTOMATABLE_EXECUTION_DISABLED');
250
251 const submit = await fetch(
252 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs/${encodeURIComponent(RUN_ID)}/submit-review`,
253 {
254 method: 'POST',
255 headers,
256 body: JSON.stringify({ intent: 'review outcome' }),
257 },
258 );
259 assert.equal(submit.status, 403);
260
261 const consent = await fetch(
262 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs/${encodeURIComponent(RUN_ID)}/consent`,
263 {
264 method: 'POST',
265 headers,
266 body: JSON.stringify({ allowed_lanes: ['local_default'], cost_cap_units: 1 }),
267 },
268 );
269 assert.equal(consent.status, 403);
270
271 assert.equal(calls.length, 8);
272 assert.equal(calls[0].path, 'get-run');
273 assert.match(calls[0].auth, /^Bearer /);
274 assert.equal(calls[1].path, 'list');
275 assert.equal(calls[2].path, 'start');
276 assert.equal(calls[3].path, 'advance');
277 assert.equal(calls[4].path, 'evidence');
278 assert.equal(calls[5].path, 'execute');
279 assert.equal(calls[6].path, 'submit');
280 assert.equal(calls[7].path, 'consent');
281 });
282 });
283
284 describe('SITE-FINISH-FLOW-RUN-KN-b — e2e', () => {
285 it('handler refuse when FLOW_RUN_WRITES_ENABLED unset (default off)', () => {
286 const prev = process.env.FLOW_RUN_WRITES_ENABLED;
287 delete process.env.FLOW_RUN_WRITES_ENABLED;
288 try {
289 const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'tmp-flow-run-kn-b-'));
290 try {
291 assert.equal(getFlowRunWritesEnabled(tmp), false);
292 assert.equal(getFlowAutomatableExecutionEnabled(tmp), false);
293 const start = handleFlowRunStartRequest({
294 dataDir: tmp,
295 vaultId: 'default',
296 role: 'admin',
297 flowId: FLOW_ID,
298 flowVersion: '1.0.0',
299 });
300 assert.equal(start.ok, false);
301 assert.equal(start.code, 'FLOW_RUN_WRITES_DISABLED');
302 } finally {
303 fs.rmSync(tmp, { recursive: true, force: true });
304 }
305 } finally {
306 if (prev === undefined) delete process.env.FLOW_RUN_WRITES_ENABLED;
307 else process.env.FLOW_RUN_WRITES_ENABLED = prev;
308 }
309 });
310 });
311
312 describe('SITE-FINISH-FLOW-RUN-KN-b — stress', () => {
313 it('N concurrent start proxies all hit bridge (no drop)', async (t) => {
314 let hits = 0;
315 const mockBridge = express();
316 mockBridge.use(express.json());
317 mockBridge.post('/api/v1/flows/:id/runs', (_req, res) => {
318 hits += 1;
319 res.status(403).json({ code: 'FLOW_RUN_WRITES_DISABLED', error: 'disabled' });
320 });
321 const { bridgeUrl, close } = await startMockBridge(mockBridge);
322 t.after(close);
323 const port = await bootGateway(t, bridgeUrl, `stress-${Date.now()}`);
324 const token = signTestJwt({ sub: 'user-run-stress', role: 'editor', type: 'session' });
325 const headers = {
326 authorization: `Bearer ${token}`,
327 'content-type': 'application/json',
328 'x-vault-id': 'default',
329 };
330 const results = await Promise.all(
331 Array.from({ length: 12 }, () =>
332 fetch(`http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs`, {
333 method: 'POST',
334 headers,
335 body: JSON.stringify({ flow_version: '1.0.0' }),
336 }),
337 ),
338 );
339 assert.ok(results.every((r) => r.status === 403));
340 assert.equal(hits, 12);
341 });
342 });
343
344 describe('SITE-FINISH-FLOW-RUN-KN-b — data-integrity', () => {
345 it('proxy preserves Authorization + JSON body to bridge', async (t) => {
346 /** @type {Record<string, unknown>|null} */
347 let seen = null;
348 const mockBridge = express();
349 mockBridge.use(express.json());
350 mockBridge.post('/api/v1/flows/:id/runs/:run_id/advance', (req, res) => {
351 seen = {
352 auth: req.headers.authorization,
353 vault: req.headers['x-vault-id'],
354 body: req.body,
355 flowId: req.params.id,
356 runId: req.params.run_id,
357 };
358 res.status(403).json({ code: 'FLOW_RUN_WRITES_DISABLED', error: 'disabled' });
359 });
360 const { bridgeUrl, close } = await startMockBridge(mockBridge);
361 t.after(close);
362 const port = await bootGateway(t, bridgeUrl, `di-${Date.now()}`);
363 const token = signTestJwt({ sub: 'user-run-di', role: 'editor', type: 'session' });
364 const body = { step_id: `${FLOW_ID}#1`, to_status: 'in_progress' };
365 const res = await fetch(
366 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs/${encodeURIComponent(RUN_ID)}/advance`,
367 {
368 method: 'POST',
369 headers: {
370 authorization: `Bearer ${token}`,
371 'content-type': 'application/json',
372 'x-vault-id': 'learner-vault',
373 },
374 body: JSON.stringify(body),
375 },
376 );
377 assert.equal(res.status, 403);
378 assert.ok(seen);
379 assert.equal(seen.auth, `Bearer ${token}`);
380 assert.equal(seen.vault, 'learner-vault');
381 assert.equal(seen.flowId, FLOW_ID);
382 assert.equal(seen.runId, RUN_ID);
383 assert.deepEqual(seen.body, body);
384 });
385 });
386
387 describe('SITE-FINISH-FLOW-RUN-KN-b — performance', () => {
388 it('proxy overhead bounded for env-off refuse (<2s)', async (t) => {
389 const mockBridge = express();
390 mockBridge.use(express.json());
391 mockBridge.post('/api/v1/flows/:id/runs', (_req, res) => {
392 res.status(403).json({ code: 'FLOW_RUN_WRITES_DISABLED', error: 'disabled' });
393 });
394 const { bridgeUrl, close } = await startMockBridge(mockBridge);
395 t.after(close);
396 const port = await bootGateway(t, bridgeUrl, `perf-${Date.now()}`);
397 const token = signTestJwt({ sub: 'user-run-perf', role: 'editor', type: 'session' });
398 const t0 = performance.now();
399 const res = await fetch(
400 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs`,
401 {
402 method: 'POST',
403 headers: {
404 authorization: `Bearer ${token}`,
405 'content-type': 'application/json',
406 'x-vault-id': 'default',
407 },
408 body: JSON.stringify({ flow_version: '1.0.0' }),
409 },
410 );
411 const elapsed = performance.now() - t0;
412 assert.equal(res.status, 403);
413 assert.ok(elapsed < 2000, `elapsed ${elapsed}ms`);
414 });
415 });
416
417 describe('SITE-FINISH-FLOW-RUN-KN-b — security', () => {
418 it('source scan: envs stay default-off; no Delegation/SCOOLING flips in route module', () => {
419 const routes = readRepo('hub/bridge/flow-run-routes.mjs');
420 const gw = readRepo('hub/gateway/server.mjs');
421 assert.doesNotMatch(routes, /FLOW_RUN_WRITES_ENABLED\s*=\s*['"]1['"]/);
422 assert.doesNotMatch(routes, /FLOW_AUTOMATABLE_EXECUTION_ENABLED\s*=\s*['"]1['"]/);
423 assert.doesNotMatch(routes, /DELEGATION_WRITES\s*=/);
424 assert.doesNotMatch(gw, /SCOOLING_FLOW_RUN_WRITE\s*=\s*['"]enabled['"]/);
425 assert.doesNotMatch(gw, /SCOOLING_DELEGATION_WRITES/);
426 assert.match(routes, /default OFF/);
427 });
428
429 it('unauthenticated gateway start does not leak to open canister catch-all success', async (t) => {
430 const calls = [];
431 const mockBridge = express();
432 mockBridge.use(express.json());
433 mockBridge.post('/api/v1/flows/:id/runs', (req, res) => {
434 calls.push(req.headers.authorization || '');
435 res.status(401).json({ error: 'Unauthorized', code: 'UNAUTHORIZED' });
436 });
437 const { bridgeUrl, close } = await startMockBridge(mockBridge);
438 t.after(close);
439 const port = await bootGateway(t, bridgeUrl, `sec-${Date.now()}`);
440 const res = await fetch(
441 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent(FLOW_ID)}/runs`,
442 {
443 method: 'POST',
444 headers: { 'content-type': 'application/json', 'x-vault-id': 'default' },
445 body: JSON.stringify({ flow_version: '1.0.0' }),
446 },
447 );
448 // Gateway auth middleware may 401 before bridge; either way must not be 2xx.
449 assert.ok(res.status === 401 || res.status === 403 || res.status >= 400);
450 assert.ok(res.status < 500 || calls.length >= 0);
451 assert.notEqual(res.status, 201);
452 assert.notEqual(res.status, 200);
453 });
454 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago