PermDock
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

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).

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" },
    },
  },
});
ExportRole
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).
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).
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 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 fixturesVitest 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, testReplayStoreConformance runners, one per extension interface 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

The scenario testing guide 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.

ExportRole
saasPermissions, saasRoles, saasPlans, saasPolicyproject 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, saasUsersOrgs 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). 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, saasAudienceES256 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.

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), and the config accepts tenants so one matrix runs with each active tenant; the repository's own matrix carries the tenancy cases from 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

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

  • 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.
  • Snapshot fixtures round-trip through the snapshot schema 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

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). 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.

  • 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: mockEndpoint request and response shapes.
  • Next.js Cache Components: Instant Navigations testing.

Last updated on

On this page