flow-capture-live-kn-b.test.mjs
633 lines 22.3 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * FLOW-CAPTURE-LIVE-KN-b — seven-tier coverage (§FCL.7 Knowtation matrix).
3 * Frozen: ~/scooling/docs/FLOW-CAPTURE-LIVE-FREEZE.md (§FCL.3 KN-b / FCL-C3 / FCL-C10)
4 *
5 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
6 */
7
8 import fs from 'node:fs';
9 import { describe, it, beforeEach, afterEach } from 'node:test';
10 import assert from 'node:assert/strict';
11 import http from 'node:http';
12 import express from 'express';
13 import crypto from 'node:crypto';
14 import path from 'node:path';
15 import { performance } from 'node:perf_hooks';
16 import { fileURLToPath, pathToFileURL } from 'node:url';
17
18 import {
19 personalSelfApplyRefusalReason,
20 isAdmittedSeamSelfApplyFingerprint,
21 matchesScoolingFlowFingerprint,
22 isSeamSurfaceProposal,
23 } from '../lib/hub-proposal-personal-self-apply.mjs';
24 import {
25 FLOW_CAPTURE_PROPOSAL_SOURCE,
26 handleFlowCaptureProposeRequest,
27 handleFlowCaptureDismissRequest,
28 getFlowCaptureDetectionEnabled,
29 getFlowCaptureWritesEnabled,
30 } from '../lib/flow/flow-capture.mjs';
31 import {
32 mergeCaptureFrontmatter,
33 normalizeCanisterProposalForCapturePrecheck,
34 FM_PROPOSAL_SOURCE,
35 FM_CAPTURE_PROPOSAL_KIND,
36 } from '../lib/flow/flow-capture-hosted-proposal.mjs';
37 import { bridgeFlowCaptureHandlerRole } from '../hub/bridge/flow-capture-routes.mjs';
38 import { FLOW_PROPOSAL_SOURCE } from '../lib/flow/flow-authoring.mjs';
39 import { DELEGATION_PROPOSAL_SOURCE } from '../lib/agent/delegation.mjs';
40 import { createProposal, getProposal, listProposals } from '../hub/proposals-store.mjs';
41 import { upsertCandidate, getCandidate } from '../lib/flow/flow-store.mjs';
42 import { makeCandidateRecord, emptyStarterDir } from './fixtures/flow/capture-helpers.mjs';
43
44 const __dirname = path.dirname(fileURLToPath(import.meta.url));
45 const projectRoot = path.resolve(__dirname, '..');
46 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-capture-live-kn-b');
47
48 const SECRET = 'gateway-flow-capture-kn-b-test-secret-32!!';
49 const ACTOR = 'google:learner-a';
50 const OTHER = 'google:learner-b';
51 const visible = new Set(['personal', 'project', 'org']);
52
53 function signTestJwt(payload) {
54 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
55 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
56 const data = `${header}.${body}`;
57 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
58 return `${data}.${sig}`;
59 }
60
61 function startMockBridge(mockBridge) {
62 const srv = http.createServer(mockBridge);
63 return new Promise((resolve, reject) => {
64 srv.listen(0, '127.0.0.1', (err) => {
65 if (err) return reject(err);
66 const port = srv.address().port;
67 resolve({
68 bridgeUrl: `http://127.0.0.1:${port}`,
69 close: () => new Promise((r) => srv.close(() => r())),
70 });
71 });
72 });
73 }
74
75 function readRepo(rel) {
76 return fs.readFileSync(path.join(projectRoot, rel), 'utf8');
77 }
78
79 async function bootGateway(t, bridgeUrl, cacheBust) {
80 process.env.NETLIFY = '1';
81 process.env.CANISTER_URL = 'http://canister.placeholder.test';
82 process.env.SESSION_SECRET = SECRET;
83 process.env.BRIDGE_URL = bridgeUrl;
84
85 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
86 const { app: gwApp } = await import(`${gwEntry}?gwcap=${cacheBust}`);
87
88 const gwSrv = http.createServer(gwApp);
89 await new Promise((resolve, reject) => {
90 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
91 });
92 t.after(() => new Promise((r) => gwSrv.close(() => r())));
93 return gwSrv.address().port;
94 }
95
96 /**
97 * @param {string} kind
98 * @param {Record<string, unknown>} [overrides]
99 */
100 function captureProposal(kind, overrides = {}) {
101 const candidateId = overrides.candidate_id || 'cand_fclknb01';
102 const bodyObj = overrides.bodyObj || {
103 proposal_kind: kind,
104 candidate_id: candidateId,
105 confirmed_scope: 'personal',
106 };
107 return {
108 proposal_id: overrides.proposal_id || `prop-cap-${kind}`,
109 status: 'proposed',
110 source: FLOW_CAPTURE_PROPOSAL_SOURCE,
111 path: `meta/candidates/${candidateId}.md`,
112 body: JSON.stringify(bodyObj),
113 frontmatter: {
114 type: 'flow_capture',
115 candidate_id: candidateId,
116 proposal_kind: kind,
117 },
118 capture_meta: {
119 proposal_kind: kind,
120 candidate_id: candidateId,
121 confirmed_scope: 'personal',
122 ...(kind === 'flow_candidate_merge' ? { merge_into_flow_id: 'flow_merge_target' } : {}),
123 },
124 proposed_by: ACTOR,
125 ...Object.fromEntries(
126 Object.entries(overrides).filter(
127 ([k]) => !['candidate_id', 'bodyObj', 'proposal_id'].includes(k),
128 ),
129 ),
130 };
131 }
132
133 /**
134 * @param {Record<string, unknown>} proposal
135 * @param {Record<string, unknown>} [extra]
136 */
137 function eligible(proposal, extra = {}) {
138 return {
139 proposal,
140 hasVaultWrite: true,
141 partitionOwned: true,
142 role: 'member',
143 humanActor: true,
144 tokenType: null,
145 actorKind: 'human',
146 sessionBound: true,
147 authorActorId: ACTOR,
148 approverActorId: ACTOR,
149 ...extra,
150 };
151 }
152
153 function flowAuthoringProposal() {
154 return {
155 proposal_id: 'prop-flow-admit',
156 status: 'proposed',
157 source: FLOW_PROPOSAL_SOURCE,
158 path: 'meta/flows/admit-ok.md',
159 external_ref: 'scooling.flow:admit-ok',
160 body: JSON.stringify({
161 flow: {
162 schema: 'knowtation.flow/v0',
163 flow_id: 'flow_admit_ok',
164 title: 'Admit',
165 version: '1.0.0',
166 scope: 'personal',
167 summary: 'ok',
168 tags: [],
169 steps: [],
170 inputs: [],
171 vault_mirror_path: 'meta/flows/admit-ok.md',
172 },
173 steps: [],
174 }),
175 frontmatter: {
176 type: 'flow',
177 flow_id: 'flow_admit_ok',
178 flow_version: '1.0.0',
179 scope: 'personal',
180 },
181 flow_meta: { kind: 'new', base_version: null, base_state_id: 'flowst1_absent' },
182 };
183 }
184
185 describe('FLOW-CAPTURE-LIVE-KN-b — unit', () => {
186 it('promote/merge/dismiss → SELF_APPLY_NOT_ADMITTED when session-bound author==approver', () => {
187 for (const kind of [
188 'flow_candidate_promote',
189 'flow_candidate_merge',
190 'flow_candidate_dismiss',
191 ]) {
192 const p = captureProposal(kind);
193 assert.equal(isSeamSurfaceProposal(p), true, kind);
194 assert.equal(isAdmittedSeamSelfApplyFingerprint(p, ACTOR), false, kind);
195 assert.equal(
196 personalSelfApplyRefusalReason(eligible(p)),
197 'SELF_APPLY_NOT_ADMITTED',
198 kind,
199 );
200 }
201 });
202
203 it('authoring Flow fingerprint still admits; Delegation still refused', () => {
204 const flow = flowAuthoringProposal();
205 assert.equal(matchesScoolingFlowFingerprint(flow), true);
206 assert.equal(personalSelfApplyRefusalReason(eligible(flow)), null);
207 assert.equal(
208 personalSelfApplyRefusalReason(
209 eligible({ status: 'proposed', source: DELEGATION_PROPOSAL_SOURCE }),
210 ),
211 'SELF_APPLY_DELEGATION_REFUSED',
212 );
213 });
214
215 it('no positive capture fingerprint helper in Wave 2 source', () => {
216 const selfApply = readRepo('lib/hub-proposal-personal-self-apply.mjs');
217 assert.doesNotMatch(selfApply, /matchesScoolingFlowCaptureFingerprint/);
218 assert.match(selfApply, /flow_capture stays SELF_APPLY_NOT_ADMITTED/);
219 });
220
221 it('bridgeCapture role map + mergeCaptureFrontmatter + normalize', () => {
222 assert.equal(bridgeFlowCaptureHandlerRole('member'), 'editor');
223 assert.equal(bridgeFlowCaptureHandlerRole('admin'), 'admin');
224 const fm = mergeCaptureFrontmatter(
225 { type: 'flow_capture' },
226 {
227 proposal_kind: 'flow_candidate_promote',
228 candidate_id: 'cand_x',
229 confirmed_scope: 'personal',
230 },
231 );
232 assert.equal(fm[FM_PROPOSAL_SOURCE], FLOW_CAPTURE_PROPOSAL_SOURCE);
233 assert.equal(fm[FM_CAPTURE_PROPOSAL_KIND], 'flow_candidate_promote');
234 const canisterRow = {
235 proposal_id: 'p1',
236 path: 'meta/candidates/cand_x.md',
237 frontmatter: fm,
238 body: JSON.stringify({ proposal_kind: 'flow_candidate_promote', candidate_id: 'cand_x' }),
239 };
240 // Hosted canister rows omit top-level source — seam via frontmatter normalize.
241 assert.equal(canisterRow.source, undefined);
242 assert.equal(isSeamSurfaceProposal(canisterRow), true);
243 assert.equal(
244 personalSelfApplyRefusalReason(eligible(canisterRow)),
245 'SELF_APPLY_NOT_ADMITTED',
246 );
247 const normalized = normalizeCanisterProposalForCapturePrecheck(canisterRow);
248 assert.ok(normalized);
249 assert.equal(normalized.source, FLOW_CAPTURE_PROPOSAL_SOURCE);
250 assert.equal(normalized.capture_meta.proposal_kind, 'flow_candidate_promote');
251 assert.equal(isAdmittedSeamSelfApplyFingerprint(normalized, ACTOR), false);
252 });
253
254 it('gateway + bridge register capture proxies; capture envs stay default off', () => {
255 const gw = readRepo('hub/gateway/server.mjs');
256 const bridge = readRepo('hub/bridge/server.mjs');
257 const routes = readRepo('hub/bridge/flow-capture-routes.mjs');
258 assert.match(gw, /FLOW-CAPTURE-LIVE-KN-b/);
259 assert.match(gw, /\/api\/v1\/flows\/capture\/observe/);
260 assert.match(gw, /\/api\/v1\/flows\/candidates/);
261 assert.match(gw, /\/api\/v1\/flows\/candidates\/:id\/propose/);
262 assert.match(gw, /\/api\/v1\/flows\/candidates\/:id\/dismiss/);
263 assert.match(bridge, /registerBridgeFlowCaptureRoutes/);
264 assert.match(routes, /createCaptureProposalOnCanister/);
265 assert.match(routes, /FLOW_CAPTURE/);
266 // Hard stop: do not hard-enable capture envs in bridge source.
267 assert.doesNotMatch(routes, /FLOW_CAPTURE_WRITES_ENABLED\s*=\s*['"]1['"]/);
268 assert.doesNotMatch(routes, /FLOW_CAPTURE_DETECTION_ENABLED\s*=\s*['"]1['"]/);
269 assert.equal(getFlowCaptureDetectionEnabled(tmpRoot), false);
270 assert.equal(getFlowCaptureWritesEnabled(tmpRoot), false);
271 });
272 });
273
274 describe('FLOW-CAPTURE-LIVE-KN-b — integration', () => {
275 it('POST observe + GET candidates + propose/dismiss hit mock bridge (not canister)', async (t) => {
276 const calls = [];
277 const mockBridge = express();
278 mockBridge.use(express.json());
279 mockBridge.post('/api/v1/flows/capture/observe', (req, res) => {
280 calls.push({ path: 'observe', auth: req.headers.authorization });
281 res.status(200).json({
282 schema: 'knowtation.flow_capture_observe/v0',
283 detection_authorized: false,
284 returned_count: 0,
285 candidates: [],
286 });
287 });
288 mockBridge.get('/api/v1/flows/candidates', (req, res) => {
289 calls.push({ path: 'list' });
290 res.status(200).json({
291 schema: 'knowtation.flow_candidate_list/v0',
292 candidates: [],
293 returned_count: 0,
294 });
295 });
296 mockBridge.post('/api/v1/flows/candidates/:id/propose', (req, res) => {
297 calls.push({ path: 'propose', id: req.params.id, body: req.body });
298 res.status(403).json({
299 error: 'Flow capture writes are disabled',
300 code: 'FLOW_CAPTURE_WRITES_DISABLED',
301 });
302 });
303 mockBridge.post('/api/v1/flows/candidates/:id/dismiss', (req, res) => {
304 calls.push({ path: 'dismiss', id: req.params.id });
305 res.status(403).json({
306 error: 'Flow capture writes are disabled',
307 code: 'FLOW_CAPTURE_WRITES_DISABLED',
308 });
309 });
310
311 const { bridgeUrl, close } = await startMockBridge(mockBridge);
312 t.after(close);
313 const port = await bootGateway(t, bridgeUrl, `int-${Date.now()}`);
314 const token = signTestJwt({ sub: 'user-cap-proxy', role: 'editor', type: 'session' });
315 const headers = {
316 authorization: `Bearer ${token}`,
317 'content-type': 'application/json',
318 'x-vault-id': 'default',
319 };
320
321 const obs = await fetch(`http://127.0.0.1:${port}/api/v1/flows/capture/observe`, {
322 method: 'POST',
323 headers,
324 body: JSON.stringify({ session_id: 'a'.repeat(64) }),
325 });
326 assert.equal(obs.status, 200);
327 const list = await fetch(`http://127.0.0.1:${port}/api/v1/flows/candidates`, {
328 headers: { authorization: `Bearer ${token}`, 'x-vault-id': 'default' },
329 });
330 assert.equal(list.status, 200);
331 const prop = await fetch(
332 `http://127.0.0.1:${port}/api/v1/flows/candidates/${encodeURIComponent('cand_x')}/propose`,
333 {
334 method: 'POST',
335 headers,
336 body: JSON.stringify({ intent: 'promote', confirmed_scope: 'personal' }),
337 },
338 );
339 assert.equal(prop.status, 403);
340 const dis = await fetch(
341 `http://127.0.0.1:${port}/api/v1/flows/candidates/${encodeURIComponent('cand_x')}/dismiss`,
342 {
343 method: 'POST',
344 headers,
345 body: JSON.stringify({ intent: 'dismiss' }),
346 },
347 );
348 assert.equal(dis.status, 403);
349
350 assert.equal(calls.length, 4);
351 assert.equal(calls[0].path, 'observe');
352 assert.match(calls[0].auth, /^Bearer /);
353 assert.equal(calls[1].path, 'list');
354 assert.equal(calls[2].path, 'propose');
355 assert.equal(calls[2].id, 'cand_x');
356 assert.equal(calls[3].path, 'dismiss');
357 });
358 });
359
360 describe('FLOW-CAPTURE-LIVE-KN-b — e2e', () => {
361 const dataDir = path.join(tmpRoot, 'e2e');
362 let starterDir;
363
364 beforeEach(() => {
365 fs.rmSync(tmpRoot, { recursive: true, force: true });
366 fs.mkdirSync(dataDir, { recursive: true });
367 starterDir = emptyStarterDir(dataDir);
368 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
369 });
370 afterEach(() => {
371 fs.rmSync(tmpRoot, { recursive: true, force: true });
372 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
373 });
374
375 it('propose capture → approve-time self-apply still NOT_ADMITTED (pending honesty)', async () => {
376 upsertCandidate(
377 dataDir,
378 'default',
379 makeCandidateRecord({ candidate_id: 'cand_e2eknb01', status: 'pending_review' }),
380 );
381 const proposed = await handleFlowCaptureProposeRequest({
382 dataDir,
383 vaultId: 'default',
384 visibleScopes: visible,
385 candidateId: 'cand_e2eknb01',
386 confirmedScope: 'personal',
387 intent: 'promote for review',
388 createProposal,
389 starterDir,
390 userId: ACTOR,
391 });
392 assert.equal(proposed.ok, true, proposed.code);
393 const stored = getProposal(dataDir, proposed.payload.proposal_id);
394 assert.equal(stored.source, FLOW_CAPTURE_PROPOSAL_SOURCE);
395 assert.equal(
396 personalSelfApplyRefusalReason(
397 eligible(stored, { authorActorId: ACTOR, approverActorId: ACTOR, sessionBound: true }),
398 ),
399 'SELF_APPLY_NOT_ADMITTED',
400 );
401 });
402 });
403
404 describe('FLOW-CAPTURE-LIVE-KN-b — stress', () => {
405 const dataDir = path.join(tmpRoot, 'stress');
406 let starterDir;
407
408 beforeEach(() => {
409 fs.rmSync(tmpRoot, { recursive: true, force: true });
410 fs.mkdirSync(dataDir, { recursive: true });
411 starterDir = emptyStarterDir(dataDir);
412 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
413 });
414 afterEach(() => {
415 fs.rmSync(tmpRoot, { recursive: true, force: true });
416 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
417 });
418
419 it('N concurrent propose on same candidate — one wins; no cross-user leak', async () => {
420 upsertCandidate(
421 dataDir,
422 'default',
423 makeCandidateRecord({ candidate_id: 'cand_stress01', status: 'pending_review' }),
424 );
425 const results = await Promise.all(
426 Array.from({ length: 8 }, (_, i) =>
427 handleFlowCaptureProposeRequest({
428 dataDir,
429 vaultId: 'default',
430 visibleScopes: visible,
431 candidateId: 'cand_stress01',
432 confirmedScope: 'personal',
433 intent: `promote-${i}`,
434 createProposal,
435 starterDir,
436 userId: i % 2 === 0 ? ACTOR : OTHER,
437 }),
438 ),
439 );
440 const ok = results.filter((r) => r.ok);
441 const refused = results.filter((r) => !r.ok);
442 assert.equal(ok.length, 1);
443 assert.ok(refused.every((r) => r.code === 'FLOW_CANDIDATE_NOT_PROMOTABLE'));
444 const { proposals } = listProposals(dataDir, { source: FLOW_CAPTURE_PROPOSAL_SOURCE });
445 assert.equal(proposals.length, 1);
446 assert.equal(proposals[0].capture_meta.candidate_id, 'cand_stress01');
447 });
448 });
449
450 describe('FLOW-CAPTURE-LIVE-KN-b — data-integrity', () => {
451 const dataDir = path.join(tmpRoot, 'integrity');
452 let starterDir;
453
454 beforeEach(() => {
455 fs.rmSync(tmpRoot, { recursive: true, force: true });
456 fs.mkdirSync(dataDir, { recursive: true });
457 starterDir = emptyStarterDir(dataDir);
458 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
459 });
460 afterEach(() => {
461 fs.rmSync(tmpRoot, { recursive: true, force: true });
462 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
463 });
464
465 it('proposal source/capture_meta preserved; candidate status unchanged until Hub apply', async () => {
466 upsertCandidate(
467 dataDir,
468 'default',
469 makeCandidateRecord({ candidate_id: 'cand_diknb01', status: 'pending_review' }),
470 );
471 const proposed = await handleFlowCaptureProposeRequest({
472 dataDir,
473 vaultId: 'default',
474 visibleScopes: visible,
475 candidateId: 'cand_diknb01',
476 confirmedScope: 'personal',
477 intent: 'promote',
478 createProposal,
479 starterDir,
480 userId: ACTOR,
481 });
482 assert.equal(proposed.ok, true, proposed.code);
483 const stored = getProposal(dataDir, proposed.payload.proposal_id);
484 assert.equal(stored.source, FLOW_CAPTURE_PROPOSAL_SOURCE);
485 assert.equal(stored.capture_meta.proposal_kind, 'flow_candidate_promote');
486 assert.equal(stored.capture_meta.candidate_id, 'cand_diknb01');
487 assert.equal(stored.capture_meta.confirmed_scope, 'personal');
488 const cand = getCandidate(dataDir, 'default', 'cand_diknb01', visible);
489 assert.equal(cand.status, 'pending_review');
490 });
491
492 it('dismiss preserves capture_meta kind; candidate still pending_review', async () => {
493 upsertCandidate(
494 dataDir,
495 'default',
496 makeCandidateRecord({ candidate_id: 'cand_didis01', status: 'pending_review' }),
497 );
498 const dismissed = await handleFlowCaptureDismissRequest({
499 dataDir,
500 vaultId: 'default',
501 visibleScopes: visible,
502 candidateId: 'cand_didis01',
503 intent: 'dismiss noise',
504 createProposal,
505 userId: ACTOR,
506 });
507 assert.equal(dismissed.ok, true, dismissed.code);
508 const stored = getProposal(dataDir, dismissed.payload.proposal_id);
509 assert.equal(stored.source, FLOW_CAPTURE_PROPOSAL_SOURCE);
510 assert.equal(stored.capture_meta.proposal_kind, 'flow_candidate_dismiss');
511 assert.equal(getCandidate(dataDir, 'default', 'cand_didis01', visible).status, 'pending_review');
512 });
513 });
514
515 describe('FLOW-CAPTURE-LIVE-KN-b — performance', () => {
516 it('proxy overhead bounded vs authoring proxy class (<2s env-off refuse)', async (t) => {
517 const mockBridge = express();
518 mockBridge.use(express.json());
519 mockBridge.post('/api/v1/flows/candidates/:id/propose', (_req, res) => {
520 res.status(403).json({ code: 'FLOW_CAPTURE_WRITES_DISABLED', error: 'disabled' });
521 });
522 const { bridgeUrl, close } = await startMockBridge(mockBridge);
523 t.after(close);
524 const port = await bootGateway(t, bridgeUrl, `perf-${Date.now()}`);
525 const token = signTestJwt({ sub: 'user-cap-perf', role: 'editor', type: 'session' });
526 const t0 = performance.now();
527 const res = await fetch(
528 `http://127.0.0.1:${port}/api/v1/flows/candidates/cand_perf/propose`,
529 {
530 method: 'POST',
531 headers: {
532 authorization: `Bearer ${token}`,
533 'content-type': 'application/json',
534 'x-vault-id': 'default',
535 },
536 body: JSON.stringify({ intent: 'x', confirmed_scope: 'personal' }),
537 },
538 );
539 const elapsed = performance.now() - t0;
540 assert.equal(res.status, 403);
541 assert.ok(elapsed < 2000, `elapsed ${elapsed}ms`);
542 });
543 });
544
545 describe('FLOW-CAPTURE-LIVE-KN-b — security', () => {
546 const dataDir = path.join(tmpRoot, 'sec');
547 let starterDir;
548
549 beforeEach(() => {
550 fs.rmSync(tmpRoot, { recursive: true, force: true });
551 fs.mkdirSync(dataDir, { recursive: true });
552 starterDir = emptyStarterDir(dataDir);
553 });
554 afterEach(() => {
555 fs.rmSync(tmpRoot, { recursive: true, force: true });
556 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
557 delete process.env.FLOW_CAPTURE_DETECTION_ENABLED;
558 });
559
560 it('env-off refuse; scope denial; no secrets in envelopes; no positive admit', async () => {
561 assert.equal(getFlowCaptureWritesEnabled(dataDir), false);
562 upsertCandidate(
563 dataDir,
564 'default',
565 makeCandidateRecord({
566 candidate_id: 'cand_secknb01',
567 status: 'pending_review',
568 scope_hint: 'personal',
569 }),
570 );
571 const refused = await handleFlowCaptureProposeRequest({
572 dataDir,
573 vaultId: 'default',
574 visibleScopes: visible,
575 candidateId: 'cand_secknb01',
576 confirmedScope: 'personal',
577 intent: 'should refuse',
578 createProposal,
579 starterDir,
580 });
581 assert.equal(refused.ok, false);
582 assert.equal(refused.code, 'FLOW_CAPTURE_WRITES_DISABLED');
583
584 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
585 const scopeDenied = await handleFlowCaptureProposeRequest({
586 dataDir,
587 vaultId: 'default',
588 visibleScopes: visible,
589 candidateId: 'cand_secknb01',
590 confirmedScope: 'org',
591 scopeWidenAcknowledged: false,
592 intent: 'widen',
593 createProposal,
594 starterDir,
595 });
596 assert.equal(scopeDenied.ok, false);
597 assert.equal(scopeDenied.code, 'FLOW_CAPTURE_SCOPE_UNCONFIRMED');
598
599 const okProp = await handleFlowCaptureProposeRequest({
600 dataDir,
601 vaultId: 'default',
602 visibleScopes: visible,
603 candidateId: 'cand_secknb01',
604 confirmedScope: 'personal',
605 intent: 'promote',
606 createProposal,
607 starterDir,
608 userId: ACTOR,
609 });
610 assert.equal(okProp.ok, true, okProp.code);
611 const blob = JSON.stringify(getProposal(dataDir, okProp.payload.proposal_id));
612 assert.doesNotMatch(blob, /password|refresh_token|BEGIN PRIVATE/i);
613 assert.equal(
614 personalSelfApplyRefusalReason(eligible(getProposal(dataDir, okProp.payload.proposal_id))),
615 'SELF_APPLY_NOT_ADMITTED',
616 );
617
618 const selfApplySrc = readRepo('lib/hub-proposal-personal-self-apply.mjs');
619 assert.doesNotMatch(selfApplySrc, /matchesScoolingFlowCaptureFingerprint/);
620 });
621
622 it('source scan: no capture env hard-on; no Delegation write env; no secrets in gateway', () => {
623 const gw = readRepo('hub/gateway/server.mjs');
624 const routes = readRepo('hub/bridge/flow-capture-routes.mjs');
625 assert.doesNotMatch(routes, /FLOW_CAPTURE_WRITES_ENABLED\s*=\s*['"]1['"]/);
626 assert.doesNotMatch(routes, /DELEGATION_WRITES\s*=/);
627 // Run proxies are SITE-FINISH-FLOW-RUN-KN-b (not this suite). Capture still
628 // must not hard-on run env or embed Hub JWTs in gateway source.
629 assert.doesNotMatch(gw, /FLOW_RUN_WRITES_ENABLED\s*=\s*['"]1['"]/);
630 assert.doesNotMatch(gw, /FLOW_AUTOMATABLE_EXECUTION_ENABLED\s*=\s*['"]1['"]/);
631 assert.doesNotMatch(gw, /SCOOLING_.*HUB.*JWT/);
632 });
633 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago