@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 (163) hide show
  1. package/package.json +7 -6
  2. package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
  3. package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
  4. package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
  5. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
  6. package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
  7. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
  8. package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
  9. package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
  10. package/src/auth-email-password/constants.ts +15 -0
  11. package/src/auth-email-password/email-templates.ts +32 -0
  12. package/src/auth-email-password/errors.ts +9 -0
  13. package/src/auth-email-password/feature.ts +37 -5
  14. package/src/auth-email-password/handlers/change-password.write.ts +2 -1
  15. package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
  16. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
  17. package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
  18. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
  19. package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
  20. package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
  21. package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
  22. package/src/auth-email-password/i18n.ts +42 -0
  23. package/src/auth-email-password/index.ts +2 -0
  24. package/src/auth-email-password/lockout-store.ts +10 -5
  25. package/src/auth-email-password/password-policy.ts +2108 -0
  26. package/src/auth-email-password/signed-token.ts +1 -0
  27. package/src/auth-email-password/unlock-token.ts +28 -0
  28. package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
  29. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
  30. package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
  31. package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
  33. package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
  34. package/src/auth-email-password/web/auth-client.ts +33 -0
  35. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  36. package/src/auth-email-password/web/index.ts +6 -0
  37. package/src/auth-email-password/web/login-screen.tsx +15 -0
  38. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  39. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  40. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  41. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  42. package/src/auth-foundation/boot-checks.ts +91 -0
  43. package/src/auth-foundation/feature.ts +103 -0
  44. package/src/auth-foundation/index.ts +23 -0
  45. package/src/auth-foundation/types.ts +107 -0
  46. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  47. package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
  48. package/src/auth-mfa/handlers/verify.write.ts +1 -1
  49. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  50. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  51. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  52. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  53. package/src/channel-email/pii-guard.ts +6 -3
  54. package/src/config/__tests__/app-overrides.test.ts +11 -0
  55. package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
  56. package/src/config/__tests__/cascade.integration.test.ts +39 -0
  57. package/src/config/__tests__/config.integration.test.ts +14 -0
  58. package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
  59. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  60. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  61. package/src/config/feature.ts +2 -4
  62. package/src/config/handlers/set.write.ts +7 -1
  63. package/src/config/resolver.ts +1 -1
  64. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  65. package/src/delivery/handlers/log.query.ts +1 -1
  66. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  67. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  68. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  69. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  70. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  71. package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
  72. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  73. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  74. package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
  75. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  76. package/src/inbound-provider-inmemory/feature.ts +2 -2
  77. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  78. package/src/jobs/handlers/detail.query.ts +1 -1
  79. package/src/jobs/handlers/list.query.ts +1 -1
  80. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  81. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  82. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  83. package/src/managed-pages/handlers/branding.query.ts +5 -1
  84. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  85. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  86. package/src/personal-access-tokens/feature.ts +23 -8
  87. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  88. package/src/personal-access-tokens/index.ts +0 -14
  89. package/src/personal-access-tokens/resolver.ts +2 -2
  90. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  91. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  92. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  93. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  94. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  95. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  96. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  97. package/src/sessions/__tests__/test-helpers.ts +15 -2
  98. package/src/sessions/feature.ts +31 -3
  99. package/src/sessions/handlers/detail.query.ts +2 -2
  100. package/src/sessions/handlers/list.query.ts +4 -2
  101. package/src/sessions/handlers/mine.query.ts +4 -2
  102. package/src/sessions/testing.ts +14 -1
  103. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  104. package/src/shared/decrypt-stored-pii.ts +12 -5
  105. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  106. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  107. package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
  108. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  109. package/src/tenant/handlers/invitations.query.ts +4 -2
  110. package/src/tenant/handlers/members.query.ts +2 -2
  111. package/src/tenant/seeding.ts +2 -2
  112. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  113. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  114. package/src/tenant-lifecycle/feature.ts +7 -0
  115. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  116. package/src/tier-engine/feature.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +20 -0
  118. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  120. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  122. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  123. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  125. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  129. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  131. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  135. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  136. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  137. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  138. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  139. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  140. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  141. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  142. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
  143. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  144. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  145. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  146. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  147. package/src/user-data-rights/boot-checks.ts +86 -0
  148. package/src/user-data-rights/feature.ts +116 -6
  149. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  150. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  151. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  152. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  153. package/src/user-data-rights/run-export-jobs.ts +3 -3
  154. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  155. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
  156. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  157. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  159. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  160. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  161. package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
  162. package/src/user-profile/handlers/change-email.write.ts +1 -1
  163. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -11,6 +11,7 @@ import {
11
11
  configurePiiSubjectKms,
12
12
  InMemoryKmsAdapter,
13
13
  isPiiCiphertext,
14
+ PII_CIPHERTEXT_PREFIX,
14
15
  resetPiiSubjectKmsForTests,
15
16
  } from "@cosmicdrift/kumiko-framework/crypto";
16
17
  import {
@@ -96,7 +97,7 @@ describe("piiEncrypted config keys", () => {
96
97
  });
97
98
  expect(row).toBeDefined();
98
99
  expect(isPiiCiphertext(row?.value)).toBe(true);
99
- expect(row?.value).toStartWith(`kumiko-pii:v1:tenant:${tenantAdmin.tenantId}:`);
100
+ expect(row?.value).toStartWith(`${PII_CIPHERTEXT_PREFIX}tenant:${tenantAdmin.tenantId}:`);
100
101
 
101
102
  const values = await stack.http.queryOk<
102
103
  Record<string, { value: unknown; scope: string; source: string }>
@@ -118,7 +119,7 @@ describe("piiEncrypted config keys", () => {
118
119
  });
119
120
  expect(row).toBeDefined();
120
121
  expect(isPiiCiphertext(row?.value)).toBe(true);
121
- expect(row?.value).toStartWith(`kumiko-pii:v1:user:${tenantAdmin.id}:`);
122
+ expect(row?.value).toStartWith(`${PII_CIPHERTEXT_PREFIX}user:${tenantAdmin.id}:`);
122
123
 
123
124
  const values = await stack.http.queryOk<
124
125
  Record<string, { value: unknown; scope: string; source: string }>
@@ -155,4 +156,24 @@ describe("piiEncrypted config keys", () => {
155
156
  configurePiiSubjectKms(kms);
156
157
  }
157
158
  });
159
+
160
+ test("read of stored piiEncrypted ciphertext without KMS fails loud", async () => {
161
+ // Plant ciphertext while KMS is wired, then strip it so the READ path
162
+ // (decryptPiiEncrypted) — not just the write path — fails loud.
163
+ await stack.http.writeOk(
164
+ ConfigHandlers.set,
165
+ { key: BILLING_ADDRESS_KEY, value: "planted-for-read" },
166
+ tenantAdmin,
167
+ );
168
+
169
+ resetPiiSubjectKmsForTests();
170
+ expect(configuredPiiSubjectKms()).toBeUndefined();
171
+
172
+ try {
173
+ const res = await stack.http.query(ConfigQueries.values, {}, tenantAdmin);
174
+ expect(res.status).toBe(500);
175
+ } finally {
176
+ configurePiiSubjectKms(kms);
177
+ }
178
+ });
158
179
  });
@@ -136,7 +136,10 @@ const noopLog = {
136
136
  // that's the point in migrateRow right after decrypt, right before its own
137
137
  // executor.update, so the job's captured row.version is guaranteed stale by
138
138
  // the time it issues that update.
139
- function racyJobCtx(rowVersionAtBatchRead: number): Parameters<typeof reencryptJob>[1] {
139
+ function racyJobCtx(
140
+ rowVersionAtBatchRead: number,
141
+ log: typeof noopLog = noopLog,
142
+ ): Parameters<typeof reencryptJob>[1] {
140
143
  let fired = false;
141
144
  const racyCipher: EnvelopeCipher = {
142
145
  decrypt: (stored, scope) => cipher.decrypt(stored, scope),
@@ -153,7 +156,7 @@ function racyJobCtx(rowVersionAtBatchRead: number): Parameters<typeof reencryptJ
153
156
  registry: stack.registry,
154
157
  masterKeyProvider: mutableProvider,
155
158
  configEncryption: racyCipher,
156
- log: noopLog,
159
+ log,
157
160
  } as unknown as Parameters<typeof reencryptJob>[1]; // @cast-boundary test-seam — job only reads db/registry/masterKeyProvider/configEncryption/log
158
161
  }
159
162
 
@@ -187,7 +190,21 @@ describe("config KEK-rotation job — concurrent write race (kumiko-framework#11
187
190
  }),
188
191
  );
189
192
 
190
- await reencryptJob({}, racyJobCtx(seeded.version));
193
+ // Distinguishes "skipped" (version_conflict, row already fine) from
194
+ // "failed" (any other rejection) — both leave the row's end-state
195
+ // identical (reencrypt.job.ts:159-166), so a regression that silently
196
+ // turns this branch into "failed" would pass every assertion below it.
197
+ // The warn log at reencrypt.job.ts:171 is the only observable signature
198
+ // that fires on "failed" but not on "skipped" — assert it stayed silent.
199
+ const warnCalls: unknown[][] = [];
200
+ const spyLog = {
201
+ ...noopLog,
202
+ warn: (...args: unknown[]) => {
203
+ warnCalls.push(args);
204
+ },
205
+ };
206
+ await reencryptJob({}, racyJobCtx(seeded.version, spyLog));
207
+ expect(warnCalls).toEqual([]);
191
208
 
192
209
  // The concurrent write already lands under the current key — its
193
210
  // envelope must survive untouched; the rotation job's own update lost
@@ -1,7 +1,6 @@
1
1
  // Regression guard for kumiko-framework#1187: proves the config
2
- // KEK-rotation job handles a real version-N → version-N+1 rotation, not
3
- // just legacy-format → envelope (see encrypted-legacy-migration.integration
4
- // .test.ts for that story). Modeled directly on auth-mfa's
2
+ // KEK-rotation job handles a real version-N → version-N+1 rotation. Modeled
3
+ // directly on auth-mfa's
5
4
  // reencrypt-job.integration.test.ts, which is the reference regression
6
5
  // guard for this exact bug class (kumiko-framework#266 Step 8): rotation
7
6
  // must go through executor.update() (a real event), not a raw UPDATE on
@@ -58,10 +58,8 @@ export function createConfigFeature(): FeatureDefinition {
58
58
  readiness: r.queryHandler(readinessQuery),
59
59
  };
60
60
 
61
- // Migration + KEK-rotation for encrypted values (legacy-format →
62
- // envelope, old kekVersion → current). Manual trigger — ops runs it
63
- // once after adding a new master key version or before retiring the
64
- // legacy CONFIG_ENCRYPTION_KEY.
61
+ // KEK-rotation for encrypted values. Manual trigger — ops runs it once
62
+ // after adding a new master key version.
65
63
  r.job("reencrypt", { trigger: { manual: true } }, reencryptJob);
66
64
 
67
65
  r.translations({ keys: CONFIG_FEATURE_I18N });
@@ -135,7 +135,13 @@ export const setWrite = defineWriteHandler({
135
135
  tenantId,
136
136
  userId: String(event.user.id),
137
137
  };
138
- serialized = await encryptPiiValueForSubject(kms, subject, serialized, kmsCtx);
138
+ serialized = await encryptPiiValueForSubject(
139
+ kms,
140
+ subject,
141
+ serialized,
142
+ kmsCtx,
143
+ event.payload.key,
144
+ );
139
145
  }
140
146
 
141
147
  const existing = await findConfigRow(db, event.payload.key, tenantId, userId);
@@ -117,7 +117,7 @@ async function decryptPiiEncrypted(raw: string, qualifiedKey: string): Promise<s
117
117
  });
118
118
  }
119
119
  const kmsCtx: KmsContext = { requestId: requestContext.get()?.requestId ?? "config:resolver" };
120
- return decryptPiiValueForSubject(kms, raw, kmsCtx);
120
+ return decryptPiiValueForSubject(kms, raw, kmsCtx, qualifiedKey);
121
121
  }
122
122
 
123
123
  // backing="secrets" keys store their value in the secrets store (flat per
@@ -14,6 +14,7 @@
14
14
  // rows on strategy "delete".
15
15
 
16
16
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
17
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
17
18
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
18
19
  import {
19
20
  buildEntityTable,
@@ -137,6 +138,7 @@ beforeAll(async () => {
137
138
  stack = await setupTestStack({
138
139
  features: [
139
140
  createUserFeature(),
141
+ authFoundationFeature,
140
142
  createSessionsFeature(),
141
143
  createDataRetentionFeature(),
142
144
  createComplianceProfilesFeature(),
@@ -31,7 +31,7 @@ export const logQuery = defineQueryHandler({
31
31
  ...row,
32
32
  recipientAddress:
33
33
  typeof row["recipientAddress"] === "string"
34
- ? await decryptStoredPii(row["recipientAddress"], "delivery-log")
34
+ ? await decryptStoredPii(row["recipientAddress"], "recipientAddress", "delivery-log")
35
35
  : row["recipientAddress"],
36
36
  })),
37
37
  ),
@@ -1,5 +1,6 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import type { FileStorageProvider } from "@cosmicdrift/kumiko-framework/files";
3
+ import { describeFileProviderContract } from "@cosmicdrift/kumiko-framework/testing";
3
4
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
4
5
  import { createS3ProviderFromEnv } from "../env-helper";
5
6
  import { createS3Provider } from "../s3-provider";
@@ -219,3 +220,9 @@ describe("createS3ProviderFromEnv", () => {
219
220
  expect(() => createS3ProviderFromEnv("NON_EXISTENT_PREFIX_")).toThrow(/missing_env/);
220
221
  });
221
222
  });
223
+
224
+ describeFileProviderContract("S3Provider (Minio)", () => provider);
225
+
226
+ describeFileProviderContract("createS3ProviderFromEnv (Minio)", () =>
227
+ createS3ProviderFromEnv("MINIO_"),
228
+ );
@@ -165,11 +165,12 @@ describe("FolderManager filing mode", () => {
165
165
  await waitFor(() => expect(onReassigned).toHaveBeenCalled());
166
166
  });
167
167
 
168
- test("the in-tree new-folder row opens a draft; submitting (Enter) creates a root folder", async () => {
168
+ test("the in-tree new-folder row opens a draft; submitting (Enter) creates a root folder and reassigns", async () => {
169
169
  folderRows = [];
170
+ const onReassigned = mock(() => {});
170
171
  render(
171
172
  <Wrapper>
172
- <FolderManager />
173
+ <FolderManager filing={filingWith(onReassigned)} />
173
174
  </Wrapper>,
174
175
  );
175
176
  fireEvent.click(screen.getByTestId("folder-manager-new-root"));
@@ -180,6 +181,30 @@ describe("FolderManager filing mode", () => {
180
181
  await waitFor(() =>
181
182
  expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.createFolder, { name: "Inbox" }),
182
183
  );
184
+ await waitFor(() => expect(onReassigned).toHaveBeenCalled());
185
+ });
186
+
187
+ test("renaming a folder dispatches update-folder and reassigns", async () => {
188
+ folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
189
+ const onReassigned = mock(() => {});
190
+ render(
191
+ <Wrapper>
192
+ <FolderManager filing={filingWith(onReassigned)} />
193
+ </Wrapper>,
194
+ );
195
+ fireEvent.click(screen.getByTestId("folder-rename-f1"));
196
+ fireEvent.change(document.getElementById("folder-manager-draft") as HTMLInputElement, {
197
+ target: { value: "Renamed" },
198
+ });
199
+ fireEvent.submit(screen.getByTestId("folder-manager-draft"));
200
+ await waitFor(() =>
201
+ expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.updateFolder, {
202
+ id: "f1",
203
+ version: 1,
204
+ changes: { name: "Renamed" },
205
+ }),
206
+ );
207
+ await waitFor(() => expect(onReassigned).toHaveBeenCalled());
183
208
  });
184
209
 
185
210
  test("a mixed-type tree files each leaf under its own entityType (per-leaf override + fallback)", async () => {
@@ -0,0 +1,30 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { MailAccountEventPayload } from "../events";
4
+ import { loadCurrentMailAccountPayload } from "../handlers/account-state";
5
+
6
+ function ctxWithEvents(
7
+ payloads: readonly MailAccountEventPayload[],
8
+ ): Pick<HandlerContext, "loadAggregate"> {
9
+ return {
10
+ loadAggregate: async () =>
11
+ payloads.map((payload) => ({
12
+ // @cast-boundary test fixture — only `payload` is read by the function under test
13
+ payload,
14
+ })) as unknown as Awaited<ReturnType<HandlerContext["loadAggregate"]>>,
15
+ };
16
+ }
17
+
18
+ describe("loadCurrentMailAccountPayload", () => {
19
+ test("returns undefined when the aggregate has no events", async () => {
20
+ const ctx = ctxWithEvents([]);
21
+ expect(await loadCurrentMailAccountPayload(ctx, "acc-1")).toBeUndefined();
22
+ });
23
+
24
+ test("returns the payload of the LAST event — full-snapshot semantics", async () => {
25
+ const first = { status: "active" } as unknown as MailAccountEventPayload;
26
+ const last = { status: "degraded" } as unknown as MailAccountEventPayload;
27
+ const ctx = ctxWithEvents([first, last]);
28
+ expect(await loadCurrentMailAccountPayload(ctx, "acc-1")).toBe(last);
29
+ });
30
+ });
@@ -274,12 +274,13 @@ describe("scenario 2b: ingest-message — Thread-Rollup selbstkorrigierend statt
274
274
  );
275
275
  const threadAggId = mailThreadAggregateId(admin.tenantId, "mid:self-heal-root@example.com");
276
276
 
277
- // Simuliert exakt den Drift, den ein Race auf getStreamVersion (siehe
278
- // Handler-Kommentar Schritt 5) verursachen kann: 2 Message-Rows liegen
279
- // vor, aber der ZULETZT geschriebene Thread-Event traegt einen zu
280
- // niedrigen Snapshot (previousCount+1 aus einer stale-Read). Das Event
281
- // ist die Quelle, die previousCount+1 (alter Code) liest — der Live-
282
- // COUNT (neuer Code) ignoriert es und liest read_inbound_messages neu.
277
+ // Simuliert den Snapshot-Drift, den ein verlorener Version-Conflict-
278
+ // Retry (siehe Handler-Kommentar Schritt 5) theoretisch trotz der
279
+ // Retry-Schleife hinterlassen könnte: 2 Message-Rows liegen vor, aber
280
+ // der ZULETZT geschriebene Thread-Event trägt einen zu niedrigen
281
+ // Snapshot. Der neue ingest liest den Live-COUNT gegen
282
+ // read_inbound_messages neu statt dem Event-Snapshot zu vertrauen —
283
+ // der Fehler ist selbstkorrigierend, nicht kumulativ.
283
284
  const expectedVersion = await getStreamVersion(db, threadAggId, admin.tenantId);
284
285
  const corruptedPayload: MailThreadEventPayload = {
285
286
  threadKey: "mid:self-heal-root@example.com",
@@ -319,6 +320,56 @@ describe("scenario 2b: ingest-message — Thread-Rollup selbstkorrigierend statt
319
320
  });
320
321
  });
321
322
 
323
+ describe("scenario 2c: ingest-message — concurrent thread-rollup race (issue #1229)", () => {
324
+ // Probabilistic — looped per house convention (mehrere Durchläufe, z.B.
325
+ // 20x): the version-conflict race between two step-5 thread-rollup
326
+ // appends only fires on genuine transaction-timing interleaving, which
327
+ // a single run can't force deterministically.
328
+ const ITERATIONS = 20;
329
+ const CONCURRENCY = 3;
330
+
331
+ test(`${CONCURRENCY} concurrent ingests of different messages in the same thread never 500 and converge on the correct messageCount, ${ITERATIONS} rounds`, async () => {
332
+ const admin = adminFor(4022);
333
+ const accountId = await connectSharedAccount(admin);
334
+
335
+ for (let round = 0; round < ITERATIONS; round++) {
336
+ const rootMessageIdHeader = `race-root-${round}@example.com`;
337
+
338
+ const responses = await Promise.all(
339
+ Array.from({ length: CONCURRENCY }, (_, n) =>
340
+ stack.http.write(
341
+ InboundMailFoundationHandlers.ingestMessage,
342
+ ingestPayload(accountId, {
343
+ providerMessageId: `race-${round}-${n}`,
344
+ messageIdHeader: `race-${round}-${n}@example.com`,
345
+ references: [rootMessageIdHeader],
346
+ }),
347
+ admin,
348
+ ),
349
+ ),
350
+ );
351
+
352
+ // No writer's transaction fails with a 500 from the thread-rollup
353
+ // version conflict — each of the CONCURRENCY distinct messages is
354
+ // its own aggregate, so step 4 never races; only step 5 (shared
355
+ // threadAggId) does, and the bounded retry loop must absorb it.
356
+ for (const res of responses) {
357
+ expect(res.status).toBe(200);
358
+ const body = (await res.json()) as { isSuccess: boolean; data: { duplicate: boolean } };
359
+ expect(body.isSuccess).toBe(true);
360
+ expect(body.data.duplicate).toBe(false);
361
+ }
362
+
363
+ const threadAggId = mailThreadAggregateId(admin.tenantId, `mid:${rootMessageIdHeader}`);
364
+ const threadRows = await selectMany(db, mailThreadsProjectionTable, { id: threadAggId });
365
+ expect(threadRows).toHaveLength(1);
366
+ // Every message's contribution landed — no drift from a silently
367
+ // stale-read-and-succeed append (the bug the retry loop replaces).
368
+ expect(threadRows[0]?.["messageCount"]).toBe(CONCURRENCY);
369
+ }
370
+ });
371
+ });
372
+
322
373
  describe("scenario 3: Scope-Sichtbarkeit (persönliches Postfach)", () => {
323
374
  test("Messages eines user-scoped Accounts sieht nur der Owner (+ TenantAdmin)", async () => {
324
375
  const tenantNumber = 4003;
@@ -0,0 +1,93 @@
1
+ // Shared contract for inbound-mail-foundation provider plugins — analog
2
+ // to describeKmsAdapterContract (packages/framework/src/crypto/__tests__).
3
+ // Cursor payloads are provider-opaque here (imap: {uidValidity,lastUid},
4
+ // inmemory: {offset}) — the contract only round-trips nextCursor, never
5
+ // inspects its shape. Error-path behaviour stays provider-specific
6
+ // (verify/wrong-credentials semantics don't line up across providers).
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import type { InboundMailContext, InboundMailProviderPlugin, MailAccountRecord } from "../types";
10
+
11
+ export type InboundMailProviderContractFixture = {
12
+ readonly plugin: InboundMailProviderPlugin;
13
+ readonly ctx: InboundMailContext;
14
+ readonly account: MailAccountRecord;
15
+ /** Injects a message with the given subject into the provider's
16
+ * backing store (imap: sends real mail; inmemory: seedInboundMessage). */
17
+ readonly seed: (subject: string) => Promise<void>;
18
+ };
19
+
20
+ export function describeInboundMailProviderContract(
21
+ name: string,
22
+ factory: () => InboundMailProviderContractFixture | Promise<InboundMailProviderContractFixture>,
23
+ opts?: { readonly skip?: boolean },
24
+ ): void {
25
+ // Eager sync probe so a provider without `watch` produces a visible
26
+ // test.skip instead of a silent 0-assertion pass (#1337). Both current
27
+ // factories (imap, inmemory) are sync object literals; an async factory
28
+ // falls back to the old runtime `if (!plugin.watch) return` check below.
29
+ const probe = factory();
30
+ const watchProbe = probe instanceof Promise ? undefined : Boolean(probe.plugin.watch);
31
+
32
+ describe(`${name} — InboundMailProviderPlugin contract`, () => {
33
+ const t = opts?.skip ? test.skip : test;
34
+ t("verify resolves for a valid account", async () => {
35
+ const { plugin, ctx, account } = await factory();
36
+ await expect(plugin.verify(ctx, account)).resolves.toBeUndefined();
37
+ });
38
+
39
+ t(
40
+ "fetch: backfill picks up a seeded message, incremental cursor finds nothing new",
41
+ async () => {
42
+ const { plugin, ctx, account, seed } = await factory();
43
+ const subject = `contract-${crypto.randomUUID()}`;
44
+ await seed(subject);
45
+
46
+ const first = await plugin.fetch(ctx, account, null, {
47
+ backfillWindowDays: 1,
48
+ maxMessages: 50,
49
+ });
50
+ expect(first.messages.some((m) => m.subject === subject)).toBe(true);
51
+
52
+ const second = await plugin.fetch(ctx, account, first.nextCursor, {
53
+ backfillWindowDays: 1,
54
+ maxMessages: 50,
55
+ });
56
+ expect(second.messages.some((m) => m.subject === subject)).toBe(false);
57
+ },
58
+ );
59
+
60
+ const watchTest = watchProbe === false ? test.skip : t;
61
+ watchTest("watch: pushes a seeded message via onMessages", async () => {
62
+ const { plugin, ctx, account, seed } = await factory();
63
+ if (!plugin.watch) return; // fallback for an async factory (watchProbe undefined)
64
+ const subject = `contract-watch-${crypto.randomUUID()}`;
65
+
66
+ const pushed = new Promise<void>((resolve, reject) => {
67
+ const timer = setTimeout(
68
+ () => reject(new Error("watch push not received within 5s")),
69
+ 5_000,
70
+ );
71
+ void plugin
72
+ .watch?.(ctx, account, {
73
+ onMessages: async (msgs) => {
74
+ if (msgs.some((m) => m.subject === subject)) {
75
+ clearTimeout(timer);
76
+ resolve();
77
+ }
78
+ },
79
+ onError: (err) => {
80
+ clearTimeout(timer);
81
+ reject(err instanceof Error ? err : new Error(String(err)));
82
+ },
83
+ })
84
+ .then((stop) => {
85
+ void seed(subject).catch(reject);
86
+ void pushed.finally(() => void stop().catch(() => {}));
87
+ }, reject);
88
+ });
89
+
90
+ await pushed;
91
+ });
92
+ });
93
+ }
@@ -43,6 +43,7 @@ import {
43
43
  InboundCursorInvalidError,
44
44
  InboundMailAccountStatuses,
45
45
  InboundMailFoundationHandlers,
46
+ InboundRateLimitError,
46
47
  inboundMailFoundationFeature,
47
48
  inboundMessagesProjectionTable,
48
49
  mailAccountsProjectionTable,
@@ -268,4 +269,157 @@ describe("watch-supervisor — error semantics", () => {
268
269
  "cursor-ok-2@example.com",
269
270
  ]);
270
271
  });
272
+
273
+ test("InboundRateLimitError on fetch keeps the account active for the next tick", async () => {
274
+ const admin = adminFor(4107);
275
+ const accountId = await connectSharedAccount(admin);
276
+ failNextFetchWith(accountId, new InboundRateLimitError("slow down", 30_000));
277
+
278
+ const supervisor = createSupervisor();
279
+ await supervisor.pollOnce();
280
+
281
+ const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
282
+ expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
283
+ });
284
+ });
285
+
286
+ describe("watch-supervisor — watch restart + ingest resilience", () => {
287
+ test("transient watch error schedules backoff restart and re-arms watch", async () => {
288
+ const admin = adminFor(4108);
289
+ const accountId = await connectSharedAccount(admin);
290
+ const supervisor = createSupervisor();
291
+ try {
292
+ await supervisor.start();
293
+ await waitFor(() => {
294
+ expect(isWatching(accountId)).toBe(true);
295
+ });
296
+
297
+ emitWatchError(accountId, new Error("socket dropped"));
298
+
299
+ await waitFor(() => {
300
+ expect(isWatching(accountId)).toBe(true);
301
+ });
302
+
303
+ const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
304
+ expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
305
+ expect(String(accounts[0]?.["watchState"])).toMatch(/^watching|backoff:/);
306
+ } finally {
307
+ await supervisor.stop();
308
+ }
309
+ });
310
+
311
+ test("watch-ingest failure is logged; poll reconciliation still ingests", async () => {
312
+ const admin = adminFor(4109);
313
+ const accountId = await connectSharedAccount(admin);
314
+ let ingestCalls = 0;
315
+ const supervisor = createInboundMailSupervisor({
316
+ providerCtx: { registry: stack.registry },
317
+ db,
318
+ dispatchWrite: ({ handlerQn, payload, tenantId }) => {
319
+ if (handlerQn === InboundMailFoundationHandlers.ingestMessage) {
320
+ ingestCalls += 1;
321
+ if (ingestCalls === 1) {
322
+ return Promise.resolve({ isSuccess: false, error: { code: "simulated" } });
323
+ }
324
+ }
325
+ return stack.dispatcher.write(
326
+ handlerQn,
327
+ payload,
328
+ createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
329
+ );
330
+ },
331
+ pollIntervalMs: 60_000,
332
+ watchBackoffInitialMs: 50,
333
+ watchBackoffMaxMs: 200,
334
+ });
335
+ try {
336
+ await supervisor.start();
337
+ await waitFor(() => {
338
+ expect(isWatching(accountId)).toBe(true);
339
+ });
340
+
341
+ await seedInboundMessage(
342
+ accountId,
343
+ rawMsg({ providerMessageId: "watch-ingest-fail-1", subject: "via watch" }),
344
+ );
345
+
346
+ await supervisor.pollOnce();
347
+
348
+ const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
349
+ expect(rows.some((r) => r["messageIdHeader"] === "watch-ingest-fail-1@example.com")).toBe(
350
+ true,
351
+ );
352
+ } finally {
353
+ await supervisor.stop();
354
+ }
355
+ });
356
+
357
+ test("storeBody hook persists raw MIME references on poll ingest", async () => {
358
+ const admin = adminFor(4110);
359
+ const accountId = await connectSharedAccount(admin);
360
+ const stored: string[] = [];
361
+ const supervisor = createInboundMailSupervisor({
362
+ providerCtx: { registry: stack.registry },
363
+ db,
364
+ dispatchWrite: ({ handlerQn, payload, tenantId }) =>
365
+ stack.dispatcher.write(
366
+ handlerQn,
367
+ payload,
368
+ createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
369
+ ),
370
+ storeBody: async (_account, msg) => {
371
+ stored.push(msg.providerMessageId);
372
+ return `body-ref:${msg.providerMessageId}`;
373
+ },
374
+ pollIntervalMs: 60_000,
375
+ });
376
+ await seedInboundMessage(
377
+ accountId,
378
+ rawMsg({
379
+ providerMessageId: "body-store-1",
380
+ rawMime: new TextEncoder().encode("raw-mime-bytes"),
381
+ }),
382
+ );
383
+ await supervisor.pollOnce();
384
+ expect(stored).toEqual(["body-store-1"]);
385
+ });
386
+
387
+ test("disconnecting an account tears down its watcher on the next pollOnce", async () => {
388
+ const admin = adminFor(4111);
389
+ const accountId = await connectSharedAccount(admin);
390
+ const supervisor = createSupervisor();
391
+ try {
392
+ await supervisor.start();
393
+ await waitFor(() => {
394
+ expect(isWatching(accountId)).toBe(true);
395
+ });
396
+
397
+ await stack.http.writeOk(
398
+ InboundMailFoundationHandlers.disconnectAccount,
399
+ { accountId, reason: "test" },
400
+ admin,
401
+ );
402
+ await supervisor.pollOnce();
403
+
404
+ expect(isWatching(accountId)).toBe(false);
405
+ } finally {
406
+ await supervisor.stop();
407
+ }
408
+ });
409
+
410
+ test("start() is idempotent — second start keeps the watcher armed", async () => {
411
+ const admin = adminFor(4112);
412
+ const accountId = await connectSharedAccount(admin);
413
+ const supervisor = createSupervisor();
414
+ try {
415
+ await supervisor.start();
416
+ await waitFor(() => {
417
+ expect(isWatching(accountId)).toBe(true);
418
+ });
419
+ await supervisor.start();
420
+ expect(isWatching(accountId)).toBe(true);
421
+ } finally {
422
+ await supervisor.stop();
423
+ }
424
+ });
271
425
  });