# Scenario testing

Source: https://permdock.com/docs/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 [#the-domain]

`permdock/testing/saas` is one multi-tenant SaaS ([testing adapter](/docs/adapters/testing)):

* 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 [#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 [#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 [#standards-and-invariants]

Two more suites sit next to the scenarios in `packages/permdock/tests`. `tests/standards/<slug>.test.ts` mirrors each [standards](/docs/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](/docs/security/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 [#related]

* [Testing adapter](/docs/adapters/testing): runner signatures and the saas entry.
* [Extension interfaces](/docs/concepts/extension-interfaces): the contracts the conformance runners check.
* [Next.js Cache Components](/docs/guides/next-cache-components): the snapshot cache pattern for Next.js.
