site-finish-flow-run-kn-b.test.mjs
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6
docs: record AIP-b SD-21 land (KN #308)
Human
10 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
10 days ago