gateway-flow-authoring-proxy.test.mjs
422 lines 15.7 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * FLOW-WRITE-LIVE-GATEWAY-PROXY — seven-tier coverage.
3 *
4 * Proves gateway Flow authoring POSTs hit BRIDGE_URL (not the canister catch-all),
5 * and that bridge + gateway source wires the three routes (parity with tasks/proposals).
6 *
7 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
8 */
9
10 import fs from 'node:fs';
11 import { describe, it, test, beforeEach, afterEach } from 'node:test';
12 import assert from 'node:assert/strict';
13 import http from 'http';
14 import express from 'express';
15 import crypto from 'crypto';
16 import path from 'path';
17 import { performance } from 'node:perf_hooks';
18 import { fileURLToPath, pathToFileURL } from 'url';
19
20 import { bridgeFlowHandlerRole } from '../hub/bridge/flow-routes.mjs';
21 import {
22 mergeFlowFrontmatter,
23 normalizeCanisterProposalForFlowPrecheck,
24 FM_PROPOSAL_SOURCE,
25 FM_FLOW_KIND,
26 FLOW_PROPOSAL_SOURCE,
27 } from '../lib/flow/flow-hosted-proposal.mjs';
28 import { matchesScoolingFlowFingerprint } from '../lib/hub-proposal-personal-self-apply.mjs';
29
30 const __dirname = path.dirname(fileURLToPath(import.meta.url));
31 const projectRoot = path.resolve(__dirname, '..');
32
33 const SECRET = 'gateway-flow-proxy-test-secret-32chars!!';
34
35 function signTestJwt(payload) {
36 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
37 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
38 const data = `${header}.${body}`;
39 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
40 return `${data}.${sig}`;
41 }
42
43 function startMockBridge(mockBridge) {
44 const srv = http.createServer(mockBridge);
45 return new Promise((resolve, reject) => {
46 srv.listen(0, '127.0.0.1', (err) => {
47 if (err) return reject(err);
48 const port = srv.address().port;
49 resolve({
50 bridgeUrl: `http://127.0.0.1:${port}`,
51 close: () => new Promise((r) => srv.close(() => r())),
52 });
53 });
54 });
55 }
56
57 function readRepo(rel) {
58 return fs.readFileSync(path.join(projectRoot, rel), 'utf8');
59 }
60
61 async function bootGateway(t, bridgeUrl, cacheBust) {
62 process.env.NETLIFY = '1';
63 process.env.CANISTER_URL = 'http://canister.placeholder.test';
64 process.env.SESSION_SECRET = SECRET;
65 process.env.BRIDGE_URL = bridgeUrl;
66
67 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
68 const { app: gwApp } = await import(`${gwEntry}?gwflow=${cacheBust}`);
69
70 const gwSrv = http.createServer(gwApp);
71 await new Promise((resolve, reject) => {
72 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
73 });
74 t.after(() => new Promise((r) => gwSrv.close(() => r())));
75 return gwSrv.address().port;
76 }
77
78 const SAMPLE_BODY = {
79 intent: 'draft personal flow',
80 flow: {
81 schema: 'knowtation.flow/v0',
82 flow_id: 'flow_gw_proxy_1',
83 title: 'Gateway proxy',
84 version: '1.0.0',
85 scope: 'personal',
86 summary: 'test',
87 tags: [],
88 steps: [],
89 inputs: [],
90 vault_mirror_path: 'meta/flows/gw-proxy-1.md',
91 },
92 steps: [],
93 external_ref: 'scooling.flow:gw-proxy-001',
94 };
95
96 describe('FLOW-WRITE-LIVE-GATEWAY-PROXY — unit', () => {
97 it('bridgeFlowHandlerRole maps member → editor', () => {
98 assert.equal(bridgeFlowHandlerRole('member'), 'editor');
99 assert.equal(bridgeFlowHandlerRole('admin'), 'admin');
100 assert.equal(bridgeFlowHandlerRole('viewer'), 'viewer');
101 });
102
103 it('mergeFlowFrontmatter embeds source + kind for canister rows', () => {
104 const fm = mergeFlowFrontmatter({ type: 'flow' }, { kind: 'new', flow_id: 'flow_x', scope: 'personal' });
105 assert.equal(fm[FM_PROPOSAL_SOURCE], FLOW_PROPOSAL_SOURCE);
106 assert.equal(fm[FM_FLOW_KIND], 'new');
107 assert.equal(fm.scope, 'personal');
108 });
109
110 it('normalizeCanisterProposalForFlowPrecheck reconstructs flow_meta from frontmatter', () => {
111 const fm = mergeFlowFrontmatter(
112 {},
113 { kind: 'import', base_state_id: 'flowst1_absent', scope: 'personal', flow_id: 'flow_gw_proxy_1' },
114 );
115 const normalized = normalizeCanisterProposalForFlowPrecheck({
116 proposal_id: 'p1',
117 path: 'meta/flows/gw-proxy-1.md',
118 external_ref: 'scooling.flow:gw-proxy-001',
119 frontmatter: fm,
120 body: JSON.stringify({
121 flow: {
122 schema: 'knowtation.flow/v0',
123 flow_id: 'flow_gw_proxy_1',
124 scope: 'personal',
125 },
126 steps: [],
127 }),
128 });
129 assert.ok(normalized);
130 assert.equal(normalized.source, FLOW_PROPOSAL_SOURCE);
131 assert.equal(normalized.flow_meta.kind, 'import');
132 assert.equal(matchesScoolingFlowFingerprint(normalized), true);
133 });
134
135 it('gateway + bridge source register the three Flow authoring proxies', () => {
136 const gw = readRepo('hub/gateway/server.mjs');
137 const bridge = readRepo('hub/bridge/server.mjs');
138 const routes = readRepo('hub/bridge/flow-routes.mjs');
139 assert.match(gw, /Flow authoring write-back \(hosted parity — FLOW-WRITE-LIVE-GATEWAY-PROXY\)/);
140 assert.match(gw, /\/api\/v1\/flows\/import/);
141 assert.match(gw, /app\.post\('\/api\/v1\/flows'/);
142 assert.match(gw, /\/api\/v1\/flows\/:id\/proposals/);
143 assert.match(bridge, /registerBridgeFlowRoutes/);
144 assert.match(routes, /createFlowProposalOnCanister/);
145 assert.match(routes, /FLOW_AUTHORING/);
146 // Run proxies land in SITE-FINISH-FLOW-RUN-KN-b (separate module).
147 assert.match(readRepo('hub/gateway/server.mjs'), /SITE-FINISH-FLOW-RUN-KN-b/);
148 });
149 });
150
151 describe('FLOW-WRITE-LIVE-GATEWAY-PROXY — integration', () => {
152 it('POST /api/v1/flows reaches mock bridge with auth headers (not canister)', async (t) => {
153 const calls = [];
154 const mockBridge = express();
155 mockBridge.use(express.json());
156 mockBridge.post('/api/v1/flows', (req, res) => {
157 calls.push({
158 method: req.method,
159 url: req.originalUrl,
160 auth: req.headers.authorization,
161 vault: req.headers['x-vault-id'],
162 body: req.body,
163 });
164 res.status(403).json({ error: 'Flow authoring writes are disabled', code: 'FLOW_AUTHORING_DISABLED' });
165 });
166
167 const { bridgeUrl, close } = await startMockBridge(mockBridge);
168 t.after(close);
169 const port = await bootGateway(t, bridgeUrl, `post-${Date.now()}`);
170
171 const token = signTestJwt({ sub: 'user-flow-proxy', role: 'editor', type: 'session' });
172 const res = await fetch(`http://127.0.0.1:${port}/api/v1/flows`, {
173 method: 'POST',
174 headers: {
175 authorization: `Bearer ${token}`,
176 'content-type': 'application/json',
177 'x-vault-id': 'default',
178 },
179 body: JSON.stringify(SAMPLE_BODY),
180 });
181 assert.equal(res.status, 403);
182 const json = await res.json();
183 assert.equal(json.code, 'FLOW_AUTHORING_DISABLED');
184 assert.equal(calls.length, 1);
185 assert.equal(calls[0].method, 'POST');
186 assert.match(calls[0].auth, /^Bearer /);
187 assert.equal(calls[0].vault, 'default');
188 });
189 });
190
191 describe('FLOW-WRITE-LIVE-GATEWAY-PROXY — e2e', () => {
192 it('POST import + POST :id/proposals both hit bridge', async (t) => {
193 const calls = [];
194 const mockBridge = express();
195 mockBridge.use(express.json());
196 mockBridge.post('/api/v1/flows/import', (req, res) => {
197 calls.push({ path: '/import', body: req.body });
198 res.status(201).json({
199 schema: 'knowtation.flow_proposal/v0',
200 proposal_id: 'prop_import_1',
201 flow_id: 'flow_imported',
202 base_version: null,
203 base_state_id: null,
204 scope: 'personal',
205 auto_approvable: false,
206 status: 'proposed',
207 review_queue: 'flow-authoring',
208 });
209 });
210 mockBridge.post('/api/v1/flows/:id/proposals', (req, res) => {
211 calls.push({ path: `/proposals/${req.params.id}`, body: req.body });
212 res.status(403).json({ error: 'Flow authoring writes are disabled', code: 'FLOW_AUTHORING_DISABLED' });
213 });
214
215 const { bridgeUrl, close } = await startMockBridge(mockBridge);
216 t.after(close);
217 const port = await bootGateway(t, bridgeUrl, `e2e-${Date.now()}`);
218 const token = signTestJwt({ sub: 'user-flow-e2e', role: 'editor', type: 'session' });
219
220 const importRes = await fetch(`http://127.0.0.1:${port}/api/v1/flows/import`, {
221 method: 'POST',
222 headers: {
223 authorization: `Bearer ${token}`,
224 'content-type': 'application/json',
225 'x-vault-id': 'default',
226 },
227 body: JSON.stringify({ intent: 'import', bundle: { flow: SAMPLE_BODY.flow, steps: [] } }),
228 });
229 assert.equal(importRes.status, 201);
230 const imported = await importRes.json();
231 assert.equal(imported.proposal_id, 'prop_import_1');
232
233 const editRes = await fetch(
234 `http://127.0.0.1:${port}/api/v1/flows/${encodeURIComponent('flow_gw_proxy_1')}/proposals`,
235 {
236 method: 'POST',
237 headers: {
238 authorization: `Bearer ${token}`,
239 'content-type': 'application/json',
240 'x-vault-id': 'default',
241 },
242 body: JSON.stringify({
243 ...SAMPLE_BODY,
244 base_version: '1.0.0',
245 base_state_id: 'flowst1_deadbeefdeadbe',
246 }),
247 },
248 );
249 assert.equal(editRes.status, 403);
250 assert.equal(calls.length, 2);
251 assert.equal(calls[0].path, '/import');
252 assert.equal(calls[1].path, '/proposals/flow_gw_proxy_1');
253 });
254 });
255
256 describe('FLOW-WRITE-LIVE-GATEWAY-PROXY — stress', () => {
257 it('N concurrent POST /api/v1/flows all hit bridge (no cross-talk)', async (t) => {
258 const calls = [];
259 const mockBridge = express();
260 mockBridge.use(express.json());
261 mockBridge.post('/api/v1/flows', (req, res) => {
262 calls.push(req.body?.intent || '');
263 res.status(403).json({ code: 'FLOW_AUTHORING_DISABLED', error: 'disabled' });
264 });
265 const { bridgeUrl, close } = await startMockBridge(mockBridge);
266 t.after(close);
267 const port = await bootGateway(t, bridgeUrl, `stress-${Date.now()}`);
268 const token = signTestJwt({ sub: 'user-flow-stress', role: 'editor', type: 'session' });
269 const N = 12;
270 const results = await Promise.all(
271 Array.from({ length: N }, (_, i) =>
272 fetch(`http://127.0.0.1:${port}/api/v1/flows`, {
273 method: 'POST',
274 headers: {
275 authorization: `Bearer ${token}`,
276 'content-type': 'application/json',
277 'x-vault-id': 'default',
278 },
279 body: JSON.stringify({ ...SAMPLE_BODY, intent: `intent-${i}` }),
280 }).then((r) => r.status),
281 ),
282 );
283 assert.equal(results.every((s) => s === 403), true);
284 assert.equal(calls.length, N);
285 assert.equal(new Set(calls).size, N);
286 });
287 });
288
289 describe('FLOW-WRITE-LIVE-GATEWAY-PROXY — data-integrity', () => {
290 it('gateway forwards JSON body intact to bridge', async (t) => {
291 let seen = null;
292 const mockBridge = express();
293 mockBridge.use(express.json({ limit: '1mb' }));
294 mockBridge.post('/api/v1/flows', (req, res) => {
295 seen = req.body;
296 res.status(201).json({
297 schema: 'knowtation.flow_proposal/v0',
298 proposal_id: 'prop_di',
299 flow_id: SAMPLE_BODY.flow.flow_id,
300 base_version: null,
301 base_state_id: null,
302 scope: 'personal',
303 auto_approvable: false,
304 status: 'proposed',
305 review_queue: 'flow-authoring',
306 });
307 });
308 const { bridgeUrl, close } = await startMockBridge(mockBridge);
309 t.after(close);
310 const port = await bootGateway(t, bridgeUrl, `di-${Date.now()}`);
311 const token = signTestJwt({ sub: 'user-flow-di', role: 'editor', type: 'session' });
312 const res = await fetch(`http://127.0.0.1:${port}/api/v1/flows`, {
313 method: 'POST',
314 headers: {
315 authorization: `Bearer ${token}`,
316 'content-type': 'application/json',
317 'x-vault-id': 'default',
318 },
319 body: JSON.stringify(SAMPLE_BODY),
320 });
321 assert.equal(res.status, 201);
322 assert.deepEqual(seen.flow.flow_id, SAMPLE_BODY.flow.flow_id);
323 assert.equal(seen.external_ref, SAMPLE_BODY.external_ref);
324 assert.equal(seen.intent, SAMPLE_BODY.intent);
325 });
326 });
327
328 describe('FLOW-WRITE-LIVE-GATEWAY-PROXY — performance', () => {
329 it('proxy round-trip stays under 2s for disabled-gate response', async (t) => {
330 const mockBridge = express();
331 mockBridge.use(express.json());
332 mockBridge.post('/api/v1/flows', (_req, res) => {
333 res.status(403).json({ code: 'FLOW_AUTHORING_DISABLED', error: 'disabled' });
334 });
335 const { bridgeUrl, close } = await startMockBridge(mockBridge);
336 t.after(close);
337 const port = await bootGateway(t, bridgeUrl, `perf-${Date.now()}`);
338 const token = signTestJwt({ sub: 'user-flow-perf', role: 'editor', type: 'session' });
339 const t0 = performance.now();
340 const res = await fetch(`http://127.0.0.1:${port}/api/v1/flows`, {
341 method: 'POST',
342 headers: {
343 authorization: `Bearer ${token}`,
344 'content-type': 'application/json',
345 'x-vault-id': 'default',
346 },
347 body: JSON.stringify(SAMPLE_BODY),
348 });
349 const elapsed = performance.now() - t0;
350 assert.equal(res.status, 403);
351 assert.ok(elapsed < 2000, `elapsed ${elapsed}ms`);
352 });
353 });
354
355 describe('FLOW-WRITE-LIVE-GATEWAY-PROXY — security', () => {
356 it('import path is not captured as :id; run proxies coexist with authoring', async (t) => {
357 const calls = [];
358 const mockBridge = express();
359 mockBridge.use(express.json());
360 mockBridge.post('/api/v1/flows/import', (req, res) => {
361 calls.push('import');
362 res.status(403).json({ code: 'FLOW_AUTHORING_DISABLED', error: 'disabled' });
363 });
364 mockBridge.post('/api/v1/flows/:id/proposals', (req, res) => {
365 calls.push(`id=${req.params.id}`);
366 res.status(403).json({ code: 'FLOW_AUTHORING_DISABLED', error: 'disabled' });
367 });
368 const { bridgeUrl, close } = await startMockBridge(mockBridge);
369 t.after(close);
370 const port = await bootGateway(t, bridgeUrl, `sec-${Date.now()}`);
371 const token = signTestJwt({ sub: 'user-flow-sec', role: 'editor', type: 'session' });
372 const res = await fetch(`http://127.0.0.1:${port}/api/v1/flows/import`, {
373 method: 'POST',
374 headers: {
375 authorization: `Bearer ${token}`,
376 'content-type': 'application/json',
377 'x-vault-id': 'default',
378 },
379 body: JSON.stringify({ intent: 'x', bundle: { flow: SAMPLE_BODY.flow, steps: [] } }),
380 });
381 assert.equal(res.status, 403);
382 assert.deepEqual(calls, ['import']);
383 const gw = readRepo('hub/gateway/server.mjs');
384 assert.match(gw, /SITE-FINISH-FLOW-RUN-KN-b/);
385 assert.match(gw, /app\.post\('\/api\/v1\/flows\/:id\/runs'/);
386 });
387
388 it('source scan: no Delegation write env and no Scooling Hub JWT envs added', () => {
389 const gw = readRepo('hub/gateway/server.mjs');
390 const bridgeRoutes = readRepo('hub/bridge/flow-routes.mjs');
391 assert.doesNotMatch(bridgeRoutes, /DELEGATION_WRITES\s*=/);
392 assert.doesNotMatch(gw, /SCOOLING_.*HUB.*JWT/);
393 assert.doesNotMatch(bridgeRoutes, /FLOW_CAPTURE_WRITES\s*=\s*['"]1['"]/);
394 assert.doesNotMatch(bridgeRoutes, /FLOW_RUN_WRITES\s*=\s*['"]1['"]/);
395 });
396 });
397
398 test('gateway proxies POST /api/v1/flows to bridge (smoke alias)', async (t) => {
399 // Kept as a top-level smoke for quick single-test runs (mirrors gateway-task-proxy style).
400 const calls = [];
401 const mockBridge = express();
402 mockBridge.use(express.json());
403 mockBridge.post('/api/v1/flows', (req, res) => {
404 calls.push(1);
405 res.status(403).json({ code: 'FLOW_AUTHORING_DISABLED', error: 'disabled' });
406 });
407 const { bridgeUrl, close } = await startMockBridge(mockBridge);
408 t.after(close);
409 const port = await bootGateway(t, bridgeUrl, `smoke-${Date.now()}`);
410 const token = signTestJwt({ sub: 'user-flow-smoke', role: 'editor', type: 'session' });
411 const res = await fetch(`http://127.0.0.1:${port}/api/v1/flows`, {
412 method: 'POST',
413 headers: {
414 authorization: `Bearer ${token}`,
415 'content-type': 'application/json',
416 'x-vault-id': 'default',
417 },
418 body: JSON.stringify(SAMPLE_BODY),
419 });
420 assert.equal(res.status, 403);
421 assert.equal(calls.length, 1);
422 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago