@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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.
- package/package.json +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
// decryptForRead), so it must peel that PII layer itself before the envelope
|
|
15
15
|
// cipher ever sees the value, mirroring decryptForRead's PII-then-envelope
|
|
16
16
|
// order — an envelope decrypt on a still-PII-wrapped string throws
|
|
17
|
-
// "
|
|
17
|
+
// "[envelope-cipher] stored value is not valid JSON" instead of rotating anything.
|
|
18
18
|
//
|
|
19
19
|
// Idempotent: re-running skips rows already on the current kekVersion.
|
|
20
20
|
// Every write is a normal executor.update — after a full run even a
|
|
@@ -66,17 +66,20 @@ export type MfaReencryptJobResult = {
|
|
|
66
66
|
readonly stoppedReason: ChunkedMigrationStopReason;
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
type EnvelopeClassification = "rotate" | "current" | "unrecognized";
|
|
70
|
+
|
|
71
|
+
// After PII peel (when configured), values must be current-cipher envelopes.
|
|
72
|
+
// Non-JSON / non-envelope is not a supported legacy decrypt path — fail the
|
|
73
|
+
// row instead of treating it as "needs rotate" (#1541, mirrors config #1513).
|
|
74
|
+
function classifyEnvelope(value: string, targetVersion: number): EnvelopeClassification {
|
|
75
|
+
let parsed: unknown;
|
|
72
76
|
try {
|
|
73
|
-
|
|
74
|
-
if (!isStoredEnvelope(parsed)) return true;
|
|
75
|
-
return parsed.kekVersion !== targetVersion;
|
|
77
|
+
parsed = JSON.parse(value);
|
|
76
78
|
} catch {
|
|
77
|
-
|
|
78
|
-
return true;
|
|
79
|
+
return "unrecognized";
|
|
79
80
|
}
|
|
81
|
+
if (!isStoredEnvelope(parsed)) return "unrecognized";
|
|
82
|
+
return parsed.kekVersion === targetVersion ? "current" : "rotate";
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> => {
|
|
@@ -152,7 +155,7 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
152
155
|
async function migrateRow(row: UserMfaRow): Promise<"migrated" | "skipped" | "failed"> {
|
|
153
156
|
const tenantId = row.tenantId as TenantId; // @cast-boundary db-row
|
|
154
157
|
|
|
155
|
-
// Peel the PII layer first (if active) so
|
|
158
|
+
// Peel the PII layer first (if active) so classifyEnvelope/cipher.decrypt
|
|
156
159
|
// below only ever see envelope strings, same order as decryptForRead.
|
|
157
160
|
let envelopeValues: { totpSecret: string; recoveryCodes: string } = row;
|
|
158
161
|
if (piiKms) {
|
|
@@ -163,20 +166,26 @@ export const mfaReencryptJob: JobHandlerFn = async (rawPayload, ctx): Promise<vo
|
|
|
163
166
|
envelopeValues = unwrapped as { totpSecret: string; recoveryCodes: string }; // @cast-boundary engine-payload
|
|
164
167
|
}
|
|
165
168
|
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
+
const totpClass = classifyEnvelope(envelopeValues.totpSecret, targetVersion);
|
|
170
|
+
const recoveryClass = classifyEnvelope(envelopeValues.recoveryCodes, targetVersion);
|
|
171
|
+
if (totpClass === "unrecognized" || recoveryClass === "unrecognized") {
|
|
172
|
+
ctx.log?.warn?.(
|
|
173
|
+
`[auth-mfa:reencrypt] row ${row.id} has a field that is not a current-cipher envelope, not re-encryptable`,
|
|
174
|
+
);
|
|
175
|
+
return "failed";
|
|
176
|
+
}
|
|
177
|
+
if (totpClass === "current" && recoveryClass === "current") {
|
|
169
178
|
alreadyCurrent++;
|
|
170
179
|
return "skipped";
|
|
171
180
|
}
|
|
172
181
|
|
|
173
|
-
// decrypt failure (
|
|
174
|
-
//
|
|
182
|
+
// decrypt failure (unknown kekVersion, tamper) throws → counted as
|
|
183
|
+
// failed via onRowError; the row stays untouched.
|
|
175
184
|
const changes: Record<string, string> = {};
|
|
176
|
-
if (
|
|
185
|
+
if (totpClass === "rotate") {
|
|
177
186
|
changes["totpSecret"] = await cipher.decrypt(envelopeValues.totpSecret, { tenantId });
|
|
178
187
|
}
|
|
179
|
-
if (
|
|
188
|
+
if (recoveryClass === "rotate") {
|
|
180
189
|
changes["recoveryCodes"] = await cipher.decrypt(envelopeValues.recoveryCodes, { tenantId });
|
|
181
190
|
}
|
|
182
191
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createEventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
2
|
import { defineWriteHandler } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { findUserMfaRow } from "../db/queries";
|
|
5
6
|
import { invalidTotpCode, mfaNotEnabled } from "../errors";
|
|
@@ -34,7 +35,20 @@ export function createRegenerateRecoveryHandler(opts: RegenerateRecoveryOptions)
|
|
|
34
35
|
// (deliberately) one of the very codes about to be replaced both
|
|
35
36
|
// count, since either way the presented code is consumed/irrelevant
|
|
36
37
|
// afterward.
|
|
37
|
-
|
|
38
|
+
//
|
|
39
|
+
// Fail closed, matching verify.write.ts/enable-confirm-preauth.write.ts
|
|
40
|
+
// (#1467): without ctx.redis, verifyMfaFactor's TOTP-replay burn is
|
|
41
|
+
// skipped, letting an observed code be replayed within the ±1-step
|
|
42
|
+
// window to regenerate (and thus invalidate) recovery codes — a
|
|
43
|
+
// session-authed route, so pre-unwiring JWTs stay a valid attack path.
|
|
44
|
+
if (!ctx.redis) {
|
|
45
|
+
return writeFailure(
|
|
46
|
+
new InternalError({
|
|
47
|
+
message: "regenerate-recovery (mfa) requires ctx.redis for TOTP replay protection",
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const replay = { redis: ctx.redis, userId: event.user.id };
|
|
38
52
|
const verify = await verifyMfaFactor(row, event.payload.code, replay);
|
|
39
53
|
if (!verify.ok) return invalidTotpCode();
|
|
40
54
|
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
defineWriteHandler,
|
|
6
6
|
type SessionUser,
|
|
7
7
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
+
import { InternalError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
9
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
10
|
import { z } from "zod";
|
|
10
11
|
import { burnToken } from "../../shared";
|
|
@@ -53,17 +54,31 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
53
54
|
if (!verified.ok) return invalidChallengeToken();
|
|
54
55
|
const { userId, tenantId } = verified.payload;
|
|
55
56
|
|
|
57
|
+
// Fail closed, matching enable-confirm-preauth.write.ts (#1467):
|
|
58
|
+
// this route sits behind a challenge token from a successful login,
|
|
59
|
+
// but its own secret is still just a short TOTP/recovery code —
|
|
60
|
+
// silently skipping the brute-force cap and single-use burn when
|
|
61
|
+
// ctx.redis is absent would let a stolen challenge token be guessed
|
|
62
|
+
// against unboundedly, and let a recovery code be replayed via a
|
|
63
|
+
// reissued challenge (mfa-verify-attempts.ts's counter is deliberately
|
|
64
|
+
// NOT reset by reissuance, precisely to survive this).
|
|
65
|
+
if (!ctx.redis) {
|
|
66
|
+
return writeFailure(
|
|
67
|
+
new InternalError({
|
|
68
|
+
message: "verify (mfa) requires ctx.redis for the brute-force cap and single-use burn",
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
// Per-account brute-force cap — survives challenge-token reissuance
|
|
57
74
|
// on purpose (see mfa-verify-attempts.ts header). Checked BEFORE the
|
|
58
75
|
// TOTP/recovery verify so a locked account can't be guessed against.
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return tooManyAttempts(retryAfterSeconds);
|
|
66
|
-
}
|
|
76
|
+
const state = await getMfaVerifyLockoutState(ctx.redis, userId);
|
|
77
|
+
if (state?.lockedUntil !== null && state?.lockedUntil !== undefined) {
|
|
78
|
+
const now = Date.now();
|
|
79
|
+
if (state.lockedUntil > now) {
|
|
80
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((state.lockedUntil - now) / 1000));
|
|
81
|
+
return tooManyAttempts(retryAfterSeconds);
|
|
67
82
|
}
|
|
68
83
|
}
|
|
69
84
|
|
|
@@ -78,12 +93,10 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
78
93
|
// bad code, no detail leak about account state.
|
|
79
94
|
if (!row) return invalidChallengeToken();
|
|
80
95
|
|
|
81
|
-
const replay =
|
|
96
|
+
const replay = { redis: ctx.redis, userId };
|
|
82
97
|
const verify = await verifyMfaFactor(row, event.payload.code, replay);
|
|
83
98
|
if (!verify.ok) {
|
|
84
|
-
|
|
85
|
-
await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
|
|
86
|
-
}
|
|
99
|
+
await recordFailedMfaVerifyAttempt(ctx.redis, userId, maxAttempts, lockoutMinutes);
|
|
87
100
|
return invalidTotpCode();
|
|
88
101
|
}
|
|
89
102
|
|
|
@@ -95,15 +108,8 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
95
108
|
// is reached, so this burn only needs to guard a DIFFERENT code
|
|
96
109
|
// (recovery, or a still-valid-but-not-yet-burned TOTP step) reused
|
|
97
110
|
// against the same challenge token.
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
ctx.redis,
|
|
101
|
-
"mfa-challenge",
|
|
102
|
-
userId,
|
|
103
|
-
verified.expiresAtMs,
|
|
104
|
-
);
|
|
105
|
-
if (burnResult === "already-used") return invalidChallengeToken();
|
|
106
|
-
}
|
|
111
|
+
const burnResult = await burnToken(ctx.redis, "mfa-challenge", userId, verified.expiresAtMs);
|
|
112
|
+
if (burnResult === "already-used") return invalidChallengeToken();
|
|
107
113
|
|
|
108
114
|
// Recovery-code use consumes the code — persist the reduced hash-list
|
|
109
115
|
// immediately so it can't be replayed. Without this a recovery code
|
|
@@ -121,9 +127,7 @@ export function createMfaVerifyHandler(opts: MfaVerifyOptions) {
|
|
|
121
127
|
if (!updateResult.isSuccess) return updateResult;
|
|
122
128
|
}
|
|
123
129
|
|
|
124
|
-
|
|
125
|
-
await clearMfaVerifyAttempts(ctx.redis, userId);
|
|
126
|
-
}
|
|
130
|
+
await clearMfaVerifyAttempts(ctx.redis, userId);
|
|
127
131
|
|
|
128
132
|
// Re-derive the full session the way login.write.ts would have, for
|
|
129
133
|
// the SAME tenant the challenge token already committed to (no
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import { confirmMfaSetupPreauth, startMfaSetupPreauth, verifyMfaChallenge } from "../mfa-client";
|
|
3
3
|
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
|
|
4
6
|
beforeEach(() => {
|
|
5
7
|
globalThis.fetch = mock(
|
|
6
8
|
async () => new Response(null, { status: 200 }),
|
|
7
9
|
) as unknown as typeof fetch;
|
|
8
10
|
});
|
|
9
|
-
afterEach(() => {
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
globalThis.fetch = originalFetch;
|
|
13
|
+
});
|
|
10
14
|
|
|
11
15
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
12
16
|
return new Response(JSON.stringify(body), { status });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
1
|
+
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
2
|
import {
|
|
3
3
|
createStaticLocaleResolver,
|
|
4
4
|
LocaleProvider,
|
|
@@ -34,6 +34,15 @@ mock.module("qrcode/lib/browser", () => ({
|
|
|
34
34
|
default: { toString: mock(async () => "<svg></svg>") },
|
|
35
35
|
}));
|
|
36
36
|
|
|
37
|
+
// bun's mock.module is process-global and outlives this file — without
|
|
38
|
+
// restoring it, any later suite in the same test run that needs the real
|
|
39
|
+
// useDispatcher from @cosmicdrift/kumiko-renderer silently gets this mock
|
|
40
|
+
// instead (green-by-accident or a confusing foreign-suite failure,
|
|
41
|
+
// depending on test order).
|
|
42
|
+
afterAll(() => {
|
|
43
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => actual_renderer);
|
|
44
|
+
});
|
|
45
|
+
|
|
37
46
|
const { MfaEnableScreen } = await import("../mfa-enable-screen");
|
|
38
47
|
|
|
39
48
|
const session = makeSessionApi();
|
|
@@ -242,7 +242,11 @@ describe("MfaSetupPreauthScreen", () => {
|
|
|
242
242
|
expect(confirm.disabled).toBe(false);
|
|
243
243
|
});
|
|
244
244
|
|
|
245
|
-
|
|
245
|
+
// #1470: the banner told the user to "start over", but setup stayed
|
|
246
|
+
// non-null — the intro block (with the only "start setup" button) never
|
|
247
|
+
// came back, leaving the user stuck without a reload (which loses the
|
|
248
|
+
// preauthSetupToken entirely).
|
|
249
|
+
test("confirm schlägt mit invalid_setup_token fehl → Neustart-Pfad: Start-Button kommt zurück (#1470)", async () => {
|
|
246
250
|
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
247
251
|
const url = String(input);
|
|
248
252
|
if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
|
|
@@ -272,5 +276,104 @@ describe("MfaSetupPreauthScreen", () => {
|
|
|
272
276
|
screen.getByText("Die Einrichtung ist abgelaufen. Bitte erneut starten."),
|
|
273
277
|
).toBeTruthy();
|
|
274
278
|
});
|
|
279
|
+
// The actual fix: intro re-appears with a working start button, the QR/
|
|
280
|
+
// recovery-codes section from the dead setup is gone.
|
|
281
|
+
expect(screen.getByRole("button", { name: "Einrichtung starten" })).toBeTruthy();
|
|
282
|
+
expect(screen.queryByText("ABCD1234")).toBeNull();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("confirm schlägt mit invalid_challenge_token fehl → auch Neustart-Pfad (#1470)", async () => {
|
|
286
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
287
|
+
const url = String(input);
|
|
288
|
+
if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
|
|
289
|
+
if (url.includes("preauth-confirm")) {
|
|
290
|
+
return jsonResponse({ isSuccess: false, error: "invalid_challenge_token" });
|
|
291
|
+
}
|
|
292
|
+
return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
|
|
293
|
+
}) as unknown as typeof fetch;
|
|
294
|
+
|
|
295
|
+
render(
|
|
296
|
+
<Wrapper>
|
|
297
|
+
<MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
|
|
298
|
+
</Wrapper>,
|
|
299
|
+
);
|
|
300
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
|
|
301
|
+
await waitFor(() => {
|
|
302
|
+
expect(screen.getByText("ABCD1234")).toBeTruthy();
|
|
303
|
+
});
|
|
304
|
+
fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
|
|
305
|
+
fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
|
|
306
|
+
target: { value: "123456" },
|
|
307
|
+
});
|
|
308
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
|
|
309
|
+
|
|
310
|
+
await waitFor(() => {
|
|
311
|
+
expect(screen.getByRole("button", { name: "Einrichtung starten" })).toBeTruthy();
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
// kumiko-framework#1470: confirmMfaSetupPreauth already extracts
|
|
316
|
+
// retryAfterSeconds from error.details (see mfa-client.test.ts), but the
|
|
317
|
+
// screen used to drop it and show a generic "too many attempts" banner
|
|
318
|
+
// with no wait-time. Pins that the seconds actually reach the UI.
|
|
319
|
+
test("confirm schlägt mit too_many_attempts fehl → Banner zeigt retryAfterSeconds (#1470)", async () => {
|
|
320
|
+
globalThis.fetch = mock(async (input: RequestInfo | URL) => {
|
|
321
|
+
const url = String(input);
|
|
322
|
+
if (url.includes("preauth-enable-start")) return jsonResponse(startBody);
|
|
323
|
+
if (url.includes("preauth-confirm")) {
|
|
324
|
+
return jsonResponse({
|
|
325
|
+
isSuccess: false,
|
|
326
|
+
error: { code: "too_many_attempts", details: { retryAfterSeconds: 42 } },
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
return jsonResponse({ isSuccess: false, error: "unexpected_url" }, 500);
|
|
330
|
+
}) as unknown as typeof fetch;
|
|
331
|
+
|
|
332
|
+
render(
|
|
333
|
+
<Wrapper>
|
|
334
|
+
<MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
|
|
335
|
+
</Wrapper>,
|
|
336
|
+
);
|
|
337
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
|
|
338
|
+
await waitFor(() => {
|
|
339
|
+
expect(screen.getByText("ABCD1234")).toBeTruthy();
|
|
340
|
+
});
|
|
341
|
+
fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
|
|
342
|
+
fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
|
|
343
|
+
target: { value: "123456" },
|
|
344
|
+
});
|
|
345
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung abschließen" }));
|
|
346
|
+
|
|
347
|
+
await waitFor(() => {
|
|
348
|
+
expect(
|
|
349
|
+
screen.getByText(
|
|
350
|
+
"Zu viele Fehlversuche. Bitte in 42 Sekunden erneut versuchen oder neu einloggen.",
|
|
351
|
+
),
|
|
352
|
+
).toBeTruthy();
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
// kumiko-framework#1470: server validates z.string().length(6) — a 7th
|
|
357
|
+
// pasted digit must not make the button look submittable, it would only
|
|
358
|
+
// 400 as invalid_body.
|
|
359
|
+
test("7-stelliger Code hält den Bestätigen-Button disabled", async () => {
|
|
360
|
+
render(
|
|
361
|
+
<Wrapper>
|
|
362
|
+
<MfaSetupPreauthScreen preauthSetupToken="preauth-1" accountLabel="user@example.com" />
|
|
363
|
+
</Wrapper>,
|
|
364
|
+
);
|
|
365
|
+
fireEvent.click(screen.getByRole("button", { name: "Einrichtung starten" }));
|
|
366
|
+
await waitFor(() => {
|
|
367
|
+
expect(screen.getByText("ABCD1234")).toBeTruthy();
|
|
368
|
+
});
|
|
369
|
+
fireEvent.click(screen.getByLabelText("Ich habe die Recovery-Codes gespeichert."));
|
|
370
|
+
fireEvent.change(screen.getByLabelText(/^Code aus der Authenticator-App/), {
|
|
371
|
+
target: { value: "1234567" },
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
const confirm = screen.getByRole("button", {
|
|
375
|
+
name: "Einrichtung abschließen",
|
|
376
|
+
}) as HTMLButtonElement;
|
|
377
|
+
expect(confirm.disabled).toBe(true);
|
|
275
378
|
});
|
|
276
379
|
});
|
|
@@ -25,12 +25,16 @@ function renderScreen(ui: ReactElement, session = makeSessionApi()): RenderResul
|
|
|
25
25
|
);
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const originalFetch = globalThis.fetch;
|
|
29
|
+
|
|
28
30
|
beforeEach(() => {
|
|
29
31
|
globalThis.fetch = mock(
|
|
30
32
|
async () => new Response(null, { status: 200 }),
|
|
31
33
|
) as unknown as typeof fetch;
|
|
32
34
|
});
|
|
33
|
-
afterEach(() => {
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
globalThis.fetch = originalFetch;
|
|
37
|
+
});
|
|
34
38
|
|
|
35
39
|
describe("MfaVerifyScreen", () => {
|
|
36
40
|
test("rendert Titel + Code-Feld + Submit-Button (de)", () => {
|
package/src/auth-mfa/web/i18n.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
20
20
|
"Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
|
|
21
21
|
"auth.mfa.errors.challengeExpired": "Die Anmeldung ist abgelaufen. Bitte erneut einloggen.",
|
|
22
22
|
"auth.mfa.errors.tooManyAttempts": "Zu viele Fehlversuche. Bitte erneut einloggen.",
|
|
23
|
+
"auth.mfa.errors.tooManyAttemptsWithSeconds":
|
|
24
|
+
"Zu viele Fehlversuche. Bitte in {seconds} Sekunden erneut versuchen oder neu einloggen.",
|
|
23
25
|
"auth.mfa.errors.verifyFailed": "Bestätigung fehlgeschlagen.",
|
|
24
26
|
"auth.mfa.errors.mfaAlreadyEnabled": "Zwei-Faktor-Authentifizierung ist bereits aktiv.",
|
|
25
27
|
"auth.mfa.errors.mfaNotEnabled": "Zwei-Faktor-Authentifizierung ist nicht aktiv.",
|
|
@@ -80,6 +82,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
80
82
|
"auth.mfa.errors.invalidChallengeToken": "Your sign-in has expired. Please sign in again.",
|
|
81
83
|
"auth.mfa.errors.challengeExpired": "Your sign-in has expired. Please sign in again.",
|
|
82
84
|
"auth.mfa.errors.tooManyAttempts": "Too many failed attempts. Please sign in again.",
|
|
85
|
+
"auth.mfa.errors.tooManyAttemptsWithSeconds":
|
|
86
|
+
"Too many failed attempts. Try again in {seconds} seconds, or sign in again.",
|
|
83
87
|
"auth.mfa.errors.verifyFailed": "Verification failed.",
|
|
84
88
|
"auth.mfa.errors.mfaAlreadyEnabled": "Two-factor authentication is already enabled.",
|
|
85
89
|
"auth.mfa.errors.mfaNotEnabled": "Two-factor authentication is not enabled.",
|
|
@@ -93,7 +93,13 @@ export async function startMfaSetupPreauth(
|
|
|
93
93
|
setupToken?: string;
|
|
94
94
|
otpauthUri?: string;
|
|
95
95
|
recoveryCodes?: readonly string[];
|
|
96
|
-
error?:
|
|
96
|
+
error?:
|
|
97
|
+
| {
|
|
98
|
+
code?: string;
|
|
99
|
+
message?: string;
|
|
100
|
+
details?: { reason?: string; retryAfterSeconds?: number };
|
|
101
|
+
}
|
|
102
|
+
| string;
|
|
97
103
|
};
|
|
98
104
|
if (
|
|
99
105
|
body.isSuccess === true &&
|
|
@@ -115,9 +121,14 @@ export async function startMfaSetupPreauth(
|
|
|
115
121
|
return { kind: "failure", error: { reason: err } };
|
|
116
122
|
}
|
|
117
123
|
const reason = err?.details?.reason ?? err?.code ?? "setup_failed";
|
|
124
|
+
const retry = err?.details?.retryAfterSeconds;
|
|
118
125
|
return {
|
|
119
126
|
kind: "failure",
|
|
120
|
-
error: {
|
|
127
|
+
error: {
|
|
128
|
+
reason,
|
|
129
|
+
...(err?.message !== undefined && { message: err.message }),
|
|
130
|
+
...(retry !== undefined && { retryAfterSeconds: retry }),
|
|
131
|
+
},
|
|
121
132
|
};
|
|
122
133
|
}
|
|
123
134
|
|
|
@@ -161,7 +161,7 @@ export function MfaEnableScreen({
|
|
|
161
161
|
variant="primary"
|
|
162
162
|
onClick={() => void confirmSetup()}
|
|
163
163
|
loading={busy}
|
|
164
|
-
disabled={busy || !acknowledged || code.length
|
|
164
|
+
disabled={busy || !acknowledged || code.length !== 6}
|
|
165
165
|
>
|
|
166
166
|
{t("auth.mfa.enable.confirm")}
|
|
167
167
|
</Button>
|
|
@@ -85,14 +85,17 @@ export function MfaSetupPreauthScreen({
|
|
|
85
85
|
const [code, setCode] = useState("");
|
|
86
86
|
const [busy, setBusy] = useState(false);
|
|
87
87
|
const [error, setError] = useState<string | null>(null);
|
|
88
|
+
const [retryAfterSeconds, setRetryAfterSeconds] = useState<number | null>(null);
|
|
88
89
|
|
|
89
90
|
const startSetup = async (): Promise<void> => {
|
|
90
91
|
setBusy(true);
|
|
91
92
|
setError(null);
|
|
93
|
+
setRetryAfterSeconds(null);
|
|
92
94
|
try {
|
|
93
95
|
const res = await startMfaSetupPreauth(preauthSetupToken, accountLabel);
|
|
94
96
|
if (res.kind !== "success") {
|
|
95
97
|
setError(res.error.reason);
|
|
98
|
+
setRetryAfterSeconds(res.error.retryAfterSeconds ?? null);
|
|
96
99
|
return;
|
|
97
100
|
}
|
|
98
101
|
// errorCorrectionLevel "H" (~30% redundancy) — more resilient to
|
|
@@ -121,10 +124,24 @@ export function MfaSetupPreauthScreen({
|
|
|
121
124
|
if (!setup) return;
|
|
122
125
|
setBusy(true);
|
|
123
126
|
setError(null);
|
|
127
|
+
setRetryAfterSeconds(null);
|
|
124
128
|
try {
|
|
125
129
|
const res = await confirmMfaSetupPreauth(setup.setupToken, code);
|
|
126
130
|
if (res.kind !== "success") {
|
|
127
131
|
setError(res.error.reason);
|
|
132
|
+
setRetryAfterSeconds(res.error.retryAfterSeconds ?? null);
|
|
133
|
+
// An expired or already-burned setupToken can never succeed again —
|
|
134
|
+
// drop back to the intro so the "start setup" button reappears,
|
|
135
|
+
// otherwise the user is stuck on this screen with no way forward
|
|
136
|
+
// short of a full reload (which loses the preauthSetupToken).
|
|
137
|
+
if (
|
|
138
|
+
res.error.reason === "invalid_setup_token" ||
|
|
139
|
+
res.error.reason === "invalid_challenge_token"
|
|
140
|
+
) {
|
|
141
|
+
setSetup(null);
|
|
142
|
+
setAcknowledged(false);
|
|
143
|
+
setCode("");
|
|
144
|
+
}
|
|
128
145
|
return;
|
|
129
146
|
}
|
|
130
147
|
onSuccess?.();
|
|
@@ -146,7 +163,13 @@ export function MfaSetupPreauthScreen({
|
|
|
146
163
|
subtitle={subtitle ?? t("auth.mfa.setup.subtitle")}
|
|
147
164
|
>
|
|
148
165
|
<div className="p-6 pt-0 flex flex-col gap-4">
|
|
149
|
-
{error !== null ?
|
|
166
|
+
{error !== null ? (
|
|
167
|
+
<Banner variant="error">
|
|
168
|
+
{error === "too_many_attempts" && retryAfterSeconds !== null
|
|
169
|
+
? t("auth.mfa.errors.tooManyAttemptsWithSeconds", { seconds: retryAfterSeconds })
|
|
170
|
+
: t(reasonToKey(error))}
|
|
171
|
+
</Banner>
|
|
172
|
+
) : null}
|
|
150
173
|
|
|
151
174
|
{!setup && (
|
|
152
175
|
<Section
|
|
@@ -175,7 +198,7 @@ export function MfaSetupPreauthScreen({
|
|
|
175
198
|
type="submit"
|
|
176
199
|
variant="primary"
|
|
177
200
|
loading={busy}
|
|
178
|
-
disabled={busy || !acknowledged || code.length
|
|
201
|
+
disabled={busy || !acknowledged || code.length !== 6}
|
|
179
202
|
>
|
|
180
203
|
{t("auth.mfa.setup.confirm")}
|
|
181
204
|
</Button>
|
|
@@ -25,9 +25,19 @@ export type SubscriptionProviderContractFixture = {
|
|
|
25
25
|
};
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
export type SubscriptionProviderContractOptions = {
|
|
29
|
+
// Providers without portal/cancel support (e.g. Mollie) must say so
|
|
30
|
+
// explicitly — a bare `if (!plugin.x) return` inside the test body makes
|
|
31
|
+
// the skip invisible in test output (a coverage illusion: Stripe's
|
|
32
|
+
// portal/cancel tests and Mollie's "skip" both show as green) (#1339).
|
|
33
|
+
readonly hasPortal: boolean;
|
|
34
|
+
readonly hasCancel: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
28
37
|
export function describeSubscriptionProviderContract(
|
|
29
38
|
name: string,
|
|
30
39
|
factory: () => SubscriptionProviderContractFixture | Promise<SubscriptionProviderContractFixture>,
|
|
40
|
+
options: SubscriptionProviderContractOptions,
|
|
31
41
|
): void {
|
|
32
42
|
describe(`${name} — SubscriptionProviderPlugin contract`, () => {
|
|
33
43
|
test("implements verifyAndParseWebhook", async () => {
|
|
@@ -52,18 +62,32 @@ export function describeSubscriptionProviderContract(
|
|
|
52
62
|
expect(result.url.length).toBeGreaterThan(0);
|
|
53
63
|
});
|
|
54
64
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
65
|
+
(options.hasPortal ? test : test.skip)(
|
|
66
|
+
"createPortalSession returns a hosted-page url",
|
|
67
|
+
async () => {
|
|
68
|
+
const { plugin, ctx, portal } = await factory();
|
|
69
|
+
if (!plugin.createPortalSession || !portal) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"describeSubscriptionProviderContract: hasPortal:true but the fixture has no createPortalSession/portal",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
const result = await plugin.createPortalSession(ctx, portal);
|
|
75
|
+
expect(typeof result.url).toBe("string");
|
|
76
|
+
expect(result.url.length).toBeGreaterThan(0);
|
|
77
|
+
},
|
|
78
|
+
);
|
|
62
79
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
80
|
+
(options.hasCancel ? test : test.skip)(
|
|
81
|
+
"cancelSubscription resolves without throwing",
|
|
82
|
+
async () => {
|
|
83
|
+
const { plugin, ctx, cancelSubscriptionId } = await factory();
|
|
84
|
+
if (!plugin.cancelSubscription || !cancelSubscriptionId) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
"describeSubscriptionProviderContract: hasCancel:true but the fixture has no cancelSubscription/cancelSubscriptionId",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
await expect(plugin.cancelSubscription(ctx, cancelSubscriptionId)).resolves.toBeUndefined();
|
|
90
|
+
},
|
|
91
|
+
);
|
|
68
92
|
});
|
|
69
93
|
}
|
|
@@ -207,6 +207,10 @@ describe("config backing=secrets — fail-loud when secrets unwired", () => {
|
|
|
207
207
|
test("cascade read on backing=secrets without secretsReader throws", async () => {
|
|
208
208
|
// Write succeeds on the wired stack; then resolve via a bare resolver
|
|
209
209
|
// (no secretsReader) so readBackingSecret fails loud on the cascade path.
|
|
210
|
+
// Direct resolver call is deliberate here, not a bypass: stack.http always
|
|
211
|
+
// uses a properly-wired resolver (secretsReader present) — there's no
|
|
212
|
+
// HTTP path that reaches an unwired resolver, so this is the only way to
|
|
213
|
+
// exercise the missing-secretsReader failure mode (kumiko-framework#1437).
|
|
210
214
|
await stack.http.writeOk(
|
|
211
215
|
ConfigHandlers.set,
|
|
212
216
|
{ key: API_KEY, value: "sk-live-planted", scope: "system" },
|
|
@@ -528,8 +528,19 @@ describe("config:query:cascade handler", () => {
|
|
|
528
528
|
});
|
|
529
529
|
});
|
|
530
530
|
|
|
531
|
-
// getAll is
|
|
532
|
-
//
|
|
531
|
+
// getAll is public ConfigResolver surface (packages/types/src/config.ts)
|
|
532
|
+
// for app authors that need every config key for a scope at once (bulk
|
|
533
|
+
// export/backup tooling, admin dashboards) — NOT wired to any handler in
|
|
534
|
+
// this repo. `resolver.getAll(...)` is the ONLY call site in the whole
|
|
535
|
+
// codebase (verified via grep, kumiko-framework#1529); there is no handler
|
|
536
|
+
// to route this through, so calling it directly isn't the
|
|
537
|
+
// handfed-context/createTestDispatcher anti-pattern the "echte HTTP-Calls"
|
|
538
|
+
// rule targets — that rule is about bypassing a REAL handler's dispatch
|
|
539
|
+
// path, and none exists here to bypass. This deliberately stays in the
|
|
540
|
+
// .integration.test.ts file rather than a standalone .test.ts because it
|
|
541
|
+
// shares the cascadeFeature/seed fixture above (duplicating that ~100-line
|
|
542
|
+
// setup just to rename the file would cost more than it buys — see
|
|
543
|
+
// kumiko-framework#1437).
|
|
533
544
|
describe("getAll", () => {
|
|
534
545
|
test("getAll picks the most specific row (user > tenant > system)", async () => {
|
|
535
546
|
await stack.http.writeOk(
|
|
@@ -141,10 +141,10 @@ describe("piiEncrypted config keys", () => {
|
|
|
141
141
|
});
|
|
142
142
|
|
|
143
143
|
test("write without a configured KMS fails loud instead of silently storing plaintext", async () => {
|
|
144
|
-
resetPiiSubjectKmsForTests();
|
|
145
|
-
expect(configuredPiiSubjectKms()).toBeUndefined();
|
|
146
|
-
|
|
147
144
|
try {
|
|
145
|
+
resetPiiSubjectKmsForTests();
|
|
146
|
+
expect(configuredPiiSubjectKms()).toBeUndefined();
|
|
147
|
+
|
|
148
148
|
const res = await stack.http.write(
|
|
149
149
|
ConfigHandlers.set,
|
|
150
150
|
{ key: BILLING_ADDRESS_KEY, value: "no-kms-value" },
|
|
@@ -166,10 +166,10 @@ describe("piiEncrypted config keys", () => {
|
|
|
166
166
|
tenantAdmin,
|
|
167
167
|
);
|
|
168
168
|
|
|
169
|
-
resetPiiSubjectKmsForTests();
|
|
170
|
-
expect(configuredPiiSubjectKms()).toBeUndefined();
|
|
171
|
-
|
|
172
169
|
try {
|
|
170
|
+
resetPiiSubjectKmsForTests();
|
|
171
|
+
expect(configuredPiiSubjectKms()).toBeUndefined();
|
|
172
|
+
|
|
173
173
|
const res = await stack.http.query(ConfigQueries.values, {}, tenantAdmin);
|
|
174
174
|
expect(res.status).toBe(500);
|
|
175
175
|
} finally {
|