path-list-security.test.mjs
283 lines 9.6 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * Tier 7 — SECURITY: no token leak, 404 not-leak, write gate, T5 refuse (KN-WORK-PATH-LIST-b).
3 *
4 * Security tier MUST fail against a pre-fix stub that (a) 403s out-of-scope get while 404 for
5 * missing, or (b) writes the store when PATH_WRITES_ENABLED is unset, or (c) includes a Bearer
6 * in the list JSON.
7 *
8 * @see docs/KN-WORK-PATH-LIST-FREEZE.md §7
9 */
10 import { describe, it, beforeEach, afterEach } from 'node:test';
11 import assert from 'node:assert/strict';
12 import fs from 'node:fs';
13 import path from 'node:path';
14 import { fileURLToPath } from 'node:url';
15 import { getRepoRoot } from '../lib/repo-root.mjs';
16 import { upsertLearningPath, listLearningPaths } from '../lib/path/path-store.mjs';
17 import { handlePathGetRequest, handlePathListRequest } from '../lib/path/path-handlers.mjs';
18 import { handlePathProposeRequest, applyApprovedPathProposal } from '../lib/path/path-write.mjs';
19 import { validateNotePath } from '../lib/path/path-store.mjs';
20 import {
21 ADMITTED_TASK_PROPOSAL_KINDS,
22 ADMITTED_FLOW_PROPOSAL_KINDS,
23 ADMITTED_MEDIA_PROPOSAL_KINDS,
24 personalSelfApplyRefusalReason,
25 } from '../lib/hub-proposal-personal-self-apply.mjs';
26 import { isPathProposalForHostedApply } from '../lib/path/path-hosted-proposal.mjs';
27 import { maybeApplyHostedPathAfterApprove } from '../hub/gateway/path-approve-hosted.mjs';
28
29 const __dirname = path.dirname(fileURLToPath(import.meta.url));
30 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-path-list-security');
31
32 function samplePath(overrides = {}) {
33 return {
34 schema: 'knowtation.learning_path/v0',
35 path_id: 'path_sec0000000000001',
36 scope: 'org',
37 status: 'active',
38 title: 'Org path',
39 summary: 'Secret org summary',
40 goal: 'Org goal',
41 steps: [{ title: 'S', objective: 'O', source_document_ids: [] }],
42 current_step_index: 0,
43 step_count: 1,
44 next_step_title: 'S',
45 active_decisions: '',
46 workspace_id: 'ws-org',
47 note_path: null,
48 created: '2026-08-18T00:00:00Z',
49 updated: '2026-08-18T00:00:00Z',
50 ...overrides,
51 };
52 }
53
54 /**
55 * Pre-fix stubs the security suite must reject.
56 */
57 function preFixGetStub(exists, inScope) {
58 if (!exists) return { status: 404, code: 'PATH_NOT_FOUND' };
59 if (!inScope) return { status: 403, code: 'FORBIDDEN' };
60 return { status: 200, code: 'OK' };
61 }
62
63 function preFixProposeWhenUnset() {
64 return { wroteStore: true, createdCanister: true };
65 }
66
67 function preFixListJson() {
68 return JSON.stringify({ paths: [], authorization: 'Bearer leaked-token' });
69 }
70
71 beforeEach(() => {
72 fs.rmSync(tmpRoot, { recursive: true, force: true });
73 fs.mkdirSync(tmpRoot, { recursive: true });
74 delete process.env.PATH_WRITES_ENABLED;
75 });
76
77 afterEach(() => {
78 fs.rmSync(tmpRoot, { recursive: true, force: true });
79 delete process.env.PATH_WRITES_ENABLED;
80 });
81
82 describe('path-list security — pre-fix stubs fail the contract', () => {
83 it('(a) stub 403s out-of-scope while 404ing missing', () => {
84 assert.equal(preFixGetStub(false, true).status, 404);
85 assert.equal(preFixGetStub(true, false).status, 403);
86 assert.notEqual(preFixGetStub(true, false).status, preFixGetStub(false, true).status);
87 });
88
89 it('(b) stub writes the store when PATH_WRITES_ENABLED is unset', () => {
90 assert.equal(preFixProposeWhenUnset().wroteStore, true);
91 assert.equal(preFixProposeWhenUnset().createdCanister, true);
92 });
93
94 it('(c) stub includes a Bearer in the list JSON', () => {
95 assert.match(preFixListJson(), /Bearer/);
96 });
97 });
98
99 describe('path-list security — real implementation', () => {
100 it('get unknown vs out-of-scope both 404 PATH_NOT_FOUND', () => {
101 const dataDir = path.join(tmpRoot, 'noleak');
102 fs.mkdirSync(dataDir, { recursive: true });
103 upsertLearningPath(dataDir, 'v', samplePath());
104 const missing = handlePathGetRequest({
105 dataDir,
106 vaultId: 'v',
107 pathId: 'path_doesnotexist0001',
108 cliScopes: ['personal'],
109 });
110 const hidden = handlePathGetRequest({
111 dataDir,
112 vaultId: 'v',
113 pathId: 'path_sec0000000000001',
114 cliScopes: ['personal'],
115 });
116 assert.equal(missing.status, 404);
117 assert.equal(hidden.status, 404);
118 assert.equal(missing.code, 'PATH_NOT_FOUND');
119 assert.equal(hidden.code, 'PATH_NOT_FOUND');
120 });
121
122 it('create with foreign path_id rejected', async () => {
123 process.env.PATH_WRITES_ENABLED = '1';
124 const dataDir = path.join(tmpRoot, 'foreign');
125 fs.mkdirSync(dataDir, { recursive: true });
126 const result = await handlePathProposeRequest({
127 dataDir,
128 vaultId: 'v',
129 cliScopes: ['personal'],
130 body: {
131 path_id: 'path_foreignowned0001',
132 title: 'T',
133 summary: 'S',
134 goal: 'G',
135 steps: [{ title: 'S', objective: 'O', source_document_ids: [] }],
136 },
137 createProposal: async () => {
138 throw new Error('must not create');
139 },
140 });
141 assert.equal(result.code, 'PATH_ID_NOT_ALLOWED');
142 });
143
144 it('note_path traversal 400', () => {
145 const bad = validateNotePath('../etc/passwd.md');
146 assert.equal(bad.ok, false);
147 assert.equal(bad.code, 'PATH_NOTE_PATH_INVALID');
148 });
149
150 it('write gate off → no store write, no canister create, apply-approved also 403', async () => {
151 const dataDir = path.join(tmpRoot, 'gate');
152 fs.mkdirSync(dataDir, { recursive: true });
153 let created = false;
154 const proposed = await handlePathProposeRequest({
155 dataDir,
156 vaultId: 'v',
157 cliScopes: ['personal'],
158 body: {
159 title: 'T',
160 summary: 'S',
161 goal: 'G',
162 steps: [{ title: 'S', objective: 'O', source_document_ids: [] }],
163 },
164 createProposal: async () => {
165 created = true;
166 return { proposal_id: 'should-not' };
167 },
168 });
169 assert.equal(proposed.code, 'PATH_WRITES_DISABLED');
170 assert.equal(created, false);
171 assert.equal(fs.existsSync(path.join(dataDir, 'hub_flow_store.json')), false);
172 assert.equal(fs.existsSync(path.join(dataDir, 'hub_proposals.json')), false);
173
174 const apply = applyApprovedPathProposal(dataDir, {
175 vault_id: 'v',
176 body: JSON.stringify({
177 proposal_kind: 'path_create',
178 path: samplePath({ path_id: 'path_shouldnotwrite01', scope: 'personal' }),
179 }),
180 });
181 assert.equal(apply.ok, false);
182 assert.equal(apply.status, 403);
183 assert.equal(apply.code, 'PATH_WRITES_DISABLED');
184 });
185
186 it('no token/Bearer in JSON of list/get', () => {
187 const dataDir = path.join(tmpRoot, 'json');
188 fs.mkdirSync(dataDir, { recursive: true });
189 upsertLearningPath(
190 dataDir,
191 'v',
192 samplePath({
193 path_id: 'path_personal00000001',
194 scope: 'personal',
195 title: 'Must not leak credentials',
196 }),
197 );
198 const listed = handlePathListRequest({ dataDir, vaultId: 'v', cliScopes: ['personal'] });
199 const dumped = JSON.stringify(listed.payload);
200 assert.equal(/Bearer/i.test(dumped), false);
201 assert.equal(/authorization/i.test(dumped), false);
202 assert.equal(/refresh/i.test(dumped), false);
203 });
204
205 it('path kinds are not in T5 admit lists; fingerprint is SELF_APPLY_NOT_ADMITTED', () => {
206 assert.equal(ADMITTED_TASK_PROPOSAL_KINDS.includes('path_create'), false);
207 assert.equal(ADMITTED_TASK_PROPOSAL_KINDS.includes('path_update'), false);
208 assert.equal(ADMITTED_TASK_PROPOSAL_KINDS.includes('path_archive'), false);
209 assert.equal(ADMITTED_FLOW_PROPOSAL_KINDS.includes('path_create'), false);
210 assert.equal(ADMITTED_MEDIA_PROPOSAL_KINDS.includes('path_create'), false);
211
212 const reason = personalSelfApplyRefusalReason({
213 proposal: {
214 source: 'learning_path',
215 review_queue: 'learning-path',
216 status: 'proposed',
217 path: 'meta/learning-paths/proposals/prop-1.json',
218 intent: 'scooling.review_tray.approve',
219 external_ref: 'scooling.path:abc',
220 body: JSON.stringify({ proposal_kind: 'path_create', path: { scope: 'personal' } }),
221 },
222 hasVaultWrite: true,
223 partitionOwned: true,
224 role: 'editor',
225 authorActorId: 'user-1',
226 approverActorId: 'user-1',
227 sessionBound: true,
228 });
229 assert.equal(reason, 'SELF_APPLY_NOT_ADMITTED');
230 });
231
232 it('external_ref malformed 400 PATH_EXTERNAL_REF_INVALID', async () => {
233 process.env.PATH_WRITES_ENABLED = '1';
234 const dataDir = path.join(tmpRoot, 'ext');
235 fs.mkdirSync(dataDir, { recursive: true });
236 const result = await handlePathProposeRequest({
237 dataDir,
238 vaultId: 'v',
239 cliScopes: ['personal'],
240 body: {
241 title: 'T',
242 summary: 'S',
243 goal: 'G',
244 steps: [{ title: 'S', objective: 'O', source_document_ids: [] }],
245 external_ref: 'scooling.task:not-a-path',
246 },
247 createProposal: async () => ({ proposal_id: 'nope' }),
248 });
249 assert.equal(result.code, 'PATH_EXTERNAL_REF_INVALID');
250 });
251
252 it('no writeNote from path modules', () => {
253 const dir = path.join(getRepoRoot(), 'lib/path');
254 for (const name of fs.readdirSync(dir)) {
255 const src = fs.readFileSync(path.join(dir, name), 'utf8');
256 assert.equal(src.includes('writeNote'), false, `${name} must not reference writeNote`);
257 }
258 });
259
260 it('hook returns null for task/capture/media proposals', async () => {
261 assert.equal(
262 isPathProposalForHostedApply({
263 source: 'task',
264 review_queue: 'task-writes',
265 body: '{}',
266 }),
267 false,
268 );
269 const skipped = await maybeApplyHostedPathAfterApprove({
270 method: 'POST',
271 pathOnly: '/api/v1/notes',
272 upstreamStatus: 200,
273 canisterUrl: 'http://c',
274 bridgeUrl: 'http://b',
275 authorization: undefined,
276 vaultId: 'v',
277 effectiveUserId: 'u',
278 actorUserId: 'u',
279 canisterAuthHeaders: () => ({}),
280 });
281 assert.equal(skipped, null);
282 });
283 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago