diff --git a/extension/bun.lock b/extension/bun.lock index a25dbd6..b00e894 100644 --- a/extension/bun.lock +++ b/extension/bun.lock @@ -31,6 +31,7 @@ "eslint-plugin-jest": "^29.15.2", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-unicorn": "64.0.0", + "fast-check": "^4.8.0", "globals": "17.6.0", "jest": "^30", "jest-environment-jsdom": "^30", @@ -630,6 +631,8 @@ "expect": ["expect@30.4.1", "", { "dependencies": { "@jest/expect-utils": "30.4.1", "@jest/get-type": "30.1.0", "jest-matcher-utils": "30.4.1", "jest-message-util": "30.4.1", "jest-mock": "30.4.1", "jest-util": "30.4.1" } }, "sha512-PMARsyh/JtqC20HoGqlFcIlQAyqUtW4PlI1rup1uhYJtKuwAjbvWi3GQMAn+STdHum/dk8xrKfUM1+5SAwpolA=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -918,7 +921,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], + "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], @@ -1156,6 +1159,8 @@ "jest-circus/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="], + "jest-circus/pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], + "jest-cli/jest-util": ["jest-util@30.4.1", "", { "dependencies": { "@jest/types": "30.4.1", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", "picomatch": "^4.0.3" } }, "sha512-vjQb1sACEiv13DKJMDToJpzVW0joCsIQrmbg0fi7CyOOt+g9jTuQl2A216pWRBYhOVt53XbL/2LbMKg1BECWOw=="], "jest-config/babel-jest": ["babel-jest@30.4.1", "", { "dependencies": { "@jest/transform": "30.4.1", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", "babel-preset-jest": "30.4.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "peerDependencies": { "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "sha512-fATAbM8piYxkiXQp3RBXmZHxZVNJZAVXXfyeyCN2Tida3+qJ8ea9UxhiJ2y4fLO90ZImKt6k9FlcH2+rLkJGhw=="], diff --git a/extension/package.json b/extension/package.json index e5339d9..4585b3c 100644 --- a/extension/package.json +++ b/extension/package.json @@ -69,6 +69,7 @@ "eslint-plugin-jest": "^29.15.2", "eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-unicorn": "64.0.0", + "fast-check": "^4.8.0", "globals": "17.6.0", "jest": "^30", "jest-environment-jsdom": "^30", diff --git a/extension/src/lib/__tests__/placeholder.property.test.ts b/extension/src/lib/__tests__/placeholder.property.test.ts new file mode 100644 index 0000000..0a171e8 --- /dev/null +++ b/extension/src/lib/__tests__/placeholder.property.test.ts @@ -0,0 +1,133 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Property-based tests for `replaceMatchesInTextNode`. The invariants are +// small but easy to break when iterating on the cursor/overlap logic, so we +// fuzz with fast-check rather than enumerating cases by hand. + +import fc from "fast-check"; + +import type { InlineMatch } from "../placeholder"; +import { PLACEHOLDER_CLASS, replaceMatchesInTextNode } from "../placeholder"; +import type { RuleId } from "../storage"; + +const RULE_ID = "pii-redact" as RuleId; +const LABEL = "[hidden]"; + +// Generate a string + a list of disjoint match windows over that string. +// `start` is inside the string, `end` is at most string.length, and each +// match's `start` is >= the previous `end` so they never overlap. +const disjointMatches = fc + .tuple( + fc.string({ minLength: 1, maxLength: 200 }), + fc.array(fc.tuple(fc.nat(20), fc.integer({ min: 1, max: 20 })), { + maxLength: 8, + }), + ) + .map(([text, raw]) => { + const matches: InlineMatch[] = []; + let cursor = 0; + for (const [gap, length] of raw) { + const start = cursor + gap; + const end = start + length; + if (end > text.length) { + break; + } + matches.push({ start, end, label: LABEL }); + cursor = end; + } + return { text, matches }; + }); + +function applyToFreshNode( + text: string, + matches: InlineMatch[], +): HTMLDivElement { + const host = document.createElement("div"); + const node = document.createTextNode(text); + host.append(node); + replaceMatchesInTextNode(node, matches, RULE_ID); + return host; +} + +describe("replaceMatchesInTextNode (property)", () => { + it("leaves the text node untouched when there are no matches", () => { + fc.assert( + fc.property(fc.string({ maxLength: 200 }), (text) => { + const host = applyToFreshNode(text, []); + expect(host.childNodes.length).toBe(1); + expect(host.firstChild?.nodeType).toBe(Node.TEXT_NODE); + expect(host.textContent).toBe(text); + expect(host.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + }), + ); + }); + + it("emits one inline placeholder per disjoint match", () => { + fc.assert( + fc.property(disjointMatches, ({ text, matches }) => { + const host = applyToFreshNode(text, matches); + const placeholders = host.querySelectorAll(`.${PLACEHOLDER_CLASS}`); + expect(placeholders).toHaveLength(matches.length); + for (const placeholder of placeholders) { + expect(placeholder.textContent).toBe(LABEL); + } + }), + ); + }); + + it("preserves non-matched text exactly, in order", () => { + fc.assert( + fc.property(disjointMatches, ({ text, matches }) => { + const host = applyToFreshNode(text, matches); + // Visible text = original gaps stitched together with LABEL between. + const expected: string[] = []; + let cursor = 0; + for (const match of matches) { + if (match.start > cursor) { + expected.push(text.slice(cursor, match.start)); + } + expected.push(LABEL); + cursor = match.end; + } + if (cursor < text.length) { + expected.push(text.slice(cursor)); + } + expect(host.textContent).toBe(expected.join("")); + }), + ); + }); + + it("restores the original text after every placeholder is revealed", () => { + fc.assert( + fc.property(disjointMatches, ({ text, matches }) => { + const host = applyToFreshNode(text, matches); + // Revealing replaces the placeholder with its original substring, so + // iterating over a live NodeList would skip nodes as it shrinks. Snap + // first, then click each. + const placeholders = [ + ...host.querySelectorAll(`.${PLACEHOLDER_CLASS}`), + ]; + for (const placeholder of placeholders) { + placeholder.dispatchEvent(new MouseEvent("click")); + } + expect(host.textContent).toBe(text); + expect(host.querySelector(`.${PLACEHOLDER_CLASS}`)).toBeNull(); + }), + ); + }); + + it("drops overlapping matches instead of double-replacing", () => { + // Hand-built overlap — two windows where the second starts inside the + // first. The implementation skips by `match.start < cursor`, so only the + // first should land. + const text = "abcdefghij"; + const matches: InlineMatch[] = [ + { start: 1, end: 5, label: LABEL }, + { start: 3, end: 7, label: LABEL }, + ]; + const host = applyToFreshNode(text, matches); + expect(host.querySelectorAll(`.${PLACEHOLDER_CLASS}`)).toHaveLength(1); + expect(host.textContent).toBe(`a${LABEL}fghij`); + }); +}); diff --git a/extension/src/rules/__tests__/pii-redact.property.test.ts b/extension/src/rules/__tests__/pii-redact.property.test.ts new file mode 100644 index 0000000..9a97bac --- /dev/null +++ b/extension/src/rules/__tests__/pii-redact.property.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 PixieBrix, Inc. +// Licensed under PolyForm Shield 1.0.0 — see LICENSE. + +// Property-based tests for the Luhn-gated card detector. The regex +// (`\b(?:\d[ -]?){12,18}\d\b`) accepts plenty of digit-only strings the Luhn +// check then has to reject; fast-check explores that boundary with random +// digit sequences and computed check digits. + +import fc from "fast-check"; + +import { PLACEHOLDER_CLASS } from "../../lib/placeholder"; +import { piiRedactRule } from "../pii-redact"; + +// Compute the Luhn check digit for a numeric string. Doubling and -9 +// folding matches the rule's `passesLuhn` so the appended digit yields a +// total divisible by 10. +function luhnCheckDigit(digits: string): string { + let sum = 0; + let alt = true; // rightmost data digit gets doubled (it becomes second-from-right after append) + for (let i = digits.length - 1; i >= 0; i--) { + let n = Number(digits[i]); + if (alt) { + n *= 2; + if (n > 9) { + n -= 9; + } + } + sum += n; + alt = !alt; + } + const remainder = sum % 10; + return String((10 - remainder) % 10); +} + +// 12-18 body digits + 1 Luhn check digit = 13-19 total, matching the regex. +const cardDigitsArb = fc + .integer({ min: 12, max: 18 }) + .chain((length) => + fc + .stringMatching(new RegExp(`^[0-9]{${length}}$`)) + .map((body) => body + luhnCheckDigit(body)), + ); + +// Flip the last digit by +1 (mod 10) — breaks the checksum, keeps length. +const invalidCardDigitsArb = cardDigitsArb.map((valid) => { + const lastDigit = Number(valid.at(-1)); + return valid.slice(0, -1) + String((lastDigit + 1) % 10); +}); + +function applyPiiToText(text: string): HTMLElement { + document.body.innerHTML = ""; + const p = document.createElement("p"); + p.textContent = text; + document.body.append(p); + piiRedactRule.apply(document.body); + return document.body; +} + +afterEach(() => { + piiRedactRule.teardown(); + document.body.innerHTML = ""; +}); + +describe("pii-redact Luhn detection (property)", () => { + it("masks every Luhn-valid card number", () => { + fc.assert( + fc.property(cardDigitsArb, (card) => { + const body = applyPiiToText(`card ${card} end`); + const placeholder = body.querySelector(`.${PLACEHOLDER_CLASS}`); + expect(placeholder?.textContent).toBe("[card hidden]"); + expect(body.textContent).not.toContain(card); + }), + ); + }); + + it("leaves Luhn-invalid digit runs alone", () => { + fc.assert( + fc.property(invalidCardDigitsArb, (notCard) => { + const body = applyPiiToText(`digits ${notCard} end`); + // No card placeholder. The phone/SSN regex could in principle bite + // (phone needs separators; SSN needs the 3-2-4 hyphen shape), but a + // bare digit run won't hit either. + const placeholder = body.querySelector(`.${PLACEHOLDER_CLASS}`); + expect(placeholder).toBeNull(); + expect(body.textContent).toContain(notCard); + }), + ); + }); +});