sec-seam-1-session-bound-identity.test.mjs
923 lines 35.5 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago
1 /**
2 * SEC-SEAM-1 — seven-tier coverage for session-bound learner identity on seam writes.
3 *
4 * Frozen: docs/SEC-SEAM-1-SESSION-BOUND-IDENTITY-FREEZE.md (S1–S10, §7).
5 * Tiers: unit · integration · 2b · e2e · 3b · stress · data-integrity · performance · security · 7b
6 */
7
8 import { test, describe, mock } 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 resolveActorTokenClass,
16 isSessionBoundActor,
17 isMcpAccessPayload,
18 } from '../hub/gateway/access-token-authz.mjs';
19 import { parseSelfApplyIneligibleSubs, SELF_APPLY_INELIGIBLE_SUBS } from '../lib/hub-self-apply-ineligible.mjs';
20 import {
21 isSeamSurfaceProposal,
22 isDelegationSurfaceProposal,
23 personalSelfApplyRefusalReason,
24 isPersonalSelfApplyClass,
25 personalSelfApplyAllowsApprove,
26 isHttpVisibleSelfApplySeamCode,
27 matchesScoolingReviewTrayFingerprint,
28 SCOOLING_REVIEW_TRAY_INTENT,
29 SELF_APPLY_HTTP_VISIBLE_SEAM_CODES,
30 } from '../lib/hub-proposal-personal-self-apply.mjs';
31 import {
32 normalizeCanisterProposalForTaskPrecheck,
33 FM_PROPOSAL_SOURCE,
34 FM_TASK_PROPOSAL_KIND,
35 } from '../lib/task/task-hosted-proposal.mjs';
36 import {
37 normalizeCanisterProposalForDelegationPrecheck,
38 isDelegationProposalIntent,
39 } from '../lib/agent/delegation-hosted-proposal.mjs';
40 import { TASK_PROPOSAL_SOURCE } from '../lib/task/task-write.mjs';
41 import { DELEGATION_PROPOSAL_SOURCE } from '../lib/agent/delegation.mjs';
42 import { MEDIA_PROPOSAL_SOURCE } from '../lib/attachments/attachment-write.mjs';
43 import { FLOW_PROPOSAL_SOURCE } from '../lib/flow/flow-authoring.mjs';
44 import { FLOW_CAPTURE_PROPOSAL_SOURCE } from '../lib/flow/flow-capture.mjs';
45
46 const __dirname = path.dirname(fileURLToPath(import.meta.url));
47 const ROOT = path.resolve(__dirname, '..');
48 const SELF_APPLY_SRC = path.join(ROOT, 'lib/hub-proposal-personal-self-apply.mjs');
49 const GATEWAY_SRC = path.join(ROOT, 'hub/gateway/server.mjs');
50 const HUB_SRC = path.join(ROOT, 'hub/server.mjs');
51 const LOCAL_AUTH_SRC = path.join(ROOT, 'hub/lib/local-auth.mjs');
52 const CORS_GW = path.join(ROOT, 'hub/gateway/cors-middleware.mjs');
53 const CORS_BRIDGE = path.join(ROOT, 'hub/bridge/server.mjs');
54 const BRIDGE_DIR = path.join(ROOT, 'hub/bridge');
55 const TASK_ROUTES = path.join(ROOT, 'hub/bridge/task-routes.mjs');
56
57 const ACTOR_A = 'google:learner-a';
58 const ACTOR_B = 'google:learner-b';
59
60 function notesTray(overrides = {}) {
61 return {
62 status: 'proposed',
63 intent: SCOOLING_REVIEW_TRAY_INTENT,
64 external_ref: 'scooling.review:sec-seam-1',
65 path: 'reviewed/sec-seam-1.md',
66 review_severity: 'standard',
67 ...overrides,
68 };
69 }
70
71 function hostedTaskProposal(overrides = {}) {
72 return {
73 status: 'proposed',
74 frontmatter: {
75 [FM_PROPOSAL_SOURCE]: TASK_PROPOSAL_SOURCE,
76 [FM_TASK_PROPOSAL_KIND]: 'task_create',
77 },
78 ...overrides,
79 };
80 }
81
82 function baseEligibleOpts(extra = {}) {
83 return {
84 proposal: notesTray(),
85 hasVaultWrite: true,
86 partitionOwned: true,
87 role: 'member',
88 humanActor: true,
89 tokenType: null,
90 actorKind: 'human',
91 ...extra,
92 };
93 }
94
95 /**
96 * Pre-fix isPersonalSelfApplyClass — branch-for-branch copy of the function as it
97 * existed before SEC-SEAM-1b (lib/hub-proposal-personal-self-apply.mjs:106-124).
98 * Security-tier regression must fail if the fix is reverted.
99 *
100 * @param {{
101 * proposal: Record<string, unknown>|null|undefined,
102 * hasVaultWrite: boolean,
103 * partitionOwned: boolean,
104 * role?: string,
105 * humanActor?: boolean,
106 * tokenType?: string|null,
107 * actorKind?: string|null,
108 * }} opts
109 * @returns {boolean}
110 */
111 function isPersonalSelfApplyClassPreFix(opts) {
112 const { proposal, hasVaultWrite, partitionOwned } = opts;
113 if (!hasVaultWrite || !partitionOwned) return false;
114 if (
115 opts.role != null &&
116 !(function roleEligible(role, actor = {}) {
117 if (actor.humanActor === false) return false;
118 if (String(actor.tokenType || '').trim() === 'mcp_access') return false;
119 if (String(actor.actorKind || '').trim() === 'agent') return false;
120 const r = String(role || '').trim();
121 return r === 'member' || r === 'editor' || r === 'admin';
122 })(opts.role, {
123 humanActor: opts.humanActor,
124 tokenType: opts.tokenType,
125 actorKind: opts.actorKind,
126 })
127 ) {
128 return false;
129 }
130 if (!proposal || typeof proposal !== 'object') return false;
131 if (String(proposal.status ?? 'proposed').trim() !== 'proposed') return false;
132 if (!matchesScoolingReviewTrayFingerprint(proposal)) return false;
133 if (
134 String(proposal.review_severity ?? '').trim() === 'elevated' ||
135 (Array.isArray(proposal.auto_flag_reasons) && proposal.auto_flag_reasons.length > 0)
136 ) {
137 return false;
138 }
139 return true;
140 }
141
142 /** Round-1 seam classifier (N1 defect): keys on frontmatter.proposal_kind only. */
143 function round1SeamByProposalKind(proposal) {
144 if (!proposal || typeof proposal !== 'object') return false;
145 const fm =
146 proposal.frontmatter && typeof proposal.frontmatter === 'object' ? proposal.frontmatter : {};
147 const kind = fm.proposal_kind;
148 const LIST = new Set([
149 'task_create',
150 'task_update',
151 'media_link',
152 'media_attach',
153 'delegation_consent',
154 ]);
155 return typeof kind === 'string' && LIST.has(kind);
156 }
157
158 // ---------------------------------------------------------------------------
159 // Tier 1 — unit
160 // ---------------------------------------------------------------------------
161 describe('SEC-SEAM-1 unit — token class, seam classify, parser, totality', () => {
162 test('resolveActorTokenClass returns each of the four classes', () => {
163 assert.equal(resolveActorTokenClass({ sub: 'a', type: 'session' }), 'session');
164 assert.equal(resolveActorTokenClass({ sub: 'a', type: 'mcp_access', scopes: [] }), 'mcp_access');
165 assert.equal(resolveActorTokenClass({ sub: 'a' }), 'legacy_session');
166 assert.equal(resolveActorTokenClass(null), 'unknown');
167 assert.equal(resolveActorTokenClass(undefined), 'unknown');
168 assert.equal(resolveActorTokenClass('x'), 'unknown');
169 assert.equal(resolveActorTokenClass({ type: 'other' }), 'unknown');
170 assert.equal(resolveActorTokenClass({ type: 'session' }), 'session');
171 });
172
173 test('internal-hop and signServiceJwt-shaped payloads classify legacy_session (G3, G35)', () => {
174 assert.equal(resolveActorTokenClass({ sub: 'gateway:bridge-hop' }), 'legacy_session');
175 assert.equal(resolveActorTokenClass({ sub: 'service:consolidator', role: 'service' }), 'legacy_session');
176 });
177
178 test('isSessionBoundActor true only for session; false for null (V11)', () => {
179 assert.equal(isSessionBoundActor({ sub: 'a', type: 'session' }), true);
180 assert.equal(isSessionBoundActor({ sub: 'a' }), false);
181 assert.equal(isSessionBoundActor({ sub: 'a', type: 'mcp_access', scopes: [] }), false);
182 assert.equal(isSessionBoundActor(null), false);
183 assert.equal(isMcpAccessPayload({ type: 'mcp_access' }), true);
184 });
185
186 test('isSeamSurfaceProposal true for each of seven S3.1 conditions independently', () => {
187 assert.equal(
188 isSeamSurfaceProposal({
189 frontmatter: { knowtation_proposal_source: 'task', task_proposal_kind: 'task_create' },
190 }),
191 true
192 );
193 assert.equal(
194 isSeamSurfaceProposal({
195 intent: 'delegation_consent_create',
196 frontmatter: { knowtation_proposal_source: 'delegation', delegation_record_kind: 'consent' },
197 }),
198 true
199 );
200 assert.equal(isSeamSurfaceProposal({ source: TASK_PROPOSAL_SOURCE }), true);
201 assert.equal(isSeamSurfaceProposal({ source: DELEGATION_PROPOSAL_SOURCE }), true);
202 assert.equal(isSeamSurfaceProposal({ source: MEDIA_PROPOSAL_SOURCE }), true);
203 assert.equal(isSeamSurfaceProposal({ source: FLOW_PROPOSAL_SOURCE }), true);
204 assert.equal(isSeamSurfaceProposal({ source: FLOW_CAPTURE_PROPOSAL_SOURCE }), true);
205 assert.equal(isSeamSurfaceProposal(notesTray()), false);
206 });
207
208 test('isDelegationSurfaceProposal true for conditions 2 and 4 only', () => {
209 assert.equal(
210 isDelegationSurfaceProposal({
211 intent: 'delegation_consent_create',
212 frontmatter: { knowtation_proposal_source: 'delegation', delegation_record_kind: 'consent' },
213 }),
214 true
215 );
216 assert.equal(isDelegationSurfaceProposal({ source: DELEGATION_PROPOSAL_SOURCE }), true);
217 assert.equal(isDelegationSurfaceProposal({ source: TASK_PROPOSAL_SOURCE }), false);
218 assert.equal(isDelegationSurfaceProposal({ source: FLOW_PROPOSAL_SOURCE }), false);
219 assert.equal(isDelegationSurfaceProposal(notesTray()), false);
220 });
221
222 test('predicate throw classifies seam (fail-closed S3.1)', async () => {
223 const modPath = pathToFileURLSafe(SELF_APPLY_SRC);
224 // Patch via a local wrapper: call with a Proxy that throws on source read after object check
225 // isSeamSurfaceProposal catches throws from normalize* — inject a poison frontmatter getter.
226 const poison = {
227 get frontmatter() {
228 throw new Error('poison');
229 },
230 };
231 assert.equal(isSeamSurfaceProposal(poison), true);
232 void modPath;
233 });
234
235 test('personalSelfApplyRefusalReason is total — no input yields undefined', () => {
236 const cases = [
237 {},
238 { hasVaultWrite: false, partitionOwned: false },
239 { hasVaultWrite: true, partitionOwned: true, proposal: null },
240 { hasVaultWrite: true, partitionOwned: true, proposal: notesTray(), role: 'viewer' },
241 baseEligibleOpts(),
242 baseEligibleOpts({ proposal: hostedTaskProposal(), sessionBound: true, authorActorId: ACTOR_A, approverActorId: ACTOR_A }),
243 ];
244 for (const opts of cases) {
245 const reason = personalSelfApplyRefusalReason(/** @type {any} */ (opts));
246 assert.notEqual(reason, undefined);
247 assert.ok(reason === null || typeof reason === 'string');
248 }
249 });
250
251 test('parseSelfApplyIneligibleSubs empty for unset / empty / comma-only (V6)', () => {
252 assert.equal(parseSelfApplyIneligibleSubs(undefined).size, 0);
253 assert.equal(parseSelfApplyIneligibleSubs('').size, 0);
254 assert.equal(parseSelfApplyIneligibleSubs(',, ').size, 0);
255 assert.deepEqual([...parseSelfApplyIneligibleSubs('a, b')], ['a', 'b']);
256 });
257 });
258
259 function pathToFileURLSafe(p) {
260 return p;
261 }
262
263 // ---------------------------------------------------------------------------
264 // Tier 2 — integration
265 // ---------------------------------------------------------------------------
266 describe('SEC-SEAM-1 integration — S6 codes, HTTP visibility, call-site wiring', () => {
267 test('refusal reasons cover each S6 code for its exact trigger', () => {
268 assert.equal(
269 personalSelfApplyRefusalReason(baseEligibleOpts({ hasVaultWrite: false })),
270 'NOT_VAULT_WRITE'
271 );
272 assert.equal(
273 personalSelfApplyRefusalReason(baseEligibleOpts({ partitionOwned: false })),
274 'NOT_PARTITION_OWNED'
275 );
276 assert.equal(
277 personalSelfApplyRefusalReason(baseEligibleOpts({ role: 'viewer' })),
278 'ROLE_NOT_ELIGIBLE'
279 );
280 assert.equal(
281 personalSelfApplyRefusalReason(baseEligibleOpts({ proposal: null })),
282 'PROPOSAL_MISSING'
283 );
284 assert.equal(
285 personalSelfApplyRefusalReason(baseEligibleOpts({ proposal: notesTray({ status: 'approved' }) })),
286 'STATUS_NOT_PROPOSED'
287 );
288 assert.equal(
289 personalSelfApplyRefusalReason(
290 baseEligibleOpts({
291 proposal: { status: 'proposed', source: DELEGATION_PROPOSAL_SOURCE },
292 sessionBound: true,
293 authorActorId: ACTOR_A,
294 approverActorId: ACTOR_A,
295 })
296 ),
297 'SELF_APPLY_DELEGATION_REFUSED'
298 );
299 assert.equal(
300 personalSelfApplyRefusalReason(
301 baseEligibleOpts({
302 proposal: hostedTaskProposal(),
303 sessionBound: false,
304 authorActorId: ACTOR_A,
305 approverActorId: ACTOR_A,
306 })
307 ),
308 'SELF_APPLY_SESSION_BINDING_REQUIRED'
309 );
310 assert.equal(
311 personalSelfApplyRefusalReason(
312 baseEligibleOpts({
313 proposal: hostedTaskProposal(),
314 sessionBound: true,
315 authorActorId: '',
316 approverActorId: ACTOR_A,
317 })
318 ),
319 'SELF_APPLY_AUTHOR_UNVERIFIED'
320 );
321 assert.equal(
322 personalSelfApplyRefusalReason(
323 baseEligibleOpts({
324 proposal: hostedTaskProposal(),
325 sessionBound: true,
326 authorActorId: ACTOR_A,
327 approverActorId: ACTOR_B,
328 })
329 ),
330 'SELF_APPLY_AUTHOR_MISMATCH'
331 );
332 assert.equal(
333 personalSelfApplyRefusalReason(
334 baseEligibleOpts({
335 proposal: hostedTaskProposal(),
336 sessionBound: true,
337 authorActorId: ACTOR_A,
338 approverActorId: ACTOR_A,
339 })
340 ),
341 'SELF_APPLY_NOT_ADMITTED'
342 );
343 assert.equal(
344 personalSelfApplyRefusalReason(baseEligibleOpts({ proposal: { status: 'proposed', intent: 'other' } })),
345 'FINGERPRINT_MISMATCH'
346 );
347 assert.equal(
348 personalSelfApplyRefusalReason(
349 baseEligibleOpts({ proposal: notesTray({ review_severity: 'elevated' }) })
350 ),
351 'ELEVATED_OR_AUTO_FLAGGED'
352 );
353 });
354
355 test('S6.1 precedence: earliest refusal wins when several apply', () => {
356 // vault write fails before seam codes even if seam + unbound
357 assert.equal(
358 personalSelfApplyRefusalReason(
359 baseEligibleOpts({
360 hasVaultWrite: false,
361 proposal: hostedTaskProposal(),
362 sessionBound: false,
363 })
364 ),
365 'NOT_VAULT_WRITE'
366 );
367 // delegation before session binding
368 assert.equal(
369 personalSelfApplyRefusalReason(
370 baseEligibleOpts({
371 proposal: { status: 'proposed', source: DELEGATION_PROPOSAL_SOURCE },
372 sessionBound: false,
373 authorActorId: '',
374 })
375 ),
376 'SELF_APPLY_DELEGATION_REFUSED'
377 );
378 // session before author empty
379 assert.equal(
380 personalSelfApplyRefusalReason(
381 baseEligibleOpts({
382 proposal: hostedTaskProposal(),
383 sessionBound: false,
384 authorActorId: '',
385 approverActorId: ACTOR_A,
386 })
387 ),
388 'SELF_APPLY_SESSION_BINDING_REQUIRED'
389 );
390 });
391
392 test('isPersonalSelfApplyClass / personalSelfApplyAllowsApprove equal reason === null (V5)', () => {
393 const matrix = [
394 baseEligibleOpts(),
395 baseEligibleOpts({ hasVaultWrite: false }),
396 baseEligibleOpts({
397 proposal: hostedTaskProposal(),
398 sessionBound: true,
399 authorActorId: ACTOR_A,
400 approverActorId: ACTOR_A,
401 }),
402 ];
403 for (const opts of matrix) {
404 const reason = personalSelfApplyRefusalReason(opts);
405 assert.equal(isPersonalSelfApplyClass(opts), reason === null);
406 assert.equal(personalSelfApplyAllowsApprove(opts), reason === null);
407 }
408 });
409
410 test('HTTP-visible seam codes vs generic FORBIDDEN wiring (source-read V4/V5/N7)', () => {
411 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
412 const hub = fs.readFileSync(HUB_SRC, 'utf8');
413 for (const src of [gw, hub]) {
414 assert.match(src, /personalSelfApplyRefusalReason\s*\(/);
415 assert.match(src, /isHttpVisibleSelfApplySeamCode/);
416 assert.match(src, /code:\s*['"]FORBIDDEN['"]/);
417 assert.match(src, /authorActorId/);
418 assert.match(src, /approverActorId/);
419 assert.match(src, /sessionBound/);
420 }
421 for (const code of SELF_APPLY_HTTP_VISIBLE_SEAM_CODES) {
422 assert.equal(isHttpVisibleSelfApplySeamCode(code), true);
423 }
424 assert.equal(isHttpVisibleSelfApplySeamCode('SELF_APPLY_SUBJECT_INELIGIBLE'), false);
425 assert.equal(isHttpVisibleSelfApplySeamCode('FINGERPRINT_MISMATCH'), false);
426 assert.equal(isHttpVisibleSelfApplySeamCode('NOT_VAULT_WRITE'), false);
427 });
428
429 test('resolveHostedActorRole returns payload at both mcp_access and main returns (W1)', () => {
430 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
431 const mcpReturn = gw.match(/isMcpAccessPayload\(bearerPayload\)[\s\S]*?return \{ role, mayApproveProposals, isMcpAccess: true, payload: bearerPayload \}/);
432 assert.ok(mcpReturn, 'mcp_access early return must include payload');
433 assert.match(gw, /return \{ role, mayApproveProposals, isMcpAccess: false, payload: bearerPayload \}/);
434 });
435
436 test('S1 stamps issueLocalToken with type session (G36)', () => {
437 const src = fs.readFileSync(LOCAL_AUTH_SRC, 'utf8');
438 const fn = src.slice(src.indexOf('export function issueLocalToken'));
439 const body = fn.slice(0, fn.indexOf('export async function createLocalCredential'));
440 assert.match(body, /type:\s*['"]session['"]/);
441 });
442
443 test('S1 stamps all five learner mint sites; not the internal hop', () => {
444 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
445 const hub = fs.readFileSync(HUB_SRC, 'utf8');
446 assert.match(gw, /function issueToken\(user\)[\s\S]*?type:\s*['"]session['"]/);
447 assert.match(gw, /function issueAccessTokenForSub\(sub\)[\s\S]*?type:\s*['"]session['"]/);
448 assert.match(hub, /function issueToken\(user\)[\s\S]*?type:\s*['"]session['"]/);
449 assert.match(hub, /function issueAccessTokenForSub\(sub\)[\s\S]*?type:\s*['"]session['"]/);
450 });
451 });
452
453 // ---------------------------------------------------------------------------
454 // Tier 2b — behavior preservation (N5)
455 // ---------------------------------------------------------------------------
456 describe('SEC-SEAM-1 2b — differential vs pre-fix for non-seam / no-S10 inputs', () => {
457 test('reason === null equals pre-fix boolean when no seam/S10 input', () => {
458 const proposals = [
459 notesTray(),
460 notesTray({ status: undefined }), // absent status → proposed
461 notesTray({ intent: 'other' }),
462 notesTray({ review_severity: 'elevated' }),
463 null,
464 { status: 'approved', intent: SCOOLING_REVIEW_TRAY_INTENT, external_ref: 'scooling.review:x', path: 'reviewed/x.md' },
465 ];
466 const roles = [undefined, null, 'member', 'viewer', 'admin'];
467 for (const proposal of proposals) {
468 for (const role of roles) {
469 const opts = {
470 proposal,
471 hasVaultWrite: true,
472 partitionOwned: true,
473 ...(role === undefined ? {} : { role }),
474 };
475 // Strip seam/S10 extras — none supplied
476 const fixedNull = personalSelfApplyRefusalReason(opts) === null;
477 const pre = isPersonalSelfApplyClassPreFix(opts);
478 assert.equal(
479 fixedNull,
480 pre,
481 `mismatch for role=${String(role)} proposal=${JSON.stringify(proposal)?.slice(0, 80)}`
482 );
483 }
484 }
485 });
486
487 test('role omitted skips check; status absent treated as proposed', () => {
488 const optsNoRole = {
489 proposal: notesTray(),
490 hasVaultWrite: true,
491 partitionOwned: true,
492 // role omitted
493 };
494 assert.equal(personalSelfApplyRefusalReason(optsNoRole), null);
495 assert.equal(isPersonalSelfApplyClassPreFix(optsNoRole), true);
496
497 const optsNoStatus = {
498 proposal: notesTray({ status: undefined }),
499 hasVaultWrite: true,
500 partitionOwned: true,
501 role: 'member',
502 };
503 // notesTray spreads status:'proposed' then undefined override deletes? Use delete
504 const p = notesTray();
505 delete p.status;
506 const opts = { proposal: p, hasVaultWrite: true, partitionOwned: true, role: 'member' };
507 assert.equal(personalSelfApplyRefusalReason(opts), null);
508 assert.equal(isPersonalSelfApplyClassPreFix(opts), true);
509 void optsNoStatus;
510 });
511 });
512
513 // ---------------------------------------------------------------------------
514 // Tier 3 — e2e approve-eligibility matrix
515 // ---------------------------------------------------------------------------
516 describe('SEC-SEAM-1 e2e — approve-eligibility matrix', () => {
517 test('{seam,non-seam} × {session,legacy,mcp} × author relations', () => {
518 const seams = [false, true];
519 const binds = [
520 { label: 'session', sessionBound: true, tokenType: null, actorKind: 'human', humanActor: true },
521 { label: 'legacy', sessionBound: false, tokenType: null, actorKind: 'human', humanActor: true },
522 {
523 label: 'mcp_access',
524 sessionBound: false,
525 tokenType: 'mcp_access',
526 actorKind: 'agent',
527 humanActor: false,
528 },
529 ];
530 const authors = [
531 { label: 'eq', authorActorId: ACTOR_A, approverActorId: ACTOR_A },
532 { label: 'neq', authorActorId: ACTOR_A, approverActorId: ACTOR_B },
533 { label: 'empty', authorActorId: '', approverActorId: ACTOR_A },
534 ];
535
536 for (const seam of seams) {
537 for (const b of binds) {
538 for (const a of authors) {
539 const proposal = seam ? hostedTaskProposal() : notesTray();
540 const opts = baseEligibleOpts({
541 proposal,
542 sessionBound: b.sessionBound,
543 tokenType: b.tokenType,
544 actorKind: b.actorKind,
545 humanActor: b.humanActor,
546 authorActorId: a.authorActorId,
547 approverActorId: a.approverActorId,
548 });
549 const reason = personalSelfApplyRefusalReason(opts);
550 if (!seam) {
551 if (b.label === 'mcp_access') {
552 assert.equal(reason, 'ROLE_NOT_ELIGIBLE');
553 } else {
554 assert.equal(reason, null, `notes must stay eligible (${b.label}/${a.label})`);
555 }
556 } else {
557 assert.notEqual(reason, null);
558 assert.ok(
559 String(reason).startsWith('SELF_APPLY_') || reason === 'ROLE_NOT_ELIGIBLE',
560 `seam must refuse with named code, got ${reason}`
561 );
562 }
563 }
564 }
565 }
566 });
567
568 test('notes tray with legacy token and empty author still eligible (S2.4)', () => {
569 const reason = personalSelfApplyRefusalReason(
570 baseEligibleOpts({
571 sessionBound: false,
572 authorActorId: '',
573 approverActorId: ACTOR_A,
574 })
575 );
576 assert.equal(reason, null);
577 });
578 });
579
580 // ---------------------------------------------------------------------------
581 // Tier 3b — role floor (N13 / S5)
582 // ---------------------------------------------------------------------------
583 describe('SEC-SEAM-1 3b — learner role floor on seam propose (S5)', () => {
584 test('hosted task propose maps member → editor; self-hosted task/delegation include viewer', () => {
585 const taskRoutes = fs.readFileSync(TASK_ROUTES, 'utf8');
586 assert.match(taskRoutes, /member.*editor|r === 'member'/);
587 const hub = fs.readFileSync(HUB_SRC, 'utf8');
588 assert.match(hub, /TASK_WRITE_ROLES\s*=\s*requireRole\('viewer',\s*'editor',\s*'admin',\s*'evaluator'\)/);
589 assert.match(
590 hub,
591 /app\.post\('\/api\/v1\/delegation\/consents'[\s\S]*?requireRole\('viewer',\s*'editor',\s*'admin',\s*'evaluator'\)/
592 );
593 });
594 });
595
596 // ---------------------------------------------------------------------------
597 // Tier 4 — stress
598 // ---------------------------------------------------------------------------
599 describe('SEC-SEAM-1 stress — many seam proposals never eligible', () => {
600 test('distinct seam proposals sharing one author never eligible; trim/equality holds', () => {
601 const author = 'x'.repeat(128);
602 for (let i = 0; i < 200; i++) {
603 const reason = personalSelfApplyRefusalReason(
604 baseEligibleOpts({
605 proposal: hostedTaskProposal({
606 intent: `adversarial/${i}/${'y'.repeat(64)}`,
607 path: `meta/tasks/proposals/${i}.json`,
608 }),
609 sessionBound: true,
610 authorActorId: ` ${author} `,
611 approverActorId: author,
612 })
613 );
614 assert.equal(reason, 'SELF_APPLY_NOT_ADMITTED');
615 }
616 assert.equal(
617 personalSelfApplyRefusalReason(
618 baseEligibleOpts({
619 proposal: hostedTaskProposal(),
620 sessionBound: true,
621 authorActorId: 'Ab',
622 approverActorId: 'ab',
623 })
624 ),
625 'SELF_APPLY_AUTHOR_MISMATCH'
626 );
627 });
628 });
629
630 // ---------------------------------------------------------------------------
631 // Tier 5 — data-integrity
632 // ---------------------------------------------------------------------------
633 describe('SEC-SEAM-1 data-integrity — pure decision, idempotent', () => {
634 test('refusal does not mutate proposal; repeated calls identical', () => {
635 const proposal = hostedTaskProposal({
636 created_by: ACTOR_A,
637 labels: ['a'],
638 });
639 const snapshot = JSON.stringify(proposal);
640 const opts = baseEligibleOpts({
641 proposal,
642 sessionBound: true,
643 authorActorId: ACTOR_A,
644 approverActorId: ACTOR_A,
645 });
646 const r1 = personalSelfApplyRefusalReason(opts);
647 const r2 = personalSelfApplyRefusalReason(opts);
648 assert.equal(r1, r2);
649 assert.equal(JSON.stringify(proposal), snapshot);
650 assert.equal(proposal.evaluation_status, undefined);
651 });
652 });
653
654 // ---------------------------------------------------------------------------
655 // Tier 6 — performance
656 // ---------------------------------------------------------------------------
657 describe('SEC-SEAM-1 performance — no extra IO in eligibility decision', () => {
658 test('eligibility resolution is pure (no fs/network in refusalReason)', () => {
659 const openSync = mock.method(fs, 'readFileSync', () => {
660 throw new Error('unexpected fs in eligibility');
661 });
662 try {
663 const t0 = performance.now();
664 for (let i = 0; i < 500; i++) {
665 personalSelfApplyRefusalReason(
666 baseEligibleOpts({
667 proposal: i % 2 === 0 ? notesTray() : hostedTaskProposal(),
668 sessionBound: true,
669 authorActorId: ACTOR_A,
670 approverActorId: ACTOR_A,
671 })
672 );
673 }
674 const elapsed = performance.now() - t0;
675 assert.ok(elapsed < 2000, `eligibility loop too slow: ${elapsed}ms`);
676 } finally {
677 openSync.mock.restore();
678 }
679 // Hosted approve still has exactly one fetch helper for proposal GET
680 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
681 const approveFn = gw.slice(gw.indexOf('async function assertHostedProposalApproveDiscard'));
682 const body = approveFn.slice(0, approveFn.indexOf('async function getNoteCountForUser'));
683 const fetches = body.match(/fetchHostedProposalForSelfApply/g) || [];
684 assert.equal(fetches.length, 1);
685 });
686 });
687
688 // ---------------------------------------------------------------------------
689 // Tier 7 — security
690 // ---------------------------------------------------------------------------
691 describe('SEC-SEAM-1 security — regression, N1 evasion, V3 overlap, S3.0, S4, S7, S10', () => {
692 test('pre-fix replica true for shared-identity task fingerprint; fixed refuses', () => {
693 // Widened-class construction: fingerprint + would-be task under shared identity
694 const shared = notesTray({
695 intent: SCOOLING_REVIEW_TRAY_INTENT,
696 });
697 const preOpts = {
698 proposal: shared,
699 hasVaultWrite: true,
700 partitionOwned: true,
701 role: 'member',
702 };
703 assert.equal(isPersonalSelfApplyClassPreFix(preOpts), true);
704
705 const seamShared = {
706 ...notesTray(),
707 frontmatter: {
708 knowtation_proposal_source: 'task',
709 task_proposal_kind: 'task_create',
710 },
711 };
712 // Pre-fix ignores seam markers → still true on fingerprint
713 assert.equal(
714 isPersonalSelfApplyClassPreFix({
715 proposal: seamShared,
716 hasVaultWrite: true,
717 partitionOwned: true,
718 role: 'member',
719 }),
720 true
721 );
722 const fixed = personalSelfApplyRefusalReason({
723 proposal: seamShared,
724 hasVaultWrite: true,
725 partitionOwned: true,
726 role: 'member',
727 sessionBound: false,
728 authorActorId: ACTOR_A,
729 approverActorId: ACTOR_A,
730 });
731 assert.ok(
732 fixed === 'SELF_APPLY_SESSION_BINDING_REQUIRED' || fixed === 'SELF_APPLY_AUTHOR_MISMATCH',
733 `expected session or mismatch, got ${fixed}`
734 );
735 });
736
737 test('N1 evasion: omit proposal_kind, set knowtation_proposal_source + task_proposal_kind → seam', () => {
738 const crafted = {
739 status: 'proposed',
740 frontmatter: {
741 knowtation_proposal_source: 'task',
742 task_proposal_kind: 'task_create',
743 // deliberately no proposal_kind
744 },
745 };
746 assert.equal(round1SeamByProposalKind(crafted), false, 'round-1 rule must miss this');
747 assert.ok(normalizeCanisterProposalForTaskPrecheck(crafted) != null);
748 assert.equal(isSeamSurfaceProposal(crafted), true);
749
750 // intent omitted / renamed must not change classification
751 assert.equal(isSeamSurfaceProposal({ ...crafted, intent: undefined }), true);
752 assert.equal(isSeamSurfaceProposal({ ...crafted, intent: 'totally_unlisted' }), true);
753 assert.equal(isSeamSurfaceProposal({ intent: 'totally_unlisted' }), false);
754 });
755
756 test('V3 overlap: fingerprint ∧ task markers → pre true, fixed seam code (not FINGERPRINT_MISMATCH)', () => {
757 const overlap = {
758 ...notesTray(),
759 frontmatter: {
760 knowtation_proposal_source: 'task',
761 task_proposal_kind: 'task_create',
762 },
763 };
764 assert.equal(matchesScoolingReviewTrayFingerprint(overlap), true);
765 assert.equal(isSeamSurfaceProposal(overlap), true);
766 assert.equal(
767 isPersonalSelfApplyClassPreFix({
768 proposal: overlap,
769 hasVaultWrite: true,
770 partitionOwned: true,
771 role: 'member',
772 }),
773 true
774 );
775 const reason = personalSelfApplyRefusalReason({
776 proposal: overlap,
777 hasVaultWrite: true,
778 partitionOwned: true,
779 role: 'member',
780 sessionBound: true,
781 authorActorId: ACTOR_A,
782 approverActorId: ACTOR_A,
783 });
784 assert.notEqual(reason, 'FINGERPRINT_MISMATCH');
785 assert.ok(String(reason).startsWith('SELF_APPLY_'));
786 assert.equal(reason, 'SELF_APPLY_NOT_ADMITTED');
787 });
788
789 test('S3.1 correspondence: each of seven conditions matches apply-path predicates', () => {
790 const taskHosted = {
791 frontmatter: { knowtation_proposal_source: 'task', task_proposal_kind: 'task_create' },
792 };
793 assert.ok(normalizeCanisterProposalForTaskPrecheck(taskHosted) != null);
794 assert.equal(isSeamSurfaceProposal(taskHosted), true);
795
796 const delHosted = {
797 intent: 'delegation_consent_create',
798 frontmatter: { knowtation_proposal_source: 'delegation', delegation_record_kind: 'consent' },
799 };
800 assert.equal(isDelegationProposalIntent(delHosted.intent), true);
801 assert.ok(normalizeCanisterProposalForDelegationPrecheck(delHosted) != null);
802 assert.equal(isSeamSurfaceProposal(delHosted), true);
803
804 assert.equal(isSeamSurfaceProposal({ source: TASK_PROPOSAL_SOURCE }), true);
805 assert.equal(isSeamSurfaceProposal({ source: DELEGATION_PROPOSAL_SOURCE }), true);
806 assert.equal(isSeamSurfaceProposal({ source: MEDIA_PROPOSAL_SOURCE }), true);
807 assert.equal(isSeamSurfaceProposal({ source: FLOW_PROPOSAL_SOURCE }), true);
808 assert.equal(isSeamSurfaceProposal({ source: FLOW_CAPTURE_PROPOSAL_SOURCE }), true);
809 });
810
811 test('S3.0 source-read: no SEAM_SURFACE_INTENTS / task_proposal_kind; no hub/gateway import (V10)', () => {
812 const src = fs.readFileSync(SELF_APPLY_SRC, 'utf8');
813 assert.equal(src.includes('SEAM_SURFACE_INTENTS'), false);
814 assert.equal(src.includes('task_proposal_kind'), false);
815 assert.equal(/from\s+['"].*hub\/gateway\//.test(src), false);
816 });
817
818 test('S10: ineligible sub refused on notes fingerprint; HTTP stays FORBIDDEN (N7)', () => {
819 const sub = 'google:operator-shared';
820 SELF_APPLY_INELIGIBLE_SUBS.add(sub);
821 try {
822 const reason = personalSelfApplyRefusalReason(
823 baseEligibleOpts({ approverActorId: sub, authorActorId: sub, sessionBound: true })
824 );
825 assert.equal(reason, 'SELF_APPLY_SUBJECT_INELIGIBLE');
826 assert.equal(isHttpVisibleSelfApplySeamCode(reason), false);
827 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
828 const hub = fs.readFileSync(HUB_SRC, 'utf8');
829 for (const s of [gw, hub]) {
830 assert.match(s, /isHttpVisibleSelfApplySeamCode/);
831 assert.match(s, /code:\s*['"]FORBIDDEN['"]/);
832 }
833 } finally {
834 SELF_APPLY_INELIGIBLE_SUBS.delete(sub);
835 }
836 });
837
838 test('S4: no client X-User-Id/X-Actor-Id as identity source; CORS advertisement removed', () => {
839 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
840 // Must not read req headers as actor identity for approve
841 assert.equal(/req\.headers\[['\"]x-user-id['\"]\]/.test(gw), false);
842 assert.equal(/req\.headers\[['\"]x-actor-id['\"]\]/.test(gw), false);
843 const bridgeFiles = fs
844 .readdirSync(BRIDGE_DIR)
845 .filter((f) => f.endsWith('.mjs'))
846 .map((f) => fs.readFileSync(path.join(BRIDGE_DIR, f), 'utf8'));
847 for (const src of bridgeFiles) {
848 // Bridge sets X-User-Id toward canister (server-derived) — forbid reading client header as identity
849 assert.equal(/req\.headers\[['\"]x-user-id['\"]\]\s*\|\|/.test(src), false);
850 assert.equal(/const\s+\w+\s*=\s*req\.headers\[['\"]x-actor-id['\"]\]/.test(src), false);
851 }
852 const corsGw = fs.readFileSync(CORS_GW, 'utf8');
853 const corsBr = fs.readFileSync(CORS_BRIDGE, 'utf8');
854 assert.equal(corsGw.includes('X-User-Id'), false);
855 assert.equal(corsBr.includes('Access-Control-Allow-Headers') && corsBr.includes('X-User-Id') === false || !/Access-Control-Allow-Headers',\s*'[^']*X-User-Id/.test(corsBr), true);
856 assert.equal(/Access-Control-Allow-Headers',\s*'[^']*X-User-Id/.test(corsGw), false);
857 assert.equal(/Access-Control-Allow-Headers',\s*'[^']*X-User-Id/.test(corsBr), false);
858 });
859
860 test('S7.3/S7.6: hosted media attachments routes + media approve hook (SEC-SEAM-MEDIA)', () => {
861 // S7.3 previously asserted NO attachments routes (sentinel until MEDIA).
862 // SEC-SEAM-MEDIA-b deliberately adds gateway→bridge proxies + the matching
863 // maybeApplyHostedMediaAfterApprove hook (S7.6 / S3.0 same-change rule).
864 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
865 assert.equal(
866 /app\.post\(['"`]\/api\/v1\/attachments\/link-proposals['"`]/.test(gw),
867 true,
868 'gateway must proxy link-proposals',
869 );
870 assert.equal(
871 /app\.post\(['"`]\/api\/v1\/attachments\/attach-proposals['"`]/.test(gw),
872 true,
873 'gateway must proxy attach-proposals',
874 );
875 assert.equal(
876 /maybeApplyHostedMediaAfterApprove/.test(gw),
877 true,
878 'gateway approve success block must call media hook',
879 );
880 const mediaHook = path.join(ROOT, 'hub/gateway/media-approve-hosted.mjs');
881 assert.equal(fs.existsSync(mediaHook), true);
882 const hookSrc = fs.readFileSync(mediaHook, 'utf8');
883 assert.equal(
884 /normalizeCanisterProposalForMediaPrecheck/.test(hookSrc),
885 true,
886 'hook classify must use media normalize (S3.0)',
887 );
888 const seamSrc = fs.readFileSync(path.join(ROOT, 'lib/hub-proposal-personal-self-apply.mjs'), 'utf8');
889 assert.equal(
890 /normalizeCanisterProposalForMediaPrecheck/.test(seamSrc),
891 true,
892 'isSeamSurfaceProposal must gain media normalize in same change (S3.1)',
893 );
894 });
895
896 test('PROXY_HEADER_ALLOWLIST not widened (S4.4)', () => {
897 const gw = fs.readFileSync(GATEWAY_SRC, 'utf8');
898 const m = gw.match(/PROXY_HEADER_ALLOWLIST\s*=\s*new Set\(\[([\s\S]*?)\]\)/);
899 assert.ok(m, 'PROXY_HEADER_ALLOWLIST Set must exist');
900 const body = m[1].toLowerCase();
901 assert.equal(body.includes('x-user-id'), false);
902 assert.equal(body.includes('x-actor-id'), false);
903 assert.equal(body.includes('x-scooling-uid'), false);
904 });
905 });
906
907 // ---------------------------------------------------------------------------
908 // Tier 7b — S10 empty-env (N13, V6)
909 // ---------------------------------------------------------------------------
910 describe('SEC-SEAM-1 7b — S10 empty-env keeps notes tray eligible', () => {
911 test('pure parser empty shapes + notes eligible when ineligible set empty', () => {
912 assert.equal(parseSelfApplyIneligibleSubs(undefined).size, 0);
913 assert.equal(parseSelfApplyIneligibleSubs('').size, 0);
914 assert.equal(parseSelfApplyIneligibleSubs(',, ').size, 0);
915 // Module-load set ships empty under D3 (unless operator env set); ensure notes eligible
916 // when approver is not on the set
917 assert.equal(SELF_APPLY_INELIGIBLE_SUBS.has(ACTOR_A), false);
918 assert.equal(
919 personalSelfApplyRefusalReason(baseEligibleOpts({ approverActorId: ACTOR_A })),
920 null
921 );
922 });
923 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 10 days ago