feat(voyage): vendor DOMPurify >=3.1.1 + sanitize annotation-content

This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 18:01:30 +02:00
commit fc8c9eecdd
5 changed files with 105 additions and 6 deletions

View file

@ -413,3 +413,50 @@ test('docs/screenshots/README.md documents mappestruktur + hooks (v4.3 Step 23)'
assert.match(text, /window\.__voyage\.scheduleRender/, 'scheduleRender hook documented');
assert.match(text, /window\.__voyage\.getProjectArtifacts/, 'getProjectArtifacts hook documented');
});
// v4.3 Step 24 — vendor DOMPurify + sanitize annotation-content
test('playground/lib/dompurify.min.js is vendored (v4.3 Step 24)', () => {
const path = join(PLAYGROUND, 'lib', 'dompurify.min.js');
assert.equal(existsSync(path), true, 'playground/lib/dompurify.min.js must exist (run scripts/vendor-playground-libs.mjs)');
const size = statSync(path).size;
// Sanity floor — DOMPurify min bundle is ~22 KB; reject empty/0-byte
assert.ok(size > 5000, 'dompurify.min.js too small (' + size + ' bytes) — vendor script may have failed');
});
test('playground/lib/VENDOR-MANIFEST.json pins dompurify >= 3.1.1 (v4.3 Step 24)', () => {
const path = join(PLAYGROUND, 'lib', 'VENDOR-MANIFEST.json');
const manifest = JSON.parse(readFileSync(path, 'utf-8'));
assert.ok(manifest.pins && manifest.pins.dompurify, 'manifest must pin dompurify');
// semver compare on major.minor: must be >= 3.1.1
const m = String(manifest.pins.dompurify).match(/^(\d+)\.(\d+)\.(\d+)/);
assert.ok(m, 'invalid dompurify pin format: ' + manifest.pins.dompurify);
const [, maj, min] = m;
assert.ok(Number(maj) > 3 || (Number(maj) === 3 && Number(min) >= 1), 'dompurify pin must be >= 3.1.1, got ' + manifest.pins.dompurify);
assert.ok(manifest.output_files.includes('dompurify.min.js'), 'manifest output_files must list dompurify.min.js');
});
test('voyage-playground.html loads dompurify.min.js (v4.3 Step 24)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /<script src="lib\/dompurify\.min\.js">/, 'lib/dompurify.min.js script tag required');
});
test('voyage-playground.html declares sanitizeAnnotation function with allowlist (v4.3 Step 24)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+sanitizeAnnotation\s*\(/, 'sanitizeAnnotation() function required');
// Must call DOMPurify.sanitize with an ALLOWED_TAGS allowlist
assert.match(text, /DOMPurify\.sanitize/, 'DOMPurify.sanitize call required');
assert.match(text, /ALLOWED_TAGS:\s*\[/, 'ALLOWED_TAGS allowlist required');
});
test('voyage-playground.html bundle stays under 460 KB HALT-gate (v4.3 Step 24)', () => {
// Sums voyage-playground.html + every playground/lib/*.js file. Per plan
// critic finding 18 — must be < 460000 bytes (40 KB margin under the
// 500 KB NFR).
const htmlSize = statSync(HTML).size;
const libDir = join(PLAYGROUND, 'lib');
const libFiles = readdirSync(libDir).filter((f) => f.endsWith('.js') || f.endsWith('.mjs'));
let libTotal = 0;
for (const f of libFiles) libTotal += statSync(join(libDir, f)).size;
const total = htmlSize + libTotal;
assert.ok(total < 460000, 'bundle size ' + total + ' bytes exceeds 460 KB HALT-gate (' + libFiles.length + ' lib files)');
});