sec-seam-media-hosted.test.mjs
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