@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,750 +1,521 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { mock, mockDb, readPrivate, NO_CLIENT_META } from '../../testing/mock.js';
3
- import * as core from '@openora/core/server';
4
- import { levelToActions, actionsToLevel, statement, findOneOrThrow, } from '@openora/core/server';
5
- import { InProcessCache } from '@openora/core/testing';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest';
3
+ import { sql, eq } from 'drizzle-orm';
4
+ import { mock, NO_CLIENT_META, makeEventBus } from '../../testing/mock.js';
5
+ import { statement, findOneOrThrow, RedisCache } from '@openora/core/server';
6
+ import { createTestDb, createTestRedis } from '@openora/core/testing';
7
+ import { migrate as migrateIam } from '@openora/core/iam/migrate';
8
+ import { migrate as migrateIdentity } from '@openora/core/pam/migrate/identity';
9
+ import { user } from '@openora/core/pam/schema/identity';
10
+ import { adminRole, adminRolePermission, adminRoleAssignment, adminInvitation, } from '../schema/index.js';
6
11
  import { IamService, DbAdminPermissionResolver, RoleNotFoundError, InvalidGrantError, NotSuperAdminError, ProtectedRoleError, LastSuperAdminError, InvitationConflictError, AdminUserNotFoundError, NotAnAdminUserError, } from '../service/iam.service.js';
7
- vi.mock('drizzle-orm', async (importOriginal) => {
8
- const actual = await importOriginal();
9
- return {
10
- ...actual,
11
- eq: vi.fn((col, val) => {
12
- const colName = col?.name;
13
- return { __col: colName, val };
14
- }),
15
- and: vi.fn((...args) => ({ __and: args })),
16
- };
17
- });
18
- function makeEvents() {
19
- return mock({ emit: vi.fn(), on: vi.fn() });
12
+ let db;
13
+ let redis;
14
+ const makeEmail = () => mock({ send: vi.fn().mockResolvedValue(undefined) });
15
+ // Bootstrap super-admin: no DB assignment row + user.role === 'admin' passes the static fallback.
16
+ const ADMIN_CALLER = { userId: randomUUID(), role: 'admin', ...NO_CLIENT_META };
17
+ const SUPPORT_CALLER = { userId: randomUUID(), role: 'support', ...NO_CLIENT_META };
18
+ const TOTAL_GRANTS = Object.keys(statement).reduce((n, r) => n + statement[r].length, 0);
19
+ async function seedRole(overrides = {}) {
20
+ const [row] = await db.drizzle.db
21
+ .insert(adminRole)
22
+ .values({
23
+ name: overrides.name ?? 'Ops',
24
+ key: overrides.key ?? null,
25
+ isSystem: overrides.isSystem ?? false,
26
+ isSuperAdmin: overrides.isSuperAdmin ?? false,
27
+ })
28
+ .returning();
29
+ return row;
20
30
  }
21
- function makeEmail() {
22
- return mock({
23
- send: vi.fn().mockResolvedValue(undefined),
24
- });
31
+ async function seedUser(role) {
32
+ const [row] = await db.drizzle.db
33
+ .insert(user)
34
+ .values({ name: 'U', email: `${randomUUID()}@x.dev`, role })
35
+ .returning();
36
+ return row;
25
37
  }
26
- function inContext(fn) {
27
- return core.withRequestContext({ userId: 'caller', traceId: 't' }, fn);
38
+ async function seedAssignment(userId, roleId) {
39
+ await db.drizzle.db.insert(adminRoleAssignment).values({ userId, roleId });
28
40
  }
29
- const ADMIN_CALLER = { userId: 'admin-1', role: 'admin', ...NO_CLIENT_META };
30
- function routingDrizzle(byTable) {
31
- let table = {};
32
- let lastSelect;
33
- const chain = {
34
- select: vi.fn().mockImplementation((sel) => {
35
- lastSelect = sel;
36
- return chain;
37
- }),
38
- from: vi.fn().mockImplementation((tbl) => {
39
- table = tbl ?? {};
40
- return chain;
41
- }),
42
- innerJoin: vi.fn().mockReturnThis(),
43
- leftJoin: vi.fn().mockReturnThis(),
44
- where: vi.fn().mockImplementation(() => {
45
- const route = () => {
46
- // getGrants' single join (select includes isSuperAdmin). Synthesize the joined
47
- // rows from the decomposed byTable so existing test data keeps working: one row
48
- // per (role x permission), super roles collapse to a bypass row, empty -> [].
49
- if (lastSelect && 'isSuperAdmin' in lastSelect) {
50
- const assignments = (byTable.assignment ?? []);
51
- if (assignments.length === 0) {
52
- return [];
53
- }
54
- const superIds = new Set((byTable.superRole ?? []).map((r) => r.id));
55
- if (assignments.some((a) => superIds.has(a.roleId))) {
56
- return [{ isSuperAdmin: true, resource: null, level: null }];
57
- }
58
- const perms = (byTable.permission ?? []);
59
- if (perms.length === 0) {
60
- return [{ isSuperAdmin: false, resource: null, level: null }];
61
- }
62
- return perms.map((p) => ({ isSuperAdmin: false, resource: p.resource, level: p.level }));
63
- }
64
- if ('emailVerified' in table) {
65
- return byTable.user ?? [];
66
- }
67
- if ('level' in table) {
68
- return byTable.permission ?? [];
69
- }
70
- // adminRoleAssignment has `userId` column. A { roleId }-only select is the
71
- // assignment read used by both getGrants and isSuperAdmin(caller); a
72
- // full-row select is the dedup existing-check. When a test needs the two
73
- // to differ it sets `callerAssignment` (else both share `assignment`).
74
- if ('userId' in table) {
75
- if (lastSelect && 'roleId' in lastSelect && Object.keys(lastSelect).length === 1) {
76
- return byTable.callerAssignment ?? byTable.assignment ?? [];
77
- }
78
- return byTable.assignment ?? [];
79
- }
80
- // adminRole: distinguish the isSuperAdmin probe (selects only id) from the
81
- // role-existence read (selects full row) via the select shape.
82
- if (lastSelect && 'id' in lastSelect && Object.keys(lastSelect).length === 1) {
83
- return byTable.superRole ?? [];
84
- }
85
- return byTable.role ?? [];
86
- };
87
- const rows = route();
88
- const thenable = {
89
- then: (res) => Promise.resolve(rows).then(res),
90
- for: () => thenable,
91
- limit: () => thenable,
92
- returning: (...args) => chain.returning(...args),
93
- };
94
- return thenable;
95
- }),
96
- insert: vi.fn().mockReturnThis(),
97
- values: vi.fn().mockResolvedValue(undefined),
98
- update: vi.fn().mockReturnThis(),
99
- set: vi.fn().mockReturnThis(),
100
- delete: vi.fn().mockReturnThis(),
101
- returning: vi.fn().mockResolvedValue([]),
102
- transaction: vi.fn().mockImplementation((fn) => fn(chain)),
103
- };
104
- return mockDb(chain);
41
+ async function seedPermission(roleId, resource, level) {
42
+ await db.drizzle.db.insert(adminRolePermission).values({ roleId, resource, level });
105
43
  }
106
- const ROLE_ROW = {
107
- id: 'role-1',
108
- key: null,
109
- name: 'Ops',
110
- isSystem: false,
111
- isSuperAdmin: false,
112
- createdAt: new Date(),
113
- };
114
- describe('level helpers', () => {
115
- it('levelToActions/actionsToLevel round-trip for a view-bearing module', () => {
116
- // player has 'view' + write actions.
117
- expect(levelToActions('player', 'no_access')).toEqual([]);
118
- expect(levelToActions('player', 'read')).toEqual(['view']);
119
- expect([...levelToActions('player', 'read_write')]).toEqual([...statement.player]);
120
- expect(actionsToLevel('player', [])).toBe('no_access');
121
- expect(actionsToLevel('player', ['view'])).toBe('read');
122
- expect(actionsToLevel('player', [...statement.player])).toBe('read_write');
123
- });
124
- it('content has no read level (no view action) - read expands to empty', () => {
125
- expect(levelToActions('content', 'read')).toEqual([]);
126
- expect([...levelToActions('content', 'read_write')]).toEqual([...statement.content]);
127
- expect(actionsToLevel('content', ['create'])).toBe('no_access');
128
- expect(actionsToLevel('content', [...statement.content])).toBe('read_write');
129
- });
130
- it('expands the two new modules', () => {
131
- expect(levelToActions('sportsbook', 'read')).toEqual(['view']);
132
- expect([...levelToActions('sportsbook', 'read_write')]).toEqual([...statement.sportsbook]);
133
- expect([...levelToActions('affiliate', 'read_write')]).toEqual([...statement.affiliate]);
134
- });
44
+ beforeAll(async () => {
45
+ db = await createTestDb([migrateIam, migrateIdentity]);
46
+ redis = await createTestRedis();
47
+ });
48
+ afterAll(async () => {
49
+ await db.drop();
50
+ await redis.quit();
51
+ });
52
+ beforeEach(async () => {
53
+ await db.drizzle.db.execute(sql `TRUNCATE ${adminRole}, ${adminRolePermission}, ${adminRoleAssignment}, ${adminInvitation}, ${user} RESTART IDENTITY CASCADE`);
54
+ await redis.flush();
135
55
  });
136
56
  describe('IamService.listCatalog', () => {
137
- it('omits the admin module from the assignable catalog', async () => {
138
- const drizzle = routingDrizzle({});
139
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
57
+ it('omits the admin module from the assignable catalog', () => {
58
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
140
59
  const catalog = svc.listCatalog();
141
60
  expect(catalog.modules.some((m) => m.resource === 'admin')).toBe(false);
142
61
  expect(catalog.modules.some((m) => m.resource === 'player')).toBe(true);
143
62
  });
144
63
  });
145
- describe('DbAdminPermissionResolver', () => {
146
- it('returns null when user has no assignment', async () => {
147
- const drizzle = routingDrizzle({ assignment: [] });
148
- const resolver = new DbAdminPermissionResolver(drizzle);
149
- const result = await resolver.getGrants('user-no-role');
150
- expect(result).toBeNull();
64
+ describe('DbAdminPermissionResolver (real PG)', () => {
65
+ it('returns null when the user has no assignment', async () => {
66
+ const resolver = new DbAdminPermissionResolver(db.drizzle);
67
+ expect(await resolver.getGrants(randomUUID())).toBeNull();
151
68
  });
152
69
  it('super-admin assignment returns ALL grants (bypass)', async () => {
153
- const drizzle = routingDrizzle({
154
- assignment: [{ roleId: 'role-super' }],
155
- superRole: [{ id: 'role-super' }],
156
- });
157
- const resolver = new DbAdminPermissionResolver(drizzle);
158
- const grants = await inContext(() => resolver.getGrants('u'));
159
- const expected = Object.keys(statement).reduce((n, r) => n + statement[r].length, 0);
160
- expect(grants).not.toBeNull();
161
- expect(grants).toHaveLength(expected);
162
- });
163
- it('expands level rows into action grants', async () => {
164
- const drizzle = routingDrizzle({
165
- assignment: [{ roleId: 'role-1' }],
166
- superRole: [],
167
- permission: [
168
- { resource: 'player', level: 'read' },
169
- { resource: 'withdrawal', level: 'read_write' },
170
- ],
171
- });
172
- const resolver = new DbAdminPermissionResolver(drizzle);
173
- const grants = await inContext(() => resolver.getGrants('u'));
70
+ const role = await seedRole({ isSuperAdmin: true });
71
+ const userId = randomUUID();
72
+ await seedAssignment(userId, role.id);
73
+ const grants = await new DbAdminPermissionResolver(db.drizzle).getGrants(userId);
74
+ expect(grants).toHaveLength(TOTAL_GRANTS);
75
+ });
76
+ it('expands stored level rows into action grants', async () => {
77
+ const role = await seedRole();
78
+ await seedPermission(role.id, 'player', 'read');
79
+ await seedPermission(role.id, 'withdrawal', 'read_write');
80
+ const userId = randomUUID();
81
+ await seedAssignment(userId, role.id);
82
+ const grants = await new DbAdminPermissionResolver(db.drizzle).getGrants(userId);
174
83
  expect(grants).toContainEqual({ resource: 'player', action: 'view' });
175
84
  expect(grants).toContainEqual({ resource: 'withdrawal', action: 'approve' });
176
85
  // read player must NOT yield write actions.
177
86
  expect(grants).not.toContainEqual({ resource: 'player', action: 'ban' });
178
87
  });
179
88
  });
180
- describe('DbAdminPermissionResolver caching', () => {
181
- // Counts only the getGrants join (the query flagged by leftJoin); the un-joined
182
- // where() is the invalidateRole holder lookup, which returns `holders`.
183
- function grantsDrizzle(holders = []) {
184
- const state = { grantsQueries: 0 };
185
- let joined = false;
186
- const chain = {
187
- select: () => chain,
188
- from: () => chain,
189
- innerJoin: () => chain,
190
- leftJoin: () => {
191
- joined = true;
192
- return chain;
193
- },
194
- where: () => {
195
- if (joined) {
196
- joined = false;
197
- state.grantsQueries += 1;
198
- return Promise.resolve([{ isSuperAdmin: true, resource: null, level: null }]);
199
- }
200
- return Promise.resolve(holders);
201
- },
202
- };
203
- return { drizzle: mockDb(chain), state };
204
- }
205
- it('serves a repeat lookup from cache without re-querying', async () => {
206
- const { drizzle, state } = grantsDrizzle();
207
- const resolver = new DbAdminPermissionResolver(drizzle, new InProcessCache());
208
- await resolver.getGrants('u1');
209
- await resolver.getGrants('u1');
210
- expect(state.grantsQueries).toBe(1);
211
- });
212
- it('without a cache, every lookup queries', async () => {
213
- const { drizzle, state } = grantsDrizzle();
214
- const resolver = new DbAdminPermissionResolver(drizzle);
215
- await resolver.getGrants('u1');
216
- await resolver.getGrants('u1');
217
- expect(state.grantsQueries).toBe(2);
218
- });
219
- it('invalidateUser purges so the next lookup re-queries', async () => {
220
- const { drizzle, state } = grantsDrizzle();
221
- const resolver = new DbAdminPermissionResolver(drizzle, new InProcessCache());
222
- await resolver.getGrants('u1');
223
- await resolver.invalidateUser('u1');
224
- await resolver.getGrants('u1');
225
- expect(state.grantsQueries).toBe(2);
89
+ describe('DbAdminPermissionResolver caching (real Redis read-through)', () => {
90
+ it('serves a repeat lookup from cache even after the underlying rows change', async () => {
91
+ const role = await seedRole({ isSuperAdmin: true });
92
+ const userId = randomUUID();
93
+ await seedAssignment(userId, role.id);
94
+ const resolver = new DbAdminPermissionResolver(db.drizzle, new RedisCache(redis.client));
95
+ expect(await resolver.getGrants(userId)).toHaveLength(TOTAL_GRANTS);
96
+ // Revoke in the DB behind the cache's back; the cached grants must still be served.
97
+ await db.drizzle.db.delete(adminRoleAssignment).where(eq(adminRoleAssignment.userId, userId));
98
+ expect(await resolver.getGrants(userId)).toHaveLength(TOTAL_GRANTS);
99
+ });
100
+ it('without a cache, every lookup re-queries the DB', async () => {
101
+ const role = await seedRole({ isSuperAdmin: true });
102
+ const userId = randomUUID();
103
+ await seedAssignment(userId, role.id);
104
+ const resolver = new DbAdminPermissionResolver(db.drizzle);
105
+ expect(await resolver.getGrants(userId)).toHaveLength(TOTAL_GRANTS);
106
+ await db.drizzle.db.delete(adminRoleAssignment).where(eq(adminRoleAssignment.userId, userId));
107
+ expect(await resolver.getGrants(userId)).toBeNull();
108
+ });
109
+ it('invalidateUser purges so the next lookup reflects the revoke', async () => {
110
+ const role = await seedRole({ isSuperAdmin: true });
111
+ const userId = randomUUID();
112
+ await seedAssignment(userId, role.id);
113
+ const resolver = new DbAdminPermissionResolver(db.drizzle, new RedisCache(redis.client));
114
+ await resolver.getGrants(userId);
115
+ await db.drizzle.db.delete(adminRoleAssignment).where(eq(adminRoleAssignment.userId, userId));
116
+ await resolver.invalidateUser(userId);
117
+ expect(await resolver.getGrants(userId)).toBeNull();
226
118
  });
227
119
  it('invalidateRole purges every current holder of the role', async () => {
228
- const { drizzle, state } = grantsDrizzle([{ userId: 'u1' }, { userId: 'u2' }]);
229
- const resolver = new DbAdminPermissionResolver(drizzle, new InProcessCache());
230
- await resolver.getGrants('u1');
231
- await resolver.getGrants('u2');
232
- expect(state.grantsQueries).toBe(2);
233
- await resolver.invalidateRole('role-1');
234
- await resolver.getGrants('u1');
235
- await resolver.getGrants('u2');
236
- expect(state.grantsQueries).toBe(4);
120
+ const role = await seedRole();
121
+ await seedPermission(role.id, 'player', 'read');
122
+ const u1 = randomUUID();
123
+ const u2 = randomUUID();
124
+ await seedAssignment(u1, role.id);
125
+ await seedAssignment(u2, role.id);
126
+ const resolver = new DbAdminPermissionResolver(db.drizzle, new RedisCache(redis.client));
127
+ // Prime both users' caches at the 'read' level (just the view action).
128
+ expect(await resolver.getGrants(u1)).toEqual([{ resource: 'player', action: 'view' }]);
129
+ expect(await resolver.getGrants(u2)).toEqual([{ resource: 'player', action: 'view' }]);
130
+ // Widen the role to read_write, then invalidate every holder.
131
+ await db.drizzle.db
132
+ .update(adminRolePermission)
133
+ .set({ level: 'read_write' })
134
+ .where(eq(adminRolePermission.roleId, role.id));
135
+ await resolver.invalidateRole(role.id);
136
+ for (const uid of [u1, u2]) {
137
+ const grants = await resolver.getGrants(uid);
138
+ expect(grants).toContainEqual({ resource: 'player', action: 'ban' });
139
+ }
237
140
  });
238
141
  });
239
142
  describe('IamService.setRolePermissions', () => {
240
143
  it('rejects a non-super-admin caller', async () => {
241
- const drizzle = routingDrizzle({
242
- role: [ROLE_ROW],
243
- assignment: [{ roleId: 'role-x' }],
244
- superRole: [],
245
- });
246
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
247
- await expect(inContext(() => svc.setRolePermissions({
248
- roleId: 'role-1',
144
+ const role = await seedRole();
145
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
146
+ await expect(svc.setRolePermissions({
147
+ roleId: role.id,
249
148
  grants: [{ resource: 'player', level: 'read' }],
250
- caller: { userId: 'sup-1', role: 'support', ...NO_CLIENT_META },
251
- }))).rejects.toBeInstanceOf(NotSuperAdminError);
252
- });
253
- it('throws RoleNotFoundError when role does not exist', async () => {
254
- const drizzle = routingDrizzle({ role: [], assignment: [], superRole: [] });
255
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
256
- await expect(inContext(() => svc.setRolePermissions({
257
- roleId: 'missing',
149
+ caller: SUPPORT_CALLER,
150
+ })).rejects.toBeInstanceOf(NotSuperAdminError);
151
+ });
152
+ it('throws RoleNotFoundError when the role does not exist', async () => {
153
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
154
+ await expect(svc.setRolePermissions({
155
+ roleId: randomUUID(),
258
156
  grants: [{ resource: 'player', level: 'read' }],
259
157
  caller: ADMIN_CALLER,
260
- }))).rejects.toBeInstanceOf(RoleNotFoundError);
158
+ })).rejects.toBeInstanceOf(RoleNotFoundError);
261
159
  });
262
160
  it('throws InvalidGrantError for an unknown module', async () => {
263
- const drizzle = routingDrizzle({ role: [ROLE_ROW], assignment: [], superRole: [] });
264
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
265
- await expect(inContext(() => svc.setRolePermissions({
266
- roleId: 'role-1',
161
+ const role = await seedRole();
162
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
163
+ await expect(svc.setRolePermissions({
164
+ roleId: role.id,
267
165
  grants: [{ resource: 'nonexistent', level: 'read' }],
268
166
  caller: ADMIN_CALLER,
269
- }))).rejects.toBeInstanceOf(InvalidGrantError);
167
+ })).rejects.toBeInstanceOf(InvalidGrantError);
270
168
  });
271
169
  it('rejects a grant targeting the admin module (super-admin-only, A6)', async () => {
272
- const drizzle = routingDrizzle({ role: [ROLE_ROW], assignment: [], superRole: [] });
273
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
274
- await expect(inContext(() => svc.setRolePermissions({
275
- roleId: 'role-1',
170
+ const role = await seedRole();
171
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
172
+ await expect(svc.setRolePermissions({
173
+ roleId: role.id,
276
174
  grants: [{ resource: 'admin', level: 'read_write' }],
277
175
  caller: ADMIN_CALLER,
278
- }))).rejects.toBeInstanceOf(InvalidGrantError);
176
+ })).rejects.toBeInstanceOf(InvalidGrantError);
279
177
  });
280
178
  it('blocks editing a super-admin role (always full)', async () => {
281
- const superRoleRow = { ...ROLE_ROW, id: 'role-super', isSuperAdmin: true, isSystem: true };
282
- const drizzle = routingDrizzle({
283
- role: [superRoleRow],
284
- assignment: [],
285
- superRole: [],
286
- });
287
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
288
- await expect(inContext(() => svc.setRolePermissions({
289
- roleId: 'role-super',
179
+ const role = await seedRole({ isSuperAdmin: true, isSystem: true });
180
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
181
+ await expect(svc.setRolePermissions({
182
+ roleId: role.id,
290
183
  grants: [{ resource: 'player', level: 'read' }],
291
184
  caller: ADMIN_CALLER,
292
- }))).rejects.toBeInstanceOf(ProtectedRoleError);
293
- });
294
- it('a super caller may grant any level (bypass)', async () => {
295
- const drizzle = routingDrizzle({
296
- role: [ROLE_ROW],
297
- assignment: [],
298
- superRole: [],
299
- permission: [{ resource: 'withdrawal', level: 'read_write' }],
300
- });
301
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
302
- const result = await inContext(() => svc.setRolePermissions({
303
- roleId: 'role-1',
185
+ })).rejects.toBeInstanceOf(ProtectedRoleError);
186
+ });
187
+ it('a super caller may grant any level and the rows are persisted', async () => {
188
+ const role = await seedRole();
189
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
190
+ const result = await svc.setRolePermissions({
191
+ roleId: role.id,
304
192
  grants: [{ resource: 'withdrawal', level: 'read_write' }],
305
193
  caller: ADMIN_CALLER,
306
- }));
194
+ });
307
195
  expect(result.permissions).toContainEqual({ resource: 'withdrawal', level: 'read_write' });
196
+ const rows = await db.drizzle.db
197
+ .select()
198
+ .from(adminRolePermission)
199
+ .where(eq(adminRolePermission.roleId, role.id));
200
+ expect(rows).toHaveLength(1);
201
+ expect(rows[0]).toMatchObject({ resource: 'withdrawal', level: 'read_write' });
308
202
  });
309
203
  it('emits iam.role.permissions.changed with actorId', async () => {
310
- const drizzle = routingDrizzle({
311
- role: [ROLE_ROW],
312
- assignment: [],
313
- superRole: [],
314
- permission: [{ resource: 'player', level: 'read' }],
315
- });
316
- const events = makeEvents();
317
- const svc = new IamService(drizzle, events, makeEmail());
318
- await inContext(() => svc.setRolePermissions({
319
- roleId: 'role-1',
204
+ const role = await seedRole();
205
+ const events = makeEventBus();
206
+ const svc = new IamService(db.drizzle, events, makeEmail());
207
+ await svc.setRolePermissions({
208
+ roleId: role.id,
320
209
  grants: [{ resource: 'player', level: 'read' }],
321
210
  caller: ADMIN_CALLER,
322
- }));
323
- expect(events.emit).toHaveBeenCalledWith('iam.role.permissions.changed', expect.objectContaining({ roleId: 'role-1', actorId: 'admin-1' }));
211
+ });
212
+ expect(events.emit).toHaveBeenCalledWith('iam.role.permissions.changed', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
324
213
  });
325
214
  });
326
215
  describe('IamService.updateRole', () => {
327
216
  it('blocks renaming the super-admin role', async () => {
328
- const drizzle = routingDrizzle({
329
- role: [{ ...ROLE_ROW, isSuperAdmin: true, isSystem: true }],
330
- assignment: [],
331
- superRole: [],
332
- });
333
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
334
- await expect(inContext(() => svc.updateRole({ roleId: 'role-1', name: 'Renamed', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(ProtectedRoleError);
217
+ const role = await seedRole({ isSuperAdmin: true, isSystem: true });
218
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
219
+ await expect(svc.updateRole({ roleId: role.id, name: 'Renamed', caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
335
220
  });
336
221
  it('allows renaming a predefined (isSystem, non-super) role', async () => {
337
- const drizzle = routingDrizzle({
338
- role: [{ ...ROLE_ROW, isSystem: true, isSuperAdmin: false }],
339
- assignment: [],
340
- superRole: [],
341
- });
342
- readPrivate(drizzle.db, 'returning').mockResolvedValue([
343
- { ...ROLE_ROW, isSystem: true, name: 'Renamed' },
344
- ]);
345
- const events = makeEvents();
346
- const svc = new IamService(drizzle, events, makeEmail());
347
- const result = await inContext(() => svc.updateRole({ roleId: 'role-1', name: 'Renamed', caller: ADMIN_CALLER }));
222
+ const role = await seedRole({ isSystem: true, isSuperAdmin: false });
223
+ const events = makeEventBus();
224
+ const svc = new IamService(db.drizzle, events, makeEmail());
225
+ const result = await svc.updateRole({ roleId: role.id, name: 'Renamed', caller: ADMIN_CALLER });
348
226
  expect(result.name).toBe('Renamed');
349
- expect(events.emit).toHaveBeenCalledWith('iam.role.updated', expect.objectContaining({ roleId: 'role-1', actorId: 'admin-1' }));
227
+ expect(events.emit).toHaveBeenCalledWith('iam.role.updated', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
228
+ const [row] = await db.drizzle.db.select().from(adminRole).where(eq(adminRole.id, role.id));
229
+ expect(row.name).toBe('Renamed');
350
230
  });
351
231
  });
352
232
  describe('IamService.deleteRole', () => {
353
233
  it('blocks deleting a system role', async () => {
354
- const drizzle = routingDrizzle({
355
- role: [{ ...ROLE_ROW, isSystem: true }],
356
- assignment: [],
357
- superRole: [],
358
- });
359
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
360
- await expect(inContext(() => svc.deleteRole({ roleId: 'role-1', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(ProtectedRoleError);
234
+ const role = await seedRole({ isSystem: true });
235
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
236
+ await expect(svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
361
237
  });
362
238
  it('blocks deleting a super-admin role', async () => {
363
- const drizzle = routingDrizzle({
364
- role: [{ ...ROLE_ROW, isSuperAdmin: true }],
365
- assignment: [],
366
- superRole: [],
367
- });
368
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
369
- await expect(inContext(() => svc.deleteRole({ roleId: 'role-1', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(ProtectedRoleError);
239
+ const role = await seedRole({ isSuperAdmin: true });
240
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
241
+ await expect(svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(ProtectedRoleError);
370
242
  });
371
243
  it('deletes a custom role with no assignments and emits only iam.role.deleted', async () => {
372
- const drizzle = routingDrizzle({ role: [ROLE_ROW], assignment: [], superRole: [] });
373
- const events = makeEvents();
374
- const svc = new IamService(drizzle, events, makeEmail());
375
- const result = await inContext(() => svc.deleteRole({ roleId: 'role-1', caller: ADMIN_CALLER }));
244
+ const role = await seedRole();
245
+ const events = makeEventBus();
246
+ const svc = new IamService(db.drizzle, events, makeEmail());
247
+ const result = await svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER });
376
248
  expect(result).toEqual({ success: true });
377
- expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId: 'role-1', actorId: 'admin-1' }));
249
+ expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
378
250
  expect(events.emit).not.toHaveBeenCalledWith('iam.role.revoked', expect.anything());
379
- });
380
- it('emits iam.role.revoked once per affected user plus iam.role.deleted', async () => {
381
- const drizzle = routingDrizzle({
382
- role: [ROLE_ROW],
383
- callerAssignment: [], // bootstrap admin caller is super via static role
384
- assignment: [{ userId: 'user-a' }, { userId: 'user-b' }],
385
- superRole: [],
386
- });
387
- const events = makeEvents();
388
- const svc = new IamService(drizzle, events, makeEmail());
389
- const result = await inContext(() => svc.deleteRole({ roleId: 'role-1', caller: ADMIN_CALLER }));
251
+ expect(await db.drizzle.db.select().from(adminRole).where(eq(adminRole.id, role.id))).toHaveLength(0);
252
+ });
253
+ it('emits iam.role.revoked once per affected user plus iam.role.deleted, and cascades assignments', async () => {
254
+ const role = await seedRole();
255
+ const userA = randomUUID();
256
+ const userB = randomUUID();
257
+ await seedAssignment(userA, role.id);
258
+ await seedAssignment(userB, role.id);
259
+ const events = makeEventBus();
260
+ const svc = new IamService(db.drizzle, events, makeEmail());
261
+ const result = await svc.deleteRole({ roleId: role.id, caller: ADMIN_CALLER });
390
262
  expect(result).toEqual({ success: true });
391
263
  expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', {
392
- roleId: 'role-1',
393
- userId: 'user-a',
394
- actorId: 'admin-1',
395
- ip: null,
396
- userAgent: null,
264
+ roleId: role.id,
265
+ userId: userA,
266
+ actorId: ADMIN_CALLER.userId,
267
+ ...NO_CLIENT_META,
397
268
  });
398
269
  expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', {
399
- roleId: 'role-1',
400
- userId: 'user-b',
401
- actorId: 'admin-1',
402
- ip: null,
403
- userAgent: null,
270
+ roleId: role.id,
271
+ userId: userB,
272
+ actorId: ADMIN_CALLER.userId,
273
+ ...NO_CLIENT_META,
404
274
  });
405
275
  const revokeCalls = events.emit.mock.calls.filter((c) => c[0] === 'iam.role.revoked');
406
276
  expect(revokeCalls).toHaveLength(2);
407
- expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId: 'role-1', actorId: 'admin-1' }));
277
+ expect(events.emit).toHaveBeenCalledWith('iam.role.deleted', expect.objectContaining({ roleId: role.id, actorId: ADMIN_CALLER.userId }));
278
+ // ON DELETE CASCADE removed the assignment rows.
279
+ expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(0);
408
280
  });
409
281
  });
410
282
  describe('IamService.assignRole', () => {
411
- it('dedupes an existing assignment without inserting again', async () => {
412
- const existingAssignment = {
413
- id: 'a1',
414
- userId: 'target',
415
- roleId: 'role-1',
416
- createdAt: new Date(),
417
- };
418
- const drizzle = routingDrizzle({
419
- role: [ROLE_ROW],
420
- user: [{ role: 'admin' }],
421
- callerAssignment: [],
422
- assignment: [existingAssignment],
423
- superRole: [],
283
+ it('dedupes an existing assignment without inserting again (unique index)', async () => {
284
+ const role = await seedRole();
285
+ const target = await seedUser('admin');
286
+ await seedAssignment(target.id, role.id);
287
+ const events = makeEventBus();
288
+ const svc = new IamService(db.drizzle, events, makeEmail());
289
+ const result = await svc.assignRole({
290
+ userId: target.id,
291
+ roleId: role.id,
292
+ caller: ADMIN_CALLER,
424
293
  });
425
- const events = makeEvents();
426
- const svc = new IamService(drizzle, events, makeEmail());
427
- const result = await inContext(() => svc.assignRole({ userId: 'target', roleId: 'role-1', caller: ADMIN_CALLER }));
428
- expect(result.id).toBe('a1');
429
294
  expect(events.emit).not.toHaveBeenCalledWith('iam.role.assigned', expect.anything());
295
+ // Still exactly one assignment row, and the existing one was returned.
296
+ const rows = await db.drizzle.db.select().from(adminRoleAssignment);
297
+ expect(rows).toHaveLength(1);
298
+ expect(result.id).toBe(rows[0].id);
430
299
  });
431
300
  it('rejects assigning a role to a non-admin (player) account', async () => {
432
- const drizzle = routingDrizzle({
433
- role: [ROLE_ROW],
434
- user: [{ role: 'player' }],
435
- callerAssignment: [],
436
- superRole: [],
437
- });
438
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
439
- await expect(inContext(() => svc.assignRole({ userId: 'player-1', roleId: 'role-1', caller: ADMIN_CALLER }))).rejects.toThrow(NotAnAdminUserError);
301
+ const role = await seedRole();
302
+ const player = await seedUser('player');
303
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
304
+ await expect(svc.assignRole({ userId: player.id, roleId: role.id, caller: ADMIN_CALLER })).rejects.toThrow(NotAnAdminUserError);
440
305
  });
441
306
  it('rejects assigning a role to an unknown user', async () => {
442
- const drizzle = routingDrizzle({
443
- role: [ROLE_ROW],
444
- user: [],
445
- callerAssignment: [],
446
- superRole: [],
447
- });
448
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
449
- await expect(inContext(() => svc.assignRole({ userId: 'ghost', roleId: 'role-1', caller: ADMIN_CALLER }))).rejects.toThrow(AdminUserNotFoundError);
307
+ const role = await seedRole();
308
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
309
+ await expect(svc.assignRole({ userId: randomUUID(), roleId: role.id, caller: ADMIN_CALLER })).rejects.toThrow(AdminUserNotFoundError);
310
+ });
311
+ it('the unique index keeps two concurrent assigns from double-inserting', async () => {
312
+ const role = await seedRole();
313
+ const target = await seedUser('admin');
314
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
315
+ await Promise.allSettled([
316
+ svc.assignRole({ userId: target.id, roleId: role.id, caller: ADMIN_CALLER }),
317
+ svc.assignRole({ userId: target.id, roleId: role.id, caller: ADMIN_CALLER }),
318
+ ]);
319
+ const rows = await db.drizzle.db
320
+ .select()
321
+ .from(adminRoleAssignment)
322
+ .where(eq(adminRoleAssignment.userId, target.id));
323
+ expect(rows).toHaveLength(1);
450
324
  });
451
325
  });
452
326
  describe('IamService.unassignRole', () => {
453
327
  it('rejects removing the last super-admin holder', async () => {
454
- const superRole = { ...ROLE_ROW, id: 'role-super', isSuperAdmin: true, isSystem: true };
455
- let table = {};
456
- let lastSelect;
457
- const chain = {
458
- select: vi.fn().mockImplementation((sel) => {
459
- lastSelect = sel;
460
- return chain;
461
- }),
462
- from: vi.fn().mockImplementation((tbl) => {
463
- table = tbl ?? {};
464
- return chain;
465
- }),
466
- where: vi.fn().mockImplementation(() => {
467
- const route = () => {
468
- if ('userId' in table) {
469
- // assignment probes: isSuperAdmin(caller) -> caller assignments;
470
- // holders query selects userId+roleId.
471
- if (lastSelect && 'roleId' in lastSelect && 'userId' in lastSelect) {
472
- return [{ userId: 'target', roleId: 'role-super' }];
473
- }
474
- return [{ roleId: 'role-super' }]; // caller is super
475
- }
476
- if (lastSelect && 'id' in lastSelect && Object.keys(lastSelect).length === 1) {
477
- return [{ id: 'role-super' }]; // super-admin probe / list
478
- }
479
- return [superRole]; // role existence
480
- };
481
- const rows = route();
482
- const thenable = {
483
- then: (res) => Promise.resolve(rows).then(res),
484
- for: () => thenable,
485
- returning: () => Promise.resolve([{ id: 'a1' }]),
486
- };
487
- return thenable;
488
- }),
489
- delete: vi.fn().mockReturnThis(),
490
- transaction: vi.fn().mockImplementation((fn) => fn(chain)),
491
- };
492
- const drizzle = mockDb(chain);
493
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
494
- await expect(inContext(() => svc.unassignRole({ userId: 'target', roleId: 'role-super', caller: ADMIN_CALLER }))).rejects.toBeInstanceOf(LastSuperAdminError);
328
+ const superRole = await seedRole({ isSuperAdmin: true, isSystem: true });
329
+ const target = randomUUID();
330
+ await seedAssignment(target, superRole.id);
331
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
332
+ await expect(svc.unassignRole({ userId: target, roleId: superRole.id, caller: ADMIN_CALLER })).rejects.toBeInstanceOf(LastSuperAdminError);
333
+ // The holder survives the rejected unassign.
334
+ expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(1);
495
335
  });
496
336
  it('returns success but emits no revoked event when nothing was deleted', async () => {
497
- const drizzle = routingDrizzle({
498
- role: [ROLE_ROW],
499
- callerAssignment: [],
500
- assignment: [],
501
- superRole: [],
337
+ const role = await seedRole();
338
+ const events = makeEventBus();
339
+ const svc = new IamService(db.drizzle, events, makeEmail());
340
+ const result = await svc.unassignRole({
341
+ userId: randomUUID(),
342
+ roleId: role.id,
343
+ caller: ADMIN_CALLER,
502
344
  });
503
- readPrivate(drizzle.db, 'returning').mockResolvedValue([]);
504
- const events = makeEvents();
505
- const svc = new IamService(drizzle, events, makeEmail());
506
- const result = await inContext(() => svc.unassignRole({ userId: 'ghost', roleId: 'role-1', caller: ADMIN_CALLER }));
507
345
  expect(result).toEqual({ success: true });
508
346
  expect(events.emit).not.toHaveBeenCalledWith('iam.role.revoked', expect.anything());
509
347
  });
510
348
  it('emits iam.role.revoked when a row was actually deleted', async () => {
511
- const drizzle = routingDrizzle({
512
- role: [ROLE_ROW],
513
- callerAssignment: [],
514
- assignment: [],
515
- superRole: [],
516
- });
517
- readPrivate(drizzle.db, 'returning').mockResolvedValue([
518
- { id: 'a1' },
349
+ const role = await seedRole();
350
+ const target = randomUUID();
351
+ await seedAssignment(target, role.id);
352
+ const events = makeEventBus();
353
+ const svc = new IamService(db.drizzle, events, makeEmail());
354
+ await svc.unassignRole({ userId: target, roleId: role.id, caller: ADMIN_CALLER });
355
+ expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', expect.objectContaining({ roleId: role.id, userId: target, actorId: ADMIN_CALLER.userId }));
356
+ expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(0);
357
+ });
358
+ it('under two concurrent unassigns of a two-holder super role, exactly one wins (FOR UPDATE guard)', async () => {
359
+ const superRole = await seedRole({ isSuperAdmin: true, isSystem: true });
360
+ const userA = randomUUID();
361
+ const userB = randomUUID();
362
+ await seedAssignment(userA, superRole.id);
363
+ await seedAssignment(userB, superRole.id);
364
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
365
+ const results = await Promise.allSettled([
366
+ svc.unassignRole({ userId: userA, roleId: superRole.id, caller: ADMIN_CALLER }),
367
+ svc.unassignRole({ userId: userB, roleId: superRole.id, caller: ADMIN_CALLER }),
519
368
  ]);
520
- const events = makeEvents();
521
- const svc = new IamService(drizzle, events, makeEmail());
522
- await inContext(() => svc.unassignRole({ userId: 'target', roleId: 'role-1', caller: ADMIN_CALLER }));
523
- expect(events.emit).toHaveBeenCalledWith('iam.role.revoked', expect.objectContaining({ roleId: 'role-1', userId: 'target', actorId: 'admin-1' }));
369
+ const rejected = results.filter((r) => r.status === 'rejected');
370
+ expect(rejected).toHaveLength(1);
371
+ expect(rejected[0].reason).toBeInstanceOf(LastSuperAdminError);
372
+ // Exactly one super-admin holder survives - the guard never let both strip it.
373
+ expect(await db.drizzle.db.select().from(adminRoleAssignment)).toHaveLength(1);
524
374
  });
525
375
  });
526
376
  describe('IamService.previewEffectivePermissions', () => {
527
377
  it('returns the max level per module across roles (union)', async () => {
528
- const drizzle = routingDrizzle({
529
- superRole: [],
530
- permission: [
531
- { resource: 'player', level: 'read' },
532
- { resource: 'player', level: 'read_write' },
533
- { resource: 'withdrawal', level: 'read' },
534
- ],
535
- });
536
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
537
- const result = await inContext(() => svc.previewEffectivePermissions({ roleIds: ['r1', 'r2'] }));
378
+ const roleA = await seedRole();
379
+ const roleB = await seedRole();
380
+ await seedPermission(roleA.id, 'player', 'read');
381
+ await seedPermission(roleA.id, 'withdrawal', 'read');
382
+ await seedPermission(roleB.id, 'player', 'read_write');
383
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
384
+ const result = await svc.previewEffectivePermissions({ roleIds: [roleA.id, roleB.id] });
538
385
  expect(result.permissions).toContainEqual({ resource: 'player', level: 'read_write' });
539
386
  expect(result.permissions).toContainEqual({ resource: 'withdrawal', level: 'read' });
540
387
  expect(result.permissions.filter((p) => p.resource === 'player')).toHaveLength(1);
541
388
  });
542
389
  it('super-admin role in the set yields all modules read_write', async () => {
543
- const drizzle = routingDrizzle({ superRole: [{ id: 'role-super' }] });
544
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
545
- const result = await inContext(() => svc.previewEffectivePermissions({ roleIds: ['role-super'] }));
390
+ const superRole = await seedRole({ isSuperAdmin: true });
391
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
392
+ const result = await svc.previewEffectivePermissions({ roleIds: [superRole.id] });
546
393
  expect(result.permissions).toHaveLength(Object.keys(statement).length);
547
394
  expect(result.permissions.every((p) => p.level === 'read_write')).toBe(true);
548
395
  });
549
- it('falls back to static role permissions if the user has no dynamic role assignments', async () => {
550
- const drizzle = routingDrizzle({
551
- assignment: [], // no dynamic assignments
552
- user: [{ role: 'support' }], // static role is 'support'
553
- });
554
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
555
- const result = await inContext(() => svc.previewEffectivePermissions({ userId: 'u1' }));
556
- // Support has non-empty permissions
396
+ it('falls back to static role permissions when the user has no dynamic role assignments', async () => {
397
+ const u = await seedUser('support');
398
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
399
+ const result = await svc.previewEffectivePermissions({ userId: u.id });
557
400
  expect(result.permissions).not.toHaveLength(0);
558
- // For support role, player access is read (view)
559
401
  expect(result.permissions).toContainEqual({ resource: 'player', level: 'read' });
560
402
  });
561
403
  });
562
404
  describe('IamService.acceptInvitation', () => {
563
- it('accepts once and emits exactly one event', async () => {
564
- const invRow = {
565
- id: 'inv-1',
405
+ async function seedInvitation() {
406
+ const role = await seedRole();
407
+ const token = randomUUID();
408
+ await db.drizzle.db.insert(adminInvitation).values({
566
409
  email: 'who@admin.com',
567
- roleId: 'role-1',
568
- token: 'tok',
569
- status: 'accepted',
570
- expiresAt: new Date(Date.now() + 1000),
571
- acceptedAt: new Date(),
572
- createdAt: new Date(),
573
- };
574
- const returning = vi.fn().mockResolvedValueOnce([invRow]).mockResolvedValueOnce([]);
575
- const chain = {
576
- update: vi.fn().mockReturnThis(),
577
- set: vi.fn().mockReturnThis(),
578
- where: vi.fn().mockReturnThis(),
579
- returning,
580
- };
581
- const drizzle = mockDb(chain);
582
- const events = makeEvents();
583
- const svc = new IamService(drizzle, events, makeEmail());
584
- const first = await svc.acceptInvitation('tok');
410
+ roleId: role.id,
411
+ token,
412
+ status: 'pending',
413
+ expiresAt: new Date(Date.now() + 60_000),
414
+ });
415
+ return token;
416
+ }
417
+ it('accepts once and emits exactly one event; a replay conflicts and emits nothing more', async () => {
418
+ const token = await seedInvitation();
419
+ const events = makeEventBus();
420
+ const svc = new IamService(db.drizzle, events, makeEmail());
421
+ const first = await svc.acceptInvitation(token);
585
422
  expect(first).toEqual({ success: true, email: 'who@admin.com' });
586
- await expect(svc.acceptInvitation('tok')).rejects.toBeInstanceOf(InvitationConflictError);
423
+ await expect(svc.acceptInvitation(token)).rejects.toBeInstanceOf(InvitationConflictError);
424
+ expect(events.emit).toHaveBeenCalledTimes(1);
425
+ });
426
+ it('two concurrent accepts: exactly one succeeds, one event (atomic conditional UPDATE)', async () => {
427
+ const token = await seedInvitation();
428
+ const events = makeEventBus();
429
+ const svc = new IamService(db.drizzle, events, makeEmail());
430
+ const results = await Promise.allSettled([
431
+ svc.acceptInvitation(token),
432
+ svc.acceptInvitation(token),
433
+ ]);
434
+ const fulfilled = results.filter((r) => r.status === 'fulfilled');
435
+ const rejected = results.filter((r) => r.status === 'rejected');
436
+ expect(fulfilled).toHaveLength(1);
437
+ expect(rejected).toHaveLength(1);
438
+ expect(rejected[0].reason).toBeInstanceOf(InvitationConflictError);
587
439
  expect(events.emit).toHaveBeenCalledTimes(1);
588
440
  });
589
441
  });
590
442
  describe('IamService.inviteAdmin', () => {
591
- const invitationRow = {
592
- id: 'inv-1',
593
- email: 'new@admin.com',
594
- roleId: 'role-1',
595
- token: 'tok-abc',
596
- status: 'pending',
597
- expiresAt: new Date(Date.now() + 86400000),
598
- acceptedAt: null,
599
- createdAt: new Date(),
600
- };
601
443
  it('creates a pending invitation and calls SEND_EMAIL when caller is super-admin', async () => {
602
- let table = {};
603
- let lastSelect;
604
- const chain = {
605
- select: vi.fn().mockImplementation((sel) => {
606
- lastSelect = sel;
607
- return chain;
608
- }),
609
- from: vi.fn().mockImplementation((tbl) => {
610
- table = tbl ?? {};
611
- return chain;
612
- }),
613
- where: vi.fn().mockImplementation(() => {
614
- // adminRoleAssignment (has userId col): caller's assignment for isSuperAdmin.
615
- if ('userId' in table) {
616
- return Promise.resolve([{ roleId: 'role-super' }]);
617
- }
618
- // adminRole id-only probe: the super-admin check.
619
- if (lastSelect && 'id' in lastSelect && Object.keys(lastSelect).length === 1) {
620
- return Promise.resolve([{ id: 'role-super' }]);
621
- }
622
- // adminRole full row: role-existence lookup.
623
- return Promise.resolve([ROLE_ROW]);
624
- }),
625
- insert: vi.fn().mockReturnThis(),
626
- values: vi.fn().mockReturnThis(),
627
- returning: vi.fn().mockResolvedValue([invitationRow]),
628
- };
629
- const drizzle = mockDb(chain);
444
+ const role = await seedRole();
630
445
  const email = makeEmail();
631
- const svc = new IamService(drizzle, makeEvents(), email);
632
- const result = await inContext(() => svc.inviteAdmin({ email: 'new@admin.com', roleId: 'role-1', caller: ADMIN_CALLER }));
446
+ const svc = new IamService(db.drizzle, makeEventBus(), email);
447
+ const result = await svc.inviteAdmin({
448
+ email: 'new@admin.com',
449
+ roleId: role.id,
450
+ caller: ADMIN_CALLER,
451
+ });
633
452
  expect(result.status).toBe('pending');
634
453
  expect(result.email).toBe('new@admin.com');
635
454
  expect(email.send).toHaveBeenCalledOnce();
636
455
  expect(email.send).toHaveBeenCalledWith(expect.objectContaining({ to: 'new@admin.com' }));
456
+ const rows = await db.drizzle.db.select().from(adminInvitation);
457
+ expect(rows).toHaveLength(1);
458
+ expect(rows[0]).toMatchObject({ email: 'new@admin.com', status: 'pending' });
637
459
  });
638
460
  it('rejects a non-super-admin caller', async () => {
639
- const drizzle = routingDrizzle({
640
- role: [ROLE_ROW],
641
- callerAssignment: [{ roleId: 'role-x' }],
642
- superRole: [],
643
- });
461
+ const role = await seedRole();
644
462
  const email = makeEmail();
645
- const svc = new IamService(drizzle, makeEvents(), email);
646
- await expect(inContext(() => svc.inviteAdmin({
647
- email: 'new@admin.com',
648
- roleId: 'role-1',
649
- caller: { userId: 'sup-1', role: 'support', ...NO_CLIENT_META },
650
- }))).rejects.toBeInstanceOf(NotSuperAdminError);
463
+ const svc = new IamService(db.drizzle, makeEventBus(), email);
464
+ await expect(svc.inviteAdmin({ email: 'new@admin.com', roleId: role.id, caller: SUPPORT_CALLER })).rejects.toBeInstanceOf(NotSuperAdminError);
651
465
  expect(email.send).not.toHaveBeenCalled();
652
466
  });
653
467
  });
654
- function paginatedDrizzle(rows, total) {
655
- const db = {
656
- select: vi.fn().mockImplementation((sel) => {
657
- const result = sel && 'count' in sel ? [{ count: total }] : rows;
658
- const chain = {
659
- then: (res) => Promise.resolve(result).then(res),
660
- from: vi.fn(() => chain),
661
- innerJoin: vi.fn(() => chain),
662
- where: vi.fn(() => chain),
663
- orderBy: vi.fn(() => chain),
664
- limit: vi.fn(() => chain),
665
- offset: vi.fn(() => chain),
666
- };
667
- return chain;
668
- }),
669
- };
670
- return mockDb(db);
671
- }
672
468
  describe('IamService paginated lists', () => {
673
469
  it('listRoles returns { items, total, page, limit }', async () => {
674
- const drizzle = paginatedDrizzle([{ ...ROLE_ROW }], 1);
675
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
676
- const result = await svc.listRoles({ page: 2, limit: 10 });
470
+ const role = await seedRole();
471
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
472
+ const result = await svc.listRoles({ page: 1, limit: 10 });
677
473
  expect(result.total).toBe(1);
678
- expect(result.page).toBe(2);
474
+ expect(result.page).toBe(1);
679
475
  expect(result.limit).toBe(10);
680
476
  expect(result.items).toHaveLength(1);
681
- expect(findOneOrThrow(result.items, new Error('expected an item')).id).toBe(ROLE_ROW.id);
477
+ expect(findOneOrThrow(result.items, new Error('expected an item')).id).toBe(role.id);
682
478
  });
683
479
  it('listInvitations returns the paginated wrapper', async () => {
684
- const invRow = {
685
- id: 'inv-1',
480
+ const role = await seedRole();
481
+ await db.drizzle.db.insert(adminInvitation).values({
686
482
  email: 'a@b.com',
687
- roleId: 'role-1',
688
- token: 'tok',
483
+ roleId: role.id,
484
+ token: randomUUID(),
689
485
  status: 'pending',
690
- expiresAt: new Date(),
691
- acceptedAt: null,
692
- createdAt: new Date(),
693
- };
694
- const drizzle = paginatedDrizzle([invRow], 5);
695
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
486
+ expiresAt: new Date(Date.now() + 60_000),
487
+ });
488
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
696
489
  const result = await svc.listInvitations({ page: 1, limit: 20 });
697
- expect(result.total).toBe(5);
490
+ expect(result.total).toBe(1);
698
491
  expect(result.items).toHaveLength(1);
699
492
  expect(findOneOrThrow(result.items, new Error('expected an item')).email).toBe('a@b.com');
700
493
  });
701
494
  it('listAssignments returns the paginated wrapper with joined role fields', async () => {
702
- const assignmentRow = {
703
- id: 'a1',
704
- userId: 'u1',
705
- roleId: 'role-1',
706
- createdAt: new Date(),
707
- roleName: 'Ops',
708
- roleKey: null,
709
- };
710
- const drizzle = paginatedDrizzle([assignmentRow], 3);
711
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
495
+ const role = await seedRole({ name: 'Ops' });
496
+ await seedAssignment(randomUUID(), role.id);
497
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
712
498
  const result = await svc.listAssignments({ page: 1, limit: 20 });
713
- expect(result.total).toBe(3);
499
+ expect(result.total).toBe(1);
714
500
  expect(result.items).toHaveLength(1);
715
501
  expect(findOneOrThrow(result.items, new Error('expected an item')).roleName).toBe('Ops');
716
502
  });
717
503
  });
718
504
  describe('IamService.forceLogout', () => {
719
505
  it('rejects a non-super-admin caller', async () => {
720
- const drizzle = routingDrizzle({
721
- role: [ROLE_ROW],
722
- assignment: [],
723
- superRole: [],
724
- });
725
- const svc = new IamService(drizzle, makeEvents(), makeEmail());
726
- await expect(inContext(() => svc.forceLogout({
727
- userId: 'target-user',
728
- caller: { userId: 'sup-1', role: 'support', ...NO_CLIENT_META },
729
- }))).rejects.toBeInstanceOf(NotSuperAdminError);
730
- });
731
- it('allows a super-admin caller to delete sessions for a user and emits identity.sessions.revoked_all', async () => {
732
- const drizzle = routingDrizzle({
733
- role: [ROLE_ROW],
734
- assignment: [],
735
- superRole: [],
736
- });
737
- const events = makeEvents();
738
- const sessionCommands = {
506
+ const svc = new IamService(db.drizzle, makeEventBus(), makeEmail());
507
+ await expect(svc.forceLogout({ userId: randomUUID(), caller: SUPPORT_CALLER })).rejects.toBeInstanceOf(NotSuperAdminError);
508
+ });
509
+ it('allows a super-admin caller to delete sessions for a user via SessionCommands', async () => {
510
+ const events = makeEventBus();
511
+ const sessionCommands = mock({
739
512
  revokeAll: vi.fn().mockResolvedValue({ success: true }),
740
- };
741
- const svc = new IamService(drizzle, events, makeEmail(), sessionCommands);
742
- const result = await inContext(() => svc.forceLogout({
743
- userId: 'target-user',
744
- caller: ADMIN_CALLER,
745
- }));
513
+ });
514
+ const svc = new IamService(db.drizzle, events, makeEmail(), sessionCommands);
515
+ const targetUserId = randomUUID();
516
+ const result = await svc.forceLogout({ userId: targetUserId, caller: ADMIN_CALLER });
746
517
  expect(result).toEqual({ success: true });
747
- expect(sessionCommands.revokeAll).toHaveBeenCalledWith('target-user', ADMIN_CALLER.userId);
518
+ expect(sessionCommands.revokeAll).toHaveBeenCalledWith(targetUserId, ADMIN_CALLER.userId);
748
519
  });
749
520
  });
750
521
  //# sourceMappingURL=iam.service.test.js.map