Testing
@permdock/testing ships policy matrix tests over roles, permissions and fixtures, snapshot fixtures for UI adapters, an RLS parity runner, Next.js instant() helpers and Vitest type tests.
Status: planned Phase: 1
Purpose
@permdock/testing is a separate package (it depends on Vitest, Playwright helpers and a Postgres client, none of which belong in permdock). It gives every app the same four kinds of test the PermDock repo runs on itself: a policy matrix that pins each role's outcome for every permission and fixture, snapshot fixtures so UI adapters can be tested without a server, an RLS parity runner that proves generated policies agree with can(), and helpers for asserting Instant Navigation behaviour in Next.js. Type tests cover what the runtime cannot: that arity, reference identity and subject narrowing are enforced by the compiler.
API
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 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). |
rlsParity(policy, options) | Runs each matrix cell in-process and against a Postgres database under set local role plus request.jwt.claims, comparing outcomes as allowed, filtered or rejected 42501. Wraps testcontainers; emits pgTAP on request. |
instant | Re-exports and wraps @next/playwright helpers so the example and user apps can assert that a guarded route renders from the prefetched App Shell without a blocking request, and that updateTag refreshes the shell. |
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, testRoleSource, testApprovalStore, testDecisionSink, testSnapshotSource, testWhereCompiler | Conformance runners, one per extension interface: a provider mapper never throws and fails closed to anonymous, a membership source returns well-formed memberships, 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 snapshot v2, a compiler fails closed on the empty allow set. Provider adapters in this repository and community implementations run the same suites. |
describePolicy subjects may carry memberships and tenant (tenancy), and the config accepts tenants so one matrix runs with each active tenant; the repository's own matrix carries the tenancy cases from ADR 0024 (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 snapshot v2.
Request lifecycle
Tests do not run a request, but the helpers follow the same order as adapters so failures point at the right layer:
- Subject:
describePolicybuilds eachPermDockwithcreatePermDock(policy, subject), includingnullfor anonymous and optionalactor/delegationfor agent cases (a matrix may declareagentswhose cells must never exceed their principal's). - 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. - Check:
decidefor every cell;filterfor collection cells whenfixturesare arrays;simulatefor agent plans. - Denial surface: cells asserting
denialscompareroleandreasontext, keeping error messages stable for models and UI.
What it validates
- Matrix exhaustiveness against
listPermissions(permissions). - Fixtures against resource schemas (always, regardless of the policy's
validatemode). - Agent cells: an
actorwithdelegationnever receivesgrantedwhere its principal isdenied. - RLS parity: for each
readcell,filterin-process equals the rows visible under the database role; forcreate/update/delete,grantedequals a successful statement anddeniedequalsrejected 42501orfiltered(zero rows affected), per the semantics in Postgres RLS. - Snapshot fixtures round-trip through the snapshot v2 schema (v1 for policies without memberships) so UI tests use exactly what the provider validates.
- RLS parity for
memberOf: tenant, team and resource scopes compile to the mapped membership tables and agree withcan()for each dialect, including expired memberships and parent hops.
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
— (no dedicated example). Every example under apps/examples carries a describePolicy suite; apps/examples/supabase-rls runs rlsParity against testcontainers in tests/integration; apps/examples/next uses instant in tests/e2e.
Related standards
- Postgres RLS: parity semantics (
USING,WITH CHECK,42501). - AuthZEN:
mockEndpointrequest and response shapes. - Research: Next.js 16.3 Instant Navigations, Postgres RLS research.
Open questions
- Matrix keys: the sketch above uses
permission.keystrings as object keys because TypeScript object literals cannot be keyed by references; a tuple-basedcells([permissions.post.update, fixture, outcomes])form would keep references typed and is the main alternative. - Whether
describePolicyshould also emit a Markdown or JSON report of the matrix forpermdock catalog. - Which Postgres drivers
rlsParitysupports out of the box (pg,postgres, Supabase local) and whether Neon's branching can replace testcontainers in CI. - Whether
instantshould live here or stay an example-only dependency on@next/playwright.
JWT
permdock/jwt verifies bearer JWTs against a JWKS or secret with jose as an optional peer and returns a PermDock subject: principal from sub, delegation from scope and authorization_details, actor from act, sender binding from cnf. Verification failure yields the anonymous subject, never an exception.
CLI
The @permdock/cli package, its commands, exit codes and how to run it in CI.