// tests/build/no-remote-fonts.test.ts — Plan 01-12 Wave 0 RED unit test. // // MV3 CSP enforcement gate: production dist/ MUST contain ZERO // fonts.googleapis.com / https://fonts URLs. The canonical tokens.css // (Wave 1 Task 2) replaces the design-incoming Google Fonts @import // (line 12 of design-incoming/.../tokens.css) with local @font-face // rules pointing at ./fonts/*.woff2. // // Polarity at Wave 0 land: // - RED-vacuous today (the design-incoming tokens.css is not yet copied // into src/shared/; once Wave 1 Task 2 copies + applies the surgical // edits, this test confirms the edit removed the remote @import). // // Implementation mirrors tests/background/no-test-hooks-in-prod-bundle.test.ts: // - Build via npm run build (gated by SKIP_BUILD=1 escape hatch) // - Recursive walk over dist/ // - For each file, count occurrences of forbidden substrings // - Assert zero matches // // References: // - RESEARCH §1 + §13 (MV3 CSP self-host enforcement) // - Plan 01-12 threat model T-01-12-01 + T-01-12-02 (remote font load // + CDN MITM mitigations) import { execFile } from 'node:child_process'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { resolve as resolvePath } from 'node:path'; import { promisify } from 'node:util'; import { describe, expect, it } from 'vitest'; const execFileAsync = promisify(execFile); /** Forbidden substrings — any occurrence in dist/ violates MV3 CSP self-host invariant. */ const FORBIDDEN_REMOTE_STRINGS: ReadonlyArray = [ 'fonts.googleapis.com', 'https://fonts', 'googleapis', ]; const PROD_BUILD_TIMEOUT_MS = 90_000; const DIST_DIR = resolvePath(process.cwd(), 'dist'); interface ForbiddenMatch { readonly filePath: string; readonly count: number; } function listAllFilesRecursive(root: string): ReadonlyArray { const accumulator: string[] = []; const stack: string[] = [root]; while (stack.length > 0) { const dir = stack.pop()!; const entries = readdirSync(dir, { withFileTypes: true }); for (const entry of entries) { const fullPath = resolvePath(dir, entry.name); if (entry.isSymbolicLink()) continue; if (entry.isDirectory()) { stack.push(fullPath); } else if (entry.isFile()) { accumulator.push(fullPath); } } } return accumulator.sort(); } function countOccurrencesInFile(filePath: string, needle: string): number { const binaryExtensions = new Set([ '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.woff', '.woff2', '.ttf', '.otf', ]); const dotIdx = filePath.lastIndexOf('.'); const ext = dotIdx >= 0 ? filePath.substring(dotIdx).toLowerCase() : ''; if (binaryExtensions.has(ext)) return 0; const stat = statSync(filePath); if (stat.size === 0) return 0; const text = readFileSync(filePath, 'utf8'); let count = 0; let from = 0; for (;;) { const idx = text.indexOf(needle, from); if (idx < 0) break; count += 1; from = idx + needle.length; } return count; } function findMatchesInDist(needle: string): ReadonlyArray { const files = listAllFilesRecursive(DIST_DIR); const matches: ForbiddenMatch[] = []; for (const filePath of files) { const count = countOccurrencesInFile(filePath, needle); if (count > 0) { matches.push({ filePath, count }); } } return matches; } async function runProductionBuild(): Promise { await execFileAsync('npm', ['run', 'build'], { timeout: PROD_BUILD_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, env: { ...process.env, NODE_NO_WARNINGS: '1' }, }); } describe('production bundle has no remote font URLs (MV3 CSP — T-01-12-01)', () => { it('npm run build completes and dist/ exists', { timeout: PROD_BUILD_TIMEOUT_MS + 5_000 }, async () => { if (process.env.SKIP_BUILD !== '1') { await runProductionBuild(); } expect( existsSync(DIST_DIR), `dist/ missing at ${DIST_DIR}. Either npm run build failed or SKIP_BUILD=1 was set ` + `without a pre-existing build.`, ).toBe(true); }); for (const needle of FORBIDDEN_REMOTE_STRINGS) { it(`dist/ does not contain '${needle}' (MV3 CSP self-host invariant)`, () => { if (!existsSync(DIST_DIR)) { throw new Error( `dist/ missing — run \`npm run build\` first (SKIP_BUILD=1 set but no prior build).`, ); } const matches = findMatchesInDist(needle); expect( matches.length, matches.length === 0 ? 'unreachable' : `dist/ contains '${needle}' in ${matches.length} file(s) — violates MV3 CSP ` + `style-src 'self' + font-src 'self'. Replace remote @import in tokens.css ` + `with local @font-face rules pointing at ./fonts/*.woff2 (Wave 1 Task 2). ` + `Offending files:\n` + matches .map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`) .join('\n'), ).toBe(0); }); } });