@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,149 @@
1
+ // Degradation test: when ctx.redis is unavailable the login handler must
2
+ // still work — every lockout check/record becomes a no-op. The feature's
3
+ // contract explicitly allows this: lockout is brute-force hardening, not a
4
+ // login prerequisite. A setup without Redis (dev, minimal deployment, or
5
+ // operator-chosen opt-out) should have working auth at the cost of losing
6
+ // this single defense layer; the IP-level rate-limiter is the parallel
7
+ // protection that stays in place regardless.
8
+ //
9
+ // Separate file rather than a case in account-lockout.integration.ts because
10
+ // the stack must be built without `context.redis` — shared `beforeAll`
11
+ // can't mix the two.
12
+
13
+ import { randomBytes } from "node:crypto";
14
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
15
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
16
+ import {
17
+ createEntityTable,
18
+ pushTables,
19
+ setupTestStack,
20
+ type TestStack,
21
+ TestUsers,
22
+ } from "@cosmicdrift/kumiko-framework/stack";
23
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
24
+ import { createConfigFeature } from "../../config";
25
+ import { createConfigResolver } from "../../config/resolver";
26
+ import { configValuesTable } from "../../config/table";
27
+ import { createTenantFeature } from "../../tenant";
28
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
29
+ import { tenantEntity } from "../../tenant/schema/tenant";
30
+ import { seedTenantMembership } from "../../tenant/testing";
31
+ import { UserHandlers } from "../../user";
32
+ import { createUserFeature } from "../../user/feature";
33
+ import { userEntity, userTable } from "../../user/schema/user";
34
+ import { AuthErrors, AuthHandlers } from "../constants";
35
+ import { createAuthEmailPasswordFeature } from "../feature";
36
+ import { hashPassword } from "../password-hashing";
37
+
38
+ let stack: TestStack;
39
+
40
+ const systemAdmin = TestUsers.systemAdmin;
41
+ const encryptionKey = randomBytes(32).toString("base64");
42
+
43
+ beforeAll(async () => {
44
+ const encryption = createEncryptionProvider(encryptionKey);
45
+ const resolver = createConfigResolver({ encryption });
46
+
47
+ stack = await setupTestStack({
48
+ features: [
49
+ createConfigFeature(),
50
+ createUserFeature(),
51
+ createTenantFeature(),
52
+ createAuthEmailPasswordFeature({
53
+ accountLockout: { maxFailedAttempts: 2, lockoutDurationMinutes: 1 },
54
+ }),
55
+ ],
56
+ // extraContext runs AFTER the default `redis: testRedis.redis` spread,
57
+ // so setting redis:undefined here overrides it on the handler-facing
58
+ // AppContext. Framework internals (rate-limit, idempotency, eventDedup,
59
+ // entityCache) receive the real redis via separate buildServer wiring
60
+ // and stay operational — only the handler's ctx.redis is gone.
61
+ extraContext: () => ({
62
+ configResolver: resolver,
63
+ configEncryption: encryption,
64
+ redis: undefined,
65
+ }),
66
+ authConfig: {
67
+ membershipQuery: "tenant:query:memberships",
68
+ loginHandler: AuthHandlers.login,
69
+ loginErrorStatusMap: {
70
+ [AuthErrors.invalidCredentials]: 401,
71
+ [AuthErrors.noMembership]: 403,
72
+ [AuthErrors.accountLocked]: 423,
73
+ },
74
+ },
75
+ });
76
+
77
+ await createEntityTable(stack.db, userEntity);
78
+ await createEntityTable(stack.db, tenantEntity);
79
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await stack.cleanup();
84
+ });
85
+
86
+ beforeEach(async () => {
87
+ await stack.db.delete(userTable);
88
+ await stack.db.delete(tenantMembershipsTable);
89
+ });
90
+
91
+ async function seedLoginUser(
92
+ email: string,
93
+ password: string,
94
+ ): Promise<{ id: string; tenantId: TenantId }> {
95
+ const hash = await hashPassword(password);
96
+ const created = await stack.http.writeOk<{ id: string }>(
97
+ UserHandlers.create,
98
+ { email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
99
+ systemAdmin,
100
+ );
101
+ const tenantId: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
102
+ await seedTenantMembership(stack.db, {
103
+ userId: created.id,
104
+ tenantId,
105
+ roles: ["User"],
106
+ });
107
+ return { id: created.id, tenantId };
108
+ }
109
+
110
+ async function loginAttempt(email: string, password: string): Promise<Response> {
111
+ return stack.http.raw("POST", "/api/auth/login", { email, password });
112
+ }
113
+
114
+ describe("account-lockout — ctx.redis unset", () => {
115
+ test("correct password → 200 login success (handler doesn't touch redis)", async () => {
116
+ await seedLoginUser("ok@example.com", "right-pw");
117
+
118
+ const res = await loginAttempt("ok@example.com", "right-pw");
119
+ expect(res.status).toBe(200);
120
+ });
121
+
122
+ test("wrong password → 401 invalid_credentials (no crash trying to read lockout state)", async () => {
123
+ await seedLoginUser("wrong@example.com", "right-pw");
124
+
125
+ const res = await loginAttempt("wrong@example.com", "nope");
126
+ expect(res.status).toBe(401);
127
+ const body = await res.json();
128
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
129
+ });
130
+
131
+ test("NO lockout applied even after many attempts beyond threshold", async () => {
132
+ // Threshold is 2 in this setup. Without redis, the counter isn't tracked,
133
+ // so repeated misses all return 401 (invalid_credentials) — never 423
134
+ // (account_locked). The IP-rate-limiter would be the catch in prod; we
135
+ // don't exercise it here (authConfig leaves loginRateLimit at default).
136
+ await seedLoginUser("many@example.com", "right-pw");
137
+
138
+ for (let i = 0; i < 5; i++) {
139
+ const res = await loginAttempt("many@example.com", `wrong-${i}`);
140
+ expect(res.status).toBe(401);
141
+ const body = await res.json();
142
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
143
+ }
144
+
145
+ // A correct password STILL logs in — no stuck lockout state.
146
+ const ok = await loginAttempt("many@example.com", "right-pw");
147
+ expect(ok.status).toBe(200);
148
+ });
149
+ });
@@ -0,0 +1,308 @@
1
+ // Account-lockout integration tests — prod-readiness welle 3, step 3.4.
2
+ //
3
+ // Covers the brute-force protection contract:
4
+ // - N wrong-password attempts lock the account for a configurable duration
5
+ // - Locked accounts refuse login without password-verify (no timing-oracle)
6
+ // - Auto-unlock after the lock expires; streak resets to 1 on the next miss
7
+ // - Success clears the Redis lockout state
8
+ // - Enumeration surface unchanged for unknown users
9
+ // - Redis unset: handler still works, lockout is silently skipped (degrades
10
+ // gracefully to the IP-rate-limiter at the edge)
11
+
12
+ import { randomBytes } from "node:crypto";
13
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
14
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import {
16
+ createEntityTable,
17
+ pushTables,
18
+ setupTestStack,
19
+ type TestStack,
20
+ TestUsers,
21
+ } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
23
+ import { createConfigFeature } from "../../config";
24
+ import { createConfigResolver } from "../../config/resolver";
25
+ import { configValuesTable } from "../../config/table";
26
+ import { createTenantFeature } from "../../tenant";
27
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
28
+ import { tenantEntity } from "../../tenant/schema/tenant";
29
+ import { seedTenantMembership } from "../../tenant/testing";
30
+ import { UserHandlers } from "../../user";
31
+ import { createUserFeature } from "../../user/feature";
32
+ import { userEntity, userTable } from "../../user/schema/user";
33
+ import { AuthErrors, AuthHandlers } from "../constants";
34
+ import { createAuthEmailPasswordFeature } from "../feature";
35
+ import { getLockoutState, type LockoutState } from "../lockout-store";
36
+ import { hashPassword } from "../password-hashing";
37
+
38
+ let stack: TestStack;
39
+
40
+ const systemAdmin = TestUsers.systemAdmin;
41
+ const encryptionKey = randomBytes(32).toString("base64");
42
+
43
+ // Tight thresholds for test speed: 3 attempts, 1-minute lock. The default
44
+ // (5/15) is covered implicitly by the config-plumbing test; here we verify
45
+ // the knobs actually land in the handler.
46
+ const MAX_ATTEMPTS = 3;
47
+ const LOCK_MINUTES = 1;
48
+
49
+ beforeAll(async () => {
50
+ const encryption = createEncryptionProvider(encryptionKey);
51
+ const resolver = createConfigResolver({ encryption });
52
+
53
+ stack = await setupTestStack({
54
+ features: [
55
+ createConfigFeature(),
56
+ createUserFeature(),
57
+ createTenantFeature(),
58
+ createAuthEmailPasswordFeature({
59
+ accountLockout: {
60
+ maxFailedAttempts: MAX_ATTEMPTS,
61
+ lockoutDurationMinutes: LOCK_MINUTES,
62
+ },
63
+ }),
64
+ ],
65
+ extraContext: { configResolver: resolver, configEncryption: encryption },
66
+ authConfig: {
67
+ membershipQuery: "tenant:query:memberships",
68
+ loginHandler: AuthHandlers.login,
69
+ loginErrorStatusMap: {
70
+ [AuthErrors.invalidCredentials]: 401,
71
+ [AuthErrors.noMembership]: 403,
72
+ [AuthErrors.accountLocked]: 423,
73
+ },
74
+ },
75
+ });
76
+
77
+ await createEntityTable(stack.db, userEntity);
78
+ await createEntityTable(stack.db, tenantEntity);
79
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
80
+ });
81
+
82
+ afterAll(async () => {
83
+ await stack.cleanup();
84
+ });
85
+
86
+ beforeEach(async () => {
87
+ await stack.db.delete(userTable);
88
+ await stack.db.delete(tenantMembershipsTable);
89
+ // Clear lockout state between tests — the key prefix is feature-owned, so
90
+ // a scan-and-del is the safe bet even if tests share a Redis namespace.
91
+ await stack.redis.flushNamespace();
92
+ });
93
+
94
+ async function seedLoginUser(
95
+ email: string,
96
+ password: string,
97
+ ): Promise<{ id: string; tenantId: TenantId }> {
98
+ const hash = await hashPassword(password);
99
+ const created = await stack.http.writeOk<{ id: string }>(
100
+ UserHandlers.create,
101
+ { email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
102
+ systemAdmin,
103
+ );
104
+ const tenantId: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
105
+ await seedTenantMembership(stack.db, {
106
+ userId: created.id,
107
+ tenantId,
108
+ roles: ["User"],
109
+ });
110
+ return { id: created.id, tenantId };
111
+ }
112
+
113
+ async function loginAttempt(email: string, password: string): Promise<Response> {
114
+ return stack.http.raw("POST", "/api/auth/login", { email, password });
115
+ }
116
+
117
+ async function readLockoutState(userId: string): Promise<LockoutState | null> {
118
+ return getLockoutState(stack.redis.redis, userId);
119
+ }
120
+
121
+ describe("account-lockout — counter increments", () => {
122
+ test("each wrong-password attempt increments the Redis failure counter", async () => {
123
+ const seed = await seedLoginUser("counter@example.com", "right-pw");
124
+
125
+ const r1 = await loginAttempt("counter@example.com", "wrong-1");
126
+ expect(r1.status).toBe(401);
127
+ expect((await readLockoutState(seed.id))?.failureCount).toBe(1);
128
+
129
+ const r2 = await loginAttempt("counter@example.com", "wrong-2");
130
+ expect(r2.status).toBe(401);
131
+ expect((await readLockoutState(seed.id))?.failureCount).toBe(2);
132
+ });
133
+
134
+ test("wrong attempts stay as invalid_credentials until the threshold is crossed", async () => {
135
+ await seedLoginUser("threshold@example.com", "right-pw");
136
+
137
+ for (let i = 0; i < MAX_ATTEMPTS - 1; i++) {
138
+ const r = await loginAttempt("threshold@example.com", `wrong-${i}`);
139
+ expect(r.status).toBe(401);
140
+ const body = await r.json();
141
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
142
+ }
143
+ });
144
+ });
145
+
146
+ describe("account-lockout — threshold + lock", () => {
147
+ test("Nth wrong attempt sets lockedUntil in the future", async () => {
148
+ const seed = await seedLoginUser("threshold@example.com", "right-pw");
149
+
150
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
151
+ await loginAttempt("threshold@example.com", `wrong-${i}`);
152
+ }
153
+
154
+ const state = await readLockoutState(seed.id);
155
+ expect(state?.failureCount).toBe(MAX_ATTEMPTS);
156
+ expect(state?.lockedUntil).not.toBeNull();
157
+ // Lock-duration ~1 min from now; assert within a generous window.
158
+ const msUntilUnlock = (state?.lockedUntil ?? 0) - Date.now();
159
+ expect(msUntilUnlock).toBeGreaterThan(50_000); // > 50 sec
160
+ expect(msUntilUnlock).toBeLessThan(70_000); // < 70 sec
161
+ });
162
+
163
+ test("locked account rejects further attempts with account_locked + retryAfterSeconds", async () => {
164
+ await seedLoginUser("locked@example.com", "right-pw");
165
+
166
+ // Trigger lock
167
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
168
+ await loginAttempt("locked@example.com", `wrong-${i}`);
169
+ }
170
+
171
+ // Next attempt (even with the CORRECT password) is blocked.
172
+ const res = await loginAttempt("locked@example.com", "right-pw");
173
+ expect(res.status).toBe(423);
174
+ const body = await res.json();
175
+ expect(body.error?.details?.reason).toBe(AuthErrors.accountLocked);
176
+ expect(body.error?.details?.retryAfterSeconds).toBeGreaterThan(0);
177
+ expect(body.error?.details?.retryAfterSeconds).toBeLessThanOrEqual(LOCK_MINUTES * 60);
178
+ });
179
+
180
+ test("locked account does not increment the counter on further attempts (no password verify)", async () => {
181
+ const seed = await seedLoginUser("nostack@example.com", "right-pw");
182
+
183
+ // Trigger lock
184
+ for (let i = 0; i < MAX_ATTEMPTS; i++) {
185
+ await loginAttempt("nostack@example.com", `wrong-${i}`);
186
+ }
187
+ const stateBefore = await readLockoutState(seed.id);
188
+
189
+ // Hammer the locked account
190
+ for (let i = 0; i < 5; i++) {
191
+ await loginAttempt("nostack@example.com", `still-wrong-${i}`);
192
+ }
193
+ const stateAfter = await readLockoutState(seed.id);
194
+
195
+ // Counter frozen, lock-until unchanged — no re-locking, no counter inflation.
196
+ expect(stateAfter?.failureCount).toBe(stateBefore?.failureCount);
197
+ expect(stateAfter?.lockedUntil).toBe(stateBefore?.lockedUntil);
198
+ });
199
+ });
200
+
201
+ describe("account-lockout — reset on success", () => {
202
+ test("successful login clears the Redis lockout key entirely", async () => {
203
+ const seed = await seedLoginUser("success@example.com", "right-pw");
204
+
205
+ // Build up some failed attempts (but not enough to lock)
206
+ await loginAttempt("success@example.com", "wrong-1");
207
+ await loginAttempt("success@example.com", "wrong-2");
208
+ expect((await readLockoutState(seed.id))?.failureCount).toBe(2);
209
+
210
+ // Correct login clears the streak
211
+ const res = await loginAttempt("success@example.com", "right-pw");
212
+ expect(res.status).toBe(200);
213
+
214
+ expect(await readLockoutState(seed.id)).toBeNull();
215
+ });
216
+ });
217
+
218
+ describe("account-lockout — auto-unlock (strict semantic)", () => {
219
+ // Simulate a "lock that just expired" — count-key still holds the pre-lock
220
+ // streak value (count >= threshold), until-key has been naturally TTL'd
221
+ // out by Redis. The counter is monotonic by design, so the next wrong
222
+ // password re-locks immediately without a fresh-streak grace period.
223
+ async function seedExpiredLock(userId: string): Promise<void> {
224
+ await stack.redis.redis.set(
225
+ `kumiko:auth:lockout:count:${userId}`,
226
+ String(MAX_ATTEMPTS),
227
+ "EX",
228
+ 3600,
229
+ );
230
+ // Deliberately DO NOT set the until-key — that's what "expired lock"
231
+ // looks like from the store's perspective (Redis auto-reaped it).
232
+ }
233
+
234
+ test("expired lock + wrong password → immediate re-lock (count grows, NO fresh streak)", async () => {
235
+ const seed = await seedLoginUser("expired@example.com", "right-pw");
236
+ await seedExpiredLock(seed.id);
237
+
238
+ // First attempt after auto-unlock: wrong password → counter jumps from
239
+ // MAX_ATTEMPTS to MAX_ATTEMPTS+1. Still at/over threshold → re-locked.
240
+ // The handler response is still 401 (invalid_credentials) because the
241
+ // gate-check at entry saw no active lock; only the NEXT attempt would
242
+ // see the newly-armed lock and get 423.
243
+ const res = await loginAttempt("expired@example.com", "still-wrong");
244
+ expect(res.status).toBe(401);
245
+ const body = await res.json();
246
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
247
+
248
+ const state = await readLockoutState(seed.id);
249
+ expect(state?.failureCount).toBe(MAX_ATTEMPTS + 1);
250
+ expect(state?.lockedUntil).not.toBeNull();
251
+
252
+ // Follow-up attempt surfaces the re-arm as a 423.
253
+ const res2 = await loginAttempt("expired@example.com", "still-wrong-2");
254
+ expect(res2.status).toBe(423);
255
+ });
256
+
257
+ test("expired lock + correct password → success clears both Redis keys", async () => {
258
+ const seed = await seedLoginUser("expired-ok@example.com", "right-pw");
259
+ await seedExpiredLock(seed.id);
260
+
261
+ const res = await loginAttempt("expired-ok@example.com", "right-pw");
262
+ expect(res.status).toBe(200);
263
+
264
+ // Both count-key and until-key are DEL'd — the successful login is the
265
+ // only path that resets the streak. Verified via getLockoutState (null
266
+ // means count-key is missing).
267
+ expect(await readLockoutState(seed.id)).toBeNull();
268
+ });
269
+ });
270
+
271
+ describe("account-lockout — enumeration surface", () => {
272
+ test("unknown email does not leak the lockout code (stays invalid_credentials)", async () => {
273
+ // We haven't seeded this user. If the lockout gate fired before the
274
+ // "user not found" check, probing would tell an attacker "this user
275
+ // exists AND is locked". Gate must stay AFTER the uniform-error branch.
276
+ const res = await loginAttempt("ghost@example.com", "anything");
277
+ expect(res.status).toBe(401);
278
+ const body = await res.json();
279
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
280
+ });
281
+ });
282
+
283
+ describe("account-lockout — race-free counter under concurrent attempts", () => {
284
+ test("parallel wrong-password attempts produce exact count — no under-counting", async () => {
285
+ // The Lua-scripted recordFailedAttempt is the load-bearing claim for
286
+ // "brute-force protection"; a GET/SET-based store would under-count
287
+ // under parallel load (two writers reading count=N both write N+1 →
288
+ // effective N+1 instead of N+2). Here we fire threshold-many attempts
289
+ // in parallel and assert the counter matches exactly.
290
+ const seed = await seedLoginUser("race@example.com", "right-pw");
291
+
292
+ const parallel = Array.from({ length: MAX_ATTEMPTS }, (_, i) =>
293
+ loginAttempt("race@example.com", `wrong-${i}`),
294
+ );
295
+ const results = await Promise.all(parallel);
296
+ // All of them get 401 (either invalid_credentials or account_locked after
297
+ // threshold — either way, none is a 200-success).
298
+ for (const r of results) {
299
+ expect(r.status).not.toBe(200);
300
+ }
301
+
302
+ const state = await readLockoutState(seed.id);
303
+ // Key claim: exactly MAX_ATTEMPTS increments landed, not fewer.
304
+ expect(state?.failureCount).toBe(MAX_ATTEMPTS);
305
+ // And since count >= threshold, the lock is active.
306
+ expect(state?.lockedUntil).not.toBeNull();
307
+ });
308
+ });