@openora/core 0.4.1-canary.37 → 0.4.1-canary.39

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 (259) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/admin-console/__tests__/backoffice.router.test.js +67 -51
  3. package/dist/admin-console/__tests__/backoffice.router.test.js.map +1 -1
  4. package/dist/admin-console/__tests__/backoffice.service.test.js +1 -13
  5. package/dist/admin-console/__tests__/backoffice.service.test.js.map +1 -1
  6. package/dist/analytics/__tests__/analytics.contract.test.js +12 -0
  7. package/dist/analytics/__tests__/analytics.contract.test.js.map +1 -1
  8. package/dist/analytics/__tests__/financial-analytics.service.test.js +3 -3
  9. package/dist/analytics/__tests__/financial-analytics.service.test.js.map +1 -1
  10. package/dist/analytics/service/financial-analytics.service.d.ts.map +1 -1
  11. package/dist/analytics/service/financial-analytics.service.js +14 -17
  12. package/dist/analytics/service/financial-analytics.service.js.map +1 -1
  13. package/dist/analytics/service/funnel-analytics.service.d.ts.map +1 -1
  14. package/dist/analytics/service/funnel-analytics.service.js +8 -11
  15. package/dist/analytics/service/funnel-analytics.service.js.map +1 -1
  16. package/dist/audit/__tests__/audit.service.test.js +128 -251
  17. package/dist/audit/__tests__/audit.service.test.js.map +1 -1
  18. package/dist/casino/gaming/__tests__/gaming.service.test.js +83 -89
  19. package/dist/casino/gaming/__tests__/gaming.service.test.js.map +1 -1
  20. package/dist/casino/lobby/__tests__/lobby.cache.test.js +42 -33
  21. package/dist/casino/lobby/__tests__/lobby.cache.test.js.map +1 -1
  22. package/dist/cms/__tests__/cms.reads.test.js +59 -80
  23. package/dist/cms/__tests__/cms.reads.test.js.map +1 -1
  24. package/dist/cms/__tests__/cms.router.test.js +62 -20
  25. package/dist/cms/__tests__/cms.router.test.js.map +1 -1
  26. package/dist/cms/__tests__/cms.service.test.js +116 -14
  27. package/dist/cms/__tests__/cms.service.test.js.map +1 -1
  28. package/dist/common/errors/__tests__/errors.mapper.test.d.ts +2 -0
  29. package/dist/common/errors/__tests__/errors.mapper.test.d.ts.map +1 -0
  30. package/dist/common/errors/__tests__/errors.mapper.test.js +59 -0
  31. package/dist/common/errors/__tests__/errors.mapper.test.js.map +1 -0
  32. package/dist/compliance/__tests__/assert.test.d.ts +2 -0
  33. package/dist/compliance/__tests__/assert.test.d.ts.map +1 -0
  34. package/dist/compliance/__tests__/assert.test.js +34 -0
  35. package/dist/compliance/__tests__/assert.test.js.map +1 -0
  36. package/dist/compliance/__tests__/compliance.service.test.js +149 -84
  37. package/dist/compliance/__tests__/compliance.service.test.js.map +1 -1
  38. package/dist/compliance/__tests__/kyc.service.test.js +247 -155
  39. package/dist/compliance/__tests__/kyc.service.test.js.map +1 -1
  40. package/dist/compliance/__tests__/limits.contract.test.d.ts +2 -0
  41. package/dist/compliance/__tests__/limits.contract.test.d.ts.map +1 -0
  42. package/dist/compliance/__tests__/limits.contract.test.js +43 -0
  43. package/dist/compliance/__tests__/limits.contract.test.js.map +1 -0
  44. package/dist/compliance/__tests__/rg-monitoring.service.test.js +256 -101
  45. package/dist/compliance/__tests__/rg-monitoring.service.test.js.map +1 -1
  46. package/dist/compliance/__tests__/rg.contract.test.js +1 -20
  47. package/dist/compliance/__tests__/rg.contract.test.js.map +1 -1
  48. package/dist/compliance/__tests__/rg.router.test.js +113 -65
  49. package/dist/compliance/__tests__/rg.router.test.js.map +1 -1
  50. package/dist/compliance/__tests__/rg.service.test.js +277 -235
  51. package/dist/compliance/__tests__/rg.service.test.js.map +1 -1
  52. package/dist/compliance/service/rg-monitoring.service.d.ts.map +1 -1
  53. package/dist/compliance/service/rg-monitoring.service.js +11 -8
  54. package/dist/compliance/service/rg-monitoring.service.js.map +1 -1
  55. package/dist/contracts/__tests__/compose-contract.test.d.ts +2 -0
  56. package/dist/contracts/__tests__/compose-contract.test.d.ts.map +1 -0
  57. package/dist/contracts/__tests__/compose-contract.test.js +27 -0
  58. package/dist/contracts/__tests__/compose-contract.test.js.map +1 -0
  59. package/dist/contracts/adapters/__tests__/token.test.d.ts +2 -0
  60. package/dist/contracts/adapters/__tests__/token.test.d.ts.map +1 -0
  61. package/dist/contracts/adapters/__tests__/token.test.js +14 -0
  62. package/dist/contracts/adapters/__tests__/token.test.js.map +1 -0
  63. package/dist/contracts/schemas/__tests__/events.test.d.ts +2 -0
  64. package/dist/contracts/schemas/__tests__/events.test.d.ts.map +1 -0
  65. package/dist/contracts/schemas/__tests__/events.test.js +51 -0
  66. package/dist/contracts/schemas/__tests__/events.test.js.map +1 -0
  67. package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts +2 -0
  68. package/dist/contracts/schemas/__tests__/igaming-config.test.d.ts.map +1 -0
  69. package/dist/contracts/schemas/__tests__/igaming-config.test.js +38 -0
  70. package/dist/contracts/schemas/__tests__/igaming-config.test.js.map +1 -0
  71. package/dist/contracts/schemas/reporting.d.ts +7 -2
  72. package/dist/contracts/schemas/reporting.d.ts.map +1 -1
  73. package/dist/contracts/schemas/reporting.js +2 -1
  74. package/dist/contracts/schemas/reporting.js.map +1 -1
  75. package/dist/engagement/chat/__tests__/chat.service.test.js +524 -364
  76. package/dist/engagement/chat/__tests__/chat.service.test.js.map +1 -1
  77. package/dist/engagement/notifications/__tests__/notifications.service.test.js +125 -68
  78. package/dist/engagement/notifications/__tests__/notifications.service.test.js.map +1 -1
  79. package/dist/iam/__tests__/iam.service.test.js +367 -596
  80. package/dist/iam/__tests__/iam.service.test.js.map +1 -1
  81. package/dist/pam/identity/__tests__/admin-user-directory.test.js +144 -68
  82. package/dist/pam/identity/__tests__/admin-user-directory.test.js.map +1 -1
  83. package/dist/pam/identity/__tests__/identity.rate-limit.test.js +33 -22
  84. package/dist/pam/identity/__tests__/identity.rate-limit.test.js.map +1 -1
  85. package/dist/pam/identity/__tests__/identity.service.test.js +217 -298
  86. package/dist/pam/identity/__tests__/identity.service.test.js.map +1 -1
  87. package/dist/pam/identity/__tests__/login-enforcement.service.test.js +97 -32
  88. package/dist/pam/identity/__tests__/login-enforcement.service.test.js.map +1 -1
  89. package/dist/pam/identity/__tests__/phone-login.service.test.js +163 -134
  90. package/dist/pam/identity/__tests__/phone-login.service.test.js.map +1 -1
  91. package/dist/pam/identity/__tests__/rg-guard.test.d.ts +2 -0
  92. package/dist/pam/identity/__tests__/rg-guard.test.d.ts.map +1 -0
  93. package/dist/pam/identity/__tests__/rg-guard.test.js +21 -0
  94. package/dist/pam/identity/__tests__/rg-guard.test.js.map +1 -0
  95. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js +73 -26
  96. package/dist/pam/player-management/__tests__/kyc-status-writer.test.js.map +1 -1
  97. package/dist/pam/player-management/__tests__/player.router.test.js +63 -46
  98. package/dist/pam/player-management/__tests__/player.router.test.js.map +1 -1
  99. package/dist/pam/player-management/__tests__/player.service.test.js +207 -111
  100. package/dist/pam/player-management/__tests__/player.service.test.js.map +1 -1
  101. package/dist/pam/profile/__tests__/profile.service.test.js +89 -41
  102. package/dist/pam/profile/__tests__/profile.service.test.js.map +1 -1
  103. package/dist/pam/shared/__tests__/shared-mappers.test.d.ts +2 -0
  104. package/dist/pam/shared/__tests__/shared-mappers.test.d.ts.map +1 -0
  105. package/dist/pam/shared/__tests__/shared-mappers.test.js +55 -0
  106. package/dist/pam/shared/__tests__/shared-mappers.test.js.map +1 -0
  107. package/dist/pam/tag/__tests__/get-active-tag-keys.test.js +72 -30
  108. package/dist/pam/tag/__tests__/get-active-tag-keys.test.js.map +1 -1
  109. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js +308 -235
  110. package/dist/pam/tag/__tests__/tag-evaluation.service.test.js.map +1 -1
  111. package/dist/pam/tag/__tests__/tag-rule.service.test.js +136 -140
  112. package/dist/pam/tag/__tests__/tag-rule.service.test.js.map +1 -1
  113. package/dist/pam/tag/__tests__/tag.router.test.js +76 -25
  114. package/dist/pam/tag/__tests__/tag.router.test.js.map +1 -1
  115. package/dist/pam/tag/__tests__/tag.service.test.js +224 -247
  116. package/dist/pam/tag/__tests__/tag.service.test.js.map +1 -1
  117. package/dist/server/auth/__tests__/admin-guard.test.js +107 -117
  118. package/dist/server/auth/__tests__/admin-guard.test.js.map +1 -1
  119. package/dist/server/auth/__tests__/permission-levels.test.d.ts +2 -0
  120. package/dist/server/auth/__tests__/permission-levels.test.d.ts.map +1 -0
  121. package/dist/server/auth/__tests__/permission-levels.test.js +89 -0
  122. package/dist/server/auth/__tests__/permission-levels.test.js.map +1 -0
  123. package/dist/server/auth/__tests__/session-resolver.test.js +45 -13
  124. package/dist/server/auth/__tests__/session-resolver.test.js.map +1 -1
  125. package/dist/server/db/__tests__/query-helpers.test.js +72 -1
  126. package/dist/server/db/__tests__/query-helpers.test.js.map +1 -1
  127. package/dist/server/db/outbox/__tests__/relay.test.js +74 -81
  128. package/dist/server/db/outbox/__tests__/relay.test.js.map +1 -1
  129. package/dist/server/kernel/__tests__/bullmq-job-queue.test.js +204 -154
  130. package/dist/server/kernel/__tests__/bullmq-job-queue.test.js.map +1 -1
  131. package/dist/server/kernel/__tests__/cache.test.js +26 -13
  132. package/dist/server/kernel/__tests__/cache.test.js.map +1 -1
  133. package/dist/server/kernel/__tests__/domain-error.test.d.ts +2 -0
  134. package/dist/server/kernel/__tests__/domain-error.test.d.ts.map +1 -0
  135. package/dist/server/kernel/__tests__/domain-error.test.js +35 -0
  136. package/dist/server/kernel/__tests__/domain-error.test.js.map +1 -0
  137. package/dist/server/kernel/__tests__/error-reporter.test.d.ts +2 -0
  138. package/dist/server/kernel/__tests__/error-reporter.test.d.ts.map +1 -0
  139. package/dist/server/kernel/__tests__/error-reporter.test.js +52 -0
  140. package/dist/server/kernel/__tests__/error-reporter.test.js.map +1 -0
  141. package/dist/server/kernel/__tests__/event-bus.test.js +60 -20
  142. package/dist/server/kernel/__tests__/event-bus.test.js.map +1 -1
  143. package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts +2 -0
  144. package/dist/server/kernel/__tests__/orpc-error-map.test.d.ts.map +1 -0
  145. package/dist/server/kernel/__tests__/orpc-error-map.test.js +54 -0
  146. package/dist/server/kernel/__tests__/orpc-error-map.test.js.map +1 -0
  147. package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts +2 -0
  148. package/dist/server/kernel/__tests__/platform-config-loader.test.d.ts.map +1 -0
  149. package/dist/server/kernel/__tests__/platform-config-loader.test.js +112 -0
  150. package/dist/server/kernel/__tests__/platform-config-loader.test.js.map +1 -0
  151. package/dist/server/kernel/__tests__/rate-limiter.test.js +22 -13
  152. package/dist/server/kernel/__tests__/rate-limiter.test.js.map +1 -1
  153. package/dist/server/kernel/__tests__/realtime-transport.test.d.ts.map +1 -0
  154. package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js.map +1 -1
  155. package/dist/server/kernel/__tests__/redis-cache.test.js +34 -34
  156. package/dist/server/kernel/__tests__/redis-cache.test.js.map +1 -1
  157. package/dist/server/kernel/__tests__/redis-rate-limiter.test.js +49 -44
  158. package/dist/server/kernel/__tests__/redis-rate-limiter.test.js.map +1 -1
  159. package/dist/server/kernel/__tests__/redis-streams-broker.test.js +141 -262
  160. package/dist/server/kernel/__tests__/redis-streams-broker.test.js.map +1 -1
  161. package/dist/server/kernel/__tests__/request-context.test.d.ts +2 -0
  162. package/dist/server/kernel/__tests__/request-context.test.d.ts.map +1 -0
  163. package/dist/server/kernel/__tests__/request-context.test.js +64 -0
  164. package/dist/server/kernel/__tests__/request-context.test.js.map +1 -0
  165. package/dist/server/kernel/__tests__/router-utils.test.d.ts +2 -0
  166. package/dist/server/kernel/__tests__/router-utils.test.d.ts.map +1 -0
  167. package/dist/server/kernel/__tests__/router-utils.test.js +80 -0
  168. package/dist/server/kernel/__tests__/router-utils.test.js.map +1 -0
  169. package/dist/server/kernel/bullmq-job-queue.d.ts.map +1 -1
  170. package/dist/server/kernel/bullmq-job-queue.js +8 -2
  171. package/dist/server/kernel/bullmq-job-queue.js.map +1 -1
  172. package/dist/server/runtime/__tests__/create-app.test.js +26 -18
  173. package/dist/server/runtime/__tests__/create-app.test.js.map +1 -1
  174. package/dist/testing/index.d.ts +2 -6
  175. package/dist/testing/index.d.ts.map +1 -1
  176. package/dist/testing/index.js +2 -10
  177. package/dist/testing/index.js.map +1 -1
  178. package/dist/testing/mock.d.ts +28 -16
  179. package/dist/testing/mock.d.ts.map +1 -1
  180. package/dist/testing/mock.js +58 -37
  181. package/dist/testing/mock.js.map +1 -1
  182. package/dist/testing/real-infra.d.ts +47 -0
  183. package/dist/testing/real-infra.d.ts.map +1 -0
  184. package/dist/testing/real-infra.js +122 -0
  185. package/dist/testing/real-infra.js.map +1 -0
  186. package/dist/wallet/__tests__/admin-reporting.test.js +201 -90
  187. package/dist/wallet/__tests__/admin-reporting.test.js.map +1 -1
  188. package/dist/wallet/__tests__/rail-for.test.d.ts +2 -0
  189. package/dist/wallet/__tests__/rail-for.test.d.ts.map +1 -0
  190. package/dist/wallet/__tests__/rail-for.test.js +22 -0
  191. package/dist/wallet/__tests__/rail-for.test.js.map +1 -0
  192. package/dist/wallet/__tests__/wallet-auto-rule.router.test.js +82 -63
  193. package/dist/wallet/__tests__/wallet-auto-rule.router.test.js.map +1 -1
  194. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js +414 -437
  195. package/dist/wallet/__tests__/wallet-auto-withdrawal.test.js.map +1 -1
  196. package/dist/wallet/__tests__/wallet-commands.service.test.js +116 -155
  197. package/dist/wallet/__tests__/wallet-commands.service.test.js.map +1 -1
  198. package/dist/wallet/__tests__/wallet-kyc-gate.test.js +63 -42
  199. package/dist/wallet/__tests__/wallet-kyc-gate.test.js.map +1 -1
  200. package/dist/wallet/__tests__/wallet-webhook.router.test.js +129 -51
  201. package/dist/wallet/__tests__/wallet-webhook.router.test.js.map +1 -1
  202. package/dist/wallet/__tests__/wallet.rate-limit.test.js +74 -21
  203. package/dist/wallet/__tests__/wallet.rate-limit.test.js.map +1 -1
  204. package/dist/wallet/__tests__/wallet.router.test.js +56 -36
  205. package/dist/wallet/__tests__/wallet.router.test.js.map +1 -1
  206. package/dist/wallet/__tests__/wallet.service.test.js +623 -887
  207. package/dist/wallet/__tests__/wallet.service.test.js.map +1 -1
  208. package/package.json +1 -2
  209. package/dist/casino/lobby/__tests__/lobby.service.test.d.ts +0 -2
  210. package/dist/casino/lobby/__tests__/lobby.service.test.d.ts.map +0 -1
  211. package/dist/casino/lobby/__tests__/lobby.service.test.js +0 -11
  212. package/dist/casino/lobby/__tests__/lobby.service.test.js.map +0 -1
  213. package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts +0 -2
  214. package/dist/contracts/schemas/__tests__/identity-schemas.test.d.ts.map +0 -1
  215. package/dist/contracts/schemas/__tests__/identity-schemas.test.js +0 -43
  216. package/dist/contracts/schemas/__tests__/identity-schemas.test.js.map +0 -1
  217. package/dist/testing/fakes/__tests__/cache.test.d.ts +0 -2
  218. package/dist/testing/fakes/__tests__/cache.test.d.ts.map +0 -1
  219. package/dist/testing/fakes/__tests__/cache.test.js +0 -89
  220. package/dist/testing/fakes/__tests__/cache.test.js.map +0 -1
  221. package/dist/testing/fakes/__tests__/job-queue.test.d.ts +0 -2
  222. package/dist/testing/fakes/__tests__/job-queue.test.d.ts.map +0 -1
  223. package/dist/testing/fakes/__tests__/job-queue.test.js +0 -135
  224. package/dist/testing/fakes/__tests__/job-queue.test.js.map +0 -1
  225. package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts +0 -2
  226. package/dist/testing/fakes/__tests__/rate-limiter.test.d.ts.map +0 -1
  227. package/dist/testing/fakes/__tests__/rate-limiter.test.js +0 -83
  228. package/dist/testing/fakes/__tests__/rate-limiter.test.js.map +0 -1
  229. package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts +0 -2
  230. package/dist/testing/fakes/__tests__/realtime-authorizer.test.d.ts.map +0 -1
  231. package/dist/testing/fakes/__tests__/realtime-authorizer.test.js +0 -19
  232. package/dist/testing/fakes/__tests__/realtime-authorizer.test.js.map +0 -1
  233. package/dist/testing/fakes/__tests__/realtime-transport.test.d.ts.map +0 -1
  234. package/dist/testing/fakes/cache.d.ts +0 -14
  235. package/dist/testing/fakes/cache.d.ts.map +0 -1
  236. package/dist/testing/fakes/cache.js +0 -56
  237. package/dist/testing/fakes/cache.js.map +0 -1
  238. package/dist/testing/fakes/event-bus.d.ts +0 -17
  239. package/dist/testing/fakes/event-bus.d.ts.map +0 -1
  240. package/dist/testing/fakes/event-bus.js +0 -30
  241. package/dist/testing/fakes/event-bus.js.map +0 -1
  242. package/dist/testing/fakes/job-queue.d.ts +0 -40
  243. package/dist/testing/fakes/job-queue.d.ts.map +0 -1
  244. package/dist/testing/fakes/job-queue.js +0 -200
  245. package/dist/testing/fakes/job-queue.js.map +0 -1
  246. package/dist/testing/fakes/rate-limiter.d.ts +0 -11
  247. package/dist/testing/fakes/rate-limiter.d.ts.map +0 -1
  248. package/dist/testing/fakes/rate-limiter.js +0 -39
  249. package/dist/testing/fakes/rate-limiter.js.map +0 -1
  250. package/dist/testing/fakes/realtime-authorizer.d.ts +0 -10
  251. package/dist/testing/fakes/realtime-authorizer.d.ts.map +0 -1
  252. package/dist/testing/fakes/realtime-authorizer.js +0 -10
  253. package/dist/testing/fakes/realtime-authorizer.js.map +0 -1
  254. package/dist/testing/fakes/realtime-transport.d.ts +0 -2
  255. package/dist/testing/fakes/realtime-transport.d.ts.map +0 -1
  256. package/dist/testing/fakes/realtime-transport.js +0 -2
  257. package/dist/testing/fakes/realtime-transport.js.map +0 -1
  258. /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.d.ts +0 -0
  259. /package/dist/{testing/fakes → server/kernel}/__tests__/realtime-transport.test.js +0 -0
@@ -1,9 +1,13 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
3
+ import { eq, sql } from 'drizzle-orm';
4
+ import { ORPCError } from '@orpc/server';
5
+ import { createTestDb, createTestRedis } from '@openora/core/testing';
6
+ import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
2
7
  import { IdentityService, SESSION_DURATION_IN_SECONDS, } from '../service/identity.service.js';
3
8
  import { UnsupportedLanguageError } from '../../shared/language.js';
4
- import { ORPCError } from '@orpc/server';
5
- import { mock } from '../../../testing/mock.js';
6
- import { InProcessCache } from '../../../testing/fakes/cache.js';
9
+ import { user, session } from '../schema/index.js';
10
+ import { mock, makeEventBus } from '../../../testing/mock.js';
7
11
  const { signInEmailMock, getSessionMock, updateUserMock, requestPasswordResetEmailOTPMock, checkVerificationOTPMock, resetPasswordEmailOTPMock, capturedAuthOptions, } = vi.hoisted(() => ({
8
12
  signInEmailMock: vi.fn(),
9
13
  getSessionMock: vi.fn().mockResolvedValue(null),
@@ -38,35 +42,23 @@ vi.mock('@openora/core/server', async (importOriginal) => ({
38
42
  };
39
43
  }),
40
44
  }));
41
- function makeDrizzle({ selectRows = [], updateReturning = [] } = {}) {
42
- const selectQueue = [...selectRows];
43
- const returningQueue = [...updateReturning];
44
- const update = vi.fn(() => ({
45
- set: () => ({
46
- where: () => Object.assign(Promise.resolve(undefined), {
47
- returning: () => Promise.resolve(returningQueue.shift() ?? []),
48
- }),
49
- }),
50
- }));
51
- const select = vi.fn(() => ({
52
- from: () => ({ where: () => ({ limit: () => Promise.resolve(selectQueue.shift() ?? []) }) }),
53
- }));
54
- return mock({
55
- db: { select, update },
56
- update,
57
- select,
58
- });
59
- }
60
- function makeEvents() {
61
- return mock({ emit: vi.fn(), on: vi.fn() });
62
- }
63
- // createAuth() is mocked below, so the renderer is never actually invoked - this fake only
64
- // satisfies IdentityService's required `templateRenderer` dependency at construction time.
45
+ const { RedisCache } = await import('@openora/core/server');
46
+ let db;
47
+ let redis;
48
+ const allowLimiter = () => mock({
49
+ consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }),
50
+ reset: vi.fn().mockResolvedValue(undefined),
51
+ });
65
52
  const testTemplateRenderer = {
66
53
  render: () => ({ subject: 'subject', body: 'body' }),
67
54
  };
68
- function withTemplateRenderer(deps) {
69
- return new IdentityService({ templateRenderer: testTemplateRenderer, ...deps });
55
+ function buildService(deps = {}) {
56
+ return new IdentityService({
57
+ templateRenderer: testTemplateRenderer,
58
+ drizzle: db.drizzle,
59
+ events: makeEventBus(),
60
+ ...deps,
61
+ });
70
62
  }
71
63
  function jsonResponse(body, status) {
72
64
  return new Response(JSON.stringify(body), {
@@ -82,72 +74,95 @@ const betterAuthUser = {
82
74
  createdAt: '2020-01-01T00:00:00.000Z',
83
75
  updatedAt: '2020-01-01T00:00:00.000Z',
84
76
  };
85
- beforeEach(() => {
77
+ const EMAIL = 'a@b.dev';
78
+ async function seedUser(over = {}) {
79
+ const [row] = await db.drizzle.db
80
+ .insert(user)
81
+ .values({ name: 'A', email: EMAIL, emailVerified: true, ...over })
82
+ .returning();
83
+ return row;
84
+ }
85
+ async function readUser(userId) {
86
+ const [row] = await db.drizzle.db.select().from(user).where(eq(user.id, userId));
87
+ return row;
88
+ }
89
+ const realCache = () => new RedisCache(redis.client);
90
+ const signInSuccess = (userId) => jsonResponse({
91
+ user: { ...betterAuthUser, id: userId },
92
+ token: 'tok',
93
+ session: { expiresAt: '2020-02-01T00:00:00.000Z' },
94
+ }, 200);
95
+ beforeAll(async () => {
96
+ db = await createTestDb([migrateIdentity]);
97
+ redis = await createTestRedis();
98
+ });
99
+ afterAll(async () => {
100
+ await db.drop();
101
+ await redis.quit();
102
+ });
103
+ beforeEach(async () => {
86
104
  vi.clearAllMocks();
87
105
  getSessionMock.mockResolvedValue(null);
106
+ await db.drizzle.db.execute(sql `TRUNCATE ${user}, ${session} RESTART IDENTITY CASCADE`);
107
+ await redis.flush();
88
108
  });
89
- describe('IdentityService - SendEmailPort seam', () => {
90
- it('constructs without a SendEmailPort (email delivery silently skipped)', () => {
91
- const svc = withTemplateRenderer({
92
- drizzle: makeDrizzle(),
93
- events: makeEvents(),
94
- });
95
- expect(svc).toBeInstanceOf(IdentityService);
96
- });
97
- it('constructs with a stub SendEmailPort and exposes it for use', () => {
98
- const stub = { send: vi.fn().mockResolvedValue(undefined) };
99
- const svc = withTemplateRenderer({
100
- drizzle: makeDrizzle(),
101
- events: makeEvents(),
102
- email: stub,
103
- });
104
- expect(svc).toBeInstanceOf(IdentityService);
105
- });
106
- it('me returns null when no session exists', async () => {
107
- const svc = withTemplateRenderer({
108
- drizzle: makeDrizzle(),
109
- events: makeEvents(),
110
- });
111
- const result = await svc.me({});
112
- expect(result).toBeNull();
109
+ describe('IdentityService.me', () => {
110
+ it('returns null when no session exists', async () => {
111
+ expect(await buildService().me({})).toBeNull();
113
112
  });
114
113
  });
115
- describe('IdentityService - login lockout', () => {
114
+ describe('IdentityService - login lockout (real PG + real Redis)', () => {
116
115
  it('locks the account once failed attempts reach the threshold and emits lockout.triggered', async () => {
117
- const drizzle = makeDrizzle({
118
- selectRows: [[{ id: 'u1', failedLoginAttempts: 4, lockoutUntil: null }]],
119
- updateReturning: [[{ failedLoginAttempts: 5 }]],
120
- });
121
- const events = makeEvents();
116
+ const account = await seedUser({ failedLoginAttempts: 4 });
117
+ const events = makeEventBus();
118
+ const limiter = allowLimiter();
122
119
  signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
123
- const limiter = mock({
124
- consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }),
125
- reset: vi.fn().mockResolvedValue(undefined),
126
- });
127
- const svc = withTemplateRenderer({ drizzle: drizzle, events: events, limiter });
120
+ const svc = buildService({ events, limiter });
128
121
  await expect(svc.login({ email: 'A@B.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
129
- expect(events.emit).toHaveBeenCalledWith('identity.user.lockout.triggered', expect.objectContaining({ userId: 'u1', email: 'a@b.dev' }));
130
- expect(limiter.reset).toHaveBeenCalledWith('login:a@b.dev');
122
+ const row = await readUser(account.id);
123
+ expect(row.failedLoginAttempts).toBe(5);
124
+ expect(row.lockoutUntil?.getTime()).toBeGreaterThan(Date.now());
125
+ expect(row.lockoutCount).toBe(1);
126
+ expect(events.emit).toHaveBeenCalledWith('identity.user.lockout.triggered', expect.objectContaining({ userId: account.id, email: EMAIL }));
127
+ expect(limiter.reset).toHaveBeenCalledWith(`login:${EMAIL}`);
131
128
  });
132
- it('emits login.failed (not lockout) while still below the threshold', async () => {
133
- const drizzle = makeDrizzle({
134
- selectRows: [[{ id: 'u1', failedLoginAttempts: 0, lockoutUntil: null }]],
135
- updateReturning: [[{ failedLoginAttempts: 1 }]],
129
+ it('escalates the lockout duration on a repeat lockout inside the 24h window', async () => {
130
+ const account = await seedUser({
131
+ failedLoginAttempts: 4,
132
+ lockoutCount: 1,
133
+ lastLockoutAt: new Date(Date.now() - 60_000),
136
134
  });
137
- const events = makeEvents();
138
135
  signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
139
- const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
140
- await expect(svc.login({ email: 'a@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
141
- expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: 'a@b.dev', reason: 'invalid_credentials' }));
136
+ const svc = buildService();
137
+ await expect(svc.login({ email: EMAIL, password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
138
+ const row = await readUser(account.id);
139
+ expect(row.lockoutCount).toBe(2);
140
+ expect(row.lockoutUntil?.getTime()).toBeGreaterThan(Date.now() + 60_000);
141
+ });
142
+ it('emits login.failed (not lockout) while still below the threshold', async () => {
143
+ const account = await seedUser();
144
+ const events = makeEventBus();
145
+ signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
146
+ const svc = buildService({ events });
147
+ await expect(svc.login({ email: EMAIL, password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
148
+ const row = await readUser(account.id);
149
+ expect(row.failedLoginAttempts).toBe(1);
150
+ expect(row.lockoutUntil).toBeNull();
151
+ expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: EMAIL, reason: 'invalid_credentials' }));
142
152
  expect(events.emit).not.toHaveBeenCalledWith('identity.user.lockout.triggered', expect.anything());
143
153
  });
154
+ it('counts concurrent credential failures exactly once each (atomic SQL increment)', async () => {
155
+ const account = await seedUser();
156
+ signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
157
+ const svc = buildService();
158
+ await Promise.allSettled(Array.from({ length: 3 }, () => svc.login({ email: EMAIL, password: 'wrongpass1' }, {}, new Headers())));
159
+ expect((await readUser(account.id)).failedLoginAttempts).toBe(3);
160
+ });
144
161
  it('rejects a currently-locked account before attempting sign-in', async () => {
145
162
  const future = new Date(Date.now() + 60_000);
146
- const drizzle = makeDrizzle({
147
- selectRows: [[{ id: 'u1', failedLoginAttempts: 5, lockoutUntil: future }]],
148
- });
149
- const svc = withTemplateRenderer({ drizzle: drizzle, events: makeEvents() });
150
- const promise = svc.login({ email: 'a@b.dev', password: 'whatever1' }, {}, new Headers());
163
+ await seedUser({ failedLoginAttempts: 5, lockoutUntil: future });
164
+ const svc = buildService();
165
+ const promise = svc.login({ email: EMAIL, password: 'whatever1' }, {}, new Headers());
151
166
  await expect(promise).rejects.toThrow(ORPCError);
152
167
  await expect(promise).rejects.toMatchObject({
153
168
  code: 'UNAUTHORIZED',
@@ -160,74 +175,57 @@ describe('IdentityService - login lockout', () => {
160
175
  expect(signInEmailMock).not.toHaveBeenCalled();
161
176
  });
162
177
  it('clears the counter and emits login on success', async () => {
163
- const drizzle = makeDrizzle({
164
- selectRows: [[{ id: 'u1', failedLoginAttempts: 2, lockoutUntil: null }]],
165
- });
166
- const events = makeEvents();
167
- signInEmailMock.mockResolvedValue(jsonResponse({ user: betterAuthUser, token: 'tok', session: { expiresAt: '2020-02-01T00:00:00.000Z' } }, 200));
168
- const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
169
- const result = await svc.login({ email: 'a@b.dev', password: 'rightpass1' }, {}, new Headers());
178
+ const account = await seedUser({ failedLoginAttempts: 2 });
179
+ const events = makeEventBus();
180
+ signInEmailMock.mockResolvedValue(signInSuccess(account.id));
181
+ const svc = buildService({ events });
182
+ const result = await svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers());
170
183
  expect(result).toMatchObject({ session: { token: 'tok' } });
171
- expect(events.emit).toHaveBeenCalledWith('identity.user.login', expect.objectContaining({ userId: 'u1' }));
172
- expect(drizzle.update).toHaveBeenCalled();
184
+ expect((await readUser(account.id)).failedLoginAttempts).toBe(0);
185
+ expect(events.emit).toHaveBeenCalledWith('identity.user.login', expect.objectContaining({ userId: account.id }));
173
186
  });
174
187
  it('bypasses lockout for admins if configured with bypassForAdmins: true', async () => {
175
- const drizzle = makeDrizzle({
176
- selectRows: [[{ id: 'u1', failedLoginAttempts: 4, lockoutUntil: null, role: 'admin' }]],
188
+ const account = await seedUser({
189
+ email: 'admin@b.dev',
190
+ role: 'admin',
191
+ failedLoginAttempts: 4,
177
192
  });
178
- const events = makeEvents();
193
+ const events = makeEventBus();
179
194
  signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
180
- const svc = withTemplateRenderer({
181
- drizzle: drizzle,
182
- events: events,
183
- options: {
184
- lockout: {
185
- enabled: true,
186
- bypassForAdmins: true,
187
- },
188
- },
195
+ const svc = buildService({
196
+ events,
197
+ options: { lockout: { enabled: true, bypassForAdmins: true } },
189
198
  });
190
199
  await expect(svc.login({ email: 'admin@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
191
- // It should not increment the failed attempts or trigger lockout
192
- expect(drizzle.update).not.toHaveBeenCalled();
200
+ const row = await readUser(account.id);
201
+ expect(row.failedLoginAttempts).toBe(4);
202
+ expect(row.lockoutUntil).toBeNull();
193
203
  expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: 'admin@b.dev' }));
194
204
  expect(events.emit).not.toHaveBeenCalledWith('identity.user.lockout.triggered', expect.anything());
195
205
  });
196
206
  it('does not bypass lockout for other backoffice roles if bypassForAdmins is true', async () => {
197
- const drizzle = makeDrizzle({
198
- selectRows: [
199
- [{ id: 'u1', failedLoginAttempts: 4, lockoutUntil: null, role: 'support' }],
200
- [{ failedLoginAttempts: 5 }],
201
- ],
207
+ const account = await seedUser({
208
+ email: 'support@b.dev',
209
+ role: 'support',
210
+ failedLoginAttempts: 4,
202
211
  });
203
- const events = makeEvents();
212
+ const events = makeEventBus();
204
213
  signInEmailMock.mockResolvedValue(jsonResponse({ message: 'Invalid' }, 401));
205
- const svc = withTemplateRenderer({
206
- drizzle: drizzle,
207
- events: events,
208
- options: {
209
- lockout: {
210
- enabled: true,
211
- bypassForAdmins: true,
212
- maxAttempts: 5,
213
- },
214
- },
214
+ const svc = buildService({
215
+ events,
216
+ options: { lockout: { enabled: true, bypassForAdmins: true, maxAttempts: 5 } },
215
217
  });
216
218
  await expect(svc.login({ email: 'support@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
217
- // It should increment the failed attempts and trigger lockout
218
- expect(drizzle.update).toHaveBeenCalled();
219
- expect(events.emit).toHaveBeenCalledWith('identity.user.lockout.triggered', expect.objectContaining({ userId: 'u1', email: 'support@b.dev' }));
219
+ const row = await readUser(account.id);
220
+ expect(row.failedLoginAttempts).toBe(5);
221
+ expect(row.lockoutUntil?.getTime()).toBeGreaterThan(Date.now());
222
+ expect(events.emit).toHaveBeenCalledWith('identity.user.lockout.triggered', expect.objectContaining({ userId: account.id, email: 'support@b.dev' }));
220
223
  });
221
224
  it('anti-enumeration: locks a nonexistent email once failed attempts reach the threshold, mirroring a real account, without emitting lockout.triggered', async () => {
222
- const drizzle = makeDrizzle({ selectRows: [[], [], [], [], []] });
223
- const events = makeEvents();
225
+ const events = makeEventBus();
226
+ const limiter = allowLimiter();
224
227
  signInEmailMock.mockImplementation(async () => jsonResponse({ message: 'Invalid' }, 401));
225
- const limiter = mock({
226
- consume: vi.fn().mockResolvedValue({ allowed: true, retryAfterMs: 0 }),
227
- reset: vi.fn().mockResolvedValue(undefined),
228
- });
229
- const cache = new InProcessCache();
230
- const svc = withTemplateRenderer({ drizzle, events, limiter, cache });
228
+ const svc = buildService({ events, limiter, cache: realCache() });
231
229
  for (let i = 0; i < 4; i++) {
232
230
  await expect(svc.login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toMatchObject({ code: 'UNAUTHORIZED', message: 'Invalid' });
233
231
  }
@@ -238,11 +236,18 @@ describe('IdentityService - login lockout', () => {
238
236
  expect(limiter.reset).toHaveBeenCalledWith('login:nobody@b.dev');
239
237
  expect(events.emit).not.toHaveBeenCalledWith('identity.user.lockout.triggered', expect.anything());
240
238
  });
239
+ it('anti-enumeration: the shadow lockout survives a fresh service instance (state lives in Redis, not memory)', async () => {
240
+ signInEmailMock.mockImplementation(async () => jsonResponse({ message: 'Invalid' }, 401));
241
+ for (let i = 0; i < 5; i++) {
242
+ const svc = buildService({ cache: realCache() });
243
+ await expect(svc.login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toThrow();
244
+ }
245
+ await expect(buildService({ cache: realCache() }).login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toMatchObject({ data: { code: 'ACCOUNT_LOCKED' } });
246
+ });
241
247
  it('never locks a nonexistent email when cache is not provided (degrades to the pre-mirroring behavior)', async () => {
242
- const drizzle = makeDrizzle({ selectRows: [[], [], [], [], [], []] });
243
- const events = makeEvents();
248
+ const events = makeEventBus();
244
249
  signInEmailMock.mockImplementation(async () => jsonResponse({ message: 'Invalid' }, 401));
245
- const svc = withTemplateRenderer({ drizzle, events });
250
+ const svc = buildService({ events });
246
251
  for (let i = 0; i < 6; i++) {
247
252
  await expect(svc.login({ email: 'nobody@b.dev', password: 'wrongpass1' }, {}, new Headers())).rejects.toMatchObject({ code: 'UNAUTHORIZED', message: 'Invalid' });
248
253
  }
@@ -251,178 +256,107 @@ describe('IdentityService - login lockout', () => {
251
256
  });
252
257
  describe('IdentityService - login banned-user 403 (not the RG block)', () => {
253
258
  it('surfaces a banned-user 403 from signInEmail as FORBIDDEN and still emits login.failed', async () => {
254
- const drizzle = makeDrizzle({
255
- selectRows: [
256
- [
257
- {
258
- id: 'u1',
259
- failedLoginAttempts: 0,
260
- lockoutUntil: null,
261
- rgBlocked: false,
262
- rgBlockedUntil: null,
263
- },
264
- ],
265
- ],
266
- });
267
- const events = makeEvents();
259
+ await seedUser();
260
+ const events = makeEventBus();
268
261
  signInEmailMock.mockResolvedValue(jsonResponse({ message: 'BANNED_USER' }, 403));
269
- const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
270
- await expect(svc.login({ email: 'a@b.dev', password: 'whatever1' }, {}, new Headers())).rejects.toMatchObject({ code: 'FORBIDDEN' });
271
- expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: 'a@b.dev', reason: 'error' }));
262
+ const svc = buildService({ events });
263
+ await expect(svc.login({ email: EMAIL, password: 'whatever1' }, {}, new Headers())).rejects.toMatchObject({ code: 'FORBIDDEN' });
264
+ expect(events.emit).toHaveBeenCalledWith('identity.user.login.failed', expect.objectContaining({ email: EMAIL, reason: 'error' }));
272
265
  expect(events.emit).not.toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.anything());
273
266
  });
274
267
  });
275
- describe('IdentityService - RG login gate', () => {
276
- it('blocks a self-excluded login AFTER credentials verify and emits rg.exclusion.login_blocked', async () => {
277
- const drizzle = makeDrizzle({
278
- selectRows: [
279
- [
280
- {
281
- id: 'u1',
282
- failedLoginAttempts: 0,
283
- lockoutUntil: null,
284
- rgBlocked: true,
285
- rgBlockedUntil: null,
286
- },
287
- ],
288
- ],
289
- });
290
- const events = makeEvents();
268
+ describe('IdentityService - RG login gate (real PG)', () => {
269
+ it('blocks a self-excluded login AFTER credentials verify, expires the issued session, and emits rg.exclusion.login_blocked', async () => {
270
+ const account = await seedUser({ rgBlocked: true, rgBlockedUntil: null });
271
+ const live = new Date(Date.now() + 24 * 60 * 60 * 1000);
272
+ await db.drizzle.db
273
+ .insert(session)
274
+ .values({ userId: account.id, token: randomUUID(), expiresAt: live });
275
+ const events = makeEventBus();
291
276
  // Valid credentials (200) - the gate must still block.
292
- signInEmailMock.mockResolvedValue(jsonResponse({ user: betterAuthUser, token: 'tok', session: {} }, 200));
293
- const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
294
- await expect(svc.login({ email: 'a@b.dev', password: 'rightpass1' }, {}, new Headers())).rejects.toMatchObject({ code: 'FORBIDDEN' });
277
+ signInEmailMock.mockResolvedValue(signInSuccess(account.id));
278
+ const svc = buildService({ events });
279
+ await expect(svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers())).rejects.toMatchObject({ code: 'FORBIDDEN' });
295
280
  expect(signInEmailMock).toHaveBeenCalled();
296
- expect(events.emit).toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.objectContaining({ userId: 'u1' }));
281
+ expect(events.emit).toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.objectContaining({ userId: account.id }));
297
282
  expect(events.emit).not.toHaveBeenCalledWith('identity.user.login', expect.anything());
298
283
  expect(events.emit).not.toHaveBeenCalledWith('identity.user.login.failed', expect.anything());
299
- // The just-issued session is revoked.
300
- expect(drizzle.update).toHaveBeenCalled();
284
+ const [revoked] = await db.drizzle.db
285
+ .select()
286
+ .from(session)
287
+ .where(eq(session.userId, account.id));
288
+ expect(revoked.expiresAt.getTime()).toBeLessThanOrEqual(Date.now());
301
289
  });
302
290
  it('allows login once a lapsed cooling-off block has elapsed', async () => {
303
- const past = new Date(Date.now() - 60_000);
304
- const drizzle = makeDrizzle({
305
- selectRows: [
306
- [
307
- {
308
- id: 'u1',
309
- failedLoginAttempts: 0,
310
- lockoutUntil: null,
311
- rgBlocked: true,
312
- rgBlockedUntil: past,
313
- },
314
- ],
315
- ],
316
- });
317
- const events = makeEvents();
318
- signInEmailMock.mockResolvedValue(jsonResponse({ user: betterAuthUser, token: 'tok', session: {} }, 200));
319
- const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
320
- const result = await svc.login({ email: 'a@b.dev', password: 'rightpass1' }, {}, new Headers());
291
+ const account = await seedUser({
292
+ rgBlocked: true,
293
+ rgBlockedUntil: new Date(Date.now() - 60_000),
294
+ });
295
+ const events = makeEventBus();
296
+ signInEmailMock.mockResolvedValue(signInSuccess(account.id));
297
+ const svc = buildService({ events });
298
+ const result = await svc.login({ email: EMAIL, password: 'rightpass1' }, {}, new Headers());
321
299
  expect(result).toMatchObject({ session: { token: 'tok' } });
322
- expect(events.emit).toHaveBeenCalledWith('identity.user.login', expect.objectContaining({ userId: 'u1' }));
300
+ expect(events.emit).toHaveBeenCalledWith('identity.user.login', expect.objectContaining({ userId: account.id }));
323
301
  expect(events.emit).not.toHaveBeenCalledWith('rg.exclusion.login_blocked', expect.anything());
324
302
  });
325
303
  });
326
- describe('IdentityService - unlockUser', () => {
327
- it('clears the lockout and emits unlocked with the prior state', async () => {
328
- const drizzle = makeDrizzle({
329
- selectRows: [
330
- [
331
- {
332
- id: 'u1',
333
- email: 'a@b.dev',
334
- failedLoginAttempts: 5,
335
- lockoutUntil: new Date('2020-01-01T00:00:00.000Z'),
336
- },
337
- ],
338
- ],
339
- });
340
- const events = makeEvents();
341
- const svc = withTemplateRenderer({ drizzle: drizzle, events: events });
342
- const res = await svc.unlockUser('u1', 'admin1');
304
+ describe('IdentityService.unlockUser (real PG)', () => {
305
+ it('clears the lockout row and emits unlocked with the prior state', async () => {
306
+ const lockedUntil = new Date('2020-01-01T00:00:00.000Z');
307
+ const account = await seedUser({ failedLoginAttempts: 5, lockoutUntil: lockedUntil });
308
+ const events = makeEventBus();
309
+ const svc = buildService({ events });
310
+ const res = await svc.unlockUser(account.id, 'admin1');
343
311
  expect(res).toEqual({ success: true });
344
- expect(drizzle.update).toHaveBeenCalled();
312
+ const row = await readUser(account.id);
313
+ expect(row.failedLoginAttempts).toBe(0);
314
+ expect(row.lockoutUntil).toBeNull();
345
315
  expect(events.emit).toHaveBeenCalledWith('identity.user.unlocked', expect.objectContaining({
346
- userId: 'u1',
316
+ userId: account.id,
347
317
  actorId: 'admin1',
348
318
  previousFailedAttempts: 5,
349
- previousLockoutUntil: '2020-01-01T00:00:00.000Z',
319
+ previousLockoutUntil: lockedUntil.toISOString(),
350
320
  }));
351
321
  });
352
322
  it('throws when the user does not exist', async () => {
353
- const drizzle = makeDrizzle({ selectRows: [[]] });
354
- const svc = withTemplateRenderer({ drizzle: drizzle, events: makeEvents() });
355
- await expect(svc.unlockUser('missing', 'admin1')).rejects.toThrow();
323
+ await expect(buildService().unlockUser(randomUUID(), 'admin1')).rejects.toThrow();
356
324
  });
357
325
  it('resets the login rate-limit window for the unlocked user', async () => {
358
- const drizzle = makeDrizzle({
359
- selectRows: [
360
- [
361
- {
362
- id: 'u1',
363
- email: 'A@B.DEV',
364
- failedLoginAttempts: 5,
365
- lockoutUntil: new Date('2020-01-01'),
366
- },
367
- ],
368
- ],
369
- });
370
- const resetMock = vi.fn().mockResolvedValue(undefined);
371
- const limiter = {
372
- consume: vi.fn(),
373
- reset: resetMock,
374
- };
375
- const svc = withTemplateRenderer({
376
- drizzle: drizzle,
377
- events: makeEvents(),
378
- limiter,
379
- });
380
- await svc.unlockUser('u1', 'admin1');
381
- expect(resetMock).toHaveBeenCalledWith('login:a@b.dev');
326
+ const account = await seedUser({
327
+ failedLoginAttempts: 5,
328
+ lockoutUntil: new Date('2020-01-01'),
329
+ });
330
+ const limiter = allowLimiter();
331
+ const svc = buildService({ limiter });
332
+ await svc.unlockUser(account.id, 'admin1');
333
+ expect(limiter.reset).toHaveBeenCalledWith(`login:${EMAIL}`);
382
334
  });
383
335
  });
384
336
  describe('IdentityService.requestPasswordReset', () => {
385
337
  it('calls the non-deprecated requestPasswordResetEmailOTP endpoint and returns SUCCESS', async () => {
386
338
  requestPasswordResetEmailOTPMock.mockResolvedValue(jsonResponse({ success: true }, 200));
387
- const svc = withTemplateRenderer({
388
- drizzle: makeDrizzle(),
389
- events: makeEvents(),
390
- });
391
- const result = await svc.requestPasswordReset({ email: 'a@b.dev' });
339
+ const result = await buildService().requestPasswordReset({ email: EMAIL });
392
340
  expect(result).toEqual({ success: true });
393
- expect(requestPasswordResetEmailOTPMock).toHaveBeenCalledWith(expect.objectContaining({ body: { email: 'a@b.dev' } }));
341
+ expect(requestPasswordResetEmailOTPMock).toHaveBeenCalledWith(expect.objectContaining({ body: { email: EMAIL } }));
394
342
  });
395
343
  it('swallows a failure and still returns SUCCESS (anti-enumeration)', async () => {
396
344
  requestPasswordResetEmailOTPMock.mockRejectedValue(new Error('boom'));
397
- const svc = withTemplateRenderer({
398
- drizzle: makeDrizzle(),
399
- events: makeEvents(),
400
- });
401
- await expect(svc.requestPasswordReset({ email: 'unregistered@x.dev' })).resolves.toEqual({
402
- success: true,
403
- });
345
+ await expect(buildService().requestPasswordReset({ email: 'unregistered@x.dev' })).resolves.toEqual({ success: true });
404
346
  });
405
347
  });
406
348
  describe('IdentityService.verifyPasswordResetOtp', () => {
407
349
  it('calls checkVerificationOTP with type forget-password and returns SUCCESS', async () => {
408
350
  checkVerificationOTPMock.mockResolvedValue(jsonResponse({ success: true }, 200));
409
- const svc = withTemplateRenderer({
410
- drizzle: makeDrizzle(),
411
- events: makeEvents(),
412
- });
413
- const result = await svc.verifyPasswordResetOtp({ email: 'a@b.dev', otp: '123456' });
351
+ const result = await buildService().verifyPasswordResetOtp({ email: EMAIL, otp: '123456' });
414
352
  expect(result).toEqual({ success: true });
415
353
  expect(checkVerificationOTPMock).toHaveBeenCalledWith(expect.objectContaining({
416
- body: { email: 'a@b.dev', type: 'forget-password', otp: '123456' },
354
+ body: { email: EMAIL, type: 'forget-password', otp: '123456' },
417
355
  }));
418
356
  });
419
357
  it('surfaces an invalid OTP (400) response as ORPCError BAD_REQUEST with a generic message', async () => {
420
358
  checkVerificationOTPMock.mockResolvedValue(jsonResponse({ message: 'INVALID_OTP' }, 400));
421
- const svc = withTemplateRenderer({
422
- drizzle: makeDrizzle(),
423
- events: makeEvents(),
424
- });
425
- await expect(svc.verifyPasswordResetOtp({ email: 'a@b.dev', otp: '000000' })).rejects.toMatchObject({
359
+ await expect(buildService().verifyPasswordResetOtp({ email: EMAIL, otp: '000000' })).rejects.toMatchObject({
426
360
  code: 'BAD_REQUEST',
427
361
  message: 'Invalid or expired verification code',
428
362
  });
@@ -432,65 +366,52 @@ describe('IdentityService.verifyPasswordResetOtp', () => {
432
366
  // unregistered email produces a distinct raw message ("User not found") that must never
433
367
  // reach the caller - it would let an unauthenticated caller learn whether an email exists.
434
368
  checkVerificationOTPMock.mockResolvedValue(jsonResponse({ message: 'User not found' }, 400));
435
- const svc = withTemplateRenderer({
436
- drizzle: makeDrizzle(),
437
- events: makeEvents(),
438
- });
439
- await expect(svc.verifyPasswordResetOtp({ email: 'unregistered@x.dev', otp: '000000' })).rejects.toMatchObject({
369
+ await expect(buildService().verifyPasswordResetOtp({ email: 'unregistered@x.dev', otp: '000000' })).rejects.toMatchObject({
440
370
  code: 'BAD_REQUEST',
441
371
  message: 'Invalid or expired verification code',
442
372
  });
443
373
  });
444
374
  it('surfaces a TOO_MANY_ATTEMPTS (403) response as ORPCError BAD_REQUEST (masked)', async () => {
445
375
  checkVerificationOTPMock.mockResolvedValue(jsonResponse({ message: 'TOO_MANY_ATTEMPTS' }, 403));
446
- const svc = withTemplateRenderer({
447
- drizzle: makeDrizzle(),
448
- events: makeEvents(),
449
- });
450
- await expect(svc.verifyPasswordResetOtp({ email: 'a@b.dev', otp: '000000' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
376
+ await expect(buildService().verifyPasswordResetOtp({ email: EMAIL, otp: '000000' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
451
377
  });
452
378
  });
453
379
  describe('IdentityService.resetPassword', () => {
454
380
  it('calls resetPasswordEmailOTP with the right body and returns SUCCESS', async () => {
455
381
  resetPasswordEmailOTPMock.mockResolvedValue(jsonResponse({ success: true }, 200));
456
- const svc = withTemplateRenderer({
457
- drizzle: makeDrizzle(),
458
- events: makeEvents(),
459
- });
460
- const result = await svc.resetPassword({
461
- email: 'a@b.dev',
382
+ const result = await buildService().resetPassword({
383
+ email: EMAIL,
462
384
  otp: '123456',
463
385
  newPassword: 'newpassword1',
464
386
  });
465
387
  expect(result).toEqual({ success: true });
466
388
  expect(resetPasswordEmailOTPMock).toHaveBeenCalledWith(expect.objectContaining({
467
- body: { email: 'a@b.dev', otp: '123456', password: 'newpassword1' },
389
+ body: { email: EMAIL, otp: '123456', password: 'newpassword1' },
468
390
  }));
469
391
  });
470
392
  it('surfaces a TOO_MANY_ATTEMPTS (403) response as ORPCError BAD_REQUEST (masked)', async () => {
471
393
  resetPasswordEmailOTPMock.mockResolvedValue(jsonResponse({ message: 'TOO_MANY_ATTEMPTS' }, 403));
472
- const svc = withTemplateRenderer({
473
- drizzle: makeDrizzle(),
474
- events: makeEvents(),
475
- });
476
- await expect(svc.resetPassword({ email: 'a@b.dev', otp: '000000', newPassword: 'newpassword1' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
394
+ await expect(buildService().resetPassword({ email: EMAIL, otp: '000000', newPassword: 'newpassword1' })).rejects.toMatchObject({ code: 'BAD_REQUEST' });
477
395
  });
478
396
  });
479
397
  describe('IdentityService onPasswordReset hook (wired via createAuth)', () => {
480
- it('clears the lockout and emits identity.password.reset when better-auth invokes the hook', async () => {
481
- const drizzle = makeDrizzle();
482
- const events = makeEvents();
483
- withTemplateRenderer({ drizzle: drizzle, events: events });
484
- await capturedAuthOptions.current?.onPasswordReset?.({ id: 'u1', email: 'a@b.dev' });
485
- expect(events.emit).toHaveBeenCalledWith('identity.password.reset', expect.objectContaining({ userId: 'u1' }));
486
- expect(drizzle.update).toHaveBeenCalled();
398
+ it('clears the lockout row and emits identity.password.reset when better-auth invokes the hook', async () => {
399
+ const account = await seedUser({
400
+ failedLoginAttempts: 5,
401
+ lockoutUntil: new Date(Date.now() + 60_000),
402
+ });
403
+ const events = makeEventBus();
404
+ buildService({ events });
405
+ await capturedAuthOptions.current?.onPasswordReset?.({ id: account.id, email: EMAIL });
406
+ expect(events.emit).toHaveBeenCalledWith('identity.password.reset', expect.objectContaining({ userId: account.id }));
407
+ const row = await readUser(account.id);
408
+ expect(row.failedLoginAttempts).toBe(0);
409
+ expect(row.lockoutUntil).toBeNull();
487
410
  });
488
411
  });
489
412
  describe('IdentityService.updateProfile language validation', () => {
490
413
  it('rejects an unsupported language before calling updateUser', async () => {
491
- const svc = withTemplateRenderer({
492
- drizzle: makeDrizzle(),
493
- events: makeEvents(),
414
+ const svc = buildService({
494
415
  platformConfig: mock({ supportedLanguages: ['en', 'fr'] }),
495
416
  });
496
417
  await expect(svc.updateProfile({ language: 'de' }, {}, new Headers())).rejects.toThrow(UnsupportedLanguageError);
@@ -499,9 +420,7 @@ describe('IdentityService.updateProfile language validation', () => {
499
420
  it('accepts a supported language and forwards it to updateUser', async () => {
500
421
  updateUserMock.mockResolvedValue(jsonResponse({ status: true }, 200));
501
422
  getSessionMock.mockResolvedValueOnce({ user: { ...betterAuthUser, language: 'fr' } });
502
- const svc = withTemplateRenderer({
503
- drizzle: makeDrizzle(),
504
- events: makeEvents(),
423
+ const svc = buildService({
505
424
  platformConfig: mock({ supportedLanguages: ['en', 'fr'] }),
506
425
  });
507
426
  const result = await svc.updateProfile({ language: 'fr' }, {}, new Headers());