// 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 — path-traversal + symlink/dotfile filter ------------------ // Mirror of the browser-side isProjectPathSafe predicate. Kept verbatim so // the playground's filter cannot drift without breaking this test. function isProjectPathSafe(inside) { if (typeof inside !== 'string' || !inside) return false; if (inside.indexOf('..') !== -1) return false; if (inside.charAt(0) === '.') return false; if (inside.indexOf('/.') !== -1) return false; if (inside.indexOf('node_modules/') === 0 || inside.indexOf('/node_modules/') !== -1) return false; if (inside.indexOf('dist/') === 0 || inside.indexOf('/dist/') !== -1) return false; if (inside.indexOf('build/') === 0 || inside.indexOf('/build/') !== -1) return false; return true; } test('isProjectPathSafe — rejects path-traversal (v4.3 Step 26)', () => { assert.equal(isProjectPathSafe('../etc/passwd'), false); assert.equal(isProjectPathSafe('foo/../etc/passwd'), false); assert.equal(isProjectPathSafe('a/b/../c'), false); }); test('isProjectPathSafe — rejects dotfiles at root + nested (v4.3 Step 26)', () => { assert.equal(isProjectPathSafe('.gitignore'), false); assert.equal(isProjectPathSafe('.git/config'), false); assert.equal(isProjectPathSafe('.DS_Store'), false); assert.equal(isProjectPathSafe('.env'), false); assert.equal(isProjectPathSafe('docs/.hidden/file'), false); assert.equal(isProjectPathSafe('research/.git/HEAD'), false); }); test('isProjectPathSafe — rejects node_modules / dist / build at any depth (v4.3 Step 26)', () => { assert.equal(isProjectPathSafe('node_modules/foo/index.js'), false); assert.equal(isProjectPathSafe('packages/sub/node_modules/x'), false); assert.equal(isProjectPathSafe('dist/bundle.js'), false); assert.equal(isProjectPathSafe('packages/x/dist/y.js'), false); assert.equal(isProjectPathSafe('build/output.js'), false); assert.equal(isProjectPathSafe('packages/x/build/y.js'), false); }); test('isProjectPathSafe — accepts valid project artifacts (v4.3 Step 26)', () => { assert.equal(isProjectPathSafe('brief.md'), true); assert.equal(isProjectPathSafe('plan.md'), true); assert.equal(isProjectPathSafe('review.md'), true); assert.equal(isProjectPathSafe('progress.json'), true); assert.equal(isProjectPathSafe('research/01-foo.md'), true); assert.equal(isProjectPathSafe('architecture/overview.md'), true); assert.equal(isProjectPathSafe('architecture/gaps.md'), true); }); test('isProjectPathSafe — fixture FileList survives filter to brief.md only (v4.3 Step 26)', () => { // Fixture mirroring Step 26 plan-Verify scenario: load a directory // containing the four hostile entries plus a valid brief.md and verify // only brief.md survives. const fixture = [ '../etc/passwd', '.git/config', 'node_modules/foo/index.js', 'brief.md', '.DS_Store', 'dist/junk.js', ]; const survivors = fixture.filter(isProjectPathSafe); assert.deepEqual(survivors, ['brief.md'], 'only brief.md should survive the filter'); }); // ===================================================================== // Group C — v4.3 Step 29 export-bundle schema validation (Wave 7). // // Verifies the JSON shape that /trekrevise consumes when an operator // applies a playground-exported annotation batch back into the source // artifact. The shape comes from buildAnnotatedMarkdown + // downloadAnnotatedBlob (markdown export — primary) but the // trekrevise-side reader (lib/parsers/anchor-parser.mjs + // lib/parsers/annotation-digest.mjs) accepts a parallel JSON payload // with the same canonical fields. The fixture in // tests/fixtures/playground/v43-export-bundle.json is the contract. // ===================================================================== import { computeAnnotationDigest } from '../../lib/parsers/annotation-digest.mjs'; const FIXTURES = join(ROOT, 'tests', 'fixtures', 'playground'); const BUNDLE = join(FIXTURES, 'v43-export-bundle.json'); const PLAN_FIXTURE = join(FIXTURES, 'v43-plan-pre-annotate.md'); test('Group C.1 — export bundle JSON parses (v4.3 Step 29)', () => { const raw = readFileSync(BUNDLE, 'utf-8'); const bundle = JSON.parse(raw); // throws on parse error assert.equal(typeof bundle, 'object', 'bundle must be object'); assert.ok(bundle !== null, 'bundle must not be null'); }); test('Group C.2 — export bundle has required top-level keys (v4.3 Step 29)', () => { const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); for (const key of ['schema_version', 'exported_at', 'target_artifact', 'annotations', 'annotation_digest']) { assert.ok(key in bundle, `required key missing: ${key}`); } assert.equal(bundle.schema_version, 1, 'schema_version must be 1'); assert.ok(['brief', 'plan', 'review', 'artifact'].includes(bundle.target_artifact), 'target_artifact must be one of brief|plan|review|artifact'); assert.ok(Array.isArray(bundle.annotations), 'annotations must be array'); }); test('Group C.3 — every annotation has id + target_anchor + intent (v4.3 Step 29)', () => { const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); assert.ok(bundle.annotations.length >= 2, 'fixture must include ≥2 annotations'); for (const a of bundle.annotations) { assert.match(a.id, /^ANN-\d{4}$/, `id ${a.id} must match ANN-NNNN`); assert.equal(typeof a.target_anchor, 'string', 'target_anchor must be string'); assert.ok(VOYAGE_ANCHOR_INTENTS.includes(a.intent), `intent ${a.intent} must be one of fix|change|question|block`); } }); test('Group C.4 — empty-export edge case produces valid bundle (v4.3 Step 29)', () => { // Mirror the export-shape with zero annotations (download button still works // — produces a bundle with annotations=[] and digest of empty canonical). const emptyBundle = { schema_version: 1, exported_at: '2026-05-10T00:00:00Z', target_artifact: 'brief', target_filename: 'annotated-brief.md', annotations: [], annotation_digest: computeAnnotationDigest([]), }; // Round-trip: serialize + parse must equal const roundTripped = JSON.parse(JSON.stringify(emptyBundle)); assert.deepEqual(roundTripped, emptyBundle, 'empty bundle must round-trip'); assert.equal(emptyBundle.annotations.length, 0, 'annotations array must be empty'); assert.match(emptyBundle.annotation_digest, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix'); }); test('Group C.5 — annotation_digest is order-independent (v4.3 Step 29)', () => { const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); const ascending = computeAnnotationDigest(bundle.annotations); const reversed = computeAnnotationDigest([...bundle.annotations].reverse()); assert.equal(ascending, reversed, 'digest must be deterministic regardless of input order'); }); // SC6 — annotation_digest SHA-256 validity (per scope-guardian SC-GAP-3). test('Group C.6 — annotation_digest is valid 16-hex-char SHA-256 prefix (v4.3 Step 29 / SC-GAP-3)', () => { const bundle = JSON.parse(readFileSync(BUNDLE, 'utf-8')); // Recompute the digest server-side and verify it matches the canonical form. // The fixture stores PLACEHOLDER_OVERWRITTEN_AT_TEST_TIME — the canonical // value comes from computeAnnotationDigest(annotations). const canonical = computeAnnotationDigest(bundle.annotations); assert.match(canonical, /^[0-9a-f]{16}$/, 'digest must be 16-hex-char prefix of SHA-256'); // Determinism: two calls with the same input MUST produce identical output const second = computeAnnotationDigest(bundle.annotations); assert.equal(canonical, second, 'digest must be deterministic'); }); test('Group C.7 — fixture plan parses with anchors at block boundaries (v4.3 Step 29)', () => { const planText = readFileSync(PLAN_FIXTURE, 'utf-8'); // Frontmatter declares revision: 0 — the entry point for /trekrevise assert.match(planText, /^---\s*$/m, 'YAML frontmatter required'); assert.match(planText, /^revision:\s*0\s*$/m, 'revision: 0 required (round-trip seed)'); // Both anchors present in canonical format assert.match(planText, //, 'ANN-0001 anchor required'); assert.match(planText, //, 'ANN-0002 anchor required'); });