@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,278 @@
1
+ import { buildServer, type JwtHelper } from "@cosmicdrift/kumiko-framework/api";
2
+ import { createTenantDb, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+ import {
4
+ createRegistry,
5
+ defineFeature,
6
+ type SessionUser,
7
+ type TenantId,
8
+ } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
+ import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
11
+ import {
12
+ createEntityTable,
13
+ createTestDb,
14
+ createTestRedis,
15
+ createTestUser,
16
+ pushTables,
17
+ type TestDb,
18
+ type TestRedis,
19
+ TestUsers,
20
+ testTenantId,
21
+ } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { bridgeStub, sleep } from "@cosmicdrift/kumiko-framework/testing";
23
+ import type { Hono } from "hono";
24
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
25
+ import { createConfigFeature } from "../../config/feature";
26
+ import { createConfigResolver } from "../../config/resolver";
27
+ import { configValuesTable } from "../../config/table";
28
+ import { TenantHandlers, TenantQueries } from "../constants";
29
+ import { createTenantFeature } from "../feature";
30
+ import { tenantMembershipsTable } from "../membership-table";
31
+ import { tenantEntity } from "../schema/tenant";
32
+
33
+ // --- Track job executions ---
34
+
35
+ const jobExecutions: Array<{ name: string; tenantId: TenantId }> = [];
36
+
37
+ // --- Feature with perTenant job ---
38
+
39
+ const billingFeature = defineFeature("billing", (r) => {
40
+ r.job("monthlyReport", { trigger: { manual: true }, perTenant: true }, async (_payload, ctx) => {
41
+ const systemUser = ctx["systemUser"] as SessionUser;
42
+ jobExecutions.push({ name: "billing:job:monthly-report", tenantId: systemUser.tenantId });
43
+ });
44
+ });
45
+
46
+ // --- Setup ---
47
+
48
+ let testDb: TestDb;
49
+ let testRedis: TestRedis;
50
+ let db: DbConnection;
51
+ let app: Hono;
52
+ let jwt: JwtHelper;
53
+ let jobRunner: JobRunner;
54
+
55
+ const systemAdmin = TestUsers.systemAdmin;
56
+ const JWT_SECRET = "multi-tenant-test-secret-minimum-32-chars!!";
57
+
58
+ beforeAll(async () => {
59
+ testDb = await createTestDb();
60
+ testRedis = await createTestRedis();
61
+ db = testDb.db;
62
+
63
+ await createEntityTable(db, tenantEntity);
64
+ await pushTables(db, { tenantMembershipsTable, configValuesTable });
65
+ await createEventsTable(db);
66
+
67
+ const configFeature = createConfigFeature();
68
+ const tenantFeature = createTenantFeature();
69
+ const registry = createRegistry([configFeature, tenantFeature, billingFeature]);
70
+ const resolver = createConfigResolver();
71
+
72
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
73
+
74
+ jobRunner = createJobRunner({
75
+ registry,
76
+ context: { db, registry, configResolver: resolver },
77
+ redisUrl,
78
+ consumerLane: "worker",
79
+ queueNamePrefix: `kumiko-multi-tenant-test-${Date.now()}`,
80
+ getActiveTenantIds: async () => {
81
+ const handler = registry.getQueryHandler(TenantQueries.activeTenantIds);
82
+ if (!handler) return [];
83
+ const result = await handler.handler(
84
+ {
85
+ type: TenantQueries.activeTenantIds,
86
+ payload: {},
87
+ user: {
88
+ id: "00000000-0000-0000-0000-000000000000",
89
+ tenantId: "00000000-0000-4000-8000-000000000000",
90
+ roles: ["system"],
91
+ },
92
+ },
93
+ {
94
+ db: createTenantDb(db, "00000000-0000-4000-8000-000000000000", "system"),
95
+ registry,
96
+ ...bridgeStub(),
97
+ },
98
+ );
99
+ return result as number[];
100
+ },
101
+ });
102
+
103
+ const context = { db, registry, configResolver: resolver, jobRunner };
104
+ const server = buildServer({
105
+ registry,
106
+ context,
107
+ jwtSecret: JWT_SECRET,
108
+ auth: { membershipQuery: TenantQueries.memberships },
109
+ });
110
+ app = server.app;
111
+ jwt = server.jwt;
112
+
113
+ await jobRunner.start();
114
+ });
115
+
116
+ afterAll(async () => {
117
+ await jobRunner.stop();
118
+ await testDb.cleanup();
119
+ await testRedis.cleanup();
120
+ });
121
+
122
+ // --- Helpers ---
123
+
124
+ async function writeApi(user: SessionUser, type: string, payload: unknown) {
125
+ const token = await jwt.sign(user);
126
+ const res = await app.request("/api/write", {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
129
+ body: JSON.stringify({ type, payload }),
130
+ });
131
+ return res.json();
132
+ }
133
+
134
+ async function queryApi(user: SessionUser, type: string, payload: unknown) {
135
+ const token = await jwt.sign(user);
136
+ const res = await app.request("/api/query", {
137
+ method: "POST",
138
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
139
+ body: JSON.stringify({ type, payload }),
140
+ });
141
+ return res.json();
142
+ }
143
+
144
+ async function getApi(user: SessionUser, path: string) {
145
+ const token = await jwt.sign(user);
146
+ return app.request(`/api${path}`, {
147
+ method: "GET",
148
+ headers: { Authorization: `Bearer ${token}` },
149
+ });
150
+ }
151
+
152
+ async function postApi(user: SessionUser, path: string, body: unknown) {
153
+ const token = await jwt.sign(user);
154
+ return app.request(`/api${path}`, {
155
+ method: "POST",
156
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
157
+ body: JSON.stringify(body),
158
+ });
159
+ }
160
+
161
+ // --- Scenario 1+2: Create tenants and memberships, then switch ---
162
+
163
+ describe("multi-tenant user", () => {
164
+ test("setup: create two tenants", async () => {
165
+ // Explicit ids so later assertions (memberships, perTenant jobs) can
166
+ // match against the fixed testTenantId(1/2) values the test fixtures use.
167
+ const r1 = await writeApi(systemAdmin, TenantHandlers.create, {
168
+ id: testTenantId(1),
169
+ key: "acme",
170
+ name: "ACME",
171
+ });
172
+ expect(r1.isSuccess).toBe(true);
173
+
174
+ const r2 = await writeApi(systemAdmin, TenantHandlers.create, {
175
+ id: testTenantId(2),
176
+ key: "beta",
177
+ name: "Beta Inc",
178
+ });
179
+ expect(r2.isSuccess).toBe(true);
180
+ });
181
+
182
+ test("add user to both tenants with different roles", async () => {
183
+ const r1 = await writeApi(systemAdmin, TenantHandlers.addMember, {
184
+ userId: "11111111-0000-4000-8000-000000000010",
185
+ tenantId: testTenantId(1),
186
+ roles: ["Admin"],
187
+ });
188
+ expect(r1.isSuccess).toBe(true);
189
+
190
+ const r2 = await writeApi(systemAdmin, TenantHandlers.addMember, {
191
+ userId: "11111111-0000-4000-8000-000000000010",
192
+ tenantId: testTenantId(2),
193
+ roles: ["Viewer"],
194
+ });
195
+ expect(r2.isSuccess).toBe(true);
196
+ });
197
+
198
+ test("list tenants for user shows both", async () => {
199
+ const result = await queryApi(systemAdmin, TenantQueries.memberships, {
200
+ userId: "11111111-0000-4000-8000-000000000010",
201
+ });
202
+ const memberships = result.data;
203
+ expect(memberships.length).toBe(2);
204
+
205
+ const tenantIds = memberships.map((m: Record<string, unknown>) => m["tenantId"]);
206
+ expect(tenantIds).toContain(testTenantId(1));
207
+ expect(tenantIds).toContain(testTenantId(2));
208
+ });
209
+
210
+ test("user has different roles per tenant", async () => {
211
+ const result = await queryApi(systemAdmin, TenantQueries.memberships, {
212
+ userId: "11111111-0000-4000-8000-000000000010",
213
+ });
214
+ const memberships = result.data;
215
+
216
+ const acme = memberships.find(
217
+ (m: Record<string, unknown>) => m["tenantId"] === testTenantId(1),
218
+ );
219
+ const beta = memberships.find(
220
+ (m: Record<string, unknown>) => m["tenantId"] === testTenantId(2),
221
+ );
222
+
223
+ expect(acme["roles"]).toEqual(["Admin"]);
224
+ expect(beta["roles"]).toEqual(["Viewer"]);
225
+ });
226
+
227
+ test("GET /auth/tenants returns tenant list", async () => {
228
+ const user = createTestUser({ id: 10 });
229
+ const res = await getApi(user, "/auth/tenants");
230
+ expect(res.status).toBe(200);
231
+ const body = await res.json();
232
+ expect(body.tenants.length).toBe(2);
233
+ expect(body.activeTenantId).toBe(testTenantId(1));
234
+ });
235
+
236
+ test("POST /auth/switch-tenant issues new JWT with different tenant", async () => {
237
+ const user = createTestUser({ id: 10 });
238
+ const res = await postApi(user, "/auth/switch-tenant", { tenantId: testTenantId(2) });
239
+ expect(res.status).toBe(200);
240
+ const body = await res.json();
241
+ expect(body.tenantId).toBe(testTenantId(2));
242
+ expect(body.roles).toEqual(["Viewer"]);
243
+ expect(body.token).toBeDefined();
244
+ });
245
+
246
+ test("switch to non-member tenant is rejected", async () => {
247
+ const user = createTestUser({ id: 10 });
248
+ const res = await postApi(user, "/auth/switch-tenant", { tenantId: testTenantId(999) });
249
+ expect(res.status).toBe(403);
250
+ });
251
+ });
252
+
253
+ // --- Scenario 4+5: perTenant jobs ---
254
+
255
+ describe("perTenant jobs", () => {
256
+ test("perTenant job dispatches once per active tenant", async () => {
257
+ jobExecutions.length = 0;
258
+
259
+ await jobRunner.dispatch("billing:job:monthly-report", {});
260
+ // This dispatches _perTenant:billing.monthlyReport which fans out
261
+ // Wait for fan-out + processing
262
+ await sleep(2000);
263
+
264
+ // Should have run for each active tenant (we created 2)
265
+ expect(jobExecutions.length).toBe(2);
266
+ const tenantIds = jobExecutions.map((e) => e.tenantId);
267
+ expect(tenantIds).toContain(testTenantId(1));
268
+ expect(tenantIds).toContain(testTenantId(2));
269
+ });
270
+
271
+ test("each sub-job has the correct tenantId in systemUser", async () => {
272
+ for (const execution of jobExecutions) {
273
+ // UUID strings — just ensure a non-empty tenantId landed in the systemUser.
274
+ expect(typeof execution.tenantId).toBe("string");
275
+ expect(execution.tenantId.length).toBeGreaterThan(0);
276
+ }
277
+ });
278
+ });
@@ -0,0 +1,229 @@
1
+ // Test for the seedTenantMembership test-helper itself. Test-helpers that
2
+ // other tests rely on for fixture setup should themselves have coverage —
3
+ // otherwise a silent regression (e.g. the helper stops writing events but
4
+ // keeps writing the projection) leaves every downstream test falsely
5
+ // passing on bogus state.
6
+ //
7
+ // Four invariants matter:
8
+ // 1. The projection row lands with the right (userId, tenantId, roles).
9
+ // 2. A `tenantMembership.created` event lands on the aggregate stream.
10
+ // 3. Duplicate call is a no-op (no second event, no crash).
11
+ // 4. The `by`-user shows up as insertedById on the projection — so
12
+ // audit-queries that join events→users actually find the actor.
13
+
14
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
15
+ import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
16
+ import {
17
+ createEntityTable,
18
+ createTestUser,
19
+ pushTables,
20
+ setupTestStack,
21
+ type TestStack,
22
+ TestUsers,
23
+ } from "@cosmicdrift/kumiko-framework/stack";
24
+ import { and, eq } from "drizzle-orm";
25
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
26
+ import { createConfigFeature } from "../../config/feature";
27
+ import { createConfigResolver } from "../../config/resolver";
28
+ import { configValuesTable } from "../../config/table";
29
+ import { createTenantFeature } from "../feature";
30
+ import { tenantMembershipsTable } from "../membership-table";
31
+ import { tenantEntity, tenantTable } from "../schema/tenant";
32
+ import { seedTenant, seedTenantMembership } from "../seeding";
33
+
34
+ let stack: TestStack;
35
+
36
+ const ALICE_ID = "11111111-0000-4000-8000-000000000aaa";
37
+ const TENANT_A: TenantId = "00000000-0000-4000-8000-000000000aaa" as TenantId;
38
+ const TENANT_B: TenantId = "00000000-0000-4000-8000-000000000bbb" as TenantId;
39
+
40
+ beforeAll(async () => {
41
+ const resolver = createConfigResolver();
42
+ stack = await setupTestStack({
43
+ features: [createConfigFeature(), createTenantFeature()],
44
+ extraContext: { configResolver: resolver },
45
+ });
46
+ await createEntityTable(stack.db, tenantEntity);
47
+ await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
48
+ await createEventsTable(stack.db);
49
+ });
50
+
51
+ afterAll(async () => {
52
+ await stack.cleanup();
53
+ });
54
+
55
+ beforeEach(async () => {
56
+ await stack.db.delete(tenantMembershipsTable);
57
+ await stack.db.delete(tenantTable);
58
+ // Events stay — the idempotency test below inspects how many .created
59
+ // events exist for the same aggregate-key pair across runs.
60
+ await stack.db.delete(eventsTable);
61
+ });
62
+
63
+ describe("seedTenant", () => {
64
+ test("schreibt Projection-Row mit id/key/name", async () => {
65
+ const id = await seedTenant(stack.db, {
66
+ id: TENANT_A,
67
+ key: "tenant-a",
68
+ name: "Tenant A",
69
+ });
70
+ expect(id).toBe(TENANT_A);
71
+
72
+ const rows = await stack.db.select().from(tenantTable).where(eq(tenantTable["id"], TENANT_A));
73
+ expect(rows).toHaveLength(1);
74
+ expect(rows[0]?.["id"]).toBe(TENANT_A);
75
+ expect(rows[0]?.["key"]).toBe("tenant-a");
76
+ expect(rows[0]?.["name"]).toBe("Tenant A");
77
+ });
78
+
79
+ test("emittiert tenant.created-Event auf den Aggregate-Stream", async () => {
80
+ await seedTenant(stack.db, { id: TENANT_A, key: "tenant-a", name: "Tenant A" });
81
+ const events = await stack.db
82
+ .select()
83
+ .from(eventsTable)
84
+ .where(eq(eventsTable.aggregateType, "tenant"));
85
+ const createdEvents = events.filter((e) => e.type === "tenant.created");
86
+ expect(createdEvents).toHaveLength(1);
87
+ // Aggregate-id steht im event-row (aggregateId), nicht im payload —
88
+ // payload trägt die Aggregat-Felder ohne PK.
89
+ expect(createdEvents[0]?.aggregateId).toBe(TENANT_A);
90
+ const payload = createdEvents[0]?.payload as { key: string; name: string };
91
+ expect(payload.key).toBe("tenant-a");
92
+ expect(payload.name).toBe("Tenant A");
93
+ });
94
+
95
+ test("zweiter Aufruf für dieselbe id: no-op (kein zweites Event, kein Crash)", async () => {
96
+ await seedTenant(stack.db, { id: TENANT_A, key: "tenant-a", name: "Tenant A" });
97
+ await seedTenant(stack.db, { id: TENANT_A, key: "tenant-a", name: "Tenant A v2" });
98
+
99
+ // Projection bleibt bei Original-name (zweiter Call wurde geskippt, kein update).
100
+ const rows = await stack.db.select().from(tenantTable).where(eq(tenantTable["id"], TENANT_A));
101
+ expect(rows).toHaveLength(1);
102
+ expect(rows[0]?.["name"]).toBe("Tenant A");
103
+
104
+ const events = await stack.db
105
+ .select()
106
+ .from(eventsTable)
107
+ .where(eq(eventsTable.aggregateType, "tenant"));
108
+ expect(events.filter((e) => e.type === "tenant.created")).toHaveLength(1);
109
+ });
110
+
111
+ test("zwei verschiedene Tenants in einem Test — beide in der Projection", async () => {
112
+ await seedTenant(stack.db, { id: TENANT_A, key: "a", name: "A" });
113
+ await seedTenant(stack.db, { id: TENANT_B, key: "b", name: "B" });
114
+ const rows = await stack.db.select().from(tenantTable);
115
+ expect(rows.map((r) => r["id"]).sort()).toEqual([TENANT_A, TENANT_B].sort());
116
+ });
117
+ });
118
+
119
+ describe("seedTenantMembership", () => {
120
+ test("writes the projection row with the given userId / tenantId / roles", async () => {
121
+ await seedTenantMembership(stack.db, {
122
+ userId: ALICE_ID,
123
+ tenantId: TENANT_A,
124
+ roles: ["Admin", "Billing"],
125
+ });
126
+
127
+ const rows = await stack.db
128
+ .select()
129
+ .from(tenantMembershipsTable)
130
+ .where(
131
+ and(
132
+ eq(tenantMembershipsTable.userId, ALICE_ID),
133
+ eq(tenantMembershipsTable.tenantId, TENANT_A),
134
+ ),
135
+ );
136
+ expect(rows).toHaveLength(1);
137
+ expect(rows[0]?.["userId"]).toBe(ALICE_ID);
138
+ expect(rows[0]?.["tenantId"]).toBe(TENANT_A);
139
+ expect(rows[0]?.["roles"]).toBe(JSON.stringify(["Admin", "Billing"]));
140
+ });
141
+
142
+ test("writes a tenantMembership.created event on the aggregate stream", async () => {
143
+ await seedTenantMembership(stack.db, {
144
+ userId: ALICE_ID,
145
+ tenantId: TENANT_A,
146
+ roles: ["User"],
147
+ });
148
+
149
+ const events = await stack.db
150
+ .select()
151
+ .from(eventsTable)
152
+ .where(eq(eventsTable.aggregateType, "tenant-membership"));
153
+ const createdEvents = events.filter((e) => e.type === "tenant-membership.created");
154
+ expect(createdEvents).toHaveLength(1);
155
+ // Payload should carry the seeded data — MSPs/audit rely on this.
156
+ const payload = createdEvents[0]?.payload as {
157
+ userId: string;
158
+ tenantId: string;
159
+ roles: string;
160
+ };
161
+ expect(payload.userId).toBe(ALICE_ID);
162
+ expect(payload.tenantId).toBe(TENANT_A);
163
+ expect(payload.roles).toBe(JSON.stringify(["User"]));
164
+ });
165
+
166
+ test("calling twice for the same (userId, tenantId) is idempotent — no second event, no crash", async () => {
167
+ // First call: creates both projection row + event.
168
+ await seedTenantMembership(stack.db, {
169
+ userId: ALICE_ID,
170
+ tenantId: TENANT_A,
171
+ roles: ["User"],
172
+ });
173
+ // Second call: helper detects existing row and no-ops. Would otherwise
174
+ // trip the (user_id, tenant_id) unique index AND would bump the event
175
+ // count — both are footguns for beforeEach-resets that only clear some
176
+ // tables.
177
+ await seedTenantMembership(stack.db, {
178
+ userId: ALICE_ID,
179
+ tenantId: TENANT_A,
180
+ roles: ["User"],
181
+ });
182
+
183
+ const projectionRows = await stack.db
184
+ .select()
185
+ .from(tenantMembershipsTable)
186
+ .where(eq(tenantMembershipsTable.userId, ALICE_ID));
187
+ expect(projectionRows).toHaveLength(1);
188
+
189
+ const events = await stack.db
190
+ .select()
191
+ .from(eventsTable)
192
+ .where(eq(eventsTable.aggregateType, "tenant-membership"));
193
+ expect(events.filter((e) => e.type === "tenant-membership.created")).toHaveLength(1);
194
+ });
195
+
196
+ test("records the `by` user as insertedById on the projection", async () => {
197
+ // Audit-queries that join events → users need a stable actor. Default
198
+ // `by` is TestUsers.systemAdmin; override to a custom test user and
199
+ // assert it propagates to the projection's inserted_by_id column.
200
+ const seedActor = createTestUser({ id: 99, tenantId: TENANT_A });
201
+ await seedTenantMembership(stack.db, {
202
+ userId: ALICE_ID,
203
+ tenantId: TENANT_A,
204
+ roles: ["User"],
205
+ by: seedActor,
206
+ });
207
+
208
+ const [row] = await stack.db
209
+ .select()
210
+ .from(tenantMembershipsTable)
211
+ .where(eq(tenantMembershipsTable.userId, ALICE_ID));
212
+ expect(row?.["insertedById"]).toBe(seedActor.id);
213
+ });
214
+
215
+ test("default `by` is TestUsers.systemAdmin", async () => {
216
+ // Documents the fallback — a regression that changed the default would
217
+ // silently skew audit queries across 18 call-sites.
218
+ await seedTenantMembership(stack.db, {
219
+ userId: ALICE_ID,
220
+ tenantId: TENANT_A,
221
+ roles: ["User"],
222
+ });
223
+ const [row] = await stack.db
224
+ .select()
225
+ .from(tenantMembershipsTable)
226
+ .where(eq(tenantMembershipsTable.userId, ALICE_ID));
227
+ expect(row?.["insertedById"]).toBe(TestUsers.systemAdmin.id);
228
+ });
229
+ });