@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
@@ -272,4 +272,48 @@ describe("mfa verify — re-checks account state the way login.write.ts does", (
272
272
  );
273
273
  expectErrorIncludes(err, "invalid_challenge_token");
274
274
  });
275
+
276
+ test("user row deleted between login and verify → challenge rejected (the !userRow gate)", async () => {
277
+ const { user, secret } = await enableMfaFor(7);
278
+ const challengeToken = challengeFor(user.id, user.tenantId);
279
+
280
+ // Simulate the read_users row disappearing after the challenge token
281
+ // was issued but before verify runs — hits verify.write.ts's
282
+ // `if (!userRow) return invalidChallengeToken()` gate directly, distinct
283
+ // from a revoked membership or a restricted-but-present user row.
284
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}" WHERE id = $1`, [
285
+ user.id,
286
+ ]);
287
+
288
+ const err = await stack.http.writeErr(
289
+ AuthMfaHandlers.verify,
290
+ { challengeToken, code: currentTotpCode(secret) },
291
+ GUEST,
292
+ );
293
+ expectErrorIncludes(err, "invalid_challenge_token");
294
+ });
295
+
296
+ test("user row homed in a DIFFERENT tenant than the challenge → verify succeeds (#1235)", async () => {
297
+ const { user, secret } = await enableMfaFor(8);
298
+
299
+ // Re-home the read_users row into another tenant — the #1235 layout:
300
+ // the user entity is tenant-agnostic (systemStream), so its row's
301
+ // tenant_id need not match the tenant being logged into. The old
302
+ // tenant-scoped UserQueries.detail lookup returned null here and every
303
+ // verify died with invalid_challenge_token; the unscoped findForAuth
304
+ // must resolve the row regardless.
305
+ await asRawClient(stack.db).unsafe(
306
+ `UPDATE "${userTable.tableName}" SET tenant_id = $1 WHERE id = $2`,
307
+ ["00000000-0000-4000-8000-0000000000fe", user.id],
308
+ );
309
+
310
+ const challengeToken = challengeFor(user.id, user.tenantId);
311
+ const res = await stack.http.writeOk<{ session: SessionUser }>(
312
+ AuthMfaHandlers.verify,
313
+ { challengeToken, code: currentTotpCode(secret) },
314
+ GUEST,
315
+ );
316
+ expect(res.session.id).toBe(user.id);
317
+ expect(res.session.tenantId).toBe(user.tenantId);
318
+ });
275
319
  });
@@ -129,7 +129,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
129
129
  // the SAME tenant the challenge token already committed to (no
130
130
  // "pick a tenant" step here — that already happened at login time).
131
131
  const systemUser = createSystemUser(tenantId, ["SystemAdmin"]);
132
- const userRow = (await ctx.queryAs(systemUser, UserQueries.detail, {
132
+ const userRow = (await ctx.queryAs(systemUser, UserQueries.findForAuth, {
133
133
  id: userId,
134
134
  })) as { roles?: string | null; status?: string } | null; // @cast-boundary engine-payload
135
135
 
@@ -0,0 +1,118 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { verifyMfaChallenge } from "../mfa-client";
3
+
4
+ beforeEach(() => {
5
+ globalThis.fetch = mock(
6
+ async () => new Response(null, { status: 200 }),
7
+ ) as unknown as typeof fetch;
8
+ });
9
+ afterEach(() => {});
10
+
11
+ function jsonResponse(body: unknown, status = 200): Response {
12
+ return new Response(JSON.stringify(body), { status });
13
+ }
14
+
15
+ describe("verifyMfaChallenge", () => {
16
+ test("posts challengeToken + code to /api/auth/mfa/verify", async () => {
17
+ const fetchMock = mock(async () =>
18
+ jsonResponse({ isSuccess: true, token: "t", user: { id: "u1", tenantId: "t1", roles: [] } }),
19
+ );
20
+ globalThis.fetch = fetchMock as unknown as typeof fetch;
21
+
22
+ await verifyMfaChallenge("challenge-1", "123456");
23
+
24
+ expect(fetchMock).toHaveBeenCalledWith(
25
+ "/api/auth/mfa/verify",
26
+ expect.objectContaining({
27
+ method: "POST",
28
+ credentials: "same-origin",
29
+ body: JSON.stringify({ challengeToken: "challenge-1", code: "123456" }),
30
+ }),
31
+ );
32
+ });
33
+
34
+ test("success → kind:success mit token+user", async () => {
35
+ globalThis.fetch = mock(async () =>
36
+ jsonResponse({
37
+ isSuccess: true,
38
+ token: "test-token",
39
+ user: { id: "u1", tenantId: "t1", roles: ["Admin"] },
40
+ }),
41
+ ) as unknown as typeof fetch;
42
+
43
+ const res = await verifyMfaChallenge("challenge-1", "123456");
44
+
45
+ expect(res).toEqual({
46
+ kind: "success",
47
+ data: { token: "test-token", user: { id: "u1", tenantId: "t1", roles: ["Admin"] } },
48
+ });
49
+ });
50
+
51
+ test("HTTP 429 → kind:failure reason:rate_limited (ohne Body-Parse)", async () => {
52
+ globalThis.fetch = mock(
53
+ async () => new Response(null, { status: 429 }),
54
+ ) as unknown as typeof fetch;
55
+
56
+ const res = await verifyMfaChallenge("challenge-1", "000000");
57
+
58
+ expect(res).toEqual({ kind: "failure", error: { reason: "rate_limited" } });
59
+ });
60
+
61
+ test("error als String → kind:failure reason:<string>", async () => {
62
+ globalThis.fetch = mock(async () =>
63
+ jsonResponse({ isSuccess: false, error: "invalid_totp_code" }),
64
+ ) as unknown as typeof fetch;
65
+
66
+ const res = await verifyMfaChallenge("challenge-1", "000000");
67
+
68
+ expect(res).toEqual({ kind: "failure", error: { reason: "invalid_totp_code" } });
69
+ });
70
+
71
+ test("error-Objekt mit details.reason → reason aus details, message+retryAfterSeconds übernommen", async () => {
72
+ globalThis.fetch = mock(async () =>
73
+ jsonResponse({
74
+ isSuccess: false,
75
+ error: {
76
+ code: "mfa_verify_failed",
77
+ message: "too many attempts",
78
+ details: { reason: "too_many_attempts", retryAfterSeconds: 60 },
79
+ },
80
+ }),
81
+ ) as unknown as typeof fetch;
82
+
83
+ const res = await verifyMfaChallenge("challenge-1", "000000");
84
+
85
+ expect(res).toEqual({
86
+ kind: "failure",
87
+ error: { reason: "too_many_attempts", message: "too many attempts", retryAfterSeconds: 60 },
88
+ });
89
+ });
90
+
91
+ test("error-Objekt ohne details.reason → fällt auf error.code zurück", async () => {
92
+ globalThis.fetch = mock(async () =>
93
+ jsonResponse({ isSuccess: false, error: { code: "invalid_challenge_token" } }),
94
+ ) as unknown as typeof fetch;
95
+
96
+ const res = await verifyMfaChallenge("challenge-1", "000000");
97
+
98
+ expect(res).toEqual({ kind: "failure", error: { reason: "invalid_challenge_token" } });
99
+ });
100
+
101
+ test("kein error-Feld, kein isSuccess → default reason mfa_verify_failed", async () => {
102
+ globalThis.fetch = mock(async () => jsonResponse({})) as unknown as typeof fetch;
103
+
104
+ const res = await verifyMfaChallenge("challenge-1", "000000");
105
+
106
+ expect(res).toEqual({ kind: "failure", error: { reason: "mfa_verify_failed" } });
107
+ });
108
+
109
+ test("kaputtes JSON → leeres Body-Objekt, default reason", async () => {
110
+ globalThis.fetch = mock(
111
+ async () => new Response("not json", { status: 200 }),
112
+ ) as unknown as typeof fetch;
113
+
114
+ const res = await verifyMfaChallenge("challenge-1", "000000");
115
+
116
+ expect(res).toEqual({ kind: "failure", error: { reason: "mfa_verify_failed" } });
117
+ });
118
+ });
@@ -0,0 +1,173 @@
1
+ import { 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, render, screen, waitFor } from "@testing-library/react";
9
+ import type { ReactNode } 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
+
14
+ type WriteResult =
15
+ | { readonly isSuccess: true; readonly data: unknown }
16
+ | { readonly isSuccess: false; readonly error: { readonly i18nKey: string } };
17
+
18
+ const dispatchSpy = mock<() => Promise<WriteResult>>(async () => ({
19
+ isSuccess: true,
20
+ data: {
21
+ setupToken: "setup-token-1",
22
+ otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD1234&issuer=App",
23
+ recoveryCodes: ["code-1", "code-2"],
24
+ },
25
+ }));
26
+
27
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
28
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
29
+ ...actual_renderer,
30
+ useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
31
+ }));
32
+
33
+ mock.module("qrcode/lib/browser", () => ({
34
+ default: { toString: mock(async () => "<svg></svg>") },
35
+ }));
36
+
37
+ const { MfaEnableScreen } = await import("../mfa-enable-screen");
38
+
39
+ const session = makeSessionApi();
40
+
41
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
42
+ return (
43
+ <PrimitivesProvider value={defaultPrimitives}>
44
+ <LocaleProvider
45
+ resolver={createStaticLocaleResolver({ locale: "de" })}
46
+ fallbackBundles={[defaultTranslations]}
47
+ >
48
+ <SessionContext.Provider value={session}>{children}</SessionContext.Provider>
49
+ </LocaleProvider>
50
+ </PrimitivesProvider>
51
+ );
52
+ }
53
+
54
+ beforeEach(() => {
55
+ dispatchSpy.mockClear();
56
+ });
57
+
58
+ describe("MfaEnableScreen", () => {
59
+ test("rendert Intro + Start-Button, kein Setup-Bereich vor dem Start", () => {
60
+ render(
61
+ <Wrapper>
62
+ <MfaEnableScreen />
63
+ </Wrapper>,
64
+ );
65
+ expect(screen.getByText("Zwei-Faktor-Authentifizierung")).toBeTruthy();
66
+ expect(screen.getByRole("button", { name: "Einrichtung starten" })).toBeTruthy();
67
+ expect(screen.queryByLabelText("Code aus der Authenticator-App")).toBeNull();
68
+ });
69
+
70
+ test("Start-Klick → dispatcher.write ruft enable-start, danach Setup-Bereich mit Secret + Recovery-Codes", async () => {
71
+ render(
72
+ <Wrapper>
73
+ <MfaEnableScreen />
74
+ </Wrapper>,
75
+ );
76
+
77
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
78
+
79
+ await waitFor(() => {
80
+ expect(dispatchSpy).toHaveBeenCalledWith(
81
+ "auth-mfa:write:enable-start",
82
+ expect.objectContaining({ accountLabel: "user@example.com" }),
83
+ );
84
+ });
85
+ await waitFor(() => {
86
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
87
+ });
88
+ expect(screen.getByText(/code-1/)).toBeTruthy();
89
+ expect(screen.getByText(/code-2/)).toBeTruthy();
90
+ });
91
+
92
+ test("Bestätigen-Button bleibt disabled bis Recovery-Codes bestätigt + 6-stelliger Code eingegeben", async () => {
93
+ render(
94
+ <Wrapper>
95
+ <MfaEnableScreen />
96
+ </Wrapper>,
97
+ );
98
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
99
+ await waitFor(() => {
100
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
101
+ });
102
+
103
+ const confirm = screen.getByRole("button", { name: "Aktivieren" }) as HTMLButtonElement;
104
+ expect(confirm.disabled).toBe(true);
105
+
106
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
107
+ expect(confirm.disabled).toBe(true);
108
+
109
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
110
+ target: { value: "123456" },
111
+ });
112
+ expect(confirm.disabled).toBe(false);
113
+ });
114
+
115
+ test("Bestätigen → dispatcher.write ruft enable-confirm, danach Success-Banner", async () => {
116
+ dispatchSpy
117
+ .mockImplementationOnce(async () => ({
118
+ isSuccess: true,
119
+ data: {
120
+ setupToken: "setup-token-1",
121
+ otpauthUri: "otpauth://totp/App:user@example.com?secret=ABCD1234&issuer=App",
122
+ recoveryCodes: ["code-1", "code-2"],
123
+ },
124
+ }))
125
+ .mockImplementationOnce(async () => ({ isSuccess: true, data: undefined }));
126
+ const onEnabled = mock(() => {});
127
+ render(
128
+ <Wrapper>
129
+ <MfaEnableScreen onEnabled={onEnabled} />
130
+ </Wrapper>,
131
+ );
132
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
133
+ await waitFor(() => {
134
+ expect(screen.getByText("ABCD1234")).toBeTruthy();
135
+ });
136
+ fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
137
+ fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
138
+ target: { value: "123456" },
139
+ });
140
+
141
+ fireEvent.click(screen.getByRole("button", { name: "Aktivieren" }));
142
+
143
+ await waitFor(() => {
144
+ expect(dispatchSpy).toHaveBeenCalledWith(
145
+ "auth-mfa:write:enable-confirm",
146
+ expect.objectContaining({ setupToken: "setup-token-1", code: "123456" }),
147
+ );
148
+ });
149
+ await waitFor(() => {
150
+ expect(screen.getByText("Zwei-Faktor-Authentifizierung ist jetzt aktiv.")).toBeTruthy();
151
+ expect(onEnabled).toHaveBeenCalled();
152
+ });
153
+ });
154
+
155
+ test("enable-start schlägt fehl → übersetztes Fehler-Banner, kein Setup-Bereich", async () => {
156
+ dispatchSpy.mockImplementationOnce(async () => ({
157
+ isSuccess: false,
158
+ error: { i18nKey: "auth.mfa.errors.mfaAlreadyEnabled" },
159
+ }));
160
+ render(
161
+ <Wrapper>
162
+ <MfaEnableScreen />
163
+ </Wrapper>,
164
+ );
165
+
166
+ fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
167
+
168
+ await waitFor(() => {
169
+ expect(screen.getByText("Zwei-Faktor-Authentifizierung ist bereits aktiv.")).toBeTruthy();
170
+ });
171
+ expect(screen.queryByText("ABCD1234")).toBeNull();
172
+ });
173
+ });
@@ -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 " +
@@ -115,4 +115,15 @@ describe("validateAppOverrides", () => {
115
115
  }),
116
116
  ).toThrow(/computed resolver.*app-overrides would silently bypass/i);
117
117
  });
118
+
119
+ test("throws on type mismatch — text key, number value", () => {
120
+ const reg = registryStub({
121
+ "a:config:s": createTenantConfig("text"),
122
+ });
123
+ expect(() =>
124
+ validateAppOverrides(reg, {
125
+ "a:config:s": 42,
126
+ }),
127
+ ).toThrow(/expected string, got number/i);
128
+ });
118
129
  });
@@ -203,6 +203,24 @@ describe("config backing=secrets — fail-loud when secrets unwired", () => {
203
203
  await unwired.cleanup();
204
204
  }
205
205
  });
206
+
207
+ test("cascade read on backing=secrets without secretsReader throws", async () => {
208
+ // Write succeeds on the wired stack; then resolve via a bare resolver
209
+ // (no secretsReader) so readBackingSecret fails loud on the cascade path.
210
+ await stack.http.writeOk(
211
+ ConfigHandlers.set,
212
+ { key: API_KEY, value: "sk-live-planted", scope: "system" },
213
+ systemAdmin,
214
+ );
215
+
216
+ const bare = createConfigResolver();
217
+ const keyDef = stack.registry.getConfigKey(API_KEY);
218
+ expect(keyDef).toBeDefined();
219
+
220
+ await expect(
221
+ bare.getCascade(API_KEY, keyDef!, systemAdmin.tenantId, systemAdmin.id, stack.db),
222
+ ).rejects.toThrow(/backing="secrets".*without a secrets/);
223
+ });
206
224
  });
207
225
 
208
226
  describe("config backing=secrets — reset dispatch", () => {
@@ -527,3 +527,42 @@ describe("config:query:cascade handler", () => {
527
527
  expect(data[TENANT_KEY]).toBeDefined();
528
528
  });
529
529
  });
530
+
531
+ // getAll / getAllWithSource are bulk readers used by admin UIs — never
532
+ // reached via the cascade query handlers above, so they stay uncovered
533
+ // unless called directly on the resolver.
534
+ describe("getAll / getAllWithSource", () => {
535
+ test("getAll picks the most specific row (user > tenant > system)", async () => {
536
+ await stack.http.writeOk(
537
+ ConfigHandlers.set,
538
+ { key: TENANT_KEY, value: "TENANT_BULK" },
539
+ tenantAdmin,
540
+ );
541
+ await stack.http.writeOk(
542
+ ConfigHandlers.set,
543
+ { key: USER_KEY, value: "USER_BULK" },
544
+ tenantAdmin,
545
+ );
546
+
547
+ const all = await resolver.getAll(tenantAdmin.tenantId, tenantAdmin.id, db);
548
+ // getAll returns raw stored strings (JSON-serialized); deserialize for asserts.
549
+ expect(JSON.parse(all.get(TENANT_KEY)?.value ?? "null")).toBe("TENANT_BULK");
550
+ expect(JSON.parse(all.get(USER_KEY)?.value ?? "null")).toBe("USER_BULK");
551
+ // System seed still present for keys without a more-specific row.
552
+ expect(JSON.parse(all.get(SYSTEM_KEY)?.value ?? "null")).toBe("SEED_SYSTEM");
553
+ });
554
+
555
+ test("getAllWithSource tags the winning row's source", async () => {
556
+ const withSource = await resolver.getAllWithSource(tenantAdmin.tenantId, tenantAdmin.id, db);
557
+
558
+ const userRow = withSource.get(USER_KEY);
559
+ expect(userRow?.source).toBe("user-row");
560
+ expect(JSON.parse(userRow?.value ?? "null")).toBe("USER_BULK");
561
+
562
+ const tenantRow = withSource.get(TENANT_KEY);
563
+ expect(tenantRow?.source).toBe("tenant-row");
564
+
565
+ const systemRow = withSource.get(SYSTEM_KEY);
566
+ expect(systemRow?.source).toBe("system-row");
567
+ });
568
+ });
@@ -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 ---