# Property-Based Testing

This guide covers when and how to use property-based testing (PBT) in the AMSOIL DLP codebase. Today PBT is used exclusively for HTML sanitization, but the pattern generalizes to any code with a very large or infinite input space.

## What Property-Based Testing Is

Traditional example-based tests check that specific inputs produce specific outputs:

```ts
expect(sanitizeHtml('<script>alert(1)</script>')).toBe('');
```

Property-based tests instead describe an **invariant** - a rule that must hold for _every_ input - and let the library (`fast-check`) generate thousands of random inputs to try to break it:

```ts
fc.assert(
  fc.property(mixedHtml, (html) => {
    const result = sanitizeHtml(html);
    expect(structuralHtmlDoesNotMatch(result, /<script[\s>]/i)).toBe(true);
  }),
  { numRuns: 500 }
);
```

When `fast-check` finds a counterexample, it **shrinks** the input - repeatedly simplifying it - until it returns the smallest failing case, which is what gets reported in the test failure.

## Why It's Used Here

HTML sanitization is the textbook case for PBT:

- The input space is effectively infinite (any UTF-8 string).
- Security invariants (no `<script>`, no `on*=` handlers, no `javascript:` URIs) must hold for **all** inputs, not just the ones we happen to think of.
- Adversarial inputs matter. A sanitizer that handles the canonical XSS vectors but breaks on `<scr<script>ipt>alert(1)</script>` is still broken.
- Both sides of the sanitization boundary (Lexical WYSIWYG client output, server-stored HTML) run through `isomorphic-dompurify` and must uphold the same invariants.

PBT was introduced in commit `ae43c73` ("feat: add property-based testing for HTML sanitization"), adding `fast-check` as a dev dependency and shipping two test files covering the strict and relaxed sanitizers on both the server and client entry points.

## `fast-check` Patterns in Use

The two PBT files in the repo are:

- `lib/cms/__tests__/sanitize.property.test.ts` - server-side sanitizers (`lib/cms/sanitize.ts`)
- `lib/cms/__tests__/sanitize-client.property.test.ts` - client-safe sanitizers (`lib/cms/sanitize-client.ts`)

### Custom Arbitraries

`fast-check` ships with primitive generators (`fc.string`, `fc.webUrl`, `fc.integer`). Domain-specific arbitraries are composed from these. The sanitization tests define:

- **`safeHtmlElement`** - a `fc.oneof` of known-good tags (`<p>`, `<strong>`, `<a href="...">`) - see `sanitize.property.test.ts:49-58`.
- **`dangerousPayload`** - known-bad HTML and obfuscation variants, including `<scr<script>ipt>`, `jAvAsCrIpT:` casing tricks, and `data:text/html` payloads - see `sanitize.property.test.ts:61-83`.
- **`mixedHtml`** - arrays of 1–8 elements combining safe and dangerous fragments, joined into one string - `sanitize.property.test.ts:86-89`.
- **`randomStyleAttr` / `relaxedStyleAttr`** - CSS declarations drawn from safe and dangerous property lists, used to probe the style-attribute allowlist - `sanitize.property.test.ts:102-123`.
- **`arbitraryHtmlString`** - a catch-all that mixes `fc.string()` with edge cases like `''`, `'<'`, `'<<>>'`, `'&amp;&lt;&gt;'` for crash-testing - `sanitize.property.test.ts:126-133`.

### Invariants

Each test asserts one security invariant across every generated input. Examples from the server-side file:

| Invariant                                                            | Location                            |
| -------------------------------------------------------------------- | ----------------------------------- |
| Output never contains `<script>`                                     | `sanitize.property.test.ts:190-200` |
| Output never contains `on*=` handlers                                | `sanitize.property.test.ts:202-212` |
| Output never contains `javascript:` URIs                             | `sanitize.property.test.ts:214-227` |
| Output tags are a subset of the strict allowlist                     | `sanitize.property.test.ts:229-241` |
| Output CSS properties are a subset of `STRICT_CSS_PROPERTIES`        | `sanitize.property.test.ts:258-271` |
| Sanitization is idempotent - `sanitize(sanitize(x)) === sanitize(x)` | `sanitize.property.test.ts:273-282` |
| `sanitize` never throws on arbitrary string input                    | `sanitize.property.test.ts:284-291` |
| No `data-*` attributes survive                                       | `sanitize.property.test.ts:293-306` |
| Strict output ⊆ relaxed output (cross-module consistency)            | `sanitize.property.test.ts:593-612` |

The client-side file mirrors these one-for-one against the `isomorphic-dompurify`-based exports in `sanitize-client.ts`.

### Shrinking

`fast-check` shrinks failing inputs automatically - no extra code required. If a future sanitizer change breaks the "no event handlers" invariant, the reported counterexample will be the minimal fragment (e.g., a single `<div onfoo="...">`) rather than an eight-element mixed blob.

### Run Counts

Each property runs 300–1000 cases (`numRuns` argument). Higher counts go to crash-only checks (`never crashes on arbitrary string input` uses `numRuns: 1000`) and to the core allowlist invariants. Keep 500 as the default for new properties and raise only when the arbitrary is cheap and the invariant is security-critical.

### Helpers

The tests share a handful of pure helpers for inspecting sanitized output without false-positive matches (attribute values can legitimately contain angle brackets after encoding):

- `stripAttributeValues` - `sanitize.property.test.ts:145-147`
- `extractTagNames` - `sanitize.property.test.ts:150-154`
- `extractAttributeNames` - `sanitize.property.test.ts:157-160`
- `extractCssProperties` - `sanitize.property.test.ts:163-174`
- `structuralHtmlDoesNotMatch` - `sanitize.property.test.ts:180-183`

Copy these into new PBT files rather than re-deriving them.

## When to Reach for PBT vs Unit Tests

Use **PBT** when:

- Input space is very large or unbounded (strings, HTML, URLs, arbitrary JSON).
- You care about a property that must hold universally (no XSS survives; output length is bounded; function is idempotent; serialize/deserialize round-trips).
- The code is a parser, sanitizer, serializer, normalizer, or validator.
- Adversarial inputs are in scope (user-submitted content, third-party data, query strings).

Use **example-based unit tests** when:

- Input space is small or enumerable (enum dispatch, permission matrix, tier → feature mapping).
- You're testing business logic with specific expected outputs ("starter tier users see the upgrade CTA").
- You're testing integration wiring (a handler calls the right service with the right args).
- A single concrete example is the specification (a known regression, a fixture from a bug report).

**Use both** when the subject deserves it. The strict sanitizer has both a conventional test file (`sanitize.test.ts`) with concrete regression cases and a PBT file (`sanitize.property.test.ts`) that asserts invariants. The unit tests catch specific bugs we've seen; the PBT catches bugs we haven't.

## CI Integration

PBT tests run alongside all other tests via `npm test`, which is invoked from `.github/workflows/test.yml` on every push to `main` and on PRs that trigger the workflow.

There is **no separate PBT pipeline**. `fast-check` properties are ordinary Jest `describe`/`it` blocks - Jest doesn't know or care they're property-based.

### Runtime Impact

Locally, the full suite runs in a few seconds. The sanitization PBT files add roughly 1–2 seconds each - `numRuns: 500` against DOMPurify on adversarial inputs is not expensive. If you see PBT runtime climb past ~10 seconds per file, lower `numRuns` on the cheapest invariants rather than dropping runs from the security-critical ones.

### Determinism and Flakes

`fast-check` uses a deterministic seed per run by default, but the seed rotates. If a PBT test fails in CI but passes locally, the failure output includes the seed and the minimal counterexample - rerun with:

```ts
fc.assert(fc.property(...), { seed: 12345, path: '...', numRuns: 1 });
```

to reproduce. Record the counterexample as a concrete unit test before fixing the underlying bug.

## Extending Coverage

When adding a new sanitizer, parser, serializer, or normalizer, add a matching `*.property.test.ts` file next to its example-based tests. Template:

```ts
import fc from 'fast-check';
import { myTransform } from '../my-module';

// 1. Define custom arbitraries for your domain.
const domainInput = fc.oneof(
  fc.constant('known-good-example'),
  fc.constant('known-bad-example'),
  fc.string()
);

describe('PBT: myTransform', () => {
  // 2. One `it` per invariant.
  it('never throws on arbitrary string input', () => {
    fc.assert(
      fc.property(fc.string(), (input) => {
        expect(() => myTransform(input)).not.toThrow();
      }),
      { numRuns: 1000 }
    );
  });

  it('is idempotent', () => {
    fc.assert(
      fc.property(domainInput, (input) => {
        const once = myTransform(input);
        const twice = myTransform(once);
        expect(twice).toBe(once);
      }),
      { numRuns: 500 }
    );
  });

  // 3. Security or correctness invariants specific to the transform.
  it('output is always within bounds', () => {
    fc.assert(
      fc.property(domainInput, (input) => {
        const result = myTransform(input);
        expect(result.length).toBeLessThanOrEqual(MAX_LEN);
      }),
      { numRuns: 500 }
    );
  });
});
```

Minimum-viable invariants to consider for any new transform:

- `never throws on arbitrary input` - catches crashes on edge cases.
- `is idempotent` - catches state leaks or compounding behavior.
- `output satisfies structural constraint X` - the actual correctness property (no script tags, length bound, always valid JSON, etc.).
- `round-trip: decode(encode(x)) === x` - for serializers.

Copy the helper functions from `sanitize.property.test.ts:145-183` when you need to inspect structural output without being fooled by attribute-value contents.
