@checkstack/auth-backend 0.0.3 → 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.
@@ -0,0 +1,1985 @@
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 accesss
24
+ const mockAdminUser = {
25
+ type: "user" as const,
26
+ id: "admin-user",
27
+ accessRules: ["*"],
28
+ roles: ["admin"],
29
+ teamIds: ["team-alpha"],
30
+ };
31
+
32
+ // Mock regular user with limited access
33
+ // Note: Uses test-plugin prefix to match createMockRpcContext's pluginMetadata
34
+ const mockRegularUser = {
35
+ type: "user" as const,
36
+ id: "regular-user",
37
+ accessRules: ["test-plugin.teams.read"],
38
+ roles: ["users"],
39
+ teamIds: ["team-beta"],
40
+ };
41
+
42
+ // Mock service user for S2S calls
43
+ const mockServiceUser = {
44
+ type: "service" as const,
45
+ pluginId: "backend-api",
46
+ };
47
+
48
+ /**
49
+ * Creates a chainable mock for database query operations.
50
+ * Allows chaining .where().innerJoin().limit().offset().orderBy()
51
+ */
52
+ function createChain<T>(data: T[] = []): Record<string, unknown> {
53
+ const chain: Record<string, unknown> = {
54
+ where: mock(() => chain),
55
+ innerJoin: mock(() => chain),
56
+ limit: mock(() => chain),
57
+ offset: mock(() => chain),
58
+ orderBy: mock(() => chain),
59
+ onConflictDoUpdate: mock(() => Promise.resolve()),
60
+ onConflictDoNothing: mock(() => Promise.resolve()),
61
+ then: (resolve: (value: T[]) => void) => Promise.resolve(resolve(data)),
62
+ };
63
+ return chain;
64
+ }
65
+
66
+ /**
67
+ * Creates a fresh mock database for each test.
68
+ * Uses type assertion to satisfy NodePgDatabase interface for testing.
69
+ */
70
+ function createMockDb(): AuthDatabase {
71
+ const mockDb = {
72
+ select: mock(() => ({
73
+ from: mock(() => createChain([])),
74
+ })),
75
+ insert: mock(() => ({
76
+ values: mock(() => ({
77
+ onConflictDoNothing: mock(() => Promise.resolve()),
78
+ onConflictDoUpdate: mock(() => Promise.resolve()),
79
+ then: (resolve: (value: unknown) => void) =>
80
+ Promise.resolve(resolve(undefined)),
81
+ })),
82
+ })),
83
+ update: mock(() => ({
84
+ set: mock(() => ({
85
+ where: mock(() => Promise.resolve()),
86
+ })),
87
+ })),
88
+ delete: mock(() => ({
89
+ where: mock(() => Promise.resolve()),
90
+ })),
91
+ transaction: mock((cb: (tx: typeof mockDb) => Promise<void>) =>
92
+ cb(mockDb)
93
+ ),
94
+ };
95
+ // Type assertion for mock database - only used in tests
96
+ return mockDb as unknown as AuthDatabase;
97
+ }
98
+
99
+ const mockRegistry = {
100
+ getStrategies: () => [
101
+ {
102
+ id: "credential",
103
+ displayName: "Credentials",
104
+ description: "Email and password authentication",
105
+ configSchema: z.object({ enabled: z.boolean() }),
106
+ configVersion: 1,
107
+ migrations: [],
108
+ requiresManualRegistration: true,
109
+ },
110
+ ],
111
+ };
112
+
113
+ const mockConfigService = {
114
+ get: mock(() => Promise.resolve(undefined)),
115
+ getRedacted: mock(() => Promise.resolve({})),
116
+ set: mock(() => Promise.resolve()),
117
+ delete: mock(() => Promise.resolve()),
118
+ list: mock(() => Promise.resolve([])),
119
+ };
120
+
121
+ const mockAccessRuleRegistry = {
122
+ getAccessRules: () => [
123
+ { id: "auth.teams.read", description: "View teams" },
124
+ { id: "auth.teams.manage", description: "Manage teams" },
125
+ ],
126
+ };
127
+
128
+ // ==========================================================================
129
+ // TEAM CRUD TESTS
130
+ // ==========================================================================
131
+
132
+ describe("getTeams", () => {
133
+ it("returns empty array when no teams exist", async () => {
134
+ const mockDb = createMockDb();
135
+ const router = createAuthRouter(
136
+ mockDb,
137
+ mockRegistry,
138
+ async () => {},
139
+ mockConfigService,
140
+ mockAccessRuleRegistry
141
+ );
142
+
143
+ const context = createMockRpcContext({ user: mockAdminUser });
144
+ const result = await call(router.getTeams, undefined, { context });
145
+
146
+ expect(Array.isArray(result)).toBe(true);
147
+ expect(result).toHaveLength(0);
148
+ });
149
+
150
+ it("returns teams with member counts", async () => {
151
+ const mockDb = createMockDb();
152
+
153
+ // Mock teams query
154
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
155
+ from: mock(() =>
156
+ createChain([
157
+ {
158
+ id: "team-1",
159
+ name: "Platform Team",
160
+ description: "Core platform team",
161
+ },
162
+ { id: "team-2", name: "API Team", description: null },
163
+ ])
164
+ ),
165
+ }));
166
+
167
+ // Mock member counts query
168
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
169
+ from: mock(() =>
170
+ createChain([
171
+ { teamId: "team-1" },
172
+ { teamId: "team-1" },
173
+ { teamId: "team-2" },
174
+ ])
175
+ ),
176
+ }));
177
+
178
+ // Mock manager query (user is manager of team-1)
179
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
180
+ from: mock(() =>
181
+ createChain([{ teamId: "team-1", userId: "admin-user" }])
182
+ ),
183
+ }));
184
+
185
+ const router = createAuthRouter(
186
+ mockDb,
187
+ mockRegistry,
188
+ async () => {},
189
+ mockConfigService,
190
+ mockAccessRuleRegistry
191
+ );
192
+
193
+ const context = createMockRpcContext({ user: mockAdminUser });
194
+ const result = await call(router.getTeams, undefined, { context });
195
+
196
+ expect(result).toHaveLength(2);
197
+ expect(result[0]).toEqual({
198
+ id: "team-1",
199
+ name: "Platform Team",
200
+ description: "Core platform team",
201
+ memberCount: 2,
202
+ isManager: true,
203
+ });
204
+ expect(result[1]).toEqual({
205
+ id: "team-2",
206
+ name: "API Team",
207
+ description: null,
208
+ memberCount: 1,
209
+ isManager: false,
210
+ });
211
+ });
212
+
213
+ it("returns teams for regular user with read-only access", async () => {
214
+ const mockDb = createMockDb();
215
+
216
+ // Mock teams query
217
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
218
+ from: mock(() =>
219
+ createChain([
220
+ {
221
+ id: "team-alpha",
222
+ name: "Alpha Team",
223
+ description: "First team",
224
+ },
225
+ {
226
+ id: "team-beta",
227
+ name: "Beta Team",
228
+ description: "Second team",
229
+ },
230
+ ])
231
+ ),
232
+ }));
233
+
234
+ // Mock member counts query
235
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
236
+ from: mock(() =>
237
+ createChain([
238
+ { teamId: "team-alpha" },
239
+ { teamId: "team-beta" },
240
+ { teamId: "team-beta" },
241
+ ])
242
+ ),
243
+ }));
244
+
245
+ // Mock manager query (regular-user is not a manager of any team)
246
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
247
+ from: mock(() => createChain([])),
248
+ }));
249
+
250
+ const router = createAuthRouter(
251
+ mockDb,
252
+ mockRegistry,
253
+ async () => {},
254
+ mockConfigService,
255
+ mockAccessRuleRegistry
256
+ );
257
+
258
+ // Use mockRegularUser who has only auth.teams.read access
259
+ const context = createMockRpcContext({ user: mockRegularUser });
260
+ const result = await call(router.getTeams, undefined, { context });
261
+
262
+ expect(result).toHaveLength(2);
263
+ expect(result[0]).toEqual({
264
+ id: "team-alpha",
265
+ name: "Alpha Team",
266
+ description: "First team",
267
+ memberCount: 1,
268
+ isManager: false,
269
+ });
270
+ expect(result[1]).toEqual({
271
+ id: "team-beta",
272
+ name: "Beta Team",
273
+ description: "Second team",
274
+ memberCount: 2,
275
+ isManager: false,
276
+ });
277
+ });
278
+
279
+ it("shows correct manager status for regular user who is a team manager", async () => {
280
+ const mockDb = createMockDb();
281
+
282
+ // Mock teams query
283
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
284
+ from: mock(() =>
285
+ createChain([
286
+ {
287
+ id: "team-beta",
288
+ name: "Beta Team",
289
+ description: "User's team",
290
+ },
291
+ ])
292
+ ),
293
+ }));
294
+
295
+ // Mock member counts query
296
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
297
+ from: mock(() => createChain([{ teamId: "team-beta" }])),
298
+ }));
299
+
300
+ // Mock manager query (regular-user IS a manager of team-beta)
301
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
302
+ from: mock(() =>
303
+ createChain([{ teamId: "team-beta", userId: "regular-user" }])
304
+ ),
305
+ }));
306
+
307
+ const router = createAuthRouter(
308
+ mockDb,
309
+ mockRegistry,
310
+ async () => {},
311
+ mockConfigService,
312
+ mockAccessRuleRegistry
313
+ );
314
+
315
+ const context = createMockRpcContext({ user: mockRegularUser });
316
+ const result = await call(router.getTeams, undefined, { context });
317
+
318
+ expect(result).toHaveLength(1);
319
+ expect(result[0].isManager).toBe(true);
320
+ });
321
+ });
322
+
323
+ describe("getTeam", () => {
324
+ it("returns undefined for non-existent team", async () => {
325
+ const mockDb = createMockDb();
326
+
327
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
328
+ from: mock(() => createChain([])),
329
+ }));
330
+
331
+ const router = createAuthRouter(
332
+ mockDb,
333
+ mockRegistry,
334
+ async () => {},
335
+ mockConfigService,
336
+ mockAccessRuleRegistry
337
+ );
338
+
339
+ const context = createMockRpcContext({ user: mockAdminUser });
340
+ const result = await call(
341
+ router.getTeam,
342
+ { teamId: "non-existent" },
343
+ { context }
344
+ );
345
+
346
+ expect(result).toBeUndefined();
347
+ });
348
+
349
+ it("returns team with members and managers", async () => {
350
+ const mockDb = createMockDb();
351
+
352
+ // Mock team query
353
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
354
+ from: mock(() =>
355
+ createChain([
356
+ {
357
+ id: "team-1",
358
+ name: "Platform Team",
359
+ description: "Core team",
360
+ },
361
+ ])
362
+ ),
363
+ }));
364
+
365
+ // Mock members query
366
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
367
+ from: mock(() =>
368
+ createChain([{ userId: "user-1" }, { userId: "user-2" }])
369
+ ),
370
+ }));
371
+
372
+ // Mock managers query
373
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
374
+ from: mock(() => createChain([{ userId: "user-1" }])),
375
+ }));
376
+
377
+ // Mock users query
378
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
379
+ from: mock(() =>
380
+ createChain([
381
+ { id: "user-1", name: "Alice", email: "alice@test.com" },
382
+ { id: "user-2", name: "Bob", email: "bob@test.com" },
383
+ ])
384
+ ),
385
+ }));
386
+
387
+ const router = createAuthRouter(
388
+ mockDb,
389
+ mockRegistry,
390
+ async () => {},
391
+ mockConfigService,
392
+ mockAccessRuleRegistry
393
+ );
394
+
395
+ const context = createMockRpcContext({ user: mockAdminUser });
396
+ const result = await call(
397
+ router.getTeam,
398
+ { teamId: "team-1" },
399
+ { context }
400
+ );
401
+
402
+ expect(result).toBeDefined();
403
+ expect(result?.name).toBe("Platform Team");
404
+ expect(result?.members).toHaveLength(2);
405
+ expect(result?.managers).toHaveLength(1);
406
+ });
407
+ });
408
+
409
+ describe("createTeam", () => {
410
+ it("creates team with name and description", async () => {
411
+ const mockDb = createMockDb();
412
+ let insertedData: Record<string, unknown> | undefined;
413
+
414
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
415
+ values: mock((data: Record<string, unknown>) => {
416
+ insertedData = data;
417
+ return Promise.resolve();
418
+ }),
419
+ }));
420
+
421
+ const router = createAuthRouter(
422
+ mockDb,
423
+ mockRegistry,
424
+ async () => {},
425
+ mockConfigService,
426
+ mockAccessRuleRegistry
427
+ );
428
+
429
+ const context = createMockRpcContext({ user: mockAdminUser });
430
+ const result = await call(
431
+ router.createTeam,
432
+ { name: "New Team", description: "A new team" },
433
+ { context }
434
+ );
435
+
436
+ expect(result).toBeDefined();
437
+ expect(result.id).toBeDefined();
438
+ expect(typeof result.id).toBe("string");
439
+ expect(mockDb.insert).toHaveBeenCalled();
440
+ expect(insertedData?.name).toBe("New Team");
441
+ expect(insertedData?.description).toBe("A new team");
442
+ });
443
+
444
+ it("creates team with minimal data", async () => {
445
+ const mockDb = createMockDb();
446
+
447
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
448
+ values: mock(() => Promise.resolve()),
449
+ }));
450
+
451
+ const router = createAuthRouter(
452
+ mockDb,
453
+ mockRegistry,
454
+ async () => {},
455
+ mockConfigService,
456
+ mockAccessRuleRegistry
457
+ );
458
+
459
+ const context = createMockRpcContext({ user: mockAdminUser });
460
+ const result = await call(
461
+ router.createTeam,
462
+ { name: "Minimal Team" },
463
+ { context }
464
+ );
465
+
466
+ expect(result).toBeDefined();
467
+ expect(result.id).toBeDefined();
468
+ });
469
+ });
470
+
471
+ describe("updateTeam", () => {
472
+ it("updates team name", async () => {
473
+ const mockDb = createMockDb();
474
+ let updatedData: Record<string, unknown> | undefined;
475
+
476
+ (mockDb.update as ReturnType<typeof mock>).mockImplementationOnce(() => ({
477
+ set: mock((data: Record<string, unknown>) => {
478
+ updatedData = data;
479
+ return {
480
+ where: mock(() => Promise.resolve()),
481
+ };
482
+ }),
483
+ }));
484
+
485
+ const router = createAuthRouter(
486
+ mockDb,
487
+ mockRegistry,
488
+ async () => {},
489
+ mockConfigService,
490
+ mockAccessRuleRegistry
491
+ );
492
+
493
+ const context = createMockRpcContext({ user: mockAdminUser });
494
+ await call(
495
+ router.updateTeam,
496
+ { id: "team-1", name: "Updated Name" },
497
+ { context }
498
+ );
499
+
500
+ expect(mockDb.update).toHaveBeenCalled();
501
+ expect(updatedData?.name).toBe("Updated Name");
502
+ expect(updatedData?.updatedAt).toBeInstanceOf(Date);
503
+ });
504
+
505
+ it("updates team description", async () => {
506
+ const mockDb = createMockDb();
507
+ let updatedData: Record<string, unknown> | undefined;
508
+
509
+ (mockDb.update as ReturnType<typeof mock>).mockImplementationOnce(() => ({
510
+ set: mock((data: Record<string, unknown>) => {
511
+ updatedData = data;
512
+ return {
513
+ where: mock(() => Promise.resolve()),
514
+ };
515
+ }),
516
+ }));
517
+
518
+ const router = createAuthRouter(
519
+ mockDb,
520
+ mockRegistry,
521
+ async () => {},
522
+ mockConfigService,
523
+ mockAccessRuleRegistry
524
+ );
525
+
526
+ const context = createMockRpcContext({ user: mockAdminUser });
527
+ await call(
528
+ router.updateTeam,
529
+ { id: "team-1", description: "New description" },
530
+ { context }
531
+ );
532
+
533
+ expect(updatedData?.description).toBe("New description");
534
+ });
535
+ });
536
+
537
+ describe("deleteTeam", () => {
538
+ it("deletes team and cascades to related tables", async () => {
539
+ const mockDb = createMockDb();
540
+ const deletedTables: unknown[] = [];
541
+
542
+ const mockTx = {
543
+ delete: mock((table: unknown) => {
544
+ deletedTables.push(table);
545
+ return {
546
+ where: mock(() => Promise.resolve()),
547
+ };
548
+ }),
549
+ };
550
+
551
+ (mockDb.transaction as ReturnType<typeof mock>).mockImplementationOnce(
552
+ (cb: (tx: typeof mockTx) => Promise<void>) => cb(mockTx)
553
+ );
554
+
555
+ const router = createAuthRouter(
556
+ mockDb,
557
+ mockRegistry,
558
+ async () => {},
559
+ mockConfigService,
560
+ mockAccessRuleRegistry
561
+ );
562
+
563
+ const context = createMockRpcContext({ user: mockAdminUser });
564
+ await call(router.deleteTeam, "team-1", { context });
565
+
566
+ expect(mockDb.transaction).toHaveBeenCalled();
567
+ expect(deletedTables).toHaveLength(5);
568
+ expect(deletedTables.includes(schema.userTeam)).toBe(true);
569
+ expect(deletedTables.includes(schema.teamManager)).toBe(true);
570
+ expect(deletedTables.includes(schema.applicationTeam)).toBe(true);
571
+ expect(deletedTables.includes(schema.resourceTeamAccess)).toBe(true);
572
+ expect(deletedTables.includes(schema.team)).toBe(true);
573
+ });
574
+ });
575
+
576
+ // ==========================================================================
577
+ // TEAM MEMBERSHIP TESTS
578
+ // ==========================================================================
579
+
580
+ describe("addUserToTeam", () => {
581
+ it("adds user to team with conflict handling", async () => {
582
+ const mockDb = createMockDb();
583
+ let insertedData: Record<string, unknown> | undefined;
584
+
585
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
586
+ values: mock((data: Record<string, unknown>) => {
587
+ insertedData = data;
588
+ return {
589
+ onConflictDoNothing: mock(() => Promise.resolve()),
590
+ };
591
+ }),
592
+ }));
593
+
594
+ const router = createAuthRouter(
595
+ mockDb,
596
+ mockRegistry,
597
+ async () => {},
598
+ mockConfigService,
599
+ mockAccessRuleRegistry
600
+ );
601
+
602
+ const context = createMockRpcContext({ user: mockAdminUser });
603
+ await call(
604
+ router.addUserToTeam,
605
+ { teamId: "team-1", userId: "user-1" },
606
+ { context }
607
+ );
608
+
609
+ expect(mockDb.insert).toHaveBeenCalled();
610
+ expect(insertedData?.teamId).toBe("team-1");
611
+ expect(insertedData?.userId).toBe("user-1");
612
+ });
613
+ });
614
+
615
+ describe("removeUserFromTeam", () => {
616
+ it("removes user from team", async () => {
617
+ const mockDb = createMockDb();
618
+
619
+ const router = createAuthRouter(
620
+ mockDb,
621
+ mockRegistry,
622
+ async () => {},
623
+ mockConfigService,
624
+ mockAccessRuleRegistry
625
+ );
626
+
627
+ const context = createMockRpcContext({ user: mockAdminUser });
628
+ await call(
629
+ router.removeUserFromTeam,
630
+ { teamId: "team-1", userId: "user-1" },
631
+ { context }
632
+ );
633
+
634
+ expect(mockDb.delete).toHaveBeenCalled();
635
+ });
636
+ });
637
+
638
+ // ==========================================================================
639
+ // TEAM MANAGER TESTS
640
+ // ==========================================================================
641
+
642
+ describe("addTeamManager", () => {
643
+ it("grants manager privileges", async () => {
644
+ const mockDb = createMockDb();
645
+ let insertedData: Record<string, unknown> | undefined;
646
+
647
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
648
+ values: mock((data: Record<string, unknown>) => {
649
+ insertedData = data;
650
+ return {
651
+ onConflictDoNothing: mock(() => Promise.resolve()),
652
+ };
653
+ }),
654
+ }));
655
+
656
+ const router = createAuthRouter(
657
+ mockDb,
658
+ mockRegistry,
659
+ async () => {},
660
+ mockConfigService,
661
+ mockAccessRuleRegistry
662
+ );
663
+
664
+ const context = createMockRpcContext({ user: mockAdminUser });
665
+ await call(
666
+ router.addTeamManager,
667
+ { teamId: "team-1", userId: "user-1" },
668
+ { context }
669
+ );
670
+
671
+ expect(mockDb.insert).toHaveBeenCalled();
672
+ expect(insertedData?.teamId).toBe("team-1");
673
+ expect(insertedData?.userId).toBe("user-1");
674
+ });
675
+ });
676
+
677
+ describe("removeTeamManager", () => {
678
+ it("revokes manager privileges", async () => {
679
+ const mockDb = createMockDb();
680
+
681
+ const router = createAuthRouter(
682
+ mockDb,
683
+ mockRegistry,
684
+ async () => {},
685
+ mockConfigService,
686
+ mockAccessRuleRegistry
687
+ );
688
+
689
+ const context = createMockRpcContext({ user: mockAdminUser });
690
+ await call(
691
+ router.removeTeamManager,
692
+ { teamId: "team-1", userId: "user-1" },
693
+ { context }
694
+ );
695
+
696
+ expect(mockDb.delete).toHaveBeenCalled();
697
+ });
698
+ });
699
+
700
+ // ==========================================================================
701
+ // RESOURCE ACCESS GRANT TESTS
702
+ // ==========================================================================
703
+
704
+ describe("getResourceTeamAccess", () => {
705
+ it("returns empty array when no grants exist", async () => {
706
+ const mockDb = createMockDb();
707
+
708
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
709
+ from: mock(() => ({
710
+ innerJoin: mock(() => createChain([])),
711
+ })),
712
+ }));
713
+
714
+ const router = createAuthRouter(
715
+ mockDb,
716
+ mockRegistry,
717
+ async () => {},
718
+ mockConfigService,
719
+ mockAccessRuleRegistry
720
+ );
721
+
722
+ const context = createMockRpcContext({ user: mockAdminUser });
723
+ const result = await call(
724
+ router.getResourceTeamAccess,
725
+ { resourceType: "catalog.system", resourceId: "sys-1" },
726
+ { context }
727
+ );
728
+
729
+ expect(Array.isArray(result)).toBe(true);
730
+ expect(result).toHaveLength(0);
731
+ });
732
+
733
+ it("returns grants with team names", async () => {
734
+ const mockDb = createMockDb();
735
+
736
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
737
+ from: mock(() => ({
738
+ innerJoin: mock(() =>
739
+ createChain([
740
+ {
741
+ resource_team_access: {
742
+ teamId: "team-1",
743
+ canRead: true,
744
+ canManage: true,
745
+ },
746
+ team: { name: "Platform Team" },
747
+ },
748
+ {
749
+ resource_team_access: {
750
+ teamId: "team-2",
751
+ canRead: true,
752
+ canManage: false,
753
+ },
754
+ team: { name: "API Team" },
755
+ },
756
+ ])
757
+ ),
758
+ })),
759
+ }));
760
+
761
+ const router = createAuthRouter(
762
+ mockDb,
763
+ mockRegistry,
764
+ async () => {},
765
+ mockConfigService,
766
+ mockAccessRuleRegistry
767
+ );
768
+
769
+ const context = createMockRpcContext({ user: mockAdminUser });
770
+ const result = await call(
771
+ router.getResourceTeamAccess,
772
+ { resourceType: "catalog.system", resourceId: "sys-1" },
773
+ { context }
774
+ );
775
+
776
+ expect(result).toHaveLength(2);
777
+ expect(result[0]).toEqual({
778
+ teamId: "team-1",
779
+ teamName: "Platform Team",
780
+ canRead: true,
781
+ canManage: true,
782
+ });
783
+ expect(result[1]).toEqual({
784
+ teamId: "team-2",
785
+ teamName: "API Team",
786
+ canRead: true,
787
+ canManage: false,
788
+ });
789
+ });
790
+ });
791
+
792
+ describe("setResourceTeamAccess", () => {
793
+ it("creates new grant with default access", async () => {
794
+ const mockDb = createMockDb();
795
+ let insertedData: Record<string, unknown> | undefined;
796
+
797
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
798
+ values: mock((data: Record<string, unknown>) => {
799
+ insertedData = data;
800
+ return {
801
+ onConflictDoUpdate: mock(() => Promise.resolve()),
802
+ };
803
+ }),
804
+ }));
805
+
806
+ const router = createAuthRouter(
807
+ mockDb,
808
+ mockRegistry,
809
+ async () => {},
810
+ mockConfigService,
811
+ mockAccessRuleRegistry
812
+ );
813
+
814
+ const context = createMockRpcContext({ user: mockAdminUser });
815
+ await call(
816
+ router.setResourceTeamAccess,
817
+ {
818
+ resourceType: "catalog.system",
819
+ resourceId: "sys-1",
820
+ teamId: "team-1",
821
+ },
822
+ { context }
823
+ );
824
+
825
+ expect(mockDb.insert).toHaveBeenCalled();
826
+ expect(insertedData?.resourceType).toBe("catalog.system");
827
+ expect(insertedData?.resourceId).toBe("sys-1");
828
+ expect(insertedData?.teamId).toBe("team-1");
829
+ expect(insertedData?.canRead).toBe(true);
830
+ expect(insertedData?.canManage).toBe(false);
831
+ });
832
+
833
+ it("creates grant with custom access", async () => {
834
+ const mockDb = createMockDb();
835
+ let insertedData: Record<string, unknown> | undefined;
836
+
837
+ (mockDb.insert as ReturnType<typeof mock>).mockImplementationOnce(() => ({
838
+ values: mock((data: Record<string, unknown>) => {
839
+ insertedData = data;
840
+ return {
841
+ onConflictDoUpdate: mock(() => Promise.resolve()),
842
+ };
843
+ }),
844
+ }));
845
+
846
+ const router = createAuthRouter(
847
+ mockDb,
848
+ mockRegistry,
849
+ async () => {},
850
+ mockConfigService,
851
+ mockAccessRuleRegistry
852
+ );
853
+
854
+ const context = createMockRpcContext({ user: mockAdminUser });
855
+ await call(
856
+ router.setResourceTeamAccess,
857
+ {
858
+ resourceType: "catalog.system",
859
+ resourceId: "sys-1",
860
+ teamId: "team-1",
861
+ canRead: true,
862
+ canManage: true,
863
+ },
864
+ { context }
865
+ );
866
+
867
+ expect(insertedData?.canRead).toBe(true);
868
+ expect(insertedData?.canManage).toBe(true);
869
+ });
870
+ });
871
+
872
+ describe("removeResourceTeamAccess", () => {
873
+ it("removes grant for specific team", async () => {
874
+ const mockDb = createMockDb();
875
+
876
+ const router = createAuthRouter(
877
+ mockDb,
878
+ mockRegistry,
879
+ async () => {},
880
+ mockConfigService,
881
+ mockAccessRuleRegistry
882
+ );
883
+
884
+ const context = createMockRpcContext({ user: mockAdminUser });
885
+ await call(
886
+ router.removeResourceTeamAccess,
887
+ {
888
+ resourceType: "catalog.system",
889
+ resourceId: "sys-1",
890
+ teamId: "team-1",
891
+ },
892
+ { context }
893
+ );
894
+
895
+ expect(mockDb.delete).toHaveBeenCalled();
896
+ });
897
+ });
898
+
899
+ // ==========================================================================
900
+ // S2S ACCESS CHECK TESTS
901
+ // ==========================================================================
902
+
903
+ describe("checkResourceTeamAccess (S2S)", () => {
904
+ it("allows access when no grants exist and user has global access", async () => {
905
+ const mockDb = createMockDb();
906
+
907
+ // No grants exist
908
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
909
+ from: mock(() => createChain([])),
910
+ }));
911
+
912
+ const router = createAuthRouter(
913
+ mockDb,
914
+ mockRegistry,
915
+ async () => {},
916
+ mockConfigService,
917
+ mockAccessRuleRegistry
918
+ );
919
+
920
+ const context = createMockRpcContext({ user: mockServiceUser });
921
+ const result = await call(
922
+ router.checkResourceTeamAccess,
923
+ {
924
+ userId: "user-1",
925
+ userType: "user",
926
+ resourceType: "catalog.system",
927
+ resourceId: "sys-1",
928
+ action: "read",
929
+ hasGlobalAccess: true,
930
+ },
931
+ { context }
932
+ );
933
+
934
+ expect(result.hasAccess).toBe(true);
935
+ });
936
+
937
+ it("denies access when no grants exist and user lacks global access", async () => {
938
+ const mockDb = createMockDb();
939
+
940
+ // No grants exist
941
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
942
+ from: mock(() => createChain([])),
943
+ }));
944
+
945
+ const router = createAuthRouter(
946
+ mockDb,
947
+ mockRegistry,
948
+ async () => {},
949
+ mockConfigService,
950
+ mockAccessRuleRegistry
951
+ );
952
+
953
+ const context = createMockRpcContext({ user: mockServiceUser });
954
+ const result = await call(
955
+ router.checkResourceTeamAccess,
956
+ {
957
+ userId: "user-1",
958
+ userType: "user",
959
+ resourceType: "catalog.system",
960
+ resourceId: "sys-1",
961
+ action: "read",
962
+ hasGlobalAccess: false,
963
+ },
964
+ { context }
965
+ );
966
+
967
+ expect(result.hasAccess).toBe(false);
968
+ });
969
+
970
+ it("allows access when user's team has grant with canRead", async () => {
971
+ const mockDb = createMockDb();
972
+
973
+ // Grant exists for team-1
974
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
975
+ from: mock(() =>
976
+ createChain([
977
+ {
978
+ teamId: "team-1",
979
+ canRead: true,
980
+ canManage: false,
981
+ },
982
+ ])
983
+ ),
984
+ }));
985
+
986
+ // Settings query - returns empty (teamOnly = false by default)
987
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
988
+ from: mock(() => createChain([])),
989
+ }));
990
+
991
+ // User is member of team-1
992
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
993
+ from: mock(() => createChain([{ teamId: "team-1" }])),
994
+ }));
995
+
996
+ const router = createAuthRouter(
997
+ mockDb,
998
+ mockRegistry,
999
+ async () => {},
1000
+ mockConfigService,
1001
+ mockAccessRuleRegistry
1002
+ );
1003
+
1004
+ const context = createMockRpcContext({ user: mockServiceUser });
1005
+ const result = await call(
1006
+ router.checkResourceTeamAccess,
1007
+ {
1008
+ userId: "user-1",
1009
+ userType: "user",
1010
+ resourceType: "catalog.system",
1011
+ resourceId: "sys-1",
1012
+ action: "read",
1013
+ hasGlobalAccess: false,
1014
+ },
1015
+ { context }
1016
+ );
1017
+
1018
+ expect(result.hasAccess).toBe(true);
1019
+ });
1020
+
1021
+ it("denies access when user's team has grant but lacks canManage for manage action", async () => {
1022
+ const mockDb = createMockDb();
1023
+
1024
+ // Grant exists for team-1 with only read access
1025
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1026
+ from: mock(() =>
1027
+ createChain([
1028
+ {
1029
+ teamId: "team-1",
1030
+ canRead: true,
1031
+ canManage: false,
1032
+ },
1033
+ ])
1034
+ ),
1035
+ }));
1036
+
1037
+ // Settings query - returns empty (teamOnly = false by default)
1038
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1039
+ from: mock(() => createChain([])),
1040
+ }));
1041
+
1042
+ // User is member of team-1
1043
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1044
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1045
+ }));
1046
+
1047
+ const router = createAuthRouter(
1048
+ mockDb,
1049
+ mockRegistry,
1050
+ async () => {},
1051
+ mockConfigService,
1052
+ mockAccessRuleRegistry
1053
+ );
1054
+
1055
+ const context = createMockRpcContext({ user: mockServiceUser });
1056
+ const result = await call(
1057
+ router.checkResourceTeamAccess,
1058
+ {
1059
+ userId: "user-1",
1060
+ userType: "user",
1061
+ resourceType: "catalog.system",
1062
+ resourceId: "sys-1",
1063
+ action: "manage",
1064
+ hasGlobalAccess: false,
1065
+ },
1066
+ { context }
1067
+ );
1068
+
1069
+ expect(result.hasAccess).toBe(false);
1070
+ });
1071
+
1072
+ it("allows access for teamOnly resource when user is in granted team", async () => {
1073
+ const mockDb = createMockDb();
1074
+
1075
+ // Grant exists for team-1
1076
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1077
+ from: mock(() =>
1078
+ createChain([
1079
+ {
1080
+ teamId: "team-1",
1081
+ canRead: true,
1082
+ canManage: false,
1083
+ },
1084
+ ])
1085
+ ),
1086
+ }));
1087
+
1088
+ // Settings query - returns teamOnly = true
1089
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1090
+ from: mock(() =>
1091
+ createChain([{ teamOnly: true, resourceId: "sys-1" }])
1092
+ ),
1093
+ }));
1094
+
1095
+ // User is member of team-1
1096
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1097
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1098
+ }));
1099
+
1100
+ const router = createAuthRouter(
1101
+ mockDb,
1102
+ mockRegistry,
1103
+ async () => {},
1104
+ mockConfigService,
1105
+ mockAccessRuleRegistry
1106
+ );
1107
+
1108
+ const context = createMockRpcContext({ user: mockServiceUser });
1109
+ const result = await call(
1110
+ router.checkResourceTeamAccess,
1111
+ {
1112
+ userId: "user-1",
1113
+ userType: "user",
1114
+ resourceType: "catalog.system",
1115
+ resourceId: "sys-1",
1116
+ action: "read",
1117
+ hasGlobalAccess: true, // Global access doesn't help with teamOnly
1118
+ },
1119
+ { context }
1120
+ );
1121
+
1122
+ expect(result.hasAccess).toBe(true);
1123
+ });
1124
+
1125
+ it("denies access for teamOnly resource when user is not in granted team", async () => {
1126
+ const mockDb = createMockDb();
1127
+
1128
+ // Grant exists for team-1
1129
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1130
+ from: mock(() =>
1131
+ createChain([
1132
+ {
1133
+ teamId: "team-1",
1134
+ canRead: true,
1135
+ canManage: false,
1136
+ },
1137
+ ])
1138
+ ),
1139
+ }));
1140
+
1141
+ // Settings query - returns teamOnly = true
1142
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1143
+ from: mock(() =>
1144
+ createChain([{ teamOnly: true, resourceId: "sys-1" }])
1145
+ ),
1146
+ }));
1147
+
1148
+ // User is member of team-2 (not team-1)
1149
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1150
+ from: mock(() => createChain([{ teamId: "team-2" }])),
1151
+ }));
1152
+
1153
+ const router = createAuthRouter(
1154
+ mockDb,
1155
+ mockRegistry,
1156
+ async () => {},
1157
+ mockConfigService,
1158
+ mockAccessRuleRegistry
1159
+ );
1160
+
1161
+ const context = createMockRpcContext({ user: mockServiceUser });
1162
+ const result = await call(
1163
+ router.checkResourceTeamAccess,
1164
+ {
1165
+ userId: "user-1",
1166
+ userType: "user",
1167
+ resourceType: "catalog.system",
1168
+ resourceId: "sys-1",
1169
+ action: "read",
1170
+ hasGlobalAccess: true, // Global access doesn't help with teamOnly
1171
+ },
1172
+ { context }
1173
+ );
1174
+
1175
+ expect(result.hasAccess).toBe(false);
1176
+ });
1177
+
1178
+ it("allows manage access when user's team has canManage grant", async () => {
1179
+ const mockDb = createMockDb();
1180
+
1181
+ // Grant exists for team-1 with canManage
1182
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1183
+ from: mock(() =>
1184
+ createChain([
1185
+ {
1186
+ teamId: "team-1",
1187
+ canRead: true,
1188
+ canManage: true,
1189
+ },
1190
+ ])
1191
+ ),
1192
+ }));
1193
+
1194
+ // Settings query - returns empty (teamOnly = false by default)
1195
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1196
+ from: mock(() => createChain([])),
1197
+ }));
1198
+
1199
+ // User is member of team-1
1200
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1201
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1202
+ }));
1203
+
1204
+ const router = createAuthRouter(
1205
+ mockDb,
1206
+ mockRegistry,
1207
+ async () => {},
1208
+ mockConfigService,
1209
+ mockAccessRuleRegistry
1210
+ );
1211
+
1212
+ const context = createMockRpcContext({ user: mockServiceUser });
1213
+ const result = await call(
1214
+ router.checkResourceTeamAccess,
1215
+ {
1216
+ userId: "user-1",
1217
+ userType: "user",
1218
+ resourceType: "catalog.system",
1219
+ resourceId: "sys-1",
1220
+ action: "manage",
1221
+ hasGlobalAccess: false,
1222
+ },
1223
+ { context }
1224
+ );
1225
+
1226
+ expect(result.hasAccess).toBe(true);
1227
+ });
1228
+
1229
+ it("allows access via global access when grants exist but resource is not teamOnly", async () => {
1230
+ const mockDb = createMockDb();
1231
+
1232
+ // Grant exists for team-1 but user is not in team-1
1233
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1234
+ from: mock(() =>
1235
+ createChain([
1236
+ {
1237
+ teamId: "team-1",
1238
+ canRead: true,
1239
+ canManage: false,
1240
+ },
1241
+ ])
1242
+ ),
1243
+ }));
1244
+
1245
+ // Settings query - returns empty (teamOnly = false by default)
1246
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1247
+ from: mock(() => createChain([])),
1248
+ }));
1249
+
1250
+ const router = createAuthRouter(
1251
+ mockDb,
1252
+ mockRegistry,
1253
+ async () => {},
1254
+ mockConfigService,
1255
+ mockAccessRuleRegistry
1256
+ );
1257
+
1258
+ const context = createMockRpcContext({ user: mockServiceUser });
1259
+ const result = await call(
1260
+ router.checkResourceTeamAccess,
1261
+ {
1262
+ userId: "user-1",
1263
+ userType: "user",
1264
+ resourceType: "catalog.system",
1265
+ resourceId: "sys-1",
1266
+ action: "read",
1267
+ hasGlobalAccess: true, // User has global access
1268
+ },
1269
+ { context }
1270
+ );
1271
+
1272
+ expect(result.hasAccess).toBe(true);
1273
+ });
1274
+
1275
+ it("denies access when user is not in any team and lacks global access", async () => {
1276
+ const mockDb = createMockDb();
1277
+
1278
+ // Grant exists for team-1
1279
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1280
+ from: mock(() =>
1281
+ createChain([
1282
+ {
1283
+ teamId: "team-1",
1284
+ canRead: true,
1285
+ canManage: false,
1286
+ },
1287
+ ])
1288
+ ),
1289
+ }));
1290
+
1291
+ // Settings query - returns empty (teamOnly = false by default)
1292
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1293
+ from: mock(() => createChain([])),
1294
+ }));
1295
+
1296
+ // User is not in any team
1297
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1298
+ from: mock(() => createChain([])),
1299
+ }));
1300
+
1301
+ const router = createAuthRouter(
1302
+ mockDb,
1303
+ mockRegistry,
1304
+ async () => {},
1305
+ mockConfigService,
1306
+ mockAccessRuleRegistry
1307
+ );
1308
+
1309
+ const context = createMockRpcContext({ user: mockServiceUser });
1310
+ const result = await call(
1311
+ router.checkResourceTeamAccess,
1312
+ {
1313
+ userId: "user-1",
1314
+ userType: "user",
1315
+ resourceType: "catalog.system",
1316
+ resourceId: "sys-1",
1317
+ action: "read",
1318
+ hasGlobalAccess: false,
1319
+ },
1320
+ { context }
1321
+ );
1322
+
1323
+ expect(result.hasAccess).toBe(false);
1324
+ });
1325
+
1326
+ it("allows access when user is in multiple teams and one has the required grant", async () => {
1327
+ const mockDb = createMockDb();
1328
+
1329
+ // Grant exists for team-2 only
1330
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1331
+ from: mock(() =>
1332
+ createChain([
1333
+ {
1334
+ teamId: "team-2",
1335
+ canRead: true,
1336
+ canManage: false,
1337
+ },
1338
+ ])
1339
+ ),
1340
+ }));
1341
+
1342
+ // Settings query - returns empty (teamOnly = false by default)
1343
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1344
+ from: mock(() => createChain([])),
1345
+ }));
1346
+
1347
+ // User is member of team-1 AND team-2
1348
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1349
+ from: mock(() =>
1350
+ createChain([{ teamId: "team-1" }, { teamId: "team-2" }])
1351
+ ),
1352
+ }));
1353
+
1354
+ const router = createAuthRouter(
1355
+ mockDb,
1356
+ mockRegistry,
1357
+ async () => {},
1358
+ mockConfigService,
1359
+ mockAccessRuleRegistry
1360
+ );
1361
+
1362
+ const context = createMockRpcContext({ user: mockServiceUser });
1363
+ const result = await call(
1364
+ router.checkResourceTeamAccess,
1365
+ {
1366
+ userId: "user-1",
1367
+ userType: "user",
1368
+ resourceType: "catalog.system",
1369
+ resourceId: "sys-1",
1370
+ action: "read",
1371
+ hasGlobalAccess: false,
1372
+ },
1373
+ { context }
1374
+ );
1375
+
1376
+ expect(result.hasAccess).toBe(true);
1377
+ });
1378
+
1379
+ it("allows access when resource has grants from multiple teams and user is in one of them", async () => {
1380
+ const mockDb = createMockDb();
1381
+
1382
+ // Grants exist for team-1 and team-2
1383
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1384
+ from: mock(() =>
1385
+ createChain([
1386
+ {
1387
+ teamId: "team-1",
1388
+ canRead: true,
1389
+ canManage: false,
1390
+ },
1391
+ {
1392
+ teamId: "team-2",
1393
+ canRead: true,
1394
+ canManage: true,
1395
+ },
1396
+ ])
1397
+ ),
1398
+ }));
1399
+
1400
+ // Settings query - teamOnly = true
1401
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1402
+ from: mock(() =>
1403
+ createChain([{ teamOnly: true, resourceId: "sys-1" }])
1404
+ ),
1405
+ }));
1406
+
1407
+ // User is member of team-2 only
1408
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1409
+ from: mock(() => createChain([{ teamId: "team-2" }])),
1410
+ }));
1411
+
1412
+ const router = createAuthRouter(
1413
+ mockDb,
1414
+ mockRegistry,
1415
+ async () => {},
1416
+ mockConfigService,
1417
+ mockAccessRuleRegistry
1418
+ );
1419
+
1420
+ const context = createMockRpcContext({ user: mockServiceUser });
1421
+ const result = await call(
1422
+ router.checkResourceTeamAccess,
1423
+ {
1424
+ userId: "user-1",
1425
+ userType: "user",
1426
+ resourceType: "catalog.system",
1427
+ resourceId: "sys-1",
1428
+ action: "manage",
1429
+ hasGlobalAccess: false,
1430
+ },
1431
+ { context }
1432
+ );
1433
+
1434
+ expect(result.hasAccess).toBe(true);
1435
+ });
1436
+
1437
+ it("allows access for application user with proper team grant", async () => {
1438
+ const mockDb = createMockDb();
1439
+
1440
+ // Grant exists for team-1
1441
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1442
+ from: mock(() =>
1443
+ createChain([
1444
+ {
1445
+ teamId: "team-1",
1446
+ canRead: true,
1447
+ canManage: false,
1448
+ },
1449
+ ])
1450
+ ),
1451
+ }));
1452
+
1453
+ // Settings query - returns empty (teamOnly = false by default)
1454
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1455
+ from: mock(() => createChain([])),
1456
+ }));
1457
+
1458
+ // Application is member of team-1
1459
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1460
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1461
+ }));
1462
+
1463
+ const router = createAuthRouter(
1464
+ mockDb,
1465
+ mockRegistry,
1466
+ async () => {},
1467
+ mockConfigService,
1468
+ mockAccessRuleRegistry
1469
+ );
1470
+
1471
+ const context = createMockRpcContext({ user: mockServiceUser });
1472
+ const result = await call(
1473
+ router.checkResourceTeamAccess,
1474
+ {
1475
+ userId: "app-1",
1476
+ userType: "application",
1477
+ resourceType: "catalog.system",
1478
+ resourceId: "sys-1",
1479
+ action: "read",
1480
+ hasGlobalAccess: false,
1481
+ },
1482
+ { context }
1483
+ );
1484
+
1485
+ expect(result.hasAccess).toBe(true);
1486
+ });
1487
+
1488
+ it("denies access for application user when not in granted team", async () => {
1489
+ const mockDb = createMockDb();
1490
+
1491
+ // Grant exists for team-1
1492
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1493
+ from: mock(() =>
1494
+ createChain([
1495
+ {
1496
+ teamId: "team-1",
1497
+ canRead: true,
1498
+ canManage: false,
1499
+ },
1500
+ ])
1501
+ ),
1502
+ }));
1503
+
1504
+ // Settings query - teamOnly = true
1505
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1506
+ from: mock(() =>
1507
+ createChain([{ teamOnly: true, resourceId: "sys-1" }])
1508
+ ),
1509
+ }));
1510
+
1511
+ // Application is member of team-2 (not team-1)
1512
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1513
+ from: mock(() => createChain([{ teamId: "team-2" }])),
1514
+ }));
1515
+
1516
+ const router = createAuthRouter(
1517
+ mockDb,
1518
+ mockRegistry,
1519
+ async () => {},
1520
+ mockConfigService,
1521
+ mockAccessRuleRegistry
1522
+ );
1523
+
1524
+ const context = createMockRpcContext({ user: mockServiceUser });
1525
+ const result = await call(
1526
+ router.checkResourceTeamAccess,
1527
+ {
1528
+ userId: "app-1",
1529
+ userType: "application",
1530
+ resourceType: "catalog.system",
1531
+ resourceId: "sys-1",
1532
+ action: "read",
1533
+ hasGlobalAccess: true,
1534
+ },
1535
+ { context }
1536
+ );
1537
+
1538
+ expect(result.hasAccess).toBe(false);
1539
+ });
1540
+
1541
+ it("denies read access when user is in team but grant only has canManage (no canRead)", async () => {
1542
+ const mockDb = createMockDb();
1543
+
1544
+ // Grant exists for team-1 with only canManage (canRead = false)
1545
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1546
+ from: mock(() =>
1547
+ createChain([
1548
+ {
1549
+ teamId: "team-1",
1550
+ canRead: false,
1551
+ canManage: true,
1552
+ },
1553
+ ])
1554
+ ),
1555
+ }));
1556
+
1557
+ // Settings query - teamOnly = true
1558
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1559
+ from: mock(() =>
1560
+ createChain([{ teamOnly: true, resourceId: "sys-1" }])
1561
+ ),
1562
+ }));
1563
+
1564
+ // User is member of team-1
1565
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1566
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1567
+ }));
1568
+
1569
+ const router = createAuthRouter(
1570
+ mockDb,
1571
+ mockRegistry,
1572
+ async () => {},
1573
+ mockConfigService,
1574
+ mockAccessRuleRegistry
1575
+ );
1576
+
1577
+ const context = createMockRpcContext({ user: mockServiceUser });
1578
+ const result = await call(
1579
+ router.checkResourceTeamAccess,
1580
+ {
1581
+ userId: "user-1",
1582
+ userType: "user",
1583
+ resourceType: "catalog.system",
1584
+ resourceId: "sys-1",
1585
+ action: "read",
1586
+ hasGlobalAccess: false,
1587
+ },
1588
+ { context }
1589
+ );
1590
+
1591
+ expect(result.hasAccess).toBe(false);
1592
+ });
1593
+ });
1594
+
1595
+ describe("getAccessibleResourceIds (S2S)", () => {
1596
+ it("returns empty array for empty input", async () => {
1597
+ const mockDb = createMockDb();
1598
+
1599
+ const router = createAuthRouter(
1600
+ mockDb,
1601
+ mockRegistry,
1602
+ async () => {},
1603
+ mockConfigService,
1604
+ mockAccessRuleRegistry
1605
+ );
1606
+
1607
+ const context = createMockRpcContext({ user: mockServiceUser });
1608
+ const result = await call(
1609
+ router.getAccessibleResourceIds,
1610
+ {
1611
+ userId: "user-1",
1612
+ userType: "user",
1613
+ resourceType: "catalog.system",
1614
+ resourceIds: [],
1615
+ action: "read",
1616
+ hasGlobalAccess: true,
1617
+ },
1618
+ { context }
1619
+ );
1620
+
1621
+ expect(result).toEqual([]);
1622
+ });
1623
+
1624
+ it("returns all resources when no grants exist and user has global access", async () => {
1625
+ const mockDb = createMockDb();
1626
+
1627
+ // No grants exist
1628
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1629
+ from: mock(() => createChain([])),
1630
+ }));
1631
+
1632
+ // User teams (not used when no grants)
1633
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1634
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1635
+ }));
1636
+
1637
+ const router = createAuthRouter(
1638
+ mockDb,
1639
+ mockRegistry,
1640
+ async () => {},
1641
+ mockConfigService,
1642
+ mockAccessRuleRegistry
1643
+ );
1644
+
1645
+ const context = createMockRpcContext({ user: mockServiceUser });
1646
+ const result = await call(
1647
+ router.getAccessibleResourceIds,
1648
+ {
1649
+ userId: "user-1",
1650
+ userType: "user",
1651
+ resourceType: "catalog.system",
1652
+ resourceIds: ["sys-1", "sys-2", "sys-3"],
1653
+ action: "read",
1654
+ hasGlobalAccess: true,
1655
+ },
1656
+ { context }
1657
+ );
1658
+
1659
+ expect(result).toEqual(["sys-1", "sys-2", "sys-3"]);
1660
+ });
1661
+
1662
+ it("filters resources based on team grants", async () => {
1663
+ const mockDb = createMockDb();
1664
+
1665
+ // Grants exist for sys-1 (team-1) and sys-2 (team-2)
1666
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1667
+ from: mock(() =>
1668
+ createChain([
1669
+ {
1670
+ resourceId: "sys-1",
1671
+ teamId: "team-1",
1672
+ canRead: true,
1673
+ canManage: false,
1674
+ },
1675
+ {
1676
+ resourceId: "sys-2",
1677
+ teamId: "team-2",
1678
+ canRead: true,
1679
+ canManage: false,
1680
+ },
1681
+ ])
1682
+ ),
1683
+ }));
1684
+
1685
+ // Settings query - both sys-1 and sys-2 are teamOnly
1686
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1687
+ from: mock(() =>
1688
+ createChain([
1689
+ { resourceId: "sys-1", teamOnly: true },
1690
+ { resourceId: "sys-2", teamOnly: true },
1691
+ ])
1692
+ ),
1693
+ }));
1694
+
1695
+ // User is member of team-1 only
1696
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1697
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1698
+ }));
1699
+
1700
+ const router = createAuthRouter(
1701
+ mockDb,
1702
+ mockRegistry,
1703
+ async () => {},
1704
+ mockConfigService,
1705
+ mockAccessRuleRegistry
1706
+ );
1707
+
1708
+ const context = createMockRpcContext({ user: mockServiceUser });
1709
+ const result = await call(
1710
+ router.getAccessibleResourceIds,
1711
+ {
1712
+ userId: "user-1",
1713
+ userType: "user",
1714
+ resourceType: "catalog.system",
1715
+ resourceIds: ["sys-1", "sys-2", "sys-3"],
1716
+ action: "read",
1717
+ hasGlobalAccess: true,
1718
+ },
1719
+ { context }
1720
+ );
1721
+
1722
+ // sys-1: user is in team-1, granted
1723
+ // sys-2: user is not in team-2, denied (teamOnly)
1724
+ // sys-3: no grants, allowed by global access
1725
+ expect(result).toContain("sys-1");
1726
+ expect(result).not.toContain("sys-2");
1727
+ expect(result).toContain("sys-3");
1728
+ });
1729
+
1730
+ it("returns no resources when user lacks global access and has no grants", async () => {
1731
+ const mockDb = createMockDb();
1732
+
1733
+ // No grants exist
1734
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1735
+ from: mock(() => createChain([])),
1736
+ }));
1737
+
1738
+ // Settings query - empty
1739
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1740
+ from: mock(() => createChain([])),
1741
+ }));
1742
+
1743
+ // User teams - empty
1744
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1745
+ from: mock(() => createChain([])),
1746
+ }));
1747
+
1748
+ const router = createAuthRouter(
1749
+ mockDb,
1750
+ mockRegistry,
1751
+ async () => {},
1752
+ mockConfigService,
1753
+ mockAccessRuleRegistry
1754
+ );
1755
+
1756
+ const context = createMockRpcContext({ user: mockServiceUser });
1757
+ const result = await call(
1758
+ router.getAccessibleResourceIds,
1759
+ {
1760
+ userId: "user-1",
1761
+ userType: "user",
1762
+ resourceType: "catalog.system",
1763
+ resourceIds: ["sys-1", "sys-2", "sys-3"],
1764
+ action: "read",
1765
+ hasGlobalAccess: false,
1766
+ },
1767
+ { context }
1768
+ );
1769
+
1770
+ expect(result).toEqual([]);
1771
+ });
1772
+
1773
+ it("filters manage action based on canManage grants", async () => {
1774
+ const mockDb = createMockDb();
1775
+
1776
+ // Grants exist - sys-1 has canManage, sys-2 only has canRead
1777
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1778
+ from: mock(() =>
1779
+ createChain([
1780
+ {
1781
+ resourceId: "sys-1",
1782
+ teamId: "team-1",
1783
+ canRead: true,
1784
+ canManage: true,
1785
+ },
1786
+ {
1787
+ resourceId: "sys-2",
1788
+ teamId: "team-1",
1789
+ canRead: true,
1790
+ canManage: false,
1791
+ },
1792
+ ])
1793
+ ),
1794
+ }));
1795
+
1796
+ // Settings query - both are teamOnly
1797
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1798
+ from: mock(() =>
1799
+ createChain([
1800
+ { resourceId: "sys-1", teamOnly: true },
1801
+ { resourceId: "sys-2", teamOnly: true },
1802
+ ])
1803
+ ),
1804
+ }));
1805
+
1806
+ // User is member of team-1
1807
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1808
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1809
+ }));
1810
+
1811
+ const router = createAuthRouter(
1812
+ mockDb,
1813
+ mockRegistry,
1814
+ async () => {},
1815
+ mockConfigService,
1816
+ mockAccessRuleRegistry
1817
+ );
1818
+
1819
+ const context = createMockRpcContext({ user: mockServiceUser });
1820
+ const result = await call(
1821
+ router.getAccessibleResourceIds,
1822
+ {
1823
+ userId: "user-1",
1824
+ userType: "user",
1825
+ resourceType: "catalog.system",
1826
+ resourceIds: ["sys-1", "sys-2"],
1827
+ action: "manage",
1828
+ hasGlobalAccess: false,
1829
+ },
1830
+ { context }
1831
+ );
1832
+
1833
+ // sys-1: has canManage, granted
1834
+ // sys-2: only canRead, denied for manage action
1835
+ expect(result).toContain("sys-1");
1836
+ expect(result).not.toContain("sys-2");
1837
+ });
1838
+
1839
+ it("filters resources for application user based on applicationTeam", async () => {
1840
+ const mockDb = createMockDb();
1841
+
1842
+ // Grants exist for sys-1 (team-1)
1843
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1844
+ from: mock(() =>
1845
+ createChain([
1846
+ {
1847
+ resourceId: "sys-1",
1848
+ teamId: "team-1",
1849
+ canRead: true,
1850
+ canManage: false,
1851
+ },
1852
+ ])
1853
+ ),
1854
+ }));
1855
+
1856
+ // Settings query - sys-1 is teamOnly
1857
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1858
+ from: mock(() =>
1859
+ createChain([{ resourceId: "sys-1", teamOnly: true }])
1860
+ ),
1861
+ }));
1862
+
1863
+ // Application is member of team-1
1864
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1865
+ from: mock(() => createChain([{ teamId: "team-1" }])),
1866
+ }));
1867
+
1868
+ const router = createAuthRouter(
1869
+ mockDb,
1870
+ mockRegistry,
1871
+ async () => {},
1872
+ mockConfigService,
1873
+ mockAccessRuleRegistry
1874
+ );
1875
+
1876
+ const context = createMockRpcContext({ user: mockServiceUser });
1877
+ const result = await call(
1878
+ router.getAccessibleResourceIds,
1879
+ {
1880
+ userId: "app-1",
1881
+ userType: "application",
1882
+ resourceType: "catalog.system",
1883
+ resourceIds: ["sys-1", "sys-2"],
1884
+ action: "read",
1885
+ hasGlobalAccess: true,
1886
+ },
1887
+ { context }
1888
+ );
1889
+
1890
+ // sys-1: application is in team-1, granted
1891
+ // sys-2: no grants, allowed by global access
1892
+ expect(result).toContain("sys-1");
1893
+ expect(result).toContain("sys-2");
1894
+ });
1895
+
1896
+ it("handles mixed teamOnly and non-teamOnly resources correctly", async () => {
1897
+ const mockDb = createMockDb();
1898
+
1899
+ // Grants exist for sys-1 (teamOnly) and sys-2 (not teamOnly)
1900
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1901
+ from: mock(() =>
1902
+ createChain([
1903
+ {
1904
+ resourceId: "sys-1",
1905
+ teamId: "team-1",
1906
+ canRead: true,
1907
+ canManage: false,
1908
+ },
1909
+ {
1910
+ resourceId: "sys-2",
1911
+ teamId: "team-1",
1912
+ canRead: true,
1913
+ canManage: false,
1914
+ },
1915
+ ])
1916
+ ),
1917
+ }));
1918
+
1919
+ // Settings query - only sys-1 is teamOnly
1920
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1921
+ from: mock(() =>
1922
+ createChain([{ resourceId: "sys-1", teamOnly: true }])
1923
+ ),
1924
+ }));
1925
+
1926
+ // User is member of team-2 (NOT team-1)
1927
+ (mockDb.select as ReturnType<typeof mock>).mockImplementationOnce(() => ({
1928
+ from: mock(() => createChain([{ teamId: "team-2" }])),
1929
+ }));
1930
+
1931
+ const router = createAuthRouter(
1932
+ mockDb,
1933
+ mockRegistry,
1934
+ async () => {},
1935
+ mockConfigService,
1936
+ mockAccessRuleRegistry
1937
+ );
1938
+
1939
+ const context = createMockRpcContext({ user: mockServiceUser });
1940
+ const result = await call(
1941
+ router.getAccessibleResourceIds,
1942
+ {
1943
+ userId: "user-1",
1944
+ userType: "user",
1945
+ resourceType: "catalog.system",
1946
+ resourceIds: ["sys-1", "sys-2"],
1947
+ action: "read",
1948
+ hasGlobalAccess: true,
1949
+ },
1950
+ { context }
1951
+ );
1952
+
1953
+ // sys-1: teamOnly=true, user not in team-1, denied
1954
+ // sys-2: teamOnly=false, user has global access, granted
1955
+ expect(result).not.toContain("sys-1");
1956
+ expect(result).toContain("sys-2");
1957
+ });
1958
+ });
1959
+
1960
+ describe("deleteResourceGrants (S2S)", () => {
1961
+ it("deletes all grants for a resource", async () => {
1962
+ const mockDb = createMockDb();
1963
+
1964
+ const router = createAuthRouter(
1965
+ mockDb,
1966
+ mockRegistry,
1967
+ async () => {},
1968
+ mockConfigService,
1969
+ mockAccessRuleRegistry
1970
+ );
1971
+
1972
+ const context = createMockRpcContext({ user: mockServiceUser });
1973
+ await call(
1974
+ router.deleteResourceGrants,
1975
+ {
1976
+ resourceType: "catalog.system",
1977
+ resourceId: "sys-1",
1978
+ },
1979
+ { context }
1980
+ );
1981
+
1982
+ expect(mockDb.delete).toHaveBeenCalled();
1983
+ });
1984
+ });
1985
+ });