Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion extension/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions extension/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
133 changes: 133 additions & 0 deletions extension/src/lib/__tests__/placeholder.property.test.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement>(`.${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`);
});
});
89 changes: 89 additions & 0 deletions extension/src/rules/__tests__/pii-redact.property.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
});
Loading