sec-seam-media-hosted.test.mjs
1,247 lines 42.4 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago
1 /**
2 * SEC-SEAM-MEDIA-b — seven-tier coverage (§SM.4 matrix).
3 * Frozen: docs/SEC-SEAM-MEDIA-FREEZE.md (SM-C1–C12).
4 *
5 * Tiers: unit · integration · e2e · stress · data-integrity · performance · security
6 */
7
8 import fs from 'node:fs';
9 import { describe, it, beforeEach, afterEach } from 'node:test';
10 import assert from 'node:assert/strict';
11 import http from 'node:http';
12 import express from 'express';
13 import crypto from 'node:crypto';
14 import path from 'node:path';
15 import { performance } from 'node:perf_hooks';
16 import { fileURLToPath, pathToFileURL } from 'node:url';
17
18 import {
19 maybeApplyHostedMediaAfterApprove,
20 mergeMediaApplyIntoApproveResponse,
21 } from '../hub/gateway/media-approve-hosted.mjs';
22 import {
23 FM_PROPOSAL_SOURCE,
24 FM_MEDIA_PROPOSAL_KIND,
25 mergeMediaFrontmatter,
26 normalizeCanisterProposalForMediaPrecheck,
27 applyApprovedMediaProposalFromCanister,
28 stageCanisterNoteToTempVault,
29 } from '../lib/attachments/media-hosted-proposal.mjs';
30 import {
31 MEDIA_PROPOSAL_SOURCE,
32 deriveLinkAttachmentId,
33 precheckApprovedMediaProposal,
34 reconcileApprovedMediaProposal,
35 handleMediaLinkProposeRequest,
36 handleMediaAttachProposeRequest,
37 resolveMediaPointerForAttach,
38 } from '../lib/attachments/attachment-write.mjs';
39 import { handleAttachmentListRequest } from '../lib/attachments/attachment-handlers.mjs';
40 import { getExternalRef, loadExternalRefStore } from '../lib/attachments/attachment-external-ref-store.mjs';
41 import {
42 withMediaBlobSync,
43 mediaBlobKey,
44 MEDIA_EXTERNAL_REFS_FILENAME,
45 mergeExternalRefStoreJson,
46 mergeImportConsentStoreJson,
47 } from '../hub/bridge/media-blob-store.mjs';
48 import {
49 isSeamSurfaceProposal,
50 matchesScoolingMediaFingerprint,
51 } from '../lib/hub-proposal-personal-self-apply.mjs';
52 import {
53 buildMediaWriteFixture,
54 grantActiveConsent,
55 sampleLinkProposeBody,
56 sampleAttachProposeBody,
57 } from './fixtures/media/write-helpers.mjs';
58 import { createProposal } from '../hub/proposals-store.mjs';
59 import { loadMediaImportConsentStore, saveMediaImportConsentStore } from '../lib/attachments/media-import-consent.mjs';
60 import { noteStateIdFromParts } from '../lib/note-state-id.mjs';
61 import { readNote } from '../lib/vault.mjs';
62
63 const __dirname = path.dirname(fileURLToPath(import.meta.url));
64 const projectRoot = path.resolve(__dirname, '..');
65 const tmpRoot = path.join(__dirname, 'fixtures', 'tmp-sec-seam-media-hosted');
66
67 const SECRET = 'sec-seam-media-hosted-secret-32chars!!';
68 const ACTOR = 'google:learner-media';
69 const visible = new Set(['personal', 'project', 'org']);
70
71 function signTestJwt(payload) {
72 const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
73 const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
74 const data = `${header}.${body}`;
75 const sig = crypto.createHmac('sha256', SECRET).update(data).digest('base64url');
76 return `${data}.${sig}`;
77 }
78
79 function startServer(handler) {
80 const srv = http.createServer(handler);
81 return new Promise((resolve, reject) => {
82 srv.listen(0, '127.0.0.1', (err) => {
83 if (err) return reject(err);
84 resolve({
85 url: `http://127.0.0.1:${srv.address().port}`,
86 close: () => new Promise((r) => srv.close(() => r())),
87 });
88 });
89 });
90 }
91
92 /**
93 * @param {Map<string, Record<string, unknown>>} proposalRows
94 * @param {Map<string, { frontmatter: object, body: string }>} [noteRows]
95 */
96 function mockCanisterApp(proposalRows, noteRows = new Map()) {
97 const app = express();
98 app.use(express.json({ limit: '2mb' }));
99 app.post('/api/v1/proposals/:id/approve', (req, res) => {
100 const row = proposalRows.get(req.params.id);
101 if (!row) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
102 row.status = 'approved';
103 res.json({ proposal_id: req.params.id, status: 'approved' });
104 });
105 app.get('/api/v1/proposals/:id', (req, res) => {
106 const row = proposalRows.get(req.params.id);
107 if (!row) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
108 res.json(row);
109 });
110 app.get('/api/v1/notes/:path(*)', (req, res) => {
111 const notePath = decodeURIComponent(req.params.path);
112 const row = noteRows.get(notePath);
113 if (!row) return res.status(404).json({ error: 'Not found', code: 'NOT_FOUND' });
114 res.json({
115 path: notePath,
116 frontmatter: JSON.stringify(row.frontmatter ?? {}),
117 body: row.body ?? '',
118 });
119 });
120 app.post('/api/v1/notes', (req, res) => {
121 const notePath = typeof req.body?.path === 'string' ? req.body.path : '';
122 if (!notePath) return res.status(400).json({ error: 'path required', code: 'BAD_REQUEST' });
123 noteRows.set(notePath, {
124 frontmatter:
125 req.body.frontmatter && typeof req.body.frontmatter === 'object' ? req.body.frontmatter : {},
126 body: typeof req.body.body === 'string' ? req.body.body : '',
127 });
128 res.json({ path: notePath, written: true });
129 });
130 return app;
131 }
132
133 function fakeBlobStore(initial = {}) {
134 const store = new Map(Object.entries(initial));
135 const sets = [];
136 return {
137 store,
138 sets,
139 get: async (key) => (store.has(key) ? store.get(key) : null),
140 set: async (key, value) => {
141 sets.push(key);
142 store.set(key, value);
143 },
144 };
145 }
146
147 function mediaLinkRow(opts) {
148 const {
149 proposalId,
150 status = 'approved',
151 connectorId = 'gdrive',
152 opaqueRef = '1AbCd_efGhIjkLmnOpQrStU',
153 consentId,
154 scope = 'personal',
155 vaultId = 'default',
156 } = opts;
157 const attachmentId = deriveLinkAttachmentId(connectorId, opaqueRef);
158 const body = {
159 proposal_kind: 'media_external_link',
160 connector_id: connectorId,
161 opaque_ref: opaqueRef,
162 display_label: 'Design board',
163 consent_id: consentId,
164 scope,
165 attachment_id: attachmentId,
166 };
167 return {
168 proposal_id: proposalId,
169 status,
170 path: `meta/media/proposals/${proposalId}.json`,
171 body: JSON.stringify(body, null, 2),
172 frontmatter: mergeMediaFrontmatter(
173 { type: 'media_proposal', proposal_kind: 'media_external_link', attachment_id: attachmentId },
174 {
175 proposal_kind: 'media_external_link',
176 attachment_id: attachmentId,
177 connector_id: connectorId,
178 consent_id: consentId,
179 note_ref: null,
180 },
181 ),
182 vault_id: vaultId,
183 base_state_id: 'kn1_absent',
184 external_ref: `scooling.media:${proposalId}`,
185 };
186 }
187
188 function mediaAttachRow(opts) {
189 const {
190 proposalId,
191 status = 'approved',
192 attachmentId,
193 noteRef,
194 baseStateId,
195 mediaPointer,
196 vaultId = 'default',
197 } = opts;
198 const body = {
199 proposal_kind: 'media_attach',
200 attachment_id: attachmentId,
201 note_ref: noteRef,
202 scope: 'personal',
203 base_state_id: baseStateId,
204 media_pointer: mediaPointer,
205 };
206 return {
207 proposal_id: proposalId,
208 status,
209 path: `meta/media/proposals/${proposalId}.json`,
210 body: JSON.stringify(body, null, 2),
211 frontmatter: mergeMediaFrontmatter(
212 {
213 type: 'media_proposal',
214 proposal_kind: 'media_attach',
215 attachment_id: attachmentId,
216 note_ref: noteRef,
217 },
218 {
219 proposal_kind: 'media_attach',
220 attachment_id: attachmentId,
221 note_ref: noteRef,
222 media_pointer: mediaPointer,
223 },
224 ),
225 vault_id: vaultId,
226 base_state_id: baseStateId,
227 external_ref: `scooling.media:${proposalId}`,
228 };
229 }
230
231 function plainNoteProposal(proposalId, status = 'approved') {
232 return {
233 proposal_id: proposalId,
234 status,
235 path: 'notes/plain.md',
236 body: 'plain note body',
237 frontmatter: { type: 'note' },
238 vault_id: 'default',
239 };
240 }
241
242 function readRepo(rel) {
243 return fs.readFileSync(path.join(projectRoot, rel), 'utf8');
244 }
245
246 async function bootGateway(t, { canisterUrl, bridgeUrl, adminSub, cacheBust }) {
247 process.env.NETLIFY = '1';
248 process.env.CANISTER_URL = canisterUrl;
249 process.env.SESSION_SECRET = SECRET;
250 process.env.BRIDGE_URL = bridgeUrl;
251 process.env.HUB_ADMIN_USER_IDS = adminSub;
252 t.after(() => {
253 delete process.env.HUB_ADMIN_USER_IDS;
254 });
255
256 const gwEntry = pathToFileURL(path.join(projectRoot, 'hub', 'gateway', 'server.mjs')).href;
257 const { app: gwApp } = await import(`${gwEntry}?gwmedia=${cacheBust}`);
258 const gwSrv = http.createServer(gwApp);
259 await new Promise((resolve, reject) => {
260 gwSrv.listen(0, '127.0.0.1', (err) => (err ? reject(err) : resolve()));
261 });
262 t.after(() => new Promise((r) => gwSrv.close(() => r())));
263 return gwSrv.address().port;
264 }
265
266 function mockBridgeApp(applyCalls, applyResponse) {
267 const app = express();
268 app.use(express.json());
269 app.get('/api/v1/role', (_req, res) => {
270 res.json({ role: 'admin', may_approve_proposals: true });
271 });
272 app.get('/api/v1/hosted-context', (_req, res) => {
273 res.status(404).json({ error: 'not hosted', code: 'NOT_FOUND' });
274 });
275 app.post('/api/v1/attachments/proposals/:proposal_id/apply-approved', (req, res) => {
276 applyCalls.push({
277 proposalId: req.params.proposal_id,
278 auth: req.headers.authorization,
279 vault: req.headers['x-vault-id'],
280 });
281 res.json({ applied: true, ...applyResponse, proposal_id: req.params.proposal_id });
282 });
283 app.post('/api/v1/attachments/link-proposals', (_req, res) => {
284 res.status(201).json({ schema: 'knowtation.media_proposal/v0', proposal_id: 'proxy-hit' });
285 });
286 return app;
287 }
288
289 function enableLinkGate() {
290 process.env.MEDIA_EXTERNAL_LINK_ENABLED = '1';
291 }
292
293 // ---------------------------------------------------------------------------
294
295 describe('SEC-SEAM-MEDIA-b — unit', () => {
296 beforeEach(() => {
297 fs.rmSync(tmpRoot, { recursive: true, force: true });
298 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
299 delete process.env.MEDIA_ATTACH_ENABLED;
300 });
301 afterEach(() => {
302 fs.rmSync(tmpRoot, { recursive: true, force: true });
303 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
304 delete process.env.MEDIA_ATTACH_ENABLED;
305 });
306
307 it('mergeMediaApplyIntoApproveResponse merges success / failure / null', () => {
308 const base = JSON.stringify({ proposal_id: 'p1', status: 'approved' });
309 assert.equal(mergeMediaApplyIntoApproveResponse(base, null), base);
310
311 const ok = JSON.parse(
312 mergeMediaApplyIntoApproveResponse(base, {
313 applied: true,
314 payload: {
315 applied: true,
316 proposal_id: 'p1',
317 proposal_kind: 'media_external_link',
318 attachment_id: 'att_link_x',
319 },
320 }),
321 );
322 assert.equal(ok.media_index_applied, true);
323 assert.equal(ok.media_apply.attachment_id, 'att_link_x');
324 assert.equal(ok.media_apply_error, undefined);
325
326 const fail = JSON.parse(
327 mergeMediaApplyIntoApproveResponse(base, {
328 applied: false,
329 error: 'Connector not allowlisted',
330 code: 'MEDIA_CONNECTOR_DENIED',
331 }),
332 );
333 assert.equal(fail.media_index_applied, false);
334 assert.equal(fail.media_apply_code, 'MEDIA_CONNECTOR_DENIED');
335 assert.equal(fail.media_apply, undefined);
336
337 assert.equal(
338 mergeMediaApplyIntoApproveResponse('not-json', { applied: true, payload: {} }),
339 'not-json',
340 );
341 });
342
343 it('normalize: true for each recognition arm; false for notes/task; sets source media', () => {
344 const kind = 'media_external_link';
345 const byFm = normalizeCanisterProposalForMediaPrecheck({
346 path: 'other/path.json',
347 body: JSON.stringify({ proposal_kind: kind }),
348 frontmatter: { [FM_PROPOSAL_SOURCE]: MEDIA_PROPOSAL_SOURCE, [FM_MEDIA_PROPOSAL_KIND]: kind },
349 });
350 assert.ok(byFm);
351 assert.equal(byFm.source, MEDIA_PROPOSAL_SOURCE);
352 assert.equal(byFm.media_meta.proposal_kind, kind);
353
354 const bySource = normalizeCanisterProposalForMediaPrecheck({
355 source: MEDIA_PROPOSAL_SOURCE,
356 path: 'x',
357 body: JSON.stringify({ proposal_kind: kind }),
358 frontmatter: {},
359 });
360 assert.ok(bySource);
361 assert.equal(bySource.source, MEDIA_PROPOSAL_SOURCE);
362
363 const byPath = normalizeCanisterProposalForMediaPrecheck({
364 path: 'meta/media/proposals/abc.json',
365 body: JSON.stringify({ proposal_kind: 'media_attach' }),
366 frontmatter: { [FM_MEDIA_PROPOSAL_KIND]: 'media_attach' },
367 });
368 assert.ok(byPath);
369 assert.equal(byPath.media_meta.proposal_kind, 'media_attach');
370
371 assert.equal(
372 normalizeCanisterProposalForMediaPrecheck({
373 path: 'notes/plain.md',
374 body: 'hi',
375 frontmatter: { type: 'note' },
376 }),
377 null,
378 );
379 assert.equal(
380 normalizeCanisterProposalForMediaPrecheck({
381 source: 'task',
382 path: 'meta/tasks/proposals/t1.json',
383 body: JSON.stringify({ proposal_kind: 'task_create' }),
384 frontmatter: { knowtation_proposal_source: 'task' },
385 }),
386 null,
387 );
388 assert.equal(
389 normalizeCanisterProposalForMediaPrecheck({
390 source: MEDIA_PROPOSAL_SOURCE,
391 path: 'meta/media/proposals/x.json',
392 body: '{}',
393 frontmatter: {},
394 }),
395 null,
396 'kind absent → fail-closed null',
397 );
398 });
399
400 it('pointer stamp preferred over vault walk in shared precheck/reconcile', () => {
401 const dir = path.join(tmpRoot, 'pointer');
402 fs.mkdirSync(dir, { recursive: true });
403 const vaultPath = path.join(dir, 'vault');
404 fs.mkdirSync(vaultPath, { recursive: true });
405 const notePath = 'lesson.md';
406 fs.writeFileSync(path.join(vaultPath, notePath), `---\ntitle: Lesson\n---\n# Body\n`, 'utf8');
407 const note = readNote(vaultPath, notePath);
408 const baseStateId = noteStateIdFromParts(note.frontmatter ?? {}, note.body ?? '');
409 const stamped = 'mist:stamped-pointer-xyz';
410 const mistId = 'att_mist_deadbeefdeadbeefdeadbeefdeadbeef';
411
412 const proposal = {
413 vault_id: 'default',
414 status: 'approved',
415 source: MEDIA_PROPOSAL_SOURCE,
416 base_state_id: baseStateId,
417 media_meta: {
418 proposal_kind: 'media_attach',
419 attachment_id: mistId,
420 note_ref: `note:${notePath}`,
421 media_pointer: stamped,
422 },
423 body: JSON.stringify({
424 proposal_kind: 'media_attach',
425 attachment_id: mistId,
426 note_ref: `note:${notePath}`,
427 media_pointer: stamped,
428 }),
429 };
430
431 const pre = precheckApprovedMediaProposal(dir, proposal, { vaultPath, vaultConfig: {} });
432 assert.equal(pre.ok, true, JSON.stringify(pre));
433 assert.equal(pre.mediaPointer, stamped);
434 reconcileApprovedMediaProposal(dir, pre);
435
436 const after = readNote(vaultPath, notePath);
437 const atts = after.frontmatter?.attachments;
438 const asList = Array.isArray(atts)
439 ? atts
440 : typeof atts === 'string'
441 ? atts.split(',').map((s) => s.trim())
442 : [];
443 assert.ok(asList.includes(stamped), `expected stamp in attachments, got ${JSON.stringify(atts)}`);
444 assert.equal(resolveMediaPointerForAttach(vaultPath, {}, mistId), null);
445 });
446
447 it('yaml-stage kn1 flip: canister GET fingerprint override prevents MEDIA_LINEAGE_CONFLICT', async (t) => {
448 process.env.MEDIA_ATTACH_ENABLED = '1';
449 const notePath = 'hms-c7-attach-smoke.md';
450 const frontmatter = { title: 'HMS C7 Attach', type: 'note' };
451 // Trailing newline is common on canister payloads; parseFrontmatterAndBody trimEnds it.
452 const bodyWithNl = 'Smoke body for hosted attach kn1.\n';
453 const getKn1 = noteStateIdFromParts(frontmatter, bodyWithNl);
454
455 const noteRows = new Map([[notePath, { frontmatter, body: bodyWithNl }]]);
456 const { url: canisterUrl, close } = await startServer(mockCanisterApp(new Map(), noteRows));
457 t.after(close);
458
459 const staged = await stageCanisterNoteToTempVault({
460 canisterUrl,
461 headers: {},
462 notePath,
463 });
464 assert.ok(!('error' in staged), JSON.stringify(staged));
465 assert.equal(staged.staged, true);
466 assert.equal(staged.liveStateId, getKn1);
467
468 const afterRead = readNote(staged.vaultPath, notePath);
469 const stageKn1 = noteStateIdFromParts(afterRead.frontmatter ?? {}, afterRead.body ?? '');
470 assert.notEqual(
471 stageKn1,
472 getKn1,
473 'yaml-stage→readNote must diverge when body has trailing newline',
474 );
475
476 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'kn1-override'));
477 // Attachment discovery walks vaultPath; stage only the note, so bring media bytes along.
478 fs.cpSync(path.join(fx.vaultPath, 'media'), path.join(staged.vaultPath, 'media'), {
479 recursive: true,
480 });
481
482 const proposeBody = sampleAttachProposeBody(fx, {
483 note_ref: `note:${notePath}`,
484 base_state_id: getKn1,
485 });
486
487 const without = await handleMediaAttachProposeRequest({
488 dataDir: fx.dataDir,
489 vaultPath: staged.vaultPath,
490 vaultId: fx.vaultId,
491 cliScopes: ['personal', 'project', 'org'],
492 body: proposeBody,
493 intent: 'attach without override',
494 createProposal,
495 });
496 assert.equal(without.ok, false);
497 assert.equal(without.code, 'MEDIA_LINEAGE_CONFLICT');
498
499 const withOverride = await handleMediaAttachProposeRequest({
500 dataDir: fx.dataDir,
501 vaultPath: staged.vaultPath,
502 vaultId: fx.vaultId,
503 cliScopes: ['personal', 'project', 'org'],
504 body: proposeBody,
505 intent: 'attach with canister GET kn1',
506 liveStateIdOverride: staged.liveStateId,
507 createProposal,
508 });
509 assert.equal(withOverride.ok, true, JSON.stringify(withOverride));
510
511 const preNo = precheckApprovedMediaProposal(
512 fx.dataDir,
513 {
514 vault_id: fx.vaultId,
515 status: 'approved',
516 source: MEDIA_PROPOSAL_SOURCE,
517 base_state_id: getKn1,
518 media_meta: {
519 proposal_kind: 'media_attach',
520 attachment_id: fx.fileId,
521 note_ref: `note:${notePath}`,
522 media_pointer: fx.fileId,
523 },
524 body: JSON.stringify({
525 proposal_kind: 'media_attach',
526 attachment_id: fx.fileId,
527 note_ref: `note:${notePath}`,
528 media_pointer: fx.fileId,
529 }),
530 },
531 { vaultPath: staged.vaultPath, vaultConfig: {} },
532 );
533 assert.equal(preNo.ok, false);
534 assert.equal(preNo.code, 'MEDIA_LINEAGE_CONFLICT');
535
536 const preYes = precheckApprovedMediaProposal(
537 fx.dataDir,
538 {
539 vault_id: fx.vaultId,
540 status: 'approved',
541 source: MEDIA_PROPOSAL_SOURCE,
542 base_state_id: getKn1,
543 media_meta: {
544 proposal_kind: 'media_attach',
545 attachment_id: fx.fileId,
546 note_ref: `note:${notePath}`,
547 media_pointer: fx.fileId,
548 },
549 body: JSON.stringify({
550 proposal_kind: 'media_attach',
551 attachment_id: fx.fileId,
552 note_ref: `note:${notePath}`,
553 media_pointer: fx.fileId,
554 }),
555 },
556 { vaultPath: staged.vaultPath, vaultConfig: {}, liveStateIdOverride: staged.liveStateId },
557 );
558 assert.equal(preYes.ok, true, JSON.stringify(preYes));
559
560 staged.cleanup();
561 });
562
563 it('hook returns null for non-approve paths and non-2xx approve', async () => {
564 const ctxBase = {
565 method: 'POST',
566 pathOnly: '/api/v1/proposals/p1/approve',
567 upstreamStatus: 200,
568 canisterUrl: 'http://127.0.0.1:1',
569 bridgeUrl: 'http://127.0.0.1:1',
570 authorization: undefined,
571 vaultId: 'default',
572 effectiveUserId: 'u',
573 actorUserId: 'u',
574 canisterAuthHeaders: () => ({}),
575 };
576 assert.equal(await maybeApplyHostedMediaAfterApprove({ ...ctxBase, method: 'GET' }), null);
577 assert.equal(
578 await maybeApplyHostedMediaAfterApprove({
579 ...ctxBase,
580 pathOnly: '/api/v1/proposals/p1/discard',
581 }),
582 null,
583 );
584 assert.equal(
585 await maybeApplyHostedMediaAfterApprove({ ...ctxBase, upstreamStatus: 403 }),
586 null,
587 );
588 assert.equal(await maybeApplyHostedMediaAfterApprove({ ...ctxBase, bridgeUrl: '' }), null);
589 });
590
591 it('apply helper refuses non-media (400) and non-approved (409)', async (t) => {
592 enableLinkGate();
593 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'unit-apply'));
594 const consentId = grantActiveConsent(fx.dataDir, fx.vaultId, 'gdrive');
595
596 const rows = new Map();
597 rows.set('prop-note', plainNoteProposal('prop-note'));
598 rows.set(
599 'prop-pending',
600 mediaLinkRow({ proposalId: 'prop-pending', status: 'proposed', consentId }),
601 );
602 rows.set('prop-ok', mediaLinkRow({ proposalId: 'prop-ok', status: 'approved', consentId }));
603
604 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
605 t.after(close);
606
607 const nonMedia = await applyApprovedMediaProposalFromCanister({
608 dataDir: fx.dataDir,
609 canisterUrl,
610 headers: {},
611 proposalId: 'prop-note',
612 vaultId: fx.vaultId,
613 });
614 assert.equal(nonMedia.ok, false);
615 assert.equal(nonMedia.status, 400);
616
617 const notApproved = await applyApprovedMediaProposalFromCanister({
618 dataDir: fx.dataDir,
619 canisterUrl,
620 headers: {},
621 proposalId: 'prop-pending',
622 vaultId: fx.vaultId,
623 });
624 assert.equal(notApproved.ok, false);
625 assert.equal(notApproved.status, 409);
626 assert.equal(notApproved.code, 'CONFLICT');
627
628 const applied = await applyApprovedMediaProposalFromCanister({
629 dataDir: fx.dataDir,
630 canisterUrl,
631 headers: {},
632 proposalId: 'prop-ok',
633 vaultId: fx.vaultId,
634 });
635 assert.equal(applied.ok, true, JSON.stringify(applied));
636 assert.equal(applied.payload.proposal_kind, 'media_external_link');
637 });
638 });
639
640 describe('SEC-SEAM-MEDIA-b — integration', () => {
641 beforeEach(() => {
642 fs.rmSync(tmpRoot, { recursive: true, force: true });
643 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
644 delete process.env.MEDIA_ATTACH_ENABLED;
645 });
646 afterEach(() => {
647 fs.rmSync(tmpRoot, { recursive: true, force: true });
648 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
649 delete process.env.MEDIA_ATTACH_ENABLED;
650 });
651
652 it('apply-approved media_external_link → external-ref listable; blob persisted', async (t) => {
653 enableLinkGate();
654 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'int'));
655 const consentId = grantActiveConsent(fx.dataDir, fx.vaultId, 'gdrive');
656 const opaqueRef = 'opaque-int-ref-001';
657 const row = mediaLinkRow({
658 proposalId: 'prop-int-link',
659 consentId,
660 opaqueRef,
661 });
662 const attachmentId = deriveLinkAttachmentId('gdrive', opaqueRef);
663
664 const rows = new Map([['prop-int-link', row]]);
665 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
666 t.after(close);
667
668 const blobStore = fakeBlobStore();
669 const result = await withMediaBlobSync({
670 blobStore,
671 dataDir: fx.dataDir,
672 run: () =>
673 applyApprovedMediaProposalFromCanister({
674 dataDir: fx.dataDir,
675 canisterUrl,
676 headers: {},
677 proposalId: 'prop-int-link',
678 requireApproved: true,
679 vaultId: fx.vaultId,
680 }),
681 });
682 assert.equal(result.ok, true, JSON.stringify(result));
683 assert.equal(result.payload.attachment_id, attachmentId);
684
685 const ref = getExternalRef(fx.dataDir, fx.vaultId, attachmentId);
686 assert.ok(ref, 'external ref must be upserted');
687 assert.equal(ref.opaque_ref, opaqueRef);
688
689 const list = handleAttachmentListRequest({
690 dataDir: fx.dataDir,
691 vaultPath: fx.dataDir,
692 vaultId: fx.vaultId,
693 visibleScopes: visible,
694 source: 'connector_ref',
695 });
696 assert.equal(list.ok, true);
697 assert.ok(
698 list.payload.attachments.some((a) => a.attachment_id === attachmentId),
699 'connector_ref must appear in attachment list',
700 );
701
702 assert.ok(
703 blobStore.sets.includes(mediaBlobKey(MEDIA_EXTERNAL_REFS_FILENAME)),
704 'external refs must persist to blob after apply',
705 );
706 });
707
708 it('media_attach apply posts mutated note to canister with stamped pointer', async (t) => {
709 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'int-attach'));
710 const notePath = fx.targetNotePath;
711 const note = readNote(fx.vaultPath, notePath);
712 const baseStateId = noteStateIdFromParts(note.frontmatter ?? {}, note.body ?? '');
713 const pointer = fx.fileId; // att_file_* doubles as pointer for non-mist ids
714 const proposalId = 'prop-int-attach';
715
716 const noteRows = new Map([
717 [notePath, { frontmatter: note.frontmatter ?? {}, body: note.body ?? '' }],
718 ]);
719 const rows = new Map([
720 [
721 proposalId,
722 mediaAttachRow({
723 proposalId,
724 attachmentId: fx.fileId,
725 noteRef: fx.targetNoteRef,
726 baseStateId,
727 mediaPointer: pointer,
728 }),
729 ],
730 ]);
731 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows, noteRows));
732 t.after(close);
733
734 const result = await applyApprovedMediaProposalFromCanister({
735 dataDir: fx.dataDir,
736 canisterUrl,
737 headers: {},
738 proposalId,
739 vaultId: fx.vaultId,
740 });
741 assert.equal(result.ok, true, JSON.stringify(result));
742 assert.equal(result.payload.proposal_kind, 'media_attach');
743
744 const written = noteRows.get(notePath);
745 assert.ok(written);
746 assert.ok(Array.isArray(written.frontmatter.attachments));
747 assert.ok(written.frontmatter.attachments.includes(pointer));
748 });
749
750 it('media_attach apply preserves trailing-newline body kn1 via canister GET override', async (t) => {
751 const notePath = 'trail-nl.md';
752 const frontmatter = { title: 'Trail NL', type: 'note' };
753 const bodyWithNl = 'Body with trailing newline.\n';
754 const baseStateId = noteStateIdFromParts(frontmatter, bodyWithNl);
755 const pointer = 'att_file_trailnldeadbeefdeadbeefdeadbe';
756 const proposalId = 'prop-trail-nl';
757
758 const noteRows = new Map([[notePath, { frontmatter, body: bodyWithNl }]]);
759 const rows = new Map([
760 [
761 proposalId,
762 mediaAttachRow({
763 proposalId,
764 attachmentId: pointer,
765 noteRef: `note:${notePath}`,
766 baseStateId,
767 mediaPointer: pointer,
768 }),
769 ],
770 ]);
771 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows, noteRows));
772 t.after(close);
773
774 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'int-attach-trail'));
775 const result = await applyApprovedMediaProposalFromCanister({
776 dataDir: fx.dataDir,
777 canisterUrl,
778 headers: {},
779 proposalId,
780 vaultId: fx.vaultId,
781 });
782 assert.equal(result.ok, true, JSON.stringify(result));
783 const written = noteRows.get(notePath);
784 assert.ok(written);
785 assert.equal(written.body, bodyWithNl, 'canister POST must keep trailing newline body');
786 assert.ok(written.frontmatter.attachments.includes(pointer));
787 });
788 });
789
790 describe('SEC-SEAM-MEDIA-b — e2e', () => {
791 beforeEach(() => {
792 fs.rmSync(tmpRoot, { recursive: true, force: true });
793 });
794 afterEach(() => {
795 fs.rmSync(tmpRoot, { recursive: true, force: true });
796 delete process.env.NETLIFY;
797 delete process.env.CANISTER_URL;
798 delete process.env.SESSION_SECRET;
799 delete process.env.BRIDGE_URL;
800 delete process.env.HUB_ADMIN_USER_IDS;
801 });
802
803 it('admin approve media → media_index_applied true; non-media → no media fields; proxy hits bridge', async (t) => {
804 const applyCalls = [];
805 const rows = new Map();
806 rows.set(
807 'prop-media-e2e',
808 mediaLinkRow({
809 proposalId: 'prop-media-e2e',
810 consentId: 'mic_0123456789abcdef',
811 }),
812 );
813 rows.set('prop-note-e2e', plainNoteProposal('prop-note-e2e'));
814
815 const { url: canisterUrl, close: closeCanister } = await startServer(mockCanisterApp(rows));
816 t.after(closeCanister);
817
818 const bridgeApp = mockBridgeApp(applyCalls, {
819 proposal_kind: 'media_external_link',
820 attachment_id: 'att_link_e2e',
821 vault_id: 'default',
822 });
823 // Catch-all must NOT receive attachment proxies — register a marker.
824 let catchAllHits = 0;
825 bridgeApp.use((req, _res, next) => {
826 if (req.path.startsWith('/api/v1/') && !req.path.includes('/attachments/')) {
827 catchAllHits += 1;
828 }
829 next();
830 });
831 const { url: bridgeUrl, close: closeBridge } = await startServer(bridgeApp);
832 t.after(closeBridge);
833
834 const adminSub = ACTOR;
835 const port = await bootGateway(t, {
836 canisterUrl,
837 bridgeUrl,
838 adminSub,
839 cacheBust: String(Date.now()),
840 });
841
842 const token = signTestJwt({
843 sub: adminSub,
844 actor_kind: 'human',
845 session_bound: true,
846 exp: Math.floor(Date.now() / 1000) + 3600,
847 });
848
849 const mediaApprove = await fetch(
850 `http://127.0.0.1:${port}/api/v1/proposals/prop-media-e2e/approve`,
851 {
852 method: 'POST',
853 headers: {
854 Authorization: `Bearer ${token}`,
855 'Content-Type': 'application/json',
856 'X-Vault-Id': 'default',
857 },
858 body: '{}',
859 },
860 );
861 const mediaText = await mediaApprove.text();
862 assert.equal(mediaApprove.status, 200, mediaText);
863 const mediaBody = JSON.parse(mediaText);
864 assert.equal(mediaBody.media_index_applied, true);
865 assert.equal(mediaBody.media_apply.proposal_kind, 'media_external_link');
866 assert.equal(applyCalls.length, 1);
867 assert.equal(applyCalls[0].proposalId, 'prop-media-e2e');
868
869 const noteApprove = await fetch(
870 `http://127.0.0.1:${port}/api/v1/proposals/prop-note-e2e/approve`,
871 {
872 method: 'POST',
873 headers: {
874 Authorization: `Bearer ${token}`,
875 'Content-Type': 'application/json',
876 'X-Vault-Id': 'default',
877 },
878 body: '{}',
879 },
880 );
881 const noteText = await noteApprove.text();
882 assert.equal(noteApprove.status, 200, noteText);
883 const noteBody = JSON.parse(noteText);
884 assert.equal(noteBody.media_index_applied, undefined);
885 assert.equal(noteBody.media_apply, undefined);
886
887 // Gateway proxy for link-proposals hits bridge (not canister catch-all).
888 const proxyRes = await fetch(`http://127.0.0.1:${port}/api/v1/attachments/link-proposals`, {
889 method: 'POST',
890 headers: {
891 Authorization: `Bearer ${token}`,
892 'Content-Type': 'application/json',
893 'X-Vault-Id': 'default',
894 },
895 body: JSON.stringify({ intent: 'x' }),
896 });
897 assert.equal(proxyRes.status, 201);
898 const proxyBody = await proxyRes.json();
899 assert.equal(proxyBody.proposal_id, 'proxy-hit');
900 assert.equal(catchAllHits, 0);
901 });
902 });
903
904 describe('SEC-SEAM-MEDIA-b — stress', () => {
905 beforeEach(() => {
906 fs.rmSync(tmpRoot, { recursive: true, force: true });
907 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
908 });
909 afterEach(() => {
910 fs.rmSync(tmpRoot, { recursive: true, force: true });
911 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
912 });
913
914 it('≥50 sequential apply-approved calls; last external-link still listable', async (t) => {
915 enableLinkGate();
916 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'stress'));
917 const consentId = grantActiveConsent(fx.dataDir, fx.vaultId, 'gdrive');
918 const rows = new Map();
919 const N = 50;
920 for (let i = 0; i < N; i++) {
921 const opaqueRef = `stress-ref-${String(i).padStart(3, '0')}`;
922 const id = `prop-stress-${i}`;
923 rows.set(id, mediaLinkRow({ proposalId: id, consentId, opaqueRef }));
924 }
925 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
926 t.after(close);
927
928 for (let i = 0; i < N; i++) {
929 const result = await applyApprovedMediaProposalFromCanister({
930 dataDir: fx.dataDir,
931 canisterUrl,
932 headers: {},
933 proposalId: `prop-stress-${i}`,
934 vaultId: fx.vaultId,
935 });
936 assert.equal(result.ok, true, `i=${i} ${JSON.stringify(result)}`);
937 }
938
939 const lastOpaque = `stress-ref-${String(N - 1).padStart(3, '0')}`;
940 const lastId = deriveLinkAttachmentId('gdrive', lastOpaque);
941 assert.ok(getExternalRef(fx.dataDir, fx.vaultId, lastId));
942
943 const list = handleAttachmentListRequest({
944 dataDir: fx.dataDir,
945 vaultPath: fx.dataDir,
946 vaultId: fx.vaultId,
947 visibleScopes: visible,
948 source: 'connector_ref',
949 });
950 assert.equal(list.ok, true);
951 assert.ok(list.payload.attachments.some((a) => a.attachment_id === lastId));
952 });
953 });
954
955 describe('SEC-SEAM-MEDIA-b — data-integrity', () => {
956 beforeEach(() => {
957 fs.rmSync(tmpRoot, { recursive: true, force: true });
958 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
959 });
960 afterEach(() => {
961 fs.rmSync(tmpRoot, { recursive: true, force: true });
962 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
963 });
964
965 it('second apply → MEDIA_LINEAGE_CONFLICT without duplicate rows; expired consent refuses', async (t) => {
966 enableLinkGate();
967 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'di'));
968 const consentId = grantActiveConsent(fx.dataDir, fx.vaultId, 'gdrive');
969 const opaqueRef = 'di-opaque-unique';
970 const proposalId = 'prop-di-link';
971 const rows = new Map([
972 [proposalId, mediaLinkRow({ proposalId, consentId, opaqueRef })],
973 ]);
974 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
975 t.after(close);
976
977 const first = await applyApprovedMediaProposalFromCanister({
978 dataDir: fx.dataDir,
979 canisterUrl,
980 headers: {},
981 proposalId,
982 vaultId: fx.vaultId,
983 });
984 assert.equal(first.ok, true);
985
986 const second = await applyApprovedMediaProposalFromCanister({
987 dataDir: fx.dataDir,
988 canisterUrl,
989 headers: {},
990 proposalId,
991 vaultId: fx.vaultId,
992 });
993 assert.equal(second.ok, false);
994 assert.equal(second.code, 'MEDIA_LINEAGE_CONFLICT');
995
996 const store = loadExternalRefStore(fx.dataDir);
997 const refs = store.vaults?.[fx.vaultId]?.refs ?? {};
998 const attachmentId = deriveLinkAttachmentId('gdrive', opaqueRef);
999 assert.equal(Object.keys(refs).filter((k) => k === attachmentId).length, 1);
1000
1001 // Expired consent refuses precheck on a fresh proposal.
1002 const expiredConsent = 'mic_abcdef0123456789';
1003 const consentStore = loadMediaImportConsentStore(fx.dataDir);
1004 if (!consentStore.vaults[fx.vaultId]) consentStore.vaults[fx.vaultId] = { consents: {} };
1005 consentStore.vaults[fx.vaultId].consents[expiredConsent] = {
1006 connector_id: 'gdrive',
1007 scope: 'personal',
1008 granted_by: 'uid_hash:test',
1009 granted_at: '2020-01-01T00:00:00.000Z',
1010 expires_at: '2020-01-02T00:00:00.000Z',
1011 status: 'active',
1012 };
1013 saveMediaImportConsentStore(fx.dataDir, consentStore);
1014
1015 const expiredRow = mediaLinkRow({
1016 proposalId: 'prop-di-expired',
1017 consentId: expiredConsent,
1018 opaqueRef: 'di-opaque-expired',
1019 });
1020 rows.set('prop-di-expired', expiredRow);
1021 const expired = await applyApprovedMediaProposalFromCanister({
1022 dataDir: fx.dataDir,
1023 canisterUrl,
1024 headers: {},
1025 proposalId: 'prop-di-expired',
1026 vaultId: fx.vaultId,
1027 });
1028 assert.equal(expired.ok, false);
1029 assert.equal(expired.code, 'MEDIA_IMPORT_CONSENT_REQUIRED');
1030 });
1031
1032 it('blob merge: newest updated wins for external refs; revoked wins for consents', () => {
1033 const localRefs = JSON.stringify({
1034 schema: 'knowtation.attachment_external_ref/v0',
1035 vaults: {
1036 default: {
1037 refs: {
1038 att_a: { updated: '2026-01-02T00:00:00.000Z', opaque_ref: 'local' },
1039 att_b: { updated: '2026-01-01T00:00:00.000Z', opaque_ref: 'local-b' },
1040 },
1041 },
1042 },
1043 });
1044 const blobRefs = JSON.stringify({
1045 schema: 'knowtation.attachment_external_ref/v0',
1046 vaults: {
1047 default: {
1048 refs: {
1049 att_a: { updated: '2026-01-01T00:00:00.000Z', opaque_ref: 'blob' },
1050 att_c: { updated: '2026-01-03T00:00:00.000Z', opaque_ref: 'blob-c' },
1051 },
1052 },
1053 },
1054 });
1055 const mergedRefs = JSON.parse(mergeExternalRefStoreJson(localRefs, blobRefs));
1056 assert.equal(mergedRefs.vaults.default.refs.att_a.opaque_ref, 'local');
1057 assert.equal(mergedRefs.vaults.default.refs.att_b.opaque_ref, 'local-b');
1058 assert.equal(mergedRefs.vaults.default.refs.att_c.opaque_ref, 'blob-c');
1059
1060 const localConsents = JSON.stringify({
1061 schema: 'knowtation.media_import_consent/v0',
1062 vaults: {
1063 default: {
1064 consents: {
1065 mic_1: { status: 'active', granted_at: '2026-01-02T00:00:00.000Z' },
1066 mic_2: { status: 'revoked', granted_at: '2026-01-01T00:00:00.000Z' },
1067 },
1068 },
1069 },
1070 });
1071 const blobConsents = JSON.stringify({
1072 schema: 'knowtation.media_import_consent/v0',
1073 vaults: {
1074 default: {
1075 consents: {
1076 mic_1: { status: 'revoked', granted_at: '2026-01-01T00:00:00.000Z' },
1077 mic_2: { status: 'active', granted_at: '2026-01-03T00:00:00.000Z' },
1078 },
1079 },
1080 },
1081 });
1082 const mergedConsents = JSON.parse(mergeImportConsentStoreJson(localConsents, blobConsents));
1083 assert.equal(mergedConsents.vaults.default.consents.mic_1.status, 'revoked');
1084 assert.equal(mergedConsents.vaults.default.consents.mic_2.status, 'revoked');
1085 });
1086 });
1087
1088 describe('SEC-SEAM-MEDIA-b — performance', () => {
1089 beforeEach(() => {
1090 fs.rmSync(tmpRoot, { recursive: true, force: true });
1091 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
1092 });
1093 afterEach(() => {
1094 fs.rmSync(tmpRoot, { recursive: true, force: true });
1095 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
1096 });
1097
1098 it('single apply-approved + list p95 budget < 500ms (local fixture)', async (t) => {
1099 enableLinkGate();
1100 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'perf'));
1101 const consentId = grantActiveConsent(fx.dataDir, fx.vaultId, 'gdrive');
1102 const rows = new Map([
1103 ['prop-perf', mediaLinkRow({ proposalId: 'prop-perf', consentId, opaqueRef: 'perf-ref' })],
1104 ]);
1105 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
1106 t.after(close);
1107
1108 const samples = [];
1109 for (let i = 0; i < 5; i++) {
1110 // Reset store between samples for fair single-apply timing.
1111 const storePath = path.join(fx.dataDir, 'hub_attachment_external_refs.json');
1112 if (fs.existsSync(storePath)) fs.unlinkSync(storePath);
1113 const t0 = performance.now();
1114 const result = await applyApprovedMediaProposalFromCanister({
1115 dataDir: fx.dataDir,
1116 canisterUrl,
1117 headers: {},
1118 proposalId: 'prop-perf',
1119 vaultId: fx.vaultId,
1120 });
1121 assert.equal(result.ok, true);
1122 handleAttachmentListRequest({
1123 dataDir: fx.dataDir,
1124 vaultPath: fx.dataDir,
1125 vaultId: fx.vaultId,
1126 visibleScopes: visible,
1127 });
1128 samples.push(performance.now() - t0);
1129 }
1130 samples.sort((a, b) => a - b);
1131 const p95 = samples[Math.floor(samples.length * 0.95)] ?? samples[samples.length - 1];
1132 // Documented bound: local mock canister + in-process apply < 500ms p95.
1133 assert.ok(p95 < 500, `p95=${p95}ms samples=${JSON.stringify(samples)}`);
1134 });
1135 });
1136
1137 describe('SEC-SEAM-MEDIA-b — security', () => {
1138 beforeEach(() => {
1139 fs.rmSync(tmpRoot, { recursive: true, force: true });
1140 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
1141 delete process.env.MEDIA_ATTACH_ENABLED;
1142 });
1143 afterEach(() => {
1144 fs.rmSync(tmpRoot, { recursive: true, force: true });
1145 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
1146 delete process.env.MEDIA_ATTACH_ENABLED;
1147 });
1148
1149 it('(a) hook trigger ≡ S3.1 media normalize; source-scan forbids parallel SEAM lists', () => {
1150 const hosted = {
1151 path: 'meta/media/proposals/p1.json',
1152 body: JSON.stringify({ proposal_kind: 'media_external_link', scope: 'personal' }),
1153 frontmatter: {
1154 [FM_PROPOSAL_SOURCE]: MEDIA_PROPOSAL_SOURCE,
1155 [FM_MEDIA_PROPOSAL_KIND]: 'media_external_link',
1156 },
1157 external_ref: 'scooling.media:p1',
1158 };
1159 const normalized = normalizeCanisterProposalForMediaPrecheck(hosted);
1160 assert.ok(normalized);
1161 assert.equal(isSeamSurfaceProposal(hosted), true);
1162 assert.equal(isSeamSurfaceProposal({ path: 'notes/x.md', frontmatter: {}, body: '' }), false);
1163
1164 // Source-scan: no new SEAM_* kind/intent arrays in media modules.
1165 const mediaHostedSrc = readRepo('lib/attachments/media-hosted-proposal.mjs');
1166 const mediaHookSrc = readRepo('hub/gateway/media-approve-hosted.mjs');
1167 const seamSrc = readRepo('lib/hub-proposal-personal-self-apply.mjs');
1168 assert.ok(!/SEAM_[A-Z_]*KINDS?\s*=/.test(mediaHostedSrc));
1169 assert.ok(!/SEAM_[A-Z_]*INTENTS?\s*=/.test(mediaHostedSrc));
1170 assert.ok(!/SEAM_[A-Z_]*KINDS?\s*=/.test(mediaHookSrc));
1171 assert.ok(
1172 seamSrc.includes('normalizeCanisterProposalForMediaPrecheck'),
1173 'S3.1 must call the same normalize',
1174 );
1175 assert.ok(
1176 mediaHookSrc.includes('normalizeCanisterProposalForMediaPrecheck'),
1177 'hook classify must call the same normalize',
1178 );
1179 });
1180
1181 it('(b) apply-approved with status proposed → 409', async (t) => {
1182 enableLinkGate();
1183 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'sec-409'));
1184 const consentId = grantActiveConsent(fx.dataDir, fx.vaultId, 'gdrive');
1185 const rows = new Map([
1186 [
1187 'prop-sec-pending',
1188 mediaLinkRow({ proposalId: 'prop-sec-pending', status: 'proposed', consentId }),
1189 ],
1190 ]);
1191 const { url: canisterUrl, close } = await startServer(mockCanisterApp(rows));
1192 t.after(close);
1193 const result = await applyApprovedMediaProposalFromCanister({
1194 dataDir: fx.dataDir,
1195 canisterUrl,
1196 headers: {},
1197 proposalId: 'prop-sec-pending',
1198 vaultId: fx.vaultId,
1199 });
1200 assert.equal(result.status, 409);
1201 assert.equal(result.code, 'CONFLICT');
1202 });
1203
1204 it('(d) opaque_ref never fetched — no http(s) client in media apply path source', () => {
1205 const applySrc = readRepo('lib/attachments/media-hosted-proposal.mjs');
1206 const writeSrc = readRepo('lib/attachments/attachment-write.mjs');
1207 // Apply path must not dereference opaque_ref via fetch to arbitrary URLs.
1208 // Allowed fetches: canister proposals/notes only (relative to canisterUrl).
1209 assert.ok(!/opaque_ref[\s\S]{0,80}fetch\(/.test(applySrc));
1210 assert.ok(!/fetch\([\s\S]{0,80}opaque_ref/.test(writeSrc));
1211 assert.ok(!/https?:\/\/\$\{/.test(writeSrc.match(/reconcileApprovedMediaProposal[\s\S]{0,800}/)?.[0] ?? ''));
1212 });
1213
1214 it('(e) gates-off propose still 403', async () => {
1215 delete process.env.MEDIA_EXTERNAL_LINK_ENABLED;
1216 const fx = buildMediaWriteFixture(path.join(tmpRoot, 'sec-gate'));
1217 // Consent grant itself is gated; propose must refuse at the gate before consent.
1218 const result = await handleMediaLinkProposeRequest({
1219 dataDir: fx.dataDir,
1220 vaultId: fx.vaultId,
1221 cliScopes: ['personal', 'project', 'org'],
1222 body: sampleLinkProposeBody({ consent_id: 'mic_0123456789abcdef' }),
1223 intent: 'should refuse',
1224 createProposal,
1225 });
1226 assert.equal(result.ok, false);
1227 assert.equal(result.status, 403);
1228 assert.equal(result.code, 'MEDIA_EXTERNAL_LINK_DISABLED');
1229 });
1230
1231 it('T5 fingerprint evaluates hosted frontmatter rows after normalize', () => {
1232 const hosted = {
1233 proposal_id: 'p-t5',
1234 path: 'meta/media/proposals/p-t5.json',
1235 body: JSON.stringify({
1236 proposal_kind: 'media_external_link',
1237 scope: 'personal',
1238 }),
1239 frontmatter: {
1240 [FM_PROPOSAL_SOURCE]: MEDIA_PROPOSAL_SOURCE,
1241 [FM_MEDIA_PROPOSAL_KIND]: 'media_external_link',
1242 },
1243 external_ref: 'scooling.media:p-t5',
1244 };
1245 assert.equal(matchesScoolingMediaFingerprint(hosted), true);
1246 });
1247 });
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 9 days ago