@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,610 @@
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
+ createTestUser,
7
+ pushTables,
8
+ setupTestStack,
9
+ type TestStack,
10
+ TestUsers,
11
+ testTenantId,
12
+ } from "@cosmicdrift/kumiko-framework/stack";
13
+ import {
14
+ expectErrorIncludes,
15
+ getSetCookieRaw,
16
+ getSetCookieValue,
17
+ } from "@cosmicdrift/kumiko-framework/testing";
18
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
19
+ import { createConfigFeature } from "../../config";
20
+ import { createConfigResolver } from "../../config/resolver";
21
+ import { configValuesTable } from "../../config/table";
22
+ import { createTenantFeature } from "../../tenant";
23
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
24
+ import { tenantEntity } from "../../tenant/schema/tenant";
25
+ import { seedTenantMembership } from "../../tenant/testing";
26
+ import { UserHandlers } from "../../user";
27
+ import { createUserFeature } from "../../user/feature";
28
+ import { userEntity, userTable } from "../../user/schema/user";
29
+ import { AuthErrors, AuthHandlers } from "../constants";
30
+ import { createAuthEmailPasswordFeature } from "../feature";
31
+ import { hashPassword } from "../password-hashing";
32
+
33
+ let stack: TestStack;
34
+
35
+ const systemAdmin = TestUsers.systemAdmin;
36
+ const encryptionKey = randomBytes(32).toString("base64");
37
+
38
+ beforeAll(async () => {
39
+ const encryption = createEncryptionProvider(encryptionKey);
40
+ const resolver = createConfigResolver({ encryption });
41
+
42
+ stack = await setupTestStack({
43
+ features: [
44
+ createConfigFeature(),
45
+ createUserFeature(),
46
+ createTenantFeature(),
47
+ createAuthEmailPasswordFeature(),
48
+ ],
49
+ extraContext: { configResolver: resolver, configEncryption: encryption },
50
+ authConfig: {
51
+ membershipQuery: "tenant:query:memberships",
52
+ loginHandler: AuthHandlers.login,
53
+ loginErrorStatusMap: {
54
+ [AuthErrors.invalidCredentials]: 401,
55
+ [AuthErrors.noMembership]: 403,
56
+ },
57
+ },
58
+ });
59
+
60
+ await createEntityTable(stack.db, userEntity);
61
+ await createEntityTable(stack.db, tenantEntity);
62
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
63
+ });
64
+
65
+ afterAll(async () => {
66
+ await stack.cleanup();
67
+ });
68
+
69
+ beforeEach(async () => {
70
+ await stack.db.delete(userTable);
71
+ await stack.db.delete(tenantMembershipsTable);
72
+ });
73
+
74
+ // Helper: seed a full login-ready user (user row + membership).
75
+ async function seedLoginUser(opts: {
76
+ email: string;
77
+ password: string;
78
+ tenantId?: TenantId;
79
+ roles?: string[];
80
+ }): Promise<{ id: string; tenantId: TenantId }> {
81
+ const hash = await hashPassword(opts.password);
82
+ const created = await stack.http.writeOk<{ id: string }>(
83
+ UserHandlers.create,
84
+ {
85
+ email: opts.email,
86
+ passwordHash: hash,
87
+ displayName: opts.email.split("@")[0] ?? "user",
88
+ },
89
+ systemAdmin,
90
+ );
91
+
92
+ const tenantId = opts.tenantId ?? "00000000-0000-4000-8000-000000000001";
93
+ await seedTenantMembership(stack.db, {
94
+ userId: created.id,
95
+ tenantId,
96
+ roles: opts.roles ?? ["User"],
97
+ });
98
+ return { id: created.id, tenantId };
99
+ }
100
+
101
+ // --- Scenario 1 + 2: login with right / wrong password ---
102
+
103
+ describe("scenario 1: login success", () => {
104
+ test("correct credentials → JWT containing id + tenantId + roles", async () => {
105
+ const seed = await seedLoginUser({
106
+ email: "good@example.com",
107
+ password: "correct-horse-battery",
108
+ roles: ["User"],
109
+ });
110
+
111
+ const res = await stack.http.raw("POST", "/api/auth/login", {
112
+ email: "good@example.com",
113
+ password: "correct-horse-battery",
114
+ });
115
+
116
+ expect(res.status).toBe(200);
117
+ const body = await res.json();
118
+ expect(body.isSuccess).toBe(true);
119
+ expect(body.token).toBeTypeOf("string");
120
+ expect(body.user).toMatchObject({
121
+ id: seed.id,
122
+ tenantId: seed.tenantId,
123
+ roles: ["User"],
124
+ });
125
+
126
+ // Verify the JWT is actually valid — call an authenticated endpoint with it.
127
+ const meRes = await stack.http.raw(
128
+ "POST",
129
+ "/api/query",
130
+ { type: "user:query:user:me", payload: {} },
131
+ {
132
+ Authorization: `Bearer ${body.token}`,
133
+ },
134
+ );
135
+ expect(meRes.status).toBe(200);
136
+ });
137
+ });
138
+
139
+ describe("scenario 2: login failure", () => {
140
+ test("wrong password → invalid_credentials (no enumeration)", async () => {
141
+ await seedLoginUser({ email: "wrong@example.com", password: "correct" });
142
+
143
+ const res = await stack.http.raw("POST", "/api/auth/login", {
144
+ email: "wrong@example.com",
145
+ password: "nope",
146
+ });
147
+
148
+ expect(res.status).toBe(401);
149
+ const body = await res.json();
150
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
151
+ });
152
+
153
+ test("unknown email → same invalid_credentials (no enumeration)", async () => {
154
+ const res = await stack.http.raw("POST", "/api/auth/login", {
155
+ email: "ghost@example.com",
156
+ password: "whatever",
157
+ });
158
+
159
+ expect(res.status).toBe(401);
160
+ const body = await res.json();
161
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
162
+ });
163
+ });
164
+
165
+ // --- Scenario 3: login without any membership ---
166
+
167
+ describe("scenario 3: login without membership", () => {
168
+ test("valid user but no tenant membership → no_membership", async () => {
169
+ const hash = await hashPassword("pw");
170
+ await stack.http.writeOk(
171
+ UserHandlers.create,
172
+ { email: "nomember@example.com", passwordHash: hash, displayName: "Lone" },
173
+ systemAdmin,
174
+ );
175
+ // intentionally NO membership insert
176
+
177
+ const res = await stack.http.raw("POST", "/api/auth/login", {
178
+ email: "nomember@example.com",
179
+ password: "pw",
180
+ });
181
+
182
+ expect(res.status).toBe(403);
183
+ const body = await res.json();
184
+ expect(body.error?.details?.reason).toBe(AuthErrors.noMembership);
185
+ });
186
+ });
187
+
188
+ // --- Scenario 4 + 5: change-password flow ---
189
+
190
+ describe("scenario 4: change-password with wrong old password", () => {
191
+ test("wrong old → invalid_credentials, DB unchanged", async () => {
192
+ const seed = await seedLoginUser({ email: "cp@example.com", password: "good-old" });
193
+ const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
194
+
195
+ const error = await stack.http.writeErr(
196
+ AuthHandlers.changePassword,
197
+ { oldPassword: "wrong", newPassword: "new-long-password" },
198
+ signedIn,
199
+ );
200
+ expectErrorIncludes(error, AuthErrors.invalidCredentials);
201
+
202
+ // Old password still works
203
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
204
+ email: "cp@example.com",
205
+ password: "good-old",
206
+ });
207
+ expect(loginRes.status).toBe(200);
208
+ });
209
+ });
210
+
211
+ describe("scenario 5: change-password success", () => {
212
+ test("correct old + new → old fails, new works", async () => {
213
+ const seed = await seedLoginUser({ email: "flip@example.com", password: "before" });
214
+ const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
215
+
216
+ await stack.http.writeOk(
217
+ AuthHandlers.changePassword,
218
+ { oldPassword: "before", newPassword: "after-long-enough" },
219
+ signedIn,
220
+ );
221
+
222
+ // Old password no longer works
223
+ const oldRes = await stack.http.raw("POST", "/api/auth/login", {
224
+ email: "flip@example.com",
225
+ password: "before",
226
+ });
227
+ expect(oldRes.status).toBe(401);
228
+
229
+ // New password works
230
+ const newRes = await stack.http.raw("POST", "/api/auth/login", {
231
+ email: "flip@example.com",
232
+ password: "after-long-enough",
233
+ });
234
+ expect(newRes.status).toBe(200);
235
+ });
236
+ });
237
+
238
+ // --- Scenario 6: logout is reachable for authenticated users ---
239
+
240
+ describe("scenario 6: logout", () => {
241
+ test("authenticated user can call logout (returns success)", async () => {
242
+ const seed = await seedLoginUser({ email: "bye@example.com", password: "pw12345678" });
243
+ const signedIn = createTestUser({ id: seed.id, tenantId: seed.tenantId, roles: ["User"] });
244
+
245
+ const data = await stack.http.writeOk<{ kind: string }>(AuthHandlers.logout, {}, signedIn);
246
+ expect(data.kind).toBe("logged-out");
247
+ });
248
+
249
+ test("unauthenticated call to logout is rejected by framework access", async () => {
250
+ // roles: ["all"] — no authenticated role. Handler's access is
251
+ // access.authenticated which requires User/Admin/SystemAdmin.
252
+ const guest = createTestUser({
253
+ id: 0,
254
+ tenantId: "00000000-0000-4000-8000-000000000000",
255
+ roles: ["all"],
256
+ });
257
+ const error = await stack.http.writeErr(AuthHandlers.logout, {}, guest);
258
+ expectErrorIncludes(error, "access_denied");
259
+ });
260
+ });
261
+
262
+ // --- Scenario 7: multi-membership — lastActiveTenantId is honored ---
263
+
264
+ describe("scenario 7: multi-membership tenant resolution", () => {
265
+ test("login picks the tenant matching lastActiveTenantId, not the first", async () => {
266
+ const hash = await hashPassword("multi-pw-1234");
267
+ const created = await stack.http.writeOk<{ id: string }>(
268
+ UserHandlers.create,
269
+ { email: "multi@example.com", passwordHash: hash, displayName: "Multi" },
270
+ systemAdmin,
271
+ );
272
+
273
+ // Two memberships: tenant 1 (first) and tenant 7 (preferred).
274
+ await seedTenantMembership(stack.db, {
275
+ userId: created.id,
276
+ tenantId: testTenantId(1),
277
+ roles: ["User"],
278
+ });
279
+ await seedTenantMembership(stack.db, {
280
+ userId: created.id,
281
+ tenantId: testTenantId(7),
282
+ roles: ["Admin"],
283
+ });
284
+
285
+ // Point the user at tenant 7 as their "last active" — login should land there.
286
+ await stack.http.writeOk(
287
+ UserHandlers.update,
288
+ { id: created.id, changes: { lastActiveTenantId: testTenantId(7) }, version: 1 },
289
+ systemAdmin,
290
+ );
291
+
292
+ const res = await stack.http.raw("POST", "/api/auth/login", {
293
+ email: "multi@example.com",
294
+ password: "multi-pw-1234",
295
+ });
296
+ const body = await res.json();
297
+
298
+ expect(body.isSuccess).toBe(true);
299
+ expect(body.user.tenantId).toBe(testTenantId(7));
300
+ expect(body.user.roles).toEqual(["Admin"]);
301
+ });
302
+
303
+ test("login falls back to first membership when lastActiveTenantId is stale", async () => {
304
+ // User has a lastActiveTenantId of a tenant they're no longer a member of.
305
+ const hash = await hashPassword("stale-pw-1234");
306
+ const created = await stack.http.writeOk<{ id: string }>(
307
+ UserHandlers.create,
308
+ { email: "stale@example.com", passwordHash: hash, displayName: "Stale" },
309
+ systemAdmin,
310
+ );
311
+ await seedTenantMembership(stack.db, {
312
+ userId: created.id,
313
+ tenantId: testTenantId(3),
314
+ roles: ["User"],
315
+ });
316
+ await stack.http.writeOk(
317
+ UserHandlers.update,
318
+ { id: created.id, changes: { lastActiveTenantId: testTenantId(999) }, version: 1 },
319
+ systemAdmin,
320
+ );
321
+
322
+ const res = await stack.http.raw("POST", "/api/auth/login", {
323
+ email: "stale@example.com",
324
+ password: "stale-pw-1234",
325
+ });
326
+ const body = await res.json();
327
+
328
+ expect(body.isSuccess).toBe(true);
329
+ expect(body.user.tenantId).toBe(testTenantId(3));
330
+ });
331
+ });
332
+
333
+ // --- Scenario 7b: Login rate-limit / brute-force protection ---
334
+
335
+ describe("scenario 7b: login rate limiting", () => {
336
+ let rlStack: TestStack;
337
+
338
+ beforeAll(async () => {
339
+ const encryption = createEncryptionProvider(encryptionKey);
340
+ const resolver = createConfigResolver({ encryption });
341
+ const { createInMemoryLoginRateLimiter } = await import("@cosmicdrift/kumiko-framework/api");
342
+
343
+ rlStack = await setupTestStack({
344
+ features: [
345
+ createConfigFeature(),
346
+ createUserFeature(),
347
+ createTenantFeature(),
348
+ createAuthEmailPasswordFeature(),
349
+ ],
350
+ extraContext: { configResolver: resolver, configEncryption: encryption },
351
+ authConfig: {
352
+ membershipQuery: "tenant:query:memberships",
353
+ loginHandler: AuthHandlers.login,
354
+ loginErrorStatusMap: {
355
+ [AuthErrors.invalidCredentials]: 401,
356
+ [AuthErrors.noMembership]: 403,
357
+ },
358
+ // Tight limit so the test finishes fast: 3 attempts per small window
359
+ loginRateLimit: createInMemoryLoginRateLimiter(3, 60_000),
360
+ },
361
+ });
362
+ await createEntityTable(rlStack.db, userEntity);
363
+ await createEntityTable(rlStack.db, tenantEntity);
364
+ await pushTables(rlStack.db, { configValuesTable, tenantMembershipsTable });
365
+
366
+ // Seed one real user
367
+ const hash = await hashPassword("right-password");
368
+ const created = await rlStack.http.writeOk<{ id: string }>(
369
+ UserHandlers.create,
370
+ { email: "brute@example.com", passwordHash: hash, displayName: "Brute" },
371
+ systemAdmin,
372
+ );
373
+ await seedTenantMembership(rlStack.db, {
374
+ userId: created.id,
375
+ tenantId: "00000000-0000-4000-8000-000000000001",
376
+ roles: ["User"],
377
+ });
378
+ });
379
+
380
+ afterAll(async () => {
381
+ await rlStack.cleanup();
382
+ });
383
+
384
+ test("repeated failed logins get 429 after threshold", async () => {
385
+ // 3 wrong attempts — all 401, not yet blocked
386
+ for (let i = 0; i < 3; i++) {
387
+ const res = await rlStack.http.raw("POST", "/api/auth/login", {
388
+ email: "brute@example.com",
389
+ password: "wrong",
390
+ });
391
+ expect(res.status).toBe(401);
392
+ }
393
+
394
+ // 4th attempt is rate-limited
395
+ const blocked = await rlStack.http.raw("POST", "/api/auth/login", {
396
+ email: "brute@example.com",
397
+ password: "wrong",
398
+ });
399
+ expect(blocked.status).toBe(429);
400
+ const body = await blocked.json();
401
+ expect(body.error).toBe("rate_limited");
402
+
403
+ // Even the CORRECT password is blocked now (attacker can't slip in
404
+ // during a lockout window).
405
+ const blockedCorrect = await rlStack.http.raw("POST", "/api/auth/login", {
406
+ email: "brute@example.com",
407
+ password: "right-password",
408
+ });
409
+ expect(blockedCorrect.status).toBe(429);
410
+ });
411
+
412
+ test("successful login resets the counter for that bucket", async () => {
413
+ // Different email → fresh bucket (key is ip+email)
414
+ const hash = await hashPassword("ok-password");
415
+ const created = await rlStack.http.writeOk<{ id: string }>(
416
+ UserHandlers.create,
417
+ { email: "reset@example.com", passwordHash: hash, displayName: "Reset" },
418
+ systemAdmin,
419
+ );
420
+ await seedTenantMembership(rlStack.db, {
421
+ userId: created.id,
422
+ tenantId: "00000000-0000-4000-8000-000000000001",
423
+ roles: ["User"],
424
+ });
425
+
426
+ // 2 wrong attempts
427
+ for (let i = 0; i < 2; i++) {
428
+ await rlStack.http.raw("POST", "/api/auth/login", {
429
+ email: "reset@example.com",
430
+ password: "wrong",
431
+ });
432
+ }
433
+
434
+ // Correct login succeeds (counter still at 2, under the limit of 3)
435
+ const ok = await rlStack.http.raw("POST", "/api/auth/login", {
436
+ email: "reset@example.com",
437
+ password: "ok-password",
438
+ });
439
+ expect(ok.status).toBe(200);
440
+
441
+ // After reset, 3 more wrong attempts must still pass before lockout
442
+ for (let i = 0; i < 3; i++) {
443
+ const res = await rlStack.http.raw("POST", "/api/auth/login", {
444
+ email: "reset@example.com",
445
+ password: "wrong",
446
+ });
447
+ expect(res.status).toBe(401);
448
+ }
449
+ });
450
+ });
451
+
452
+ // --- Scenario 8: JWT claims roundtrip (reserved field works end-to-end) ---
453
+
454
+ describe("scenario 8: SessionUser.claims JWT roundtrip", () => {
455
+ test("signing a session with claims and verifying carries them through", async () => {
456
+ const signed = await stack.jwt.sign({
457
+ id: "11111111-0000-4000-8000-000000000042",
458
+ tenantId: testTenantId(5),
459
+ roles: ["User"],
460
+ claims: { customerId: 99, scopes: ["read", "write"] },
461
+ });
462
+
463
+ const payload = await stack.jwt.verify(signed);
464
+ expect(payload.sub).toBe("11111111-0000-4000-8000-000000000042");
465
+ expect(payload.tenantId).toBe(testTenantId(5));
466
+ expect(payload.roles).toEqual(["User"]);
467
+ expect(payload.claims).toEqual({ customerId: 99, scopes: ["read", "write"] });
468
+ });
469
+
470
+ test("session without claims produces a JWT without the claims field", async () => {
471
+ const signed = await stack.jwt.sign({
472
+ id: "11111111-0000-4000-8000-000000000001",
473
+ tenantId: "00000000-0000-4000-8000-000000000001",
474
+ roles: ["User"],
475
+ });
476
+
477
+ const payload = await stack.jwt.verify(signed);
478
+ expect(payload.claims).toBeUndefined();
479
+ });
480
+ });
481
+
482
+ describe("scenario 7: cookie-auth + CSRF end-to-end", () => {
483
+ // Full-stack proof that the cookie path from Vorarbeit A behaves correctly
484
+ // against a real login handler + dispatcher. Unit tests cover the
485
+ // middleware logic in isolation; this locks down the wiring.
486
+
487
+ test("login sets both cookies and the token works via cookie transport", async () => {
488
+ await seedLoginUser({ email: "cookie-user@example.com", password: "correct-horse" });
489
+
490
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
491
+ email: "cookie-user@example.com",
492
+ password: "correct-horse",
493
+ });
494
+ expect(loginRes.status).toBe(200);
495
+
496
+ const authCookie = getSetCookieValue(loginRes, "kumiko_auth");
497
+ const csrfCookie = getSetCookieValue(loginRes, "kumiko_csrf");
498
+ expect(authCookie).toBeDefined();
499
+ expect(csrfCookie).toBeDefined();
500
+
501
+ // Query via cookie ONLY (no bearer). POST /query is state-changing from
502
+ // the middleware's POV — same API convention as /write — so the web
503
+ // client has to echo the csrf cookie in X-CSRF-Token on every POST.
504
+ const queryRes = await stack.http.raw(
505
+ "POST",
506
+ "/api/query",
507
+ { type: "user:query:user:me", payload: {} },
508
+ {
509
+ Cookie: `kumiko_auth=${authCookie}; kumiko_csrf=${csrfCookie}`,
510
+ ...(csrfCookie ? { "X-CSRF-Token": csrfCookie } : {}),
511
+ },
512
+ );
513
+ expect(queryRes.status).toBe(200);
514
+ });
515
+
516
+ test("state-changing request via cookie without CSRF token → 403", async () => {
517
+ await seedLoginUser({ email: "csrf-user@example.com", password: "correct-horse" });
518
+
519
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
520
+ email: "csrf-user@example.com",
521
+ password: "correct-horse",
522
+ });
523
+ const authCookie = getSetCookieValue(loginRes, "kumiko_auth");
524
+ const csrfCookie = getSetCookieValue(loginRes, "kumiko_csrf");
525
+
526
+ // POST /write with cookie but no X-CSRF-Token → csrf-middleware blocks.
527
+ const writeRes = await stack.http.raw(
528
+ "POST",
529
+ "/api/write",
530
+ { type: "user:write:user:create", payload: {} },
531
+ { Cookie: `kumiko_auth=${authCookie}; kumiko_csrf=${csrfCookie}` },
532
+ );
533
+ expect(writeRes.status).toBe(403);
534
+ const body = await writeRes.json();
535
+ expect(body.error?.code).toBe("csrf_token_mismatch");
536
+ });
537
+
538
+ test("browser auth flow: login → /me → logout → /me 401", async () => {
539
+ // Bildet exakt den Pfad ab, den die Web-UI fährt: SessionProvider
540
+ // ruft refresh() (→ /auth/tenants + /me), nach Login funktioniert /me,
541
+ // nach Logout ist der Cookie weg → /me OHNE Cookie ist 401.
542
+ await seedLoginUser({ email: "flow@example.com", password: "correct-horse" });
543
+
544
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
545
+ email: "flow@example.com",
546
+ password: "correct-horse",
547
+ });
548
+ expect(loginRes.status).toBe(200);
549
+ const authCookie = getSetCookieValue(loginRes, "kumiko_auth");
550
+ const csrfCookie = getSetCookieValue(loginRes, "kumiko_csrf");
551
+ expect(authCookie).toBeDefined();
552
+ expect(csrfCookie).toBeDefined();
553
+
554
+ const cookieHeader = `kumiko_auth=${authCookie}; kumiko_csrf=${csrfCookie}`;
555
+
556
+ // Eingeloggt: /me liefert User
557
+ const meOk = await stack.http.raw(
558
+ "POST",
559
+ "/api/query",
560
+ { type: "user:query:user:me", payload: {} },
561
+ { Cookie: cookieHeader, ...(csrfCookie ? { "X-CSRF-Token": csrfCookie } : {}) },
562
+ );
563
+ expect(meOk.status).toBe(200);
564
+
565
+ // Logout: Server muss Cookies löschen (Set-Cookie mit Max-Age=0)
566
+ const logoutRes = await stack.http.raw(
567
+ "POST",
568
+ "/api/auth/logout",
569
+ {},
570
+ { Cookie: cookieHeader, ...(csrfCookie ? { "X-CSRF-Token": csrfCookie } : {}) },
571
+ );
572
+ expect(logoutRes.status).toBe(200);
573
+ const clearedAuth = getSetCookieRaw(logoutRes, "kumiko_auth");
574
+ const clearedCsrf = getSetCookieRaw(logoutRes, "kumiko_csrf");
575
+ expect(clearedAuth).toMatch(/Max-Age=0/);
576
+ expect(clearedCsrf).toMatch(/Max-Age=0/);
577
+
578
+ // Nach Logout: kein Cookie mehr → /me ohne Cookie/Bearer = 401
579
+ const meAfter = await stack.http.raw("POST", "/api/query", {
580
+ type: "user:query:user:me",
581
+ payload: {},
582
+ });
583
+ expect(meAfter.status).toBe(401);
584
+ });
585
+
586
+ test("both cookie AND bearer present → 400 ambiguous_auth", async () => {
587
+ await seedLoginUser({ email: "ambig@example.com", password: "correct-horse" });
588
+
589
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
590
+ email: "ambig@example.com",
591
+ password: "correct-horse",
592
+ });
593
+ const body = await loginRes.json();
594
+ const token = body.token;
595
+ const authCookie = getSetCookieValue(loginRes, "kumiko_auth");
596
+
597
+ const res = await stack.http.raw(
598
+ "POST",
599
+ "/api/query",
600
+ { type: "user:query:user:me", payload: {} },
601
+ {
602
+ Cookie: `kumiko_auth=${authCookie}`,
603
+ Authorization: `Bearer ${token}`,
604
+ },
605
+ );
606
+ expect(res.status).toBe(400);
607
+ const errBody = await res.json();
608
+ expect(errBody.error?.code).toBe("ambiguous_auth");
609
+ });
610
+ });
@@ -0,0 +1,67 @@
1
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
3
+ import { describe, expect, test } from "vitest";
4
+ import { runConfirmTokenFlow } from "../handlers/confirm-token-flow";
5
+
6
+ // Pins the "fail-loud when ctx.redis is missing" branch. Without this
7
+ // test, a refactor that accidentally drops the redis check would not
8
+ // trip any CI — the remaining assertions (single-use, retry, cross-
9
+ // purpose) all run against a wired Redis and would pass regardless.
10
+
11
+ function fakeCtxWithoutRedis(): HandlerContext {
12
+ // Minimal fake ctx for the specific branch under test — the flow
13
+ // returns before touching any other ctx field. `as unknown as` is the
14
+ // established pattern for test-only fakes at system boundaries.
15
+ return { redis: undefined } as unknown as HandlerContext;
16
+ }
17
+
18
+ function invalidTokenStub() {
19
+ return writeFailure(
20
+ new UnprocessableError("invalid_token", {
21
+ i18nKey: "invalid_token",
22
+ }),
23
+ );
24
+ }
25
+
26
+ describe("runConfirmTokenFlow — ctx.redis missing", () => {
27
+ test("returns InternalError with the feature-supplied message", async () => {
28
+ const result = await runConfirmTokenFlow(
29
+ fakeCtxWithoutRedis(),
30
+ "11111111-1111-4111-8111-111111111111",
31
+ Date.now() + 60_000,
32
+ {
33
+ purpose: "reset",
34
+ redisRequiredMessage: "password-reset requires redis",
35
+ invalidToken: invalidTokenStub,
36
+ buildChanges: async () => ({ passwordHash: "new" }),
37
+ successData: { kind: "password-reset" as const },
38
+ },
39
+ );
40
+
41
+ expect(result.isSuccess).toBe(false);
42
+ if (result.isSuccess) throw new Error("expected failure");
43
+ // InternalError surfaces as 500 at the HTTP layer; for this test
44
+ // we just pin that it carries the feature-specific message so a
45
+ // caller operator log can distinguish reset vs verify misconfigs.
46
+ expect(result.error.message).toContain("password-reset requires redis");
47
+ });
48
+
49
+ test("message is forwarded verbatim — no framework-level rewording", async () => {
50
+ const result = await runConfirmTokenFlow(
51
+ fakeCtxWithoutRedis(),
52
+ "22222222-2222-4222-8222-222222222222",
53
+ Date.now() + 60_000,
54
+ {
55
+ purpose: "verify",
56
+ redisRequiredMessage: "email-verification requires redis",
57
+ invalidToken: invalidTokenStub,
58
+ buildChanges: async () => ({ emailVerified: true }),
59
+ successData: { kind: "verified" as const },
60
+ },
61
+ );
62
+
63
+ expect(result.isSuccess).toBe(false);
64
+ if (result.isSuccess) throw new Error("expected failure");
65
+ expect(result.error.message).toContain("email-verification requires redis");
66
+ });
67
+ });