@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.
- package/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/src/index.ts +43 -86
- package/src/router.test.ts +680 -30
- package/src/router.ts +406 -127
package/src/router.test.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, it, expect, mock } from "bun:test";
|
|
2
|
-
import {
|
|
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
|
|
117
|
+
it("deleteUser prevents deleting users with admin role", async () => {
|
|
112
118
|
const context = createMockRpcContext({ user: mockUser });
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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:
|
|
124
|
-
const mockTx:
|
|
125
|
-
delete: mock((table:
|
|
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:
|
|
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
|
});
|