@prsm/auth 1.0.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.
Files changed (61) hide show
  1. package/README.md +226 -0
  2. package/index.d.ts +19 -0
  3. package/package.json +76 -0
  4. package/src/__tests__/auth.test.js +1171 -0
  5. package/src/__tests__/impersonation-test-setup.js +208 -0
  6. package/src/__tests__/impersonation.test.js +473 -0
  7. package/src/__tests__/oauth-test-setup.js +136 -0
  8. package/src/__tests__/oauth.test.js +400 -0
  9. package/src/__tests__/prsm.test.js +215 -0
  10. package/src/__tests__/test-setup.js +385 -0
  11. package/src/__tests__/totp.test.js +158 -0
  12. package/src/__tests__/two-factor-test-setup.js +331 -0
  13. package/src/__tests__/two-factor.test.js +396 -0
  14. package/src/activity-logger.js +228 -0
  15. package/src/auth-context.js +120 -0
  16. package/src/auth-functions.js +520 -0
  17. package/src/auth-manager.js +1371 -0
  18. package/src/errors.js +173 -0
  19. package/src/hooks.js +41 -0
  20. package/src/index.js +23 -0
  21. package/src/invalidation.js +166 -0
  22. package/src/middleware.js +33 -0
  23. package/src/providers/azure-provider.js +114 -0
  24. package/src/providers/base-provider.js +152 -0
  25. package/src/providers/github-provider.js +86 -0
  26. package/src/providers/google-provider.js +76 -0
  27. package/src/providers/index.js +4 -0
  28. package/src/queries.js +543 -0
  29. package/src/schema.js +261 -0
  30. package/src/totp.js +221 -0
  31. package/src/two-factor/index.js +3 -0
  32. package/src/two-factor/otp-provider.js +128 -0
  33. package/src/two-factor/totp-provider.js +98 -0
  34. package/src/two-factor/two-factor-manager.js +676 -0
  35. package/src/types.js +399 -0
  36. package/src/user-roles.js +128 -0
  37. package/src/util.js +32 -0
  38. package/types/activity-logger.d.ts +73 -0
  39. package/types/auth-context.d.ts +88 -0
  40. package/types/auth-functions.d.ts +151 -0
  41. package/types/auth-manager.d.ts +365 -0
  42. package/types/errors.d.ts +108 -0
  43. package/types/hooks.d.ts +30 -0
  44. package/types/index.d.ts +13 -0
  45. package/types/invalidation.d.ts +40 -0
  46. package/types/middleware.d.ts +11 -0
  47. package/types/providers/azure-provider.d.ts +35 -0
  48. package/types/providers/base-provider.d.ts +52 -0
  49. package/types/providers/github-provider.d.ts +29 -0
  50. package/types/providers/google-provider.d.ts +29 -0
  51. package/types/providers/index.d.ts +4 -0
  52. package/types/queries.d.ts +287 -0
  53. package/types/schema.d.ts +37 -0
  54. package/types/totp.d.ts +72 -0
  55. package/types/two-factor/index.d.ts +3 -0
  56. package/types/two-factor/otp-provider.d.ts +57 -0
  57. package/types/two-factor/totp-provider.d.ts +58 -0
  58. package/types/two-factor/two-factor-manager.d.ts +191 -0
  59. package/types/types.d.ts +688 -0
  60. package/types/user-roles.d.ts +47 -0
  61. package/types/util.d.ts +3 -0
@@ -0,0 +1,1171 @@
1
+ import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest"
2
+ import request from "supertest"
3
+ import { createTestApp, createTestDatabase } from "./test-setup.js"
4
+ import {
5
+ AuthRole,
6
+ AuthStatus,
7
+ defineRoles,
8
+ createAuthContext,
9
+ createAuthTables,
10
+ dropAuthTables,
11
+ cleanupExpiredTokens,
12
+ getAuthTableStats,
13
+ authenticateRequest,
14
+ isValidEmail,
15
+ validateEmail,
16
+ InvalidEmailError,
17
+ InvalidPasswordError,
18
+ EmailTakenError,
19
+ UserNotFoundError,
20
+ UserInactiveError,
21
+ EmailNotVerifiedError,
22
+ ConfirmationNotFoundError,
23
+ ConfirmationExpiredError,
24
+ ResetNotFoundError,
25
+ ResetExpiredError,
26
+ ResetDisabledError,
27
+ TooManyResetsError,
28
+ UserNotLoggedInError,
29
+ } from "../index.js"
30
+
31
+ describe("Integration tests", () => {
32
+ let app
33
+ let pool
34
+ let authConfig
35
+ let cleanup
36
+
37
+ beforeAll(async () => {
38
+ const testApp = await createTestApp()
39
+ app = testApp.app
40
+ pool = testApp.pool
41
+ authConfig = testApp.authConfig
42
+ cleanup = testApp.cleanup
43
+ })
44
+
45
+ afterAll(async () => {
46
+ await cleanup()
47
+ })
48
+
49
+ beforeEach(async () => {
50
+ await pool.query("DELETE FROM test_2fa_tokens")
51
+ await pool.query("DELETE FROM test_2fa_methods")
52
+ await pool.query("DELETE FROM test_providers")
53
+ await pool.query("DELETE FROM test_resets")
54
+ await pool.query("DELETE FROM test_remembers")
55
+ await pool.query("DELETE FROM test_confirmations")
56
+ await pool.query("DELETE FROM test_activity_log")
57
+ await pool.query("DELETE FROM test_accounts")
58
+ })
59
+
60
+ // helpers
61
+
62
+ async function registerUser(agent, email, password = "password123", opts = {}) {
63
+ return agent.post("/register").send({ email, password, ...opts })
64
+ }
65
+
66
+ async function loginUser(agent, email, password = "password123", remember = false) {
67
+ return agent.post("/login").send({ email, password, remember })
68
+ }
69
+
70
+ async function registerAndLogin(agent, email, password = "password123") {
71
+ await registerUser(agent, email, password)
72
+ await loginUser(agent, email, password)
73
+ }
74
+
75
+ // ---- registration ----
76
+
77
+ describe("User registration", () => {
78
+ it("should register a new user without email confirmation", async () => {
79
+ const agent = request.agent(app)
80
+ const response = await registerUser(agent, "test@example.com")
81
+
82
+ expect(response.status).toBe(200)
83
+ expect(response.body.account.email).toBe("test@example.com")
84
+ expect(response.body.account.verified).toBe(true)
85
+ expect(response.body.confirmationToken).toBeUndefined()
86
+ })
87
+
88
+ it("should register a new user with email confirmation", async () => {
89
+ const agent = request.agent(app)
90
+ const response = await registerUser(agent, "confirm@example.com", "password123", {
91
+ requireConfirmation: true,
92
+ })
93
+
94
+ expect(response.status).toBe(200)
95
+ expect(response.body.account.verified).toBe(false)
96
+ expect(response.body.confirmationToken).toBeDefined()
97
+ })
98
+
99
+ it("should auto-generate userId when none provided", async () => {
100
+ const agent = request.agent(app)
101
+ const response = await registerUser(agent, "autoid@example.com")
102
+
103
+ expect(response.status).toBe(200)
104
+ expect(response.body.account.user_id).toBeDefined()
105
+ expect(response.body.account.user_id).toMatch(/^[0-9a-f-]{36}$/)
106
+ })
107
+
108
+ it("should use provided userId", async () => {
109
+ const agent = request.agent(app)
110
+ const response = await registerUser(agent, "customid@example.com", "password123", {
111
+ userId: "my-custom-id",
112
+ })
113
+
114
+ expect(response.status).toBe(200)
115
+ expect(response.body.account.user_id).toBe("my-custom-id")
116
+ })
117
+
118
+ it("should reject duplicate email registration", async () => {
119
+ const agent = request.agent(app)
120
+ await registerUser(agent, "duplicate@example.com")
121
+
122
+ const response = await registerUser(agent, "duplicate@example.com", "different-password")
123
+
124
+ expect(response.status).toBe(400)
125
+ expect(response.body.errorType).toBe("EmailTakenError")
126
+ })
127
+
128
+ it("should reject password below minimum length", async () => {
129
+ const agent = request.agent(app)
130
+ const response = await registerUser(agent, "short@example.com", "123")
131
+
132
+ expect(response.status).toBe(400)
133
+ expect(response.body.errorType).toBe("InvalidPasswordError")
134
+ })
135
+
136
+ it("should reject password above maximum length", async () => {
137
+ const agent = request.agent(app)
138
+ const response = await registerUser(agent, "long@example.com", "a".repeat(51))
139
+
140
+ expect(response.status).toBe(400)
141
+ expect(response.body.errorType).toBe("InvalidPasswordError")
142
+ })
143
+
144
+ it("should reject invalid email format", async () => {
145
+ const agent = request.agent(app)
146
+
147
+ for (const email of ["notanemail", "@missing.com", "no@", "spaces in@email.com", ""]) {
148
+ const response = await registerUser(agent, email)
149
+ expect(response.status).toBe(400)
150
+ expect(response.body.errorType).toBe("InvalidEmailError")
151
+ }
152
+ })
153
+ })
154
+
155
+ // ---- login ----
156
+
157
+ describe("User login", () => {
158
+ beforeEach(async () => {
159
+ const agent = request.agent(app)
160
+ await registerUser(agent, "login@example.com")
161
+ })
162
+
163
+ it("should login with valid credentials", async () => {
164
+ const agent = request.agent(app)
165
+ const response = await loginUser(agent, "login@example.com")
166
+
167
+ expect(response.status).toBe(200)
168
+
169
+ const profile = await agent.get("/profile")
170
+ expect(profile.body.email).toBe("login@example.com")
171
+ expect(profile.body.verified).toBe(true)
172
+ expect(profile.body.statusName).toBe("Normal")
173
+ expect(profile.body.hasPassword).toBe(true)
174
+ })
175
+
176
+ it("should reject invalid password", async () => {
177
+ const agent = request.agent(app)
178
+ const response = await loginUser(agent, "login@example.com", "wrongpassword")
179
+
180
+ expect(response.status).toBe(401)
181
+ expect(response.body.errorType).toBe("InvalidPasswordError")
182
+ })
183
+
184
+ it("should reject non-existent email", async () => {
185
+ const agent = request.agent(app)
186
+ const response = await loginUser(agent, "nonexistent@example.com")
187
+
188
+ expect(response.status).toBe(401)
189
+ expect(response.body.errorType).toBe("UserNotFoundError")
190
+ })
191
+
192
+ it("should reject login for banned account", async () => {
193
+ const agent = request.agent(app)
194
+ await pool.query("UPDATE test_accounts SET status = $1 WHERE email = $2", [AuthStatus.Banned, "login@example.com"])
195
+
196
+ const response = await loginUser(agent, "login@example.com")
197
+
198
+ expect(response.status).toBe(401)
199
+ expect(response.body.errorType).toBe("UserInactiveError")
200
+ })
201
+
202
+ it("should reject login for locked account", async () => {
203
+ const agent = request.agent(app)
204
+ await pool.query("UPDATE test_accounts SET status = $1 WHERE email = $2", [AuthStatus.Locked, "login@example.com"])
205
+
206
+ const response = await loginUser(agent, "login@example.com")
207
+
208
+ expect(response.status).toBe(401)
209
+ expect(response.body.errorType).toBe("UserInactiveError")
210
+ })
211
+
212
+ it("should reject login for suspended account", async () => {
213
+ const agent = request.agent(app)
214
+ await pool.query("UPDATE test_accounts SET status = $1 WHERE email = $2", [AuthStatus.Suspended, "login@example.com"])
215
+
216
+ const response = await loginUser(agent, "login@example.com")
217
+
218
+ expect(response.status).toBe(401)
219
+ expect(response.body.errorType).toBe("UserInactiveError")
220
+ })
221
+
222
+ it("should reject login for unverified email", async () => {
223
+ const agent = request.agent(app)
224
+ await registerUser(agent, "unverified@example.com", "password123", { requireConfirmation: true })
225
+
226
+ const response = await loginUser(agent, "unverified@example.com")
227
+
228
+ expect(response.status).toBe(401)
229
+ expect(response.body.errorType).toBe("EmailNotVerifiedError")
230
+ })
231
+
232
+ it("should handle remember me functionality", async () => {
233
+ const agent = request.agent(app)
234
+ const response = await loginUser(agent, "login@example.com", "password123", true)
235
+
236
+ expect(response.status).toBe(200)
237
+ const cookies = response.headers["set-cookie"]
238
+ expect(cookies.some((cookie) => cookie.includes("test_remember_token"))).toBe(true)
239
+
240
+ const profile = await agent.get("/profile")
241
+ expect(profile.body.remembered).toBe(true)
242
+ })
243
+ })
244
+
245
+ // ---- email confirmation ----
246
+
247
+ describe("Email confirmation", () => {
248
+ it("should confirm email without auto-login", async () => {
249
+ const agent = request.agent(app)
250
+ const reg = await registerUser(agent, "confirm@example.com", "password123", { requireConfirmation: true })
251
+
252
+ const response = await agent.post("/confirm-email").send({ token: reg.body.confirmationToken })
253
+
254
+ expect(response.status).toBe(200)
255
+ expect(response.body.email).toBe("confirm@example.com")
256
+
257
+ // should not be logged in
258
+ const profile = await agent.get("/profile")
259
+ expect(profile.status).toBe(401)
260
+ })
261
+
262
+ it("should confirm email and auto-login", async () => {
263
+ const agent = request.agent(app)
264
+ const reg = await registerUser(agent, "autologin@example.com", "password123", { requireConfirmation: true })
265
+
266
+ await agent.post("/confirm-email").send({ token: reg.body.confirmationToken, autoLogin: true }).expect(200)
267
+
268
+ const profile = await agent.get("/profile")
269
+ expect(profile.body.email).toBe("autologin@example.com")
270
+ expect(profile.body.verified).toBe(true)
271
+ })
272
+
273
+ it("should reject invalid confirmation token", async () => {
274
+ const agent = request.agent(app)
275
+ const response = await agent.post("/confirm-email").send({ token: "invalid-token" })
276
+
277
+ expect(response.status).toBe(400)
278
+ expect(response.body.errorType).toBe("ConfirmationNotFoundError")
279
+ })
280
+
281
+ it("should reject expired confirmation token", async () => {
282
+ const agent = request.agent(app)
283
+ const reg = await registerUser(agent, "expired@example.com", "password123", { requireConfirmation: true })
284
+
285
+ await pool.query("UPDATE test_confirmations SET expires = NOW() - INTERVAL '1 day' WHERE token = $1", [reg.body.confirmationToken])
286
+
287
+ const response = await agent.post("/confirm-email").send({ token: reg.body.confirmationToken })
288
+
289
+ expect(response.status).toBe(400)
290
+ expect(response.body.errorType).toBe("ConfirmationExpiredError")
291
+ })
292
+ })
293
+
294
+ // ---- email change ----
295
+
296
+ describe("Email change", () => {
297
+ it("should change email with confirmation flow", async () => {
298
+ const agent = request.agent(app)
299
+ await registerAndLogin(agent, "original@example.com")
300
+
301
+ const changeResponse = await agent.post("/change-email").send({ newEmail: "newemail@example.com" })
302
+
303
+ expect(changeResponse.status).toBe(200)
304
+ expect(changeResponse.body.confirmationToken).toBeDefined()
305
+
306
+ await agent.post("/confirm-email").send({ token: changeResponse.body.confirmationToken }).expect(200)
307
+
308
+ const profile = await agent.get("/profile")
309
+ expect(profile.body.email).toBe("newemail@example.com")
310
+ })
311
+
312
+ it("should reject email change when not logged in", async () => {
313
+ const agent = request.agent(app)
314
+ const response = await agent.post("/change-email").send({ newEmail: "new@example.com" })
315
+
316
+ expect(response.status).toBe(400)
317
+ expect(response.body.errorType).toBe("UserNotLoggedInError")
318
+ })
319
+
320
+ it("should reject email change to taken email", async () => {
321
+ const agent = request.agent(app)
322
+ await registerUser(agent, "taken@example.com")
323
+ await registerAndLogin(agent, "changer@example.com")
324
+
325
+ const response = await agent.post("/change-email").send({ newEmail: "taken@example.com" })
326
+
327
+ expect(response.status).toBe(400)
328
+ expect(response.body.errorType).toBe("EmailTakenError")
329
+ })
330
+ })
331
+
332
+ // ---- password reset ----
333
+
334
+ describe("Password reset", () => {
335
+ beforeEach(async () => {
336
+ const agent = request.agent(app)
337
+ await registerUser(agent, "reset@example.com", "oldpassword123")
338
+ })
339
+
340
+ it("should initiate and confirm password reset", async () => {
341
+ const agent = request.agent(app)
342
+ const resetResponse = await agent.post("/reset-password").send({ email: "reset@example.com" })
343
+
344
+ expect(resetResponse.body.resetToken).toBeDefined()
345
+
346
+ await agent.post("/confirm-reset").send({ token: resetResponse.body.resetToken, password: "newpassword123" }).expect(200)
347
+
348
+ await loginUser(agent, "reset@example.com", "newpassword123").then((r) => expect(r.status).toBe(200))
349
+ // old password should fail
350
+ const agent2 = request.agent(app)
351
+ await loginUser(agent2, "reset@example.com", "oldpassword123").then((r) => expect(r.status).toBe(401))
352
+ })
353
+
354
+ it("should reject reset for non-existent email", async () => {
355
+ const agent = request.agent(app)
356
+ const response = await agent.post("/reset-password").send({ email: "nope@example.com" })
357
+
358
+ expect(response.status).toBe(400)
359
+ expect(response.body.errorType).toBe("EmailNotVerifiedError")
360
+ })
361
+
362
+ it("should reject reset for unverified email", async () => {
363
+ const agent = request.agent(app)
364
+ await registerUser(agent, "unverified-reset@example.com", "password123", { requireConfirmation: true })
365
+
366
+ const response = await agent.post("/reset-password").send({ email: "unverified-reset@example.com" })
367
+
368
+ expect(response.status).toBe(400)
369
+ expect(response.body.errorType).toBe("EmailNotVerifiedError")
370
+ })
371
+
372
+ it("should reject reset when resettable is false", async () => {
373
+ const agent = request.agent(app)
374
+ await pool.query("UPDATE test_accounts SET resettable = false WHERE email = $1", ["reset@example.com"])
375
+
376
+ const response = await agent.post("/reset-password").send({ email: "reset@example.com" })
377
+
378
+ expect(response.status).toBe(400)
379
+ expect(response.body.errorType).toBe("ResetDisabledError")
380
+ })
381
+
382
+ it("should enforce max open reset requests", async () => {
383
+ const agent = request.agent(app)
384
+
385
+ await agent.post("/reset-password").send({ email: "reset@example.com", maxRequests: 1 }).expect(200)
386
+
387
+ const response = await agent.post("/reset-password").send({ email: "reset@example.com", maxRequests: 1 })
388
+
389
+ expect(response.status).toBe(400)
390
+ expect(response.body.errorType).toBe("TooManyResetsError")
391
+ })
392
+
393
+ it("should reject expired reset token", async () => {
394
+ const agent = request.agent(app)
395
+ const resetResponse = await agent.post("/reset-password").send({ email: "reset@example.com" })
396
+
397
+ await pool.query("UPDATE test_resets SET expires = NOW() - INTERVAL '1 day' WHERE token = $1", [resetResponse.body.resetToken])
398
+
399
+ const response = await agent.post("/confirm-reset").send({ token: resetResponse.body.resetToken, password: "newpassword" })
400
+
401
+ expect(response.status).toBe(400)
402
+ expect(response.body.errorType).toBe("ResetExpiredError")
403
+ })
404
+
405
+ it("should reject invalid reset token", async () => {
406
+ const agent = request.agent(app)
407
+ const response = await agent.post("/confirm-reset").send({ token: "bogus-token", password: "newpassword" })
408
+
409
+ expect(response.status).toBe(400)
410
+ expect(response.body.errorType).toBe("ResetNotFoundError")
411
+ })
412
+
413
+ it("should reject weak password on reset confirm", async () => {
414
+ const agent = request.agent(app)
415
+ const resetResponse = await agent.post("/reset-password").send({ email: "reset@example.com" })
416
+
417
+ const response = await agent.post("/confirm-reset").send({ token: resetResponse.body.resetToken, password: "123" })
418
+
419
+ expect(response.status).toBe(400)
420
+ expect(response.body.errorType).toBe("InvalidPasswordError")
421
+ })
422
+ })
423
+
424
+ // ---- session management ----
425
+
426
+ describe("Session management", () => {
427
+ it("should logout successfully", async () => {
428
+ const agent = request.agent(app)
429
+ await registerAndLogin(agent, "session@example.com")
430
+
431
+ await agent.get("/profile").expect(200)
432
+ await agent.post("/logout").expect(200)
433
+ await agent.get("/profile").expect(401)
434
+ })
435
+
436
+ it("should verify password correctly", async () => {
437
+ const agent = request.agent(app)
438
+ await registerAndLogin(agent, "verify@example.com")
439
+
440
+ const correct = await agent.post("/verify-password").send({ password: "password123" })
441
+ expect(correct.body.isValid).toBe(true)
442
+
443
+ const wrong = await agent.post("/verify-password").send({ password: "wrongpassword" })
444
+ expect(wrong.body.isValid).toBe(false)
445
+ })
446
+
447
+ it("should reject verify password when not logged in", async () => {
448
+ const agent = request.agent(app)
449
+ const response = await agent.post("/verify-password").send({ password: "anything" })
450
+
451
+ expect(response.status).toBe(400)
452
+ expect(response.body.errorType).toBe("UserNotLoggedInError")
453
+ })
454
+
455
+ it("should logout everywhere", async () => {
456
+ const agent = request.agent(app)
457
+ await registerAndLogin(agent, "everywhere@example.com")
458
+
459
+ await agent.post("/logout-everywhere").expect(200)
460
+ await agent.get("/profile").expect(401)
461
+ })
462
+
463
+ it("should logout everywhere else (current session survives)", async () => {
464
+ const agent = request.agent(app)
465
+ await registerAndLogin(agent, "everywhereelse@example.com")
466
+
467
+ await agent.post("/logout-everywhere-else").expect(200)
468
+
469
+ // current session should still work
470
+ await agent.get("/profile").expect(200)
471
+ })
472
+
473
+ it("should return null values when not logged in", async () => {
474
+ const agent = request.agent(app)
475
+ const profile = await agent.get("/profile")
476
+
477
+ expect(profile.status).toBe(401)
478
+ })
479
+ })
480
+
481
+ // ---- admin functions ----
482
+
483
+ describe("Admin functions", () => {
484
+ it("should create user as admin", async () => {
485
+ const agent = request.agent(app)
486
+ const response = await agent.post("/admin/create-user").send({ email: "admin-created@example.com", password: "adminpassword123" })
487
+
488
+ expect(response.status).toBe(200)
489
+ expect(response.body.account.email).toBe("admin-created@example.com")
490
+ expect(response.body.account.verified).toBe(true)
491
+ })
492
+
493
+ it("should create user with confirmation via admin", async () => {
494
+ const agent = request.agent(app)
495
+ const response = await agent.post("/admin/create-user").send({
496
+ email: "admin-confirm@example.com",
497
+ password: "password123",
498
+ requireConfirmation: true,
499
+ })
500
+
501
+ expect(response.status).toBe(200)
502
+ expect(response.body.account.verified).toBe(false)
503
+ expect(response.body.confirmationToken).toBeDefined()
504
+ })
505
+
506
+ it("should login as another user", async () => {
507
+ const agent = request.agent(app)
508
+ await agent.post("/admin/create-user").send({ email: "impersonate@example.com", password: "password123" })
509
+ await agent.post("/admin/login-as").send({ email: "impersonate@example.com" }).expect(200)
510
+
511
+ const profile = await agent.get("/profile")
512
+ expect(profile.body.email).toBe("impersonate@example.com")
513
+ })
514
+
515
+ it("should reject login-as for non-existent user", async () => {
516
+ const agent = request.agent(app)
517
+ const response = await agent.post("/admin/login-as").send({ email: "nope@example.com" })
518
+
519
+ expect(response.status).toBe(400)
520
+ expect(response.body.errorType).toBe("UserNotFoundError")
521
+ })
522
+
523
+ it("should add and check roles", async () => {
524
+ const agent = request.agent(app)
525
+ await agent.post("/admin/create-user").send({ email: "roles@example.com", password: "password123" })
526
+
527
+ await agent.post("/admin/add-role").send({ identifier: { email: "roles@example.com" }, role: AuthRole.Admin }).expect(200)
528
+
529
+ await agent.post("/admin/login-as").send({ email: "roles@example.com" })
530
+
531
+ const profile = await agent.get("/profile")
532
+ expect(profile.body.roles).toContain("Admin")
533
+ expect(profile.body.isAdmin).toBe(true)
534
+ })
535
+
536
+ it("should remove roles", async () => {
537
+ const agent = request.agent(app)
538
+ await agent.post("/admin/create-user").send({ email: "removerole@example.com", password: "password123" })
539
+
540
+ await agent.post("/admin/add-role").send({ identifier: { email: "removerole@example.com" }, role: AuthRole.Admin | AuthRole.Editor })
541
+
542
+ const hasAdmin = await agent.post("/admin/has-role").send({ identifier: { email: "removerole@example.com" }, role: AuthRole.Admin })
543
+ expect(hasAdmin.body.hasRole).toBe(true)
544
+
545
+ await agent.post("/admin/remove-role").send({ identifier: { email: "removerole@example.com" }, role: AuthRole.Admin }).expect(200)
546
+
547
+ const afterRemove = await agent.post("/admin/has-role").send({ identifier: { email: "removerole@example.com" }, role: AuthRole.Admin })
548
+ expect(afterRemove.body.hasRole).toBe(false)
549
+
550
+ // editor should still be there
551
+ const hasEditor = await agent.post("/admin/has-role").send({ identifier: { email: "removerole@example.com" }, role: AuthRole.Editor })
552
+ expect(hasEditor.body.hasRole).toBe(true)
553
+ })
554
+
555
+ it("should check combined roles (bitmask composition)", async () => {
556
+ const agent = request.agent(app)
557
+ await agent.post("/admin/create-user").send({ email: "combo@example.com", password: "password123" })
558
+
559
+ const combined = AuthRole.Admin | AuthRole.Editor | AuthRole.Moderator
560
+ await agent.post("/admin/add-role").send({ identifier: { email: "combo@example.com" }, role: combined })
561
+
562
+ await agent.post("/admin/login-as").send({ email: "combo@example.com" })
563
+
564
+ const profile = await agent.get("/profile")
565
+ expect(profile.body.roles).toContain("Admin")
566
+ expect(profile.body.roles).toContain("Editor")
567
+ expect(profile.body.roles).toContain("Moderator")
568
+ expect(profile.body.roles).not.toContain("Owner")
569
+ })
570
+
571
+ it("should change password for user", async () => {
572
+ const agent = request.agent(app)
573
+ await agent.post("/admin/create-user").send({ email: "changepw@example.com", password: "oldpassword" })
574
+
575
+ await agent.post("/admin/change-password").send({ identifier: { email: "changepw@example.com" }, password: "newpassword" }).expect(200)
576
+
577
+ await loginUser(agent, "changepw@example.com", "newpassword").then((r) => expect(r.status).toBe(200))
578
+ })
579
+
580
+ it("should reject weak password on admin change", async () => {
581
+ const agent = request.agent(app)
582
+ await agent.post("/admin/create-user").send({ email: "weakpw@example.com", password: "password123" })
583
+
584
+ const response = await agent.post("/admin/change-password").send({ identifier: { email: "weakpw@example.com" }, password: "12" })
585
+
586
+ expect(response.status).toBe(400)
587
+ expect(response.body.errorType).toBe("InvalidPasswordError")
588
+ })
589
+
590
+ it("should set status for user", async () => {
591
+ const agent = request.agent(app)
592
+ await agent.post("/admin/create-user").send({ email: "statususer@example.com", password: "password123" })
593
+
594
+ await agent.post("/admin/set-status").send({ identifier: { email: "statususer@example.com" }, status: AuthStatus.Banned }).expect(200)
595
+
596
+ const response = await loginUser(agent, "statususer@example.com")
597
+ expect(response.status).toBe(401)
598
+ expect(response.body.errorType).toBe("UserInactiveError")
599
+ })
600
+
601
+ it("should initiate password reset for user by identifier", async () => {
602
+ const agent = request.agent(app)
603
+ await agent.post("/admin/create-user").send({ email: "adminreset@example.com", password: "password123" })
604
+
605
+ const response = await agent.post("/admin/initiate-reset").send({ identifier: { email: "adminreset@example.com" } })
606
+
607
+ expect(response.status).toBe(200)
608
+ expect(response.body.resetToken).toBeDefined()
609
+ })
610
+
611
+ it("should check user exists by email", async () => {
612
+ const agent = request.agent(app)
613
+ await agent.post("/admin/create-user").send({ email: "exists@example.com", password: "password123" })
614
+
615
+ const exists = await agent.post("/admin/user-exists").send({ email: "exists@example.com" })
616
+ expect(exists.body.exists).toBe(true)
617
+
618
+ const notExists = await agent.post("/admin/user-exists").send({ email: "nope@example.com" })
619
+ expect(notExists.body.exists).toBe(false)
620
+ })
621
+
622
+ it("should delete user", async () => {
623
+ const agent = request.agent(app)
624
+ await agent.post("/admin/create-user").send({ email: "deleteme@example.com", password: "password123" })
625
+
626
+ await agent.post("/admin/delete-user").send({ email: "deleteme@example.com" }).expect(200)
627
+
628
+ const exists = await agent.post("/admin/user-exists").send({ email: "deleteme@example.com" })
629
+ expect(exists.body.exists).toBe(false)
630
+ })
631
+
632
+ it("should reject delete for non-existent user", async () => {
633
+ const agent = request.agent(app)
634
+ const response = await agent.post("/admin/delete-user").send({ email: "nope@example.com" })
635
+
636
+ expect(response.status).toBe(400)
637
+ expect(response.body.errorType).toBe("UserNotFoundError")
638
+ })
639
+
640
+ it("should immediately force logout current user", async () => {
641
+ const agent = request.agent(app)
642
+ await registerAndLogin(agent, "forcelogout@example.com")
643
+
644
+ await agent.get("/profile").expect(200)
645
+ await agent.post("/admin/force-logout-user").send({ email: "forcelogout@example.com" }).expect(200)
646
+ await agent.get("/profile").expect(401)
647
+ })
648
+
649
+ it("should find user by accountId, email, and userId", async () => {
650
+ const agent = request.agent(app)
651
+ const reg = await agent.post("/admin/create-user").send({ email: "findme@example.com", password: "password123", userId: "find-me-id" })
652
+ const accountId = reg.body.account.id
653
+
654
+ // by email
655
+ const byEmail = await agent.post("/admin/has-role").send({ identifier: { email: "findme@example.com" }, role: AuthRole.Admin })
656
+ expect(byEmail.status).toBe(200)
657
+
658
+ // by accountId
659
+ const byId = await agent.post("/admin/has-role").send({ identifier: { accountId }, role: AuthRole.Admin })
660
+ expect(byId.status).toBe(200)
661
+
662
+ // by userId
663
+ const byUserId = await agent.post("/admin/has-role").send({ identifier: { userId: "find-me-id" }, role: AuthRole.Admin })
664
+ expect(byUserId.status).toBe(200)
665
+ })
666
+ })
667
+
668
+ // ---- error handling ----
669
+
670
+ describe("Error handling", () => {
671
+ it("should handle profile access when not logged in", async () => {
672
+ const agent = request.agent(app)
673
+ const response = await agent.get("/profile")
674
+ expect(response.status).toBe(401)
675
+ })
676
+ })
677
+ })
678
+
679
+ // ---- defineRoles ----
680
+
681
+ describe("defineRoles", () => {
682
+ it("should create role object with sequential powers of 2", () => {
683
+ const roles = defineRoles("owner", "editor", "viewer")
684
+
685
+ expect(roles.owner).toBe(1)
686
+ expect(roles.editor).toBe(2)
687
+ expect(roles.viewer).toBe(4)
688
+ })
689
+
690
+ it("should return frozen object", () => {
691
+ const roles = defineRoles("admin", "user")
692
+ expect(Object.isFrozen(roles)).toBe(true)
693
+ })
694
+
695
+ it("should work with bitmask operations", () => {
696
+ const roles = defineRoles("admin", "editor", "viewer")
697
+
698
+ const mask = roles.admin | roles.viewer
699
+ expect(mask & roles.admin).toBe(roles.admin)
700
+ expect(mask & roles.editor).toBe(0)
701
+ expect(mask & roles.viewer).toBe(roles.viewer)
702
+ })
703
+
704
+ it("should reject duplicate names", () => {
705
+ expect(() => defineRoles("admin", "admin")).toThrow("Duplicate role name")
706
+ })
707
+
708
+ it("should reject empty names list", () => {
709
+ expect(() => defineRoles()).toThrow("At least one role name")
710
+ })
711
+
712
+ it("should reject more than 31 roles", () => {
713
+ const names = Array.from({ length: 32 }, (_, i) => `role${i}`)
714
+ expect(() => defineRoles(...names)).toThrow("Cannot define more than 31 roles")
715
+ })
716
+
717
+ it("should preserve exact name casing", () => {
718
+ const roles = defineRoles("SuperAdmin", "basic_user", "READONLY")
719
+ expect("SuperAdmin" in roles).toBe(true)
720
+ expect("basic_user" in roles).toBe(true)
721
+ expect("READONLY" in roles).toBe(true)
722
+ })
723
+ })
724
+
725
+ // ---- defineRoles + getRoleNames integration ----
726
+
727
+ describe("Custom roles with getRoleNames", () => {
728
+ let app
729
+ let pool
730
+ let cleanup
731
+
732
+ const CustomRoles = defineRoles("owner", "editor", "viewer")
733
+
734
+ beforeAll(async () => {
735
+ const testApp = await createTestApp({ roles: CustomRoles, tablePrefix: "roles_test_" })
736
+ app = testApp.app
737
+ pool = testApp.pool
738
+ cleanup = testApp.cleanup
739
+ })
740
+
741
+ afterAll(async () => {
742
+ await cleanup()
743
+ })
744
+
745
+ beforeEach(async () => {
746
+ await pool.query("DELETE FROM roles_test_2fa_tokens")
747
+ await pool.query("DELETE FROM roles_test_2fa_methods")
748
+ await pool.query("DELETE FROM roles_test_providers")
749
+ await pool.query("DELETE FROM roles_test_resets")
750
+ await pool.query("DELETE FROM roles_test_remembers")
751
+ await pool.query("DELETE FROM roles_test_confirmations")
752
+ await pool.query("DELETE FROM roles_test_activity_log")
753
+ await pool.query("DELETE FROM roles_test_accounts")
754
+ })
755
+
756
+ it("should return custom role names from getRoleNames", async () => {
757
+ const agent = request.agent(app)
758
+ await agent.post("/admin/create-user").send({ email: "custom@example.com", password: "password123" })
759
+
760
+ await agent.post("/admin/add-role").send({
761
+ identifier: { email: "custom@example.com" },
762
+ role: CustomRoles.owner | CustomRoles.viewer,
763
+ })
764
+
765
+ await agent.post("/admin/login-as").send({ email: "custom@example.com" })
766
+
767
+ const profile = await agent.get("/profile")
768
+ expect(profile.body.roles).toContain("owner")
769
+ expect(profile.body.roles).toContain("viewer")
770
+ expect(profile.body.roles).not.toContain("editor")
771
+ // should NOT contain default AuthRole names
772
+ expect(profile.body.roles).not.toContain("Admin")
773
+ })
774
+ })
775
+
776
+ // ---- AuthContext (standalone, no request) ----
777
+
778
+ describe("AuthContext", () => {
779
+ let pool
780
+ let authConfig
781
+
782
+ beforeAll(async () => {
783
+ pool = await createTestDatabase()
784
+
785
+ authConfig = {
786
+ db: pool,
787
+ tablePrefix: "ctx_test_",
788
+ minPasswordLength: 6,
789
+ maxPasswordLength: 50,
790
+ }
791
+
792
+ await dropAuthTables(authConfig)
793
+ await createAuthTables(authConfig)
794
+ })
795
+
796
+ afterAll(async () => {
797
+ await dropAuthTables(authConfig)
798
+ await pool.end()
799
+ })
800
+
801
+ beforeEach(async () => {
802
+ await pool.query("DELETE FROM ctx_test_resets")
803
+ await pool.query("DELETE FROM ctx_test_remembers")
804
+ await pool.query("DELETE FROM ctx_test_confirmations")
805
+ await pool.query("DELETE FROM ctx_test_accounts")
806
+ })
807
+
808
+ it("should create and use an auth context", async () => {
809
+ const ctx = createAuthContext(authConfig)
810
+
811
+ const account = await ctx.createUser({ email: "ctx@example.com", password: "password123" })
812
+ expect(account.email).toBe("ctx@example.com")
813
+ expect(account.verified).toBe(true)
814
+ })
815
+
816
+ it("should register via context", async () => {
817
+ const ctx = createAuthContext(authConfig)
818
+
819
+ const account = await ctx.register("ctxreg@example.com", "password123")
820
+ expect(account.email).toBe("ctxreg@example.com")
821
+ })
822
+
823
+ it("should check user exists via context", async () => {
824
+ const ctx = createAuthContext(authConfig)
825
+
826
+ await ctx.createUser({ email: "ctxexists@example.com", password: "password123" })
827
+
828
+ expect(await ctx.userExistsByEmail("ctxexists@example.com")).toBe(true)
829
+ expect(await ctx.userExistsByEmail("nope@example.com")).toBe(false)
830
+ })
831
+
832
+ it("should delete user via context", async () => {
833
+ const ctx = createAuthContext(authConfig)
834
+
835
+ await ctx.createUser({ email: "ctxdelete@example.com", password: "password123" })
836
+ await ctx.deleteUserBy({ email: "ctxdelete@example.com" })
837
+
838
+ expect(await ctx.userExistsByEmail("ctxdelete@example.com")).toBe(false)
839
+ })
840
+
841
+ it("should manage roles via context", async () => {
842
+ const ctx = createAuthContext(authConfig)
843
+
844
+ await ctx.createUser({ email: "ctxroles@example.com", password: "password123" })
845
+
846
+ await ctx.addRoleForUserBy({ email: "ctxroles@example.com" }, AuthRole.Admin | AuthRole.Editor)
847
+ expect(await ctx.hasRoleForUserBy({ email: "ctxroles@example.com" }, AuthRole.Admin)).toBe(true)
848
+
849
+ await ctx.removeRoleForUserBy({ email: "ctxroles@example.com" }, AuthRole.Admin)
850
+ expect(await ctx.hasRoleForUserBy({ email: "ctxroles@example.com" }, AuthRole.Admin)).toBe(false)
851
+ expect(await ctx.hasRoleForUserBy({ email: "ctxroles@example.com" }, AuthRole.Editor)).toBe(true)
852
+ })
853
+
854
+ it("should change password via context", async () => {
855
+ const ctx = createAuthContext(authConfig)
856
+
857
+ await ctx.createUser({ email: "ctxpw@example.com", password: "password123" })
858
+ const before = await ctx.getAccount({ email: "ctxpw@example.com" })
859
+ await ctx.changePasswordForUserBy({ email: "ctxpw@example.com" }, "newpassword")
860
+ const after = await ctx.getAccount({ email: "ctxpw@example.com" })
861
+
862
+ expect(after.password).toBeTruthy()
863
+ expect(after.password).not.toBe(before.password)
864
+ })
865
+
866
+ it("should set status via context", async () => {
867
+ const ctx = createAuthContext(authConfig)
868
+
869
+ await ctx.createUser({ email: "ctxstatus@example.com", password: "password123" })
870
+ await ctx.setStatusForUserBy({ email: "ctxstatus@example.com" }, AuthStatus.Banned)
871
+
872
+ const result = await pool.query("SELECT status FROM ctx_test_accounts WHERE email = $1", ["ctxstatus@example.com"])
873
+ expect(result.rows[0].status).toBe(AuthStatus.Banned)
874
+ })
875
+
876
+ it("should reset password via context", async () => {
877
+ const ctx = createAuthContext(authConfig)
878
+
879
+ await ctx.createUser({ email: "ctxreset@example.com", password: "password123" })
880
+
881
+ let token
882
+ await ctx.resetPassword("ctxreset@example.com", "1h", 3, (t) => {
883
+ token = t
884
+ })
885
+ expect(token).toBeDefined()
886
+
887
+ const result = await ctx.confirmResetPassword(token, "newpassword123")
888
+ expect(result.email).toBe("ctxreset@example.com")
889
+ })
890
+
891
+ it("should force logout via context", async () => {
892
+ const ctx = createAuthContext(authConfig)
893
+
894
+ await ctx.createUser({ email: "ctxforce@example.com", password: "password123" })
895
+ const result = await ctx.forceLogoutForUserBy({ email: "ctxforce@example.com" })
896
+ expect(result.accountId).toBeDefined()
897
+ })
898
+
899
+ it("should initiate reset for user by identifier", async () => {
900
+ const ctx = createAuthContext(authConfig)
901
+
902
+ await ctx.createUser({ email: "ctxinitiate@example.com", password: "password123" })
903
+
904
+ let token
905
+ await ctx.initiatePasswordResetForUserBy({ email: "ctxinitiate@example.com" }, "1h", (t) => {
906
+ token = t
907
+ })
908
+
909
+ expect(token).toBeDefined()
910
+ })
911
+ })
912
+
913
+ // ---- schema operations ----
914
+
915
+ describe("Schema operations", () => {
916
+ let pool
917
+
918
+ beforeAll(async () => {
919
+ pool = await createTestDatabase()
920
+ })
921
+
922
+ afterAll(async () => {
923
+ await pool.end()
924
+ })
925
+
926
+ it("should create and drop tables", async () => {
927
+ const config = { db: pool, tablePrefix: "schema_test_" }
928
+
929
+ await createAuthTables(config)
930
+
931
+ const result = await pool.query("SELECT table_name FROM information_schema.tables WHERE table_name LIKE 'schema_test_%'")
932
+ const tableNames = result.rows.map((r) => r.table_name)
933
+
934
+ expect(tableNames).toContain("schema_test_accounts")
935
+ expect(tableNames).toContain("schema_test_confirmations")
936
+ expect(tableNames).toContain("schema_test_remembers")
937
+ expect(tableNames).toContain("schema_test_resets")
938
+ expect(tableNames).toContain("schema_test_providers")
939
+ expect(tableNames).toContain("schema_test_activity_log")
940
+ expect(tableNames).toContain("schema_test_2fa_methods")
941
+ expect(tableNames).toContain("schema_test_2fa_tokens")
942
+
943
+ await dropAuthTables(config)
944
+
945
+ const afterDrop = await pool.query("SELECT table_name FROM information_schema.tables WHERE table_name LIKE 'schema_test_%'")
946
+ expect(afterDrop.rows).toHaveLength(0)
947
+ })
948
+
949
+ it("should be idempotent (create twice without error)", async () => {
950
+ const config = { db: pool, tablePrefix: "idempotent_test_" }
951
+
952
+ await createAuthTables(config)
953
+ await createAuthTables(config)
954
+
955
+ await dropAuthTables(config)
956
+ })
957
+
958
+ it("should cleanup expired tokens", async () => {
959
+ const config = { db: pool, tablePrefix: "cleanup_test_" }
960
+
961
+ await dropAuthTables(config)
962
+ await createAuthTables(config)
963
+
964
+ // insert an account and some expired tokens
965
+ await pool.query("INSERT INTO cleanup_test_accounts (user_id, email, password, verified, status, rolemask) VALUES ('u1', 'cleanup@test.com', 'pw', true, 0, 0)")
966
+ const account = await pool.query("SELECT id FROM cleanup_test_accounts LIMIT 1")
967
+ const accountId = account.rows[0].id
968
+
969
+ await pool.query("INSERT INTO cleanup_test_confirmations (account_id, token, email, expires) VALUES ($1, 'expired', 'a@b.c', NOW() - INTERVAL '1 day')", [accountId])
970
+ await pool.query("INSERT INTO cleanup_test_remembers (account_id, token, expires) VALUES ($1, 'expired', NOW() - INTERVAL '1 day')", [accountId])
971
+ await pool.query("INSERT INTO cleanup_test_resets (account_id, token, expires) VALUES ($1, 'expired', NOW() - INTERVAL '1 day')", [accountId])
972
+
973
+ await cleanupExpiredTokens(config)
974
+
975
+ const confirmations = await pool.query("SELECT * FROM cleanup_test_confirmations")
976
+ const remembers = await pool.query("SELECT * FROM cleanup_test_remembers")
977
+ const resets = await pool.query("SELECT * FROM cleanup_test_resets")
978
+
979
+ expect(confirmations.rows).toHaveLength(0)
980
+ expect(remembers.rows).toHaveLength(0)
981
+ expect(resets.rows).toHaveLength(0)
982
+
983
+ await dropAuthTables(config)
984
+ })
985
+
986
+ it("should return table stats", async () => {
987
+ const config = { db: pool, tablePrefix: "stats_test_" }
988
+
989
+ await dropAuthTables(config)
990
+ await createAuthTables(config)
991
+
992
+ await pool.query("INSERT INTO stats_test_accounts (user_id, email, password, verified, status, rolemask) VALUES ('u1', 'stats@test.com', 'pw', true, 0, 0)")
993
+
994
+ const stats = await getAuthTableStats(config)
995
+
996
+ expect(stats.accounts).toBe(1)
997
+ expect(stats.providers).toBe(0)
998
+ expect(stats.confirmations).toBe(0)
999
+
1000
+ await dropAuthTables(config)
1001
+ })
1002
+ })
1003
+
1004
+ // ---- utility functions ----
1005
+
1006
+ describe("Utility functions", () => {
1007
+ it("isValidEmail should validate email format", () => {
1008
+ expect(isValidEmail("user@example.com")).toBe(true)
1009
+ expect(isValidEmail("user@sub.domain.com")).toBe(true)
1010
+ expect(isValidEmail("notanemail")).toBe(false)
1011
+ expect(isValidEmail("@missing.com")).toBe(false)
1012
+ expect(isValidEmail("no@")).toBe(false)
1013
+ expect(isValidEmail("")).toBe(false)
1014
+ })
1015
+
1016
+ it("validateEmail should throw InvalidEmailError for bad emails", () => {
1017
+ expect(() => validateEmail("valid@email.com")).not.toThrow()
1018
+ expect(() => validateEmail("bad")).toThrow(InvalidEmailError)
1019
+ expect(() => validateEmail("")).toThrow(InvalidEmailError)
1020
+ expect(() => validateEmail(123)).toThrow(InvalidEmailError)
1021
+ })
1022
+ })
1023
+
1024
+ // ---- authenticateRequest ----
1025
+
1026
+ describe("authenticateRequest", () => {
1027
+ let pool
1028
+ let authConfig
1029
+
1030
+ beforeAll(async () => {
1031
+ pool = await createTestDatabase()
1032
+
1033
+ authConfig = {
1034
+ db: pool,
1035
+ tablePrefix: "authreq_test_",
1036
+ }
1037
+
1038
+ await dropAuthTables(authConfig)
1039
+ await createAuthTables(authConfig)
1040
+ })
1041
+
1042
+ afterAll(async () => {
1043
+ await dropAuthTables(authConfig)
1044
+ await pool.end()
1045
+ })
1046
+
1047
+ beforeEach(async () => {
1048
+ await pool.query("DELETE FROM authreq_test_remembers")
1049
+ await pool.query("DELETE FROM authreq_test_confirmations")
1050
+ await pool.query("DELETE FROM authreq_test_accounts")
1051
+ })
1052
+
1053
+ it("should return null when no session or cookie", async () => {
1054
+ const req = { headers: {} }
1055
+ const result = await authenticateRequest(authConfig, req)
1056
+
1057
+ expect(result.account).toBeNull()
1058
+ expect(result.source).toBeNull()
1059
+ })
1060
+
1061
+ it("should authenticate via remember token cookie", async () => {
1062
+ const { default: hash } = await import("@prsm/hash")
1063
+
1064
+ await pool.query("INSERT INTO authreq_test_accounts (user_id, email, password, verified, status, rolemask) VALUES ('u1', 'auth@test.com', 'pw', true, 0, 0)")
1065
+ const account = await pool.query("SELECT id FROM authreq_test_accounts LIMIT 1")
1066
+ const accountId = account.rows[0].id
1067
+
1068
+ const token = await hash.encode("auth@test.com")
1069
+ const expires = new Date(Date.now() + 1000 * 60 * 60)
1070
+
1071
+ await pool.query("INSERT INTO authreq_test_remembers (account_id, token, expires) VALUES ($1, $2, $3)", [accountId, token, expires])
1072
+
1073
+ const req = {
1074
+ headers: { cookie: `remember_token=${encodeURIComponent(token)}` },
1075
+ }
1076
+
1077
+ const result = await authenticateRequest(authConfig, req)
1078
+
1079
+ expect(result.account).not.toBeNull()
1080
+ expect(result.account.email).toBe("auth@test.com")
1081
+ expect(result.source).toBe("remember")
1082
+ })
1083
+
1084
+ it("should reject expired remember token", async () => {
1085
+ const { default: hash } = await import("@prsm/hash")
1086
+
1087
+ await pool.query("INSERT INTO authreq_test_accounts (user_id, email, password, verified, status, rolemask) VALUES ('u2', 'expired@test.com', 'pw', true, 0, 0)")
1088
+ const account = await pool.query("SELECT id FROM authreq_test_accounts WHERE email = 'expired@test.com'")
1089
+ const accountId = account.rows[0].id
1090
+
1091
+ const token = await hash.encode("expired@test.com")
1092
+ const expires = new Date(Date.now() - 1000)
1093
+
1094
+ await pool.query("INSERT INTO authreq_test_remembers (account_id, token, expires) VALUES ($1, $2, $3)", [accountId, token, expires])
1095
+
1096
+ const req = {
1097
+ headers: { cookie: `remember_token=${encodeURIComponent(token)}` },
1098
+ }
1099
+
1100
+ const result = await authenticateRequest(authConfig, req)
1101
+ expect(result.account).toBeNull()
1102
+ })
1103
+
1104
+ it("should reject remember token for inactive account", async () => {
1105
+ const { default: hash } = await import("@prsm/hash")
1106
+
1107
+ await pool.query("INSERT INTO authreq_test_accounts (user_id, email, password, verified, status, rolemask) VALUES ('u3', 'banned@test.com', 'pw', true, $1, 0)", [AuthStatus.Banned])
1108
+ const account = await pool.query("SELECT id FROM authreq_test_accounts WHERE email = 'banned@test.com'")
1109
+ const accountId = account.rows[0].id
1110
+
1111
+ const token = await hash.encode("banned@test.com")
1112
+ const expires = new Date(Date.now() + 1000 * 60 * 60)
1113
+
1114
+ await pool.query("INSERT INTO authreq_test_remembers (account_id, token, expires) VALUES ($1, $2, $3)", [accountId, token, expires])
1115
+
1116
+ const req = {
1117
+ headers: { cookie: `remember_token=${encodeURIComponent(token)}` },
1118
+ }
1119
+
1120
+ const result = await authenticateRequest(authConfig, req)
1121
+ expect(result.account).toBeNull()
1122
+ })
1123
+ })
1124
+
1125
+ // ---- standalone role functions ----
1126
+
1127
+ describe("Standalone role functions", () => {
1128
+ let pool
1129
+ let authConfig
1130
+
1131
+ beforeAll(async () => {
1132
+ pool = await createTestDatabase()
1133
+
1134
+ authConfig = { db: pool, tablePrefix: "rolefn_test_" }
1135
+ await dropAuthTables(authConfig)
1136
+ await createAuthTables(authConfig)
1137
+ })
1138
+
1139
+ afterAll(async () => {
1140
+ await dropAuthTables(authConfig)
1141
+ await pool.end()
1142
+ })
1143
+
1144
+ beforeEach(async () => {
1145
+ await pool.query("DELETE FROM rolefn_test_accounts")
1146
+ })
1147
+
1148
+ it("should add, get, set, and remove roles", async () => {
1149
+ const { addRoleToUser, removeRoleFromUser, setUserRoles, getUserRoles } = await import("../user-roles.js")
1150
+
1151
+ await pool.query("INSERT INTO rolefn_test_accounts (user_id, email, password, verified, status, rolemask) VALUES ('u1', 'role@test.com', 'pw', true, 0, 0)")
1152
+
1153
+ await addRoleToUser(authConfig, { email: "role@test.com" }, AuthRole.Admin)
1154
+ expect(await getUserRoles(authConfig, { email: "role@test.com" })).toBe(AuthRole.Admin)
1155
+
1156
+ await addRoleToUser(authConfig, { email: "role@test.com" }, AuthRole.Editor)
1157
+ expect(await getUserRoles(authConfig, { email: "role@test.com" })).toBe(AuthRole.Admin | AuthRole.Editor)
1158
+
1159
+ await removeRoleFromUser(authConfig, { email: "role@test.com" }, AuthRole.Admin)
1160
+ expect(await getUserRoles(authConfig, { email: "role@test.com" })).toBe(AuthRole.Editor)
1161
+
1162
+ await setUserRoles(authConfig, { email: "role@test.com" }, AuthRole.Moderator | AuthRole.Reviewer)
1163
+ expect(await getUserRoles(authConfig, { email: "role@test.com" })).toBe(AuthRole.Moderator | AuthRole.Reviewer)
1164
+ })
1165
+
1166
+ it("should throw UserNotFoundError for missing user", async () => {
1167
+ const { addRoleToUser } = await import("../user-roles.js")
1168
+
1169
+ await expect(addRoleToUser(authConfig, { email: "nope@test.com" }, AuthRole.Admin)).rejects.toThrow(UserNotFoundError)
1170
+ })
1171
+ })