@pixpilot/trpc-access-testing 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Pixpilot
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,33 @@
1
- # Temporary Holding Version
1
+ # @pixpilot/trpc-access-testing
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Test tRPC route classifications, database activity, and isolation between users.
4
+
5
+ ```sh
6
+ pnpm add -D @pixpilot/trpc-access-testing
7
+ ```
8
+
9
+ Requires Node.js 22+ and tRPC 11. Use your existing test runner; no CLI is required.
10
+
11
+ 1. List every procedure in an `AccessManifest<typeof appRouter>`.
12
+ 2. Create one tracker and inject `instrumentDrizzleDb(testDb, tracker)` into test contexts and services.
13
+ 3. Create an access tester with your router, manifest, tracker, and server-context `getUserId`.
14
+ 4. Run `testAuthentication` for protected routes and `testIsolation` with two users.
15
+ 5. Assert results **and unchanged foreign data** for mutations, then call `ensureCoverage()`.
16
+
17
+ The app supplies its schema, database fixtures, contexts, and assertions. The package
18
+ exports functions so these stay inside ordinary tests and CI.
19
+
20
+ See the [usage guide](docs/usage.md), [API reference](docs/api.md), and
21
+ [complete, runnable PostgreSQL example](examples/ownership.test.ts).
22
+
23
+ Run this package's checks:
24
+
25
+ ```sh
26
+ pnpm --filter @pixpilot/trpc-access-testing typecheck
27
+ pnpm --filter @pixpilot/trpc-access-testing lint
28
+ pnpm --filter @pixpilot/trpc-access-testing test
29
+ pnpm --filter @pixpilot/trpc-access-testing build
30
+ ```
31
+
32
+ Tests use real in-memory PostgreSQL through PGlite and Drizzle; no Docker or
33
+ external database is needed.
package/dist/index.cjs ADDED
@@ -0,0 +1,244 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ let _trpc_server = require("@trpc/server");
3
+ let node_async_hooks = require("node:async_hooks");
4
+
5
+ //#region src/drizzle/instrument-drizzle-db.ts
6
+ const activities = {
7
+ select: "read",
8
+ selectDistinct: "read",
9
+ selectDistinctOn: "read",
10
+ insert: "write",
11
+ update: "write",
12
+ delete: "write",
13
+ refreshMaterializedView: "write",
14
+ findFirst: "read",
15
+ findMany: "read",
16
+ $count: "read"
17
+ };
18
+ const executions = /* @__PURE__ */ new Set([
19
+ "then",
20
+ "catch",
21
+ "finally",
22
+ "execute",
23
+ "all",
24
+ "get",
25
+ "run"
26
+ ]);
27
+ /** Wrap a test-only Drizzle database; preserve its types and record query execution, not SQL text. */
28
+ function instrumentDrizzleDb(db, tracker) {
29
+ function wrap(target, activity, operation) {
30
+ return new Proxy(target, { get(object, key) {
31
+ const value = Reflect.get(object, key, object);
32
+ if (key === "$client" || key === "session" || key === "_" || key === "batch") throw new Error(`Direct Drizzle internals (${String(key)}) bypass access tracking`);
33
+ if (typeof value !== "function") return key === "query" && typeof value === "object" && value !== null ? wrapRelations(value) : value;
34
+ return (...args) => {
35
+ const method = String(key);
36
+ if (method === "transaction") {
37
+ const callback = args[0];
38
+ if (typeof callback !== "function") throw new TypeError("Expected transaction callback");
39
+ return Reflect.apply(value, object, [(tx) => Reflect.apply(callback, void 0, [wrap(tx)]), ...args.slice(1)]);
40
+ }
41
+ if (method === "execute" && activity === void 0) {
42
+ const raw = Reflect.apply(value, object, args);
43
+ if (raw instanceof Promise) {
44
+ tracker.record("unknown", "execute");
45
+ return raw;
46
+ }
47
+ return typeof raw === "object" && raw !== null ? wrap(raw, "unknown", "execute") : raw;
48
+ }
49
+ const nextActivity = activities[method] ?? activity;
50
+ const nextOperation = activities[method] === void 0 ? operation : method;
51
+ const executes = executions.has(method) && (nextActivity !== void 0 || method === "execute");
52
+ if (executes) tracker.record(nextActivity ?? "unknown", nextOperation ?? method);
53
+ const result = Reflect.apply(value, object, args);
54
+ if ([
55
+ "as",
56
+ "getSQL",
57
+ "toSQL"
58
+ ].includes(method)) return result;
59
+ if (executes || result instanceof Promise || typeof result !== "object" || result === null) return result;
60
+ return wrap(result, nextActivity, nextOperation);
61
+ };
62
+ } });
63
+ }
64
+ function wrapRelations(target) {
65
+ return new Proxy(target, { get(object, key) {
66
+ const value = Reflect.get(object, key, object);
67
+ return typeof value === "object" && value !== null ? wrap(value) : value;
68
+ } });
69
+ }
70
+ return wrap(db);
71
+ }
72
+
73
+ //#endregion
74
+ //#region src/manifest/get-registered-procedures.ts
75
+ /** Isolate tRPC v11 router introspection and reject incompletely loaded lazy routers. */
76
+ function getRegisteredProcedures(router) {
77
+ const definition = Reflect.get(router, "_def");
78
+ if (Object.keys(definition.lazy).length > 0) throw new Error("Lazy routers are unsupported; supply an eagerly assembled test router");
79
+ return Object.fromEntries(Object.entries(definition.procedures).map(([path, procedure]) => {
80
+ return [path, Reflect.get(procedure, "_def").type];
81
+ }));
82
+ }
83
+
84
+ //#endregion
85
+ //#region src/manifest/ensure-manifest-coverage.ts
86
+ /** Compare the actual registered router against an explicit, complete access manifest. */
87
+ function ensureManifestCoverage(router, manifest) {
88
+ const paths = Object.keys(getRegisteredProcedures(router));
89
+ const registered = new Set(paths);
90
+ const issues = [];
91
+ for (const path of paths) {
92
+ if (!Object.hasOwn(manifest, path)) {
93
+ issues.push(`Missing manifest entry: ${path}`);
94
+ continue;
95
+ }
96
+ const access = manifest[path];
97
+ if (access === void 0 || !["public", "user"].includes(access.auth) || ![
98
+ "none",
99
+ "read",
100
+ "write"
101
+ ].includes(access.db) || typeof access.isolated !== "boolean" || access.isolated && access.auth !== "user") issues.push(`Invalid manifest entry: ${path} (isolation requires auth: user)`);
102
+ }
103
+ for (const path of Object.keys(manifest)) if (!registered.has(path)) issues.push(`Stale manifest entry: ${path}`);
104
+ if (issues.length > 0) throw new Error(issues.join("\n"));
105
+ }
106
+
107
+ //#endregion
108
+ //#region src/tracking/ensure-database-access.ts
109
+ /** Enforce DB permissions even when the request itself threw an authorization error. */
110
+ function ensureDatabaseAccess(procedure, access, events) {
111
+ const violations = events.filter((event) => event.procedure !== procedure || access.db === "none" || access.db === "read" && event.activity !== "read");
112
+ if (violations.length > 0) throw new Error(`Database access violation in ${procedure} (db: ${access.db}): ${violations.map((event) => `${event.activity} via ${event.operation}`).join(", ")}`);
113
+ }
114
+
115
+ //#endregion
116
+ //#region src/testing/invoke-procedure.ts
117
+ /** Invoke an eagerly registered query/mutation using tRPC's server-side caller. */
118
+ async function invokeProcedure(router, ctx, path, input) {
119
+ const procedures = getRegisteredProcedures(router);
120
+ if (!Object.hasOwn(procedures, path)) throw new Error(`Unknown tRPC procedure: ${path}`);
121
+ if (procedures[path] === "subscription") throw new Error(`Subscriptions need stream-specific tests: ${path}`);
122
+ let current = router.createCaller(ctx);
123
+ for (const segment of path.split(".")) current = Reflect.get(current, segment);
124
+ if (typeof current !== "function") throw new Error(`Not a callable procedure: ${path}`);
125
+ return Reflect.apply(current, void 0, [input]);
126
+ }
127
+
128
+ //#endregion
129
+ //#region src/testing/create-access-tester.ts
130
+ /** Test actual tRPC callers with explicit users, DB policies, and required scenario coverage. */
131
+ function createAccessTester(options) {
132
+ const { router, manifest, tracker, getUserId } = options;
133
+ ensureManifestCoverage(router, manifest);
134
+ const exercised = /* @__PURE__ */ new Set();
135
+ const authenticated = /* @__PURE__ */ new Set();
136
+ const isolated = /* @__PURE__ */ new Set();
137
+ async function call(request) {
138
+ const access = manifest[request.path];
139
+ if (!Object.hasOwn(manifest, request.path)) throw new Error(`Unknown procedure: ${request.path}`);
140
+ const result = await tracker.capture(request.path, async () => invokeProcedure(router, request.ctx, request.path, request.input));
141
+ ensureDatabaseAccess(request.path, access, result.events);
142
+ if (result.ok) exercised.add(request.path);
143
+ return result;
144
+ }
145
+ return {
146
+ call,
147
+ async testAuthentication(request) {
148
+ if (manifest[request.path].auth !== "user") throw new Error(`Authentication test requires auth: user: ${request.path}`);
149
+ if (getUserId(request.ctx) != null) throw new Error("Expected an anonymous context");
150
+ const result = await call(request);
151
+ if (result.ok || !(result.error instanceof _trpc_server.TRPCError) || result.error.code !== "UNAUTHORIZED") throw new Error(`Anonymous access was not rejected with UNAUTHORIZED: ${request.path}`);
152
+ authenticated.add(request.path);
153
+ },
154
+ async testIsolation(test) {
155
+ if (!manifest[test.path].isolated) throw new Error(`Isolation test requires isolated: true: ${test.path}`);
156
+ const ownerId = getUserId(test.owner.ctx);
157
+ const otherId = getUserId(test.other.ctx);
158
+ if (ownerId == null || otherId == null || ownerId === "" || otherId === "" || ownerId === otherId) throw new Error("Isolation tests require two distinct authenticated user IDs");
159
+ const otherResult = await call({
160
+ ...test.other,
161
+ path: test.path
162
+ });
163
+ await test.verifyOther(otherResult);
164
+ const ownerResult = await call({
165
+ ...test.owner,
166
+ path: test.path
167
+ });
168
+ if (!ownerResult.ok) throw new Error(`Owner access failed: ${test.path}`, { cause: ownerResult.error });
169
+ await test.verifyOwner(ownerResult.data);
170
+ isolated.add(test.path);
171
+ },
172
+ ensureCoverage() {
173
+ ensureManifestCoverage(router, manifest);
174
+ const issues = [];
175
+ for (const path of Object.keys(getRegisteredProcedures(router))) {
176
+ const access = manifest[path];
177
+ if (!exercised.has(path)) issues.push(`Missing successful call: ${path}`);
178
+ if (access.auth === "user" && !authenticated.has(path)) issues.push(`Missing anonymous rejection test: ${path}`);
179
+ if (access.isolated && !isolated.has(path)) issues.push(`Missing isolation test: ${path}`);
180
+ }
181
+ if (issues.length > 0) throw new Error(issues.join("\n"));
182
+ }
183
+ };
184
+ }
185
+
186
+ //#endregion
187
+ //#region src/testing/ensure-access-denied.ts
188
+ /** Require an intentional access denial; unrelated failures do not prove isolation. */
189
+ function ensureAccessDenied(result) {
190
+ if (result.ok || !(result.error instanceof _trpc_server.TRPCError) || ![
191
+ "UNAUTHORIZED",
192
+ "FORBIDDEN",
193
+ "NOT_FOUND"
194
+ ].includes(result.error.code)) throw new Error("Expected UNAUTHORIZED, FORBIDDEN, or NOT_FOUND access denial");
195
+ }
196
+
197
+ //#endregion
198
+ //#region src/tracking/create-access-tracker.ts
199
+ /** Capture DB activity per awaited request, including failures and concurrent calls. */
200
+ function createAccessTracker() {
201
+ const storage = new node_async_hooks.AsyncLocalStorage();
202
+ return {
203
+ record(activity, operation = activity) {
204
+ const scope = storage.getStore();
205
+ if (scope?.active === true) scope.events.push(Object.freeze({
206
+ procedure: scope.procedure,
207
+ activity,
208
+ operation
209
+ }));
210
+ },
211
+ async capture(procedure, run) {
212
+ const scope = {
213
+ procedure,
214
+ events: [],
215
+ active: true
216
+ };
217
+ return storage.run(scope, async () => {
218
+ try {
219
+ return {
220
+ ok: true,
221
+ data: await run(),
222
+ events: Object.freeze([...scope.events])
223
+ };
224
+ } catch (error) {
225
+ return {
226
+ ok: false,
227
+ error,
228
+ events: Object.freeze([...scope.events])
229
+ };
230
+ } finally {
231
+ scope.active = false;
232
+ }
233
+ });
234
+ }
235
+ };
236
+ }
237
+
238
+ //#endregion
239
+ exports.createAccessTester = createAccessTester;
240
+ exports.createAccessTracker = createAccessTracker;
241
+ exports.ensureAccessDenied = ensureAccessDenied;
242
+ exports.ensureDatabaseAccess = ensureDatabaseAccess;
243
+ exports.ensureManifestCoverage = ensureManifestCoverage;
244
+ exports.instrumentDrizzleDb = instrumentDrizzleDb;
@@ -0,0 +1,82 @@
1
+ import { AnyProcedure, AnyRouter, inferProcedureInput, inferProcedureOutput, inferRouterContext } from "@trpc/server";
2
+ //#region src/types.d.ts
3
+ interface ProcedureAccess {
4
+ auth: 'public' | 'user';
5
+ db: 'none' | 'read' | 'write';
6
+ isolated: boolean;
7
+ }
8
+ type Paths<T> = { [K in keyof T & string]: T[K] extends AnyProcedure ? K : T[K] extends object ? `${K}.${Paths<T[K]>}` : never; }[keyof T & string];
9
+ type AtPath<T, P extends string> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? AtPath<T[K], Rest> : never : P extends keyof T ? T[P] : never;
10
+ type ProcedurePath<R extends AnyRouter> = Paths<R['_def']['record']>;
11
+ type ProcedureAt<R extends AnyRouter, P extends ProcedurePath<R>> = Extract<AtPath<R['_def']['record'], P>, AnyProcedure>;
12
+ type AccessManifest<R extends AnyRouter> = Readonly<Record<ProcedurePath<R>, ProcedureAccess>>;
13
+ type DatabaseActivity = 'read' | 'write' | 'unknown';
14
+ interface DatabaseEvent {
15
+ readonly procedure: string;
16
+ readonly activity: DatabaseActivity;
17
+ readonly operation: string;
18
+ }
19
+ type AccessResult<T> = ({
20
+ readonly ok: true;
21
+ readonly data: T;
22
+ } | {
23
+ readonly ok: false;
24
+ readonly error: unknown;
25
+ }) & {
26
+ readonly events: readonly DatabaseEvent[];
27
+ };
28
+ interface AccessTracker {
29
+ capture: <T>(procedure: string, run: () => T | PromiseLike<T>) => Promise<AccessResult<T>>;
30
+ record: (activity: DatabaseActivity, operation?: string) => void;
31
+ }
32
+ interface ProcedureCall<R extends AnyRouter, P extends ProcedurePath<R>> {
33
+ path: P;
34
+ ctx: inferRouterContext<R>;
35
+ input: inferProcedureInput<ProcedureAt<R, P>>;
36
+ }
37
+ interface IsolationTest<R extends AnyRouter, P extends ProcedurePath<R>> {
38
+ path: P;
39
+ owner: Omit<ProcedureCall<R, P>, 'path'>;
40
+ other: Omit<ProcedureCall<R, P>, 'path'>;
41
+ verifyOwner: (data: inferProcedureOutput<ProcedureAt<R, P>>) => void | Promise<void>;
42
+ /** Check denied/empty results AND persisted state for mutations, including errors after writes. */
43
+ verifyOther: (result: AccessResult<inferProcedureOutput<ProcedureAt<R, P>>>) => void | Promise<void>;
44
+ }
45
+ interface AccessTesterOptions<R extends AnyRouter> {
46
+ router: R;
47
+ manifest: AccessManifest<R>;
48
+ tracker: AccessTracker;
49
+ getUserId: (ctx: inferRouterContext<R>) => string | null | undefined;
50
+ }
51
+ interface AccessTester<R extends AnyRouter> {
52
+ call: <P extends ProcedurePath<R>>(call: ProcedureCall<R, P>) => Promise<AccessResult<inferProcedureOutput<ProcedureAt<R, P>>>>;
53
+ testAuthentication: <P extends ProcedurePath<R>>(call: ProcedureCall<R, P>) => Promise<void>;
54
+ testIsolation: <P extends ProcedurePath<R>>(test: IsolationTest<R, P>) => Promise<void>;
55
+ ensureCoverage: () => void;
56
+ }
57
+ //#endregion
58
+ //#region src/drizzle/instrument-drizzle-db.d.ts
59
+ /** Wrap a test-only Drizzle database; preserve its types and record query execution, not SQL text. */
60
+ export declare function instrumentDrizzleDb<T extends object>(db: T, tracker: AccessTracker): T;
61
+ //#endregion
62
+ //#region src/manifest/ensure-manifest-coverage.d.ts
63
+ /** Compare the actual registered router against an explicit, complete access manifest. */
64
+ export declare function ensureManifestCoverage(router: AnyRouter, manifest: Readonly<Record<string, ProcedureAccess>>): void;
65
+ //#endregion
66
+ //#region src/testing/create-access-tester.d.ts
67
+ /** Test actual tRPC callers with explicit users, DB policies, and required scenario coverage. */
68
+ export declare function createAccessTester<R extends AnyRouter>(options: AccessTesterOptions<R>): AccessTester<R>;
69
+ //#endregion
70
+ //#region src/testing/ensure-access-denied.d.ts
71
+ /** Require an intentional access denial; unrelated failures do not prove isolation. */
72
+ export declare function ensureAccessDenied(result: AccessResult<unknown>): void;
73
+ //#endregion
74
+ //#region src/tracking/create-access-tracker.d.ts
75
+ /** Capture DB activity per awaited request, including failures and concurrent calls. */
76
+ export declare function createAccessTracker(): AccessTracker;
77
+ //#endregion
78
+ //#region src/tracking/ensure-database-access.d.ts
79
+ /** Enforce DB permissions even when the request itself threw an authorization error. */
80
+ export declare function ensureDatabaseAccess(procedure: string, access: ProcedureAccess, events: readonly DatabaseEvent[]): void;
81
+ //#endregion
82
+ export type { AccessManifest, AccessResult, AccessTester, AccessTesterOptions, AccessTracker, DatabaseActivity, DatabaseEvent, IsolationTest, ProcedureAccess, ProcedureAt, ProcedureCall, ProcedurePath };
@@ -0,0 +1,82 @@
1
+ import { AnyProcedure, AnyRouter, inferProcedureInput, inferProcedureOutput, inferRouterContext } from "@trpc/server";
2
+ //#region src/types.d.ts
3
+ interface ProcedureAccess {
4
+ auth: 'public' | 'user';
5
+ db: 'none' | 'read' | 'write';
6
+ isolated: boolean;
7
+ }
8
+ type Paths<T> = { [K in keyof T & string]: T[K] extends AnyProcedure ? K : T[K] extends object ? `${K}.${Paths<T[K]>}` : never; }[keyof T & string];
9
+ type AtPath<T, P extends string> = P extends `${infer K}.${infer Rest}` ? K extends keyof T ? AtPath<T[K], Rest> : never : P extends keyof T ? T[P] : never;
10
+ type ProcedurePath<R extends AnyRouter> = Paths<R['_def']['record']>;
11
+ type ProcedureAt<R extends AnyRouter, P extends ProcedurePath<R>> = Extract<AtPath<R['_def']['record'], P>, AnyProcedure>;
12
+ type AccessManifest<R extends AnyRouter> = Readonly<Record<ProcedurePath<R>, ProcedureAccess>>;
13
+ type DatabaseActivity = 'read' | 'write' | 'unknown';
14
+ interface DatabaseEvent {
15
+ readonly procedure: string;
16
+ readonly activity: DatabaseActivity;
17
+ readonly operation: string;
18
+ }
19
+ type AccessResult<T> = ({
20
+ readonly ok: true;
21
+ readonly data: T;
22
+ } | {
23
+ readonly ok: false;
24
+ readonly error: unknown;
25
+ }) & {
26
+ readonly events: readonly DatabaseEvent[];
27
+ };
28
+ interface AccessTracker {
29
+ capture: <T>(procedure: string, run: () => T | PromiseLike<T>) => Promise<AccessResult<T>>;
30
+ record: (activity: DatabaseActivity, operation?: string) => void;
31
+ }
32
+ interface ProcedureCall<R extends AnyRouter, P extends ProcedurePath<R>> {
33
+ path: P;
34
+ ctx: inferRouterContext<R>;
35
+ input: inferProcedureInput<ProcedureAt<R, P>>;
36
+ }
37
+ interface IsolationTest<R extends AnyRouter, P extends ProcedurePath<R>> {
38
+ path: P;
39
+ owner: Omit<ProcedureCall<R, P>, 'path'>;
40
+ other: Omit<ProcedureCall<R, P>, 'path'>;
41
+ verifyOwner: (data: inferProcedureOutput<ProcedureAt<R, P>>) => void | Promise<void>;
42
+ /** Check denied/empty results AND persisted state for mutations, including errors after writes. */
43
+ verifyOther: (result: AccessResult<inferProcedureOutput<ProcedureAt<R, P>>>) => void | Promise<void>;
44
+ }
45
+ interface AccessTesterOptions<R extends AnyRouter> {
46
+ router: R;
47
+ manifest: AccessManifest<R>;
48
+ tracker: AccessTracker;
49
+ getUserId: (ctx: inferRouterContext<R>) => string | null | undefined;
50
+ }
51
+ interface AccessTester<R extends AnyRouter> {
52
+ call: <P extends ProcedurePath<R>>(call: ProcedureCall<R, P>) => Promise<AccessResult<inferProcedureOutput<ProcedureAt<R, P>>>>;
53
+ testAuthentication: <P extends ProcedurePath<R>>(call: ProcedureCall<R, P>) => Promise<void>;
54
+ testIsolation: <P extends ProcedurePath<R>>(test: IsolationTest<R, P>) => Promise<void>;
55
+ ensureCoverage: () => void;
56
+ }
57
+ //#endregion
58
+ //#region src/drizzle/instrument-drizzle-db.d.ts
59
+ /** Wrap a test-only Drizzle database; preserve its types and record query execution, not SQL text. */
60
+ export declare function instrumentDrizzleDb<T extends object>(db: T, tracker: AccessTracker): T;
61
+ //#endregion
62
+ //#region src/manifest/ensure-manifest-coverage.d.ts
63
+ /** Compare the actual registered router against an explicit, complete access manifest. */
64
+ export declare function ensureManifestCoverage(router: AnyRouter, manifest: Readonly<Record<string, ProcedureAccess>>): void;
65
+ //#endregion
66
+ //#region src/testing/create-access-tester.d.ts
67
+ /** Test actual tRPC callers with explicit users, DB policies, and required scenario coverage. */
68
+ export declare function createAccessTester<R extends AnyRouter>(options: AccessTesterOptions<R>): AccessTester<R>;
69
+ //#endregion
70
+ //#region src/testing/ensure-access-denied.d.ts
71
+ /** Require an intentional access denial; unrelated failures do not prove isolation. */
72
+ export declare function ensureAccessDenied(result: AccessResult<unknown>): void;
73
+ //#endregion
74
+ //#region src/tracking/create-access-tracker.d.ts
75
+ /** Capture DB activity per awaited request, including failures and concurrent calls. */
76
+ export declare function createAccessTracker(): AccessTracker;
77
+ //#endregion
78
+ //#region src/tracking/ensure-database-access.d.ts
79
+ /** Enforce DB permissions even when the request itself threw an authorization error. */
80
+ export declare function ensureDatabaseAccess(procedure: string, access: ProcedureAccess, events: readonly DatabaseEvent[]): void;
81
+ //#endregion
82
+ export type { AccessManifest, AccessResult, AccessTester, AccessTesterOptions, AccessTracker, DatabaseActivity, DatabaseEvent, IsolationTest, ProcedureAccess, ProcedureAt, ProcedureCall, ProcedurePath };
package/dist/index.js ADDED
@@ -0,0 +1,238 @@
1
+ import { TRPCError } from "@trpc/server";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+
4
+ //#region src/drizzle/instrument-drizzle-db.ts
5
+ const activities = {
6
+ select: "read",
7
+ selectDistinct: "read",
8
+ selectDistinctOn: "read",
9
+ insert: "write",
10
+ update: "write",
11
+ delete: "write",
12
+ refreshMaterializedView: "write",
13
+ findFirst: "read",
14
+ findMany: "read",
15
+ $count: "read"
16
+ };
17
+ const executions = /* @__PURE__ */ new Set([
18
+ "then",
19
+ "catch",
20
+ "finally",
21
+ "execute",
22
+ "all",
23
+ "get",
24
+ "run"
25
+ ]);
26
+ /** Wrap a test-only Drizzle database; preserve its types and record query execution, not SQL text. */
27
+ function instrumentDrizzleDb(db, tracker) {
28
+ function wrap(target, activity, operation) {
29
+ return new Proxy(target, { get(object, key) {
30
+ const value = Reflect.get(object, key, object);
31
+ if (key === "$client" || key === "session" || key === "_" || key === "batch") throw new Error(`Direct Drizzle internals (${String(key)}) bypass access tracking`);
32
+ if (typeof value !== "function") return key === "query" && typeof value === "object" && value !== null ? wrapRelations(value) : value;
33
+ return (...args) => {
34
+ const method = String(key);
35
+ if (method === "transaction") {
36
+ const callback = args[0];
37
+ if (typeof callback !== "function") throw new TypeError("Expected transaction callback");
38
+ return Reflect.apply(value, object, [(tx) => Reflect.apply(callback, void 0, [wrap(tx)]), ...args.slice(1)]);
39
+ }
40
+ if (method === "execute" && activity === void 0) {
41
+ const raw = Reflect.apply(value, object, args);
42
+ if (raw instanceof Promise) {
43
+ tracker.record("unknown", "execute");
44
+ return raw;
45
+ }
46
+ return typeof raw === "object" && raw !== null ? wrap(raw, "unknown", "execute") : raw;
47
+ }
48
+ const nextActivity = activities[method] ?? activity;
49
+ const nextOperation = activities[method] === void 0 ? operation : method;
50
+ const executes = executions.has(method) && (nextActivity !== void 0 || method === "execute");
51
+ if (executes) tracker.record(nextActivity ?? "unknown", nextOperation ?? method);
52
+ const result = Reflect.apply(value, object, args);
53
+ if ([
54
+ "as",
55
+ "getSQL",
56
+ "toSQL"
57
+ ].includes(method)) return result;
58
+ if (executes || result instanceof Promise || typeof result !== "object" || result === null) return result;
59
+ return wrap(result, nextActivity, nextOperation);
60
+ };
61
+ } });
62
+ }
63
+ function wrapRelations(target) {
64
+ return new Proxy(target, { get(object, key) {
65
+ const value = Reflect.get(object, key, object);
66
+ return typeof value === "object" && value !== null ? wrap(value) : value;
67
+ } });
68
+ }
69
+ return wrap(db);
70
+ }
71
+
72
+ //#endregion
73
+ //#region src/manifest/get-registered-procedures.ts
74
+ /** Isolate tRPC v11 router introspection and reject incompletely loaded lazy routers. */
75
+ function getRegisteredProcedures(router) {
76
+ const definition = Reflect.get(router, "_def");
77
+ if (Object.keys(definition.lazy).length > 0) throw new Error("Lazy routers are unsupported; supply an eagerly assembled test router");
78
+ return Object.fromEntries(Object.entries(definition.procedures).map(([path, procedure]) => {
79
+ return [path, Reflect.get(procedure, "_def").type];
80
+ }));
81
+ }
82
+
83
+ //#endregion
84
+ //#region src/manifest/ensure-manifest-coverage.ts
85
+ /** Compare the actual registered router against an explicit, complete access manifest. */
86
+ function ensureManifestCoverage(router, manifest) {
87
+ const paths = Object.keys(getRegisteredProcedures(router));
88
+ const registered = new Set(paths);
89
+ const issues = [];
90
+ for (const path of paths) {
91
+ if (!Object.hasOwn(manifest, path)) {
92
+ issues.push(`Missing manifest entry: ${path}`);
93
+ continue;
94
+ }
95
+ const access = manifest[path];
96
+ if (access === void 0 || !["public", "user"].includes(access.auth) || ![
97
+ "none",
98
+ "read",
99
+ "write"
100
+ ].includes(access.db) || typeof access.isolated !== "boolean" || access.isolated && access.auth !== "user") issues.push(`Invalid manifest entry: ${path} (isolation requires auth: user)`);
101
+ }
102
+ for (const path of Object.keys(manifest)) if (!registered.has(path)) issues.push(`Stale manifest entry: ${path}`);
103
+ if (issues.length > 0) throw new Error(issues.join("\n"));
104
+ }
105
+
106
+ //#endregion
107
+ //#region src/tracking/ensure-database-access.ts
108
+ /** Enforce DB permissions even when the request itself threw an authorization error. */
109
+ function ensureDatabaseAccess(procedure, access, events) {
110
+ const violations = events.filter((event) => event.procedure !== procedure || access.db === "none" || access.db === "read" && event.activity !== "read");
111
+ if (violations.length > 0) throw new Error(`Database access violation in ${procedure} (db: ${access.db}): ${violations.map((event) => `${event.activity} via ${event.operation}`).join(", ")}`);
112
+ }
113
+
114
+ //#endregion
115
+ //#region src/testing/invoke-procedure.ts
116
+ /** Invoke an eagerly registered query/mutation using tRPC's server-side caller. */
117
+ async function invokeProcedure(router, ctx, path, input) {
118
+ const procedures = getRegisteredProcedures(router);
119
+ if (!Object.hasOwn(procedures, path)) throw new Error(`Unknown tRPC procedure: ${path}`);
120
+ if (procedures[path] === "subscription") throw new Error(`Subscriptions need stream-specific tests: ${path}`);
121
+ let current = router.createCaller(ctx);
122
+ for (const segment of path.split(".")) current = Reflect.get(current, segment);
123
+ if (typeof current !== "function") throw new Error(`Not a callable procedure: ${path}`);
124
+ return Reflect.apply(current, void 0, [input]);
125
+ }
126
+
127
+ //#endregion
128
+ //#region src/testing/create-access-tester.ts
129
+ /** Test actual tRPC callers with explicit users, DB policies, and required scenario coverage. */
130
+ function createAccessTester(options) {
131
+ const { router, manifest, tracker, getUserId } = options;
132
+ ensureManifestCoverage(router, manifest);
133
+ const exercised = /* @__PURE__ */ new Set();
134
+ const authenticated = /* @__PURE__ */ new Set();
135
+ const isolated = /* @__PURE__ */ new Set();
136
+ async function call(request) {
137
+ const access = manifest[request.path];
138
+ if (!Object.hasOwn(manifest, request.path)) throw new Error(`Unknown procedure: ${request.path}`);
139
+ const result = await tracker.capture(request.path, async () => invokeProcedure(router, request.ctx, request.path, request.input));
140
+ ensureDatabaseAccess(request.path, access, result.events);
141
+ if (result.ok) exercised.add(request.path);
142
+ return result;
143
+ }
144
+ return {
145
+ call,
146
+ async testAuthentication(request) {
147
+ if (manifest[request.path].auth !== "user") throw new Error(`Authentication test requires auth: user: ${request.path}`);
148
+ if (getUserId(request.ctx) != null) throw new Error("Expected an anonymous context");
149
+ const result = await call(request);
150
+ if (result.ok || !(result.error instanceof TRPCError) || result.error.code !== "UNAUTHORIZED") throw new Error(`Anonymous access was not rejected with UNAUTHORIZED: ${request.path}`);
151
+ authenticated.add(request.path);
152
+ },
153
+ async testIsolation(test) {
154
+ if (!manifest[test.path].isolated) throw new Error(`Isolation test requires isolated: true: ${test.path}`);
155
+ const ownerId = getUserId(test.owner.ctx);
156
+ const otherId = getUserId(test.other.ctx);
157
+ if (ownerId == null || otherId == null || ownerId === "" || otherId === "" || ownerId === otherId) throw new Error("Isolation tests require two distinct authenticated user IDs");
158
+ const otherResult = await call({
159
+ ...test.other,
160
+ path: test.path
161
+ });
162
+ await test.verifyOther(otherResult);
163
+ const ownerResult = await call({
164
+ ...test.owner,
165
+ path: test.path
166
+ });
167
+ if (!ownerResult.ok) throw new Error(`Owner access failed: ${test.path}`, { cause: ownerResult.error });
168
+ await test.verifyOwner(ownerResult.data);
169
+ isolated.add(test.path);
170
+ },
171
+ ensureCoverage() {
172
+ ensureManifestCoverage(router, manifest);
173
+ const issues = [];
174
+ for (const path of Object.keys(getRegisteredProcedures(router))) {
175
+ const access = manifest[path];
176
+ if (!exercised.has(path)) issues.push(`Missing successful call: ${path}`);
177
+ if (access.auth === "user" && !authenticated.has(path)) issues.push(`Missing anonymous rejection test: ${path}`);
178
+ if (access.isolated && !isolated.has(path)) issues.push(`Missing isolation test: ${path}`);
179
+ }
180
+ if (issues.length > 0) throw new Error(issues.join("\n"));
181
+ }
182
+ };
183
+ }
184
+
185
+ //#endregion
186
+ //#region src/testing/ensure-access-denied.ts
187
+ /** Require an intentional access denial; unrelated failures do not prove isolation. */
188
+ function ensureAccessDenied(result) {
189
+ if (result.ok || !(result.error instanceof TRPCError) || ![
190
+ "UNAUTHORIZED",
191
+ "FORBIDDEN",
192
+ "NOT_FOUND"
193
+ ].includes(result.error.code)) throw new Error("Expected UNAUTHORIZED, FORBIDDEN, or NOT_FOUND access denial");
194
+ }
195
+
196
+ //#endregion
197
+ //#region src/tracking/create-access-tracker.ts
198
+ /** Capture DB activity per awaited request, including failures and concurrent calls. */
199
+ function createAccessTracker() {
200
+ const storage = new AsyncLocalStorage();
201
+ return {
202
+ record(activity, operation = activity) {
203
+ const scope = storage.getStore();
204
+ if (scope?.active === true) scope.events.push(Object.freeze({
205
+ procedure: scope.procedure,
206
+ activity,
207
+ operation
208
+ }));
209
+ },
210
+ async capture(procedure, run) {
211
+ const scope = {
212
+ procedure,
213
+ events: [],
214
+ active: true
215
+ };
216
+ return storage.run(scope, async () => {
217
+ try {
218
+ return {
219
+ ok: true,
220
+ data: await run(),
221
+ events: Object.freeze([...scope.events])
222
+ };
223
+ } catch (error) {
224
+ return {
225
+ ok: false,
226
+ error,
227
+ events: Object.freeze([...scope.events])
228
+ };
229
+ } finally {
230
+ scope.active = false;
231
+ }
232
+ });
233
+ }
234
+ };
235
+ }
236
+
237
+ //#endregion
238
+ export { createAccessTester, createAccessTracker, ensureAccessDenied, ensureDatabaseAccess, ensureManifestCoverage, instrumentDrizzleDb };
package/docs/api.md ADDED
@@ -0,0 +1,79 @@
1
+ # API
2
+
3
+ All exports come from `@pixpilot/trpc-access-testing`.
4
+
5
+ ## Manifest
6
+
7
+ `AccessManifest<typeof router>` maps every dotted procedure path to:
8
+
9
+ | Field | Values | Meaning |
10
+ | ---------- | ------------------- | ----------------------------------------------------------------- |
11
+ | `auth` | `public\|user` | Anonymous rejection tests required for `user`. |
12
+ | `db` | `none\|read\|write` | No DB operations; reads only; or reads/writes/unknown operations. |
13
+ | `isolated` | boolean | Cross-user assertions required when true; requires `auth: user`. |
14
+
15
+ `db: read` and `db: write` allow zero queries (for example, cache hits).
16
+ Neither forces a query to occur. An admin route can use `auth: user,
17
+ isolated: false`; test its role-specific permissions separately.
18
+
19
+ `ensureManifestCoverage(router, manifest): void` checks registered paths against
20
+ the manifest at runtime, including JavaScript callers. Throws on missing/stale
21
+ paths, invalid classifications, and lazy routers. TypeScript's `satisfies` also
22
+ checks paths during development.
23
+
24
+ ## Access tester
25
+
26
+ `createAccessTester({ router, manifest, tracker, getUserId })` validates the
27
+ manifest immediately and returns:
28
+
29
+ | Method | Behavior |
30
+ | ----------------------------------------------------------------- | --------------------------------------------------------------------------- |
31
+ | `call({ path, ctx, input })` | Calls the real procedure, checks DB policy, returns an `AccessResult`. |
32
+ | `testAuthentication({ path, ctx, input })` | Requires an anonymous context and a tRPC `UNAUTHORIZED` response. |
33
+ | `testIsolation({ path, owner, other, verifyOwner, verifyOther })` | Requires distinct authenticated identities and awaits both assertions. |
34
+ | `ensureCoverage()` | Throws for missing successful calls, anonymous checks, or isolation checks. |
35
+
36
+ Paths, contexts, inputs, and output callback values are inferred from the router.
37
+ Pass `input: undefined` for procedures without input. With defaulted input fields,
38
+ supply values in the shape accepted by your procedure's input parser.
39
+
40
+ `owner` and `other` each contain `{ ctx, input }`. Their inputs can differ to
41
+ exercise spoofed ownership or create different rows.
42
+
43
+ `AccessResult<T>` is a discriminated union:
44
+
45
+ ```ts
46
+ type AccessResult<T> =
47
+ | { ok: true; data: T; events: readonly DatabaseEvent[] }
48
+ | { ok: false; error: unknown; events: readonly DatabaseEvent[] };
49
+ ```
50
+
51
+ Procedure errors are returned with `ok: false`. Manifest errors, DB violations,
52
+ coverage errors, and failed verification callbacks throw. DB policy is checked
53
+ even when a procedure throws. `testAuthentication` requires correctly shaped input;
54
+ a validation error is not accepted as authentication enforcement.
55
+
56
+ `ensureAccessDenied(result): void` throws unless a failed result contains a
57
+ tRPC `UNAUTHORIZED`, `FORBIDDEN`, or `NOT_FOUND` error. Use explicit assertions
58
+ for successful empty responses. Always check foreign persisted state for mutations.
59
+
60
+ ## Tracking
61
+
62
+ `createAccessTracker()` returns:
63
+
64
+ - `capture(procedure, callback)`: awaits a callback and returns its `AccessResult`
65
+ with a frozen event snapshot, including events recorded before errors.
66
+ - `record(activity, operation?)`: records an event in the current capture.
67
+ Outside an active capture it does nothing, so fixture setup is excluded.
68
+
69
+ Each event contains `{ procedure, activity, operation }`. Activity is `read`,
70
+ `write`, or `unknown`. The default operation label equals the activity.
71
+ Events do not contain SQL or query parameters.
72
+
73
+ `instrumentDrizzleDb(db, tracker)` returns a proxy retaining the database's
74
+ TypeScript type. Inject this returned object; it does not modify the original
75
+ database. No Drizzle or PGlite runtime dependency is bundled.
76
+
77
+ `ensureDatabaseAccess(path, classification, events): void` validates a captured
78
+ event list independently. It throws for mismatched originating paths, any
79
+ activity under `db: none`, or write/unknown activity under `db: read`.
package/docs/usage.md ADDED
@@ -0,0 +1,135 @@
1
+ # Usage
2
+
3
+ ## Wire the application
4
+
5
+ Keep this package in dev dependencies and import it only from tests. Supply your
6
+ actual eagerly assembled tRPC router, not a duplicate router created just for the tests.
7
+
8
+ ```ts
9
+ import type { AccessManifest } from '@pixpilot/trpc-access-testing';
10
+ import {
11
+ createAccessTester,
12
+ createAccessTracker,
13
+ instrumentDrizzleDb,
14
+ } from '@pixpilot/trpc-access-testing';
15
+
16
+ const manifest = {
17
+ health: { auth: 'public', db: 'none', isolated: false },
18
+ 'documents.get': { auth: 'user', db: 'read', isolated: true },
19
+ } satisfies AccessManifest<typeof appRouter>;
20
+
21
+ const tracker = createAccessTracker();
22
+ const db = instrumentDrizzleDb(testDb, tracker);
23
+ const access = createAccessTester({
24
+ router: appRouter,
25
+ manifest,
26
+ tracker,
27
+ getUserId: (ctx) => ctx.user?.id,
28
+ });
29
+ ```
30
+
31
+ Here `appRouter` is your application router and `testDb` is your test database.
32
+ Add all your actual procedure paths to the manifest. The example assumes your
33
+ context has `user` and `db` fields; adapt those to the application.
34
+
35
+ Use the **same tracker and wrapped database** in contexts, injected repositories,
36
+ and services. A service that imports an unwrapped global database will bypass
37
+ tracking; replace that binding in the test setup. Seed and inspect fixtures using
38
+ the original database, outside the tracked request.
39
+
40
+ ## Check access
41
+
42
+ Seed Users A and B and a document owned by A. Create server contexts for A, B,
43
+ and an anonymous request, each using the wrapped database. Client input must
44
+ never determine the authorization identity.
45
+
46
+ ```ts
47
+ import { expect, it } from 'vitest';
48
+
49
+ it('covers access to documents.get', async () => {
50
+ await access.testAuthentication({
51
+ path: 'documents.get',
52
+ ctx: { user: null, db },
53
+ input: { id: 'a-document' },
54
+ });
55
+ await access.testIsolation({
56
+ path: 'documents.get',
57
+ owner: { ctx: { user: { id: 'user-a' }, db }, input: { id: 'a-document' } },
58
+ other: { ctx: { user: { id: 'user-b' }, db }, input: { id: 'a-document' } },
59
+ verifyOwner: (document) => {
60
+ expect(document?.id).toBe('a-document');
61
+ },
62
+ verifyOther: (result) => {
63
+ expect(result).toMatchObject({ ok: true, data: null });
64
+ },
65
+ });
66
+ });
67
+ ```
68
+
69
+ For a route that intentionally throws, use `ensureAccessDenied(result)` inside
70
+ `verifyOther`. It accepts only tRPC `UNAUTHORIZED`, `FORBIDDEN`, and `NOT_FOUND`;
71
+ connection failures, validation errors, and internal errors do not prove isolation.
72
+
73
+ For update/delete tests, also read A's persisted records inside `verifyOther`
74
+ and assert they are unchanged. An error response can follow an unauthorized write.
75
+ For list routes, assert that every returned row belongs to B. For creation, assert
76
+ that ownership is assigned from B's server context, even when input spoofs A's ID.
77
+ Use separate fixtures or IDs for separate scenarios.
78
+
79
+ The attacker runs first, then the owner, then the route is marked isolated only
80
+ after both verification callbacks pass. Owner calls must succeed; each callback
81
+ must contain meaningful assertions. This library cannot prove an empty callback
82
+ contains a useful test.
83
+
84
+ ## Require coverage
85
+
86
+ Exercise public/shared routes using `access.call` and assert the result:
87
+
88
+ ```ts
89
+ const result = await access.call({
90
+ path: 'health',
91
+ ctx: { user: null, db },
92
+ input: undefined,
93
+ });
94
+ expect(result).toMatchObject({ ok: true, data: 'ok' });
95
+ ```
96
+
97
+ Call `access.ensureCoverage()` after the complete suite. With Vitest, an
98
+ `afterAll(() => access.ensureCoverage())` hook is suitable when all cases share
99
+ one tester. Create that tester once per suite; resetting it per test resets its
100
+ coverage. Each registered path needs a successful call, every `auth: user` route
101
+ needs an anonymous rejection test, and every `isolated: true` route needs a
102
+ completed isolation test. Multiple scenarios for the same path are allowed.
103
+ For suites split across files, use a central coverage suite rather than expecting
104
+ coverage sets to merge across processes.
105
+
106
+ Construction already fails on missing, stale, or invalid manifest entries.
107
+ Mark admin, health, shared-data, and other exceptional tRPC procedures explicitly.
108
+ HTTP webhooks outside the tRPC router are outside this manifest.
109
+
110
+ ## Database and runtime boundaries
111
+
112
+ - The Drizzle adapter is tested with PostgreSQL/PGlite and Drizzle 0.45.x.
113
+ It tracks CRUD builders, relational queries, prepared queries, counts,
114
+ transactions, and nested transactions, without parsing SQL.
115
+ - Raw `execute` calls are `unknown`, rejected by `db: none` and `db: read`.
116
+ A `db: write` classification permits them. Keep raw-SQL paths explicit.
117
+ - Activity describes attempted ORM operations, not committed changes. A rolled-back
118
+ or failed write is still recorded as a write.
119
+ - Builder classifications cannot inspect side effects inside SQL expressions,
120
+ CTEs, stored functions, or triggers. Verify persisted state for those cases.
121
+ Database activity auditing does not establish ownership.
122
+ - Direct access to `$client`, `session`, `_`, and `batch` through the wrapped DB
123
+ throws. The wrapper is test instrumentation, not a production security boundary;
124
+ an independently retained raw client can bypass it.
125
+ - Other database adapters can call `tracker.record('read' | 'write' | 'unknown',
126
+ operation)` at their execution boundary. ORM/driver classification is their responsibility.
127
+ - Await all request database work. Detached background jobs that outlive a request
128
+ are not included. Test jobs separately.
129
+ - Node.js `AsyncLocalStorage` preserves originating procedure names across awaited
130
+ service/repository calls and separates concurrent requests. Edge runtimes are unsupported.
131
+ - Lazy routers and subscriptions fail explicitly; assemble lazy routers eagerly
132
+ for tests, and test subscription streams separately.
133
+ - Calls use the server-side tRPC caller. This covers tRPC middleware and resolvers;
134
+ HTTP session parsing, cookies, headers, and transport adapters need their own tests.
135
+ - In-memory PostgreSQL tests do not verify production RLS roles, extensions, or deployment configuration.
@@ -0,0 +1,113 @@
1
+ import type { AccessManifest } from '@pixpilot/trpc-access-testing';
2
+ import { PGlite } from '@electric-sql/pglite';
3
+ import {
4
+ createAccessTester,
5
+ createAccessTracker,
6
+ instrumentDrizzleDb,
7
+ } from '@pixpilot/trpc-access-testing';
8
+ import { initTRPC, TRPCError } from '@trpc/server';
9
+ import { and, eq } from 'drizzle-orm';
10
+ import { pgTable, text } from 'drizzle-orm/pg-core';
11
+ import { drizzle } from 'drizzle-orm/pglite';
12
+ import { afterAll, beforeAll, expect, it } from 'vitest';
13
+ import { z } from 'zod';
14
+
15
+ const users = pgTable('users', { id: text().primaryKey() }).enableRLS();
16
+ const documents = pgTable('documents', {
17
+ id: text().primaryKey(),
18
+ ownerId: text()
19
+ .notNull()
20
+ .references(() => users.id),
21
+ body: text().notNull(),
22
+ }).enableRLS();
23
+ const schema = { users, documents };
24
+ type TestDb = ReturnType<typeof drizzle<typeof schema>>;
25
+ interface Context {
26
+ userId: string | null;
27
+ db: TestDb;
28
+ }
29
+
30
+ // Substitute your application's real router and fixtures when adapting this example.
31
+ const t = initTRPC.context<Context>().create();
32
+ const router = t.router({
33
+ health: t.procedure.query(() => 'ok'),
34
+ documents: t.router({
35
+ get: t.procedure
36
+ .use(async ({ ctx, next }) => {
37
+ if (ctx.userId === null) throw new TRPCError({ code: 'UNAUTHORIZED' });
38
+ return next({ ctx: { ...ctx, userId: ctx.userId } });
39
+ })
40
+ .input(z.object({ id: z.string() }))
41
+ .query(async ({ ctx, input }) => {
42
+ const rows = await ctx.db
43
+ .select()
44
+ .from(documents)
45
+ .where(and(eq(documents.id, input.id), eq(documents.ownerId, ctx.userId)));
46
+ return rows[0] ?? null;
47
+ }),
48
+ }),
49
+ });
50
+ const manifest = {
51
+ health: { auth: 'public', db: 'none', isolated: false },
52
+ 'documents.get': { auth: 'user', db: 'read', isolated: true },
53
+ } satisfies AccessManifest<typeof router>;
54
+
55
+ let client: PGlite;
56
+ let db: TestDb;
57
+ let access: ReturnType<typeof createAccessTester<typeof router>>;
58
+ beforeAll(async () => {
59
+ client = new PGlite();
60
+ await client.exec(
61
+ 'CREATE TABLE users (id text PRIMARY KEY); CREATE TABLE documents (id text PRIMARY KEY, "ownerId" text NOT NULL REFERENCES users(id), body text NOT NULL);',
62
+ );
63
+ const rawDb = drizzle(client, { schema });
64
+ const tracker = createAccessTracker();
65
+ db = instrumentDrizzleDb(rawDb, tracker);
66
+ await rawDb.insert(users).values([{ id: 'user-a' }, { id: 'user-b' }]);
67
+ await rawDb
68
+ .insert(documents)
69
+ .values({ id: 'a-document', ownerId: 'user-a', body: 'private' });
70
+ access = createAccessTester({
71
+ router,
72
+ manifest,
73
+ tracker,
74
+ getUserId: (ctx) => ctx.userId,
75
+ });
76
+ }, 30000);
77
+ afterAll(async () => {
78
+ try {
79
+ access.ensureCoverage();
80
+ } finally {
81
+ await client.close();
82
+ }
83
+ });
84
+
85
+ it('covers the public health procedure', async () => {
86
+ expect(
87
+ await access.call({
88
+ path: 'health',
89
+ ctx: { userId: null, db },
90
+ input: undefined,
91
+ }),
92
+ ).toMatchObject({ ok: true, data: 'ok' });
93
+ });
94
+
95
+ it('requires authentication and isolates the document owner', async () => {
96
+ const input = { id: 'a-document' };
97
+ await access.testAuthentication({
98
+ path: 'documents.get',
99
+ ctx: { userId: null, db },
100
+ input,
101
+ });
102
+ await access.testIsolation({
103
+ path: 'documents.get',
104
+ owner: { ctx: { userId: 'user-a', db }, input },
105
+ other: { ctx: { userId: 'user-b', db }, input },
106
+ verifyOwner: (row) => {
107
+ expect(row?.id).toBe(input.id);
108
+ },
109
+ verifyOther: (result) => {
110
+ expect(result).toMatchObject({ ok: true, data: null });
111
+ },
112
+ });
113
+ });
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "@pixpilot/trpc-access-testing",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "type": "module",
4
+ "version": "0.2.0",
5
+ "description": "Manifest coverage, database auditing and cross-user isolation tests for tRPC",
6
+ "author": "Mo Doaie <m.doaie@hotmail.com>",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/pixpilot/trpc-tools",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/pixpilot/trpc-tools.git",
12
+ "directory": "packages/trpc-access-testing"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/pixpilot/trpc-tools/issues"
16
+ },
17
+ "keywords": [
18
+ "trpc",
19
+ "testing",
20
+ "authorization",
21
+ "isolation",
22
+ "drizzle"
23
+ ],
24
+ "sideEffects": false,
25
+ "exports": {
26
+ ".": {
27
+ "types": "./dist/index.d.ts",
28
+ "import": "./dist/index.js",
29
+ "require": "./dist/index.cjs"
30
+ }
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "docs",
35
+ "examples"
36
+ ],
37
+ "publishConfig": {
38
+ "access": "public",
39
+ "registry": "https://registry.npmjs.org"
40
+ },
41
+ "prettier": "@internal/prettier-config",
42
+ "engines": {
43
+ "node": ">=22"
44
+ },
45
+ "peerDependencies": {
46
+ "@trpc/server": "^11.16.0"
47
+ },
48
+ "devDependencies": {
49
+ "@electric-sql/pglite": "^0.5.8",
50
+ "@trpc/server": "^11.16.0",
51
+ "@types/node": "^26.6.2",
52
+ "@vitest/coverage-v8": "^5.0.1",
53
+ "@vitest/ui": "^5.0.1",
54
+ "drizzle-orm": "^0.45.2",
55
+ "eslint": "^10.11.0",
56
+ "tsdown": "^0.23.0",
57
+ "typescript": "^6.0.3",
58
+ "vitest": "^5.0.1",
59
+ "zod": "^4.6.5",
60
+ "@internal/eslint-config": "0.3.0",
61
+ "@internal/prettier-config": "0.0.1",
62
+ "@internal/tsconfig": "0.1.0",
63
+ "@internal/vitest-config": "0.1.0",
64
+ "@internal/tsdown-config": "0.1.0"
65
+ },
66
+ "scripts": {
67
+ "clean": "git clean -xdf .cache .turbo dist",
68
+ "clean:all": "pnpm clean && git clean -xdf node_modules",
69
+ "build": "tsdown",
70
+ "build:watch": "tsdown --watch",
71
+ "test": "vitest --run",
72
+ "test:watch": "vitest --watch",
73
+ "test:ui": "vitest --ui --watch",
74
+ "test:ui:coverage": "vitest --ui --coverage --watch --hookTimeout=30000 --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.branches=0 --coverage.thresholds.statements=0",
75
+ "test:coverage": "vitest --coverage",
76
+ "typecheck": "tsc --noEmit",
77
+ "lint": "eslint",
78
+ "format": "prettier --check . --ignore-path ../../.gitignore --ignore-path ../../.prettierignore"
79
+ },
80
+ "main": "./dist/index.cjs",
81
+ "module": "./dist/index.js",
82
+ "types": "./dist/index.d.ts"
6
83
  }