@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,106 @@
1
+ // Unit-Tests für die default-HTML-Renderer der Reset/Verify-Mails.
2
+ // Pure-Functions, kein DOM, kein Mail-Versand — nur das Rendering-
3
+ // Contract: subject enthält App-Namen, body enthält Reset-URL und
4
+ // expiresAt, HTML escaped XSS-Versuche, beide Locales unterscheiden
5
+ // sich erwartungsgemäß.
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { renderResetPasswordEmail, renderVerifyEmail } from "../email-templates";
9
+
10
+ describe("renderResetPasswordEmail", () => {
11
+ const baseArgs = {
12
+ resetUrl: "https://acme.example/reset?token=t-abc123",
13
+ expiresAt: "2026-05-04T13:45:00.000Z",
14
+ };
15
+
16
+ test("default-locale 'en' + default-appName 'Account'", () => {
17
+ const out = renderResetPasswordEmail(baseArgs);
18
+ expect(out.subject).toBe("Account — Reset your password");
19
+ expect(out.html).toContain("Reset password");
20
+ expect(out.html).toContain(baseArgs.resetUrl);
21
+ // expiresAt wird zu human-readable-Format formatiert (UTC-pinned).
22
+ expect(out.html).toContain("2026-05-04 13:45 UTC");
23
+ });
24
+
25
+ test("locale 'de' liefert deutsche Subjects + Body", () => {
26
+ const out = renderResetPasswordEmail({ ...baseArgs, locale: "de" });
27
+ expect(out.subject).toContain("Passwort zurücksetzen");
28
+ expect(out.html).toContain("Passwort zurücksetzen");
29
+ expect(out.html).toContain("Hallo");
30
+ });
31
+
32
+ test("appName-Override taucht in subject + body auf", () => {
33
+ const out = renderResetPasswordEmail({ ...baseArgs, appName: "PublicStatus", locale: "en" });
34
+ expect(out.subject).toBe("PublicStatus — Reset your password");
35
+ expect(out.html).toContain("PublicStatus");
36
+ });
37
+
38
+ test("escaped potentielle XSS in resetUrl", () => {
39
+ // Ein Angreifer der den resetUrl-input kontrollieren würde, würde
40
+ // sonst HTML-injection im Mail erreichen. Token-Generation ist
41
+ // server-side, aber defense-in-depth schadet nicht.
42
+ const out = renderResetPasswordEmail({
43
+ ...baseArgs,
44
+ resetUrl: 'https://x.example/?token=t"><script>alert(1)</script>',
45
+ });
46
+ expect(out.html).not.toContain("<script>alert");
47
+ expect(out.html).toContain("&quot;");
48
+ });
49
+
50
+ test("subject + html sind beide non-empty + html startet mit <!DOCTYPE", () => {
51
+ const out = renderResetPasswordEmail(baseArgs);
52
+ expect(out.subject.length).toBeGreaterThan(0);
53
+ expect(out.html).toMatch(/^<!DOCTYPE html>/);
54
+ });
55
+ });
56
+
57
+ describe("renderVerifyEmail", () => {
58
+ const baseArgs = {
59
+ verificationUrl: "https://acme.example/verify?token=v-abc123",
60
+ expiresAt: "2026-05-04T13:45:00.000Z",
61
+ };
62
+
63
+ test("default-locale 'en' + default-appName 'Account'", () => {
64
+ const out = renderVerifyEmail(baseArgs);
65
+ expect(out.subject).toBe("Account — Verify your email");
66
+ expect(out.html).toContain("Verify email");
67
+ expect(out.html).toContain(baseArgs.verificationUrl);
68
+ expect(out.html).toContain("2026-05-04 13:45 UTC");
69
+ });
70
+
71
+ test("locale 'de' liefert deutsche Subjects + Body", () => {
72
+ const out = renderVerifyEmail({ ...baseArgs, locale: "de" });
73
+ expect(out.subject).toContain("E-Mail bestätigen");
74
+ expect(out.html).toContain("E-Mail bestätigen");
75
+ expect(out.html).toContain("Willkommen");
76
+ });
77
+
78
+ test("appName-Override im subject", () => {
79
+ const out = renderVerifyEmail({ ...baseArgs, appName: "PublicStatus", locale: "en" });
80
+ expect(out.subject).toBe("PublicStatus — Verify your email");
81
+ });
82
+
83
+ test("escaped XSS in verificationUrl", () => {
84
+ const out = renderVerifyEmail({
85
+ ...baseArgs,
86
+ verificationUrl: 'https://x.example/?token=v"><script>alert(1)</script>',
87
+ });
88
+ expect(out.html).not.toContain("<script>alert");
89
+ });
90
+ });
91
+
92
+ describe("Reset vs Verify haben separate subjects + body-Texte", () => {
93
+ // Sicherheit gegen Copy-Paste-Bugs zwischen den beiden Renderern —
94
+ // die sind strukturell ähnlich, aber subjects + body-Intros müssen
95
+ // klar unterschiedlich sein damit User die Mails nicht verwechselt.
96
+ const args = { expiresAt: "2026-05-04T13:45:00.000Z" };
97
+ const reset = renderResetPasswordEmail({ ...args, resetUrl: "https://x/r" });
98
+ const verify = renderVerifyEmail({ ...args, verificationUrl: "https://x/v" });
99
+ test("subjects unterscheiden sich", () => {
100
+ expect(reset.subject).not.toBe(verify.subject);
101
+ });
102
+ test("button-labels unterscheiden sich", () => {
103
+ expect(reset.html).toContain("Reset password");
104
+ expect(verify.html).toContain("Verify email");
105
+ });
106
+ });
@@ -0,0 +1,327 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import {
5
+ createEntityTable,
6
+ pushTables,
7
+ setupTestStack,
8
+ type TestStack,
9
+ TestUsers,
10
+ } from "@cosmicdrift/kumiko-framework/stack";
11
+ import { eq } from "drizzle-orm";
12
+ import { Temporal } from "temporal-polyfill";
13
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
14
+ import { createConfigFeature } from "../../config";
15
+ import { createConfigResolver } from "../../config/resolver";
16
+ import { configValuesTable } from "../../config/table";
17
+ import { createTenantFeature } from "../../tenant";
18
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
19
+ import { tenantEntity } from "../../tenant/schema/tenant";
20
+ import { seedTenantMembership } from "../../tenant/testing";
21
+ import { UserHandlers } from "../../user";
22
+ import { createUserFeature } from "../../user/feature";
23
+ import { userEntity, userTable } from "../../user/schema/user";
24
+ import { AuthErrors, AuthHandlers } from "../constants";
25
+ import { createAuthEmailPasswordFeature } from "../feature";
26
+ import { hashPassword } from "../password-hashing";
27
+ import { signResetToken } from "../reset-token";
28
+ import { signVerificationToken } from "../verification-token";
29
+
30
+ const capturedEmails: Array<{ email: string; verificationUrl: string; expiresAt: string }> = [];
31
+
32
+ let stack: TestStack;
33
+ const systemAdmin = TestUsers.systemAdmin;
34
+ const encryptionKey = randomBytes(32).toString("base64");
35
+ const verifySecret = randomBytes(32).toString("base64");
36
+ // Reset-flow co-configured so the cross-purpose-burn-isolation test can
37
+ // consume a reset token and then prove a verify token survives. Unused by
38
+ // the other tests in this file — no side effects on their setups.
39
+ const resetSecret = randomBytes(32).toString("base64");
40
+ const appVerifyUrl = "https://app.example.com/verify";
41
+ const appResetUrl = "https://app.example.com/reset";
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
+ emailVerification: {
54
+ hmacSecret: verifySecret,
55
+ tokenTtlMinutes: 60,
56
+ mode: "strict",
57
+ },
58
+ passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15 },
59
+ }),
60
+ ],
61
+ extraContext: { configResolver: resolver, configEncryption: encryption },
62
+ authConfig: {
63
+ membershipQuery: "tenant:query:memberships",
64
+ loginHandler: AuthHandlers.login,
65
+ loginErrorStatusMap: {
66
+ [AuthErrors.invalidCredentials]: 401,
67
+ [AuthErrors.noMembership]: 403,
68
+ [AuthErrors.emailNotVerified]: 403,
69
+ },
70
+ emailVerification: {
71
+ requestHandler: AuthHandlers.requestEmailVerification,
72
+ confirmHandler: AuthHandlers.verifyEmail,
73
+ appVerifyUrl,
74
+ sendVerificationEmail: async (args) => {
75
+ capturedEmails.push(args);
76
+ },
77
+ },
78
+ passwordReset: {
79
+ requestHandler: AuthHandlers.requestPasswordReset,
80
+ confirmHandler: AuthHandlers.resetPassword,
81
+ appResetUrl,
82
+ sendResetEmail: async () => {},
83
+ },
84
+ },
85
+ });
86
+
87
+ await createEntityTable(stack.db, userEntity);
88
+ await createEntityTable(stack.db, tenantEntity);
89
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
90
+ });
91
+
92
+ afterAll(async () => {
93
+ await stack.cleanup();
94
+ });
95
+
96
+ beforeEach(async () => {
97
+ await stack.db.delete(userTable);
98
+ await stack.db.delete(tenantMembershipsTable);
99
+ capturedEmails.length = 0;
100
+ });
101
+
102
+ async function seedUser(opts: {
103
+ email: string;
104
+ password: string;
105
+ emailVerified?: boolean;
106
+ tenantId?: TenantId;
107
+ }): Promise<{ id: string; tenantId: TenantId }> {
108
+ const hash = await hashPassword(opts.password);
109
+ const created = await stack.http.writeOk<{ id: string }>(
110
+ UserHandlers.create,
111
+ {
112
+ email: opts.email,
113
+ passwordHash: hash,
114
+ displayName: opts.email.split("@")[0] ?? "user",
115
+ },
116
+ systemAdmin,
117
+ );
118
+ // user:create schema doesn't expose emailVerified (by design — it's a
119
+ // privileged field only the verify-email flow flips). Tests need a
120
+ // pre-verified account for "login with verified user" cases, so we set
121
+ // it directly via SQL after create. Row.version is left at 1; no
122
+ // subsequent event-store writes happen on this row in these tests.
123
+ if (opts.emailVerified === true) {
124
+ await stack.db
125
+ .update(userTable)
126
+ .set({ emailVerified: true })
127
+ .where(eq(userTable["id"], created.id));
128
+ }
129
+ const tenantId = opts.tenantId ?? "00000000-0000-4000-8000-000000000001";
130
+ await seedTenantMembership(stack.db, {
131
+ userId: created.id,
132
+ tenantId,
133
+ roles: ["User"],
134
+ });
135
+ return { id: created.id, tenantId };
136
+ }
137
+
138
+ async function post(path: string, body: unknown): Promise<Response> {
139
+ return stack.http.raw("POST", path, body);
140
+ }
141
+
142
+ // --- request-email-verification -------------------------------------------
143
+
144
+ describe("POST /auth/request-email-verification", () => {
145
+ test("unverified user → 200, email callback invoked with verification URL", async () => {
146
+ await seedUser({ email: "fresh@example.com", password: "pw-initial-1234" });
147
+
148
+ const res = await post("/api/auth/request-email-verification", {
149
+ email: "fresh@example.com",
150
+ });
151
+
152
+ expect(res.status).toBe(200);
153
+ expect(capturedEmails).toHaveLength(1);
154
+ const [captured] = capturedEmails;
155
+ if (!captured) throw new Error("no email captured");
156
+ expect(captured.email).toBe("fresh@example.com");
157
+ expect(captured.verificationUrl.startsWith(`${appVerifyUrl}?token=`)).toBe(true);
158
+ });
159
+
160
+ test("already-verified user → 200, NO callback (enumeration-safe)", async () => {
161
+ await seedUser({
162
+ email: "done@example.com",
163
+ password: "pw-already-1234",
164
+ emailVerified: true,
165
+ });
166
+
167
+ const res = await post("/api/auth/request-email-verification", {
168
+ email: "done@example.com",
169
+ });
170
+
171
+ expect(res.status).toBe(200);
172
+ expect(capturedEmails).toHaveLength(0);
173
+ });
174
+
175
+ test("unknown email → 200, NO callback (enumeration-safe)", async () => {
176
+ const res = await post("/api/auth/request-email-verification", {
177
+ email: "ghost@example.com",
178
+ });
179
+ expect(res.status).toBe(200);
180
+ expect(capturedEmails).toHaveLength(0);
181
+ });
182
+ });
183
+
184
+ // --- verify-email ----------------------------------------------------------
185
+
186
+ describe("POST /auth/verify-email", () => {
187
+ test("valid token → emailVerified=true on the user row", async () => {
188
+ const seed = await seedUser({ email: "ben@example.com", password: "pw-for-ben-1234" });
189
+ const { token } = signVerificationToken(seed.id, 60, verifySecret);
190
+
191
+ const res = await post("/api/auth/verify-email", { token });
192
+ expect(res.status).toBe(200);
193
+
194
+ const row = (await stack.db.select().from(userTable)).find((r) => r["id"] === seed.id);
195
+ expect(row?.["emailVerified"]).toBe(true);
196
+ });
197
+
198
+ test("expired token via the route → 422 invalid_verification_token", async () => {
199
+ const seed = await seedUser({ email: "time@example.com", password: "pw-time-1234" });
200
+ const past = Temporal.Now.instant().subtract({ hours: 25 });
201
+ const { token } = signVerificationToken(seed.id, 60, verifySecret, past);
202
+
203
+ const res = await post("/api/auth/verify-email", { token });
204
+ expect(res.status).toBe(422);
205
+ const body = await res.json();
206
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidVerificationToken);
207
+ });
208
+
209
+ test("verify that fails before the write is retryable (burn released on failure)", async () => {
210
+ // Symmetric to the reset-password retry test: if the confirm-flow
211
+ // fails AFTER burning (here: no memberships → empty tenantOrder),
212
+ // the finally-block in runConfirmTokenFlow releases the burn so
213
+ // the user can click the same link again once ops restores state.
214
+ const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
215
+ const { token } = signVerificationToken(seed.id, 60, verifySecret);
216
+
217
+ await stack.db.delete(tenantMembershipsTable);
218
+ const firstAttempt = await post("/api/auth/verify-email", { token });
219
+ expect(firstAttempt.status).toBe(422);
220
+
221
+ await seedTenantMembership(stack.db, {
222
+ userId: seed.id,
223
+ tenantId: seed.tenantId,
224
+ roles: ["User"],
225
+ });
226
+
227
+ const secondAttempt = await post("/api/auth/verify-email", { token });
228
+ expect(secondAttempt.status).toBe(200);
229
+ });
230
+
231
+ test("cross-purpose burn isolation: consuming a reset-token doesn't block a verify-token for the same user+expiry", async () => {
232
+ // The burn-store key includes purpose ("reset" vs "verify"). Tokens
233
+ // signed with the SAME userId + expiresAtMs but different purpose
234
+ // therefore occupy different burn slots. Without that separation,
235
+ // a password-reset would incorrectly block a follow-up email
236
+ // verification (or vice versa) during TTL overlap.
237
+ const seed = await seedUser({ email: "iso@example.com", password: "initial-pw-1234" });
238
+ const ts = Temporal.Now.instant();
239
+ const { token: resetToken } = signResetToken(seed.id, 15, resetSecret, ts);
240
+ const { token: verifyToken } = signVerificationToken(seed.id, 15, verifySecret, ts);
241
+
242
+ const reset = await post("/api/auth/reset-password", {
243
+ token: resetToken,
244
+ newPassword: "after-reset-1234",
245
+ });
246
+ expect(reset.status).toBe(200);
247
+
248
+ // Reset burned the "reset" slot. Verify uses the "verify" slot —
249
+ // must be independent.
250
+ const verify = await post("/api/auth/verify-email", { token: verifyToken });
251
+ expect(verify.status).toBe(200);
252
+ });
253
+
254
+ test("replayed verify-token → 422 invalid_verification_token (single-use burn)", async () => {
255
+ const seed = await seedUser({ email: "oneshot@example.com", password: "pw-oneshot-1234" });
256
+ const { token } = signVerificationToken(seed.id, 60, verifySecret);
257
+
258
+ const first = await post("/api/auth/verify-email", { token });
259
+ expect(first.status).toBe(200);
260
+
261
+ const second = await post("/api/auth/verify-email", { token });
262
+ expect(second.status).toBe(422);
263
+ const body = await second.json();
264
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidVerificationToken);
265
+ });
266
+
267
+ test("reset-token replayed as verify-token → 422 (cross-purpose blocked)", async () => {
268
+ const seed = await seedUser({ email: "cross@example.com", password: "pw-cross-1234" });
269
+ // Sign a token with a different purpose but the SAME secret+userId —
270
+ // the verify-token verify() must reject it.
271
+ const { signResetToken } = await import("../reset-token");
272
+ const { token } = signResetToken(seed.id, 60, verifySecret);
273
+
274
+ const res = await post("/api/auth/verify-email", { token });
275
+ expect(res.status).toBe(422);
276
+ const body = await res.json();
277
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidVerificationToken);
278
+ });
279
+ });
280
+
281
+ // --- login gate (strict mode) ---------------------------------------------
282
+
283
+ describe("login with strict email-verification", () => {
284
+ test("unverified user → 403 email_not_verified (post-password check)", async () => {
285
+ await seedUser({ email: "locked@example.com", password: "pw-locked-1234" });
286
+
287
+ const res = await post("/api/auth/login", {
288
+ email: "locked@example.com",
289
+ password: "pw-locked-1234",
290
+ });
291
+
292
+ expect(res.status).toBe(403);
293
+ const body = await res.json();
294
+ expect(body.error?.details?.reason).toBe(AuthErrors.emailNotVerified);
295
+ });
296
+
297
+ test("verified user → 200, token returned", async () => {
298
+ await seedUser({
299
+ email: "verified@example.com",
300
+ password: "pw-verif-1234",
301
+ emailVerified: true,
302
+ });
303
+
304
+ const res = await post("/api/auth/login", {
305
+ email: "verified@example.com",
306
+ password: "pw-verif-1234",
307
+ });
308
+
309
+ expect(res.status).toBe(200);
310
+ const body = await res.json();
311
+ expect(body.isSuccess).toBe(true);
312
+ expect(typeof body.token).toBe("string");
313
+ });
314
+
315
+ test("wrong password → still invalid_credentials (verification-check runs AFTER)", async () => {
316
+ await seedUser({ email: "pwprobe@example.com", password: "pw-probe-1234" });
317
+
318
+ const res = await post("/api/auth/login", {
319
+ email: "pwprobe@example.com",
320
+ password: "nope",
321
+ });
322
+
323
+ expect(res.status).toBe(401);
324
+ const body = await res.json();
325
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
326
+ });
327
+ });
@@ -0,0 +1,174 @@
1
+ // Tests for the ASP.NET Core Identity V3 password-hash verifier.
2
+ //
3
+ // Strategy:
4
+ // - "Round-trip" tests build a V3-format blob from scratch using the
5
+ // documented byte-layout, then verify it. The Buffer.concat + readUInt32BE
6
+ // in `buildV3Hash` are independent of the verifier's parser, so a bug in
7
+ // either side surfaces as a mismatch.
8
+ // - The hardcoded "BMC-shaped" hash bytes (0x01, PRF=1, iter=10000, salt=16
9
+ // bytes) lock the format the BMC dump actually carries. If the parser
10
+ // drifted to e.g. little-endian or a wrong header offset, the iter+saltLen
11
+ // read would mis-align and verification would fail.
12
+ // - Negative tests cover wrong password, format-marker mismatch, truncated
13
+ // blobs, unsupported PRF — all the ways a verifier could leak a 500
14
+ // instead of returning false.
15
+
16
+ import { pbkdf2Sync } from "node:crypto";
17
+ import { describe, expect, test } from "vitest";
18
+ import { isIdentityV3Hash, verifyIdentityV3Hash } from "../identity-v3-hash";
19
+ import { verifyPassword } from "../password-hashing";
20
+
21
+ // --- Test helpers ---
22
+
23
+ const BMC_ITERATIONS = 10_000;
24
+ const BMC_SUBKEY_LENGTH = 32;
25
+
26
+ const PRF_HMAC_SHA256 = 1;
27
+ const PRF_HMAC_SHA512 = 2;
28
+
29
+ function buildV3Hash(args: {
30
+ readonly password: string;
31
+ readonly salt: Buffer;
32
+ readonly iterations: number;
33
+ readonly prf: number;
34
+ readonly subkeyLength?: number;
35
+ }): string {
36
+ const algorithm =
37
+ args.prf === PRF_HMAC_SHA256 ? "sha256" : args.prf === PRF_HMAC_SHA512 ? "sha512" : null;
38
+ if (algorithm === null) throw new Error(`unsupported test PRF: ${args.prf}`);
39
+
40
+ const subkeyLength = args.subkeyLength ?? BMC_SUBKEY_LENGTH;
41
+ const subkey = pbkdf2Sync(args.password, args.salt, args.iterations, subkeyLength, algorithm);
42
+
43
+ const header = Buffer.alloc(13);
44
+ header.writeUInt8(0x01, 0); // V3 format marker
45
+ header.writeUInt32BE(args.prf, 1);
46
+ header.writeUInt32BE(args.iterations, 5);
47
+ header.writeUInt32BE(args.salt.length, 9);
48
+
49
+ return Buffer.concat([header, args.salt, subkey]).toString("base64");
50
+ }
51
+
52
+ // Reproducible salt via static seed — using random salts in tests muddies
53
+ // the failure mode (was it the verifier or RNG flakiness?).
54
+ const FIXED_SALT_16 = Buffer.from("0123456789abcdef", "ascii");
55
+
56
+ describe("isIdentityV3Hash", () => {
57
+ test("recognizes a real-shape BMC V3 hash by format marker", () => {
58
+ const hash = buildV3Hash({
59
+ password: "irrelevant",
60
+ salt: FIXED_SALT_16,
61
+ iterations: BMC_ITERATIONS,
62
+ prf: PRF_HMAC_SHA256,
63
+ });
64
+ expect(isIdentityV3Hash(hash)).toBe(true);
65
+ // Sanity: real BMC hashes start with this fixed prefix because the first
66
+ // 9 header bytes are deterministic for HMAC-SHA256/10000-iter hashes.
67
+ expect(hash.startsWith("AQAAAAEAACcQ")).toBe(true);
68
+ });
69
+
70
+ test("rejects argon2 hashes (different format prefix)", () => {
71
+ expect(isIdentityV3Hash("$argon2id$v=19$m=19456,t=2,p=1$abc$def")).toBe(false);
72
+ });
73
+
74
+ test("rejects empty strings and garbage", () => {
75
+ expect(isIdentityV3Hash("")).toBe(false);
76
+ expect(isIdentityV3Hash("not-base64-!!!")).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("verifyIdentityV3Hash — BMC-shaped (HMACSHA256, 10k iter, 16-byte salt)", () => {
81
+ const password = "Test-Pa$$word-1!";
82
+ const hash = buildV3Hash({
83
+ password,
84
+ salt: FIXED_SALT_16,
85
+ iterations: BMC_ITERATIONS,
86
+ prf: PRF_HMAC_SHA256,
87
+ });
88
+
89
+ test("accepts the correct password", () => {
90
+ expect(verifyIdentityV3Hash(password, hash)).toBe(true);
91
+ });
92
+
93
+ test("rejects an incorrect password (no exception, just false)", () => {
94
+ expect(verifyIdentityV3Hash("Wrong-Password!", hash)).toBe(false);
95
+ });
96
+
97
+ test("rejects empty password against a real hash", () => {
98
+ expect(verifyIdentityV3Hash("", hash)).toBe(false);
99
+ });
100
+ });
101
+
102
+ describe("verifyIdentityV3Hash — HMACSHA512 variant", () => {
103
+ // ASP.NET also writes V3 hashes with PRF=2 (HMACSHA512). BMC doesn't use it
104
+ // but we shouldn't break a future migration that does.
105
+ const password = "another-secret";
106
+ const hash = buildV3Hash({
107
+ password,
108
+ salt: FIXED_SALT_16,
109
+ iterations: 50_000,
110
+ prf: PRF_HMAC_SHA512,
111
+ });
112
+
113
+ test("verifies SHA512 hashes correctly", () => {
114
+ expect(verifyIdentityV3Hash(password, hash)).toBe(true);
115
+ expect(verifyIdentityV3Hash("not-it", hash)).toBe(false);
116
+ });
117
+ });
118
+
119
+ describe("verifyIdentityV3Hash — malformed input", () => {
120
+ test("returns false on truncated header", () => {
121
+ // 5 bytes: format + half-PRF — not enough for the 13-byte header.
122
+ const truncated = Buffer.from([0x01, 0x00, 0x00, 0x00, 0x01]).toString("base64");
123
+ expect(verifyIdentityV3Hash("anything", truncated)).toBe(false);
124
+ });
125
+
126
+ test("returns false on unsupported PRF (e.g. 99)", () => {
127
+ const password = "secret";
128
+ const subkey = pbkdf2Sync(password, FIXED_SALT_16, 1000, 32, "sha256");
129
+ const header = Buffer.alloc(13);
130
+ header.writeUInt8(0x01, 0);
131
+ header.writeUInt32BE(99, 1); // bogus PRF
132
+ header.writeUInt32BE(1000, 5);
133
+ header.writeUInt32BE(FIXED_SALT_16.length, 9);
134
+ const hash = Buffer.concat([header, FIXED_SALT_16, subkey]).toString("base64");
135
+ expect(verifyIdentityV3Hash(password, hash)).toBe(false);
136
+ });
137
+
138
+ test("returns false when subkey is missing", () => {
139
+ // Header announces 16-byte salt and supplies it, but no subkey bytes.
140
+ const header = Buffer.alloc(13);
141
+ header.writeUInt8(0x01, 0);
142
+ header.writeUInt32BE(1, 1);
143
+ header.writeUInt32BE(10_000, 5);
144
+ header.writeUInt32BE(16, 9);
145
+ const hash = Buffer.concat([header, FIXED_SALT_16]).toString("base64");
146
+ expect(verifyIdentityV3Hash("anything", hash)).toBe(false);
147
+ });
148
+
149
+ test("returns false on wrong format marker (e.g. 0x00 = legacy V2)", () => {
150
+ const subkey = pbkdf2Sync("x", FIXED_SALT_16, 1000, 32, "sha256");
151
+ const header = Buffer.alloc(13);
152
+ header.writeUInt8(0x00, 0); // V2 marker, not supported here
153
+ header.writeUInt32BE(1, 1);
154
+ header.writeUInt32BE(1000, 5);
155
+ header.writeUInt32BE(16, 9);
156
+ const hash = Buffer.concat([header, FIXED_SALT_16, subkey]).toString("base64");
157
+ expect(verifyIdentityV3Hash("x", hash)).toBe(false);
158
+ });
159
+ });
160
+
161
+ describe("verifyPassword — routes between argon2 and Identity-V3", () => {
162
+ test("accepts a V3 hash via the unified verifyPassword entry point", async () => {
163
+ const password = "legacy-user-password";
164
+ const hash = buildV3Hash({
165
+ password,
166
+ salt: FIXED_SALT_16,
167
+ iterations: BMC_ITERATIONS,
168
+ prf: PRF_HMAC_SHA256,
169
+ });
170
+
171
+ expect(await verifyPassword(hash, password)).toBe(true);
172
+ expect(await verifyPassword(hash, "wrong")).toBe(false);
173
+ });
174
+ });