@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.
- package/package.json +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +20 -0
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +3 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +22 -7
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +4 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Multiplicity boot-check for auth-foundation's `tokenVerifier` extension
|
|
2
|
+
// point (#1368). Two static-shape conflicts a boot-check CAN catch (a
|
|
3
|
+
// runtime shape-match predicate's overlap can't be proven at boot): a
|
|
4
|
+
// malformed plugin registration, and two providers both claiming the same
|
|
5
|
+
// shape (resolveTokenVerifier can't tell them apart). Zero registered
|
|
6
|
+
// providers is ALSO caught here — fail-fast at boot ("you mounted the
|
|
7
|
+
// foundation but forgot a provider") rather than a runtime 401 nobody
|
|
8
|
+
// can attribute, unlike file-foundation/mail-foundation which defer that
|
|
9
|
+
// check to request-time (their provider is picked from tenant config,
|
|
10
|
+
// which doesn't exist at boot; auth-foundation's providers are static
|
|
11
|
+
// per-deployment, so boot is the right time to catch it).
|
|
12
|
+
|
|
13
|
+
import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import {
|
|
15
|
+
EXT_SESSION_STORE,
|
|
16
|
+
EXT_TOKEN_VERIFIER,
|
|
17
|
+
isAuthProviderPlugin,
|
|
18
|
+
isSessionStoreProvider,
|
|
19
|
+
tokenShapeKey,
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
export function validateTokenVerifierMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
23
|
+
const namesByShape = new Map<string, string[]>();
|
|
24
|
+
|
|
25
|
+
for (const feature of features) {
|
|
26
|
+
for (const usage of feature.extensionUsages) {
|
|
27
|
+
if (usage.extensionName !== EXT_TOKEN_VERIFIER) continue;
|
|
28
|
+
if (!isAuthProviderPlugin(usage.options)) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
`[auth-foundation] tokenVerifier provider "${usage.entityName}" (feature "${feature.name}") ` +
|
|
31
|
+
`registered without a valid AuthProviderPlugin — options must have a { shape, build } shape.`,
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
const key = tokenShapeKey(usage.options.shape);
|
|
35
|
+
const names = namesByShape.get(key) ?? [];
|
|
36
|
+
names.push(usage.entityName);
|
|
37
|
+
namesByShape.set(key, names);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (namesByShape.size === 0) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"[auth-foundation] no tokenVerifier providers registered — mount at least one " +
|
|
44
|
+
"auth-provider-* feature (e.g. auth-provider-jwt) alongside auth-foundation.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [key, names] of namesByShape) {
|
|
49
|
+
if (names.length >= 2) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`[auth-foundation] ${names.length} tokenVerifier providers claim the same shape "${key}" ` +
|
|
52
|
+
`(${names.join(", ")}) — resolveTokenVerifier can't route between them unambiguously. ` +
|
|
53
|
+
`Give each provider a distinct shape.`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Multiplicity boot-check for the `sessionStore` extension point (#1370).
|
|
60
|
+
// Single-provider, unlike tokenVerifier — no shape to route on, so exactly
|
|
61
|
+
// one registration is required; 0 or ≥2 both fail boot.
|
|
62
|
+
export function validateSessionStoreMultiplicity(features: readonly FeatureDefinition[]): void {
|
|
63
|
+
const names: string[] = [];
|
|
64
|
+
|
|
65
|
+
for (const feature of features) {
|
|
66
|
+
for (const usage of feature.extensionUsages) {
|
|
67
|
+
if (usage.extensionName !== EXT_SESSION_STORE) continue;
|
|
68
|
+
if (!isSessionStoreProvider(usage.options)) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`[auth-foundation] sessionStore provider "${usage.entityName}" (feature "${feature.name}") ` +
|
|
71
|
+
`registered without a valid SessionStoreProvider — options must have a { build } shape.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
names.push(usage.entityName);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (names.length === 0) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
"[auth-foundation] no sessionStore provider registered — mount a feature that " +
|
|
81
|
+
"registers one via r.useExtension(EXT_SESSION_STORE, ...) alongside auth-foundation.",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (names.length >= 2) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`[auth-foundation] ${names.length} sessionStore providers registered (${names.join(", ")}) — ` +
|
|
88
|
+
`only one sessionStore provider may be mounted at a time.`,
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// auth-foundation — declares the `tokenVerifier` extension point for Bearer-
|
|
4
|
+
// token verification. Provider-features (auth-provider-jwt, auth-provider-
|
|
5
|
+
// pat, ...) register via `r.useExtension(EXT_TOKEN_VERIFIER, "<name>", plugin)`.
|
|
6
|
+
//
|
|
7
|
+
// **Scaffold only:** not wired into `api/auth-middleware.ts` yet —
|
|
8
|
+
// personal-access-tokens still uses its own direct `patResolver` callback
|
|
9
|
+
// (kumiko-framework#745). A future issue migrates the middleware onto this
|
|
10
|
+
// extension point; #1368 only lays the groundwork (PAT-Plan-Doc Phase-1-
|
|
11
|
+
// Sub-Step-1).
|
|
12
|
+
//
|
|
13
|
+
// **Pattern precedent:** file-foundation/mail-foundation for the extension-
|
|
14
|
+
// point + plugin-registry shape. Diverges on ROUTING: those pick one active
|
|
15
|
+
// provider per tenant via a config-key; auth-foundation has no tenant yet
|
|
16
|
+
// at verify-time, so providers are tried in shape-match order instead (see
|
|
17
|
+
// types.ts).
|
|
18
|
+
|
|
19
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { defineFeature, type Registry } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import { validateSessionStoreMultiplicity, validateTokenVerifierMultiplicity } from "./boot-checks";
|
|
22
|
+
import {
|
|
23
|
+
type AuthProviderBuildDeps,
|
|
24
|
+
EXT_SESSION_STORE,
|
|
25
|
+
EXT_TOKEN_VERIFIER,
|
|
26
|
+
isAuthProviderPlugin,
|
|
27
|
+
isSessionStoreProvider,
|
|
28
|
+
type SessionStore,
|
|
29
|
+
tokenShapeMatches,
|
|
30
|
+
} from "./types";
|
|
31
|
+
|
|
32
|
+
export { EXT_SESSION_STORE, EXT_TOKEN_VERIFIER } from "./types";
|
|
33
|
+
|
|
34
|
+
const FEATURE_NAME = "auth-foundation";
|
|
35
|
+
|
|
36
|
+
export const authFoundationFeature = defineFeature(FEATURE_NAME, (r) => {
|
|
37
|
+
r.describe(
|
|
38
|
+
"Declares the `tokenVerifier` (Bearer-token auth) and `sessionStore` extension points. Provider-features register a static `shape` plus a `build()` for tokenVerifier — mount together with at least one auth-provider-* feature (e.g. personal-access-tokens) and call `resolveTokenVerifier(deps, rawToken)` to find the matching one; sessions self-registers as the single sessionStore provider.",
|
|
39
|
+
);
|
|
40
|
+
r.uiHints({
|
|
41
|
+
displayLabel: "Auth Provider Foundation",
|
|
42
|
+
category: "identity",
|
|
43
|
+
recommended: false,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
r.extendsRegistrar(EXT_TOKEN_VERIFIER, {
|
|
47
|
+
onRegister: () => {
|
|
48
|
+
// No side-effects at register-time — registry stores the usage,
|
|
49
|
+
// resolveTokenVerifier looks it up at request-time.
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Owns EXT_TOKEN_VERIFIER, so its own mount is the trigger — same
|
|
54
|
+
// convention as tenant-lifecycle/user-data-rights' self-bootCheck.
|
|
55
|
+
r.bootCheck(({ features }) => validateTokenVerifierMultiplicity(features));
|
|
56
|
+
|
|
57
|
+
r.extendsRegistrar(EXT_SESSION_STORE, {
|
|
58
|
+
onRegister: () => {
|
|
59
|
+
// No side-effects at register-time — registry stores the usage,
|
|
60
|
+
// resolveSessionStore looks it up at request-time.
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
r.bootCheck(({ features }) => validateSessionStoreMultiplicity(features));
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Tries every registered tokenVerifier provider's shape against `rawToken`
|
|
69
|
+
* in registration order and calls the first match's verifier. Returns null
|
|
70
|
+
* when no provider's shape matches, or when the matching provider's
|
|
71
|
+
* verifier rejects the token.
|
|
72
|
+
*/
|
|
73
|
+
export async function resolveTokenVerifier(
|
|
74
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
75
|
+
rawToken: string,
|
|
76
|
+
): Promise<SessionUser | null> {
|
|
77
|
+
const usages = deps.registry.getExtensionUsages(EXT_TOKEN_VERIFIER);
|
|
78
|
+
for (const usage of usages) {
|
|
79
|
+
if (!isAuthProviderPlugin(usage.options)) continue;
|
|
80
|
+
if (!tokenShapeMatches(usage.options.shape, rawToken)) continue;
|
|
81
|
+
const verify = await usage.options.build(deps);
|
|
82
|
+
return verify(rawToken);
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves the single registered sessionStore provider. Assumes
|
|
89
|
+
* validateSessionStoreMultiplicity already ran at boot — a missing provider
|
|
90
|
+
* here means the app booted without going through the framework's boot
|
|
91
|
+
* validator, not a runtime state resolveSessionStore should model.
|
|
92
|
+
*/
|
|
93
|
+
export async function resolveSessionStore(
|
|
94
|
+
deps: AuthProviderBuildDeps & { readonly registry: Registry },
|
|
95
|
+
): Promise<SessionStore> {
|
|
96
|
+
const usage = deps.registry.getExtensionUsages(EXT_SESSION_STORE)[0];
|
|
97
|
+
if (!usage || !isSessionStoreProvider(usage.options)) {
|
|
98
|
+
throw new Error(
|
|
99
|
+
"[auth-foundation] no sessionStore provider registered — did the boot validator run?",
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return usage.options.build(deps);
|
|
103
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Public API of the auth-foundation bundled-feature.
|
|
2
|
+
|
|
3
|
+
export {
|
|
4
|
+
authFoundationFeature,
|
|
5
|
+
resolveSessionStore,
|
|
6
|
+
resolveTokenVerifier,
|
|
7
|
+
} from "./feature";
|
|
8
|
+
export {
|
|
9
|
+
type AuthProviderBuildDeps,
|
|
10
|
+
type AuthProviderPlugin,
|
|
11
|
+
type AuthVerifier,
|
|
12
|
+
EXT_SESSION_STORE,
|
|
13
|
+
EXT_TOKEN_VERIFIER,
|
|
14
|
+
isAuthProviderPlugin,
|
|
15
|
+
isSessionStoreProvider,
|
|
16
|
+
isValidTokenShape,
|
|
17
|
+
type SessionMassRevoker,
|
|
18
|
+
type SessionStore,
|
|
19
|
+
type SessionStoreProvider,
|
|
20
|
+
type TokenShape,
|
|
21
|
+
tokenShapeKey,
|
|
22
|
+
tokenShapeMatches,
|
|
23
|
+
} from "./types";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// kumiko-feature-version: 1
|
|
2
|
+
//
|
|
3
|
+
// Type contracts for auth-foundation's `tokenVerifier` extension point.
|
|
4
|
+
// Provider-features (future: auth-provider-jwt, auth-provider-pat) register
|
|
5
|
+
// an AuthProviderPlugin via `r.useExtension(EXT_TOKEN_VERIFIER, "<name>", plugin)`.
|
|
6
|
+
//
|
|
7
|
+
// **Shape-match routing:** unlike file-foundation/mail-foundation (one active
|
|
8
|
+
// provider per tenant, chosen via a config-key), token verification happens
|
|
9
|
+
// BEFORE the tenant is known — there's nothing to select on. Every registered
|
|
10
|
+
// provider declares a static `shape` (how to recognize its own tokens by
|
|
11
|
+
// looking at the raw bearer value); `resolveTokenVerifier` tries each
|
|
12
|
+
// provider's shape in turn and calls the first match's verifier.
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
SessionChecker,
|
|
16
|
+
SessionCreator,
|
|
17
|
+
SessionRevoker,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/api";
|
|
19
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
20
|
+
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
|
|
22
|
+
export const EXT_TOKEN_VERIFIER = "tokenVerifier";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Deps available to a provider's `build()`. DB access only — a hot-path
|
|
26
|
+
* resolver (see personal-access-tokens/resolver.ts) needs a raw-DB handle
|
|
27
|
+
* for a point-read + live-role-resolution, not a per-request ctx (there is
|
|
28
|
+
* no tenant/session yet at verify-time).
|
|
29
|
+
*/
|
|
30
|
+
export type AuthProviderBuildDeps = {
|
|
31
|
+
readonly db: DbConnection;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** How to recognize this provider's tokens by looking at the raw bearer value. */
|
|
35
|
+
export type TokenShape =
|
|
36
|
+
| { readonly kind: "jwt" }
|
|
37
|
+
| { readonly kind: "prefix"; readonly prefix: string };
|
|
38
|
+
|
|
39
|
+
export type AuthVerifier = (rawToken: string) => Promise<SessionUser | null>;
|
|
40
|
+
|
|
41
|
+
export type AuthProviderPlugin = {
|
|
42
|
+
readonly shape: TokenShape;
|
|
43
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<AuthVerifier> | AuthVerifier;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export function isValidTokenShape(shape: unknown): shape is TokenShape {
|
|
47
|
+
if (typeof shape !== "object" || shape === null || !("kind" in shape)) return false;
|
|
48
|
+
const kind = (shape as { kind: unknown }).kind;
|
|
49
|
+
if (kind === "jwt") return true;
|
|
50
|
+
if (kind === "prefix") {
|
|
51
|
+
const prefix = "prefix" in shape ? (shape as { prefix: unknown }).prefix : undefined;
|
|
52
|
+
return typeof prefix === "string" && prefix.length > 0;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// extension-usage `options` is engine-payload (unknown) — structurally validate
|
|
58
|
+
// instead of casting blind. Mirrors file-foundation's isFileProviderPlugin.
|
|
59
|
+
export function isAuthProviderPlugin(o: unknown): o is AuthProviderPlugin {
|
|
60
|
+
return (
|
|
61
|
+
typeof o === "object" &&
|
|
62
|
+
o !== null &&
|
|
63
|
+
"build" in o &&
|
|
64
|
+
typeof (o as { build: unknown }).build === "function" &&
|
|
65
|
+
"shape" in o &&
|
|
66
|
+
isValidTokenShape((o as { shape: unknown }).shape)
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function tokenShapeMatches(shape: TokenShape, rawToken: string): boolean {
|
|
71
|
+
if (shape.kind === "prefix") return rawToken.startsWith(shape.prefix);
|
|
72
|
+
return rawToken.split(".").length === 3;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Static discriminator used by the multiplicity boot-check to detect two providers claiming the same shape. */
|
|
76
|
+
export function tokenShapeKey(shape: TokenShape): string {
|
|
77
|
+
return shape.kind === "jwt" ? "jwt" : `prefix:${shape.prefix}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Session-store extension point (#1370). Unlike EXT_TOKEN_VERIFIER, this is
|
|
81
|
+
// single-provider — sessions have no per-provider shape to route on, so
|
|
82
|
+
// exactly one implementation must be registered (boot-fails on 0 or ≥2).
|
|
83
|
+
|
|
84
|
+
export const EXT_SESSION_STORE = "sessionStore";
|
|
85
|
+
|
|
86
|
+
/** Mass-revoke every live session for a user. Used by password-change and "sign out everywhere". */
|
|
87
|
+
export type SessionMassRevoker = (userId: string) => Promise<number>;
|
|
88
|
+
|
|
89
|
+
export type SessionStore = {
|
|
90
|
+
readonly creator: SessionCreator;
|
|
91
|
+
readonly revoker: SessionRevoker;
|
|
92
|
+
readonly checker: SessionChecker;
|
|
93
|
+
readonly massRevoker: SessionMassRevoker;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
export type SessionStoreProvider = {
|
|
97
|
+
readonly build: (deps: AuthProviderBuildDeps) => Promise<SessionStore> | SessionStore;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export function isSessionStoreProvider(o: unknown): o is SessionStoreProvider {
|
|
101
|
+
return (
|
|
102
|
+
typeof o === "object" &&
|
|
103
|
+
o !== null &&
|
|
104
|
+
"build" in o &&
|
|
105
|
+
typeof (o as { build: unknown }).build === "function"
|
|
106
|
+
);
|
|
107
|
+
}
|
|
@@ -28,4 +28,12 @@ describe("auth-mfa errors.ts i18nKey / client-bundle parity", () => {
|
|
|
28
28
|
expect(defaultTranslations["de"]?.[key]).toBeDefined();
|
|
29
29
|
});
|
|
30
30
|
}
|
|
31
|
+
|
|
32
|
+
// Client-only key: mfa-enable-screen.tsx sets this directly (not via a
|
|
33
|
+
// DispatcherError), so it isn't covered by the failures loop above.
|
|
34
|
+
test("client-only auth.mfa.errors.setupFailed is registered in en/de translations", () => {
|
|
35
|
+
const key = "auth.mfa.errors.setupFailed";
|
|
36
|
+
expect(defaultTranslations["en"]?.[key]).toBeDefined();
|
|
37
|
+
expect(defaultTranslations["de"]?.[key]).toBeDefined();
|
|
38
|
+
});
|
|
31
39
|
});
|
|
@@ -272,4 +272,24 @@ 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
|
+
});
|
|
275
295
|
});
|
|
@@ -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
|
+
});
|