@checkstack/auth-backend 0.0.3 → 0.1.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,1230 @@
1
+ import { describe, it, expect, mock, beforeEach } from "bun:test";
2
+ import { createAuthRouter } from "./router";
3
+ import { createMockRpcContext } from "@checkstack/backend-api";
4
+ import { call } from "@orpc/server";
5
+ import { z } from "zod";
6
+ import * as schema from "./schema";
7
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
8
+
9
+ /** Type alias for the database type used in auth router */
10
+ type AuthDatabase = NodePgDatabase<typeof schema>;
11
+
12
+ /**
13
+ * Tests for Team and Resource-Level Access Control endpoints.
14
+ *
15
+ * These tests cover:
16
+ * - Team CRUD operations (getTeams, createTeam, updateTeam, deleteTeam)
17
+ * - Team membership management (addUserToTeam, removeUserFromTeam)
18
+ * - Team manager operations (addTeamManager, removeTeamManager)
19
+ * - Resource access grants (getResourceTeamAccess, setResourceTeamAccess, removeResourceTeamAccess)
20
+ * - S2S access checks (checkResourceTeamAccess, getAccessibleResourceIds)
21
+ */
22
+ describe("Teams and Resource Access Control", () => {
23
+ // Mock user with admin permissions
24
+ const mockAdminUser = {
25
+ type: "user" as const,
26
+ id: "admin-user",
27
+ permissions: ["*"],
28
+ roles: ["admin"],
29
+ teamIds: ["team-alpha"],
30
+ };
31
+
32
+ // Mock regular user with limited permissions
33
+ const mockRegularUser = {
34
+ type: "user" as const,
35
+ id: "regular-user",
36
+ permissions: ["auth.teams.read"],
37
+ roles: ["users"],
38
+ teamIds: ["team-beta"],
39
+ };
40
+
41
+ // Mock service user for S2S calls
42
+ const mockServiceUser = {
43
+ type: "service" as const,
44
+ pluginId: "backend-api",
45
+ };
46
+
47
+ /**
48
+ * Creates a chainable mock for database query operations.
49
+ * Allows chaining .where().innerJoin().limit().offset().orderBy()
50
+ */
51
+ function createChain<T>(data: T[] = []): Record<string, unknown> {
52
+ const chain: Record<string, unknown> = {
53
+ where: mock(() => chain),
54
+ innerJoin: mock(() => chain),
55
+ limit: mock(() => chain),
56
+ offset: mock(() => chain),
57
+ orderBy: mock(() => chain),
58
+ onConflictDoUpdate: mock(() => Promise.resolve()),
59
+ onConflictDoNothing: mock(() => Promise.resolve()),
60
+ then: (resolve: (value: T[]) => void) => Promise.resolve(resolve(data)),
61
+ };
62
+ return chain;
63
+ }
64
+
65
+ /**
66
+ * Creates a fresh mock database for each test.
67
+ * Uses type assertion to satisfy NodePgDatabase interface for testing.
68
+ */
69
+ function createMockDb(): AuthDatabase {
70
+ const mockDb = {
71
+ select: mock(() => ({
72
+ from: mock(() => createChain([])),
73
+ })),
74
+ insert: mock(() => ({
75
+ values: mock(() => ({
76
+ onConflictDoNothing: mock(() => Promise.resolve()),
77
+ onConflictDoUpdate: mock(() => Promise.resolve()),
78
+ then: (resolve: (value: unknown) => void) =>
79
+ Promise.resolve(resolve(undefined)),
80
+ })),
81
+ })),
82
+ update: mock(() => ({
83
+ set: mock(() => ({
84
+ where: mock(() => Promise.resolve()),
85
+ })),
86
+ })),
87
+ delete: mock(() => ({
88
+ where: mock(() => Promise.resolve()),
89
+ })),
90
+ transaction: mock((cb: (tx: typeof mockDb) => Promise<void>) =>
91
+ cb(mockDb)
92
+ ),
93
+ };
94
+ // Type assertion for mock database - only used in tests
95
+ return mockDb as unknown as AuthDatabase;
96
+ }
97
+
98
+ const mockRegistry = {
99
+ getStrategies: () => [
100
+ {
101
+ id: "credential",
102
+ displayName: "Credentials",
103
+ description: "Email and password authentication",
104
+ configSchema: z.object({ enabled: z.boolean() }),
105
+ configVersion: 1,
106
+ migrations: [],
107
+ requiresManualRegistration: true,
108
+ },
109
+ ],
110
+ };
111
+
112
+ const mockConfigService = {
113
+ get: mock(() => Promise.resolve(undefined)),
114
+ getRedacted: mock(() => Promise.resolve({})),
115
+ set: mock(() => Promise.resolve()),
116
+ delete: mock(() => Promise.resolve()),
117
+ list: mock(() => Promise.resolve([])),
118
+ };
119
+
120
+ const mockPermissionRegistry = {
121
+ getPermissions: () => [
122
+ { id: "auth.teams.read", description: "View teams" },
123
+ { id: "auth.teams.manage", description: "Manage teams" },
124
+ ],
125
+ };
126
+
127
+ // ==========================================================================
128
+ // TEAM CRUD TESTS
129
+ // ==========================================================================
130
+
131
+ describe("getTeams", () => {
132
+ it("returns empty array when no teams exist", async () => {
133
+ const mockDb = createMockDb();
134
+ const router = createAuthRouter(
135
+ mockDb,
136
+ mockRegistry,
137
+ async () => {},
138
+ mockConfigService,
139
+ mockPermissionRegistry
140
+ );
141
+
142
+ const context = createMockRpcContext({ user: mockAdminUser });
143
+ const result = await call(router.getTeams, undefined, { context });
144
+
145
+ expect(Array.isArray(result)).toBe(true);
146
+ expect(result).toHaveLength(0);
147
+ });
148
+
149
+ it("returns teams with member counts", async () => {
150
+ const mockDb = createMockDb();
151
+
152
+ // Mock teams query
153
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
154
+ from: mock(() =>
155
+ createChain([
156
+ {
157
+ id: "team-1",
158
+ name: "Platform Team",
159
+ description: "Core platform team",
160
+ },
161
+ { id: "team-2", name: "API Team", description: null },
162
+ ])
163
+ ),
164
+ }));
165
+
166
+ // Mock member counts query
167
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
168
+ from: mock(() =>
169
+ createChain([
170
+ { teamId: "team-1" },
171
+ { teamId: "team-1" },
172
+ { teamId: "team-2" },
173
+ ])
174
+ ),
175
+ }));
176
+
177
+ // Mock manager query (user is manager of team-1)
178
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
179
+ from: mock(() =>
180
+ createChain([{ teamId: "team-1", userId: "admin-user" }])
181
+ ),
182
+ }));
183
+
184
+ const router = createAuthRouter(
185
+ mockDb,
186
+ mockRegistry,
187
+ async () => {},
188
+ mockConfigService,
189
+ mockPermissionRegistry
190
+ );
191
+
192
+ const context = createMockRpcContext({ user: mockAdminUser });
193
+ const result = await call(router.getTeams, undefined, { context });
194
+
195
+ expect(result).toHaveLength(2);
196
+ expect(result[0]).toEqual({
197
+ id: "team-1",
198
+ name: "Platform Team",
199
+ description: "Core platform team",
200
+ memberCount: 2,
201
+ isManager: true,
202
+ });
203
+ expect(result[1]).toEqual({
204
+ id: "team-2",
205
+ name: "API Team",
206
+ description: null,
207
+ memberCount: 1,
208
+ isManager: false,
209
+ });
210
+ });
211
+ });
212
+
213
+ describe("getTeam", () => {
214
+ it("returns undefined for non-existent team", async () => {
215
+ const mockDb = createMockDb();
216
+
217
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
218
+ from: mock(() => createChain([])),
219
+ }));
220
+
221
+ const router = createAuthRouter(
222
+ mockDb,
223
+ mockRegistry,
224
+ async () => {},
225
+ mockConfigService,
226
+ mockPermissionRegistry
227
+ );
228
+
229
+ const context = createMockRpcContext({ user: mockAdminUser });
230
+ const result = await call(
231
+ router.getTeam,
232
+ { teamId: "non-existent" },
233
+ { context }
234
+ );
235
+
236
+ expect(result).toBeUndefined();
237
+ });
238
+
239
+ it("returns team with members and managers", async () => {
240
+ const mockDb = createMockDb();
241
+
242
+ // Mock team query
243
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
244
+ from: mock(() =>
245
+ createChain([
246
+ {
247
+ id: "team-1",
248
+ name: "Platform Team",
249
+ description: "Core team",
250
+ },
251
+ ])
252
+ ),
253
+ }));
254
+
255
+ // Mock members query
256
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
257
+ from: mock(() =>
258
+ createChain([{ userId: "user-1" }, { userId: "user-2" }])
259
+ ),
260
+ }));
261
+
262
+ // Mock managers query
263
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
264
+ from: mock(() => createChain([{ userId: "user-1" }])),
265
+ }));
266
+
267
+ // Mock users query
268
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
269
+ from: mock(() =>
270
+ createChain([
271
+ { id: "user-1", name: "Alice", email: "alice@test.com" },
272
+ { id: "user-2", name: "Bob", email: "bob@test.com" },
273
+ ])
274
+ ),
275
+ }));
276
+
277
+ const router = createAuthRouter(
278
+ mockDb,
279
+ mockRegistry,
280
+ async () => {},
281
+ mockConfigService,
282
+ mockPermissionRegistry
283
+ );
284
+
285
+ const context = createMockRpcContext({ user: mockAdminUser });
286
+ const result = await call(
287
+ router.getTeam,
288
+ { teamId: "team-1" },
289
+ { context }
290
+ );
291
+
292
+ expect(result).toBeDefined();
293
+ expect(result?.name).toBe("Platform Team");
294
+ expect(result?.members).toHaveLength(2);
295
+ expect(result?.managers).toHaveLength(1);
296
+ });
297
+ });
298
+
299
+ describe("createTeam", () => {
300
+ it("creates team with name and description", async () => {
301
+ const mockDb = createMockDb();
302
+ let insertedData: Record<string, unknown> | undefined;
303
+
304
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
305
+ values: mock((data: Record<string, unknown>) => {
306
+ insertedData = data;
307
+ return Promise.resolve();
308
+ }),
309
+ }));
310
+
311
+ const router = createAuthRouter(
312
+ mockDb,
313
+ mockRegistry,
314
+ async () => {},
315
+ mockConfigService,
316
+ mockPermissionRegistry
317
+ );
318
+
319
+ const context = createMockRpcContext({ user: mockAdminUser });
320
+ const result = await call(
321
+ router.createTeam,
322
+ { name: "New Team", description: "A new team" },
323
+ { context }
324
+ );
325
+
326
+ expect(result).toBeDefined();
327
+ expect(result.id).toBeDefined();
328
+ expect(typeof result.id).toBe("string");
329
+ expect(mockDb.insert).toHaveBeenCalled();
330
+ expect(insertedData?.name).toBe("New Team");
331
+ expect(insertedData?.description).toBe("A new team");
332
+ });
333
+
334
+ it("creates team with minimal data", async () => {
335
+ const mockDb = createMockDb();
336
+
337
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
338
+ values: mock(() => Promise.resolve()),
339
+ }));
340
+
341
+ const router = createAuthRouter(
342
+ mockDb,
343
+ mockRegistry,
344
+ async () => {},
345
+ mockConfigService,
346
+ mockPermissionRegistry
347
+ );
348
+
349
+ const context = createMockRpcContext({ user: mockAdminUser });
350
+ const result = await call(
351
+ router.createTeam,
352
+ { name: "Minimal Team" },
353
+ { context }
354
+ );
355
+
356
+ expect(result).toBeDefined();
357
+ expect(result.id).toBeDefined();
358
+ });
359
+ });
360
+
361
+ describe("updateTeam", () => {
362
+ it("updates team name", async () => {
363
+ const mockDb = createMockDb();
364
+ let updatedData: Record<string, unknown> | undefined;
365
+
366
+ (mockDb.update as ReturnType<typeof mock>).mockImplementationOnce(() => ({
367
+ set: mock((data: Record<string, unknown>) => {
368
+ updatedData = data;
369
+ return {
370
+ where: mock(() => Promise.resolve()),
371
+ };
372
+ }),
373
+ }));
374
+
375
+ const router = createAuthRouter(
376
+ mockDb,
377
+ mockRegistry,
378
+ async () => {},
379
+ mockConfigService,
380
+ mockPermissionRegistry
381
+ );
382
+
383
+ const context = createMockRpcContext({ user: mockAdminUser });
384
+ await call(
385
+ router.updateTeam,
386
+ { id: "team-1", name: "Updated Name" },
387
+ { context }
388
+ );
389
+
390
+ expect(mockDb.update).toHaveBeenCalled();
391
+ expect(updatedData?.name).toBe("Updated Name");
392
+ expect(updatedData?.updatedAt).toBeInstanceOf(Date);
393
+ });
394
+
395
+ it("updates team description", async () => {
396
+ const mockDb = createMockDb();
397
+ let updatedData: Record<string, unknown> | undefined;
398
+
399
+ (mockDb.update as ReturnType<typeof mock>).mockImplementationOnce(() => ({
400
+ set: mock((data: Record<string, unknown>) => {
401
+ updatedData = data;
402
+ return {
403
+ where: mock(() => Promise.resolve()),
404
+ };
405
+ }),
406
+ }));
407
+
408
+ const router = createAuthRouter(
409
+ mockDb,
410
+ mockRegistry,
411
+ async () => {},
412
+ mockConfigService,
413
+ mockPermissionRegistry
414
+ );
415
+
416
+ const context = createMockRpcContext({ user: mockAdminUser });
417
+ await call(
418
+ router.updateTeam,
419
+ { id: "team-1", description: "New description" },
420
+ { context }
421
+ );
422
+
423
+ expect(updatedData?.description).toBe("New description");
424
+ });
425
+ });
426
+
427
+ describe("deleteTeam", () => {
428
+ it("deletes team and cascades to related tables", async () => {
429
+ const mockDb = createMockDb();
430
+ const deletedTables: unknown[] = [];
431
+
432
+ const mockTx = {
433
+ delete: mock((table: unknown) => {
434
+ deletedTables.push(table);
435
+ return {
436
+ where: mock(() => Promise.resolve()),
437
+ };
438
+ }),
439
+ };
440
+
441
+ (mockDb.transaction as ReturnType<typeof mock>).mockImplementationOnce(
442
+ (cb: (tx: typeof mockTx) => Promise<void>) => cb(mockTx)
443
+ );
444
+
445
+ const router = createAuthRouter(
446
+ mockDb,
447
+ mockRegistry,
448
+ async () => {},
449
+ mockConfigService,
450
+ mockPermissionRegistry
451
+ );
452
+
453
+ const context = createMockRpcContext({ user: mockAdminUser });
454
+ await call(router.deleteTeam, "team-1", { context });
455
+
456
+ expect(mockDb.transaction).toHaveBeenCalled();
457
+ expect(deletedTables).toHaveLength(5);
458
+ expect(deletedTables.includes(schema.userTeam)).toBe(true);
459
+ expect(deletedTables.includes(schema.teamManager)).toBe(true);
460
+ expect(deletedTables.includes(schema.applicationTeam)).toBe(true);
461
+ expect(deletedTables.includes(schema.resourceTeamAccess)).toBe(true);
462
+ expect(deletedTables.includes(schema.team)).toBe(true);
463
+ });
464
+ });
465
+
466
+ // ==========================================================================
467
+ // TEAM MEMBERSHIP TESTS
468
+ // ==========================================================================
469
+
470
+ describe("addUserToTeam", () => {
471
+ it("adds user to team with conflict handling", async () => {
472
+ const mockDb = createMockDb();
473
+ let insertedData: Record<string, unknown> | undefined;
474
+
475
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
476
+ values: mock((data: Record<string, unknown>) => {
477
+ insertedData = data;
478
+ return {
479
+ onConflictDoNothing: mock(() => Promise.resolve()),
480
+ };
481
+ }),
482
+ }));
483
+
484
+ const router = createAuthRouter(
485
+ mockDb,
486
+ mockRegistry,
487
+ async () => {},
488
+ mockConfigService,
489
+ mockPermissionRegistry
490
+ );
491
+
492
+ const context = createMockRpcContext({ user: mockAdminUser });
493
+ await call(
494
+ router.addUserToTeam,
495
+ { teamId: "team-1", userId: "user-1" },
496
+ { context }
497
+ );
498
+
499
+ expect(mockDb.insert).toHaveBeenCalled();
500
+ expect(insertedData?.teamId).toBe("team-1");
501
+ expect(insertedData?.userId).toBe("user-1");
502
+ });
503
+ });
504
+
505
+ describe("removeUserFromTeam", () => {
506
+ it("removes user from team", async () => {
507
+ const mockDb = createMockDb();
508
+
509
+ const router = createAuthRouter(
510
+ mockDb,
511
+ mockRegistry,
512
+ async () => {},
513
+ mockConfigService,
514
+ mockPermissionRegistry
515
+ );
516
+
517
+ const context = createMockRpcContext({ user: mockAdminUser });
518
+ await call(
519
+ router.removeUserFromTeam,
520
+ { teamId: "team-1", userId: "user-1" },
521
+ { context }
522
+ );
523
+
524
+ expect(mockDb.delete).toHaveBeenCalled();
525
+ });
526
+ });
527
+
528
+ // ==========================================================================
529
+ // TEAM MANAGER TESTS
530
+ // ==========================================================================
531
+
532
+ describe("addTeamManager", () => {
533
+ it("grants manager privileges", async () => {
534
+ const mockDb = createMockDb();
535
+ let insertedData: Record<string, unknown> | undefined;
536
+
537
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
538
+ values: mock((data: Record<string, unknown>) => {
539
+ insertedData = data;
540
+ return {
541
+ onConflictDoNothing: mock(() => Promise.resolve()),
542
+ };
543
+ }),
544
+ }));
545
+
546
+ const router = createAuthRouter(
547
+ mockDb,
548
+ mockRegistry,
549
+ async () => {},
550
+ mockConfigService,
551
+ mockPermissionRegistry
552
+ );
553
+
554
+ const context = createMockRpcContext({ user: mockAdminUser });
555
+ await call(
556
+ router.addTeamManager,
557
+ { teamId: "team-1", userId: "user-1" },
558
+ { context }
559
+ );
560
+
561
+ expect(mockDb.insert).toHaveBeenCalled();
562
+ expect(insertedData?.teamId).toBe("team-1");
563
+ expect(insertedData?.userId).toBe("user-1");
564
+ });
565
+ });
566
+
567
+ describe("removeTeamManager", () => {
568
+ it("revokes manager privileges", async () => {
569
+ const mockDb = createMockDb();
570
+
571
+ const router = createAuthRouter(
572
+ mockDb,
573
+ mockRegistry,
574
+ async () => {},
575
+ mockConfigService,
576
+ mockPermissionRegistry
577
+ );
578
+
579
+ const context = createMockRpcContext({ user: mockAdminUser });
580
+ await call(
581
+ router.removeTeamManager,
582
+ { teamId: "team-1", userId: "user-1" },
583
+ { context }
584
+ );
585
+
586
+ expect(mockDb.delete).toHaveBeenCalled();
587
+ });
588
+ });
589
+
590
+ // ==========================================================================
591
+ // RESOURCE ACCESS GRANT TESTS
592
+ // ==========================================================================
593
+
594
+ describe("getResourceTeamAccess", () => {
595
+ it("returns empty array when no grants exist", async () => {
596
+ const mockDb = createMockDb();
597
+
598
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
599
+ from: mock(() => ({
600
+ innerJoin: mock(() => createChain([])),
601
+ })),
602
+ }));
603
+
604
+ const router = createAuthRouter(
605
+ mockDb,
606
+ mockRegistry,
607
+ async () => {},
608
+ mockConfigService,
609
+ mockPermissionRegistry
610
+ );
611
+
612
+ const context = createMockRpcContext({ user: mockAdminUser });
613
+ const result = await call(
614
+ router.getResourceTeamAccess,
615
+ { resourceType: "catalog.system", resourceId: "sys-1" },
616
+ { context }
617
+ );
618
+
619
+ expect(Array.isArray(result)).toBe(true);
620
+ expect(result).toHaveLength(0);
621
+ });
622
+
623
+ it("returns grants with team names", async () => {
624
+ const mockDb = createMockDb();
625
+
626
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
627
+ from: mock(() => ({
628
+ innerJoin: mock(() =>
629
+ createChain([
630
+ {
631
+ resource_team_access: {
632
+ teamId: "team-1",
633
+ canRead: true,
634
+ canManage: true,
635
+ },
636
+ team: { name: "Platform Team" },
637
+ },
638
+ {
639
+ resource_team_access: {
640
+ teamId: "team-2",
641
+ canRead: true,
642
+ canManage: false,
643
+ },
644
+ team: { name: "API Team" },
645
+ },
646
+ ])
647
+ ),
648
+ })),
649
+ }));
650
+
651
+ const router = createAuthRouter(
652
+ mockDb,
653
+ mockRegistry,
654
+ async () => {},
655
+ mockConfigService,
656
+ mockPermissionRegistry
657
+ );
658
+
659
+ const context = createMockRpcContext({ user: mockAdminUser });
660
+ const result = await call(
661
+ router.getResourceTeamAccess,
662
+ { resourceType: "catalog.system", resourceId: "sys-1" },
663
+ { context }
664
+ );
665
+
666
+ expect(result).toHaveLength(2);
667
+ expect(result[0]).toEqual({
668
+ teamId: "team-1",
669
+ teamName: "Platform Team",
670
+ canRead: true,
671
+ canManage: true,
672
+ });
673
+ expect(result[1]).toEqual({
674
+ teamId: "team-2",
675
+ teamName: "API Team",
676
+ canRead: true,
677
+ canManage: false,
678
+ });
679
+ });
680
+ });
681
+
682
+ describe("setResourceTeamAccess", () => {
683
+ it("creates new grant with default permissions", async () => {
684
+ const mockDb = createMockDb();
685
+ let insertedData: Record<string, unknown> | undefined;
686
+
687
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
688
+ values: mock((data: Record<string, unknown>) => {
689
+ insertedData = data;
690
+ return {
691
+ onConflictDoUpdate: mock(() => Promise.resolve()),
692
+ };
693
+ }),
694
+ }));
695
+
696
+ const router = createAuthRouter(
697
+ mockDb,
698
+ mockRegistry,
699
+ async () => {},
700
+ mockConfigService,
701
+ mockPermissionRegistry
702
+ );
703
+
704
+ const context = createMockRpcContext({ user: mockAdminUser });
705
+ await call(
706
+ router.setResourceTeamAccess,
707
+ {
708
+ resourceType: "catalog.system",
709
+ resourceId: "sys-1",
710
+ teamId: "team-1",
711
+ },
712
+ { context }
713
+ );
714
+
715
+ expect(mockDb.insert).toHaveBeenCalled();
716
+ expect(insertedData?.resourceType).toBe("catalog.system");
717
+ expect(insertedData?.resourceId).toBe("sys-1");
718
+ expect(insertedData?.teamId).toBe("team-1");
719
+ expect(insertedData?.canRead).toBe(true);
720
+ expect(insertedData?.canManage).toBe(false);
721
+ });
722
+
723
+ it("creates grant with custom permissions", async () => {
724
+ const mockDb = createMockDb();
725
+ let insertedData: Record<string, unknown> | undefined;
726
+
727
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
728
+ values: mock((data: Record<string, unknown>) => {
729
+ insertedData = data;
730
+ return {
731
+ onConflictDoUpdate: mock(() => Promise.resolve()),
732
+ };
733
+ }),
734
+ }));
735
+
736
+ const router = createAuthRouter(
737
+ mockDb,
738
+ mockRegistry,
739
+ async () => {},
740
+ mockConfigService,
741
+ mockPermissionRegistry
742
+ );
743
+
744
+ const context = createMockRpcContext({ user: mockAdminUser });
745
+ await call(
746
+ router.setResourceTeamAccess,
747
+ {
748
+ resourceType: "catalog.system",
749
+ resourceId: "sys-1",
750
+ teamId: "team-1",
751
+ canRead: true,
752
+ canManage: true,
753
+ },
754
+ { context }
755
+ );
756
+
757
+ expect(insertedData?.canRead).toBe(true);
758
+ expect(insertedData?.canManage).toBe(true);
759
+ });
760
+ });
761
+
762
+ describe("removeResourceTeamAccess", () => {
763
+ it("removes grant for specific team", async () => {
764
+ const mockDb = createMockDb();
765
+
766
+ const router = createAuthRouter(
767
+ mockDb,
768
+ mockRegistry,
769
+ async () => {},
770
+ mockConfigService,
771
+ mockPermissionRegistry
772
+ );
773
+
774
+ const context = createMockRpcContext({ user: mockAdminUser });
775
+ await call(
776
+ router.removeResourceTeamAccess,
777
+ {
778
+ resourceType: "catalog.system",
779
+ resourceId: "sys-1",
780
+ teamId: "team-1",
781
+ },
782
+ { context }
783
+ );
784
+
785
+ expect(mockDb.delete).toHaveBeenCalled();
786
+ });
787
+ });
788
+
789
+ // ==========================================================================
790
+ // S2S ACCESS CHECK TESTS
791
+ // ==========================================================================
792
+
793
+ describe("checkResourceTeamAccess (S2S)", () => {
794
+ it("allows access when no grants exist and user has global permission", async () => {
795
+ const mockDb = createMockDb();
796
+
797
+ // No grants exist
798
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
799
+ from: mock(() => createChain([])),
800
+ }));
801
+
802
+ const router = createAuthRouter(
803
+ mockDb,
804
+ mockRegistry,
805
+ async () => {},
806
+ mockConfigService,
807
+ mockPermissionRegistry
808
+ );
809
+
810
+ const context = createMockRpcContext({ user: mockServiceUser });
811
+ const result = await call(
812
+ router.checkResourceTeamAccess,
813
+ {
814
+ userId: "user-1",
815
+ userType: "user",
816
+ resourceType: "catalog.system",
817
+ resourceId: "sys-1",
818
+ action: "read",
819
+ hasGlobalPermission: true,
820
+ },
821
+ { context }
822
+ );
823
+
824
+ expect(result.hasAccess).toBe(true);
825
+ });
826
+
827
+ it("denies access when no grants exist and user lacks global permission", async () => {
828
+ const mockDb = createMockDb();
829
+
830
+ // No grants exist
831
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
832
+ from: mock(() => createChain([])),
833
+ }));
834
+
835
+ const router = createAuthRouter(
836
+ mockDb,
837
+ mockRegistry,
838
+ async () => {},
839
+ mockConfigService,
840
+ mockPermissionRegistry
841
+ );
842
+
843
+ const context = createMockRpcContext({ user: mockServiceUser });
844
+ const result = await call(
845
+ router.checkResourceTeamAccess,
846
+ {
847
+ userId: "user-1",
848
+ userType: "user",
849
+ resourceType: "catalog.system",
850
+ resourceId: "sys-1",
851
+ action: "read",
852
+ hasGlobalPermission: false,
853
+ },
854
+ { context }
855
+ );
856
+
857
+ expect(result.hasAccess).toBe(false);
858
+ });
859
+
860
+ it("allows access when user's team has grant with canRead", async () => {
861
+ const mockDb = createMockDb();
862
+
863
+ // Grant exists for team-1
864
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
865
+ from: mock(() =>
866
+ createChain([
867
+ {
868
+ teamId: "team-1",
869
+ canRead: true,
870
+ canManage: false,
871
+ },
872
+ ])
873
+ ),
874
+ }));
875
+
876
+ // Settings query - returns empty (teamOnly = false by default)
877
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
878
+ from: mock(() => createChain([])),
879
+ }));
880
+
881
+ // User is member of team-1
882
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
883
+ from: mock(() => createChain([{ teamId: "team-1" }])),
884
+ }));
885
+
886
+ const router = createAuthRouter(
887
+ mockDb,
888
+ mockRegistry,
889
+ async () => {},
890
+ mockConfigService,
891
+ mockPermissionRegistry
892
+ );
893
+
894
+ const context = createMockRpcContext({ user: mockServiceUser });
895
+ const result = await call(
896
+ router.checkResourceTeamAccess,
897
+ {
898
+ userId: "user-1",
899
+ userType: "user",
900
+ resourceType: "catalog.system",
901
+ resourceId: "sys-1",
902
+ action: "read",
903
+ hasGlobalPermission: false,
904
+ },
905
+ { context }
906
+ );
907
+
908
+ expect(result.hasAccess).toBe(true);
909
+ });
910
+
911
+ it("denies access when user's team has grant but lacks canManage for manage action", async () => {
912
+ const mockDb = createMockDb();
913
+
914
+ // Grant exists for team-1 with only read permission
915
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
916
+ from: mock(() =>
917
+ createChain([
918
+ {
919
+ teamId: "team-1",
920
+ canRead: true,
921
+ canManage: false,
922
+ },
923
+ ])
924
+ ),
925
+ }));
926
+
927
+ // Settings query - returns empty (teamOnly = false by default)
928
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
929
+ from: mock(() => createChain([])),
930
+ }));
931
+
932
+ // User is member of team-1
933
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
934
+ from: mock(() => createChain([{ teamId: "team-1" }])),
935
+ }));
936
+
937
+ const router = createAuthRouter(
938
+ mockDb,
939
+ mockRegistry,
940
+ async () => {},
941
+ mockConfigService,
942
+ mockPermissionRegistry
943
+ );
944
+
945
+ const context = createMockRpcContext({ user: mockServiceUser });
946
+ const result = await call(
947
+ router.checkResourceTeamAccess,
948
+ {
949
+ userId: "user-1",
950
+ userType: "user",
951
+ resourceType: "catalog.system",
952
+ resourceId: "sys-1",
953
+ action: "manage",
954
+ hasGlobalPermission: false,
955
+ },
956
+ { context }
957
+ );
958
+
959
+ expect(result.hasAccess).toBe(false);
960
+ });
961
+
962
+ it("allows access for teamOnly resource when user is in granted team", async () => {
963
+ const mockDb = createMockDb();
964
+
965
+ // Grant exists for team-1
966
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
967
+ from: mock(() =>
968
+ createChain([
969
+ {
970
+ teamId: "team-1",
971
+ canRead: true,
972
+ canManage: false,
973
+ },
974
+ ])
975
+ ),
976
+ }));
977
+
978
+ // Settings query - returns teamOnly = true
979
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
980
+ from: mock(() =>
981
+ createChain([{ teamOnly: true, resourceId: "sys-1" }])
982
+ ),
983
+ }));
984
+
985
+ // User is member of team-1
986
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
987
+ from: mock(() => createChain([{ teamId: "team-1" }])),
988
+ }));
989
+
990
+ const router = createAuthRouter(
991
+ mockDb,
992
+ mockRegistry,
993
+ async () => {},
994
+ mockConfigService,
995
+ mockPermissionRegistry
996
+ );
997
+
998
+ const context = createMockRpcContext({ user: mockServiceUser });
999
+ const result = await call(
1000
+ router.checkResourceTeamAccess,
1001
+ {
1002
+ userId: "user-1",
1003
+ userType: "user",
1004
+ resourceType: "catalog.system",
1005
+ resourceId: "sys-1",
1006
+ action: "read",
1007
+ hasGlobalPermission: true, // Global permission doesn't help with teamOnly
1008
+ },
1009
+ { context }
1010
+ );
1011
+
1012
+ expect(result.hasAccess).toBe(true);
1013
+ });
1014
+
1015
+ it("denies access for teamOnly resource when user is not in granted team", async () => {
1016
+ const mockDb = createMockDb();
1017
+
1018
+ // Grant exists for team-1
1019
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1020
+ from: mock(() =>
1021
+ createChain([
1022
+ {
1023
+ teamId: "team-1",
1024
+ canRead: true,
1025
+ canManage: false,
1026
+ },
1027
+ ])
1028
+ ),
1029
+ }));
1030
+
1031
+ // Settings query - returns teamOnly = true
1032
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1033
+ from: mock(() =>
1034
+ createChain([{ teamOnly: true, resourceId: "sys-1" }])
1035
+ ),
1036
+ }));
1037
+
1038
+ // User is member of team-2 (not team-1)
1039
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1040
+ from: mock(() => createChain([{ teamId: "team-2" }])),
1041
+ }));
1042
+
1043
+ const router = createAuthRouter(
1044
+ mockDb,
1045
+ mockRegistry,
1046
+ async () => {},
1047
+ mockConfigService,
1048
+ mockPermissionRegistry
1049
+ );
1050
+
1051
+ const context = createMockRpcContext({ user: mockServiceUser });
1052
+ const result = await call(
1053
+ router.checkResourceTeamAccess,
1054
+ {
1055
+ userId: "user-1",
1056
+ userType: "user",
1057
+ resourceType: "catalog.system",
1058
+ resourceId: "sys-1",
1059
+ action: "read",
1060
+ hasGlobalPermission: true, // Global permission doesn't help with teamOnly
1061
+ },
1062
+ { context }
1063
+ );
1064
+
1065
+ expect(result.hasAccess).toBe(false);
1066
+ });
1067
+ });
1068
+
1069
+ describe("getAccessibleResourceIds (S2S)", () => {
1070
+ it("returns empty array for empty input", async () => {
1071
+ const mockDb = createMockDb();
1072
+
1073
+ const router = createAuthRouter(
1074
+ mockDb,
1075
+ mockRegistry,
1076
+ async () => {},
1077
+ mockConfigService,
1078
+ mockPermissionRegistry
1079
+ );
1080
+
1081
+ const context = createMockRpcContext({ user: mockServiceUser });
1082
+ const result = await call(
1083
+ router.getAccessibleResourceIds,
1084
+ {
1085
+ userId: "user-1",
1086
+ userType: "user",
1087
+ resourceType: "catalog.system",
1088
+ resourceIds: [],
1089
+ action: "read",
1090
+ hasGlobalPermission: true,
1091
+ },
1092
+ { context }
1093
+ );
1094
+
1095
+ expect(result).toEqual([]);
1096
+ });
1097
+
1098
+ it("returns all resources when no grants exist and user has global permission", async () => {
1099
+ const mockDb = createMockDb();
1100
+
1101
+ // No grants exist
1102
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1103
+ from: mock(() => createChain([])),
1104
+ }));
1105
+
1106
+ // User teams (not used when no grants)
1107
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1108
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1109
+ }));
1110
+
1111
+ const router = createAuthRouter(
1112
+ mockDb,
1113
+ mockRegistry,
1114
+ async () => {},
1115
+ mockConfigService,
1116
+ mockPermissionRegistry
1117
+ );
1118
+
1119
+ const context = createMockRpcContext({ user: mockServiceUser });
1120
+ const result = await call(
1121
+ router.getAccessibleResourceIds,
1122
+ {
1123
+ userId: "user-1",
1124
+ userType: "user",
1125
+ resourceType: "catalog.system",
1126
+ resourceIds: ["sys-1", "sys-2", "sys-3"],
1127
+ action: "read",
1128
+ hasGlobalPermission: true,
1129
+ },
1130
+ { context }
1131
+ );
1132
+
1133
+ expect(result).toEqual(["sys-1", "sys-2", "sys-3"]);
1134
+ });
1135
+
1136
+ it("filters resources based on team grants", async () => {
1137
+ const mockDb = createMockDb();
1138
+
1139
+ // Grants exist for sys-1 (team-1) and sys-2 (team-2)
1140
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1141
+ from: mock(() =>
1142
+ createChain([
1143
+ {
1144
+ resourceId: "sys-1",
1145
+ teamId: "team-1",
1146
+ canRead: true,
1147
+ canManage: false,
1148
+ },
1149
+ {
1150
+ resourceId: "sys-2",
1151
+ teamId: "team-2",
1152
+ canRead: true,
1153
+ canManage: false,
1154
+ },
1155
+ ])
1156
+ ),
1157
+ }));
1158
+
1159
+ // Settings query - both sys-1 and sys-2 are teamOnly
1160
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1161
+ from: mock(() =>
1162
+ createChain([
1163
+ { resourceId: "sys-1", teamOnly: true },
1164
+ { resourceId: "sys-2", teamOnly: true },
1165
+ ])
1166
+ ),
1167
+ }));
1168
+
1169
+ // User is member of team-1 only
1170
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1171
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1172
+ }));
1173
+
1174
+ const router = createAuthRouter(
1175
+ mockDb,
1176
+ mockRegistry,
1177
+ async () => {},
1178
+ mockConfigService,
1179
+ mockPermissionRegistry
1180
+ );
1181
+
1182
+ const context = createMockRpcContext({ user: mockServiceUser });
1183
+ const result = await call(
1184
+ router.getAccessibleResourceIds,
1185
+ {
1186
+ userId: "user-1",
1187
+ userType: "user",
1188
+ resourceType: "catalog.system",
1189
+ resourceIds: ["sys-1", "sys-2", "sys-3"],
1190
+ action: "read",
1191
+ hasGlobalPermission: true,
1192
+ },
1193
+ { context }
1194
+ );
1195
+
1196
+ // sys-1: user is in team-1, granted
1197
+ // sys-2: user is not in team-2, denied (teamOnly)
1198
+ // sys-3: no grants, allowed by global permission
1199
+ expect(result).toContain("sys-1");
1200
+ expect(result).not.toContain("sys-2");
1201
+ expect(result).toContain("sys-3");
1202
+ });
1203
+ });
1204
+
1205
+ describe("deleteResourceGrants (S2S)", () => {
1206
+ it("deletes all grants for a resource", async () => {
1207
+ const mockDb = createMockDb();
1208
+
1209
+ const router = createAuthRouter(
1210
+ mockDb,
1211
+ mockRegistry,
1212
+ async () => {},
1213
+ mockConfigService,
1214
+ mockPermissionRegistry
1215
+ );
1216
+
1217
+ const context = createMockRpcContext({ user: mockServiceUser });
1218
+ await call(
1219
+ router.deleteResourceGrants,
1220
+ {
1221
+ resourceType: "catalog.system",
1222
+ resourceId: "sys-1",
1223
+ },
1224
+ { context }
1225
+ );
1226
+
1227
+ expect(mockDb.delete).toHaveBeenCalled();
1228
+ });
1229
+ });
1230
+ });