@checkstack/backend-api 0.2.0 → 0.3.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.
@@ -0,0 +1,530 @@
1
+ import { describe, expect, it, mock, beforeEach, type Mock } from "bun:test";
2
+ import { oc } from "@orpc/contract";
3
+ import { call, implement } from "@orpc/server";
4
+ import { z } from "zod";
5
+ import { autoAuthMiddleware, RpcContext } from "./rpc";
6
+ import { createMockRpcContext } from "./test-utils";
7
+ import { access, accessPair, ProcedureMetadata } from "@checkstack/common";
8
+
9
+ // =============================================================================
10
+ // TEST CONTRACT DEFINITIONS
11
+ // =============================================================================
12
+
13
+ const _base = oc.$meta<ProcedureMetadata>({});
14
+
15
+ /**
16
+ * Test contracts for different access patterns.
17
+ */
18
+ const testContracts = {
19
+ // Anonymous endpoint - no auth required
20
+ anonymousEndpoint: _base
21
+ .meta({ userType: "anonymous" })
22
+ .output(z.object({ message: z.string() })),
23
+
24
+ // Public endpoint with global access rules only (no instance access)
25
+ publicGlobalEndpoint: _base
26
+ .meta({
27
+ userType: "public",
28
+ access: [access("resource", "read", "Test access")],
29
+ })
30
+ .output(z.object({ message: z.string() })),
31
+
32
+ // Public endpoint with list filtering
33
+ publicListEndpoint: _base
34
+ .meta({
35
+ userType: "public",
36
+ access: [
37
+ accessPair(
38
+ "system",
39
+ { read: "View systems", manage: "Manage systems" },
40
+ { listKey: "systems", readIsPublic: true }
41
+ ).read,
42
+ ],
43
+ })
44
+ .output(
45
+ z.object({
46
+ systems: z.array(z.object({ id: z.string(), name: z.string() })),
47
+ })
48
+ ),
49
+
50
+ // Authenticated endpoint
51
+ authenticatedEndpoint: _base
52
+ .meta({ userType: "authenticated" })
53
+ .output(z.object({ message: z.string() })),
54
+
55
+ // User-only endpoint
56
+ userOnlyEndpoint: _base
57
+ .meta({ userType: "user" })
58
+ .output(z.object({ message: z.string() })),
59
+
60
+ // Service-only endpoint
61
+ serviceOnlyEndpoint: _base
62
+ .meta({ userType: "service" })
63
+ .output(z.object({ message: z.string() })),
64
+
65
+ // Single resource endpoint with idParam
66
+ singleResourceEndpoint: _base
67
+ .meta({
68
+ userType: "public",
69
+ access: [
70
+ accessPair(
71
+ "system",
72
+ { read: "View systems", manage: "Manage systems" },
73
+ { idParam: "systemId", readIsPublic: true }
74
+ ).read,
75
+ ],
76
+ })
77
+ .input(z.object({ systemId: z.string() }))
78
+ .output(z.object({ system: z.object({ id: z.string() }).nullable() })),
79
+ };
80
+
81
+ // =============================================================================
82
+ // TEST ROUTER IMPLEMENTATION
83
+ // =============================================================================
84
+
85
+ /**
86
+ * Create the test router using implement + contract pattern.
87
+ */
88
+ function createTestRouter() {
89
+ return implement(testContracts)
90
+ .$context<RpcContext>()
91
+ .use(autoAuthMiddleware)
92
+ .router({
93
+ anonymousEndpoint: implement(testContracts.anonymousEndpoint)
94
+ .$context<RpcContext>()
95
+ .handler(async () => ({
96
+ message: "success",
97
+ })),
98
+
99
+ publicGlobalEndpoint: implement(testContracts.publicGlobalEndpoint)
100
+ .$context<RpcContext>()
101
+ .handler(async () => ({
102
+ message: "success",
103
+ })),
104
+
105
+ publicListEndpoint: implement(testContracts.publicListEndpoint)
106
+ .$context<RpcContext>()
107
+ .handler(async () => ({
108
+ systems: [
109
+ { id: "sys-1", name: "System 1" },
110
+ { id: "sys-2", name: "System 2" },
111
+ { id: "sys-3", name: "System 3" },
112
+ ],
113
+ })),
114
+
115
+ authenticatedEndpoint: implement(testContracts.authenticatedEndpoint)
116
+ .$context<RpcContext>()
117
+ .handler(async () => ({
118
+ message: "success",
119
+ })),
120
+
121
+ userOnlyEndpoint: implement(testContracts.userOnlyEndpoint)
122
+ .$context<RpcContext>()
123
+ .handler(async () => ({
124
+ message: "success",
125
+ })),
126
+
127
+ serviceOnlyEndpoint: implement(testContracts.serviceOnlyEndpoint)
128
+ .$context<RpcContext>()
129
+ .handler(async () => ({
130
+ message: "success",
131
+ })),
132
+
133
+ singleResourceEndpoint: implement(testContracts.singleResourceEndpoint)
134
+ .$context<RpcContext>()
135
+ .handler(async ({ input }) => ({
136
+ system: { id: input.systemId },
137
+ })),
138
+ });
139
+ }
140
+
141
+ describe("autoAuthMiddleware", () => {
142
+ let mockContext: RpcContext;
143
+ let router: ReturnType<typeof createTestRouter>;
144
+
145
+ beforeEach(() => {
146
+ mockContext = createMockRpcContext();
147
+ router = createTestRouter();
148
+ });
149
+
150
+ // ==========================================================================
151
+ // ANONYMOUS ENDPOINTS (userType: "anonymous")
152
+ // ==========================================================================
153
+
154
+ describe("anonymous endpoints (userType: anonymous)", () => {
155
+ it("should allow access without authentication", async () => {
156
+ const result = await call(router.anonymousEndpoint, undefined, {
157
+ context: mockContext,
158
+ });
159
+ expect(result).toEqual({ message: "success" });
160
+ });
161
+
162
+ it("should skip all access rule checks", async () => {
163
+ const result = await call(router.anonymousEndpoint, undefined, {
164
+ context: mockContext,
165
+ });
166
+ expect(result).toEqual({ message: "success" });
167
+ // getAnonymousAccessRules should NOT be called for anonymous endpoints
168
+ expect(mockContext.auth.getAnonymousAccessRules).not.toHaveBeenCalled();
169
+ });
170
+ });
171
+
172
+ // ==========================================================================
173
+ // PUBLIC ENDPOINTS - Global Access Rules
174
+ // ==========================================================================
175
+
176
+ describe("public endpoints with global access rules", () => {
177
+ it("should allow anonymous users with matching access rule", async () => {
178
+ // Mock anonymous role has the required access rule
179
+ (
180
+ mockContext.auth.getAnonymousAccessRules as Mock<
181
+ () => Promise<string[]>
182
+ >
183
+ ).mockResolvedValue(["test-plugin.resource.read"]);
184
+
185
+ const result = await call(router.publicGlobalEndpoint, undefined, {
186
+ context: mockContext,
187
+ });
188
+ expect(result).toEqual({ message: "success" });
189
+ expect(mockContext.auth.getAnonymousAccessRules).toHaveBeenCalled();
190
+ });
191
+
192
+ it("should reject anonymous users without matching access rule", async () => {
193
+ // Mock anonymous role does NOT have the required access rule
194
+ (
195
+ mockContext.auth.getAnonymousAccessRules as Mock<
196
+ () => Promise<string[]>
197
+ >
198
+ ).mockResolvedValue([]);
199
+
200
+ await expect(
201
+ call(router.publicGlobalEndpoint, undefined, { context: mockContext })
202
+ ).rejects.toThrow();
203
+ });
204
+
205
+ it("should allow authenticated users with matching access rule", async () => {
206
+ mockContext.user = {
207
+ type: "user",
208
+ id: "user-1",
209
+ accessRules: ["test-plugin.resource.read"],
210
+ };
211
+
212
+ const result = await call(router.publicGlobalEndpoint, undefined, {
213
+ context: mockContext,
214
+ });
215
+ expect(result).toEqual({ message: "success" });
216
+ expect(mockContext.auth.getAnonymousAccessRules).not.toHaveBeenCalled();
217
+ });
218
+
219
+ it("should reject authenticated users without matching access rule", async () => {
220
+ mockContext.user = {
221
+ type: "user",
222
+ id: "user-1",
223
+ accessRules: ["other.permission"],
224
+ };
225
+
226
+ await expect(
227
+ call(router.publicGlobalEndpoint, undefined, { context: mockContext })
228
+ ).rejects.toThrow();
229
+ });
230
+
231
+ it("should allow users with wildcard access rule", async () => {
232
+ mockContext.user = {
233
+ type: "user",
234
+ id: "user-1",
235
+ accessRules: ["*"],
236
+ };
237
+
238
+ const result = await call(router.publicGlobalEndpoint, undefined, {
239
+ context: mockContext,
240
+ });
241
+ expect(result).toEqual({ message: "success" });
242
+ });
243
+
244
+ it("should allow service users without checking access rules", async () => {
245
+ mockContext.user = {
246
+ type: "service",
247
+ pluginId: "other-plugin",
248
+ };
249
+
250
+ const result = await call(router.publicGlobalEndpoint, undefined, {
251
+ context: mockContext,
252
+ });
253
+ expect(result).toEqual({ message: "success" });
254
+ });
255
+ });
256
+
257
+ // ==========================================================================
258
+ // PUBLIC ENDPOINTS - List Filtering (Regression test for anonymous access)
259
+ // ==========================================================================
260
+
261
+ describe("public endpoints with list filtering (instanceAccess.listKey)", () => {
262
+ it("should return all items for anonymous users WITH global access", async () => {
263
+ // Anonymous role HAS the required access rule
264
+ (
265
+ mockContext.auth.getAnonymousAccessRules as Mock<
266
+ () => Promise<string[]>
267
+ >
268
+ ).mockResolvedValue(["test-plugin.system.read"]);
269
+
270
+ const result = await call(router.publicListEndpoint, undefined, {
271
+ context: mockContext,
272
+ });
273
+
274
+ // Should return all systems since anonymous has global access
275
+ expect(result.systems).toHaveLength(3);
276
+ expect(result.systems.map((s) => s.id)).toEqual([
277
+ "sys-1",
278
+ "sys-2",
279
+ "sys-3",
280
+ ]);
281
+ });
282
+
283
+ it("should return empty list for anonymous users WITHOUT global access", async () => {
284
+ // Anonymous role does NOT have the required access rule
285
+ (
286
+ mockContext.auth.getAnonymousAccessRules as Mock<
287
+ () => Promise<string[]>
288
+ >
289
+ ).mockResolvedValue([]);
290
+
291
+ const result = await call(router.publicListEndpoint, undefined, {
292
+ context: mockContext,
293
+ });
294
+
295
+ // Should return empty list since anonymous has no access
296
+ expect(result.systems).toHaveLength(0);
297
+ });
298
+
299
+ it("should return all items for anonymous users with wildcard access", async () => {
300
+ // Anonymous role has wildcard access
301
+ (
302
+ mockContext.auth.getAnonymousAccessRules as Mock<
303
+ () => Promise<string[]>
304
+ >
305
+ ).mockResolvedValue(["*"]);
306
+
307
+ const result = await call(router.publicListEndpoint, undefined, {
308
+ context: mockContext,
309
+ });
310
+
311
+ // Should return all systems
312
+ expect(result.systems).toHaveLength(3);
313
+ });
314
+
315
+ it("should filter items via S2S for authenticated users without global access", async () => {
316
+ mockContext.user = {
317
+ type: "user",
318
+ id: "user-1",
319
+ accessRules: [], // No global access
320
+ };
321
+
322
+ // Mock S2S call returns only accessible IDs
323
+ (
324
+ mockContext.auth.getAccessibleResourceIds as Mock<
325
+ () => Promise<string[]>
326
+ >
327
+ ).mockResolvedValue(["sys-1", "sys-3"]);
328
+
329
+ const result = await call(router.publicListEndpoint, undefined, {
330
+ context: mockContext,
331
+ });
332
+
333
+ // Should return only filtered systems
334
+ expect(result.systems).toHaveLength(2);
335
+ expect(result.systems.map((s) => s.id)).toEqual(["sys-1", "sys-3"]);
336
+ });
337
+
338
+ it("should return all items for authenticated users with global access", async () => {
339
+ mockContext.user = {
340
+ type: "user",
341
+ id: "user-1",
342
+ accessRules: ["test-plugin.system.read"], // Has global access
343
+ };
344
+
345
+ // S2S should return all items when user has global access
346
+ (
347
+ mockContext.auth.getAccessibleResourceIds as Mock<
348
+ () => Promise<string[]>
349
+ >
350
+ ).mockResolvedValue(["sys-1", "sys-2", "sys-3"]);
351
+
352
+ const result = await call(router.publicListEndpoint, undefined, {
353
+ context: mockContext,
354
+ });
355
+
356
+ expect(result.systems).toHaveLength(3);
357
+ });
358
+ });
359
+
360
+ // ==========================================================================
361
+ // AUTHENTICATED ENDPOINTS
362
+ // ==========================================================================
363
+
364
+ describe("authenticated endpoints (userType: authenticated)", () => {
365
+ it("should reject unauthenticated requests", async () => {
366
+ // No user in context
367
+ await expect(
368
+ call(router.authenticatedEndpoint, undefined, { context: mockContext })
369
+ ).rejects.toThrow("Authentication required");
370
+ });
371
+
372
+ it("should allow authenticated users", async () => {
373
+ mockContext.user = {
374
+ type: "user",
375
+ id: "user-1",
376
+ accessRules: [],
377
+ };
378
+
379
+ const result = await call(router.authenticatedEndpoint, undefined, {
380
+ context: mockContext,
381
+ });
382
+ expect(result).toEqual({ message: "success" });
383
+ });
384
+
385
+ it("should allow service users", async () => {
386
+ mockContext.user = {
387
+ type: "service",
388
+ pluginId: "other-plugin",
389
+ };
390
+
391
+ const result = await call(router.authenticatedEndpoint, undefined, {
392
+ context: mockContext,
393
+ });
394
+ expect(result).toEqual({ message: "success" });
395
+ });
396
+ });
397
+
398
+ // ==========================================================================
399
+ // USER-ONLY ENDPOINTS
400
+ // ==========================================================================
401
+
402
+ describe("user-only endpoints (userType: user)", () => {
403
+ it("should reject service users", async () => {
404
+ mockContext.user = {
405
+ type: "service",
406
+ pluginId: "other-plugin",
407
+ };
408
+
409
+ await expect(
410
+ call(router.userOnlyEndpoint, undefined, { context: mockContext })
411
+ ).rejects.toThrow("This endpoint is for users only");
412
+ });
413
+
414
+ it("should allow real users", async () => {
415
+ mockContext.user = {
416
+ type: "user",
417
+ id: "user-1",
418
+ accessRules: [],
419
+ };
420
+
421
+ const result = await call(router.userOnlyEndpoint, undefined, {
422
+ context: mockContext,
423
+ });
424
+ expect(result).toEqual({ message: "success" });
425
+ });
426
+ });
427
+
428
+ // ==========================================================================
429
+ // SERVICE-ONLY ENDPOINTS
430
+ // ==========================================================================
431
+
432
+ describe("service-only endpoints (userType: service)", () => {
433
+ it("should reject real users", async () => {
434
+ mockContext.user = {
435
+ type: "user",
436
+ id: "user-1",
437
+ accessRules: [],
438
+ };
439
+
440
+ await expect(
441
+ call(router.serviceOnlyEndpoint, undefined, { context: mockContext })
442
+ ).rejects.toThrow("This endpoint is for services only");
443
+ });
444
+
445
+ it("should allow service users", async () => {
446
+ mockContext.user = {
447
+ type: "service",
448
+ pluginId: "other-plugin",
449
+ };
450
+
451
+ const result = await call(router.serviceOnlyEndpoint, undefined, {
452
+ context: mockContext,
453
+ });
454
+ expect(result).toEqual({ message: "success" });
455
+ });
456
+ });
457
+
458
+ // ==========================================================================
459
+ // SINGLE RESOURCE ACCESS
460
+ // ==========================================================================
461
+
462
+ describe("single resource access (instanceAccess.idParam)", () => {
463
+ it("should deny anonymous users access to single resources", async () => {
464
+ // Anonymous role has the access rule
465
+ (
466
+ mockContext.auth.getAnonymousAccessRules as Mock<
467
+ () => Promise<string[]>
468
+ >
469
+ ).mockResolvedValue(["test-plugin.system.read"]);
470
+
471
+ await expect(
472
+ call(
473
+ router.singleResourceEndpoint,
474
+ { systemId: "sys-1" },
475
+ { context: mockContext }
476
+ )
477
+ ).rejects.toThrow("Authentication required to access system:sys-1");
478
+ });
479
+
480
+ it("should check team access via S2S for authenticated users", async () => {
481
+ mockContext.user = {
482
+ type: "user",
483
+ id: "user-1",
484
+ accessRules: [],
485
+ };
486
+
487
+ (
488
+ mockContext.auth.checkResourceTeamAccess as Mock<
489
+ () => Promise<{ hasAccess: boolean }>
490
+ >
491
+ ).mockResolvedValue({ hasAccess: true });
492
+
493
+ const result = await call(
494
+ router.singleResourceEndpoint,
495
+ { systemId: "sys-1" },
496
+ { context: mockContext }
497
+ );
498
+
499
+ expect(result.system?.id).toBe("sys-1");
500
+ expect(mockContext.auth.checkResourceTeamAccess).toHaveBeenCalledWith(
501
+ expect.objectContaining({
502
+ userId: "user-1",
503
+ resourceId: "sys-1",
504
+ })
505
+ );
506
+ });
507
+
508
+ it("should deny access when team access check fails", async () => {
509
+ mockContext.user = {
510
+ type: "user",
511
+ id: "user-1",
512
+ accessRules: [],
513
+ };
514
+
515
+ (
516
+ mockContext.auth.checkResourceTeamAccess as Mock<
517
+ () => Promise<{ hasAccess: boolean }>
518
+ >
519
+ ).mockResolvedValue({ hasAccess: false });
520
+
521
+ await expect(
522
+ call(
523
+ router.singleResourceEndpoint,
524
+ { systemId: "sys-1" },
525
+ { context: mockContext }
526
+ )
527
+ ).rejects.toThrow("Access denied to resource system:sys-1");
528
+ });
529
+ });
530
+ });