// tests/integration/annotation-export-schema.test.mjs // v4.3 Sesjon 5 — STUB. Full schema-validation tests land in Sesjon 6 (Wave 7 // Step 29). Sesjon 5 seeds this file with the behavioral fixtures for: // - Step 25 — HTML-comment indirect prompt-injection mitigation (Sec T4) // - Step 26 — path-traversal + symlink/dotfile filter on loaded files // // These tests re-implement the browser-side filter logic locally so we can // validate behavior without spinning up a headless browser. The voyage // playground HTML carries the same logic inline; tests/playground/ // voyage-playground.test.mjs covers the static-grep that the inline // implementations exist. import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { readFileSync } from 'node:fs'; import { dirname, resolve, join } from 'node:path'; import { fileURLToPath } from 'node:url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const ROOT = resolve(__dirname, '..', '..'); const HTML = join(ROOT, 'playground', 'voyage-playground.html'); // Mirror of the browser-side VOYAGE_ANCHOR_RE / parseAnchor / stripUnsafeComments // (Step 16 + Step 25). Kept verbatim so a regression in browser parseAnchor // surfaces here too. If you change the regex in the playground, mirror it // here. const VOYAGE_ANCHOR_RE = /^(\s*)\s*$/; const VOYAGE_ANCHOR_ATTR_RE = /(\w+)="([^"]*)"/g; const VOYAGE_ANCHOR_ID_RE = /^ANN-\d{4}$/; const VOYAGE_ANCHOR_INTENTS = ['fix', 'change', 'question', 'block']; function parseAnchor(line) { if (typeof line !== 'string') return null; const m = line.match(VOYAGE_ANCHOR_RE); if (!m) return null; const attrs = {}; VOYAGE_ANCHOR_ATTR_RE.lastIndex = 0; let a; while ((a = VOYAGE_ANCHOR_ATTR_RE.exec(m[2])) !== null) attrs[a[1]] = a[2]; if (!attrs.id || !VOYAGE_ANCHOR_ID_RE.test(attrs.id)) return null; if (typeof attrs.target !== 'string' || attrs.target.length === 0) return null; if (attrs.line !== undefined) { const n = parseInt(attrs.line, 10); if (!Number.isInteger(n) || n <= 0) return null; } if (attrs.snippet && attrs.snippet.length > 80) return null; if (attrs.intent && VOYAGE_ANCHOR_INTENTS.indexOf(attrs.intent) === -1) return null; return { id: attrs.id, target: attrs.target }; } function stripUnsafeComments(text) { if (typeof text !== 'string') return text; return text.replace(//g, (match) => parseAnchor(match) ? match : ''); } // --- Step 25 — HTML-comment indirect prompt-injection mitigation --------- test('stripUnsafeComments — drops prompt-injection comment, keeps voyage:anchor (v4.3 Step 25)', () => { const fixture = [ '# Document', '', '', '', '', 'Body text.', ].join('\n'); const out = stripUnsafeComments(fixture); assert.ok(!out.includes('IGNORE PREVIOUS INSTRUCTIONS'), 'malicious comment must be stripped'); assert.ok(out.includes('voyage:anchor id="ANN-0001"'), 'valid voyage:anchor must survive'); }); test('stripUnsafeComments — strips arbitrary HTML comments (v4.3 Step 25)', () => { const fixture = '
Hi
'; const out = stripUnsafeComments(fixture); assert.equal(out, 'Hi
', 'all non-voyage comments must be stripped'); }); test('stripUnsafeComments — rejects malformed voyage:anchor (Sec T4) (v4.3 Step 25)', () => { // A comment that LOOKS like voyage:anchor but fails the strict allowlist // (missing id, bad id format, missing target, bogus intent). const cases = [ '', // no id '', // bad id format '', // no target '', // bad intent ]; for (const c of cases) { const out = stripUnsafeComments('A\n' + c + '\nB'); assert.ok(!out.includes('voyage:anchor'), 'malformed comment "' + c + '" must be stripped'); } }); test('voyage-playground.html stripUnsafeComments wired into renderArtifact (v4.3 Step 25)', () => { const text = readFileSync(HTML, 'utf-8'); // Function declared assert.match(text, /function\s+stripUnsafeComments\s*\(/, 'stripUnsafeComments() function required'); // Renderer must call it before md.render to enforce the allowlist assert.match(text, /var\s+safeText\s*=\s*stripUnsafeComments\(/, 'renderArtifact must call stripUnsafeComments before md.render'); }); // --- Step 26 placeholder — full filter test added by Sesjon 5 Step 26 ---- // (Test below activates after Step 26 lands; kept as documentation stub.)