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 (permdock/testing/core needs no runner, see other test runners), 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, policyMatrix, snapshotFixture, seedRows, testRlsParity, testOrmParity, testClientParity, testLegacyParity, testClientStore, testHttpAdapter, testAgentAdapter, the core-backed conformance runners (testSubjectResolver, testMembershipSource, testRelationSource, testCustomRoleSource, testApprovalStore, testDecisionSink, testSnapshotSource, testPolicySource, testWhereCompiler, testTokenVerifier, testTokenSigner, testLimitStore, testDirectoryStore, testReplayStore, testSetQueue, testRevocationFeed, testIdempotencyStore), the AuthZEN interop runner testAuthzen with the interop Todo domain, the PermDock Cloud contract runner testCloudApi, and Vitest type fixtures, plus one signed-output fixture per typ (vnd.permdock.snapshot+jwt, vnd.permdock.approval+jwt, vnd.permdock.evidence+jwt, vnd.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,
testRlsParity,
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 |
|---|---|
policyMatrix(policy, config) | Decides the same matrix with no test runner and returns { cells, missing }. Each cell is { name, permission, subject, fixture?, expected, actual, decision, failures }, where failures lists each way the decision differs from the cell (empty when it holds) and name is <permission> / <subject> or <permission> / <fixture> / <subject>. missing lists the permissions an exhaustive matrix leaves out. Also exported from permdock/testing/core. |
describePolicy(policy, config) | policyMatrix under Vitest: one describe per permission and one it per subject and fixture, each asserting the cell has no failures. 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. |
seedRows(query, rows) | Inserts rows, such as permdock gen --kind factories output, through the same query function testRlsParity takes: seedRows(query, { project: [project], task_item: [task] }). Tables are inserted in key order, so list parents first. Each row is one parameterised insert with its own keys as quoted column names; undefined fields keep the column default, and a table or column that is not a plain identifier throws. Run it as the table owner, before RLS applies. Resolves to the number of rows inserted. |
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. |
testLegacyParity(policy, legacy, cases) | For each case (name, user, permission, row, expect), asserts that the policy answers as the application's legacy check does, or as expect says where the difference is intended. Run it before enforcing a permission with shadowMode. |
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. |
testAgentRuntime({ name, mount, skip? }) | The AgentPermDock contract over the same SaaS domain. mount(domain) returns { agent, close? }, an AgentPermDock whose context is { user, org }, built with domain.subject, domain.operations, domain.models, domain.customRoles and domain.store. Fourteen scenarios: the eight tool-call scenarios through decideOperation, an unmapped operation, allowedOperations, the model gate, permittedModels in declaration order, filterResults by the operation's results permission, and gatewayOptions. skip names a scenario the runtime cannot express, with a reason (agents). |
testOrmParity(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 nativeStore 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). |
testCloudApi(baseUrl, { client, admin?, export? }, { approver, environment?, fetch?, subject?, verifier?, authzen?, platform? }) | Runs testApprovalStore, testDecisionSink, testPolicySource, testSnapshotSource (with subject, a fresh PermDock-Subject token per call) and testAuthzen (with authzen) against one PermDock Cloud environment, then the HTTP contract: 401 Problem Details without a key and 403 for a key of another kind, the ETag on an approval read and 409 for a stale one, resolve 401, 403, 409, 410 and 404, 422 for every token type POST /sign refuses, a repeated Idempotency-Key on POST /deliveries, hosted-grant 422 with reason, catalog unchanged, and a replayed subject token refused. With platform: { token, project?, unpublished?, registration? } (TestCloudPlatformOptions) it also checks the platform issuer: RFC 8414 metadata at the path-inserted URL with a matching issuer, the projects read API with RateLimit fields, 404 policy-not-published for unpublished.project, 401 without a token, 403 for a push with the read-only token, RFC 7009 revocation of an unknown token, and, with registration, RFC 7591 registration that refuses a plain http redirect. Every response is validated against permdock/schemas/cloud-api-v1.json. Point it at a test environment: it creates approvals and sends deliveries to .invalid addresses (PermDock Cloud). |
testRlsParity(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 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, testCustomRoleSource, testApprovalStore, testDecisionSink, testSnapshotSource, testPolicySource, testWhereCompiler, testTokenVerifier, testTokenSigner, testLimitStore, testDirectoryStore, testReplayStore, testDecisionProvider, testSnapshotStorage | Conformance 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, a decision provider denies when its transport fails, a snapshot storage reads back what it stored. Provider adapters in this repository and community implementations run the same suites. |
Other test runners
permdock/testing/core exports policyMatrix, testRlsParity and testOrmParity and imports no test runner, so node:test, Bun, Deno or Jest can run them; tests/bundle asserts it loads no package. permdock/testing re-exports all three. The same matrix under node:test:
import assert from "node:assert/strict";
import { test } from "node:test";
import { policyMatrix } from "permdock/testing/core";
import { permissions } from "../src/permissions";
import { policy } from "../src/policy";
test("policy matrix", async () => {
const report = await policyMatrix(policy, {
subjects: {
anonymous: null,
member: { id: "u1", orgId: "o1", roles: ["member"] },
},
fixtures: { ownPost: { id: "p1", authorId: "u1", orgId: "o1" } },
matrix: {
[permissions.post.create.key]: { anonymous: "denied", member: "granted" },
[permissions.post.update.key]: {
ownPost: { anonymous: "denied", member: "granted" },
},
},
});
assert.deepEqual(report.missing, []);
for (const cell of report.cells) {
assert.deepEqual(cell.failures, [], cell.name);
}
});For one result line per cell, take the test context t and run await t.test(cell.name, () => assert.deepEqual(cell.failures, [])) for each cell.
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.
| 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 getPermDock(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, 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.
import { getPermDock } from "permdock";
import {
saasPolicy,
saasScenarioOptions,
saasScenarios,
saasUser,
} from "permdock/testing/saas";
for (const scenario of saasScenarios) {
const permdock = await getPermDock(
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); 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.
permdock/testing/fixtures exports two real documents from apps/examples/next-better-supabase, for testing code that consumes PermDock's files rather than its runtime, such as a Cloud implementation or a catalog viewer. betterSupabaseCatalog is its permissions.catalog.json (catalog-v1: resources, permissions, scopes, roles, grants and delegations) and betterSupabaseManifest its permdock.manifest.json (supabase-manifest-v1). Both are parsed with parseCatalog and parseSupabaseManifest at import. pnpm gen:check fails when the copies drift from the example.
import {
betterSupabaseCatalog,
betterSupabaseManifest,
} from "permdock/testing/fixtures";
const pushed = await client.catalog.push(betterSupabaseCatalog);
expect(pushed.fingerprint).toBe(betterSupabaseCatalog.fingerprint);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:
policyMatrixbuilds onePermDockper entry ofsubjectswithgetPermDock(policy, subject, options),nullfor anonymous; a subject the policy maps to anactorordelegationcovers agent cases. A cell naming a subject thatsubjectslacks decides forundefined. - Check:
decidefor every cell, with the cell's fixture as the row. A flat row of an instance permission uses the first fixture. - Trace: a
deniedBycell also callsexplainand reads the winning deny'sname;snapshot: truedecides the cell again onfromSnapshotof a JSON round trip. - 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 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 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). describePolicy suites run in packages/permdock/tests/testing; every example under apps/examples runs permdock doctor. tests/integration runs testRlsParity against testcontainers Postgres (pnpm test:integration), testOrmParity 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
- Postgres RLS: parity semantics (
USING,WITH CHECK,42501).testRlsParitytakes aqueryfunction, so any driver or a Neon branch works; this repository usespgagainst testcontainers. - AuthZEN:
mockEndpointrequest and response shapes. - Next.js Cache Components: Instant Navigations testing.
Last updated on
JWT
permdock/jwt verifies bearer JWTs against a JWKS, an OpenID Connect Discovery document or a secret with jose as an optional peer and returns a PermDock subject: principal from iss and sub, delegation from scope, authorization_details and access, actor from act, sender binding from cnf. Verification failure yields the anonymous subject, never an exception, and every failure has an RFC 6750 rendering.
CLI
The permdock CLI that ships in the permdock package, its commands, exit codes and how to run it in CI.