@pixpilot/trpc-access-testing
v1.0.0
Published
Manifest coverage, database auditing and cross-user and cross-tenant isolation tests for tRPC
Readme
@pixpilot/trpc-access-testing
Test tRPC route classifications, database activity, and isolation between users and tenants.
pnpm add -D @pixpilot/trpc-access-testingRequires Node.js 22+ and tRPC 11. Use your existing test runner; no CLI is required.
- List every procedure in an
AccessManifest<typeof appRouter>. - Create one tracker and inject
instrumentDrizzleDb(testDb, tracker)into test contexts and services. - Create an access tester with your router, manifest, tracker, and server-context
getUserId(plusisAdminfor admin routes,getTenantIdfor tenant routes). - Run the bulk auth rejection tests, and
testIsolationwith two users or two tenants. - Assert results and unchanged foreign data for mutations, then call
ensureCoverage().
The app supplies its schema, database fixtures, contexts, and assertions. The package exports functions so these stay inside ordinary tests and CI.
Example
import type { AccessManifest } from '@pixpilot/trpc-access-testing';
import {
accessPresets,
authOnly,
createAccessTester,
createAccessTracker,
ensureAccessDenied,
instrumentDrizzleDb,
} from '@pixpilot/trpc-access-testing';
import { expect, it } from 'vitest';
const { publicRead, ownedRead, ownedWrite, tenantRead, adminWrite } = accessPresets;
// Every route, classified. A missing or stale path fails at construction.
const manifest = {
'posts.list': publicRead,
'documents.get': ownedRead,
'documents.update': ownedWrite,
'posts.list': tenantRead, // shared within a tenant, isolated across tenants
'admin.purge': adminWrite,
// Mounted from a package with its own suite: only auth rejection is required here.
'billing.getBalance': authOnly(ownedRead),
} satisfies AccessManifest<typeof appRouter>;
const tracker = createAccessTracker();
const db = instrumentDrizzleDb(testDb, tracker); // inject wherever the app gets its db
const access = createAccessTester({
router: appRouter,
manifest,
tracker,
getUserId: (ctx) => ctx.user?.id,
isAdmin: (ctx) => ctx.isAdmin,
getTenantId: (ctx) => ctx.tenantId,
strictDatabaseAccess: true, // `db` must match what the calls did, not just bound it
});
it('rejects anonymous callers and non-admins', async () => {
await access.testAllAuthentication(anonymousCtx); // every `user` and `admin` route
await access.testAllAuthorization(memberCtx); // every `admin` route
});
it('keeps documents private', async () => {
await access.testIsolation({
path: 'documents.get',
owner: { ctx: aliceCtx, input: { id: aliceDocId } },
other: { ctx: bobCtx, input: { id: aliceDocId } }, // same tenant as Alice
verifyOwner: (doc) => expect(doc.id).toBe(aliceDocId),
verifyOther: (result) => ensureAccessDenied(result),
});
});
it('keeps posts inside their tenant', async () => {
await access.testIsolation({
path: 'posts.list',
owner: { ctx: aliceCtx, input: undefined },
other: { ctx: otherTenantCtx, input: undefined },
verifyOwner: (posts) => expect(posts.every((p) => p.tenantId === acmeId)).toBe(true),
verifyOther: (result) => expect(result).toMatchObject({ ok: true, data: [] }),
});
});
// ...one successful call per remaining `full` route, then:
it('covers every route', () => {
access.ensureCoverage();
});| auth | Required tests |
| -------- | -------------------------------------------------------------------------- |
| public | a successful anonymous call |
| user | anonymous → UNAUTHORIZED, plus a successful signed-in non-admin call |
| admin | anonymous → UNAUTHORIZED, non-admin → FORBIDDEN, successful admin call |
Rejections must not write to the database. isolation: 'user' or 'tenant'
adds an isolation test. verify: 'auth-only' drops the successful call and
isolation test; public routes cannot use it.
See the usage guide, API reference, and complete, runnable PostgreSQL example.
Run this package's checks:
pnpm --filter @pixpilot/trpc-access-testing typecheck
pnpm --filter @pixpilot/trpc-access-testing lint
pnpm --filter @pixpilot/trpc-access-testing test
pnpm --filter @pixpilot/trpc-access-testing buildTests use real in-memory PostgreSQL through PGlite and Drizzle; no Docker or external database is needed.
