@checkstack/auth-backend 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,7 +361,7 @@ 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();
@@ -371,7 +390,7 @@ describe("Auth Router", () => {
371
390
  name: "New User",
372
391
  password: "ValidPass123",
373
392
  },
374
- { context }
393
+ { context },
375
394
  );
376
395
 
377
396
  expect(result.userId).toBeDefined();
@@ -390,8 +409,8 @@ describe("Auth Router", () => {
390
409
  name: "Test User",
391
410
  password: "weakpass1",
392
411
  },
393
- { context }
394
- )
412
+ { context },
413
+ ),
395
414
  ).rejects.toThrow("uppercase");
396
415
  });
397
416
 
@@ -414,8 +433,8 @@ describe("Auth Router", () => {
414
433
  name: "Existing User",
415
434
  password: "ValidPass123",
416
435
  },
417
- { context }
418
- )
436
+ { context },
437
+ ),
419
438
  ).rejects.toThrow("already exists");
420
439
  });
421
440
 
@@ -433,8 +452,374 @@ describe("Auth Router", () => {
433
452
  name: "Test User",
434
453
  password: "ValidPass123",
435
454
  },
436
- { context }
437
- )
455
+ { context },
456
+ ),
438
457
  ).rejects.toThrow("not enabled");
439
458
  });
459
+
460
+ // ==========================================================================
461
+ // ONBOARDING ENDPOINT TESTS
462
+ // ==========================================================================
463
+
464
+ it("getOnboardingStatus returns needsOnboarding true when no users exist", async () => {
465
+ const context = createMockRpcContext({ user: undefined });
466
+
467
+ mockDb.select.mockImplementationOnce(() => ({
468
+ from: mock(() => createChain([])),
469
+ }));
470
+
471
+ const result = await call(router.getOnboardingStatus, undefined, {
472
+ context,
473
+ });
474
+ expect(result.needsOnboarding).toBe(true);
475
+ });
476
+
477
+ it("getOnboardingStatus returns needsOnboarding false when users exist", async () => {
478
+ const context = createMockRpcContext({ user: undefined });
479
+
480
+ mockDb.select.mockImplementationOnce(() => ({
481
+ from: mock(() => createChain([{ id: "existing-user" }])),
482
+ }));
483
+
484
+ const result = await call(router.getOnboardingStatus, undefined, {
485
+ context,
486
+ });
487
+ expect(result.needsOnboarding).toBe(false);
488
+ });
489
+
490
+ it("completeOnboarding creates first admin user", async () => {
491
+ const context = createMockRpcContext({ user: undefined });
492
+
493
+ // No existing users
494
+ mockDb.select.mockImplementationOnce(() => ({
495
+ from: mock(() => createChain([])),
496
+ }));
497
+
498
+ const result = await call(
499
+ router.completeOnboarding,
500
+ {
501
+ name: "Admin User",
502
+ email: "admin@example.com",
503
+ password: "ValidPass123",
504
+ },
505
+ { context },
506
+ );
507
+
508
+ expect(result.success).toBe(true);
509
+ expect(mockDb.transaction).toHaveBeenCalled();
510
+ });
511
+
512
+ it("completeOnboarding rejects when users already exist", async () => {
513
+ const context = createMockRpcContext({ user: undefined });
514
+
515
+ // Existing user
516
+ mockDb.select.mockImplementationOnce(() => ({
517
+ from: mock(() => createChain([{ id: "existing-user" }])),
518
+ }));
519
+
520
+ expect(
521
+ call(
522
+ router.completeOnboarding,
523
+ {
524
+ name: "Admin User",
525
+ email: "admin@example.com",
526
+ password: "ValidPass123",
527
+ },
528
+ { context },
529
+ ),
530
+ ).rejects.toThrow("already been completed");
531
+ });
532
+
533
+ it("completeOnboarding rejects weak password", async () => {
534
+ const context = createMockRpcContext({ user: undefined });
535
+
536
+ // No existing users
537
+ mockDb.select.mockImplementationOnce(() => ({
538
+ from: mock(() => createChain([])),
539
+ }));
540
+
541
+ expect(
542
+ call(
543
+ router.completeOnboarding,
544
+ {
545
+ name: "Admin User",
546
+ email: "admin@example.com",
547
+ password: "weak",
548
+ },
549
+ { context },
550
+ ),
551
+ ).rejects.toThrow();
552
+ });
553
+
554
+ // ==========================================================================
555
+ // USER PROFILE ENDPOINT TESTS
556
+ // ==========================================================================
557
+
558
+ it("getCurrentUserProfile returns profile with credential account flag", async () => {
559
+ const context = createMockRpcContext({ user: mockUser });
560
+
561
+ // Mock user data
562
+ mockDb.select.mockImplementationOnce(() => ({
563
+ from: mock(() =>
564
+ createChain([
565
+ { id: "test-user", name: "Test", email: "test@test.com" },
566
+ ]),
567
+ ),
568
+ }));
569
+ // Mock credential account check
570
+ mockDb.select.mockImplementationOnce(() => ({
571
+ from: mock(() => createChain([{ providerId: "credential" }])),
572
+ }));
573
+
574
+ const result = await call(router.getCurrentUserProfile, undefined, {
575
+ context,
576
+ });
577
+ expect(result.id).toBe("test-user");
578
+ expect(result.hasCredentialAccount).toBe(true);
579
+ });
580
+
581
+ it("getCurrentUserProfile returns false for OAuth-only users", async () => {
582
+ const context = createMockRpcContext({ user: mockUser });
583
+
584
+ mockDb.select.mockImplementationOnce(() => ({
585
+ from: mock(() =>
586
+ createChain([
587
+ { id: "test-user", name: "Test", email: "test@test.com" },
588
+ ]),
589
+ ),
590
+ }));
591
+ mockDb.select.mockImplementationOnce(() => ({
592
+ from: mock(() => createChain([])), // No credential account
593
+ }));
594
+
595
+ const result = await call(router.getCurrentUserProfile, undefined, {
596
+ context,
597
+ });
598
+ expect(result.hasCredentialAccount).toBe(false);
599
+ });
600
+
601
+ it("updateCurrentUser updates name for any user", async () => {
602
+ const context = createMockRpcContext({ user: mockUser });
603
+
604
+ mockDb.update = mock(() => ({
605
+ set: mock(() => ({
606
+ where: mock(() => Promise.resolve()),
607
+ })),
608
+ }));
609
+
610
+ await call(router.updateCurrentUser, { name: "New Name" }, { context });
611
+
612
+ expect(mockDb.update).toHaveBeenCalled();
613
+ });
614
+
615
+ it("updateCurrentUser allows email update for credential users", async () => {
616
+ const context = createMockRpcContext({ user: mockUser });
617
+
618
+ // Has credential account
619
+ mockDb.select.mockImplementationOnce(() => ({
620
+ from: mock(() => createChain([{ providerId: "credential" }])),
621
+ }));
622
+ // No duplicate email
623
+ mockDb.select.mockImplementationOnce(() => ({
624
+ from: mock(() => createChain([])),
625
+ }));
626
+
627
+ mockDb.update = mock(() => ({
628
+ set: mock(() => ({
629
+ where: mock(() => Promise.resolve()),
630
+ })),
631
+ }));
632
+
633
+ await call(
634
+ router.updateCurrentUser,
635
+ { email: "new@example.com" },
636
+ { context },
637
+ );
638
+
639
+ expect(mockDb.update).toHaveBeenCalled();
640
+ });
641
+
642
+ it("updateCurrentUser rejects email update for OAuth users", async () => {
643
+ const context = createMockRpcContext({ user: mockUser });
644
+
645
+ // No credential account
646
+ mockDb.select.mockImplementationOnce(() => ({
647
+ from: mock(() => createChain([])),
648
+ }));
649
+
650
+ expect(
651
+ call(router.updateCurrentUser, { email: "new@example.com" }, { context }),
652
+ ).rejects.toThrow("credential-based accounts");
653
+ });
654
+
655
+ // ==========================================================================
656
+ // ROLE CRUD ENDPOINT TESTS
657
+ // ==========================================================================
658
+
659
+ it("createRole creates role with access rules", async () => {
660
+ const context = createMockRpcContext({ user: mockUser });
661
+
662
+ const result = await call(
663
+ router.createRole,
664
+ {
665
+ name: "Custom Role",
666
+ description: "A custom role",
667
+ accessRules: ["auth-backend.users.read"],
668
+ },
669
+ { context },
670
+ );
671
+
672
+ expect(mockDb.transaction).toHaveBeenCalled();
673
+ });
674
+
675
+ it("deleteRole prevents deleting system roles", async () => {
676
+ // Use a user without admin role to properly test system role protection
677
+ const nonAdminUser = {
678
+ type: "user" as const,
679
+ id: "non-admin-user",
680
+ accessRules: ["*"],
681
+ roles: ["users"],
682
+ } as ReturnType<typeof createMockRpcContext>["user"];
683
+ const context = createMockRpcContext({ user: nonAdminUser });
684
+
685
+ mockDb.select.mockImplementationOnce(() => ({
686
+ from: mock(() => createChain([{ id: ADMIN_ROLE_ID, isSystem: true }])),
687
+ }));
688
+
689
+ expect(call(router.deleteRole, ADMIN_ROLE_ID, { context })).rejects.toThrow(
690
+ "system role",
691
+ );
692
+ });
693
+
694
+ it("deleteRole prevents deleting own roles", async () => {
695
+ const userWithRole = {
696
+ ...mockUser,
697
+ roles: ["custom-role"],
698
+ };
699
+ const context = createMockRpcContext({ user: userWithRole });
700
+
701
+ expect(call(router.deleteRole, "custom-role", { context })).rejects.toThrow(
702
+ "currently have",
703
+ );
704
+ });
705
+
706
+ it("getAccessRules returns registry access rules", async () => {
707
+ const context = createMockRpcContext({ user: mockUser });
708
+
709
+ const result = await call(router.getAccessRules, undefined, { context });
710
+ expect(
711
+ result.some((r: { id: string }) => r.id === "auth-backend.users.read"),
712
+ ).toBe(true);
713
+ });
714
+
715
+ it("updateUserRoles prevents assigning anonymous role", async () => {
716
+ const context = createMockRpcContext({ user: mockUser });
717
+
718
+ expect(
719
+ call(
720
+ router.updateUserRoles,
721
+ { userId: "other-user", roles: [ANONYMOUS_ROLE_ID] },
722
+ { context },
723
+ ),
724
+ ).rejects.toThrow("anonymous");
725
+ });
726
+
727
+ // ==========================================================================
728
+ // APPLICATION MANAGEMENT ENDPOINT TESTS
729
+ // ==========================================================================
730
+
731
+ it("getApplications returns applications with roles", async () => {
732
+ const context = createMockRpcContext({ user: mockUser });
733
+
734
+ mockDb.select.mockImplementationOnce(() => ({
735
+ from: mock(() =>
736
+ createChain([
737
+ {
738
+ id: "app-1",
739
+ name: "Test App",
740
+ description: "Test description",
741
+ createdById: "user-1",
742
+ createdAt: new Date(),
743
+ lastUsedAt: null,
744
+ },
745
+ ]),
746
+ ),
747
+ }));
748
+ mockDb.select.mockImplementationOnce(() => ({
749
+ from: mock(() =>
750
+ createChain([{ applicationId: "app-1", roleId: APPLICATIONS_ROLE_ID }]),
751
+ ),
752
+ }));
753
+
754
+ const result = await call(router.getApplications, undefined, { context });
755
+ expect(result).toHaveLength(1);
756
+ expect(result[0].roles).toContain(APPLICATIONS_ROLE_ID);
757
+ });
758
+
759
+ it("createApplication creates application with secret", async () => {
760
+ const context = createMockRpcContext({ user: mockUser });
761
+
762
+ const result = await call(
763
+ router.createApplication,
764
+ { name: "New App", description: "Test application" },
765
+ { context },
766
+ );
767
+
768
+ expect(result.application.name).toBe("New App");
769
+ expect(result.secret).toMatch(/^ck_/); // Secret has proper prefix
770
+ expect(mockDb.transaction).toHaveBeenCalled();
771
+ });
772
+
773
+ it("deleteApplication handles not found", async () => {
774
+ const context = createMockRpcContext({ user: mockUser });
775
+
776
+ mockDb.select.mockImplementationOnce(() => ({
777
+ from: mock(() => createChain([])), // No application found
778
+ }));
779
+
780
+ await expect(
781
+ call(router.deleteApplication, "non-existent-id", { context }),
782
+ ).rejects.toThrow("not found");
783
+ });
784
+
785
+ it("regenerateApplicationSecret returns new secret", async () => {
786
+ const context = createMockRpcContext({ user: mockUser });
787
+
788
+ mockDb.select.mockImplementationOnce(() => ({
789
+ from: mock(() => createChain([{ id: "app-1" }])),
790
+ }));
791
+
792
+ mockDb.update = mock(() => ({
793
+ set: mock(() => ({
794
+ where: mock(() => Promise.resolve()),
795
+ })),
796
+ }));
797
+
798
+ const result = await call(router.regenerateApplicationSecret, "app-1", {
799
+ context,
800
+ });
801
+
802
+ expect(result.secret).toMatch(/^ck_app-1_/);
803
+ expect(mockDb.update).toHaveBeenCalled();
804
+ });
805
+
806
+ // ==========================================================================
807
+ // PUBLIC ENDPOINT TESTS
808
+ // ==========================================================================
809
+
810
+ it("getEnabledStrategies returns only enabled strategies", async () => {
811
+ const context = createMockRpcContext({ user: undefined });
812
+
813
+ // Mock credential enabled (default)
814
+ mockConfigService.get.mockResolvedValueOnce(undefined); // Uses default
815
+
816
+ const result = await call(router.getEnabledStrategies, undefined, {
817
+ context,
818
+ });
819
+
820
+ // Should contain the credential strategy
821
+ expect(result.some((s: { id: string }) => s.id === "credential")).toBe(
822
+ true,
823
+ );
824
+ });
440
825
  });