Two new build-gate vitest files at `tests/build/` per Plan 04-02 Wave 0 TDD-strict RED-first contract: - `no-new-function-in-sw-chunk.test.ts`: SW-chunk CSP-hardening grep gate. Narrows the file walk to `dist/assets/index.ts-*.js` (the SW + loader chunks; cf. plan-checker iter-1 BLOCKER 1 fix). RED today: 1 occurrence of `new Function` in the SW chunk (the pre-existing `setimmediate` npm package fallback bundled transitively by vite-plugin-node-polyfills, per .planning/phases/01-stabilize-video-pipeline/deferred-items.md). Flips GREEN after Task 2's setimmediate replacement lands. Build-prep gate (npm run build + dist/assets/ existence + ≥1 SW chunk match) precedes the grep gate so the test is self-bootstrapping under SKIP_BUILD=0 and self-asserting under SKIP_BUILD=1. - `dead-code-grep.test.ts`: ROADMAP SC #4 regression pin against `src/`. Asserts absence of `permissions.request` (removed in Phase 1 Plan 01-05 SW shrink). GREEN-on-arrival today; acts as regression guard so re-introducing the deleted permission-request flow breaks CI. The offscreen-inline-string sub-test is documented as delegated to the vite.config.ts review + tests/build/no-remote-fonts.test.ts (no single literal sentinel pinnable post-Plan-01-06 collapse). Polarity confirmation: - Acceptance grep: `grep -v '^//' tests/build/no-new-function-in-sw-chunk.test.ts | grep -c 'new Function'` returns 3 (≥2 required). - Acceptance grep: `grep -v '^//' tests/build/dead-code-grep.test.ts | grep -c 'permissions.request'` returns 2 (≥2 required). - SKIP_BUILD=1 npm test -- tests/build/no-new-function-in-sw-chunk.test.ts tests/build/dead-code-grep.test.ts --run: 2 passed + 1 failed (the expected RED gate). - Full vitest: 180 passed + 3 failed (1 = this task's expected RED + 2 = pre-existing ffmpeg/ffprobe flakes per 04-01-SUMMARY Issues Encountered — owned by Plan 04-03). References: - .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md §"tests/build/no-new-function-in-sw-chunk.test.ts" + §"tests/build/dead-code-grep.test.ts" - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1 - Plan 04-02 threat model T-04-02-01 (Elevation of Privilege) + T-04-02-03 (Information Disclosure regression pin) - tests/build/no-remote-fonts.test.ts (Plan 01-12 analog scaffold) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
169 lines
7.0 KiB
TypeScript
169 lines
7.0 KiB
TypeScript
// tests/build/no-new-function-in-sw-chunk.test.ts — Plan 04-02 Wave 0 RED unit test.
|
|
//
|
|
// MV3 CSP-hardening gate: the production SW chunk MUST contain ZERO occurrences
|
|
// of `new Function`. The pre-existing offender (verified across Phase 1 history;
|
|
// documented at .planning/phases/01-stabilize-video-pipeline/deferred-items.md)
|
|
// is the `setimmediate` npm package's CSP-unsafe fallback for `setImmediate(string)`,
|
|
// bundled transitively by `vite-plugin-node-polyfills`:
|
|
//
|
|
// b.setImmediate=function(I){typeof I!="function"&&(I=new Function(""+I));...}
|
|
//
|
|
// JSZip (the only legitimate consumer of setImmediate in the bundle) calls
|
|
// `setImmediate(function)` only — never the string form — so the unsafe fallback
|
|
// is dead in the static call graph but Rollup conservatively preserves it
|
|
// (runtime type check, not static dead branch). Plan 04-02 Task 2 replaces the
|
|
// transitive polyfill with an inline `queueMicrotask` polyfill and adds
|
|
// `exclude: ['setimmediate']` to the nodePolyfills config — after which the
|
|
// `new Function` literal disappears from the SW chunk entirely.
|
|
//
|
|
// Polarity at Wave 0 land:
|
|
// - RED today (1 occurrence; the setimmediate polyfill literal in
|
|
// dist/assets/index.ts-<hash>.js). Flips GREEN after Task 2 lands the
|
|
// polyfill replacement.
|
|
//
|
|
// Scope: the file walk is narrowed to `dist/assets/` filtered by the SW chunk
|
|
// regex `^index\.ts-.*\.js$`. The offscreen + welcome + loader chunks are out
|
|
// of scope (they cannot run `setImmediate(string)` since they don't ship the
|
|
// setimmediate polyfill anyway).
|
|
//
|
|
// Implementation mirrors tests/build/no-remote-fonts.test.ts:
|
|
// - Build via npm run build (gated by SKIP_BUILD=1 escape hatch)
|
|
// - Narrowed walk over dist/assets/index.ts-*.js
|
|
// - For each file, count occurrences of forbidden substring `new Function`
|
|
// - Assert zero matches
|
|
//
|
|
// References:
|
|
// - .planning/phases/04-harden-clean-up-optional/04-RESEARCH.md §Q1 (setimmediate)
|
|
// - .planning/phases/01-stabilize-video-pipeline/deferred-items.md (1-occurrence
|
|
// baseline + closure-flip target)
|
|
// - .planning/phases/04-harden-clean-up-optional/04-PATTERNS.md (this test
|
|
// scaffold; analog at tests/build/no-remote-fonts.test.ts)
|
|
// - Plan 04-02 threat model T-04-02-01 (Elevation of Privilege via static
|
|
// `new Function` red flag)
|
|
|
|
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 the SW chunk violates MV3 CSP hardening (Plan 04-02). */
|
|
const FORBIDDEN_SW_CSP_PATTERNS: ReadonlyArray<string> = [
|
|
'new Function',
|
|
];
|
|
|
|
const PROD_BUILD_TIMEOUT_MS = 90_000;
|
|
const DIST_DIR = resolvePath(process.cwd(), 'dist');
|
|
const DIST_ASSETS_DIR = resolvePath(DIST_DIR, 'assets');
|
|
|
|
// SW chunk filename pattern: `index.ts-<hash>.js`. The loader chunk
|
|
// `index.ts-loader-<hash>.js` is also matched by `^index\.ts-.*\.js$` — that's
|
|
// intentional; the loader chunk is part of the SW boot pipeline and must also
|
|
// be CSP-safe. (Note: the loader chunk currently contains 0 hits, so this only
|
|
// strengthens the regression net.)
|
|
const SW_CHUNK_REGEX = /^index\.ts-.*\.js$/;
|
|
|
|
interface ForbiddenMatch {
|
|
readonly filePath: string;
|
|
readonly count: number;
|
|
}
|
|
|
|
function listSwChunkFiles(): ReadonlyArray<string> {
|
|
if (!existsSync(DIST_ASSETS_DIR)) return [];
|
|
const entries = readdirSync(DIST_ASSETS_DIR);
|
|
return entries
|
|
.filter((name) => SW_CHUNK_REGEX.test(name))
|
|
.map((name) => resolvePath(DIST_ASSETS_DIR, name))
|
|
.sort();
|
|
}
|
|
|
|
function countOccurrencesInFile(filePath: string, needle: string): number {
|
|
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 findMatchesInSwChunks(needle: string): ReadonlyArray<ForbiddenMatch> {
|
|
const files = listSwChunkFiles();
|
|
const accumulator: ForbiddenMatch[] = [];
|
|
for (const filePath of files) {
|
|
const count = countOccurrencesInFile(filePath, needle);
|
|
if (count > 0) {
|
|
accumulator.push({ filePath, count });
|
|
}
|
|
}
|
|
return accumulator;
|
|
}
|
|
|
|
async function runProductionBuild(): Promise<void> {
|
|
await execFileAsync('npm', ['run', 'build'], {
|
|
timeout: PROD_BUILD_TIMEOUT_MS,
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
env: { ...process.env, NODE_NO_WARNINGS: '1' },
|
|
});
|
|
}
|
|
|
|
describe('production SW chunk has no `new Function` literal (MV3 CSP hardening — Plan 04-02 / RESEARCH Q1)', () => {
|
|
it(
|
|
'npm run build completes and dist/assets/ exists with at least one SW chunk',
|
|
{ timeout: PROD_BUILD_TIMEOUT_MS + 5_000 },
|
|
async () => {
|
|
if (process.env.SKIP_BUILD !== '1') {
|
|
await runProductionBuild();
|
|
}
|
|
expect(
|
|
existsSync(DIST_ASSETS_DIR),
|
|
`dist/assets/ missing at ${DIST_ASSETS_DIR}. Either npm run build failed or ` +
|
|
`SKIP_BUILD=1 was set without a pre-existing build.`,
|
|
).toBe(true);
|
|
const swChunks = listSwChunkFiles();
|
|
expect(
|
|
swChunks.length,
|
|
`Expected at least one SW chunk matching /^index\\.ts-.*\\.js$/ in ${DIST_ASSETS_DIR}; ` +
|
|
`found ${swChunks.length}. Glob pre-gate: the file walk for the CSP grep depends on ` +
|
|
`the SW chunk filename pattern; if no files match, the grep silently no-ops and the ` +
|
|
`test would pass falsely. Investigate the Vite bundling pipeline (rollupOptions.input + ` +
|
|
`assetFileNames + crxjs plugin output).`,
|
|
).toBeGreaterThanOrEqual(1);
|
|
},
|
|
);
|
|
|
|
for (const needle of FORBIDDEN_SW_CSP_PATTERNS) {
|
|
it(`dist/assets/index.ts-*.js does not contain '${needle}' (MV3 CSP — Plan 04-02 T-04-02-01)`, () => {
|
|
if (!existsSync(DIST_ASSETS_DIR)) {
|
|
throw new Error(
|
|
`dist/assets/ missing — run \`npm run build\` first (SKIP_BUILD=1 set but no prior build).`,
|
|
);
|
|
}
|
|
const matches = findMatchesInSwChunks(needle);
|
|
expect(
|
|
matches.length,
|
|
matches.length === 0
|
|
? 'unreachable'
|
|
: `SW chunk(s) contain '${needle}' in ${matches.length} file(s) — violates MV3 CSP ` +
|
|
`hardening (Plan 04-02 T-04-02-01). The pre-existing offender is the setimmediate ` +
|
|
`npm package's \`new Function("" + I)\` fallback, bundled transitively by ` +
|
|
`vite-plugin-node-polyfills. Fix: add \`exclude: ['setimmediate']\` to vite.config.ts ` +
|
|
`nodePolyfills config + add an inline \`queueMicrotask\`-based polyfill prelude at ` +
|
|
`the top of src/background/index.ts. See Plan 04-02 Task 2 + RESEARCH §Q1.\n` +
|
|
`Offending files:\n` +
|
|
matches
|
|
.map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`)
|
|
.join('\n'),
|
|
).toBe(0);
|
|
});
|
|
}
|
|
});
|