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 rolecontractor) andglobex(pro plan), plusorg-1andtenant-1, whose ids a loose comparison would confuse; projectanddocrows with tenant and team relations, collection actions (members, settings, billing, audit, SSO), plan-gated analytics, andapiKeywith 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-writtenuser,tenant,permission,rowandexpectedcases, including an agent acting without a delegation, a delegation narrower than the user's grants, an exhausted quota,org-1againsttenant-1on reads, lists and writes, and an expired membership on every permission class.saasUser(scenario)andsaasScenarioOptions(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
| Runner | What it proves | Where PermDock runs it |
|---|---|---|
describePolicy | Every permission has a cell per subject and fixture; a new permission cannot ship untested | Every example app |
testClientParity | fromSnapshot never grants what the server denies, per membership and tenant | packages/permdock/src/testing over the saas domain |
testClientStore | A client store's tenant switch, refresh races, stale snapshots, signed refreshes and cache | permdock/react-native createNativeStore |
testHttpAdapter | 14 HTTP scenarios over a real server: tenant from the path, per-org roles, custom roles, quotas, approvals, Problem Details | tests/integration/src/http, one file per adapter |
testAgentAdapter | 8 tool-call scenarios per agent adapter: a grant, the denials an attacker or an outage produces, and an approval | packages/permdock/tests/testing, the seven server-side agent adapters |
ormParity | toWhere(where()) on a real database returns exactly the rows filter() keeps in memory | Drizzle, Kysely and Prisma 7 in tests/integration |
rlsParity | Generated RLS policies return the same rows as the in-memory evaluator | tests/integration on testcontainers Postgres |
test<Interface> conformance runners | A custom ApprovalStore, DecisionSink, MembershipSource, RoleSource, SnapshotSource, PolicySource, LimitStore, DirectoryStore, ReplayStore, RevocationFeed, SubjectResolver, TokenVerifier, TokenSigner or WhereCompiler keeps the interface's contract | The 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.
Related
- Testing adapter: runner signatures and the saas entry.
- Extension interfaces: the contracts the conformance runners check.
- Next.js Cache Components: the snapshot cache pattern for Next.js.
Last updated on
Next.js Cache Components
How a multi-org SaaS keeps permission UI in the prefetched App Shell with Next.js 16.3 Cache Components, Partial Prefetching and instant navigation, how fresh each layer is after a role or plan change, and how Supabase claims plug in.
Support access
Let staff act inside a customer's account with better-supabase support sessions and a PermDock delegation, read-only by default, enforced in process and in Postgres, and attributed on every decision.