@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,85 @@
1
+ // HMAC-signed single-purpose tokens for out-of-band auth flows
2
+ // (password-reset, email-verification, future: magic-link).
3
+ //
4
+ // Format: <userId>.<expiresAtMs>.<hmac-base64url>
5
+ //
6
+ // The `purpose` is mixed INTO the HMAC input so a token minted for one
7
+ // purpose (e.g. password-reset) can't be replayed against an endpoint that
8
+ // expects another (e.g. verify-email), even if the caller knows the
9
+ // userId and a valid expiry. Purpose is NOT carried in the token body —
10
+ // verify() takes the purpose as argument and recomputes.
11
+ //
12
+ // Timing-safe comparison on verify so a valid-length forgery can't leak
13
+ // signal through a short-circuit.
14
+
15
+ import { createHmac, timingSafeEqual } from "node:crypto";
16
+ import { Temporal } from "temporal-polyfill";
17
+
18
+ export type VerifyResult =
19
+ | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
20
+ | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
21
+
22
+ function sign(input: string, secret: string): string {
23
+ return createHmac("sha256", secret).update(input).digest("base64url");
24
+ }
25
+
26
+ function payload(purpose: string, userId: string, expiresAtMs: number): string {
27
+ return `${purpose}:${userId}.${expiresAtMs}`;
28
+ }
29
+
30
+ export function signToken(
31
+ userId: string,
32
+ purpose: string,
33
+ ttlMinutes: number,
34
+ secret: string,
35
+ now: Temporal.Instant = Temporal.Now.instant(),
36
+ ): { token: string; expiresAt: Temporal.Instant } {
37
+ const expiresAt = now.add({ minutes: ttlMinutes });
38
+ const expiresAtMs = expiresAt.epochMilliseconds;
39
+ const signature = sign(payload(purpose, userId, expiresAtMs), secret);
40
+ return {
41
+ token: `${userId}.${expiresAtMs}.${signature}`,
42
+ expiresAt,
43
+ };
44
+ }
45
+
46
+ export function verifyToken(
47
+ token: string,
48
+ purpose: string,
49
+ secret: string,
50
+ now: Temporal.Instant = Temporal.Now.instant(),
51
+ ): VerifyResult {
52
+ const parts = token.split(".");
53
+ if (parts.length !== 3) return { ok: false, reason: "malformed" };
54
+ const [userId, expiresAtRaw, providedSig] = parts;
55
+ if (!userId || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
56
+
57
+ const expiresAtMs = Number(expiresAtRaw);
58
+ if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
59
+ return { ok: false, reason: "malformed" };
60
+ }
61
+
62
+ const expected = sign(payload(purpose, userId, expiresAtMs), secret);
63
+ const expectedBuf = Buffer.from(expected, "base64url");
64
+ const providedBuf = Buffer.from(providedSig, "base64url");
65
+ // Length mismatch fails BEFORE timingSafeEqual, which throws on different
66
+ // lengths — but that throw itself leaks via timing. Explicit length check
67
+ // keeps the path uniform.
68
+ if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
69
+ if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
70
+
71
+ if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
72
+ return { ok: false, reason: "expired" };
73
+ }
74
+
75
+ // expiresAtMs surfaces so callers (burn-store TTL, telemetry, …) don't
76
+ // have to re-parse the token themselves.
77
+ return { ok: true, userId, expiresAtMs };
78
+ }
79
+
80
+ // Canonical purposes baked into the framework. Features that introduce new
81
+ // flows extend this set (or just pass an inline string).
82
+ export const TokenPurpose = {
83
+ passwordReset: "reset",
84
+ emailVerification: "verify",
85
+ } as const;
@@ -0,0 +1,104 @@
1
+ // Redis-backed Pre-Activation-Token-Store für Magic-Link-Signup.
2
+ //
3
+ // Token-Material: opaque random 256-bit aus crypto.randomBytes
4
+ // (siehe signup-request.write.ts → generateToken() aus framework/api).
5
+ // Base64url-codiert zu 43 chars. NICHT no-confusable und NICHT für
6
+ // menschliches Tippen — der User klickt den Mail-Link, niemand tippt
7
+ // den Token ab.
8
+ //
9
+ // Anders als reset/verify-Tokens (HMAC-signed, stateless verifizierbar)
10
+ // brauchen Signup-Tokens einen serverside Lookup: der User existiert
11
+ // noch nicht, also gibt's keinen userId-claim den der HMAC binden
12
+ // könnte. Wir mappen daher Token ↔ Email bidirektional in Redis und
13
+ // löschen das Pair beim Confirm. Bidirektional weil:
14
+ // - by-token: confirm-handler braucht Token → Email
15
+ // - by-email: signup-request muss bei Resend einen existierenden
16
+ // Token wiederverwenden statt einen zweiten parallel laufen zu
17
+ // lassen (sonst hätte der User zwei Mails mit zwei verschiedenen
18
+ // Tokens, beide gültig, beide könnten zu zwei separaten Tenants
19
+ // führen wenn er beide klickt — unnötiges Risiko)
20
+ //
21
+ // TTL-Refresh bei Resend: wenn der Token noch lebt, refreshen wir
22
+ // einfach beide Keys auf die volle TTL — der User bekommt eine neue
23
+ // Mail mit dem GLEICHEN Token, alte Mail bleibt gültig (idempotent
24
+ // für den User).
25
+ //
26
+ // Keine Kollision mit reset/verify-Tokens: alle Signup-Keys haben
27
+ // `signup:`-Prefix.
28
+
29
+ import type Redis from "ioredis";
30
+
31
+ const TOKEN_KEY_PREFIX = "signup:by-token:";
32
+ const EMAIL_KEY_PREFIX = "signup:by-email:";
33
+ const BURN_KEY_PREFIX = "signup:burn:";
34
+
35
+ /** Email-Normalisierung — single source für jede Lookup-Schicht (Store
36
+ * intern UND Caller die im Return-Body / Mail-Send eine konsistente
37
+ * Form brauchen). Vorher zwei Stellen mit `.toLowerCase()` — eine
38
+ * Quelle = kein Drift. */
39
+ export function normalizeEmail(email: string): string {
40
+ return email.toLowerCase();
41
+ }
42
+
43
+ function tokenKey(token: string): string {
44
+ return `${TOKEN_KEY_PREFIX}${token}`;
45
+ }
46
+ function emailKey(email: string): string {
47
+ return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
48
+ }
49
+ function burnKey(token: string): string {
50
+ return `${BURN_KEY_PREFIX}${token}`;
51
+ }
52
+
53
+ /** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
54
+ * Idempotent — re-write derselben Token-Email-Kombi ist OK. */
55
+ export async function storeSignupToken(
56
+ redis: Redis,
57
+ args: { email: string; token: string; ttlSeconds: number },
58
+ ): Promise<void> {
59
+ await Promise.all([
60
+ redis.set(tokenKey(args.token), normalizeEmail(args.email), "EX", args.ttlSeconds),
61
+ redis.set(emailKey(args.email), args.token, "EX", args.ttlSeconds),
62
+ ]);
63
+ }
64
+
65
+ /** Lookup: Email für einen Token. Null wenn Token nicht (mehr) existiert
66
+ * (abgelaufen, schon konsumiert, oder ungültig). */
67
+ export async function getEmailForSignupToken(redis: Redis, token: string): Promise<string | null> {
68
+ return redis.get(tokenKey(token));
69
+ }
70
+
71
+ /** Lookup: Existierenden Token für eine Email — falls noch valid und
72
+ * noch nicht konsumiert. Für Resend-Idempotenz im signup-request-Handler. */
73
+ export async function getTokenForSignupEmail(redis: Redis, email: string): Promise<string | null> {
74
+ return redis.get(emailKey(email));
75
+ }
76
+
77
+ /** Single-Use-Burn: wenn zwei Tabs gleichzeitig den Confirm-Link klicken,
78
+ * gewinnt der erste, der zweite kriegt "already-used". TTL = 1 Stunde
79
+ * (kurz genug damit der Burn-Key Redis nicht dauerhaft belastet, lang
80
+ * genug damit Replays in normalen Race-Windows abgefangen werden). */
81
+ export async function burnSignupToken(
82
+ redis: Redis,
83
+ token: string,
84
+ ): Promise<"burned" | "already-used"> {
85
+ // SET NX EX — atomic check-and-set. Returnt "OK" wenn Key neu, null
86
+ // wenn schon da.
87
+ const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
88
+ return result === "OK" ? "burned" : "already-used";
89
+ }
90
+
91
+ /** Cleanup nach erfolgreichem Confirm — beide Lookup-Keys löschen.
92
+ * Burn-Key bleibt (verhindert Replay innerhalb der Burn-TTL). */
93
+ export async function deleteSignupToken(
94
+ redis: Redis,
95
+ args: { email: string; token: string },
96
+ ): Promise<void> {
97
+ await Promise.all([redis.del(tokenKey(args.token)), redis.del(emailKey(args.email))]);
98
+ }
99
+
100
+ /** Burn-Release für Failed-Confirm-Pfade (DB-Error etc.) damit ein
101
+ * legitimer Retry nicht durch einen stale Burn-Marker geblockt wird. */
102
+ export async function unburnSignupToken(redis: Redis, token: string): Promise<void> {
103
+ await redis.del(burnKey(token));
104
+ }
@@ -0,0 +1,31 @@
1
+ // user-feature runs with r.systemScope() but events land on a concrete
2
+ // tenant stream. The row.version tracks whichever stream the user's last
3
+ // modification wrote to — and that tenant is NOT discoverable from the
4
+ // row alone.
5
+ //
6
+ // Strategy: prioritize lastActiveTenantId (most likely holds the latest
7
+ // event), fall through to the remaining memberships in insertion order,
8
+ // and let the caller try each one in sequence. The first stream whose
9
+ // version matches row.version wins; the rest are bypassed.
10
+ //
11
+ // This is pragmatic — the real fix is to scope user events to
12
+ // SYSTEM_TENANT_ID when the feature is r.systemScope(), which is a
13
+ // framework-level change tracked separately. Until then, "try each
14
+ // tenant the user belongs to" is robust against non-deterministic
15
+ // memberships-query ordering (tenant:query:memberships has no ORDER BY).
16
+
17
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
+
19
+ export function orderTenantsByPreference(
20
+ memberships: readonly { readonly tenantId: TenantId }[],
21
+ lastActiveTenantId: string | null | undefined,
22
+ ): TenantId[] {
23
+ if (memberships.length === 0) return [];
24
+ const ids = memberships.map((m) => m.tenantId);
25
+ if (!lastActiveTenantId) return ids;
26
+ // Move lastActiveTenantId to the front; preserve relative order of rest.
27
+ const preferred = ids.find((id) => id === lastActiveTenantId);
28
+ if (!preferred) return ids;
29
+ const rest = ids.filter((id) => id !== preferred);
30
+ return [preferred, ...rest];
31
+ }
@@ -0,0 +1,5 @@
1
+ // /testing re-exportiert /seeding. Siehe ../tenant/testing.ts für die
2
+ // Begründung — Helpers sind shared zwischen Tests und Dev-Server-
3
+ // Bootstrap, /seeding ist die stabile Heimat.
4
+
5
+ export * from "./seeding";
@@ -0,0 +1,57 @@
1
+ // Single-use enforcement for HMAC-signed auth tokens (password-reset,
2
+ // email-verification).
3
+ //
4
+ // Problem: the token itself carries only userId + expiry + signature.
5
+ // Without server-side burn, the same token can be replayed within its
6
+ // TTL — an attacker with short-lived mailbox access (Shoulder-Surfing,
7
+ // Mail-Forwarding, stolen laptop) can re-use a link AFTER the legitimate
8
+ // user has already consumed it. Industry standard (Auth0, Stripe, GitHub,
9
+ // Google) is single-use.
10
+ //
11
+ // Mechanism: `SET burn:<purpose>:<userId>:<expiresAtMs> "1" EX <ttl> NX`.
12
+ // First caller wins ("OK"), replay loses (`null`). The key's natural TTL
13
+ // matches the token's — once the token would have expired anyway, Redis
14
+ // reclaims the marker.
15
+ //
16
+ // Storage footprint: one small string per used token, auto-evicted. At
17
+ // 10k password-resets/day × 15-min TTL, at any moment ~100 keys live.
18
+
19
+ import type Redis from "ioredis";
20
+
21
+ const BURN_KEY_PREFIX = "kumiko:auth:burn";
22
+
23
+ export type BurnResult = "fresh" | "already-used";
24
+
25
+ export async function burnToken(
26
+ redis: Redis,
27
+ purpose: string,
28
+ userId: string,
29
+ expiresAtMs: number,
30
+ now: number = Date.now(),
31
+ ): Promise<BurnResult> {
32
+ // Floor at 60s so a near-expiry token still leaves a burn marker long
33
+ // enough to block a replay; ceil() rounds token-TTL up so we never
34
+ // evict the marker before the token itself becomes invalid.
35
+ const ttlSeconds = Math.max(60, Math.ceil((expiresAtMs - now) / 1000));
36
+ const key = burnKey(purpose, userId, expiresAtMs);
37
+ const set = await redis.set(key, "1", "EX", ttlSeconds, "NX");
38
+ return set === "OK" ? "fresh" : "already-used";
39
+ }
40
+
41
+ // Release a burn marker. Called by handlers when the post-burn write path
42
+ // failed for reasons unrelated to the token (e.g. every tenant stream
43
+ // rejected version_conflict — the token itself was never consumed). Without
44
+ // this, a legit retry with the same mail link would hit `already-used` and
45
+ // lock the user out permanently within TTL.
46
+ export async function unburnToken(
47
+ redis: Redis,
48
+ purpose: string,
49
+ userId: string,
50
+ expiresAtMs: number,
51
+ ): Promise<void> {
52
+ await redis.del(burnKey(purpose, userId, expiresAtMs));
53
+ }
54
+
55
+ function burnKey(purpose: string, userId: string, expiresAtMs: number): string {
56
+ return `${BURN_KEY_PREFIX}:${purpose}:${userId}:${expiresAtMs}`;
57
+ }
@@ -0,0 +1,27 @@
1
+ // Thin wrapper around signed-token.ts pinning the purpose to "verify".
2
+ // Mirrors reset-token.ts so callers can import a flow-specific helper
3
+ // without knowing the underlying HMAC scheme.
4
+
5
+ import type { Temporal } from "temporal-polyfill";
6
+ import { signToken, TokenPurpose, verifyToken } from "./signed-token";
7
+
8
+ export type VerifyResult =
9
+ | { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
10
+ | { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
11
+
12
+ export function signVerificationToken(
13
+ userId: string,
14
+ ttlMinutes: number,
15
+ secret: string,
16
+ now?: Temporal.Instant,
17
+ ): { token: string; expiresAt: Temporal.Instant } {
18
+ return signToken(userId, TokenPurpose.emailVerification, ttlMinutes, secret, now);
19
+ }
20
+
21
+ export function verifyVerificationToken(
22
+ token: string,
23
+ secret: string,
24
+ now?: Temporal.Instant,
25
+ ): VerifyResult {
26
+ return verifyToken(token, TokenPurpose.emailVerification, secret, now);
27
+ }
@@ -0,0 +1,51 @@
1
+ // @vitest-environment jsdom
2
+ import { screen } from "@testing-library/react";
3
+ import type { ReactNode } from "react";
4
+ import { describe, expect, test } from "vitest";
5
+ import { makeAuthGate } from "../auth-gate";
6
+ import { makeSessionApi, renderWithProviders } from "./test-utils";
7
+
8
+ describe("makeAuthGate", () => {
9
+ function CustomLogin(): ReactNode {
10
+ return <div data-testid="custom-login">custom-login</div>;
11
+ }
12
+
13
+ test("loading → renders placeholder, not children, not login", () => {
14
+ const Gate = makeAuthGate(CustomLogin);
15
+ const session = makeSessionApi({ status: "loading", user: null });
16
+ const { container } = renderWithProviders(
17
+ <Gate>
18
+ <div data-testid="protected">secret</div>
19
+ </Gate>,
20
+ { session },
21
+ );
22
+ expect(screen.queryByTestId("protected")).toBeNull();
23
+ expect(screen.queryByTestId("custom-login")).toBeNull();
24
+ // Placeholder div ist gerendert (kein leerer Tree)
25
+ expect(container.firstChild).not.toBeNull();
26
+ });
27
+
28
+ test("unauthenticated → renders LoginComponent, not children", () => {
29
+ const Gate = makeAuthGate(CustomLogin);
30
+ const session = makeSessionApi({ status: "unauthenticated", user: null });
31
+ renderWithProviders(
32
+ <Gate>
33
+ <div data-testid="protected">secret</div>
34
+ </Gate>,
35
+ { session },
36
+ );
37
+ expect(screen.getByTestId("custom-login")).toBeTruthy();
38
+ expect(screen.queryByTestId("protected")).toBeNull();
39
+ });
40
+
41
+ test("authenticated → renders children, not login", () => {
42
+ const Gate = makeAuthGate(CustomLogin);
43
+ renderWithProviders(
44
+ <Gate>
45
+ <div data-testid="protected">secret</div>
46
+ </Gate>,
47
+ );
48
+ expect(screen.getByTestId("protected")).toBeTruthy();
49
+ expect(screen.queryByTestId("custom-login")).toBeNull();
50
+ });
51
+ });
@@ -0,0 +1,80 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
+ import { ForgotPasswordScreen } from "../forgot-password-screen";
5
+ import { renderWithProviders } from "./test-utils";
6
+
7
+ beforeEach(() => {
8
+ // global fetch wird von auth-client.ts gerufen — wir mocken pro Test.
9
+ vi.stubGlobal(
10
+ "fetch",
11
+ vi.fn(async () => new Response(null, { status: 200 })),
12
+ );
13
+ });
14
+ afterEach(() => {
15
+ vi.unstubAllGlobals();
16
+ });
17
+
18
+ describe("ForgotPasswordScreen", () => {
19
+ test("rendert title + email-input + submit-button (de)", () => {
20
+ renderWithProviders(<ForgotPasswordScreen />);
21
+ expect(screen.getByText("Passwort zurücksetzen")).toBeTruthy();
22
+ expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
23
+ expect(screen.getByRole("button", { name: "Link anfordern" })).toBeTruthy();
24
+ });
25
+
26
+ test("submit ruft /api/auth/request-password-reset mit der Email", async () => {
27
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
28
+ vi.stubGlobal("fetch", fetchMock);
29
+
30
+ renderWithProviders(<ForgotPasswordScreen />);
31
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
32
+ target: { value: "user@example.com" },
33
+ });
34
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
35
+
36
+ await waitFor(() => {
37
+ expect(fetchMock).toHaveBeenCalledWith(
38
+ "/api/auth/request-password-reset",
39
+ expect.objectContaining({
40
+ method: "POST",
41
+ body: JSON.stringify({ email: "user@example.com" }),
42
+ }),
43
+ );
44
+ });
45
+ });
46
+
47
+ test("nach erfolgreichem Submit: success-Banner + 'Zurück zum Login'-Link", async () => {
48
+ renderWithProviders(<ForgotPasswordScreen />);
49
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
50
+ target: { value: "user@example.com" },
51
+ });
52
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
53
+
54
+ await waitFor(() => {
55
+ expect(screen.getByText("Mail gesendet")).toBeTruthy();
56
+ });
57
+ // Link zurück zum Login muss im Success-State da sein.
58
+ expect(screen.getByRole("link", { name: /Zurück zum Login/i })).toBeTruthy();
59
+ });
60
+
61
+ test("server 5xx → error-banner statt Success-State", async () => {
62
+ vi.stubGlobal(
63
+ "fetch",
64
+ vi.fn(async () => new Response(null, { status: 500 })),
65
+ );
66
+
67
+ renderWithProviders(<ForgotPasswordScreen />);
68
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
69
+ target: { value: "user@example.com" },
70
+ });
71
+ fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
72
+
73
+ await waitFor(() => {
74
+ // unknownError-Bundle-key → "Etwas ist schief gegangen..."
75
+ expect(screen.getByRole("alert").textContent).toContain("schief");
76
+ });
77
+ // Kein success-state.
78
+ expect(screen.queryByText("Mail gesendet")).toBeNull();
79
+ });
80
+ });
@@ -0,0 +1,94 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
+ import { describe, expect, test, vi } from "vitest";
4
+ import { LoginScreen } from "../login-screen";
5
+ import { makeSessionApi, renderWithProviders } from "./test-utils";
6
+
7
+ describe("LoginScreen", () => {
8
+ test("renders translated title + email + password labels (de)", () => {
9
+ renderWithProviders(<LoginScreen />);
10
+ expect(screen.getByText("Anmelden")).toBeTruthy();
11
+ expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
12
+ expect(screen.getByLabelText(/^Passwort/)).toBeTruthy();
13
+ expect(screen.getByRole("button", { name: "Einloggen" })).toBeTruthy();
14
+ });
15
+
16
+ test("submit calls session.login with form values", async () => {
17
+ const session = makeSessionApi({ status: "unauthenticated", user: null });
18
+ renderWithProviders(<LoginScreen />, { session });
19
+
20
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
21
+ target: { value: "demo@example.com" },
22
+ });
23
+ fireEvent.change(screen.getByLabelText(/^Passwort/), {
24
+ target: { value: "secret" },
25
+ });
26
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
27
+
28
+ await waitFor(() => {
29
+ expect(session.login).toHaveBeenCalledWith({
30
+ email: "demo@example.com",
31
+ password: "secret",
32
+ });
33
+ });
34
+ });
35
+
36
+ test("invalid_credentials → renders translated error message", async () => {
37
+ const session = makeSessionApi({
38
+ status: "unauthenticated",
39
+ user: null,
40
+ login: vi.fn(async () => ({ ok: false, error: { reason: "invalid_credentials" } })),
41
+ });
42
+ renderWithProviders(<LoginScreen />, { session });
43
+
44
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
45
+ target: { value: "wrong@example.com" },
46
+ });
47
+ fireEvent.change(screen.getByLabelText(/^Passwort/), {
48
+ target: { value: "x" },
49
+ });
50
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
51
+
52
+ await waitFor(() => {
53
+ expect(screen.getByRole("alert")).toBeTruthy();
54
+ expect(screen.getByRole("alert").textContent).toBe("E-Mail oder Passwort falsch.");
55
+ });
56
+ });
57
+
58
+ test("account_locked with retryAfterSeconds renders interpolated minutes", async () => {
59
+ const session = makeSessionApi({
60
+ status: "unauthenticated",
61
+ user: null,
62
+ login: vi.fn(async () => ({
63
+ ok: false,
64
+ error: { reason: "account_locked", retryAfterSeconds: 540 },
65
+ })),
66
+ });
67
+ renderWithProviders(<LoginScreen />, { session });
68
+
69
+ fireEvent.change(screen.getByLabelText(/^E-Mail/), {
70
+ target: { value: "x@example.com" },
71
+ });
72
+ fireEvent.change(screen.getByLabelText(/^Passwort/), {
73
+ target: { value: "x" },
74
+ });
75
+ fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
76
+
77
+ await waitFor(() => {
78
+ // 540s → 9 Minuten (Math.ceil)
79
+ expect(screen.getByRole("alert").textContent).toMatch(/9 Minuten/);
80
+ });
81
+ });
82
+
83
+ test("forgotPasswordHref-prop → Link rendert mit korrektem href", () => {
84
+ renderWithProviders(<LoginScreen forgotPasswordHref="/forgot-password" />);
85
+ const link = screen.getByRole("link", { name: /Passwort vergessen/i });
86
+ expect(link).toBeTruthy();
87
+ expect(link.getAttribute("href")).toBe("/forgot-password");
88
+ });
89
+
90
+ test("ohne forgotPasswordHref → KEIN Link (Login bleibt minimalistisch)", () => {
91
+ renderWithProviders(<LoginScreen />);
92
+ expect(screen.queryByRole("link", { name: /Passwort vergessen/i })).toBeNull();
93
+ });
94
+ });
@@ -0,0 +1,108 @@
1
+ // @vitest-environment jsdom
2
+ import { fireEvent, screen, waitFor } from "@testing-library/react";
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
+ import { ResetPasswordScreen } from "../reset-password-screen";
5
+ import { renderWithProviders } from "./test-utils";
6
+
7
+ beforeEach(() => {
8
+ vi.stubGlobal(
9
+ "fetch",
10
+ vi.fn(async () => new Response(null, { status: 200 })),
11
+ );
12
+ });
13
+ afterEach(() => {
14
+ vi.unstubAllGlobals();
15
+ });
16
+
17
+ describe("ResetPasswordScreen", () => {
18
+ test("ohne Token in URL UND ohne token-Prop → missing-token-Page", () => {
19
+ // jsdom default location is "about:blank" → search = ""
20
+ renderWithProviders(<ResetPasswordScreen />);
21
+ expect(screen.getByText(/enthält keinen Token/i)).toBeTruthy();
22
+ });
23
+
24
+ test("mit token-Prop → Form rendert", () => {
25
+ renderWithProviders(<ResetPasswordScreen token="abc-token" />);
26
+ expect(screen.getByLabelText(/^Neues Passwort/)).toBeTruthy();
27
+ expect(screen.getByLabelText(/^Passwort bestätigen/)).toBeTruthy();
28
+ expect(screen.getByRole("button", { name: "Passwort speichern" })).toBeTruthy();
29
+ });
30
+
31
+ test("Passwort < 8 Zeichen → client-side error, kein fetch-Call", async () => {
32
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
33
+ vi.stubGlobal("fetch", fetchMock);
34
+
35
+ renderWithProviders(<ResetPasswordScreen token="abc" />);
36
+ fireEvent.change(screen.getByLabelText(/^Neues Passwort/), { target: { value: "short" } });
37
+ fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), { target: { value: "short" } });
38
+ fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
39
+
40
+ await waitFor(() => {
41
+ expect(screen.getByRole("alert").textContent).toContain("8 Zeichen");
42
+ });
43
+ expect(fetchMock).not.toHaveBeenCalled();
44
+ });
45
+
46
+ test("mismatch zwischen Passwort und Confirm → client-side error", async () => {
47
+ renderWithProviders(<ResetPasswordScreen token="abc" />);
48
+ fireEvent.change(screen.getByLabelText(/^Neues Passwort/), {
49
+ target: { value: "validpass1" },
50
+ });
51
+ fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), {
52
+ target: { value: "differentpass" },
53
+ });
54
+ fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
55
+
56
+ await waitFor(() => {
57
+ expect(screen.getByRole("alert").textContent).toContain("nicht überein");
58
+ });
59
+ });
60
+
61
+ test("happy path: gültiges Passwort → fetch-Call + success-State", async () => {
62
+ const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
63
+ vi.stubGlobal("fetch", fetchMock);
64
+
65
+ renderWithProviders(<ResetPasswordScreen token="abc-token" />);
66
+ fireEvent.change(screen.getByLabelText(/^Neues Passwort/), {
67
+ target: { value: "validpass1" },
68
+ });
69
+ fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), {
70
+ target: { value: "validpass1" },
71
+ });
72
+ fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
73
+
74
+ await waitFor(() => {
75
+ expect(fetchMock).toHaveBeenCalledWith(
76
+ "/api/auth/reset-password",
77
+ expect.objectContaining({
78
+ method: "POST",
79
+ body: JSON.stringify({ token: "abc-token", newPassword: "validpass1" }),
80
+ }),
81
+ );
82
+ expect(screen.getByText("Passwort gesetzt")).toBeTruthy();
83
+ });
84
+ });
85
+
86
+ test("server invalid_reset_token → mapped i18n-error im UI", async () => {
87
+ const errBody = JSON.stringify({
88
+ error: { code: "invalid_reset_token", details: { reason: "invalid_reset_token" } },
89
+ });
90
+ vi.stubGlobal(
91
+ "fetch",
92
+ vi.fn(async () => new Response(errBody, { status: 422 })),
93
+ );
94
+
95
+ renderWithProviders(<ResetPasswordScreen token="bad" />);
96
+ fireEvent.change(screen.getByLabelText(/^Neues Passwort/), {
97
+ target: { value: "validpass1" },
98
+ });
99
+ fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), {
100
+ target: { value: "validpass1" },
101
+ });
102
+ fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
103
+
104
+ await waitFor(() => {
105
+ expect(screen.getByRole("alert").textContent).toContain("ungültig");
106
+ });
107
+ });
108
+ });