capture-hosted-apply-kn-b.test.mjs
923 lines 32.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * CAPTURE-HOSTED-APPLY-KN-b — seven-tier coverage (§CHA.4 matrix).
3 * Frozen: docs/CAPTURE-HOSTED-APPLY-FREEZE.md (CHA-C1–C11).
4 *
5 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
6 *
7 * Proves: gateway post-approve capture hook + response merge (CHA-C1); bridge
8 * apply-approved via shared precheck/apply (CHA-C2/C3/C10); hosted GET flows
9 * list/get exposure after promote (CHA-C5); T5 stays refuse-all (CHA-C4);
10 * fail-closed 409/400 gates (CHA-C8); approve-then-apply honesty (CHA-C11).
11 */
12
13 import fs from 'node:fs';
14 import { describe, it, beforeEach, afterEach } 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 maybeApplyHostedCaptureAfterApprove,
25 mergeCaptureApplyIntoApproveResponse,
26 } from '../hub/gateway/capture-approve-hosted.mjs';
27 import { applyApprovedCaptureProposalFromCanister } from '../lib/flow/flow-capture-hosted-apply.mjs';
28 import {
29 FLOW_CAPTURE_PROPOSAL_SOURCE,
30 handleFlowCaptureProposeRequest,
31 } from '../lib/flow/flow-capture.mjs';
32 import {
33 FM_PROPOSAL_SOURCE,
34 FM_CAPTURE_PROPOSAL_KIND,
35 FM_CAPTURE_CANDIDATE_ID,
36 } from '../lib/flow/flow-capture-hosted-proposal.mjs';
37 import { handleFlowListRequest, handleFlowGetRequest } from '../lib/flow/flow-handlers.mjs';
38 import {
39 upsertCandidate,
40 getCandidate,
41 getFlow,
42 loadFlowStore,
43 FLOW_STORE_FILENAME,
44 } from '../lib/flow/flow-store.mjs';
45 import {
46 withExternalProtocolBlobSync,
47 externalProtocolBlobKey,
48 } from '../hub/bridge/external-agent-blob-store.mjs';
49 import {
50 personalSelfApplyRefusalReason,
51 isAdmittedSeamSelfApplyFingerprint,
52 } from '../lib/hub-proposal-personal-self-apply.mjs';
53 import { createProposal, getProposal } from '../hub/proposals-store.mjs';
54 import { makeCandidateRecord, emptyStarterDir } from './fixtures/flow/capture-helpers.mjs';
55
56 const __dirname = path.dirname(fileURLToPath(import.meta.url));
57 const projectRoot = path.resolve(__dirname, '..');
58 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-capture-hosted-apply-kn-b');
59
60 const SECRET = 'capture-hosted-apply-kn-b-secret-32!!';
61 const ACTOR = 'google:learner-cap';
62 const visible = new Set(['personal', 'project', 'org']);
63
64 /** Sign an HS256 JWT for gateway auth in live-server tiers. */
65 function signTestJwt(payload) {
66 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
67 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
68 const data = `${header}.${body}`;
69 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
70 return `${data}.${sig}`;
71 }
72
73 /** Start an HTTP server on an ephemeral port; returns { url, close }. */
74 function startServer(handler) {
75 const srv = http.createServer(handler);
76 return new Promise((resolve, reject) => {
77 srv.listen(0, '127.0.0.1', (err) => {
78 if (err) return reject(err);
79 resolve({
80 url: `http://127.0.0.1:${srv.address().port}`,
81 close: () => new Promise((r) => srv.close(() => r())),
82 });
83 });
84 });
85 }
86
87 /**
88 * Mock canister serving GET /api/v1/proposals/:id from a Map and 200 on approve.
89 * @param {Map<string, Record<string, unknown>>} rows
90 */
91 function mockCanisterApp(rows) {
92 const app = express();
93 app.use(express.json());
94 app.post('/api/v1/proposals/:id/approve', (req, res) => {
95 const row = rows.get(req.params.id);
96 if (!row) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
97 row.status = 'approved';
98 res.json({ proposal_id: req.params.id, status: 'approved' });
99 });
100 app.get('/api/v1/proposals/:id', (req, res) => {
101 const row = rows.get(req.params.id);
102 if (!row) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
103 res.json(row);
104 });
105 return app;
106 }
107
108 /** In-memory Netlify-Blobs stand-in recording set() keys. */
109 function fakeBlobStore(initial = {}) {
110 const store = new Map(Object.entries(initial));
111 const sets = [];
112 return {
113 store,
114 sets,
115 get: async (key) => (store.has(key) ? store.get(key) : null),
116 set: async (key, value) => {
117 sets.push(key);
118 store.set(key, value);
119 },
120 };
121 }
122
123 /**
124 * Seed a pending candidate + real promote proposal body through the shared
125 * propose handler, then shape it as the canister GET row for apply.
126 * Requires FLOW_CAPTURE_WRITES_ENABLED=1.
127 */
128 async function seedPromoteRow(dataDir, starterDir, candidateId, { status = 'approved', proposalId } = {}) {
129 upsertCandidate(
130 dataDir,
131 'default',
132 makeCandidateRecord({ candidate_id: candidateId, status: 'pending_review' }),
133 );
134 const proposed = await handleFlowCaptureProposeRequest({
135 dataDir,
136 vaultId: 'default',
137 visibleScopes: visible,
138 candidateId,
139 confirmedScope: 'personal',
140 intent: 'promote for hosted apply',
141 createProposal,
142 starterDir,
143 userId: ACTOR,
144 });
145 assert.equal(proposed.ok, true, `seed propose failed: ${proposed.code}`);
146 const stored = getProposal(dataDir, proposed.payload.proposal_id);
147 return {
148 ...stored,
149 proposal_id: proposalId ?? stored.proposal_id,
150 status,
151 };
152 }
153
154 /** Canister row for a dismiss proposal (no bundle needed by precheck). */
155 function dismissRow(proposalId, candidateId, status = 'approved') {
156 return {
157 proposal_id: proposalId,
158 status,
159 path: `meta/candidates/${candidateId}.md`,
160 body: JSON.stringify({ proposal_kind: 'flow_candidate_dismiss', candidate_id: candidateId }),
161 frontmatter: {
162 [FM_PROPOSAL_SOURCE]: FLOW_CAPTURE_PROPOSAL_SOURCE,
163 type: 'flow_capture',
164 [FM_CAPTURE_PROPOSAL_KIND]: 'flow_candidate_dismiss',
165 [FM_CAPTURE_CANDIDATE_ID]: candidateId,
166 },
167 vault_id: 'default',
168 };
169 }
170
171 /** Non-capture canister row (plain note proposal). */
172 function noteRow(proposalId, status = 'approved') {
173 return {
174 proposal_id: proposalId,
175 status,
176 path: 'notes/plain.md',
177 body: 'plain note body',
178 frontmatter: { type: 'note' },
179 vault_id: 'default',
180 };
181 }
182
183 /** Eligibility scaffold for personalSelfApplyRefusalReason (T5 regression). */
184 function eligible(proposal, extra = {}) {
185 return {
186 proposal,
187 hasVaultWrite: true,
188 partitionOwned: true,
189 role: 'member',
190 humanActor: true,
191 tokenType: null,
192 actorKind: 'human',
193 sessionBound: true,
194 authorActorId: ACTOR,
195 approverActorId: ACTOR,
196 ...extra,
197 };
198 }
199
200 function readRepo(rel) {
201 return fs.readFileSync(path.join(projectRoot, rel), 'utf8');
202 }
203
204 /** Boot the real gateway Express app against mock canister + bridge URLs. */
205 async function bootGateway(t, { canisterUrl, bridgeUrl, adminSub, cacheBust }) {
206 process.env.NETLIFY = '1';
207 process.env.CANISTER_URL = canisterUrl;
208 process.env.SESSION_SECRET = SECRET;
209 process.env.BRIDGE_URL = bridgeUrl;
210 process.env.HUB_ADMIN_USER_IDS = adminSub;
211 t.after(() => {
212 delete process.env.HUB_ADMIN_USER_IDS;
213 });
214
215 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
216 const { app: gwApp } = await import(`${gwEntry}?gwcapapply=${cacheBust}`);
217 const gwSrv = http.createServer(gwApp);
218 await new Promise((resolve, reject) => {
219 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
220 });
221 t.after(() => new Promise((r) => gwSrv.close(() => r())));
222 return gwSrv.address().port;
223 }
224
225 /** Mock bridge for e2e: admin role, no hosted-context, records apply calls. */
226 function mockBridgeApp(applyCalls, applyResponse) {
227 const app = express();
228 app.use(express.json());
229 app.get('/api/v1/role', (_req, res) => {
230 res.json({ role: 'admin', may_approve_proposals: true });
231 });
232 app.get('/api/v1/hosted-context', (_req, res) => {
233 res.status(404).json({ error: 'not hosted', code: 'NOT_FOUND' });
234 });
235 app.post('/api/v1/flows/capture/proposals/:proposal_id/apply-approved', (req, res) => {
236 applyCalls.push({
237 proposalId: req.params.proposal_id,
238 auth: req.headers.authorization,
239 vault: req.headers['x-vault-id'],
240 });
241 res.json({ applied: true, ...applyResponse, proposal_id: req.params.proposal_id });
242 });
243 return app;
244 }
245
246 // ---------------------------------------------------------------------------
247
248 describe('CAPTURE-HOSTED-APPLY-KN-b — unit', () => {
249 const dataDir = path.join(tmpRoot, 'unit');
250 let starterDir;
251
252 beforeEach(() => {
253 fs.rmSync(tmpRoot, { recursive: true, force: true });
254 fs.mkdirSync(dataDir, { recursive: true });
255 starterDir = emptyStarterDir(dataDir);
256 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
257 });
258 afterEach(() => {
259 fs.rmSync(tmpRoot, { recursive: true, force: true });
260 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
261 });
262
263 it('mergeCaptureApplyIntoApproveResponse merges success / failure / null', () => {
264 const base = JSON.stringify({ proposal_id: 'p1', status: 'approved' });
265
266 assert.equal(mergeCaptureApplyIntoApproveResponse(base, null), base);
267
268 const ok = JSON.parse(
269 mergeCaptureApplyIntoApproveResponse(base, {
270 applied: true,
271 payload: { applied: true, proposal_id: 'p1', proposal_kind: 'flow_candidate_promote', flow_id: 'flow_cap_x' },
272 }),
273 );
274 assert.equal(ok.capture_index_applied, true);
275 assert.equal(ok.capture_apply.flow_id, 'flow_cap_x');
276 assert.equal(ok.capture_apply_error, undefined);
277
278 const fail = JSON.parse(
279 mergeCaptureApplyIntoApproveResponse(base, {
280 applied: false,
281 error: 'Candidate not promotable at approve time',
282 code: 'FLOW_CANDIDATE_NOT_PROMOTABLE',
283 }),
284 );
285 assert.equal(fail.capture_index_applied, false);
286 assert.equal(fail.capture_apply_code, 'FLOW_CANDIDATE_NOT_PROMOTABLE');
287 assert.equal(fail.capture_apply, undefined);
288
289 // Non-JSON upstream body passes through untouched.
290 assert.equal(
291 mergeCaptureApplyIntoApproveResponse('not-json', { applied: true, payload: {} }),
292 'not-json',
293 );
294 });
295
296 it('hook returns null for non-approve paths and non-2xx approve', async () => {
297 const ctxBase = {
298 method: 'POST',
299 pathOnly: '/api/v1/proposals/p1/approve',
300 upstreamStatus: 200,
301 canisterUrl: 'http://127.0.0.1:1',
302 bridgeUrl: 'http://127.0.0.1:1',
303 authorization: undefined,
304 vaultId: 'default',
305 effectiveUserId: 'u',
306 actorUserId: 'u',
307 canisterAuthHeaders: () => ({}),
308 };
309 assert.equal(await maybeApplyHostedCaptureAfterApprove({ ...ctxBase, method: 'GET' }), null);
310 assert.equal(
311 await maybeApplyHostedCaptureAfterApprove({
312 ...ctxBase,
313 pathOnly: '/api/v1/proposals/p1/discard',
314 }),
315 null,
316 );
317 assert.equal(
318 await maybeApplyHostedCaptureAfterApprove({ ...ctxBase, upstreamStatus: 403 }),
319 null,
320 );
321 assert.equal(await maybeApplyHostedCaptureAfterApprove({ ...ctxBase, bridgeUrl: '' }), null);
322 });
323
324 it('apply helper refuses non-capture (400) and non-approved (409); promote payload includes flow_id', async (t) => {
325 const rows = new Map();
326 rows.set('prop-note', noteRow('prop-note'));
327 rows.set('prop-dis-pending', dismissRow('prop-dis-pending', 'cand_unit01', 'proposed'));
328 const promoteRow = await seedPromoteRow(dataDir, starterDir, 'cand_unit02', {
329 proposalId: 'prop-promote-unit',
330 });
331 rows.set('prop-promote-unit', promoteRow);
332
333 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
334 t.after(close);
335
336 const nonCapture = await applyApprovedCaptureProposalFromCanister({
337 dataDir,
338 canisterUrl,
339 headers: {},
340 proposalId: 'prop-note',
341 });
342 assert.equal(nonCapture.ok, false);
343 assert.equal(nonCapture.status, 400);
344 assert.equal(nonCapture.code, 'BAD_REQUEST');
345
346 const notApproved = await applyApprovedCaptureProposalFromCanister({
347 dataDir,
348 canisterUrl,
349 headers: {},
350 proposalId: 'prop-dis-pending',
351 });
352 assert.equal(notApproved.ok, false);
353 assert.equal(notApproved.status, 409);
354 assert.equal(notApproved.code, 'CONFLICT');
355
356 const promoted = await applyApprovedCaptureProposalFromCanister({
357 dataDir,
358 canisterUrl,
359 headers: {},
360 proposalId: 'prop-promote-unit',
361 });
362 assert.equal(promoted.ok, true, JSON.stringify(promoted));
363 assert.equal(promoted.payload.applied, true);
364 assert.equal(promoted.payload.proposal_kind, 'flow_candidate_promote');
365 assert.equal(promoted.payload.flow_id, 'flow_cap_unit02');
366 assert.equal(promoted.payload.apply_result, 'promote');
367 });
368 });
369
370 describe('CAPTURE-HOSTED-APPLY-KN-b — integration', () => {
371 const dataDir = path.join(tmpRoot, 'int');
372 let starterDir;
373
374 beforeEach(() => {
375 fs.rmSync(tmpRoot, { recursive: true, force: true });
376 fs.mkdirSync(dataDir, { recursive: true });
377 starterDir = emptyStarterDir(dataDir);
378 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
379 });
380 afterEach(() => {
381 fs.rmSync(tmpRoot, { recursive: true, force: true });
382 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
383 });
384
385 it('apply-approved → upsertFlowVersion visible via list/get; blob persisted', async (t) => {
386 const rows = new Map();
387 rows.set(
388 'prop-int-promote',
389 await seedPromoteRow(dataDir, starterDir, 'cand_int01', { proposalId: 'prop-int-promote' }),
390 );
391 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
392 t.after(close);
393
394 const blobStore = fakeBlobStore();
395 const result = await withExternalProtocolBlobSync({
396 blobStore,
397 dataDir,
398 run: () =>
399 applyApprovedCaptureProposalFromCanister({
400 dataDir,
401 canisterUrl,
402 headers: {},
403 proposalId: 'prop-int-promote',
404 requireApproved: true,
405 }),
406 });
407 assert.equal(result.ok, true, JSON.stringify(result));
408 assert.equal(result.payload.flow_id, 'flow_cap_int01');
409
410 // CHA-C5: promote is observable through the same handlers the bridge mounts.
411 const list = handleFlowListRequest({
412 dataDir,
413 vaultId: 'default',
414 visibleScopes: visible,
415 });
416 assert.equal(list.ok, true);
417 assert.ok(
418 list.payload.flows.some((f) => f.flow_id === 'flow_cap_int01'),
419 'promoted flow must appear in list',
420 );
421 const got = handleFlowGetRequest({
422 dataDir,
423 vaultId: 'default',
424 flowId: 'flow_cap_int01',
425 visibleScopes: visible,
426 });
427 assert.equal(got.ok, true);
428 assert.equal(got.payload.flow.flow_id, 'flow_cap_int01');
429
430 // Candidate terminal state + blob persist of hub_flow_store.json.
431 assert.equal(getCandidate(dataDir, 'default', 'cand_int01', visible).status, 'promoted');
432 assert.ok(
433 blobStore.sets.includes(externalProtocolBlobKey(FLOW_STORE_FILENAME)),
434 'hub_flow_store.json must be persisted to blob after apply',
435 );
436 });
437
438 it('cold lambda: candidate only in blob → hydrate before precheck (CHA-C3)', async (t) => {
439 // Build a flow store containing the pending candidate + real proposal in a warm dir.
440 const warmDir = path.join(tmpRoot, 'int-warm');
441 fs.mkdirSync(warmDir, { recursive: true });
442 const warmStarter = emptyStarterDir(warmDir);
443 const row = await seedPromoteRow(warmDir, warmStarter, 'cand_cold01', {
444 proposalId: 'prop-cold-promote',
445 });
446 const warmStoreRaw = fs.readFileSync(path.join(warmDir, FLOW_STORE_FILENAME), 'utf8');
447
448 const rows = new Map([['prop-cold-promote', row]]);
449 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
450 t.after(close);
451
452 // Cold dir: no local store file — only the blob has the candidate.
453 const coldDir = path.join(tmpRoot, 'int-cold');
454 fs.mkdirSync(coldDir, { recursive: true });
455 const blobStore = fakeBlobStore({
456 [externalProtocolBlobKey(FLOW_STORE_FILENAME)]: warmStoreRaw,
457 });
458
459 const result = await withExternalProtocolBlobSync({
460 blobStore,
461 dataDir: coldDir,
462 run: () =>
463 applyApprovedCaptureProposalFromCanister({
464 dataDir: coldDir,
465 canisterUrl,
466 headers: {},
467 proposalId: 'prop-cold-promote',
468 requireApproved: true,
469 }),
470 });
471 assert.equal(result.ok, true, JSON.stringify(result));
472 assert.equal(result.payload.flow_id, 'flow_cap_cold01');
473 assert.equal(getCandidate(coldDir, 'default', 'cand_cold01', visible).status, 'promoted');
474 });
475 });
476
477 describe('CAPTURE-HOSTED-APPLY-KN-b — e2e', () => {
478 const dataDir = path.join(tmpRoot, 'e2e');
479 let starterDir;
480
481 beforeEach(() => {
482 fs.rmSync(tmpRoot, { recursive: true, force: true });
483 fs.mkdirSync(dataDir, { recursive: true });
484 starterDir = emptyStarterDir(dataDir);
485 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
486 });
487 afterEach(() => {
488 fs.rmSync(tmpRoot, { recursive: true, force: true });
489 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
490 });
491
492 it('admin approve of capture proposal → capture_index_applied true; bridge apply invoked', async (t) => {
493 const rows = new Map();
494 rows.set(
495 'prop-e2e-cap',
496 await seedPromoteRow(dataDir, starterDir, 'cand_e2e01', {
497 proposalId: 'prop-e2e-cap',
498 status: 'proposed',
499 }),
500 );
501 const { url: canisterUrl, close: closeCanister } = await startServer(mockCanisterApp(rows));
502 t.after(closeCanister);
503
504 const applyCalls = [];
505 const { url: bridgeUrl, close: closeBridge } = await startServer(
506 mockBridgeApp(applyCalls, {
507 proposal_kind: 'flow_candidate_promote',
508 flow_id: 'flow_cap_e2e01',
509 vault_id: 'default',
510 }),
511 );
512 t.after(closeBridge);
513
514 const adminSub = 'google:cap-admin';
515 const port = await bootGateway(t, {
516 canisterUrl,
517 bridgeUrl,
518 adminSub,
519 cacheBust: `e2e-${Date.now()}`,
520 });
521 const token = signTestJwt({ sub: adminSub });
522 const res = await fetch(`http://127.0.0.1:${port}/api/v1/proposals/prop-e2e-cap/approve`, {
523 method: 'POST',
524 headers: {
525 Authorization: `Bearer ${token}`,
526 'Content-Type': 'application/json',
527 'X-Vault-Id': 'default',
528 },
529 body: JSON.stringify({}),
530 });
531 assert.equal(res.status, 200, await res.clone().text());
532 const json = await res.json();
533 assert.equal(json.status, 'approved');
534 assert.equal(json.capture_index_applied, true);
535 assert.equal(json.capture_apply.flow_id, 'flow_cap_e2e01');
536 assert.equal(applyCalls.length, 1);
537 assert.equal(applyCalls[0].proposalId, 'prop-e2e-cap');
538 assert.match(applyCalls[0].auth, /^Bearer /);
539 assert.equal(applyCalls[0].vault, 'default');
540 });
541
542 it('non-capture approve → no capture fields; bridge apply not invoked', async (t) => {
543 const rows = new Map();
544 rows.set('prop-e2e-note', noteRow('prop-e2e-note', 'proposed'));
545 const { url: canisterUrl, close: closeCanister } = await startServer(mockCanisterApp(rows));
546 t.after(closeCanister);
547
548 const applyCalls = [];
549 const { url: bridgeUrl, close: closeBridge } = await startServer(
550 mockBridgeApp(applyCalls, {}),
551 );
552 t.after(closeBridge);
553
554 const adminSub = 'google:cap-admin2';
555 const port = await bootGateway(t, {
556 canisterUrl,
557 bridgeUrl,
558 adminSub,
559 cacheBust: `e2e2-${Date.now()}`,
560 });
561 const token = signTestJwt({ sub: adminSub });
562 const res = await fetch(`http://127.0.0.1:${port}/api/v1/proposals/prop-e2e-note/approve`, {
563 method: 'POST',
564 headers: {
565 Authorization: `Bearer ${token}`,
566 'Content-Type': 'application/json',
567 'X-Vault-Id': 'default',
568 },
569 body: JSON.stringify({}),
570 });
571 assert.equal(res.status, 200, await res.clone().text());
572 const json = await res.json();
573 assert.equal(json.capture_index_applied, undefined);
574 assert.equal(json.capture_apply, undefined);
575 assert.equal(json.capture_apply_error, undefined);
576 assert.equal(applyCalls.length, 0);
577 });
578 });
579
580 describe('CAPTURE-HOSTED-APPLY-KN-b — stress', () => {
581 const dataDir = path.join(tmpRoot, 'stress');
582 let starterDir;
583
584 beforeEach(() => {
585 fs.rmSync(tmpRoot, { recursive: true, force: true });
586 fs.mkdirSync(dataDir, { recursive: true });
587 starterDir = emptyStarterDir(dataDir);
588 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
589 });
590 afterEach(() => {
591 fs.rmSync(tmpRoot, { recursive: true, force: true });
592 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
593 });
594
595 it('50 sequential apply-approved calls; last promote still gettable', async (t) => {
596 const N = 50;
597 const rows = new Map();
598 // Seed all proposals before any apply so the dedup scan sees no flows yet.
599 for (let i = 0; i < N; i++) {
600 const candidateId = `cand_st${String(i).padStart(3, '0')}`;
601 rows.set(
602 `prop-st-${i}`,
603 await seedPromoteRow(dataDir, starterDir, candidateId, { proposalId: `prop-st-${i}` }),
604 );
605 }
606 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
607 t.after(close);
608
609 for (let i = 0; i < N; i++) {
610 const result = await applyApprovedCaptureProposalFromCanister({
611 dataDir,
612 canisterUrl,
613 headers: {},
614 proposalId: `prop-st-${i}`,
615 requireApproved: true,
616 });
617 assert.equal(result.ok, true, `apply ${i} failed: ${JSON.stringify(result)}`);
618 }
619
620 const lastId = `flow_cap_st${String(N - 1).padStart(3, '0')}`;
621 const last = getFlow(dataDir, 'default', lastId, { filterScopes: visible });
622 assert.ok(last, 'last promoted flow must exist');
623 assert.equal(
624 getCandidate(dataDir, 'default', `cand_st${String(N - 1).padStart(3, '0')}`, visible).status,
625 'promoted',
626 );
627 });
628 });
629
630 describe('CAPTURE-HOSTED-APPLY-KN-b — data-integrity', () => {
631 const dataDir = path.join(tmpRoot, 'di');
632 let starterDir;
633
634 beforeEach(() => {
635 fs.rmSync(tmpRoot, { recursive: true, force: true });
636 fs.mkdirSync(dataDir, { recursive: true });
637 starterDir = emptyStarterDir(dataDir);
638 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
639 });
640 afterEach(() => {
641 fs.rmSync(tmpRoot, { recursive: true, force: true });
642 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
643 });
644
645 it('second apply after promote fails closed; no duplicate flow versions', async (t) => {
646 const rows = new Map();
647 rows.set(
648 'prop-di-promote',
649 await seedPromoteRow(dataDir, starterDir, 'cand_di01', { proposalId: 'prop-di-promote' }),
650 );
651 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
652 t.after(close);
653
654 const first = await applyApprovedCaptureProposalFromCanister({
655 dataDir,
656 canisterUrl,
657 headers: {},
658 proposalId: 'prop-di-promote',
659 });
660 assert.equal(first.ok, true, JSON.stringify(first));
661
662 // Candidate is now `promoted` — the shared precheck refuses re-apply (CHA-C11
663 // ops recovery only works while store state is applicable).
664 const second = await applyApprovedCaptureProposalFromCanister({
665 dataDir,
666 canisterUrl,
667 headers: {},
668 proposalId: 'prop-di-promote',
669 });
670 assert.equal(second.ok, false);
671 assert.equal(second.status, 409);
672 assert.equal(second.code, 'FLOW_CANDIDATE_NOT_PROMOTABLE');
673
674 const flow = getFlow(dataDir, 'default', 'flow_cap_di01', { filterScopes: visible });
675 assert.ok(flow);
676 const store = loadFlowStore(dataDir);
677 const versions = store.vaults.default.flows.filter((f) => f.flow_id === 'flow_cap_di01');
678 assert.equal(versions.length, 1, 'no duplicate flow versions');
679 assert.equal(versions[0].version, '1.0.0');
680 });
681
682 it('merge and dismiss terminal candidate statuses stick', async (t) => {
683 const rows = new Map();
684 // Promote target first so merge has an existing flow.
685 rows.set(
686 'prop-di-target',
687 await seedPromoteRow(dataDir, starterDir, 'cand_di10', { proposalId: 'prop-di-target' }),
688 );
689 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
690 t.after(close);
691
692 const target = await applyApprovedCaptureProposalFromCanister({
693 dataDir,
694 canisterUrl,
695 headers: {},
696 proposalId: 'prop-di-target',
697 });
698 assert.equal(target.ok, true, JSON.stringify(target));
699
700 // Merge: propose with merge_into_flow_id (identical draft steps ⇒ dedup match).
701 upsertCandidate(
702 dataDir,
703 'default',
704 makeCandidateRecord({ candidate_id: 'cand_di11', status: 'pending_review' }),
705 );
706 const mergeProposed = await handleFlowCaptureProposeRequest({
707 dataDir,
708 vaultId: 'default',
709 visibleScopes: visible,
710 candidateId: 'cand_di11',
711 confirmedScope: 'personal',
712 mergeIntoFlowId: 'flow_cap_di10',
713 intent: 'merge into existing',
714 createProposal,
715 starterDir,
716 userId: ACTOR,
717 });
718 assert.equal(mergeProposed.ok, true, mergeProposed.code);
719 assert.equal(mergeProposed.payload.proposal_kind, 'flow_candidate_merge');
720 const mergeStored = getProposal(dataDir, mergeProposed.payload.proposal_id);
721 rows.set('prop-di-merge', { ...mergeStored, proposal_id: 'prop-di-merge', status: 'approved' });
722
723 const merged = await applyApprovedCaptureProposalFromCanister({
724 dataDir,
725 canisterUrl,
726 headers: {},
727 proposalId: 'prop-di-merge',
728 });
729 assert.equal(merged.ok, true, JSON.stringify(merged));
730 assert.equal(merged.payload.merge_into_flow_id, 'flow_cap_di10');
731 assert.equal(
732 getCandidate(dataDir, 'default', 'cand_di11', visible).status,
733 'merged_into:flow_cap_di10',
734 );
735
736 // Dismiss.
737 upsertCandidate(
738 dataDir,
739 'default',
740 makeCandidateRecord({ candidate_id: 'cand_di12', status: 'pending_review' }),
741 );
742 rows.set('prop-di-dismiss', dismissRow('prop-di-dismiss', 'cand_di12'));
743 const dismissed = await applyApprovedCaptureProposalFromCanister({
744 dataDir,
745 canisterUrl,
746 headers: {},
747 proposalId: 'prop-di-dismiss',
748 });
749 assert.equal(dismissed.ok, true, JSON.stringify(dismissed));
750 assert.equal(dismissed.payload.dismissed, true);
751 assert.equal(getCandidate(dataDir, 'default', 'cand_di12', visible).status, 'rejected');
752
753 // Terminal states survive a re-read from disk.
754 assert.equal(
755 getCandidate(dataDir, 'default', 'cand_di11', visible).status,
756 'merged_into:flow_cap_di10',
757 );
758 assert.equal(getCandidate(dataDir, 'default', 'cand_di12', visible).status, 'rejected');
759 });
760 });
761
762 describe('CAPTURE-HOSTED-APPLY-KN-b — performance', () => {
763 const dataDir = path.join(tmpRoot, 'perf');
764 let starterDir;
765
766 beforeEach(() => {
767 fs.rmSync(tmpRoot, { recursive: true, force: true });
768 fs.mkdirSync(dataDir, { recursive: true });
769 starterDir = emptyStarterDir(dataDir);
770 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
771 });
772 afterEach(() => {
773 fs.rmSync(tmpRoot, { recursive: true, force: true });
774 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
775 });
776
777 it('single apply-approved + list within documented budget (2s local fixture)', async (t) => {
778 // Documented bound: mock canister on loopback, small store — apply + list must
779 // finish well under 2000ms. No network to a real canister (§CHA.4 tier 6).
780 const rows = new Map();
781 rows.set(
782 'prop-perf',
783 await seedPromoteRow(dataDir, starterDir, 'cand_perf01', { proposalId: 'prop-perf' }),
784 );
785 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
786 t.after(close);
787
788 const t0 = performance.now();
789 const result = await applyApprovedCaptureProposalFromCanister({
790 dataDir,
791 canisterUrl,
792 headers: {},
793 proposalId: 'prop-perf',
794 });
795 const list = handleFlowListRequest({ dataDir, vaultId: 'default', visibleScopes: visible });
796 const elapsed = performance.now() - t0;
797 assert.equal(result.ok, true);
798 assert.equal(list.ok, true);
799 assert.ok(elapsed < 2000, `apply+list took ${elapsed}ms (budget 2000ms)`);
800 });
801 });
802
803 describe('CAPTURE-HOSTED-APPLY-KN-b — security', () => {
804 const dataDir = path.join(tmpRoot, 'sec');
805 let starterDir;
806
807 beforeEach(() => {
808 fs.rmSync(tmpRoot, { recursive: true, force: true });
809 fs.mkdirSync(dataDir, { recursive: true });
810 starterDir = emptyStarterDir(dataDir);
811 process.env.FLOW_CAPTURE_WRITES_ENABLED = '1';
812 });
813 afterEach(() => {
814 fs.rmSync(tmpRoot, { recursive: true, force: true });
815 delete process.env.FLOW_CAPTURE_WRITES_ENABLED;
816 });
817
818 it('(a) T5 refuse-all regression — promote/merge/dismiss stay SELF_APPLY_NOT_ADMITTED', () => {
819 for (const kind of [
820 'flow_candidate_promote',
821 'flow_candidate_merge',
822 'flow_candidate_dismiss',
823 ]) {
824 const row = {
825 proposal_id: `prop-sec-${kind}`,
826 status: 'proposed',
827 path: 'meta/candidates/cand_sec01.md',
828 body: JSON.stringify({ proposal_kind: kind, candidate_id: 'cand_sec01' }),
829 frontmatter: {
830 [FM_PROPOSAL_SOURCE]: FLOW_CAPTURE_PROPOSAL_SOURCE,
831 type: 'flow_capture',
832 [FM_CAPTURE_PROPOSAL_KIND]: kind,
833 [FM_CAPTURE_CANDIDATE_ID]: 'cand_sec01',
834 },
835 };
836 assert.equal(isAdmittedSeamSelfApplyFingerprint(row, ACTOR), false, kind);
837 assert.equal(personalSelfApplyRefusalReason(eligible(row)), 'SELF_APPLY_NOT_ADMITTED', kind);
838 }
839 });
840
841 it('(b) apply-approved with status proposed → 409, store untouched', async (t) => {
842 const rows = new Map();
843 rows.set(
844 'prop-sec-pending',
845 await seedPromoteRow(dataDir, starterDir, 'cand_sec02', {
846 proposalId: 'prop-sec-pending',
847 status: 'proposed',
848 }),
849 );
850 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
851 t.after(close);
852
853 const refused = await applyApprovedCaptureProposalFromCanister({
854 dataDir,
855 canisterUrl,
856 headers: {},
857 proposalId: 'prop-sec-pending',
858 requireApproved: true,
859 });
860 assert.equal(refused.ok, false);
861 assert.equal(refused.status, 409);
862 assert.equal(refused.code, 'CONFLICT');
863 assert.equal(getCandidate(dataDir, 'default', 'cand_sec02', visible).status, 'pending_review');
864 assert.equal(getFlow(dataDir, 'default', 'flow_cap_sec02', { filterScopes: visible }), null);
865 });
866
867 it('(c) source scan: no capture T5 admission; gateway wiring + ordering locked', () => {
868 const selfApply = readRepo('lib/hub-proposal-personal-self-apply.mjs');
869 assert.doesNotMatch(selfApply, /matchesScoolingFlowCaptureFingerprint/);
870 assert.match(selfApply, /flow_capture stays SELF_APPLY_NOT_ADMITTED/);
871
872 const applyHelper = readRepo('lib/flow/flow-capture-hosted-apply.mjs');
873 assert.doesNotMatch(applyHelper, /isAdmittedSeamSelfApplyFingerprint/);
874 assert.doesNotMatch(applyHelper, /FLOW_CAPTURE_WRITES_ENABLED\s*=\s*['"]1['"]/);
875
876 const gw = readRepo('hub/gateway/server.mjs');
877 assert.match(gw, /maybeApplyHostedCaptureAfterApprove/);
878 assert.match(gw, /mergeCaptureApplyIntoApproveResponse/);
879 assert.match(gw, /\/api\/v1\/flows\/capture\/proposals\/:proposal_id\/apply-approved/);
880 // CHA-C5 ordering: candidates / external-grants / projection GETs register
881 // before the GET :id proxy so static paths always win.
882 const idIdx = gw.indexOf("app.get('/api/v1/flows/:id',");
883 assert.ok(idIdx > 0, 'GET /api/v1/flows/:id proxy registered');
884 assert.ok(gw.indexOf("app.get('/api/v1/flows/candidates'") < idIdx);
885 assert.ok(gw.indexOf("app.get('/api/v1/flows/external-grants'") < idIdx);
886 assert.ok(gw.indexOf("app.get('/api/v1/flows/:id/projection'") < idIdx);
887 assert.ok(gw.indexOf("app.get('/api/v1/flows',") < idIdx);
888
889 const routes = readRepo('hub/bridge/flow-capture-routes.mjs');
890 assert.match(routes, /applyApprovedCaptureProposalFromCanister/);
891 assert.match(routes, /withExternalProtocolBlobSync/);
892 const bridgeIdIdx = routes.indexOf("app.get('/api/v1/flows/:id'");
893 assert.ok(bridgeIdIdx > 0, 'bridge GET /api/v1/flows/:id registered');
894 assert.ok(routes.indexOf("app.get('/api/v1/flows/candidates'") < bridgeIdIdx);
895 // requireApproved must be pinned true on the bridge route (CHA-C8).
896 assert.match(routes, /requireApproved:\s*true/);
897 });
898
899 it('(d) promote + list payload carries no secrets', async (t) => {
900 const rows = new Map();
901 rows.set(
902 'prop-sec-clean',
903 await seedPromoteRow(dataDir, starterDir, 'cand_sec03', { proposalId: 'prop-sec-clean' }),
904 );
905 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
906 t.after(close);
907
908 const applied = await applyApprovedCaptureProposalFromCanister({
909 dataDir,
910 canisterUrl,
911 headers: { 'X-Gateway-Auth': 'test-canister-shared-key' },
912 proposalId: 'prop-sec-clean',
913 });
914 assert.equal(applied.ok, true);
915 const applyBlob = JSON.stringify(applied.payload);
916 assert.doesNotMatch(applyBlob, /password|refresh_token|BEGIN PRIVATE|gateway-auth|shared-key/i);
917
918 const list = handleFlowListRequest({ dataDir, vaultId: 'default', visibleScopes: visible });
919 assert.equal(list.ok, true);
920 const listBlob = JSON.stringify(list.payload);
921 assert.doesNotMatch(listBlob, /password|refresh_token|BEGIN PRIVATE|gateway-auth|shared-key/i);
922 });
923 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago