@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.159.1

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 (149) 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-gate.test.tsx +65 -3
  29. package/src/auth-email-password/web/auth-client.ts +33 -0
  30. package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
  31. package/src/auth-email-password/web/index.ts +6 -0
  32. package/src/auth-email-password/web/login-screen.tsx +15 -0
  33. package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
  34. package/src/auth-foundation/__tests__/feature.test.ts +33 -0
  35. package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
  36. package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
  37. package/src/auth-foundation/boot-checks.ts +91 -0
  38. package/src/auth-foundation/feature.ts +103 -0
  39. package/src/auth-foundation/index.ts +23 -0
  40. package/src/auth-foundation/types.ts +107 -0
  41. package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
  42. package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
  43. package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
  44. package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
  45. package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
  46. package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
  47. package/src/channel-email/pii-guard.ts +6 -3
  48. package/src/config/__tests__/config.integration.test.ts +14 -0
  49. package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
  50. package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
  51. package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
  52. package/src/config/feature.ts +2 -4
  53. package/src/config/handlers/set.write.ts +7 -1
  54. package/src/config/resolver.ts +1 -1
  55. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
  56. package/src/delivery/handlers/log.query.ts +1 -1
  57. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
  58. package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
  59. package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
  60. package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
  61. package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
  62. package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
  63. package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
  64. package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
  65. package/src/inbound-provider-inmemory/feature.ts +2 -2
  66. package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
  67. package/src/jobs/handlers/detail.query.ts +1 -1
  68. package/src/jobs/handlers/list.query.ts +1 -1
  69. package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
  70. package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
  71. package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
  72. package/src/managed-pages/handlers/branding.query.ts +5 -1
  73. package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
  74. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
  75. package/src/personal-access-tokens/feature.ts +22 -7
  76. package/src/personal-access-tokens/handlers/list.query.ts +1 -1
  77. package/src/personal-access-tokens/index.ts +0 -14
  78. package/src/personal-access-tokens/resolver.ts +2 -2
  79. package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
  80. package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
  81. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
  82. package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
  83. package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
  84. package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
  85. package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
  86. package/src/sessions/__tests__/test-helpers.ts +15 -2
  87. package/src/sessions/feature.ts +31 -3
  88. package/src/sessions/handlers/detail.query.ts +2 -2
  89. package/src/sessions/handlers/list.query.ts +4 -2
  90. package/src/sessions/handlers/mine.query.ts +4 -2
  91. package/src/sessions/testing.ts +14 -1
  92. package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
  93. package/src/shared/decrypt-stored-pii.ts +12 -5
  94. package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
  95. package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
  96. package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
  97. package/src/tenant/handlers/invitations.query.ts +4 -2
  98. package/src/tenant/handlers/members.query.ts +2 -2
  99. package/src/tenant/seeding.ts +2 -2
  100. package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
  101. package/src/tenant-lifecycle/boot-checks.ts +53 -0
  102. package/src/tenant-lifecycle/feature.ts +7 -0
  103. package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
  104. package/src/tier-engine/feature.ts +2 -2
  105. package/src/user-data-rights/COMPLIANCE.md +20 -0
  106. package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
  107. package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
  108. package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
  109. package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
  110. package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
  111. package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
  112. package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
  113. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
  114. package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
  115. package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
  116. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
  117. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
  118. package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
  119. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
  120. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
  121. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
  122. package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
  123. package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
  124. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
  129. package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
  130. package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
  131. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
  132. package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
  133. package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
  134. package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
  135. package/src/user-data-rights/boot-checks.ts +86 -0
  136. package/src/user-data-rights/feature.ts +116 -6
  137. package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
  138. package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
  139. package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
  140. package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
  141. package/src/user-data-rights/run-export-jobs.ts +3 -3
  142. package/src/user-data-rights/run-forget-cleanup.ts +1 -1
  143. package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
  144. package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
  145. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
  146. package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
  147. package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
  148. package/src/user-profile/handlers/change-email.write.ts +1 -1
  149. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
@@ -0,0 +1,100 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { fireEvent, type RenderResult, render, screen, waitFor } from "@testing-library/react";
9
+ import type { ReactElement } from "react";
10
+ import { makeSessionApi } from "../../../auth-email-password/web/__tests__/test-utils";
11
+ import { SessionContext } from "../../../auth-email-password/web/session";
12
+ import { defaultTranslations } from "../i18n";
13
+ import { MfaVerifyScreen } from "../mfa-verify-screen";
14
+
15
+ function renderScreen(ui: ReactElement, session = makeSessionApi()): RenderResult {
16
+ return render(
17
+ <PrimitivesProvider value={defaultPrimitives}>
18
+ <LocaleProvider
19
+ resolver={createStaticLocaleResolver({ locale: "de" })}
20
+ fallbackBundles={[defaultTranslations]}
21
+ >
22
+ <SessionContext.Provider value={session}>{ui}</SessionContext.Provider>
23
+ </LocaleProvider>
24
+ </PrimitivesProvider>,
25
+ );
26
+ }
27
+
28
+ beforeEach(() => {
29
+ globalThis.fetch = mock(
30
+ async () => new Response(null, { status: 200 }),
31
+ ) as unknown as typeof fetch;
32
+ });
33
+ afterEach(() => {});
34
+
35
+ describe("MfaVerifyScreen", () => {
36
+ test("rendert Titel + Code-Feld + Submit-Button (de)", () => {
37
+ void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" />);
38
+ expect(screen.getByText("Zwei-Faktor-Bestätigung")).toBeTruthy();
39
+ expect(screen.getByLabelText(/^Code/)).toBeTruthy();
40
+ expect(screen.getByRole("button", { name: "Bestätigen" })).toBeTruthy();
41
+ });
42
+
43
+ test("richtiger Code → ruft session.refresh() + onSuccess()", async () => {
44
+ globalThis.fetch = mock(
45
+ async () =>
46
+ new Response(
47
+ JSON.stringify({
48
+ isSuccess: true,
49
+ token: "t",
50
+ user: { id: "u1", tenantId: "t1", roles: [] },
51
+ }),
52
+ { status: 200 },
53
+ ),
54
+ ) as unknown as typeof fetch;
55
+ const session = makeSessionApi();
56
+ const onSuccess = mock(() => {});
57
+ void renderScreen(
58
+ <MfaVerifyScreen challengeToken="challenge-1" onSuccess={onSuccess} />,
59
+ session,
60
+ );
61
+
62
+ fireEvent.change(screen.getByLabelText(/^Code/), { target: { value: "123456" } });
63
+ fireEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
64
+
65
+ await waitFor(() => {
66
+ expect(session.refresh).toHaveBeenCalled();
67
+ expect(onSuccess).toHaveBeenCalled();
68
+ });
69
+ });
70
+
71
+ test("falscher Code (invalid_totp_code) → Banner mit übersetztem Text", async () => {
72
+ globalThis.fetch = mock(
73
+ async () =>
74
+ new Response(JSON.stringify({ isSuccess: false, error: "invalid_totp_code" }), {
75
+ status: 200,
76
+ }),
77
+ ) as unknown as typeof fetch;
78
+ void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" />);
79
+
80
+ fireEvent.change(screen.getByLabelText(/^Code/), { target: { value: "000000" } });
81
+ fireEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
82
+
83
+ await waitFor(() => {
84
+ expect(screen.getByText("Ungültiger Code. Bitte erneut versuchen.")).toBeTruthy();
85
+ });
86
+ });
87
+
88
+ test("onCancel gesetzt → 'Zurück zum Login'-Button vorhanden, klick ruft onCancel", () => {
89
+ const onCancel = mock(() => {});
90
+ void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" onCancel={onCancel} />);
91
+
92
+ fireEvent.click(screen.getByRole("button", { name: "Zurück zum Login" }));
93
+ expect(onCancel).toHaveBeenCalled();
94
+ });
95
+
96
+ test("ohne onCancel → kein 'Zurück zum Login'-Button", () => {
97
+ void renderScreen(<MfaVerifyScreen challengeToken="challenge-1" />);
98
+ expect(screen.queryByRole("button", { name: "Zurück zum Login" })).toBeNull();
99
+ });
100
+ });
@@ -0,0 +1,69 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { SubscriptionProviderPlugin } from "../types";
4
+
5
+ export type SubscriptionProviderContractFixture = {
6
+ readonly plugin: SubscriptionProviderPlugin;
7
+ readonly ctx: HandlerContext;
8
+ readonly checkout: Parameters<
9
+ NonNullable<SubscriptionProviderPlugin["createCheckoutSession"]>
10
+ >[1];
11
+ // Providers without a customer-portal (e.g. Mollie) omit this — the
12
+ // portal-test then skips, mirroring `createPortalSession` being optional.
13
+ readonly portal?: Parameters<NonNullable<SubscriptionProviderPlugin["createPortalSession"]>>[1];
14
+ readonly cancelSubscriptionId?: string;
15
+ // A real, provider-signed/verifiable webhook payload for the happy path.
16
+ // Providers without a fixture skip the webhook-parsing test — building one
17
+ // needs provider-specific signing (Stripe HMAC) or a mocked API client
18
+ // (Mollie lazy-fetch), which the provider's own verify-webhook.test.ts
19
+ // already assembles.
20
+ readonly webhook?: {
21
+ readonly rawBody: string;
22
+ readonly headers: Record<string, string>;
23
+ readonly expectedTenantId: string;
24
+ readonly expectedTier: string;
25
+ };
26
+ };
27
+
28
+ export function describeSubscriptionProviderContract(
29
+ name: string,
30
+ factory: () => SubscriptionProviderContractFixture | Promise<SubscriptionProviderContractFixture>,
31
+ ): void {
32
+ describe(`${name} — SubscriptionProviderPlugin contract`, () => {
33
+ test("implements verifyAndParseWebhook", async () => {
34
+ const { plugin } = await factory();
35
+ expect(typeof plugin.verifyAndParseWebhook).toBe("function");
36
+ });
37
+
38
+ test("verifyAndParseWebhook parses a valid provider event into a SubscriptionEvent", async () => {
39
+ const { plugin, webhook } = await factory();
40
+ if (!webhook) return;
41
+ const event = await plugin.verifyAndParseWebhook(webhook.rawBody, webhook.headers);
42
+ expect(event).not.toBeNull();
43
+ expect(event?.tenantId).toBe(webhook.expectedTenantId);
44
+ expect(event?.tier).toBe(webhook.expectedTier);
45
+ });
46
+
47
+ test("createCheckoutSession returns a hosted-page url", async () => {
48
+ const { plugin, ctx, checkout } = await factory();
49
+ if (!plugin.createCheckoutSession) return;
50
+ const result = await plugin.createCheckoutSession(ctx, checkout);
51
+ expect(typeof result.url).toBe("string");
52
+ expect(result.url.length).toBeGreaterThan(0);
53
+ });
54
+
55
+ test("createPortalSession returns a hosted-page url — providers without a portal skip", async () => {
56
+ const { plugin, ctx, portal } = await factory();
57
+ if (!plugin.createPortalSession || !portal) return;
58
+ const result = await plugin.createPortalSession(ctx, portal);
59
+ expect(typeof result.url).toBe("string");
60
+ expect(result.url.length).toBeGreaterThan(0);
61
+ });
62
+
63
+ test("cancelSubscription resolves without throwing — providers without cancel-support skip", async () => {
64
+ const { plugin, ctx, cancelSubscriptionId } = await factory();
65
+ if (!plugin.cancelSubscription || !cancelSubscriptionId) return;
66
+ await expect(plugin.cancelSubscription(ctx, cancelSubscriptionId)).resolves.toBeUndefined();
67
+ });
68
+ });
69
+ }
@@ -2,21 +2,24 @@ import { PII_CIPHERTEXT_PREFIX } from "@cosmicdrift/kumiko-framework/crypto";
2
2
  import type { EmailMessage, EmailTransport } from "./types";
3
3
 
4
4
  const isProductionEnv = () => process.env["NODE_ENV"] === "production";
5
- const CIPHERTEXT_RE = /kumiko-pii:v1:[^"\s<>\\]*/g;
5
+ // Version-agnostic: catches both the current PII_CIPHERTEXT_PREFIX and any
6
+ // older/decrypt-only format version still present in unmigrated rows.
7
+ const CIPHERTEXT_MARKER = "kumiko-pii:v";
8
+ const CIPHERTEXT_RE = /kumiko-pii:v\d+:[^"\s<>\\]*/g;
6
9
 
7
10
  // A PII ciphertext never belongs in an outgoing mail. A ciphertext RECIPIENT
8
11
  // is always refused (the address is garbage — better no mail than a leaked
9
12
  // blob); ciphertext in subject/body fails loud in dev and is redacted+logged
10
13
  // in prod.
11
14
  export function guardEmailMessage(message: EmailMessage): EmailMessage {
12
- if (message.to.includes(PII_CIPHERTEXT_PREFIX)) {
15
+ if (message.to.includes(CIPHERTEXT_MARKER)) {
13
16
  throw new Error(
14
17
  "[channel-email] refusing to send: recipient address is a PII ciphertext " +
15
18
  `("${PII_CIPHERTEXT_PREFIX}…") — decrypt the stored value before mailing (decryptStoredPii).`,
16
19
  );
17
20
  }
18
21
  const leaking =
19
- message.subject.includes(PII_CIPHERTEXT_PREFIX) || message.html.includes(PII_CIPHERTEXT_PREFIX);
22
+ message.subject.includes(CIPHERTEXT_MARKER) || message.html.includes(CIPHERTEXT_MARKER);
20
23
  if (!leaking) return message;
21
24
  const detail =
22
25
  "[channel-email] mail subject/body contains a PII ciphertext " +
@@ -1086,6 +1086,20 @@ describe("encrypted config", () => {
1086
1086
  const value = await configFn("integration:config:api-secret");
1087
1087
  expect(value).toBe("sk-super-secret-key-12345");
1088
1088
  });
1089
+
1090
+ test("reading an encrypted value with no cipher wired throws instead of leaking ciphertext", async () => {
1091
+ const noCipherResolver = createConfigResolver({});
1092
+ const configFn = createConfigAccessor(
1093
+ stack.registry,
1094
+ noCipherResolver,
1095
+ "00000000-0000-4000-8000-000000000001",
1096
+ systemAdmin.id,
1097
+ db,
1098
+ );
1099
+ await expect(configFn("integration:config:api-secret")).rejects.toThrow(
1100
+ /encrypted but no cipher/,
1101
+ );
1102
+ });
1089
1103
  });
1090
1104
 
1091
1105
  // --- Config lifecycle events ---
@@ -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 }>
@@ -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
+ }