flow-write-live-kn-b.test.mjs
484 lines 16.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * FLOW-WRITE-LIVE-KN-b — seven-tier coverage (§FWL.7 Knowtation matrix).
3 * Frozen: ~/scooling/docs/FLOW-WRITE-LIVE-FREEZE.md (§FWL.4 / §FWL.7)
4 *
5 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
6 */
7
8 import { describe, it, beforeEach, afterEach } from 'node:test';
9 import assert from 'node:assert/strict';
10 import fs from 'node:fs';
11 import path from 'node:path';
12 import { performance } from 'node:perf_hooks';
13 import { fileURLToPath } from 'node:url';
14 import {
15 personalSelfApplyRefusalReason,
16 isPersonalSelfApplyClass,
17 matchesScoolingFlowFingerprint,
18 isAdmittedSeamSelfApplyFingerprint,
19 applyPersonalSelfApplyEvaluationE1,
20 SCOOLING_FLOW_EXTERNAL_REF_RE,
21 SCOOLING_FLOW_MIRROR_PATH_RE,
22 ADMITTED_FLOW_PROPOSAL_KINDS,
23 } from '../lib/hub-proposal-personal-self-apply.mjs';
24 import {
25 resolveOptionalScoolingExternalRef,
26 readProposeExternalRefRaw,
27 } from '../lib/scooling-external-ref.mjs';
28 import {
29 FLOW_PROPOSAL_SOURCE,
30 handleFlowProposeRequest,
31 } from '../lib/flow/flow-authoring.mjs';
32 import { FLOW_CAPTURE_PROPOSAL_SOURCE } from '../lib/flow/flow-capture.mjs';
33 import { DELEGATION_PROPOSAL_SOURCE } from '../lib/agent/delegation.mjs';
34 import { createProposal, getProposal } from '../hub/proposals-store.mjs';
35 import { stripClientEvaluationFields } from '../lib/hub-proposal-create-augment.mjs';
36 import { makeFlowBundle } from './fixtures/flow/authoring-helpers.mjs';
37
38 const __dirname = path.dirname(fileURLToPath(import.meta.url));
39 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-write-live-kn-b');
40
41 const ACTOR = 'google:learner-a';
42 const OTHER = 'google:learner-b';
43
44 /**
45 * @param {Record<string, unknown>} [overrides]
46 */
47 function flowProposal(overrides = {}) {
48 const kind = overrides.kind || 'new';
49 const flowId = overrides.flowId || 'flow_fwl_1';
50 const scope = overrides.scope || 'personal';
51 const mirror =
52 overrides.path ||
53 `meta/flows/${String(flowId).replace(/^flow_/, '').replace(/_/g, '-')}.md`;
54 const bodyObj = overrides.bodyObj || {
55 flow: {
56 schema: 'knowtation.flow/v0',
57 flow_id: flowId,
58 title: 'FWL',
59 version: '1.0.0',
60 scope,
61 summary: 'fwl',
62 tags: [],
63 steps: [],
64 inputs: [],
65 vault_mirror_path: mirror,
66 },
67 steps: [],
68 };
69 const hasMeta = Object.prototype.hasOwnProperty.call(overrides, 'flow_meta');
70 const flowMeta = hasMeta
71 ? overrides.flow_meta
72 : { kind, base_version: null, base_state_id: 'flowst1_absent' };
73 return {
74 proposal_id: overrides.proposal_id || 'prop-flow-1',
75 status: 'proposed',
76 source: FLOW_PROPOSAL_SOURCE,
77 path: mirror,
78 external_ref: overrides.external_ref ?? 'scooling.flow:fixture-001',
79 body: JSON.stringify(bodyObj),
80 frontmatter: {
81 type: 'flow',
82 flow_id: flowId,
83 flow_version: '1.0.0',
84 scope,
85 ...(overrides.frontmatter || {}),
86 },
87 ...(flowMeta !== undefined ? { flow_meta: flowMeta } : {}),
88 ...Object.fromEntries(
89 Object.entries(overrides).filter(
90 ([k]) =>
91 ![
92 'kind',
93 'flowId',
94 'scope',
95 'bodyObj',
96 'path',
97 'external_ref',
98 'flow_meta',
99 'frontmatter',
100 'proposal_id',
101 ].includes(k),
102 ),
103 ),
104 };
105 }
106
107 /**
108 * @param {Record<string, unknown>} proposal
109 * @param {Record<string, unknown>} [extra]
110 */
111 function eligible(proposal, extra = {}) {
112 return {
113 proposal,
114 hasVaultWrite: true,
115 partitionOwned: true,
116 role: 'member',
117 humanActor: true,
118 tokenType: null,
119 actorKind: 'human',
120 sessionBound: true,
121 authorActorId: ACTOR,
122 approverActorId: ACTOR,
123 ...extra,
124 };
125 }
126
127 describe('FLOW-WRITE-LIVE-KN-b unit — §FWL.4.1 Flow fingerprint', () => {
128 it('admits new/edit/import + scooling.flow: + personal meta/flows', async () => {
129 for (const kind of ADMITTED_FLOW_PROPOSAL_KINDS) {
130 const p = flowProposal({ kind, external_ref: `scooling.flow:${kind}-ok` });
131 assert.equal(matchesScoolingFlowFingerprint(p), true, kind);
132 assert.equal(personalSelfApplyRefusalReason(eligible(p)), null, kind);
133 }
134 assert.equal(SCOOLING_FLOW_MIRROR_PATH_RE.test('meta/flows/capture-to-note.md'), true);
135 assert.equal(SCOOLING_FLOW_EXTERNAL_REF_RE.test('scooling.flow:ok'), true);
136 });
137
138 it('refuses capture / delegation / wrong ref / empty author / project scope / missing kind', async () => {
139 assert.equal(
140 personalSelfApplyRefusalReason(
141 eligible({ status: 'proposed', source: FLOW_CAPTURE_PROPOSAL_SOURCE }),
142 ),
143 'SELF_APPLY_NOT_ADMITTED',
144 );
145 assert.equal(
146 personalSelfApplyRefusalReason(
147 eligible({ status: 'proposed', source: DELEGATION_PROPOSAL_SOURCE }),
148 ),
149 'SELF_APPLY_DELEGATION_REFUSED',
150 );
151 assert.equal(
152 personalSelfApplyRefusalReason(
153 eligible(flowProposal({ external_ref: 'scooling.task:wrong' })),
154 ),
155 'SELF_APPLY_NOT_ADMITTED',
156 );
157 assert.equal(
158 personalSelfApplyRefusalReason(eligible(flowProposal(), { authorActorId: '' })),
159 'SELF_APPLY_AUTHOR_UNVERIFIED',
160 );
161 assert.equal(
162 personalSelfApplyRefusalReason(eligible(flowProposal({ scope: 'project' }))),
163 'SELF_APPLY_NOT_ADMITTED',
164 );
165 assert.equal(
166 personalSelfApplyRefusalReason(eligible(flowProposal({ scope: 'org' }))),
167 'SELF_APPLY_NOT_ADMITTED',
168 );
169 // Missing flow_meta / empty kind — never default to new at admission.
170 assert.equal(matchesScoolingFlowFingerprint(flowProposal({ flow_meta: undefined })), false);
171 assert.equal(
172 matchesScoolingFlowFingerprint(flowProposal({ flow_meta: { kind: '', base_version: null } })),
173 false,
174 );
175 assert.equal(
176 matchesScoolingFlowFingerprint(
177 flowProposal({ flow_meta: { kind: 'draft', base_version: null } }),
178 ),
179 false,
180 );
181 assert.equal(
182 matchesScoolingFlowFingerprint(
183 flowProposal({ path: 'notes/flows/x.md', external_ref: 'scooling.flow:ok' }),
184 ),
185 false,
186 );
187 });
188
189 it('external_ref helpers validate Flow regex; malformed refuse', async () => {
190 const bad = resolveOptionalScoolingExternalRef('muse:ref', SCOOLING_FLOW_EXTERNAL_REF_RE);
191 assert.equal(bad.ok, false);
192 assert.equal(bad.status, 400);
193 assert.equal(bad.code, 'EXTERNAL_REF_INVALID');
194 const ok = resolveOptionalScoolingExternalRef('scooling.flow:a', SCOOLING_FLOW_EXTERNAL_REF_RE);
195 assert.equal(ok.ok, true);
196 assert.equal(ok.externalRef, 'scooling.flow:a');
197 assert.equal(
198 readProposeExternalRefRaw({ body: { external_ref: 'scooling.flow:b' } }),
199 'scooling.flow:b',
200 );
201 });
202 });
203
204 describe('FLOW-WRITE-LIVE-KN-b integration — propose persist + approve class', () => {
205 const dataDir = path.join(tmpRoot, 'integ');
206 const visible = new Set(['personal', 'project', 'org']);
207 beforeEach(() => {
208 fs.rmSync(tmpRoot, { recursive: true, force: true });
209 fs.mkdirSync(dataDir, { recursive: true });
210 process.env.FLOW_AUTHORING_WRITES = '1';
211 });
212 afterEach(() => {
213 delete process.env.FLOW_AUTHORING_WRITES;
214 fs.rmSync(tmpRoot, { recursive: true, force: true });
215 });
216
217 it('persists scooling.flow: on new propose; session-bound class holds', async () => {
218 const bundle = makeFlowBundle({ flowId: 'flow_fwl_persist' });
219 const proposed = await handleFlowProposeRequest({
220 dataDir,
221 vaultId: 'default',
222 userId: ACTOR,
223 visibleScopes: visible,
224 kind: 'new',
225 flow: bundle.flow,
226 steps: bundle.steps,
227 intent: 'draft personal flow',
228 externalRef: 'scooling.flow:persist-1',
229 sessionBound: true,
230 createProposal,
231 });
232 assert.equal(proposed.ok, true);
233 const row = getProposal(dataDir, proposed.payload.proposal_id);
234 assert.equal(row.external_ref, 'scooling.flow:persist-1');
235 assert.equal(row.source, FLOW_PROPOSAL_SOURCE);
236 assert.equal(row.flow_meta.kind, 'new');
237 assert.match(row.path, /^meta\/flows\/.+\.md$/);
238 assert.equal(personalSelfApplyRefusalReason(eligible(row, { role: 'editor' })), null);
239 });
240
241 it('absent external_ref proposes ok but not admitted; malformed → 400', async () => {
242 const bundle = makeFlowBundle({ flowId: 'flow_fwl_noref' });
243 const noRef = await handleFlowProposeRequest({
244 dataDir,
245 vaultId: 'default',
246 userId: ACTOR,
247 visibleScopes: visible,
248 kind: 'new',
249 flow: bundle.flow,
250 steps: bundle.steps,
251 intent: 'no ref',
252 sessionBound: true,
253 createProposal,
254 });
255 assert.equal(noRef.ok, true);
256 const row = getProposal(dataDir, noRef.payload.proposal_id);
257 assert.equal(personalSelfApplyRefusalReason(eligible(row)), 'SELF_APPLY_NOT_ADMITTED');
258
259 const badBundle = makeFlowBundle({ flowId: 'flow_fwl_badref' });
260 const badNew = await handleFlowProposeRequest({
261 dataDir,
262 vaultId: 'default',
263 userId: ACTOR,
264 visibleScopes: visible,
265 kind: 'new',
266 flow: badBundle.flow,
267 steps: badBundle.steps,
268 intent: 'bad ref',
269 externalRef: 'not-a-scooling-flow-ref',
270 createProposal,
271 });
272 assert.equal(badNew.ok, false);
273 assert.equal(badNew.status, 400);
274 assert.equal(badNew.code, 'EXTERNAL_REF_INVALID');
275 });
276
277 it('import kind persists scooling.flow:; lineage muse ref refused', async () => {
278 const bundle = makeFlowBundle({ flowId: 'flow_fwl_import' });
279 const imported = await handleFlowProposeRequest({
280 dataDir,
281 vaultId: 'default',
282 userId: ACTOR,
283 visibleScopes: visible,
284 kind: 'import',
285 bundle: { flow: bundle.flow, steps: bundle.steps },
286 intent: 'import',
287 externalRef: 'scooling.flow:import-1',
288 sourceVaultHint: 'partner',
289 sessionBound: true,
290 createProposal,
291 });
292 assert.equal(imported.ok, true);
293 const row = getProposal(dataDir, imported.payload.proposal_id);
294 assert.equal(row.flow_meta.kind, 'import');
295 assert.equal(row.external_ref, 'scooling.flow:import-1');
296 assert.equal(isAdmittedSeamSelfApplyFingerprint(row, ACTOR), true);
297
298 const lineageBundle = makeFlowBundle({ flowId: 'flow_fwl_import_lineage' });
299 const lineageOnly = await handleFlowProposeRequest({
300 dataDir,
301 vaultId: 'default',
302 userId: ACTOR,
303 visibleScopes: visible,
304 kind: 'import',
305 bundle: { flow: lineageBundle.flow, steps: lineageBundle.steps },
306 intent: 'import lineage',
307 externalRef: 'muse:ref-123',
308 createProposal,
309 });
310 assert.equal(lineageOnly.ok, false);
311 assert.equal(lineageOnly.code, 'EXTERNAL_REF_INVALID');
312 });
313 });
314
315 describe('FLOW-WRITE-LIVE-KN-b e2e — personal draft without Hub eval hop', () => {
316 const dataDir = path.join(tmpRoot, 'e2e');
317 const visible = new Set(['personal']);
318 beforeEach(() => {
319 fs.rmSync(tmpRoot, { recursive: true, force: true });
320 fs.mkdirSync(dataDir, { recursive: true });
321 process.env.FLOW_AUTHORING_WRITES = '1';
322 });
323 afterEach(() => {
324 delete process.env.FLOW_AUTHORING_WRITES;
325 fs.rmSync(tmpRoot, { recursive: true, force: true });
326 });
327
328 it('create+E1+class holds for session-bound personal Flow new', async () => {
329 const bundle = makeFlowBundle({ flowId: 'flow_fwl_e2e' });
330 const proposed = await handleFlowProposeRequest({
331 dataDir,
332 vaultId: 'default',
333 userId: ACTOR,
334 visibleScopes: visible,
335 kind: 'new',
336 flow: bundle.flow,
337 steps: bundle.steps,
338 intent: 'e2e draft',
339 externalRef: 'scooling.flow:e2e-1',
340 sessionBound: true,
341 createProposal,
342 });
343 assert.equal(proposed.ok, true);
344 const row = getProposal(dataDir, proposed.payload.proposal_id);
345 assert.equal(row.evaluation_status, 'passed');
346 assert.equal(row.evaluated_by, ACTOR);
347 assert.equal(isPersonalSelfApplyClass(eligible(row, { role: 'editor' })), true);
348 });
349 });
350
351 describe('FLOW-WRITE-LIVE-KN-b stress — N cycles no cross-user leakage', () => {
352 it('distinct users never share Flow admission', async () => {
353 for (let i = 0; i < 100; i++) {
354 const a = flowProposal({
355 proposal_id: `prop-a-${i}`,
356 external_ref: `scooling.flow:a-${i}`,
357 flowId: `flow_a_${i}`,
358 });
359 const b = flowProposal({
360 proposal_id: `prop-b-${i}`,
361 external_ref: `scooling.flow:b-${i}`,
362 flowId: `flow_b_${i}`,
363 });
364 assert.equal(
365 personalSelfApplyRefusalReason(eligible(a, { authorActorId: ACTOR, approverActorId: ACTOR })),
366 null,
367 );
368 assert.equal(
369 personalSelfApplyRefusalReason(eligible(b, { authorActorId: ACTOR, approverActorId: OTHER })),
370 'SELF_APPLY_AUTHOR_MISMATCH',
371 );
372 }
373 });
374 });
375
376 describe('FLOW-WRITE-LIVE-KN-b data-integrity — index fields + server audit', () => {
377 it('client evaluation_status stripped; E1 sets server audit on Flow fingerprint', async () => {
378 const stripped = stripClientEvaluationFields({
379 evaluation_status: 'passed',
380 evaluated_by: 'forged',
381 evaluated_at: '2099-01-01T00:00:00.000Z',
382 path: 'meta/flows/fwl-di.md',
383 source: FLOW_PROPOSAL_SOURCE,
384 external_ref: 'scooling.flow:di-1',
385 body: JSON.stringify({
386 flow: { flow_id: 'flow_fwl_di', scope: 'personal', version: '1.0.0' },
387 steps: [],
388 }),
389 frontmatter: { type: 'flow', flow_id: 'flow_fwl_di', scope: 'personal' },
390 flow_meta: { kind: 'new', base_version: null, base_state_id: 'x' },
391 });
392 assert.equal(stripped.evaluation_status, undefined);
393 const e1 = applyPersonalSelfApplyEvaluationE1(stripped, {
394 evaluatedBy: ACTOR,
395 sessionBound: true,
396 authorActorId: ACTOR,
397 evaluatedAt: '2026-07-28T00:00:00.000Z',
398 });
399 assert.equal(e1.evaluation_status, 'passed');
400 assert.equal(e1.evaluated_by, ACTOR);
401 assert.equal(e1.evaluated_at, '2026-07-28T00:00:00.000Z');
402 assert.equal(e1.external_ref, 'scooling.flow:di-1');
403 assert.equal(e1.flow_meta.kind, 'new');
404 });
405 });
406
407 describe('FLOW-WRITE-LIVE-KN-b performance — Flow admission bounded like Tasks class', () => {
408 it('1000 Flow fingerprint + E1 stamps stay under budget', async () => {
409 const body = {
410 path: 'meta/flows/perf.md',
411 source: FLOW_PROPOSAL_SOURCE,
412 external_ref: 'scooling.flow:perf',
413 body: JSON.stringify({
414 flow: { flow_id: 'flow_perf', scope: 'personal', version: '1.0.0' },
415 steps: [],
416 }),
417 frontmatter: { type: 'flow', scope: 'personal' },
418 flow_meta: { kind: 'new', base_version: null, base_state_id: 'x' },
419 };
420 const t0 = performance.now();
421 for (let i = 0; i < 1000; i++) {
422 assert.equal(matchesScoolingFlowFingerprint(body), true);
423 applyPersonalSelfApplyEvaluationE1(body, {
424 evaluatedBy: ACTOR,
425 sessionBound: true,
426 authorActorId: ACTOR,
427 });
428 }
429 const ms = performance.now() - t0;
430 assert.ok(ms < 500, `Flow E1 1000 cycles took ${ms}ms`);
431 });
432 });
433
434 describe('FLOW-WRITE-LIVE-KN-b security — scope / injection / P4 / secrets', () => {
435 it('scope widen refuse; injection inert; P4 delegation refused; no secrets in fingerprint', async () => {
436 assert.equal(
437 personalSelfApplyRefusalReason(eligible(flowProposal(), { partitionOwned: false })),
438 'NOT_PARTITION_OWNED',
439 );
440 assert.equal(
441 personalSelfApplyRefusalReason(eligible(flowProposal(), { sessionBound: false })),
442 'SELF_APPLY_SESSION_BINDING_REQUIRED',
443 );
444 assert.equal(
445 personalSelfApplyRefusalReason(
446 eligible({ status: 'proposed', source: DELEGATION_PROPOSAL_SOURCE }),
447 ),
448 'SELF_APPLY_DELEGATION_REFUSED',
449 );
450 const injected = flowProposal({
451 external_ref: 'scooling.flow:ok"; DROP TABLE--',
452 });
453 assert.equal(matchesScoolingFlowFingerprint(injected), false);
454 assert.equal(
455 personalSelfApplyRefusalReason(eligible(injected)),
456 'SELF_APPLY_NOT_ADMITTED',
457 );
458 const withSecretAttempt = flowProposal({
459 bodyObj: {
460 flow: {
461 flow_id: 'flow_sec',
462 scope: 'personal',
463 version: '1.0.0',
464 api_key: 'sk-secret-should-not-affect-admission',
465 },
466 steps: [],
467 },
468 });
469 assert.equal(matchesScoolingFlowFingerprint(withSecretAttempt), true);
470 assert.equal(
471 JSON.stringify(withSecretAttempt).includes('sk-secret'),
472 true,
473 'fixture may carry inert body fields',
474 );
475 // Admission uses path/source/kind/ref/scope only — secret body field does not elevate.
476 assert.equal(personalSelfApplyRefusalReason(eligible(withSecretAttempt)), null);
477 assert.equal(
478 personalSelfApplyRefusalReason(
479 eligible(flowProposal({ review_severity: 'elevated' })),
480 ),
481 'ELEVATED_OR_AUTO_FLAGGED',
482 );
483 });
484 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago