flow-authoring-unit.test.mjs
214 lines 7.6 KB
Raw
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 16 hours ago
1 /**
2 * Tier 1 — UNIT: Flow authoring tokens, gating, derivation, envelope shape.
3 *
4 * @see lib/flow/flow-authoring.mjs
5 * @see docs/FLOW-AUTHORING-WRITEBACK-CONTRACT-7A-L1.md §4, §6, §7
6 */
7 import { describe, it, beforeEach, afterEach } from 'node:test';
8 import assert from 'node:assert/strict';
9 import fs from 'node:fs';
10 import path from 'node:path';
11 import { fileURLToPath } from 'node:url';
12 import {
13 flowStateId,
14 absentFlowStateId,
15 deriveAutoApprovable,
16 getFlowAuthoringWritesEnabled,
17 getFlowAuthoringForbidden,
18 handleFlowProposeRequest,
19 FLOW_STATE_ID_PREFIX,
20 FLOW_PROPOSAL_SCHEMA,
21 } from '../lib/flow/flow-authoring.mjs';
22 import { createProposal } from '../hub/proposals-store.mjs';
23 import { getRepoRoot } from '../lib/repo-root.mjs';
24
25 const __dirname = path.dirname(fileURLToPath(import.meta.url));
26 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-flow-authoring-unit');
27 const starterDir = path.join(getRepoRoot(), 'flows/starter');
28
29 function loadStarter(name) {
30 return JSON.parse(fs.readFileSync(path.join(starterDir, name), 'utf8'));
31 }
32
33 describe('flowStateId — deterministic optimistic-concurrency token', () => {
34 const flow = {
35 schema: 'knowtation.flow/v0',
36 flow_id: 'flow_x',
37 title: 'Title',
38 version: '1.2.0',
39 scope: 'personal',
40 summary: 'summary',
41 tags: ['a', 'b'],
42 steps: ['flow_x#1'],
43 inputs: [],
44 vault_mirror_path: 'meta/flows/x.md',
45 updated: '2026-06-20T00:00:00Z',
46 truncated: false,
47 };
48 const steps = [{ schema: 'knowtation.flow_step/v0', step_id: 'flow_x#1', flow_id: 'flow_x', ordinal: 1 }];
49
50 it('is stable across key order in both flow and steps', () => {
51 const a = flowStateId(flow, steps);
52 const reordered = {
53 truncated: false,
54 updated: '2026-06-20T00:00:00Z',
55 steps: ['flow_x#1'],
56 version: '1.2.0',
57 flow_id: 'flow_x',
58 scope: 'personal',
59 title: 'Title',
60 summary: 'summary',
61 tags: ['a', 'b'],
62 inputs: [],
63 vault_mirror_path: 'meta/flows/x.md',
64 schema: 'knowtation.flow/v0',
65 };
66 const b = flowStateId(reordered, [{ ordinal: 1, flow_id: 'flow_x', step_id: 'flow_x#1', schema: 'knowtation.flow_step/v0' }]);
67 assert.equal(a, b);
68 assert.ok(a.startsWith(FLOW_STATE_ID_PREFIX));
69 assert.equal(a.length, FLOW_STATE_ID_PREFIX.length + 16);
70 });
71
72 it('changes when content changes', () => {
73 const a = flowStateId(flow, steps);
74 const b = flowStateId({ ...flow, summary: 'different' }, steps);
75 assert.notEqual(a, b);
76 });
77
78 it('orders steps by ordinal regardless of input order', () => {
79 const s2 = [
80 { step_id: 'flow_x#2', flow_id: 'flow_x', ordinal: 2 },
81 { step_id: 'flow_x#1', flow_id: 'flow_x', ordinal: 1 },
82 ];
83 const s2rev = [s2[1], s2[0]];
84 assert.equal(flowStateId(flow, s2), flowStateId(flow, s2rev));
85 });
86
87 it('absent sentinel is stable and prefixed', () => {
88 assert.equal(absentFlowStateId(), absentFlowStateId());
89 assert.ok(absentFlowStateId().startsWith(FLOW_STATE_ID_PREFIX));
90 });
91 });
92
93 describe('deriveAutoApprovable — server-derived, human_review ⇒ false', () => {
94 it('false when any step requires human_review', () => {
95 const steps = [
96 { verification: { kind: 'artifact_exists' } },
97 { verification: { kind: 'human_review' } },
98 ];
99 assert.equal(deriveAutoApprovable(steps), false);
100 });
101
102 it('true only when no human_review step exists', () => {
103 const steps = [
104 { verification: { kind: 'artifact_exists' } },
105 { verification: { kind: 'test_pass' } },
106 ];
107 assert.equal(deriveAutoApprovable(steps), true);
108 });
109
110 it('false for empty step list', () => {
111 assert.equal(deriveAutoApprovable([]), false);
112 });
113 });
114
115 describe('gating — FLOW_AUTHORING_WRITES default OFF (tri-state)', () => {
116 const dataDir = path.join(tmpRoot, 'gate');
117 beforeEach(() => {
118 fs.rmSync(tmpRoot, { recursive: true, force: true });
119 fs.mkdirSync(dataDir, { recursive: true });
120 delete process.env.FLOW_AUTHORING_WRITES;
121 delete process.env.FLOW_AUTHORING_FORBIDDEN;
122 });
123 afterEach(() => {
124 fs.rmSync(tmpRoot, { recursive: true, force: true });
125 delete process.env.FLOW_AUTHORING_WRITES;
126 delete process.env.FLOW_AUTHORING_FORBIDDEN;
127 });
128
129 it('defaults to disabled with no env and no policy file', () => {
130 assert.equal(getFlowAuthoringWritesEnabled(dataDir), false);
131 assert.equal(getFlowAuthoringForbidden(dataDir), false);
132 });
133
134 it('env 1/true enables; 0/false disables; precedence over file', () => {
135 fs.writeFileSync(
136 path.join(dataDir, 'hub_flow_authoring_policy.json'),
137 JSON.stringify({ flow_authoring_writes_enabled: true }),
138 'utf8',
139 );
140 assert.equal(getFlowAuthoringWritesEnabled(dataDir), true);
141 process.env.FLOW_AUTHORING_WRITES = '0';
142 assert.equal(getFlowAuthoringWritesEnabled(dataDir), false);
143 process.env.FLOW_AUTHORING_WRITES = '1';
144 assert.equal(getFlowAuthoringWritesEnabled(dataDir), true);
145 });
146
147 it('disabled propose returns FLOW_AUTHORING_DISABLED', () => {
148 const result = handleFlowProposeRequest({
149 dataDir,
150 vaultId: 'default',
151 visibleScopes: new Set(['personal']),
152 kind: 'new',
153 flow: loadStarter('flow_capture_to_note.json').flow,
154 steps: loadStarter('flow_capture_to_note.json').steps,
155 intent: 'x',
156 createProposal,
157 });
158 assert.equal(result.ok, false);
159 assert.equal(result.status, 403);
160 assert.equal(result.code, 'FLOW_AUTHORING_DISABLED');
161 });
162 });
163
164 describe('handler validation + envelope (writes enabled)', () => {
165 const dataDir = path.join(tmpRoot, 'env');
166 const visible = new Set(['personal', 'project', 'org']);
167 beforeEach(() => {
168 fs.rmSync(tmpRoot, { recursive: true, force: true });
169 fs.mkdirSync(dataDir, { recursive: true });
170 process.env.FLOW_AUTHORING_WRITES = '1';
171 });
172 afterEach(() => {
173 fs.rmSync(tmpRoot, { recursive: true, force: true });
174 delete process.env.FLOW_AUTHORING_WRITES;
175 });
176
177 it('rejects an anatomy-incomplete draft with FLOW_DRAFT_INVALID', () => {
178 const bundle = loadStarter('flow_capture_to_note.json');
179 bundle.steps[0].trigger = '';
180 const result = handleFlowProposeRequest({
181 dataDir, vaultId: 'default', visibleScopes: visible, kind: 'new',
182 flow: bundle.flow, steps: bundle.steps, intent: 'x', createProposal,
183 });
184 assert.equal(result.ok, false);
185 assert.equal(result.code, 'FLOW_DRAFT_INVALID');
186 });
187
188 it('requires a non-empty intent', () => {
189 const bundle = loadStarter('flow_capture_to_note.json');
190 const result = handleFlowProposeRequest({
191 dataDir, vaultId: 'default', visibleScopes: visible, kind: 'new',
192 flow: bundle.flow, steps: bundle.steps, intent: ' ', createProposal,
193 });
194 assert.equal(result.ok, false);
195 assert.equal(result.code, 'FLOW_DRAFT_INVALID');
196 });
197
198 it('stamps knowtation.flow_proposal/v0 with pointers only (no body, no secret)', () => {
199 const bundle = loadStarter('flow_capture_to_note.json');
200 const result = handleFlowProposeRequest({
201 dataDir, vaultId: 'default', visibleScopes: visible, kind: 'new',
202 flow: bundle.flow, steps: bundle.steps, intent: 'add it', createProposal,
203 });
204 assert.equal(result.ok, true);
205 assert.equal(result.payload.schema, FLOW_PROPOSAL_SCHEMA);
206 assert.equal(result.payload.base_version, null);
207 assert.equal(result.payload.base_state_id, null);
208 assert.equal(result.payload.status, 'proposed');
209 assert.equal(result.payload.scope, 'personal');
210 assert.equal(typeof result.payload.auto_approvable, 'boolean');
211 const serialized = JSON.stringify(result.payload);
212 assert.ok(!/token|oauth|refresh_token|instruction/i.test(serialized));
213 });
214 });
File History 1 commit
sha256:8915fe406161f95c1681f9469375e7bae5b28c884f00bedbdef65e4b0cd0738d docs(flow): commit FLOW-V0-SPEC.md hygiene for 7A-INT merge Human 16 hours ago