@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,150 @@
1
+ // End-to-end: ein migrierter User mit ASP.NET-Identity-V3-passwordHash kann
2
+ // sich über den normalen `auth-email-password.login`-Handler einloggen, ohne
3
+ // vorher Password-Reset durchlaufen zu müssen.
4
+ //
5
+ // Das ist der Kern-Use-Case der BMC-Migration — Legacy-Hashes 1:1
6
+ // übernommen, Login funktioniert weiter.
7
+
8
+ import { pbkdf2Sync, randomBytes } from "node:crypto";
9
+ import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
10
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
11
+ import {
12
+ createEntityTable,
13
+ pushTables,
14
+ setupTestStack,
15
+ type TestStack,
16
+ TestUsers,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
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
+
32
+ let stack: TestStack;
33
+
34
+ const systemAdmin = TestUsers.systemAdmin;
35
+ const encryptionKey = randomBytes(32).toString("base64");
36
+
37
+ // Build a V3-format hash with the BMC profile (HMACSHA256, 10000 iter,
38
+ // 16-byte salt). Mirrors the encoding in identity-v3-hash.test.ts — kept
39
+ // inline so this integration test stands alone.
40
+ function buildBmcStyleV3Hash(password: string, salt: Buffer): string {
41
+ const subkey = pbkdf2Sync(password, salt, 10_000, 32, "sha256");
42
+ const header = Buffer.alloc(13);
43
+ header.writeUInt8(0x01, 0); // V3 format marker
44
+ header.writeUInt32BE(1, 1); // PRF = HMACSHA256
45
+ header.writeUInt32BE(10_000, 5); // iterations
46
+ header.writeUInt32BE(salt.length, 9); // salt length
47
+ return Buffer.concat([header, salt, subkey]).toString("base64");
48
+ }
49
+
50
+ beforeAll(async () => {
51
+ const encryption = createEncryptionProvider(encryptionKey);
52
+ const resolver = createConfigResolver({ encryption });
53
+
54
+ stack = await setupTestStack({
55
+ features: [
56
+ createConfigFeature(),
57
+ createUserFeature(),
58
+ createTenantFeature(),
59
+ createAuthEmailPasswordFeature(),
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
+ },
69
+ },
70
+ });
71
+
72
+ await createEntityTable(stack.db, userEntity);
73
+ await createEntityTable(stack.db, tenantEntity);
74
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
75
+ });
76
+
77
+ afterAll(async () => {
78
+ await stack.cleanup();
79
+ });
80
+
81
+ beforeEach(async () => {
82
+ await stack.db.delete(userTable);
83
+ await stack.db.delete(tenantMembershipsTable);
84
+ });
85
+
86
+ describe("Identity-V3 password-hash compatibility", () => {
87
+ test("legacy V3-hashed user can log in with the right password", async () => {
88
+ const password = "Migrated!Password-2025";
89
+ const salt = randomBytes(16);
90
+ const v3Hash = buildBmcStyleV3Hash(password, salt);
91
+
92
+ // Seed the migrated user with the legacy hash 1:1 — no rehash, no reset.
93
+ const tenantId = "00000000-0000-4000-8000-000000000099" as TenantId;
94
+ const created = await stack.http.writeOk<{ id: string }>(
95
+ UserHandlers.create,
96
+ {
97
+ email: "alice@legacy.example",
98
+ passwordHash: v3Hash,
99
+ displayName: "Alice Migrated",
100
+ },
101
+ systemAdmin,
102
+ );
103
+ await seedTenantMembership(stack.db, {
104
+ userId: created.id,
105
+ tenantId,
106
+ roles: ["User"],
107
+ });
108
+
109
+ // Login: same /api/auth/login route the BMC frontend will hit
110
+ // post-migration. Public route — no JWT, no authenticated caller.
111
+ const res = await stack.http.raw("POST", "/api/auth/login", {
112
+ email: "alice@legacy.example",
113
+ password,
114
+ });
115
+ expect(res.status).toBe(200);
116
+ const body = await res.json();
117
+ expect(body.isSuccess).toBe(true);
118
+ expect(body.user).toMatchObject({ id: created.id, tenantId });
119
+ });
120
+
121
+ test("legacy V3-hashed user is rejected with the wrong password", async () => {
122
+ const password = "right-password";
123
+ const salt = randomBytes(16);
124
+ const v3Hash = buildBmcStyleV3Hash(password, salt);
125
+
126
+ const tenantId = "00000000-0000-4000-8000-000000000098" as TenantId;
127
+ const created = await stack.http.writeOk<{ id: string }>(
128
+ UserHandlers.create,
129
+ {
130
+ email: "bob@legacy.example",
131
+ passwordHash: v3Hash,
132
+ displayName: "Bob Migrated",
133
+ },
134
+ systemAdmin,
135
+ );
136
+ await seedTenantMembership(stack.db, {
137
+ userId: created.id,
138
+ tenantId,
139
+ roles: ["User"],
140
+ });
141
+
142
+ const res = await stack.http.raw("POST", "/api/auth/login", {
143
+ email: "bob@legacy.example",
144
+ password: "wrong-password",
145
+ });
146
+ expect(res.status).toBe(401);
147
+ const body = await res.json();
148
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
149
+ });
150
+ });
@@ -0,0 +1,458 @@
1
+ // Tenant-Invite-Flow Full-Stack Integration-Test. Spec für die 3
2
+ // Accept-Branches via stack.http (echte HTTP-Routes durch).
3
+ //
4
+ // Setup:
5
+ // - Tenant-A mit Admin "alice@" als Admin-Member
6
+ // - Tenant-B mit User "bob@" als Member (für Branch 1: Bob ist
7
+ // eingeloggt in Tenant-B und akzeptiert ein Tenant-A-Invite)
8
+ // - "carol@" existiert NICHT (für Branch 3: neue Email)
9
+ //
10
+ // Flow pro Test:
11
+ // 1. Admin invitet email → invite-create (Admin-Auth)
12
+ // 2. Mail captured durch sendInviteEmail-callback
13
+ // 3. Token aus Activation-URL extrahieren
14
+ // 4. Branch-spezifischer Accept-Endpoint
15
+ // 5. DB-State + Membership + Cookies/JWT verifizieren
16
+
17
+ import {
18
+ createSystemUser,
19
+ type SessionUser,
20
+ SYSTEM_TENANT_ID,
21
+ type TenantId,
22
+ } from "@cosmicdrift/kumiko-framework/engine";
23
+ import {
24
+ createEntityTable,
25
+ pushTables,
26
+ setupTestStack,
27
+ type TestStack,
28
+ } from "@cosmicdrift/kumiko-framework/stack";
29
+ import { eq } from "drizzle-orm";
30
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
31
+ import { createConfigFeature } from "../../config";
32
+ import { createConfigResolver } from "../../config/resolver";
33
+ import { configValuesTable } from "../../config/table";
34
+ import { createTenantFeature } from "../../tenant";
35
+ import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
36
+ import { tenantMembershipsTable } from "../../tenant/membership-table";
37
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
38
+ import { seedTenant, seedTenantMembership } from "../../tenant/seeding";
39
+ import { createUserFeature } from "../../user/feature";
40
+ import { userEntity, userTable } from "../../user/schema/user";
41
+ import { AuthErrors, AuthHandlers } from "../constants";
42
+ import { createAuthEmailPasswordFeature } from "../feature";
43
+ import { hashPassword } from "../password-hashing";
44
+ import { seedUser } from "../seeding";
45
+
46
+ const APP_ACCEPT_URL = "https://app.example.com/invite/accept";
47
+ const ALICE_EMAIL = "alice@example.com";
48
+ const BOB_EMAIL = "bob@example.com";
49
+ const CAROL_EMAIL = "carol@example.com";
50
+ const BOB_PASSWORD = "bob-existing-pw-1234";
51
+ const CAROL_PASSWORD = "carol-new-pw-1234";
52
+
53
+ const capturedInviteEmails: Array<{
54
+ email: string;
55
+ inviteUrl: string;
56
+ expiresAt: string;
57
+ role: string;
58
+ }> = [];
59
+
60
+ let stack: TestStack;
61
+ let aliceId: string;
62
+ let bobId: string;
63
+ // Pro Test frische Tenant-IDs damit der event-store-stream beim
64
+ // db.delete-cleanup nicht mit version_conflict beim Re-seed feuert.
65
+ let TENANT_A_ID: TenantId;
66
+ let TENANT_B_ID: TenantId;
67
+
68
+ function newTenantId(_suffix: string): TenantId {
69
+ // UUIDv4 + suffix für Lesbarkeit in Logs.
70
+ const rand = crypto.randomUUID();
71
+ return rand as TenantId;
72
+ }
73
+
74
+ const GUEST: SessionUser = {
75
+ id: "00000000-0000-0000-0000-000000000000",
76
+ tenantId: SYSTEM_TENANT_ID,
77
+ roles: ["all"],
78
+ };
79
+
80
+ beforeAll(async () => {
81
+ stack = await setupTestStack({
82
+ features: [
83
+ createConfigFeature(),
84
+ createUserFeature(),
85
+ createTenantFeature(),
86
+ createAuthEmailPasswordFeature({
87
+ invite: { tokenTtlMinutes: 60 },
88
+ }),
89
+ ],
90
+ extraContext: { configResolver: createConfigResolver() },
91
+ authConfig: {
92
+ membershipQuery: "tenant:query:memberships",
93
+ loginHandler: AuthHandlers.login,
94
+ invite: {
95
+ acceptHandler: AuthHandlers.inviteAccept,
96
+ acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
97
+ signupCompleteHandler: AuthHandlers.inviteSignupComplete,
98
+ appAcceptUrl: APP_ACCEPT_URL,
99
+ sendInviteEmail: async (args) => {
100
+ capturedInviteEmails.push(args);
101
+ },
102
+ },
103
+ },
104
+ });
105
+
106
+ await createEntityTable(stack.db, userEntity);
107
+ await createEntityTable(stack.db, tenantEntity);
108
+ await createEntityTable(stack.db, tenantInvitationEntity);
109
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
110
+ });
111
+
112
+ afterAll(async () => {
113
+ await stack.cleanup();
114
+ });
115
+
116
+ beforeEach(async () => {
117
+ await stack.db.delete(userTable);
118
+ await stack.db.delete(tenantMembershipsTable);
119
+ await stack.db.delete(tenantInvitationsTable);
120
+ await stack.db.delete(tenantTable);
121
+ capturedInviteEmails.length = 0;
122
+ const allKeys = await stack.redis.redis.keys("invite:*");
123
+ if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
124
+
125
+ // Pro Test frische Tenant-IDs + tenant.key (sonst unique-violation
126
+ // auf read_tenants_key_unique beim 2. Run).
127
+ TENANT_A_ID = newTenantId("a");
128
+ TENANT_B_ID = newTenantId("b");
129
+ await seedTenant(stack.db, {
130
+ id: TENANT_A_ID,
131
+ key: `tenant-a-${TENANT_A_ID.slice(0, 8)}`,
132
+ name: "Tenant A",
133
+ });
134
+ await seedTenant(stack.db, {
135
+ id: TENANT_B_ID,
136
+ key: `tenant-b-${TENANT_B_ID.slice(0, 8)}`,
137
+ name: "Tenant B",
138
+ });
139
+
140
+ // Alice = Admin von Tenant-A
141
+ aliceId = await seedUser(stack.db, {
142
+ email: ALICE_EMAIL,
143
+ displayName: "Alice",
144
+ passwordHash: await hashPassword("alice-pw-1234"),
145
+ emailVerified: true,
146
+ });
147
+ await seedTenantMembership(stack.db, {
148
+ userId: aliceId,
149
+ tenantId: TENANT_A_ID,
150
+ roles: ["Admin"],
151
+ });
152
+
153
+ // Bob = Member von Tenant-B (für Branch 1 + 2 tests)
154
+ bobId = await seedUser(stack.db, {
155
+ email: BOB_EMAIL,
156
+ displayName: "Bob",
157
+ passwordHash: await hashPassword(BOB_PASSWORD),
158
+ emailVerified: true,
159
+ });
160
+ await seedTenantMembership(stack.db, {
161
+ userId: bobId,
162
+ tenantId: TENANT_B_ID,
163
+ roles: ["User"],
164
+ });
165
+ });
166
+
167
+ function aliceSession(): SessionUser {
168
+ return { id: aliceId, tenantId: TENANT_A_ID, roles: ["Admin"] };
169
+ }
170
+
171
+ function bobSession(): SessionUser {
172
+ return { id: bobId, tenantId: TENANT_B_ID, roles: ["User"] };
173
+ }
174
+
175
+ async function authedRaw(
176
+ method: string,
177
+ path: string,
178
+ body: unknown,
179
+ user: SessionUser,
180
+ ): Promise<Response> {
181
+ const token = await stack.jwt.sign(user);
182
+ return stack.http.raw(method, path, body, { Authorization: `Bearer ${token}` });
183
+ }
184
+
185
+ async function inviteEmail(email: string, role: string): Promise<string> {
186
+ // invite-create geht via /api/write (Admin-Auth via JWT). Token kommt
187
+ // direkt aus dem handler-result; sendInviteEmail-callback ist die
188
+ // optionale Mail-Side, die in production-Setups separat von der
189
+ // Sample-App aufgerufen wird (NICHT framework-route — invite-create
190
+ // ist Admin-only und somit kein Magic-Link-Pattern wie signup-request).
191
+ const result = (await stack.http.writeOk(
192
+ AuthHandlers.inviteCreate,
193
+ { email, role },
194
+ aliceSession(),
195
+ )) as { token: string };
196
+ return result.token;
197
+ }
198
+
199
+ describe("invite-create", () => {
200
+ test("Admin invitet → invitation row + token in result", async () => {
201
+ const result = (await stack.http.writeOk(
202
+ AuthHandlers.inviteCreate,
203
+ { email: BOB_EMAIL, role: "Admin" },
204
+ aliceSession(),
205
+ )) as { invitationId: string; email: string; role: string; token: string };
206
+
207
+ expect(result.email).toBe(BOB_EMAIL);
208
+ expect(result.role).toBe("Admin");
209
+ expect(result.token).toBeTruthy();
210
+ expect(result.token.length).toBeGreaterThanOrEqual(16);
211
+
212
+ const rows = await stack.db
213
+ .select()
214
+ .from(tenantInvitationsTable)
215
+ .where(eq(tenantInvitationsTable.email, BOB_EMAIL));
216
+ expect(rows).toHaveLength(1);
217
+ expect(rows[0]?.["status"]).toBe("pending");
218
+ expect(rows[0]?.["role"]).toBe("Admin");
219
+ expect(rows[0]?.["tenantId"]).toBe(TENANT_A_ID);
220
+ });
221
+
222
+ test("Resend: zweiter invite für selbe email → existing row updated, gleicher token", async () => {
223
+ const firstToken = await inviteEmail(BOB_EMAIL, "Admin");
224
+ const secondToken = await inviteEmail(BOB_EMAIL, "Editor");
225
+
226
+ expect(secondToken).toBe(firstToken);
227
+
228
+ // Eine Row, role updated
229
+ const rows = await stack.db
230
+ .select()
231
+ .from(tenantInvitationsTable)
232
+ .where(eq(tenantInvitationsTable.email, BOB_EMAIL));
233
+ expect(rows).toHaveLength(1);
234
+ expect(rows[0]?.["role"]).toBe("Editor");
235
+ });
236
+ });
237
+
238
+ describe("invite-accept (Branch 1: logged-in)", () => {
239
+ test("Bob (logged-in in Tenant-B) accepts Tenant-A invite → membership added", async () => {
240
+ const token = await inviteEmail(BOB_EMAIL, "Admin");
241
+
242
+ const result = (await stack.http.writeOk(
243
+ AuthHandlers.inviteAccept,
244
+ { token },
245
+ bobSession(),
246
+ )) as { tenantId: string; role: string; alreadyMember: boolean };
247
+
248
+ expect(result.tenantId).toBe(TENANT_A_ID);
249
+ expect(result.role).toBe("Admin");
250
+ expect(result.alreadyMember).toBe(false);
251
+
252
+ // Bob hat jetzt 2 Memberships
253
+ const memberships = await stack.db
254
+ .select()
255
+ .from(tenantMembershipsTable)
256
+ .where(eq(tenantMembershipsTable.userId, bobId));
257
+ expect(memberships).toHaveLength(2);
258
+ const tenantIds = memberships.map((m) => m["tenantId"]).sort();
259
+ expect(tenantIds).toEqual([TENANT_A_ID, TENANT_B_ID].sort());
260
+
261
+ // Invitation status = accepted
262
+ const inv = await stack.db
263
+ .select()
264
+ .from(tenantInvitationsTable)
265
+ .where(eq(tenantInvitationsTable.email, BOB_EMAIL));
266
+ expect(inv[0]?.["status"]).toBe("accepted");
267
+ });
268
+
269
+ test("Email-Mismatch: Bob klickt Carol's Invite-Link → inviteEmailMismatch", async () => {
270
+ const token = await inviteEmail(CAROL_EMAIL, "Admin");
271
+
272
+ const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
273
+ expect(res.status).toBe(422);
274
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
275
+ expect(body.error?.details?.reason).toBe(AuthErrors.inviteEmailMismatch);
276
+ });
277
+
278
+ test("Already-Member: Bob ist schon Member → idempotent no-op + alreadyMember=true", async () => {
279
+ // Bob direkt zu Tenant-A hinzufügen
280
+ await seedTenantMembership(stack.db, {
281
+ userId: bobId,
282
+ tenantId: TENANT_A_ID,
283
+ roles: ["User"],
284
+ by: createSystemUser(TENANT_A_ID),
285
+ });
286
+
287
+ const token = await inviteEmail(BOB_EMAIL, "Admin");
288
+
289
+ const result = (await stack.http.writeOk(
290
+ AuthHandlers.inviteAccept,
291
+ { token },
292
+ bobSession(),
293
+ )) as { alreadyMember: boolean };
294
+ expect(result.alreadyMember).toBe(true);
295
+ });
296
+ });
297
+
298
+ describe("invite-accept-with-login (Branch 2: anon + existing email)", () => {
299
+ test("Bob (nicht eingeloggt) accepts mit email+password → JWT + membership", async () => {
300
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
301
+
302
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
303
+ token,
304
+ email: BOB_EMAIL,
305
+ password: BOB_PASSWORD,
306
+ });
307
+ expect(res.status).toBe(200);
308
+ const body = (await res.json()) as {
309
+ isSuccess: boolean;
310
+ tenantId: string;
311
+ role: string;
312
+ token?: string;
313
+ };
314
+ expect(body.isSuccess).toBe(true);
315
+ expect(body.tenantId).toBe(TENANT_A_ID);
316
+ expect(body.role).toBe("Editor");
317
+ expect(body.token).toBeTruthy();
318
+ const setCookies = res.headers.get("set-cookie") ?? "";
319
+ expect(setCookies).toContain("kumiko_auth=");
320
+
321
+ // Membership added
322
+ const memberships = await stack.db
323
+ .select()
324
+ .from(tenantMembershipsTable)
325
+ .where(eq(tenantMembershipsTable.userId, bobId));
326
+ expect(memberships).toHaveLength(2);
327
+ });
328
+
329
+ test("Wrong password → 422 invalid_invite_token (anti-enum)", async () => {
330
+ const token = await inviteEmail(BOB_EMAIL, "Editor");
331
+ const res = await stack.http.raw("POST", "/api/auth/invite-accept-with-login", {
332
+ token,
333
+ email: BOB_EMAIL,
334
+ password: "wrong-pw-1234",
335
+ });
336
+ expect(res.status).toBe(422);
337
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
338
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
339
+ });
340
+ });
341
+
342
+ describe("invite-signup-complete (Branch 3: anon + new email)", () => {
343
+ test("Carol (no account) accepts → user + membership entstehen, JWT", async () => {
344
+ const token = await inviteEmail(CAROL_EMAIL, "Admin");
345
+
346
+ const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
347
+ token,
348
+ password: CAROL_PASSWORD,
349
+ });
350
+ expect(res.status).toBe(200);
351
+ const body = (await res.json()) as {
352
+ isSuccess: boolean;
353
+ user: { id: string };
354
+ tenantId: string;
355
+ role: string;
356
+ };
357
+ expect(body.isSuccess).toBe(true);
358
+ expect(body.tenantId).toBe(TENANT_A_ID);
359
+ expect(body.role).toBe("Admin");
360
+
361
+ // Carol entstanden in users
362
+ const carolRows = await stack.db
363
+ .select()
364
+ .from(userTable)
365
+ .where(eq(userTable.email, CAROL_EMAIL));
366
+ expect(carolRows).toHaveLength(1);
367
+ expect(carolRows[0]?.["emailVerified"]).toBe(true);
368
+ expect(carolRows[0]?.["id"]).toBe(body.user.id);
369
+
370
+ // Login funktioniert
371
+ const loginRes = await stack.http.raw("POST", "/api/auth/login", {
372
+ email: CAROL_EMAIL,
373
+ password: CAROL_PASSWORD,
374
+ });
375
+ expect(loginRes.status).toBe(200);
376
+ });
377
+
378
+ test("Existing email → invalid_invite_token (User soll Branch 2 nutzen)", async () => {
379
+ const token = await inviteEmail(BOB_EMAIL, "Admin");
380
+
381
+ const res = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
382
+ token,
383
+ password: "new-pw-1234",
384
+ });
385
+ expect(res.status).toBe(422);
386
+ const body = (await res.json()) as { error?: { details?: { reason?: string } } };
387
+ expect(body.error?.details?.reason).toBe(AuthErrors.invalidInviteToken);
388
+
389
+ // Bob hat keine zweite Membership erworben
390
+ const memberships = await stack.db
391
+ .select()
392
+ .from(tenantMembershipsTable)
393
+ .where(eq(tenantMembershipsTable.userId, bobId));
394
+ expect(memberships).toHaveLength(1);
395
+ void GUEST;
396
+ });
397
+ });
398
+
399
+ describe("Single-Use-Burn (alle Branches)", () => {
400
+ test("Branch 1: zweiter accept mit gleichem Token → invalid", async () => {
401
+ const token = await inviteEmail(BOB_EMAIL, "Admin");
402
+ await stack.http.writeOk(AuthHandlers.inviteAccept, { token }, bobSession());
403
+
404
+ const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
405
+ expect(res.status).toBe(422);
406
+ });
407
+ });
408
+
409
+ describe("cancel-invitation", () => {
410
+ test("Admin cancellt → status=cancelled + token weg, accept wird invalid", async () => {
411
+ const token = await inviteEmail(BOB_EMAIL, "Admin");
412
+
413
+ // Find invitationId
414
+ const rows = await stack.db
415
+ .select()
416
+ .from(tenantInvitationsTable)
417
+ .where(eq(tenantInvitationsTable.email, BOB_EMAIL));
418
+ const invitationId = rows[0]?.["id"] as string;
419
+
420
+ await stack.http.writeOk("tenant:write:cancel-invitation", { invitationId }, aliceSession());
421
+
422
+ const updated = await stack.db
423
+ .select()
424
+ .from(tenantInvitationsTable)
425
+ .where(eq(tenantInvitationsTable.id, invitationId));
426
+ expect(updated[0]?.["status"]).toBe("cancelled");
427
+
428
+ // Accept mit dem gecancelten Token → invalid
429
+ const res = await authedRaw("POST", "/api/auth/invite-accept", { token }, bobSession());
430
+ expect(res.status).toBe(422);
431
+ });
432
+ });
433
+
434
+ describe("invitations-query (pending list)", () => {
435
+ test("Admin sieht nur pending invitations", async () => {
436
+ await inviteEmail(BOB_EMAIL, "Admin");
437
+ await inviteEmail(CAROL_EMAIL, "Editor");
438
+
439
+ // Cancel das erste
440
+ const allRows = await stack.db.select().from(tenantInvitationsTable);
441
+ const bobInv = allRows.find((r) => r["email"] === BOB_EMAIL);
442
+ if (!bobInv) throw new Error("bob invitation missing");
443
+ await stack.http.writeOk(
444
+ "tenant:write:cancel-invitation",
445
+ { invitationId: bobInv["id"] },
446
+ aliceSession(),
447
+ );
448
+
449
+ const list = (await stack.http.queryOk(
450
+ "tenant:query:invitations",
451
+ {},
452
+ aliceSession(),
453
+ )) as Array<{ email: string; status: string }>;
454
+ expect(list).toHaveLength(1);
455
+ expect(list[0]?.email).toBe(CAROL_EMAIL);
456
+ expect(list[0]?.status).toBe("pending");
457
+ });
458
+ });