@checkstack/auth-backend 0.2.2 → 0.4.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.
@@ -1,5 +1,11 @@
1
1
  import { describe, it, expect, mock } from "bun:test";
2
- import { createAuthRouter } from "./router";
2
+ import {
3
+ createAuthRouter,
4
+ ADMIN_ROLE_ID,
5
+ USERS_ROLE_ID,
6
+ ANONYMOUS_ROLE_ID,
7
+ APPLICATIONS_ROLE_ID,
8
+ } from "./router";
3
9
  import { createMockRpcContext } from "@checkstack/backend-api";
4
10
  import { call } from "@orpc/server";
5
11
  import { z } from "zod";
@@ -82,7 +88,7 @@ describe("Auth Router", () => {
82
88
  mockRegistry,
83
89
  async () => {},
84
90
  mockConfigService,
85
- mockAccessRuleRegistry
91
+ mockAccessRuleRegistry,
86
92
  );
87
93
 
88
94
  it("getAccessRules returns current user access rules", async () => {
@@ -96,7 +102,7 @@ describe("Auth Router", () => {
96
102
 
97
103
  mockDb.select.mockImplementationOnce(() => ({
98
104
  from: mock(() =>
99
- createChain([{ id: "1", email: "user1@test.com", name: "User 1" }])
105
+ createChain([{ id: "1", email: "user1@test.com", name: "User 1" }]),
100
106
  ),
101
107
  }));
102
108
  mockDb.select.mockImplementationOnce(() => ({
@@ -108,21 +114,32 @@ describe("Auth Router", () => {
108
114
  expect(result[0].roles).toContain("admin");
109
115
  });
110
116
 
111
- it("deleteUser prevents deleting initial admin", async () => {
117
+ it("deleteUser prevents deleting users with admin role", async () => {
112
118
  const context = createMockRpcContext({ user: mockUser });
113
- expect(
114
- call(router.deleteUser, "initial-admin-id", { context })
115
- ).rejects.toThrow("Cannot delete initial admin");
119
+
120
+ // Mock user has admin role
121
+ mockDb.select.mockImplementationOnce(() => ({
122
+ from: mock(() => createChain([{ roleId: ADMIN_ROLE_ID }])),
123
+ }));
124
+
125
+ await expect(
126
+ call(router.deleteUser, "admin-user-id", { context }),
127
+ ).rejects.toThrow("admin role");
116
128
  });
117
129
 
118
130
  it("deleteUser cascades to delete related records", async () => {
119
131
  const context = createMockRpcContext({ user: mockUser });
120
132
  const userId = "user-to-delete";
121
133
 
134
+ // Mock user has no admin role
135
+ mockDb.select.mockImplementationOnce(() => ({
136
+ from: mock(() => createChain([{ roleId: USERS_ROLE_ID }])),
137
+ }));
138
+
122
139
  // Track which tables had delete called on them
123
- const deletedTables: any[] = [];
124
- const mockTx: any = {
125
- delete: mock((table: any) => {
140
+ const deletedTables: unknown[] = [];
141
+ const mockTx: unknown = {
142
+ delete: mock((table: unknown) => {
126
143
  deletedTables.push(table); // Track table
127
144
  return {
128
145
  where: mock(() => Promise.resolve()),
@@ -130,7 +147,9 @@ describe("Auth Router", () => {
130
147
  }),
131
148
  };
132
149
 
133
- mockDb.transaction.mockImplementationOnce((cb: any) => cb(mockTx));
150
+ mockDb.transaction.mockImplementationOnce((cb: (tx: unknown) => unknown) =>
151
+ cb(mockTx),
152
+ );
134
153
 
135
154
  await call(router.deleteUser, userId, { context });
136
155
 
@@ -152,7 +171,7 @@ describe("Auth Router", () => {
152
171
  }));
153
172
  mockDb.select.mockImplementationOnce(() => ({
154
173
  from: mock(() =>
155
- createChain([{ roleId: "admin", accessRuleId: "users.manage" }])
174
+ createChain([{ roleId: "admin", accessRuleId: "users.manage" }]),
156
175
  ),
157
176
  }));
158
177
 
@@ -168,7 +187,7 @@ describe("Auth Router", () => {
168
187
  const result = await call(
169
188
  router.updateUserRoles,
170
189
  { userId: "other-user", roles: ["admin"] },
171
- { context }
190
+ { context },
172
191
  );
173
192
  // updateUserRoles returns void, so just check it completed
174
193
  expect(result).toBeUndefined();
@@ -182,8 +201,8 @@ describe("Auth Router", () => {
182
201
  call(
183
202
  router.updateUserRoles,
184
203
  { userId: "test-user", roles: ["admin"] },
185
- { context }
186
- )
204
+ { context },
205
+ ),
187
206
  ).rejects.toThrow("Cannot update your own roles");
188
207
  });
189
208
 
@@ -200,7 +219,7 @@ describe("Auth Router", () => {
200
219
  const result = await call(
201
220
  router.updateStrategy,
202
221
  { id: "credential", enabled: false },
203
- { context }
222
+ { context },
204
223
  );
205
224
  expect(result.success).toBe(true);
206
225
  expect(mockConfigService.set).toHaveBeenCalled();
@@ -219,14 +238,14 @@ describe("Auth Router", () => {
219
238
  const result = await call(
220
239
  router.setRegistrationStatus,
221
240
  { allowRegistration: false },
222
- { context }
241
+ { context },
223
242
  );
224
243
  expect(result.success).toBe(true);
225
244
  expect(mockConfigService.set).toHaveBeenCalledWith(
226
245
  "platform.registration",
227
246
  expect.anything(),
228
247
  1,
229
- { allowRegistration: false }
248
+ { allowRegistration: false },
230
249
  );
231
250
  });
232
251
 
@@ -249,7 +268,7 @@ describe("Auth Router", () => {
249
268
  const result = await call(
250
269
  router.findUserByEmail,
251
270
  { email: "test@example.com" },
252
- { context }
271
+ { context },
253
272
  );
254
273
  expect(result).toEqual({ id: "user-123" });
255
274
  });
@@ -264,7 +283,7 @@ describe("Auth Router", () => {
264
283
  const result = await call(
265
284
  router.findUserByEmail,
266
285
  { email: "nonexistent@example.com" },
267
- { context }
286
+ { context },
268
287
  );
269
288
  expect(result).toBeUndefined();
270
289
  });
@@ -289,7 +308,7 @@ describe("Auth Router", () => {
289
308
  accountId: "ldapuser",
290
309
  password: "hashed-password",
291
310
  },
292
- { context }
311
+ { context },
293
312
  );
294
313
 
295
314
  expect(result.created).toBe(true);
@@ -322,7 +341,7 @@ describe("Auth Router", () => {
322
341
  password: "hashed-password",
323
342
  autoUpdateUser: true,
324
343
  },
325
- { context }
344
+ { context },
326
345
  );
327
346
 
328
347
  expect(result.created).toBe(false);
@@ -342,13 +361,278 @@ describe("Auth Router", () => {
342
361
  token: "session-token",
343
362
  expiresAt,
344
363
  },
345
- { context }
364
+ { context },
346
365
  );
347
366
 
348
367
  expect(result.sessionId).toBeDefined();
349
368
  expect(mockDb.insert).toHaveBeenCalled();
350
369
  });
351
370
 
371
+ it("upsertExternalUser syncs roles when syncRoles provided for new user", async () => {
372
+ const context = createMockRpcContext({ user: mockServiceUser });
373
+
374
+ // Mock user not found (empty result)
375
+ mockDb.select.mockImplementationOnce(() => ({
376
+ from: mock(() => createChain([])),
377
+ }));
378
+
379
+ // Mock registration allowed
380
+ mockConfigService.get.mockResolvedValueOnce({ allowRegistration: true });
381
+
382
+ // Mock valid roles lookup
383
+ mockDb.select.mockImplementationOnce(() => ({
384
+ from: mock(() =>
385
+ createChain([{ id: USERS_ROLE_ID }, { id: "custom-role" }]),
386
+ ),
387
+ }));
388
+
389
+ // Mock current roles lookup (empty for new user)
390
+ mockDb.select.mockImplementationOnce(() => ({
391
+ from: mock(() => createChain([])),
392
+ }));
393
+
394
+ const result = await call(
395
+ router.upsertExternalUser,
396
+ {
397
+ email: "saml-user@example.com",
398
+ name: "SAML User",
399
+ providerId: "saml",
400
+ accountId: "samluser",
401
+ password: "hashed-password",
402
+ syncRoles: [USERS_ROLE_ID, "custom-role", "invalid-role"],
403
+ },
404
+ { context },
405
+ );
406
+
407
+ expect(result.created).toBe(true);
408
+ expect(mockDb.insert).toHaveBeenCalled();
409
+ });
410
+
411
+ it("upsertExternalUser additively syncs roles for existing user", async () => {
412
+ const context = createMockRpcContext({ user: mockServiceUser });
413
+
414
+ // Mock existing user found
415
+ mockDb.select.mockImplementationOnce(() => ({
416
+ from: mock(() => createChain([{ id: "existing-user-id" }])),
417
+ }));
418
+
419
+ // Mock update chain
420
+ mockDb.update = mock(() => ({
421
+ set: mock(() => ({
422
+ where: mock(() => Promise.resolve()),
423
+ })),
424
+ }));
425
+
426
+ // Mock valid roles lookup
427
+ mockDb.select.mockImplementationOnce(() => ({
428
+ from: mock(() => createChain([{ id: "new-role" }])),
429
+ }));
430
+
431
+ // Mock current roles lookup (user already has existing role)
432
+ mockDb.select.mockImplementationOnce(() => ({
433
+ from: mock(() => createChain([{ roleId: USERS_ROLE_ID }])),
434
+ }));
435
+
436
+ const result = await call(
437
+ router.upsertExternalUser,
438
+ {
439
+ email: "existing@example.com",
440
+ name: "Existing User",
441
+ providerId: "ldap",
442
+ accountId: "existinguser",
443
+ password: "hashed-password",
444
+ autoUpdateUser: true,
445
+ syncRoles: ["new-role"],
446
+ },
447
+ { context },
448
+ );
449
+
450
+ expect(result.created).toBe(false);
451
+ expect(result.userId).toBe("existing-user-id");
452
+ // New role should be added (insert called)
453
+ expect(mockDb.insert).toHaveBeenCalled();
454
+ });
455
+
456
+ it("upsertExternalUser ignores invalid role IDs silently", async () => {
457
+ const context = createMockRpcContext({ user: mockServiceUser });
458
+
459
+ // Mock user not found (empty result)
460
+ mockDb.select.mockImplementationOnce(() => ({
461
+ from: mock(() => createChain([])),
462
+ }));
463
+
464
+ // Mock registration allowed
465
+ mockConfigService.get.mockResolvedValueOnce({ allowRegistration: true });
466
+
467
+ // Mock valid roles lookup - none of the provided roles are valid
468
+ mockDb.select.mockImplementationOnce(() => ({
469
+ from: mock(() => createChain([])), // No valid roles
470
+ }));
471
+
472
+ const result = await call(
473
+ router.upsertExternalUser,
474
+ {
475
+ email: "user@example.com",
476
+ name: "User",
477
+ providerId: "saml",
478
+ accountId: "user123",
479
+ password: "hashed-password",
480
+ syncRoles: ["invalid-role-1", "invalid-role-2"],
481
+ },
482
+ { context },
483
+ );
484
+
485
+ // Should still succeed even with all invalid roles
486
+ expect(result.created).toBe(true);
487
+ expect(result.userId).toBeDefined();
488
+ });
489
+
490
+ it("upsertExternalUser does not sync roles when syncRoles not provided", async () => {
491
+ const context = createMockRpcContext({ user: mockServiceUser });
492
+
493
+ // Mock user not found (empty result)
494
+ mockDb.select.mockImplementationOnce(() => ({
495
+ from: mock(() => createChain([])),
496
+ }));
497
+
498
+ // Mock registration allowed
499
+ mockConfigService.get.mockResolvedValueOnce({ allowRegistration: true });
500
+
501
+ // Reset insert mock to track calls
502
+ const insertMock = mock(() => ({
503
+ values: mock(() => createChain()),
504
+ }));
505
+ mockDb.insert = insertMock;
506
+
507
+ const result = await call(
508
+ router.upsertExternalUser,
509
+ {
510
+ email: "user@example.com",
511
+ name: "User",
512
+ providerId: "saml",
513
+ accountId: "user123",
514
+ password: "hashed-password",
515
+ // No syncRoles provided
516
+ },
517
+ { context },
518
+ );
519
+
520
+ expect(result.created).toBe(true);
521
+ // Transaction should be called for user creation, but no role sync
522
+ expect(mockDb.transaction).toHaveBeenCalled();
523
+ });
524
+
525
+ it("upsertExternalUser removes managed roles when user leaves directory groups", async () => {
526
+ const context = createMockRpcContext({ user: mockServiceUser });
527
+
528
+ // Mock existing user found
529
+ mockDb.select.mockImplementationOnce(() => ({
530
+ from: mock(() => createChain([{ id: "existing-user-id" }])),
531
+ }));
532
+
533
+ // Mock update chain (for autoUpdateUser)
534
+ mockDb.update = mock(() => ({
535
+ set: mock(() => ({
536
+ where: mock(() => Promise.resolve()),
537
+ })),
538
+ }));
539
+
540
+ // Note: When syncRoles is empty [], we skip the valid roles query
541
+ // So next select is the current roles lookup
542
+
543
+ // Mock current roles lookup - user has managed role that should be removed
544
+ mockDb.select.mockImplementationOnce(() => ({
545
+ from: mock(() =>
546
+ createChain([
547
+ { roleId: "managed-role-1" }, // Should be removed - managed but not in syncRoles
548
+ { roleId: "manual-role" }, // Should be preserved - not in managedRoleIds
549
+ ]),
550
+ ),
551
+ }));
552
+
553
+ // Mock delete
554
+ mockDb.delete = mock(() => ({
555
+ where: mock(() => Promise.resolve()),
556
+ }));
557
+
558
+ const result = await call(
559
+ router.upsertExternalUser,
560
+ {
561
+ email: "user@example.com",
562
+ name: "User",
563
+ providerId: "ldap",
564
+ accountId: "user123",
565
+ password: "hashed-password",
566
+ autoUpdateUser: true,
567
+ syncRoles: [], // User no longer has any groups
568
+ managedRoleIds: ["managed-role-1", "managed-role-2"], // Roles controlled by directory
569
+ },
570
+ { context },
571
+ );
572
+
573
+ expect(result.created).toBe(false);
574
+ // Delete should be called to remove the managed role
575
+ expect(mockDb.delete).toHaveBeenCalled();
576
+ });
577
+
578
+ it("upsertExternalUser preserves unmanaged roles during sync", async () => {
579
+ const context = createMockRpcContext({ user: mockServiceUser });
580
+
581
+ // Mock existing user found
582
+ mockDb.select.mockImplementationOnce(() => ({
583
+ from: mock(() => createChain([{ id: "existing-user-id" }])),
584
+ }));
585
+
586
+ // Mock update chain
587
+ mockDb.update = mock(() => ({
588
+ set: mock(() => ({
589
+ where: mock(() => Promise.resolve()),
590
+ })),
591
+ }));
592
+
593
+ // Mock valid sync roles lookup
594
+ mockDb.select.mockImplementationOnce(() => ({
595
+ from: mock(() => createChain([{ id: "new-managed-role" }])),
596
+ }));
597
+
598
+ // Mock current roles - user has both managed and unmanaged roles
599
+ mockDb.select.mockImplementationOnce(() => ({
600
+ from: mock(() =>
601
+ createChain([
602
+ { roleId: "old-managed-role" }, // Should be removed - managed but not in syncRoles
603
+ { roleId: "admin-role" }, // Should be preserved - manually assigned, not managed
604
+ ]),
605
+ ),
606
+ }));
607
+
608
+ // Mock delete
609
+ const deleteMock = mock(() => ({
610
+ where: mock(() => Promise.resolve()),
611
+ }));
612
+ mockDb.delete = deleteMock;
613
+
614
+ const result = await call(
615
+ router.upsertExternalUser,
616
+ {
617
+ email: "user@example.com",
618
+ name: "User",
619
+ providerId: "ldap",
620
+ accountId: "user123",
621
+ password: "hashed-password",
622
+ autoUpdateUser: true,
623
+ syncRoles: ["new-managed-role"],
624
+ managedRoleIds: ["old-managed-role", "new-managed-role"], // Only these are managed
625
+ },
626
+ { context },
627
+ );
628
+
629
+ expect(result.created).toBe(false);
630
+ // Insert should be called to add new role
631
+ expect(mockDb.insert).toHaveBeenCalled();
632
+ // Delete should be called to remove old-managed-role (but NOT admin-role)
633
+ expect(deleteMock).toHaveBeenCalled();
634
+ });
635
+
352
636
  // ==========================================================================
353
637
  // ADMIN USER CREATION TESTS
354
638
  // ==========================================================================
@@ -371,7 +655,7 @@ describe("Auth Router", () => {
371
655
  name: "New User",
372
656
  password: "ValidPass123",
373
657
  },
374
- { context }
658
+ { context },
375
659
  );
376
660
 
377
661
  expect(result.userId).toBeDefined();
@@ -390,8 +674,8 @@ describe("Auth Router", () => {
390
674
  name: "Test User",
391
675
  password: "weakpass1",
392
676
  },
393
- { context }
394
- )
677
+ { context },
678
+ ),
395
679
  ).rejects.toThrow("uppercase");
396
680
  });
397
681
 
@@ -414,8 +698,8 @@ describe("Auth Router", () => {
414
698
  name: "Existing User",
415
699
  password: "ValidPass123",
416
700
  },
417
- { context }
418
- )
701
+ { context },
702
+ ),
419
703
  ).rejects.toThrow("already exists");
420
704
  });
421
705
 
@@ -433,8 +717,374 @@ describe("Auth Router", () => {
433
717
  name: "Test User",
434
718
  password: "ValidPass123",
435
719
  },
436
- { context }
437
- )
720
+ { context },
721
+ ),
438
722
  ).rejects.toThrow("not enabled");
439
723
  });
724
+
725
+ // ==========================================================================
726
+ // ONBOARDING ENDPOINT TESTS
727
+ // ==========================================================================
728
+
729
+ it("getOnboardingStatus returns needsOnboarding true when no users exist", async () => {
730
+ const context = createMockRpcContext({ user: undefined });
731
+
732
+ mockDb.select.mockImplementationOnce(() => ({
733
+ from: mock(() => createChain([])),
734
+ }));
735
+
736
+ const result = await call(router.getOnboardingStatus, undefined, {
737
+ context,
738
+ });
739
+ expect(result.needsOnboarding).toBe(true);
740
+ });
741
+
742
+ it("getOnboardingStatus returns needsOnboarding false when users exist", async () => {
743
+ const context = createMockRpcContext({ user: undefined });
744
+
745
+ mockDb.select.mockImplementationOnce(() => ({
746
+ from: mock(() => createChain([{ id: "existing-user" }])),
747
+ }));
748
+
749
+ const result = await call(router.getOnboardingStatus, undefined, {
750
+ context,
751
+ });
752
+ expect(result.needsOnboarding).toBe(false);
753
+ });
754
+
755
+ it("completeOnboarding creates first admin user", async () => {
756
+ const context = createMockRpcContext({ user: undefined });
757
+
758
+ // No existing users
759
+ mockDb.select.mockImplementationOnce(() => ({
760
+ from: mock(() => createChain([])),
761
+ }));
762
+
763
+ const result = await call(
764
+ router.completeOnboarding,
765
+ {
766
+ name: "Admin User",
767
+ email: "admin@example.com",
768
+ password: "ValidPass123",
769
+ },
770
+ { context },
771
+ );
772
+
773
+ expect(result.success).toBe(true);
774
+ expect(mockDb.transaction).toHaveBeenCalled();
775
+ });
776
+
777
+ it("completeOnboarding rejects when users already exist", async () => {
778
+ const context = createMockRpcContext({ user: undefined });
779
+
780
+ // Existing user
781
+ mockDb.select.mockImplementationOnce(() => ({
782
+ from: mock(() => createChain([{ id: "existing-user" }])),
783
+ }));
784
+
785
+ expect(
786
+ call(
787
+ router.completeOnboarding,
788
+ {
789
+ name: "Admin User",
790
+ email: "admin@example.com",
791
+ password: "ValidPass123",
792
+ },
793
+ { context },
794
+ ),
795
+ ).rejects.toThrow("already been completed");
796
+ });
797
+
798
+ it("completeOnboarding rejects weak password", async () => {
799
+ const context = createMockRpcContext({ user: undefined });
800
+
801
+ // No existing users
802
+ mockDb.select.mockImplementationOnce(() => ({
803
+ from: mock(() => createChain([])),
804
+ }));
805
+
806
+ expect(
807
+ call(
808
+ router.completeOnboarding,
809
+ {
810
+ name: "Admin User",
811
+ email: "admin@example.com",
812
+ password: "weak",
813
+ },
814
+ { context },
815
+ ),
816
+ ).rejects.toThrow();
817
+ });
818
+
819
+ // ==========================================================================
820
+ // USER PROFILE ENDPOINT TESTS
821
+ // ==========================================================================
822
+
823
+ it("getCurrentUserProfile returns profile with credential account flag", async () => {
824
+ const context = createMockRpcContext({ user: mockUser });
825
+
826
+ // Mock user data
827
+ mockDb.select.mockImplementationOnce(() => ({
828
+ from: mock(() =>
829
+ createChain([
830
+ { id: "test-user", name: "Test", email: "test@test.com" },
831
+ ]),
832
+ ),
833
+ }));
834
+ // Mock credential account check
835
+ mockDb.select.mockImplementationOnce(() => ({
836
+ from: mock(() => createChain([{ providerId: "credential" }])),
837
+ }));
838
+
839
+ const result = await call(router.getCurrentUserProfile, undefined, {
840
+ context,
841
+ });
842
+ expect(result.id).toBe("test-user");
843
+ expect(result.hasCredentialAccount).toBe(true);
844
+ });
845
+
846
+ it("getCurrentUserProfile returns false for OAuth-only users", async () => {
847
+ const context = createMockRpcContext({ user: mockUser });
848
+
849
+ mockDb.select.mockImplementationOnce(() => ({
850
+ from: mock(() =>
851
+ createChain([
852
+ { id: "test-user", name: "Test", email: "test@test.com" },
853
+ ]),
854
+ ),
855
+ }));
856
+ mockDb.select.mockImplementationOnce(() => ({
857
+ from: mock(() => createChain([])), // No credential account
858
+ }));
859
+
860
+ const result = await call(router.getCurrentUserProfile, undefined, {
861
+ context,
862
+ });
863
+ expect(result.hasCredentialAccount).toBe(false);
864
+ });
865
+
866
+ it("updateCurrentUser updates name for any user", async () => {
867
+ const context = createMockRpcContext({ user: mockUser });
868
+
869
+ mockDb.update = mock(() => ({
870
+ set: mock(() => ({
871
+ where: mock(() => Promise.resolve()),
872
+ })),
873
+ }));
874
+
875
+ await call(router.updateCurrentUser, { name: "New Name" }, { context });
876
+
877
+ expect(mockDb.update).toHaveBeenCalled();
878
+ });
879
+
880
+ it("updateCurrentUser allows email update for credential users", async () => {
881
+ const context = createMockRpcContext({ user: mockUser });
882
+
883
+ // Has credential account
884
+ mockDb.select.mockImplementationOnce(() => ({
885
+ from: mock(() => createChain([{ providerId: "credential" }])),
886
+ }));
887
+ // No duplicate email
888
+ mockDb.select.mockImplementationOnce(() => ({
889
+ from: mock(() => createChain([])),
890
+ }));
891
+
892
+ mockDb.update = mock(() => ({
893
+ set: mock(() => ({
894
+ where: mock(() => Promise.resolve()),
895
+ })),
896
+ }));
897
+
898
+ await call(
899
+ router.updateCurrentUser,
900
+ { email: "new@example.com" },
901
+ { context },
902
+ );
903
+
904
+ expect(mockDb.update).toHaveBeenCalled();
905
+ });
906
+
907
+ it("updateCurrentUser rejects email update for OAuth users", async () => {
908
+ const context = createMockRpcContext({ user: mockUser });
909
+
910
+ // No credential account
911
+ mockDb.select.mockImplementationOnce(() => ({
912
+ from: mock(() => createChain([])),
913
+ }));
914
+
915
+ expect(
916
+ call(router.updateCurrentUser, { email: "new@example.com" }, { context }),
917
+ ).rejects.toThrow("credential-based accounts");
918
+ });
919
+
920
+ // ==========================================================================
921
+ // ROLE CRUD ENDPOINT TESTS
922
+ // ==========================================================================
923
+
924
+ it("createRole creates role with access rules", async () => {
925
+ const context = createMockRpcContext({ user: mockUser });
926
+
927
+ const result = await call(
928
+ router.createRole,
929
+ {
930
+ name: "Custom Role",
931
+ description: "A custom role",
932
+ accessRules: ["auth-backend.users.read"],
933
+ },
934
+ { context },
935
+ );
936
+
937
+ expect(mockDb.transaction).toHaveBeenCalled();
938
+ });
939
+
940
+ it("deleteRole prevents deleting system roles", async () => {
941
+ // Use a user without admin role to properly test system role protection
942
+ const nonAdminUser = {
943
+ type: "user" as const,
944
+ id: "non-admin-user",
945
+ accessRules: ["*"],
946
+ roles: ["users"],
947
+ } as ReturnType<typeof createMockRpcContext>["user"];
948
+ const context = createMockRpcContext({ user: nonAdminUser });
949
+
950
+ mockDb.select.mockImplementationOnce(() => ({
951
+ from: mock(() => createChain([{ id: ADMIN_ROLE_ID, isSystem: true }])),
952
+ }));
953
+
954
+ expect(call(router.deleteRole, ADMIN_ROLE_ID, { context })).rejects.toThrow(
955
+ "system role",
956
+ );
957
+ });
958
+
959
+ it("deleteRole prevents deleting own roles", async () => {
960
+ const userWithRole = {
961
+ ...mockUser,
962
+ roles: ["custom-role"],
963
+ };
964
+ const context = createMockRpcContext({ user: userWithRole });
965
+
966
+ expect(call(router.deleteRole, "custom-role", { context })).rejects.toThrow(
967
+ "currently have",
968
+ );
969
+ });
970
+
971
+ it("getAccessRules returns registry access rules", async () => {
972
+ const context = createMockRpcContext({ user: mockUser });
973
+
974
+ const result = await call(router.getAccessRules, undefined, { context });
975
+ expect(
976
+ result.some((r: { id: string }) => r.id === "auth-backend.users.read"),
977
+ ).toBe(true);
978
+ });
979
+
980
+ it("updateUserRoles prevents assigning anonymous role", async () => {
981
+ const context = createMockRpcContext({ user: mockUser });
982
+
983
+ expect(
984
+ call(
985
+ router.updateUserRoles,
986
+ { userId: "other-user", roles: [ANONYMOUS_ROLE_ID] },
987
+ { context },
988
+ ),
989
+ ).rejects.toThrow("anonymous");
990
+ });
991
+
992
+ // ==========================================================================
993
+ // APPLICATION MANAGEMENT ENDPOINT TESTS
994
+ // ==========================================================================
995
+
996
+ it("getApplications returns applications with roles", async () => {
997
+ const context = createMockRpcContext({ user: mockUser });
998
+
999
+ mockDb.select.mockImplementationOnce(() => ({
1000
+ from: mock(() =>
1001
+ createChain([
1002
+ {
1003
+ id: "app-1",
1004
+ name: "Test App",
1005
+ description: "Test description",
1006
+ createdById: "user-1",
1007
+ createdAt: new Date(),
1008
+ lastUsedAt: null,
1009
+ },
1010
+ ]),
1011
+ ),
1012
+ }));
1013
+ mockDb.select.mockImplementationOnce(() => ({
1014
+ from: mock(() =>
1015
+ createChain([{ applicationId: "app-1", roleId: APPLICATIONS_ROLE_ID }]),
1016
+ ),
1017
+ }));
1018
+
1019
+ const result = await call(router.getApplications, undefined, { context });
1020
+ expect(result).toHaveLength(1);
1021
+ expect(result[0].roles).toContain(APPLICATIONS_ROLE_ID);
1022
+ });
1023
+
1024
+ it("createApplication creates application with secret", async () => {
1025
+ const context = createMockRpcContext({ user: mockUser });
1026
+
1027
+ const result = await call(
1028
+ router.createApplication,
1029
+ { name: "New App", description: "Test application" },
1030
+ { context },
1031
+ );
1032
+
1033
+ expect(result.application.name).toBe("New App");
1034
+ expect(result.secret).toMatch(/^ck_/); // Secret has proper prefix
1035
+ expect(mockDb.transaction).toHaveBeenCalled();
1036
+ });
1037
+
1038
+ it("deleteApplication handles not found", async () => {
1039
+ const context = createMockRpcContext({ user: mockUser });
1040
+
1041
+ mockDb.select.mockImplementationOnce(() => ({
1042
+ from: mock(() => createChain([])), // No application found
1043
+ }));
1044
+
1045
+ await expect(
1046
+ call(router.deleteApplication, "non-existent-id", { context }),
1047
+ ).rejects.toThrow("not found");
1048
+ });
1049
+
1050
+ it("regenerateApplicationSecret returns new secret", async () => {
1051
+ const context = createMockRpcContext({ user: mockUser });
1052
+
1053
+ mockDb.select.mockImplementationOnce(() => ({
1054
+ from: mock(() => createChain([{ id: "app-1" }])),
1055
+ }));
1056
+
1057
+ mockDb.update = mock(() => ({
1058
+ set: mock(() => ({
1059
+ where: mock(() => Promise.resolve()),
1060
+ })),
1061
+ }));
1062
+
1063
+ const result = await call(router.regenerateApplicationSecret, "app-1", {
1064
+ context,
1065
+ });
1066
+
1067
+ expect(result.secret).toMatch(/^ck_app-1_/);
1068
+ expect(mockDb.update).toHaveBeenCalled();
1069
+ });
1070
+
1071
+ // ==========================================================================
1072
+ // PUBLIC ENDPOINT TESTS
1073
+ // ==========================================================================
1074
+
1075
+ it("getEnabledStrategies returns only enabled strategies", async () => {
1076
+ const context = createMockRpcContext({ user: undefined });
1077
+
1078
+ // Mock credential enabled (default)
1079
+ mockConfigService.get.mockResolvedValueOnce(undefined); // Uses default
1080
+
1081
+ const result = await call(router.getEnabledStrategies, undefined, {
1082
+ context,
1083
+ });
1084
+
1085
+ // Should contain the credential strategy
1086
+ expect(result.some((s: { id: string }) => s.id === "credential")).toBe(
1087
+ true,
1088
+ );
1089
+ });
440
1090
  });