diff --git a/package.json b/package.json index b793f67..31a2bb5 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "build": "node build/build.mjs", "lint": "eslint .", "prepare": "node build/build.mjs", - "test": "node --experimental-test-module-mocks --test test/pdf-vfs.test.js" + "test": "node --experimental-test-module-mocks --test test/*.test.js" }, "peerDependencies": { "marked": ">=12", diff --git a/test/fixtures/xss-lint-covers.json b/test/fixtures/xss-lint-covers.json new file mode 100644 index 0000000..9e3ce36 --- /dev/null +++ b/test/fixtures/xss-lint-covers.json @@ -0,0 +1,12 @@ +{ + "_comment": [ + "DS §15 — this repo's declaration of what the XSS rule covers.", + "Product source only. build/build.mjs is tooling and is globally ignored." + ], + "covers": [ + "src/exporters/html.js", + "src/core.js", + "src/common.js" + ], + "why": "html.js is the HTML export surface (htmlPage, wordHtml, printDoc). core.js is renderHTML/escapeHtml. common.js is the shared helpers those call. Not build/build.mjs — tooling, globally ignored, and naming it would be the uploadwizard-app failure mode (a green count on files that do not render HTML)." +} diff --git a/test/fixtures/xss-lint-fixture.js b/test/fixtures/xss-lint-fixture.js new file mode 100644 index 0000000..54762c4 --- /dev/null +++ b/test/fixtures/xss-lint-fixture.js @@ -0,0 +1,70 @@ +/** + * TGWAB — fixture for the interpolated-JSON XSS rule. DEV-STANDARDS §15. + * + * Place at: test/fixtures/xss-lint-fixture.js + * Its test: test/xss-lint-fixture.test.js (templates/xss-lint-fixture.test.js) + * + * WHY A FIXTURE SHIPS WITH THE RULE. A rule only ever shown to pass on clean + * input has not been shown to do anything. This file is the positive control: + * five cases that MUST flag and two that MUST NOT, so a run proves the rule can + * both fire and stay silent. It is also how the selector choice was measured — + * the direct-child form catches cases 1 and 2 only. + * + * THIS FILE IS DELIBERATELY NOT LINTED BY THE REPO'S OWN RUN. The config's + * scope is `files: ['**\/*.js']`, which DOES reach this file, so what keeps it + * out is the explicit `'**\/xss-lint-fixture.js'` entry in that config's global + * ignores — named there with this reason. That is what lets `npm run lint` stay + * green with a file full of deliberate hazards in the tree; without it this + * fixture contributes five errors to every adopting repo. The test does NOT lint it where it sits — that would apply no rule at + * all — it reads this text and lints it under a synthetic src/ path so the + * repo's real scoping applies. See the test's header. + */ +/* eslint-disable no-unused-vars, no-undef -- every case here is a specimen: it + is never called, and `escapeHtml` is deliberately undeclared because the + point is the SHAPE of the call, not a working implementation. Only these two + rules are disabled, and never no-restricted-syntax — disabling that would + silence the very thing this file exists to trip. */ +// Seven cases: five that MUST flag, two that MUST NOT. +// Numbered so a run's output can be read against the spec without counting. + +// ---- MUST FLAG ------------------------------------------------------------- + +// 1. Direct child. The shape the shipped rules were written for. +function case1(n) { + return ``; +} + +// 2. Direct child, no `; +} + +// 4. Grandchild via a wrapper call. NOT exploitable — see the template comment — +// but the same wrong pattern with the same remedy. +function case4(n) { + return ``; +} + +// 5. Grandchild via an arrow inside .map(). +function case5(xs) { + return ``; +} + +// ---- MUST NOT FLAG --------------------------------------------------------- + +// 6. The literal TEXT "JSON.stringify" inside a browser-script template. No call. +function case6() { + return ``; +} + +// 7. An ordinary call, nowhere near a template literal. +function case7(x) { + const body = JSON.stringify(x); + return body; +} diff --git a/test/xss-lint-fixture.test.js b/test/xss-lint-fixture.test.js new file mode 100644 index 0000000..098f054 --- /dev/null +++ b/test/xss-lint-fixture.test.js @@ -0,0 +1,178 @@ +/** + * TGWAB — the XSS rule's own control. DEV-STANDARDS §15. + * + * Runner is node:test (this repo does not use vitest); assertions match the + * vendored kit. The specimen (`fixtures/xss-lint-fixture.js`) is byte-identical + * to tgwab-standards `templates/xss-lint-fixture.js`. Adding vitest for this + * one file would be a second runner that only the kit uses — gh-office already + * made this call. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { ESLint } from "eslint"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { basename, dirname, join, relative, resolve } from "node:path"; + +const here = dirname(fileURLToPath(import.meta.url)); +const FIXTURE = resolve(here, "fixtures/xss-lint-fixture.js"); +const COVERS = resolve(here, "fixtures/xss-lint-covers.json"); + +async function flaggedCases() { + const code = readFileSync(FIXTURE, "utf8"); + const eslint = new ESLint(); + const [result] = await eslint.lintText(code, { + filePath: resolve(process.cwd(), "src/__xss-lint-fixture__.js"), + }); + const lines = code.split("\n"); + const cases = new Set(); + for (const m of result.messages) { + if (m.ruleId !== "no-restricted-syntax") continue; + for (let i = m.line - 1; i >= 0; i--) { + const name = /function (case\d)/.exec(lines[i]); + if (name) { + cases.add(name[1]); + break; + } + } + } + return [...cases].sort(); +} + +test("the interpolated-JSON XSS rule flags all five hazards (DS §15)", async () => { + const flagged = await flaggedCases(); + assert.deepEqual(flagged, ["case1", "case2", "case3", "case4", "case5"]); +}); + +test("the interpolated-JSON XSS rule leaves the two negative controls alone", async () => { + const flagged = await flaggedCases(); + assert.ok(flagged.includes("case1")); + assert.ok(!flagged.includes("case6")); + assert.ok(!flagged.includes("case7")); +}); + +const KIT_FILES = new Set([ + "eslint.config.js", + "eslint.config.mjs", + "eslint.config.cjs", + "xss-lint-fixture.js", + "xss-lint-fixture.test.js", +]); + +function* shippedFiles(dir, root) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === ".git" || entry.name === "node_modules") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) yield* shippedFiles(full, root); + else if (entry.isFile()) yield relative(root, full); + } +} + +function carriesTheRule(config) { + const entry = config?.rules?.["no-restricted-syntax"]; + if (!Array.isArray(entry) || entry[0] !== 2) return false; + return entry + .slice(1) + .some( + (o) => + typeof o?.selector === "string" && + o.selector.includes("JSON") && + o.selector.includes("stringify"), + ); +} + +async function ruleCoverage(root = process.cwd()) { + const eslint = new ESLint(); + const covered = []; + let linted = 0; + for (const rel of shippedFiles(root, root)) { + if (KIT_FILES.has(basename(rel))) continue; + if (await eslint.isPathIgnored(rel)) continue; + linted++; + if (carriesTheRule(await eslint.calculateConfigForFile(rel))) covered.push(rel); + } + return { covered, linted }; +} + +function repoRoot() { + let dir = here; + for (;;) { + if (existsSync(join(dir, ".git"))) return dir; + const up = dirname(dir); + if (up === dir) return null; + dir = up; + } +} + +function resolveNamed(named, root) { + const fromCwd = resolve(process.cwd(), named); + if (existsSync(fromCwd)) return fromCwd; + if (root) { + const fromRoot = resolve(root, named); + if (existsSync(fromRoot)) return fromRoot; + } + return null; +} + +const NOT_PRODUCT_SOURCE = [ + /(^|\/)[^/]*\.config\.(js|mjs|cjs)$/, + /^(scripts|migrations|tests?|__tests__|e2e|loadtest|test-kit)\//, +]; + +function readDeclaration() { + let raw; + try { + raw = readFileSync(COVERS, "utf8"); + } catch { + throw new Error( + `DS §15: ${relative(process.cwd(), COVERS)} is missing. Create it naming ` + + "the product source this repo ships that the rule must cover.", + ); + } + const d = JSON.parse(raw); + if (!Array.isArray(d.covers)) throw new Error("DS §15: `covers` must be an array."); + return d; +} + +test("the rule resolves for every file this repo NAMES as covered product source", async () => { + const d = readDeclaration(); + const { covered, linted } = await ruleCoverage(); + process.stdout.write( + `DS §15 coverage: the rule resolves for ${covered.length} of ${linted} ` + + `linted files; ${d.covers.length} named as product source.\n`, + ); + + if (d.covers.length === 0) { + assert.ok( + typeof d.declaredGap === "string" && d.declaredGap.trim().length > 0, + "DS §15: `covers` is empty, so `declaredGap` MUST say why", + ); + return; + } + + const eslint = new ESLint(); + const root = repoRoot(); + for (const named of d.covers) { + const abs = resolveNamed(named, root); + const normalized = abs && root ? relative(root, abs) : named; + + assert.equal( + KIT_FILES.has(basename(named)), + false, + `DS §15: \`${named}\` is a file this repo GAINS by adopting the kit.`, + ); + assert.equal( + NOT_PRODUCT_SOURCE.some((re) => re.test(normalized)), + false, + `DS §15: \`${named}\` is build tooling, not product source.`, + ); + assert.ok( + abs !== null, + `DS §15: \`${named}\` is named in xss-lint-covers.json but does not exist.`, + ); + assert.ok( + carriesTheRule(await eslint.calculateConfigForFile(abs)), + `DS §15: the rule does NOT resolve for \`${named}\`.`, + ); + } +});