@cosmicdrift/kumiko-bundled-features 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (333) hide show
  1. package/package.json +90 -0
  2. package/src/audit/__tests__/audit.integration.ts +328 -0
  3. package/src/audit/constants.ts +7 -0
  4. package/src/audit/feature.ts +23 -0
  5. package/src/audit/handlers/list.query.ts +98 -0
  6. package/src/audit/index.ts +2 -0
  7. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
  8. package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
  9. package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
  10. package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
  11. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
  12. package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
  13. package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
  14. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
  15. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
  16. package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
  17. package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
  18. package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
  19. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
  20. package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
  21. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
  22. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
  23. package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
  24. package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
  25. package/src/auth-email-password/auth-user-row.ts +41 -0
  26. package/src/auth-email-password/constants.ts +101 -0
  27. package/src/auth-email-password/email-templates.ts +283 -0
  28. package/src/auth-email-password/feature.ts +140 -0
  29. package/src/auth-email-password/handlers/change-password.write.ts +58 -0
  30. package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
  31. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
  32. package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
  33. package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
  34. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
  35. package/src/auth-email-password/handlers/login.write.ts +208 -0
  36. package/src/auth-email-password/handlers/logout.write.ts +12 -0
  37. package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
  38. package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
  39. package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
  40. package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
  41. package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
  42. package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
  43. package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
  44. package/src/auth-email-password/i18n.ts +211 -0
  45. package/src/auth-email-password/identity-v3-hash.ts +97 -0
  46. package/src/auth-email-password/index.ts +35 -0
  47. package/src/auth-email-password/invite-token-store.ts +92 -0
  48. package/src/auth-email-password/lockout-store.ts +118 -0
  49. package/src/auth-email-password/password-hashing.ts +43 -0
  50. package/src/auth-email-password/reset-token.ts +28 -0
  51. package/src/auth-email-password/seeding.ts +183 -0
  52. package/src/auth-email-password/signed-token.ts +85 -0
  53. package/src/auth-email-password/signup-token-store.ts +104 -0
  54. package/src/auth-email-password/stream-tenant.ts +31 -0
  55. package/src/auth-email-password/testing.ts +5 -0
  56. package/src/auth-email-password/token-burn-store.ts +57 -0
  57. package/src/auth-email-password/verification-token.ts +27 -0
  58. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
  59. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
  60. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
  61. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
  62. package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
  63. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
  64. package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
  65. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
  66. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
  67. package/src/auth-email-password/web/auth-client.ts +350 -0
  68. package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
  69. package/src/auth-email-password/web/auth-gate.tsx +33 -0
  70. package/src/auth-email-password/web/client-plugin.ts +48 -0
  71. package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
  72. package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
  73. package/src/auth-email-password/web/index.ts +56 -0
  74. package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
  75. package/src/auth-email-password/web/login-screen.tsx +150 -0
  76. package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
  77. package/src/auth-email-password/web/session.tsx +171 -0
  78. package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
  79. package/src/auth-email-password/web/signup-screen.tsx +130 -0
  80. package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
  81. package/src/auth-email-password/web/use-shell-user.ts +34 -0
  82. package/src/auth-email-password/web/user-menu.tsx +89 -0
  83. package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
  84. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
  85. package/src/billing-foundation/__tests__/feature.test.ts +110 -0
  86. package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
  87. package/src/billing-foundation/aggregate-id.ts +21 -0
  88. package/src/billing-foundation/constants.ts +70 -0
  89. package/src/billing-foundation/entities.ts +50 -0
  90. package/src/billing-foundation/events.ts +71 -0
  91. package/src/billing-foundation/feature.ts +122 -0
  92. package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
  93. package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
  94. package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
  95. package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
  96. package/src/billing-foundation/handlers/process-event.write.ts +160 -0
  97. package/src/billing-foundation/index.ts +42 -0
  98. package/src/billing-foundation/projection.ts +135 -0
  99. package/src/billing-foundation/types.ts +157 -0
  100. package/src/billing-foundation/webhook-handler.ts +184 -0
  101. package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
  102. package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
  103. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
  104. package/src/cap-counter/aggregate-id.ts +61 -0
  105. package/src/cap-counter/constants.ts +32 -0
  106. package/src/cap-counter/enforce-cap.ts +404 -0
  107. package/src/cap-counter/entity.ts +48 -0
  108. package/src/cap-counter/feature.ts +90 -0
  109. package/src/cap-counter/handlers/get-counter.query.ts +43 -0
  110. package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
  111. package/src/cap-counter/handlers/increment.write.ts +92 -0
  112. package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
  113. package/src/cap-counter/index.ts +34 -0
  114. package/src/cap-counter/with-cap-enforcement.ts +179 -0
  115. package/src/channel-email/email-channel.ts +48 -0
  116. package/src/channel-email/feature.ts +15 -0
  117. package/src/channel-email/index.ts +4 -0
  118. package/src/channel-email/smtp-transport.ts +65 -0
  119. package/src/channel-email/types.ts +34 -0
  120. package/src/channel-in-app/constants.ts +11 -0
  121. package/src/channel-in-app/feature.ts +30 -0
  122. package/src/channel-in-app/handlers/inbox.query.ts +28 -0
  123. package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
  124. package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
  125. package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
  126. package/src/channel-in-app/in-app-channel.ts +44 -0
  127. package/src/channel-in-app/index.ts +4 -0
  128. package/src/channel-in-app/tables.ts +22 -0
  129. package/src/channel-push/feature.ts +15 -0
  130. package/src/channel-push/index.ts +3 -0
  131. package/src/channel-push/push-channel.ts +33 -0
  132. package/src/channel-push/types.ts +22 -0
  133. package/src/config/__tests__/app-overrides.test.ts +118 -0
  134. package/src/config/__tests__/config.integration.ts +1246 -0
  135. package/src/config/constants.ts +23 -0
  136. package/src/config/feature.ts +117 -0
  137. package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
  138. package/src/config/handlers/reset.write.ts +45 -0
  139. package/src/config/handlers/schema.query.ts +22 -0
  140. package/src/config/handlers/set.write.ts +93 -0
  141. package/src/config/handlers/values.query.ts +43 -0
  142. package/src/config/index.ts +15 -0
  143. package/src/config/resolver.ts +283 -0
  144. package/src/config/table.ts +35 -0
  145. package/src/config/write-helpers.ts +268 -0
  146. package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
  147. package/src/delivery/__tests__/delivery.integration.ts +1405 -0
  148. package/src/delivery/constants.ts +33 -0
  149. package/src/delivery/delivery-service.ts +489 -0
  150. package/src/delivery/events.ts +18 -0
  151. package/src/delivery/feature.ts +70 -0
  152. package/src/delivery/handlers/log.query.ts +21 -0
  153. package/src/delivery/handlers/preferences.query.ts +18 -0
  154. package/src/delivery/handlers/set-preference.write.ts +28 -0
  155. package/src/delivery/index.ts +35 -0
  156. package/src/delivery/tables.ts +74 -0
  157. package/src/delivery/testing.ts +47 -0
  158. package/src/delivery/types.ts +71 -0
  159. package/src/delivery/unsubscribe.ts +99 -0
  160. package/src/delivery/upsert-preference.ts +145 -0
  161. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
  162. package/src/feature-toggles/constants.ts +20 -0
  163. package/src/feature-toggles/events.ts +18 -0
  164. package/src/feature-toggles/feature.ts +98 -0
  165. package/src/feature-toggles/global-feature-state-table.ts +28 -0
  166. package/src/feature-toggles/handlers/list.query.ts +26 -0
  167. package/src/feature-toggles/handlers/registered.query.ts +56 -0
  168. package/src/feature-toggles/handlers/set.write.ts +158 -0
  169. package/src/feature-toggles/index.ts +9 -0
  170. package/src/feature-toggles/toggle-runtime.ts +73 -0
  171. package/src/file-foundation/__tests__/feature.test.ts +35 -0
  172. package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
  173. package/src/file-foundation/feature.ts +123 -0
  174. package/src/file-foundation/index.ts +7 -0
  175. package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
  176. package/src/file-provider-inmemory/feature.ts +73 -0
  177. package/src/file-provider-inmemory/index.ts +3 -0
  178. package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
  179. package/src/file-provider-s3/feature.ts +169 -0
  180. package/src/file-provider-s3/index.ts +3 -0
  181. package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
  182. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
  183. package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
  184. package/src/files-provider-s3/env-helper.ts +49 -0
  185. package/src/files-provider-s3/index.ts +3 -0
  186. package/src/files-provider-s3/s3-provider.ts +114 -0
  187. package/src/foundation-shared/config-helpers.ts +67 -0
  188. package/src/foundation-shared/index.ts +4 -0
  189. package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
  190. package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
  191. package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
  192. package/src/jobs/constants.ts +21 -0
  193. package/src/jobs/events.ts +39 -0
  194. package/src/jobs/feature.ts +150 -0
  195. package/src/jobs/handlers/detail.query.ts +30 -0
  196. package/src/jobs/handlers/list.query.ts +36 -0
  197. package/src/jobs/handlers/retry.write.ts +69 -0
  198. package/src/jobs/handlers/trigger.write.ts +39 -0
  199. package/src/jobs/index.ts +5 -0
  200. package/src/jobs/job-run-logger.ts +213 -0
  201. package/src/jobs/job-run-table.ts +55 -0
  202. package/src/legal-pages/README.md +195 -0
  203. package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
  204. package/src/legal-pages/constants.ts +36 -0
  205. package/src/legal-pages/feature.ts +187 -0
  206. package/src/legal-pages/index.ts +13 -0
  207. package/src/legal-pages/markdown.ts +69 -0
  208. package/src/mail-foundation/__tests__/feature.test.ts +46 -0
  209. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
  210. package/src/mail-foundation/feature.ts +160 -0
  211. package/src/mail-foundation/index.ts +14 -0
  212. package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
  213. package/src/mail-transport-inmemory/feature.ts +90 -0
  214. package/src/mail-transport-inmemory/index.ts +3 -0
  215. package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
  216. package/src/mail-transport-smtp/feature.ts +182 -0
  217. package/src/mail-transport-smtp/index.ts +3 -0
  218. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
  219. package/src/rate-limiting/constants.ts +9 -0
  220. package/src/rate-limiting/feature.ts +16 -0
  221. package/src/rate-limiting/handlers/status.query.ts +52 -0
  222. package/src/rate-limiting/index.ts +2 -0
  223. package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
  224. package/src/renderer-simple/feature.ts +12 -0
  225. package/src/renderer-simple/index.ts +2 -0
  226. package/src/renderer-simple/simple-renderer.ts +72 -0
  227. package/src/secrets/__tests__/rotate.integration.ts +176 -0
  228. package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
  229. package/src/secrets/__tests__/secrets.integration.ts +118 -0
  230. package/src/secrets/feature.ts +84 -0
  231. package/src/secrets/handlers/delete.write.ts +20 -0
  232. package/src/secrets/handlers/list.query.ts +38 -0
  233. package/src/secrets/handlers/rotate.job.ts +193 -0
  234. package/src/secrets/handlers/set.write.ts +50 -0
  235. package/src/secrets/index.ts +16 -0
  236. package/src/secrets/secrets-context.ts +296 -0
  237. package/src/secrets/table.ts +68 -0
  238. package/src/sessions/__tests__/cleanup.integration.ts +175 -0
  239. package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
  240. package/src/sessions/__tests__/sessions.integration.ts +472 -0
  241. package/src/sessions/__tests__/test-helpers.ts +66 -0
  242. package/src/sessions/constants.ts +43 -0
  243. package/src/sessions/feature.ts +84 -0
  244. package/src/sessions/handlers/cleanup.job.ts +109 -0
  245. package/src/sessions/handlers/list.query.ts +35 -0
  246. package/src/sessions/handlers/mine.query.ts +37 -0
  247. package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
  248. package/src/sessions/handlers/revoke.write.ts +76 -0
  249. package/src/sessions/index.ts +17 -0
  250. package/src/sessions/schema/index.ts +5 -0
  251. package/src/sessions/schema/user-session.ts +67 -0
  252. package/src/sessions/session-callbacks.ts +110 -0
  253. package/src/sessions/testing.ts +42 -0
  254. package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
  255. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
  256. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
  257. package/src/subscription-mollie/constants.ts +33 -0
  258. package/src/subscription-mollie/feature.ts +144 -0
  259. package/src/subscription-mollie/index.ts +13 -0
  260. package/src/subscription-mollie/plugin-methods.ts +79 -0
  261. package/src/subscription-mollie/verify-webhook.ts +244 -0
  262. package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
  263. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
  264. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
  265. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
  266. package/src/subscription-stripe/constants.ts +20 -0
  267. package/src/subscription-stripe/feature.ts +120 -0
  268. package/src/subscription-stripe/index.ts +14 -0
  269. package/src/subscription-stripe/plugin-methods.ts +91 -0
  270. package/src/subscription-stripe/verify-webhook.ts +235 -0
  271. package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
  272. package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
  273. package/src/tenant/__tests__/tenant.integration.ts +347 -0
  274. package/src/tenant/command-schemas.ts +37 -0
  275. package/src/tenant/constants.ts +37 -0
  276. package/src/tenant/feature.ts +109 -0
  277. package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
  278. package/src/tenant/handlers/add-member.write.ts +53 -0
  279. package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
  280. package/src/tenant/handlers/create.write.ts +21 -0
  281. package/src/tenant/handlers/disable.write.ts +18 -0
  282. package/src/tenant/handlers/invitations.query.ts +31 -0
  283. package/src/tenant/handlers/list.query.ts +17 -0
  284. package/src/tenant/handlers/me.query.ts +17 -0
  285. package/src/tenant/handlers/members.query.ts +22 -0
  286. package/src/tenant/handlers/memberships.query.ts +24 -0
  287. package/src/tenant/handlers/remove-member.write.ts +40 -0
  288. package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
  289. package/src/tenant/handlers/update-member-roles.write.ts +54 -0
  290. package/src/tenant/handlers/update.write.ts +20 -0
  291. package/src/tenant/index.ts +12 -0
  292. package/src/tenant/invitation-table.ts +93 -0
  293. package/src/tenant/membership-table.ts +35 -0
  294. package/src/tenant/schema/index.ts +5 -0
  295. package/src/tenant/schema/tenant.ts +27 -0
  296. package/src/tenant/seeding.ts +155 -0
  297. package/src/tenant/testing.ts +8 -0
  298. package/src/text-content/README.md +190 -0
  299. package/src/text-content/__tests__/text-content.integration.ts +415 -0
  300. package/src/text-content/api.ts +92 -0
  301. package/src/text-content/constants.ts +19 -0
  302. package/src/text-content/feature.ts +29 -0
  303. package/src/text-content/handlers/by-slug.query.ts +55 -0
  304. package/src/text-content/handlers/set.write.ts +118 -0
  305. package/src/text-content/index.ts +14 -0
  306. package/src/text-content/seeding.ts +91 -0
  307. package/src/text-content/table.ts +45 -0
  308. package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
  309. package/src/tier-engine/__tests__/drift.test.ts +42 -0
  310. package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
  311. package/src/tier-engine/aggregate-id.ts +27 -0
  312. package/src/tier-engine/compose-app.ts +150 -0
  313. package/src/tier-engine/constants.ts +15 -0
  314. package/src/tier-engine/entity.ts +30 -0
  315. package/src/tier-engine/feature.ts +72 -0
  316. package/src/tier-engine/handlers/active-tier.query.ts +23 -0
  317. package/src/tier-engine/index.ts +22 -0
  318. package/src/user/__tests__/seed-testing.integration.ts +127 -0
  319. package/src/user/__tests__/user.integration.ts +198 -0
  320. package/src/user/command-schemas.ts +15 -0
  321. package/src/user/constants.ts +23 -0
  322. package/src/user/feature.ts +32 -0
  323. package/src/user/handlers/create.write.ts +54 -0
  324. package/src/user/handlers/detail.query.ts +9 -0
  325. package/src/user/handlers/find-for-auth.query.ts +38 -0
  326. package/src/user/handlers/list.query.ts +8 -0
  327. package/src/user/handlers/me.query.ts +15 -0
  328. package/src/user/handlers/update.write.ts +54 -0
  329. package/src/user/index.ts +4 -0
  330. package/src/user/schema/index.ts +5 -0
  331. package/src/user/schema/user.ts +69 -0
  332. package/src/user/seeding.ts +93 -0
  333. package/src/user/testing.ts +5 -0
@@ -0,0 +1,256 @@
1
+ // Multi-Rollen — globale user.roles parallel zu tenant-membership.roles.
2
+ //
3
+ // Pin: ein User mit `users.roles = ["SystemAdmin"]` + Membership auf
4
+ // Tenant A mit ["Admin"] hat in der Session BEIDE Rollen. Switch zu
5
+ // Tenant B mit ["User"] → Session hat ["SystemAdmin", "User"]. Globale
6
+ // Rollen bleiben tenant-unabhängig stabil.
7
+ //
8
+ // Gegen-Beweis: User OHNE globale Rollen verhält sich wie vorher
9
+ // (nur tenant-membership-roles in der Session).
10
+
11
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
+ import {
13
+ createEntityTable,
14
+ pushTables,
15
+ setupTestStack,
16
+ type TestStack,
17
+ TestUsers,
18
+ testTenantId,
19
+ } from "@cosmicdrift/kumiko-framework/stack";
20
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
21
+ import { createConfigFeature } from "../../config";
22
+ import { createConfigResolver } from "../../config/resolver";
23
+ import { configValuesTable } from "../../config/table";
24
+ import { createTenantFeature } from "../../tenant";
25
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
26
+ import { tenantEntity } from "../../tenant/schema/tenant";
27
+ import { UserHandlers, UserQueries } from "../../user";
28
+ import { createUserFeature } from "../../user/feature";
29
+ import { userEntity, userTable } from "../../user/schema/user";
30
+ import { AuthErrors, AuthHandlers } from "../constants";
31
+ import { createAuthEmailPasswordFeature } from "../feature";
32
+ import { hashPassword } from "../password-hashing";
33
+
34
+ let stack: TestStack;
35
+ const systemAdmin = TestUsers.systemAdmin;
36
+ const tenantA: TenantId = testTenantId(1);
37
+ const tenantB: TenantId = testTenantId(2);
38
+
39
+ beforeAll(async () => {
40
+ const resolver = createConfigResolver();
41
+
42
+ stack = await setupTestStack({
43
+ features: [
44
+ createConfigFeature(),
45
+ createUserFeature(),
46
+ createTenantFeature(),
47
+ createAuthEmailPasswordFeature(),
48
+ ],
49
+ extraContext: { configResolver: resolver },
50
+ authConfig: {
51
+ membershipQuery: "tenant:query:memberships",
52
+ // KRITISCH: ohne userQuery wired ruft switch-tenant keinen
53
+ // user-row-lookup → globale Rollen leaken nicht durch zum neuen
54
+ // tenant. Hier explizit setzen damit der merge greift.
55
+ userQuery: UserQueries.findForAuth,
56
+ loginHandler: AuthHandlers.login,
57
+ loginErrorStatusMap: {
58
+ [AuthErrors.invalidCredentials]: 401,
59
+ [AuthErrors.noMembership]: 403,
60
+ },
61
+ },
62
+ });
63
+
64
+ await createEntityTable(stack.db, userEntity);
65
+ await createEntityTable(stack.db, tenantEntity);
66
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
67
+ });
68
+
69
+ afterAll(async () => {
70
+ await stack.cleanup();
71
+ });
72
+
73
+ beforeEach(async () => {
74
+ await stack.db.delete(userTable);
75
+ await stack.db.delete(tenantMembershipsTable);
76
+ });
77
+
78
+ async function seedUser(
79
+ email: string,
80
+ password: string,
81
+ globalRoles: readonly string[] = [],
82
+ ): Promise<string> {
83
+ const hash = await hashPassword(password);
84
+ const created = await stack.http.writeOk<{ id: string }>(
85
+ UserHandlers.create,
86
+ {
87
+ email,
88
+ passwordHash: hash,
89
+ displayName: email.split("@")[0] ?? "user",
90
+ // user.roles im create wird privileged geprüft — systemAdmin hat
91
+ // SystemAdmin-Rolle (siehe TestUsers.systemAdmin).
92
+ roles: JSON.stringify(globalRoles),
93
+ },
94
+ systemAdmin,
95
+ );
96
+ return created.id;
97
+ }
98
+
99
+ async function addMembership(
100
+ userId: string,
101
+ tenantId: TenantId,
102
+ roles: readonly string[],
103
+ ): Promise<void> {
104
+ await stack.db.insert(tenantMembershipsTable).values({
105
+ userId,
106
+ tenantId,
107
+ roles: JSON.stringify(roles),
108
+ });
109
+ }
110
+
111
+ async function login(
112
+ email: string,
113
+ password: string,
114
+ ): Promise<{ token: string; user: { id: string; tenantId: string; roles: string[] } }> {
115
+ const res = await stack.http.raw("POST", "/api/auth/login", { email, password });
116
+ expect(res.status).toBe(200);
117
+ const body = (await res.json()) as {
118
+ token: string;
119
+ user: { id: string; tenantId: string; roles: string[] };
120
+ };
121
+ return body;
122
+ }
123
+
124
+ describe("multi-roles: login mergt globale + membership-roles", () => {
125
+ test("user mit ['SystemAdmin'] global + ['Admin'] auf tenantA → session hat beide", async () => {
126
+ const userId = await seedUser("syadmin@example.com", "pw-long-enough", ["SystemAdmin"]);
127
+ await addMembership(userId, tenantA, ["Admin"]);
128
+
129
+ // Pin write-path: roles MUSS in DB landen, sonst ist der session-merge
130
+ // nur Zufall (z.B. wenn login-handler hardcoded SystemAdmin reinpacken
131
+ // würde). Direct DB-read schließt das aus.
132
+ const { eq } = await import("drizzle-orm");
133
+ const dbRow = await stack.db
134
+ .select({ roles: userTable["roles"] })
135
+ .from(userTable)
136
+ .where(eq(userTable["id"], userId));
137
+ expect(dbRow[0]?.roles).toBe(JSON.stringify(["SystemAdmin"]));
138
+
139
+ const { user } = await login("syadmin@example.com", "pw-long-enough");
140
+ expect(user.tenantId).toBe(tenantA);
141
+ expect(user.roles.sort()).toEqual(["Admin", "SystemAdmin"]);
142
+ });
143
+
144
+ test("user OHNE globale rollen → nur membership-roles in der session", async () => {
145
+ const userId = await seedUser("plain@example.com", "pw-long-enough");
146
+ await addMembership(userId, tenantA, ["Admin"]);
147
+
148
+ const { user } = await login("plain@example.com", "pw-long-enough");
149
+ expect(user.roles).toEqual(["Admin"]);
150
+ });
151
+
152
+ test("globale rollen + tenant-rollen mit overlap → dedupliziert (kein doppeltes Admin)", async () => {
153
+ const userId = await seedUser("dup@example.com", "pw-long-enough", ["Admin", "SystemAdmin"]);
154
+ await addMembership(userId, tenantA, ["Admin", "User"]);
155
+
156
+ const { user } = await login("dup@example.com", "pw-long-enough");
157
+ expect(user.roles.sort()).toEqual(["Admin", "SystemAdmin", "User"]);
158
+ });
159
+ });
160
+
161
+ describe("multi-roles: privilege-escalation blocked via field-level write-access", () => {
162
+ test("Tenant-Admin (ohne SystemAdmin) kann user.roles NICHT setzen via update", async () => {
163
+ // Setup: User mit ["Admin"] auf tenantA (Tenant-Admin) + ein Target-User
164
+ // mit leeren globalRoles. Tenant-Admin versucht den Target via
165
+ // user:write:user:update auf SystemAdmin zu eskalieren.
166
+ const adminId = await seedUser("ta@example.com", "pw-long-enough");
167
+ await addMembership(adminId, tenantA, ["Admin"]);
168
+ const tenantAdminSession = { id: adminId, tenantId: tenantA, roles: ["Admin"] };
169
+
170
+ // Target user mit version 1 (frisch erstellt).
171
+ const targetId = await seedUser("victim@example.com", "pw-long-enough");
172
+
173
+ const res = await stack.http.write(
174
+ UserHandlers.update,
175
+ {
176
+ id: targetId,
177
+ version: 1,
178
+ changes: { roles: JSON.stringify(["SystemAdmin"]) },
179
+ },
180
+ tenantAdminSession,
181
+ );
182
+ // Field-level guard greift VOR dem handler — AccessDeniedError mit
183
+ // field=roles. Das pinst: kein "silent drop", sondern hard-fail.
184
+ expect(res.status).toBeGreaterThanOrEqual(400);
185
+ expect(res.status).toBeLessThan(500);
186
+ const body = (await res.json()) as {
187
+ error?: { details?: { field?: string; reason?: string } };
188
+ };
189
+ expect(body.error?.details?.field).toBe("roles");
190
+ expect(body.error?.details?.reason).toBe("field_access_denied");
191
+ });
192
+
193
+ test("Self-update mit roles → blocked (kein Privilege-Selbst-Erteilung)", async () => {
194
+ // Auch wenn isSelf-check im handler den ownership-guard umgeht,
195
+ // greift der field-level write-access auf roles unabhängig: User
196
+ // kann sich nicht selbst zum SystemAdmin machen.
197
+ const userId = await seedUser("selfescalate@example.com", "pw-long-enough");
198
+ await addMembership(userId, tenantA, ["User"]);
199
+ const userSession = { id: userId, tenantId: tenantA, roles: ["User"] };
200
+
201
+ const res = await stack.http.write(
202
+ UserHandlers.update,
203
+ {
204
+ id: userId,
205
+ version: 1,
206
+ changes: { roles: JSON.stringify(["SystemAdmin"]) },
207
+ },
208
+ userSession,
209
+ );
210
+ expect(res.status).toBeGreaterThanOrEqual(400);
211
+ expect(res.status).toBeLessThan(500);
212
+ const body = (await res.json()) as {
213
+ error?: { details?: { field?: string } };
214
+ };
215
+ expect(body.error?.details?.field).toBe("roles");
216
+ });
217
+ });
218
+
219
+ describe("multi-roles: switch-tenant erhält globale rollen", () => {
220
+ test("switch von tenantA → tenantB → SystemAdmin bleibt, tenant-roles wechseln", async () => {
221
+ const userId = await seedUser("syadmin2@example.com", "pw-long-enough", ["SystemAdmin"]);
222
+ await addMembership(userId, tenantA, ["Admin"]);
223
+ await addMembership(userId, tenantB, ["User"]);
224
+
225
+ const { token } = await login("syadmin2@example.com", "pw-long-enough");
226
+
227
+ const switchRes = await stack.http.raw(
228
+ "POST",
229
+ "/api/auth/switch-tenant",
230
+ { tenantId: tenantB },
231
+ { authorization: `Bearer ${token}` },
232
+ );
233
+ expect(switchRes.status).toBe(200);
234
+ const switchBody = (await switchRes.json()) as { tenantId: string; roles: string[] };
235
+ expect(switchBody.tenantId).toBe(tenantB);
236
+ expect([...switchBody.roles].sort()).toEqual(["SystemAdmin", "User"]);
237
+ });
238
+
239
+ test("switch ohne globale rollen → roles wechseln 1:1 zu membership", async () => {
240
+ const userId = await seedUser("plain2@example.com", "pw-long-enough");
241
+ await addMembership(userId, tenantA, ["Admin"]);
242
+ await addMembership(userId, tenantB, ["User"]);
243
+
244
+ const { token } = await login("plain2@example.com", "pw-long-enough");
245
+
246
+ const switchRes = await stack.http.raw(
247
+ "POST",
248
+ "/api/auth/switch-tenant",
249
+ { tenantId: tenantB },
250
+ { authorization: `Bearer ${token}` },
251
+ );
252
+ expect(switchRes.status).toBe(200);
253
+ const switchBody = (await switchRes.json()) as { roles: string[] };
254
+ expect(switchBody.roles).toEqual(["User"]);
255
+ });
256
+ });
@@ -0,0 +1,346 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ createEntityTable,
6
+ pushTables,
7
+ setupTestStack,
8
+ type TestStack,
9
+ TestUsers,
10
+ } from "@cosmicdrift/kumiko-framework/stack";
11
+ import { Temporal } from "temporal-polyfill";
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
13
+ import { createConfigFeature } from "../../config";
14
+ import { createConfigResolver } from "../../config/resolver";
15
+ import { configValuesTable } from "../../config/table";
16
+ import { createSessionsFeature, userSessionTable } from "../../sessions";
17
+ import { createTenantFeature } from "../../tenant";
18
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
19
+ import { tenantEntity } from "../../tenant/schema/tenant";
20
+ import { seedTenantMembership } from "../../tenant/testing";
21
+ import { UserHandlers } from "../../user";
22
+ import { createUserFeature } from "../../user/feature";
23
+ import { userEntity, userTable } from "../../user/schema/user";
24
+ import { AuthErrors, AuthHandlers } from "../constants";
25
+ import { createAuthEmailPasswordFeature } from "../feature";
26
+ import { hashPassword, verifyPassword } from "../password-hashing";
27
+ import { signResetToken } from "../reset-token";
28
+
29
+ // Signed tokens are forwarded out-of-band (email). In the test we grab them
30
+ // from the sendResetEmail callback instead.
31
+ const capturedEmails: Array<{ email: string; resetUrl: string; expiresAt: string }> = [];
32
+
33
+ // Records the userId every time the sessions feature's auto-revoke hook
34
+ // fires after a password change. The session-revoke tests assert on this
35
+ // list — we don't need a full session store, just proof the hook fired.
36
+ const autoRevokeCalls: string[] = [];
37
+
38
+ let stack: TestStack;
39
+ const systemAdmin = TestUsers.systemAdmin;
40
+ const encryptionKey = randomBytes(32).toString("base64");
41
+ const resetSecret = randomBytes(32).toString("base64");
42
+ const appResetUrl = "https://app.example.com/reset";
43
+
44
+ beforeAll(async () => {
45
+ const encryption = createEncryptionProvider(encryptionKey);
46
+ const resolver = createConfigResolver({ encryption });
47
+
48
+ stack = await setupTestStack({
49
+ features: [
50
+ createConfigFeature(),
51
+ createUserFeature(),
52
+ createTenantFeature(),
53
+ createAuthEmailPasswordFeature({
54
+ passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15 },
55
+ }),
56
+ // Sessions feature wires the cross-feature entityHook on
57
+ // "user.postSave" that triggers autoRevokeOnPasswordChange whenever
58
+ // the passwordHash delta is present. Integration-test proves the
59
+ // reset-flow's changes.passwordHash triggers the same hook.
60
+ createSessionsFeature({
61
+ autoRevokeOnPasswordChange: async (userId) => {
62
+ autoRevokeCalls.push(userId);
63
+ return 0; // no real session store behind this spy
64
+ },
65
+ }),
66
+ ],
67
+ extraContext: { configResolver: resolver, configEncryption: encryption },
68
+ authConfig: {
69
+ membershipQuery: "tenant:query:memberships",
70
+ loginHandler: AuthHandlers.login,
71
+ passwordReset: {
72
+ requestHandler: AuthHandlers.requestPasswordReset,
73
+ confirmHandler: AuthHandlers.resetPassword,
74
+ appResetUrl,
75
+ sendResetEmail: async (args) => {
76
+ capturedEmails.push(args);
77
+ },
78
+ },
79
+ },
80
+ });
81
+
82
+ await createEntityTable(stack.db, userEntity);
83
+ await createEntityTable(stack.db, tenantEntity);
84
+ await pushTables(stack.db, {
85
+ configValuesTable,
86
+ tenantMembershipsTable,
87
+ userSessionTable,
88
+ });
89
+ });
90
+
91
+ afterAll(async () => {
92
+ await stack.cleanup();
93
+ });
94
+
95
+ beforeEach(async () => {
96
+ await stack.db.delete(userTable);
97
+ await stack.db.delete(tenantMembershipsTable);
98
+ await stack.db.delete(userSessionTable);
99
+ capturedEmails.length = 0;
100
+ autoRevokeCalls.length = 0;
101
+ });
102
+
103
+ async function seedUser(opts: {
104
+ email: string;
105
+ password: string;
106
+ tenantId?: TenantId;
107
+ }): Promise<{ id: string; tenantId: TenantId }> {
108
+ const hash = await hashPassword(opts.password);
109
+ const created = await stack.http.writeOk<{ id: string }>(
110
+ UserHandlers.create,
111
+ {
112
+ email: opts.email,
113
+ passwordHash: hash,
114
+ displayName: opts.email.split("@")[0] ?? "user",
115
+ },
116
+ systemAdmin,
117
+ );
118
+ const tenantId = opts.tenantId ?? "00000000-0000-4000-8000-000000000001";
119
+ await seedTenantMembership(stack.db, {
120
+ userId: created.id,
121
+ tenantId,
122
+ roles: ["User"],
123
+ });
124
+ return { id: created.id, tenantId };
125
+ }
126
+
127
+ async function post(path: string, body: unknown): Promise<Response> {
128
+ return stack.http.raw("POST", path, body);
129
+ }
130
+
131
+ // --- request-password-reset -----------------------------------------------
132
+
133
+ describe("POST /auth/request-password-reset", () => {
134
+ test("known email → 200, email callback invoked with reset URL", async () => {
135
+ await seedUser({ email: "alice@example.com", password: "initial-pw!" });
136
+
137
+ const res = await post("/api/auth/request-password-reset", { email: "alice@example.com" });
138
+
139
+ expect(res.status).toBe(200);
140
+ expect(await res.json()).toEqual({ isSuccess: true });
141
+ expect(capturedEmails).toHaveLength(1);
142
+ const [captured] = capturedEmails;
143
+ if (!captured) throw new Error("no email captured");
144
+ expect(captured.email).toBe("alice@example.com");
145
+ expect(captured.resetUrl.startsWith(`${appResetUrl}?token=`)).toBe(true);
146
+ expect(typeof captured.expiresAt).toBe("string");
147
+ });
148
+
149
+ test("unknown email → 200 with NO sendResetEmail side-effect (enumeration-safe)", async () => {
150
+ const res = await post("/api/auth/request-password-reset", { email: "ghost@example.com" });
151
+
152
+ expect(res.status).toBe(200);
153
+ expect(await res.json()).toEqual({ isSuccess: true });
154
+ expect(capturedEmails).toHaveLength(0);
155
+ });
156
+
157
+ test("malformed body → 200 (silent success, no enumeration via error shape)", async () => {
158
+ const res = await post("/api/auth/request-password-reset", { wrong: "shape" });
159
+ expect(res.status).toBe(200);
160
+ expect(await res.json()).toEqual({ isSuccess: true });
161
+ expect(capturedEmails).toHaveLength(0);
162
+ });
163
+ });
164
+
165
+ // --- reset-password --------------------------------------------------------
166
+
167
+ describe("POST /auth/reset-password", () => {
168
+ test("valid token → password set; login works with new password", async () => {
169
+ const seed = await seedUser({ email: "bob@example.com", password: "old-pw-1234" });
170
+
171
+ // Generate the token the same way the handler does — bypassing the email
172
+ // hop keeps the test deterministic.
173
+ const { token } = signResetToken(seed.id, 15, resetSecret);
174
+
175
+ const res = await post("/api/auth/reset-password", {
176
+ token,
177
+ newPassword: "brand-new-pw-9876",
178
+ });
179
+
180
+ expect(res.status).toBe(200);
181
+ expect(await res.json()).toEqual({ isSuccess: true });
182
+
183
+ // Proof: the new password actually hashes in. Read the row, verify the
184
+ // hash matches the new plaintext.
185
+ const row = (await stack.db.select().from(userTable)).find((r) => r["id"] === seed.id);
186
+ if (!row?.["passwordHash"]) throw new Error("user row / hash missing");
187
+ expect(await verifyPassword(row["passwordHash"] as string, "brand-new-pw-9876")).toBe(true);
188
+ expect(await verifyPassword(row["passwordHash"] as string, "old-pw-1234")).toBe(false);
189
+ });
190
+
191
+ test("tampered token → 422 invalid_reset_token", async () => {
192
+ const seed = await seedUser({ email: "carol@example.com", password: "keep-me!" });
193
+ const { token } = signResetToken(seed.id, 15, resetSecret);
194
+ const tampered = `${token.slice(0, -3)}XXX`;
195
+
196
+ const res = await post("/api/auth/reset-password", {
197
+ token: tampered,
198
+ newPassword: "new-password-1234",
199
+ });
200
+
201
+ expect(res.status).toBe(422);
202
+ const body = await res.json();
203
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
204
+
205
+ // Old password still wins.
206
+ const row = (await stack.db.select().from(userTable)).find((r) => r["id"] === seed.id);
207
+ if (!row?.["passwordHash"]) throw new Error("user row / hash missing");
208
+ expect(await verifyPassword(row["passwordHash"] as string, "keep-me!")).toBe(true);
209
+ });
210
+
211
+ test("token signed with different secret → 422 (not auth via other deployments' tokens)", async () => {
212
+ const seed = await seedUser({ email: "dave@example.com", password: "original" });
213
+ const { token } = signResetToken(seed.id, 15, "wrong-secret-wrong-secret-wrong!!");
214
+
215
+ const res = await post("/api/auth/reset-password", {
216
+ token,
217
+ newPassword: "should-not-stick-1234",
218
+ });
219
+
220
+ expect(res.status).toBe(422);
221
+ });
222
+
223
+ test("too-short newPassword → 400 (schema rejects <8 chars)", async () => {
224
+ const seed = await seedUser({ email: "eve@example.com", password: "original" });
225
+ const { token } = signResetToken(seed.id, 15, resetSecret);
226
+
227
+ const res = await post("/api/auth/reset-password", {
228
+ token,
229
+ newPassword: "tiny",
230
+ });
231
+
232
+ expect(res.status).toBe(400);
233
+ });
234
+
235
+ test("expired token via the route → 422 invalid_reset_token", async () => {
236
+ const seed = await seedUser({ email: "time@example.com", password: "once-valid-1234" });
237
+ // Sign with now set far in the past so expiry already fired.
238
+ const past = Temporal.Now.instant().subtract({ minutes: 30 });
239
+ const { token } = signResetToken(seed.id, 15, resetSecret, past);
240
+
241
+ const res = await post("/api/auth/reset-password", {
242
+ token,
243
+ newPassword: "brand-new-pw-time",
244
+ });
245
+
246
+ expect(res.status).toBe(422);
247
+ const body = await res.json();
248
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
249
+ });
250
+
251
+ test("reset that fails before the write is retryable (burn is released on failure)", async () => {
252
+ // The burn marker goes down BEFORE the state change so a racing replay
253
+ // can't slip through. But if the state change itself fails — e.g. no
254
+ // memberships in the row, every tenant stream rejected, DB error —
255
+ // the token was never actually consumed. The handler releases the
256
+ // burn in those branches so the user can click the link again
257
+ // without hitting a stuck "already-used".
258
+ //
259
+ // Repro: drop the user's membership → tenantOrder is empty →
260
+ // invalidToken + unburn. Re-insert membership → second attempt
261
+ // with the same token succeeds (proves the burn was released).
262
+ const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
263
+ const { token } = signResetToken(seed.id, 15, resetSecret);
264
+
265
+ await stack.db.delete(tenantMembershipsTable);
266
+ const firstAttempt = await post("/api/auth/reset-password", {
267
+ token,
268
+ newPassword: "never-lands-1234",
269
+ });
270
+ expect(firstAttempt.status).toBe(422);
271
+
272
+ // Re-insert the membership. Same userId, same token still valid.
273
+ await seedTenantMembership(stack.db, {
274
+ userId: seed.id,
275
+ tenantId: seed.tenantId,
276
+ roles: ["User"],
277
+ });
278
+
279
+ const secondAttempt = await post("/api/auth/reset-password", {
280
+ token,
281
+ newPassword: "finally-lands-1234",
282
+ });
283
+ expect(secondAttempt.status).toBe(200);
284
+ });
285
+
286
+ test("replayed reset-token → 422 invalid_reset_token (single-use burn)", async () => {
287
+ // Reset tokens are single-use: the handler burns them in Redis via
288
+ // SETNX before the state change. First click wins; replay within TTL
289
+ // collapses to the same invalid_reset_token code as a tampered or
290
+ // expired token — no leak that "this token was legitimately used".
291
+ const seed = await seedUser({ email: "twice@example.com", password: "first-pw-1234" });
292
+ const { token } = signResetToken(seed.id, 15, resetSecret);
293
+
294
+ const first = await post("/api/auth/reset-password", { token, newPassword: "next-pw-1234" });
295
+ expect(first.status).toBe(200);
296
+
297
+ const second = await post("/api/auth/reset-password", {
298
+ token,
299
+ newPassword: "yet-another-pw-1234",
300
+ });
301
+ expect(second.status).toBe(422);
302
+ const body = await second.json();
303
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
304
+ });
305
+ });
306
+
307
+ // --- session auto-revoke (H.3 cross-feature hook) -------------------------
308
+
309
+ describe("reset-password triggers session auto-revoke", () => {
310
+ test("successful reset fires the sessions-feature entityHook on user", async () => {
311
+ const seed = await seedUser({
312
+ email: "revokeme@example.com",
313
+ password: "hack-exposed-1234",
314
+ });
315
+ const { token } = signResetToken(seed.id, 15, resetSecret);
316
+
317
+ const res = await post("/api/auth/reset-password", {
318
+ token,
319
+ newPassword: "fresh-secure-1234",
320
+ });
321
+ expect(res.status).toBe(200);
322
+
323
+ // The sessions feature registered r.entityHook("postSave", "user", ...)
324
+ // with autoRevokeOnPasswordChange. Reset writes changes.passwordHash
325
+ // through user:update → hook fires → spy records the userId. Without
326
+ // this assertion the commit's "session revocation" claim is unverified.
327
+ expect(autoRevokeCalls).toEqual([seed.id]);
328
+ });
329
+
330
+ test("failed reset (invalid token) does NOT trigger auto-revoke", async () => {
331
+ const seed = await seedUser({
332
+ email: "keepme@example.com",
333
+ password: "still-mine-1234",
334
+ });
335
+
336
+ const res = await post("/api/auth/reset-password", {
337
+ token: "fake.1234567890.whatever",
338
+ newPassword: "does-not-matter-1234",
339
+ });
340
+ expect(res.status).toBe(422);
341
+ // No passwordHash write → no hook → no revoke. Otherwise a garbage-
342
+ // token spammer could log everyone out.
343
+ expect(autoRevokeCalls).toEqual([]);
344
+ expect(seed.id).toBeTruthy(); // silence lint on unused var
345
+ });
346
+ });