# Testing

Source: https://permdock.com/docs/adapters/testing

permdock/testing ships policy matrix tests over roles, permissions and fixtures, snapshot fixtures for UI adapters, an RLS parity runner, and Vitest type tests.

## Purpose [#purpose]

`permdock/testing` is a subpath of `permdock` that only test files import. Vitest is its optional peer, it never takes a Postgres client as a dependency (runners receive a query function), and no application entry imports it, so it adds nothing to an app bundle. It ships `describePolicy`, `snapshotFixture`, `rlsParity`, `ormParity`, `testClientParity`, `testClientStore`, `testHttpAdapter`, `testAgentAdapter`, the core-backed conformance runners (`testSubjectResolver`, `testMembershipSource`, `testRelationSource`, `testRoleSource`, `testApprovalStore`, `testDecisionSink`, `testSnapshotSource`, `testPolicySource`, `testWhereCompiler`, `testTokenVerifier`, `testTokenSigner`, `testLimitStore`, `testDirectoryStore`, `testReplayStore`, `testRevocationFeed`), the AuthZEN interop runner `testAuthZen` with the interop Todo domain, and Vitest type fixtures, plus one signed-output fixture per `typ` (`permdock-snapshot+jwt`, `permdock-approval+jwt`, `permdock-decisions+jwt`, `permdock-policy+jwt`). `mockEndpoint` and `mswHandlers` are planned. There is no `instant()` wrapper: call `@next/playwright` `instant()` directly ([Next.js Cache Components guide](/docs/guides/next-cache-components#testing-it)).

## API [#api]

```ts
import {
  describePolicy,
  snapshotFixture,
  rlsParity,
  expectTypeOf,
} from "permdock/testing";
import { policy } from "../src/policy";
import { permissions } from "../src/permissions";

describePolicy(policy, {
  subjects: {
    anonymous: null,
    member: { id: "u1", orgId: "o1", roles: ["member"] },
    admin: { id: "u2", orgId: "o1", roles: ["admin"] },
  },
  fixtures: {
    ownPost: { id: "p1", authorId: "u1", orgId: "o1", published: false },
    otherPost: { id: "p2", authorId: "u9", orgId: "o1", published: true },
  },
  matrix: {
    [permissions.post.create.key]: {
      anonymous: "denied",
      member: "granted",
      admin: "granted",
    },
    [permissions.post.update.key]: {
      ownPost: { anonymous: "denied", member: "granted", admin: "granted" },
      otherPost: { anonymous: "denied", member: "denied", admin: "granted" },
    },
    [permissions.post.delete.key]: {
      ownPost: { member: "approval-required", admin: "granted" },
    },
  },
});
```

| Export | Role |
| --- | --- |
| `describePolicy(policy, config)` | Generates one Vitest `describe` per permission and one `it` per subject and fixture, asserting `decide(...).outcome`. Every permission from `listPermissions` must appear in `matrix` (`exhaustive: true` by default); a missing cell fails the suite, so a new permission cannot ship untested. Cells accept `'granted'`, `'denied'`, `'approval-required'`, or an object with `denials` and `alternatives` for exact assertions. |
| `snapshotFixture(policy, subject, options?)` | Returns the JSON `permdock.snapshot()` would produce for that subject (optionally scoped with `include`), for use as the `snapshot` prop of `PermDockProvider` in component tests and Storybook. |
| `mockEndpoint(policy, subjects)` | A Fetch-compatible handler answering AuthZEN `evaluations` requests from a chosen subject, for testing closure-backed `usePermission` without a server (MSW or Vitest browser mode). |
| `mswHandlers(policy, subjects, options?)` | [MSW](https://mswjs.io) `http.*` handlers built from `mockEndpoint` plus, when `options.openapi` is the applied OpenAPI description, one handler per protected operation that answers `403` Problem Details (`.../denied` or `.../approval-required`, with `permission`, `denials`, `alternatives`) when the chosen subject is not granted the operation's `x-permdock-permissions`, and passes through otherwise. Lets a Storybook story or a component test show the real denial body an SDK generated by Hey API or Orval would receive, without a server. Same handlers in Node (`setupServer`) and the browser (`setupWorker`). |
| `testClientParity(policy, cases, { customRoles })` | For each case (`name`, `user`, `tenant`, `permission`, `row`), builds the server instance and `fromSnapshot(permdock.snapshot())` and asserts the client never grants what the server denies. Cases marked `stricter: true` (a closure deny, a quota, an approval) may deny on the client; every other case must match. |
| `testHttpAdapter({ name, mount, skip?, streams? })` | Real-life HTTP scenarios over the shared SaaS domain. `mount(domain)` builds the application once with the adapter under test and returns `fetch` (a REST mount, usually over a real server on an ephemeral port), `call` (an RPC mount that maps each operation to a procedure and its error to a status) and `close`. The runner signs bearer tokens and sends 14 scenarios: tenant from the path on every `protect`, a role that differs per org, a custom role, a plan gate, an expired membership, a quota per subject and tenant, boundary validation and a cross-tenant create, `assert` in a handler, approval with resume and replay, a stale approval header, the decision endpoint in the path tenant, a multipart upload left to the framework parser, a sub-app, and 50 parallel requests with mixed subjects. Expectations are written by hand. `skip` names a scenario the transport cannot express, with a reason. With `streams: true` the mount also serves an SSE events route over a kernel `Connection`, and four more scenarios run: a revoked session closes the stream, a membership change that re-denies it closes it, items the subscriber cannot read are dropped while the stream stays open, and an expired token closes it. The kernel and Hono mounts enable it. The route contract is on the `HttpMounted` type. |
| `testAgentAdapter({ name, mount, skip? })` | Tool-call scenarios over the shared SaaS domain for an agent adapter. `mount(domain)` wires the adapter with `domain.subject`, `domain.tools`, `domain.customRoles` and `domain.store`, and returns `call({ user, org, tool, args })`, which runs one call through the adapter's own entry and answers `granted`, `denied` or `approval-required`. Eight scenarios: a grant, a user with no membership, a row from another org, no user, a subject resolver that throws (`AGENT_THROWING_USER`), a row loader that throws (`AGENT_THROWING_ROW`), a missing row, and an approval. Every failure must deny. `skip` names a scenario the adapter cannot express, with a reason. |
| `ormParity(policy, scenarios, { run, id })` | For each scenario (`name`, `user`, `options`, `permission`, `rows`), the expected ids are `permdock.filter(permission, rows)` in memory; `run({ scenario, where })` executes `toWhere(where)` against a real database and returns ids. The sets must be equal. A `partial` `where()` may throw non-portable (fail closed) or return a subset. Returns `{ ok, results }` with `expected`, `actual` and `error` per scenario. |
| `testClientStore(name, createStore)` | Registers race and cache cases for a client snapshot store over the saas domain: a tenant switch the snapshot carries (no request) and one it does not (one `?tenant=` query, committed only on success), `pending` during a refresh, a refresh resolving after `clear()` or after a `replace` for another user (dropped), a stale `expiresAt`, a signed seed and a signed refresh through `verifier`, endpoint answers cached per tenant and forgotten on a new snapshot, and no synchronous notification from a status read. `createStore` receives `{ snapshot, snapshotUrl, endpoint, tenant, fetch, verifier }` and returns `{ get, replace }`; PermDock runs it against `createNativeStore` and the React, Vue, Svelte and Solid providers. |
| `testAuthZen(handle, { vectors, origin?, headers?, discovery? })` | Runs AuthZEN 1.0 vectors in the interop harness layout (`evaluation`, `evaluations`, and `search.subject` / `search.resource` / `search.action` compared order-insensitively) against any `(Request) => Promise<Response>`, and checks the `.well-known/authzen-configuration` document. `authzenTodoPermissions`, `authzenTodoPolicy`, `authzenTodoUsers`, `authzenTodoData` and `authzenTodoVectors()` are the interop Todo domain; the official vectors are fetched, never vendored ([AuthZEN conformance](/docs/standards/authzen#conformance)). |
| `rlsParity(policy, options)` | Compares `can()` to a caller-supplied `query` under `set local role` (`authenticated` or `anon`, never `service_role`) plus GUC or `request.jwt.claims`. Outcomes are `allowed`, `filtered` or `rejected` (`42501`). `customRoles` resolves custom roles in `can()` and adds each membership's compact `grants` claim for `jwt`-mode helpers; seeding the `database`-mode tables is the caller's. Memberships are written to the claim in the canonical [named scopes](/docs/concepts/scopes) form. `snapshot: true` also decides each case from the subject's serialized snapshot (`fromSnapshot`) and reports it as `snapshot`; a disagreement fails the case. A fixture subject's `claims` are read in memory as `principal.claims.*` and sent as token claims (`supabase`, `neon`) or one `<prefix>.<name>` setting per top-level claim (`guc`). The `neon` dialect sets `request.jwt.claims`, so the test database stubs `auth.session()` and `auth.user_id()` over it. `fieldViews: true` checks the `rls generate --fields views` output: each instance read also selects its row from `<table>_visible` (or the table when it has no view) and reports `fields: { app, database }`, the columns `pick` keeps plus the key and the columns holding a value; a difference fails the case, and statements read back only the key so a table closed with `--revoke-columns` does not reject them. `tests/integration` wraps testcontainers Postgres; pgTAP stays on `permdock rls verify`. |
| `expectTypeOf` and type fixtures | Vitest `expectTypeOf`-based assertions for arity (`can(permissions.post.create, post)` is an error), reference identity across `mergePermissions`, subject narrowing after `assert`, and `never` exhaustiveness on `Decision.outcome` switches. |
| `testSubjectResolver`, `testMembershipSource`, `testRelationSource`, `testRoleSource`, `testApprovalStore`, `testDecisionSink`, `testSnapshotSource`, `testPolicySource`, `testWhereCompiler`, `testTokenVerifier`, `testTokenSigner`, `testLimitStore`, `testDirectoryStore`, `testReplayStore` | Conformance runners, one per [extension interface](/docs/concepts/extension-interfaces) that has a runner: a provider mapper never throws and fails closed to anonymous, a membership source returns well-formed memberships, a relation source answers chains nearest first within `depth` and nothing for an unknown object, a role source never resolves beyond the declared assignable roles, a store round-trips and refuses double resolution, a sink never propagates errors, a snapshot source round-trips a snapshot, a policy source answers synchronously and never lets a grant outside `hostable` merge, a compiler fails closed on the empty allow set and, given `matches`, selects the rows the in-memory evaluator keeps, NULL fields included, a verifier maps the JWT behaviour table, a signer emits compact JWS with only `alg`, `kid` and `typ`, a limit store counts down and fails closed, a directory store isolates tenants, a replay store remembers a `jti`. Provider adapters in this repository and community implementations run the same suites. |

### Shared SaaS domain [#shared-saas-domain]

The [scenario testing guide](/docs/guides/scenario-testing) explains how PermDock runs these against every surface and how to add a fixture app.

`permdock/testing/saas` is a second entry that imports neither Vitest nor Node built-ins, so a fixture app can load it at runtime. It is one realistic multi-tenant SaaS: the oracle every framework fixture, ORM parity run and client suite in this repository shares.

| Export | Role |
| --- | --- |
| `saasPermissions`, `saasRoles`, `saasPlans`, `saasPolicy` | `project` and `doc` rows (tenant and team relations), collection actions for members, settings, billing, plan-gated analytics, audit and SSO, and `apiKey` with a daily quota (`create`) and an owner approval (`revokeAll`). Roles: `owner`, `admin`, `member`, `viewer` on the tenant, `lead` on the team (with a closure deny on locked docs), `collaborator` on one project. |
| `saasSeed`, `saasPrincipal(user, tenant?)`, `saasMemberships(user)`, `saasCustomRoles`, `saasUsers` | Orgs `acme` (free, custom role `contractor`), `globex` (pro), `org-1` and `tenant-1`; users chosen for one hazard each: a role that differs per org, the same role in two orgs, an expired membership, no memberships, a team lead, a team role held on the tenant membership (`ivan`), a resource collaborator, and ids such as `user-2` and `2` or tenants such as `org-1` and `tenant-1` that loose comparison would confuse. |
| `saasScenarios`, `saasProject(id)`, `saasDoc(id)` | Hand-written `{ user, tenant, permission, row, expected }` cases. The expectations are never computed by the engine; each suite runs them through its own surface (a decision, an HTTP status, a SQL row count, a rendered button). `client: false` marks a case that depends on a quota store or an approval. A case may carry an `actor` and a `delegation` (an agent acting for the user) and `quotaUsed` (api-key quota already spent); `saasUser(scenario)` builds the subject and `saasScenarioOptions(scenario)` the tenant, custom roles, primed quota store (`saasLimitStore(used)`) and folder tree. |
| `saasFolderScenarios`, `saasFolder(id)`, `saasRelations(seed?)` | The folder tree: acme's `root` ─ `eng` ─ `platform` ─ `infra` and `root` ─ `hr` (restricted) ─ `payroll`, with viewer and editor shares in `seed.shares` (one expired). Decide them with `createPermDock(saasPolicy, principal, { relations: saasRelations() })`; `clientOutcome: 'denied'` marks graph grants a snapshot client sends to the server ([relationships](/docs/concepts/relationships)). `saasSchemaSql` creates `folder`, `folder_share` and `folder_editor`. |
| `saasSchemaSql`, `saasSeedSql(seed?)` | Postgres tables whose columns match the resource fields, with RLS enabled and forced on the row tables, plus `insert` statements for the seed. Policies come from `permdock rls generate`. |
| `signSaasToken(sub, options?)`, `verifySaasToken(token, now?)`, `saasJwks`, `saasIssuer`, `saasAudience` | ES256 access tokens signed with WebCrypto (no `jose` dependency) under a test-only key, with the seed memberships as a claim unless `memberships: false`. `verifySaasToken` checks the signature, `kid`, `iss`, `aud` and `exp` and returns `sub`, or `null` for anything else; it stands in for the verifying auth layer in fixtures. |

`permdock/testing/saas/permissions` carries only the definitions (`saasPermissions`, `saasRoles`, `saasPlans`, the row schemas and types) for client bundles that cannot tree-shake the policy, seed and test key away, such as Metro in the Expo fixture.

```ts
import { createPermDock } from "permdock";
import {
  saasPolicy,
  saasScenarioOptions,
  saasScenarios,
  saasUser,
} from "permdock/testing/saas";

for (const scenario of saasScenarios) {
  const permdock = await createPermDock(
    saasPolicy,
    saasUser(scenario),
    saasScenarioOptions(scenario),
  );
  expect(permdock.decide(scenario.permission, scenario.row).outcome).toBe(
    scenario.expected.outcome,
  );
}
```

`describePolicy` subjects may carry `memberships` and `tenant` ([tenancy](/docs/concepts/tenancy)), and the config accepts `tenants` so one matrix runs with each active tenant; the repository's own matrix carries the tenancy cases from [tenancy](/docs/concepts/tenancy) (tenant allow versus global deny, team role outside the active tenant, resource role through parent hops, expired membership, custom role with an unknown include, collection action in a foreign tenant). `snapshotFixture` accepts `{ tenant, tenants: 'all', simulated: true }` and emits a `v: 1` snapshot.

Matrix rows are keyed by `permission.key` computed from the reference (`[permissions.post.update.key]`), never by a string literal, because an object literal cannot be keyed by the reference itself. The matrix is a test, not a report; the permission inventory comes from `permdock catalog`.

## Request lifecycle [#request-lifecycle]

Tests do not run a request, but the helpers follow the same order as adapters so failures point at the right layer:

1. Subject: `describePolicy` builds each `PermDock` with `createPermDock(policy, subject)`, including `null` for anonymous and optional `actor` / `delegation` for agent cases (a matrix may declare `agents` whose cells must never exceed their principal's).
2. Instance: one immutable instance per subject; fixtures are validated against the resource schema before use so a malformed fixture fails as `PermDockValidationError`, not as a wrong outcome.
3. Check: `decide` for every cell; `filter` for collection cells when `fixtures` are arrays; `simulate` for agent plans.
4. Denial surface: cells asserting `denials` compare `role` and `reason` text, keeping error messages stable for models and UI.

## What it validates [#what-it-validates]

* Matrix exhaustiveness against `listPermissions(permissions)`.
* Fixtures against resource schemas (always, regardless of the policy's `validate` mode).
* Agent cells: an `actor` with `delegation` never receives `granted` where its principal is `denied`.
* RLS parity: for each `read` cell, `filter` in-process equals the rows visible under the database role; for `create`/`update`/`delete`, `granted` equals a successful statement and `denied` equals `rejected 42501` or `filtered` (zero rows affected), per the semantics in [Postgres RLS](/docs/standards/postgres-rls).
* Snapshot fixtures round-trip through the [snapshot schema](/docs/concepts/wire-formats) so UI tests use exactly what the provider validates.
* RLS parity for `memberOf`: named scopes and resource scopes compile to the mapped membership tables and agree with `can()` for each dialect, including expired memberships and parent hops.

## How denials surface [#how-denials-surface]

Test failures print the `Decision`: outcome, matched grant, `denials` with role and reason, `alternatives`. Parity failures print both sides (in-process outcome and database outcome with the SQL error code). Type failures are ordinary `tsc` errors surfaced by Vitest's typecheck mode.

## Example app [#example-app]

— (no dedicated example). `describePolicy` suites run in `packages/permdock/tests/testing`; every example under `apps/examples` runs `permdock doctor`. `tests/integration` runs `rlsParity` against testcontainers Postgres (`pnpm test:integration`), `ormParity` for Drizzle (node-postgres and PGlite), Kysely and Prisma 7 in `src/orm-parity.test.ts`, `testAgentAdapter` in `packages/permdock/tests/testing` against `permdock/ai-sdk`, `permdock/openai`, `permdock/claude-agent`, `permdock/eve`, `permdock/mcp`, `permdock/a2a` and `permdock/terminal`, and `testHttpAdapter` in `tests/integration/src/http` against the kernel, `permdock/node`, Hono, Express, Fastify, Elysia, Nest on both platforms, tRPC, oRPC and `permdock/supabase/middleware`. `apps/examples/supabase-rls` is the consumer-facing fixture. `tests/runtimes` (`pnpm test:runtimes`) serves one Fetch app built from the kernel, `permdock/hono`, `permdock/authzen` and `permdock/jwt` under Bun, Deno and workerd, plus `permdock/elysia` on Bun, and replays the same request table against each; the workerd bundle is built with `platform: 'neutral'` and runs without `nodejs_compat`.

## Related standards [#related-standards]

* [Postgres RLS](/docs/standards/postgres-rls): parity semantics (`USING`, `WITH CHECK`, `42501`). `rlsParity` takes a `query` function, so any driver or a Neon branch works; this repository uses `pg` against testcontainers.
* [AuthZEN](/docs/standards/authzen): `mockEndpoint` request and response shapes.
* [Next.js Cache Components](/docs/guides/next-cache-components#why-this-shape): Instant Navigations testing.
