PermDock
Guides

Scenario testing

How PermDock tests itself against one realistic multi-tenant SaaS, and which runners in permdock/testing to reuse in your own suites.

Unit tests prove that a function does what its author thought. Scenario tests prove that an application built the way the docs say behaves the way the docs say: the right org, the right role, the right status code, the right rows, the right button. PermDock's own suites run one shared SaaS domain through every surface it ships, and the runners that do it are public in permdock/testing, so an application can hold its own wiring to the same standard.

The domain

permdock/testing/saas is one multi-tenant SaaS (testing adapter):

  • orgs acme (free plan, custom role contractor) and globex (pro plan), plus org-1 and tenant-1, whose ids a loose comparison would confuse;
  • project and doc rows with tenant and team relations, collection actions (members, settings, billing, audit, SSO), plan-gated analytics, and apiKey with a daily quota and an owner approval;
  • users chosen for one hazard each: a role that differs per org, the same role in two orgs, an expired membership, no memberships at all, a team lead, a team role held on the tenant membership instead of a team, a resource collaborator;
  • saasScenarios: hand-written user, tenant, permission, row and expected cases, including an agent acting without a delegation, a delegation narrower than the user's grants, an exhausted quota, org-1 against tenant-1 on reads, lists and writes, and an expired membership on every permission class. saasUser(scenario) and saasScenarioOptions(scenario) build the subject and options a case runs with.

A distinct-approver refusal is not a single decision, so it lives in testApprovalStore rather than in saasScenarios.

The expectations are never computed by the engine. Every suite runs the same cases through its own surface and compares against the hand-written outcome, so a bug in decide cannot make its own test pass. client: false marks a case that depends on a quota store or an approval, which a snapshot client cannot see; clientOutcome records where the client is deliberately stricter (a closure deny fails closed in a snapshot).

saasSchemaSql and saasSeedSql create the same domain in Postgres, with RLS forced on the row tables and policies from permdock rls generate. signSaasToken signs ES256 access tokens under a test-only key; never reuse that key outside tests.

Runners

RunnerWhat it provesWhere PermDock runs it
describePolicyEvery permission has a cell per subject and fixture; a new permission cannot ship untestedEvery example app
testClientParityfromSnapshot never grants what the server denies, per membership and tenantpackages/permdock/src/testing over the saas domain
testClientStoreA client store's tenant switch, refresh races, stale snapshots, signed refreshes and cachepermdock/react-native createNativeStore
testHttpAdapter14 HTTP scenarios over a real server: tenant from the path, per-org roles, custom roles, quotas, approvals, Problem Detailstests/integration/src/http, one file per adapter
testAgentAdapter8 tool-call scenarios per agent adapter: a grant, the denials an attacker or an outage produces, and an approvalpackages/permdock/tests/testing, the seven server-side agent adapters
ormParitytoWhere(where()) on a real database returns exactly the rows filter() keeps in memoryDrizzle, Kysely and Prisma 7 in tests/integration
rlsParityGenerated RLS policies return the same rows as the in-memory evaluatortests/integration on testcontainers Postgres
test<Interface> conformance runnersA custom ApprovalStore, DecisionSink, MembershipSource, RoleSource, SnapshotSource, PolicySource, LimitStore, DirectoryStore, ReplayStore, RevocationFeed, SubjectResolver, TokenVerifier, TokenSigner or WhereCompiler keeps the interface's contractThe in-package defaults, the Drizzle ApprovalStore recipe on Postgres, a PGlite DirectoryStore in tests/integration

An application does not need the saas domain to use them. describePolicy, ormParity, rlsParity and the conformance runners take your own policy, scenarios and implementations; testHttpAdapter, testAgentAdapter and testClientStore are tied to the saas domain because their scenarios are, and are most useful when you write a new adapter or store.

Runtimes

tests/runtimes runs one kernel, Hono, Elysia and AuthZEN app on Node, Bun, Deno and workerd (through Miniflare) with pnpm test:runtimes; CI requires every runtime, and a local run skips the ones that are not installed.

PermDock has no end-to-end or browser tests. A behaviour is covered in the lowest layer that reaches it: a unit test, a runner above, tests/integration for Postgres and real HTTP servers, or tests/runtimes. A scenario that finds a bug does its job: write the failing case first, fix the library, and keep the case.

Standards and invariants

Two more suites sit next to the scenarios in packages/permdock/tests. tests/standards/<slug>.test.ts mirrors each standards page and checks PermDock's output against the upstream schemas and RFC examples vendored in tests/fixtures/standards; pnpm docs:drift fails when a page has no test or a test no page. tests/invariants holds one file per invariant in the threat model, so a change that breaks fail-closed evaluation, deny precedence or prototype safety fails a test named after the rule it breaks.

Last updated on

On this page