@cosmicdrift/kumiko-bundled-features 0.1.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 +90 -0
- package/src/audit/__tests__/audit.integration.ts +328 -0
- package/src/audit/constants.ts +7 -0
- package/src/audit/feature.ts +23 -0
- package/src/audit/handlers/list.query.ts +98 -0
- package/src/audit/index.ts +2 -0
- package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +149 -0
- package/src/auth-email-password/__tests__/account-lockout.integration.ts +308 -0
- package/src/auth-email-password/__tests__/auth-claims.integration.ts +512 -0
- package/src/auth-email-password/__tests__/auth.integration.ts +610 -0
- package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +67 -0
- package/src/auth-email-password/__tests__/email-templates.test.ts +106 -0
- package/src/auth-email-password/__tests__/email-verification.integration.ts +327 -0
- package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +174 -0
- package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +150 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.ts +458 -0
- package/src/auth-email-password/__tests__/multi-roles.integration.ts +256 -0
- package/src/auth-email-password/__tests__/password-reset.integration.ts +346 -0
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +144 -0
- package/src/auth-email-password/__tests__/seed-admin.integration.ts +176 -0
- package/src/auth-email-password/__tests__/session-callbacks.integration.ts +310 -0
- package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +101 -0
- package/src/auth-email-password/__tests__/signed-token.test.ts +78 -0
- package/src/auth-email-password/__tests__/signup-flow.integration.ts +259 -0
- package/src/auth-email-password/auth-user-row.ts +41 -0
- package/src/auth-email-password/constants.ts +101 -0
- package/src/auth-email-password/email-templates.ts +283 -0
- package/src/auth-email-password/feature.ts +140 -0
- package/src/auth-email-password/handlers/change-password.write.ts +58 -0
- package/src/auth-email-password/handlers/confirm-token-flow.ts +191 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +203 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +189 -0
- package/src/auth-email-password/handlers/invite-create.write.ts +145 -0
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +192 -0
- package/src/auth-email-password/handlers/login.write.ts +208 -0
- package/src/auth-email-password/handlers/logout.write.ts +12 -0
- package/src/auth-email-password/handlers/request-email-verification.write.ts +29 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +31 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +61 -0
- package/src/auth-email-password/handlers/signup-confirm.write.ts +170 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +104 -0
- package/src/auth-email-password/handlers/token-request-handler.ts +114 -0
- package/src/auth-email-password/handlers/verify-email.write.ts +62 -0
- package/src/auth-email-password/i18n.ts +211 -0
- package/src/auth-email-password/identity-v3-hash.ts +97 -0
- package/src/auth-email-password/index.ts +35 -0
- package/src/auth-email-password/invite-token-store.ts +92 -0
- package/src/auth-email-password/lockout-store.ts +118 -0
- package/src/auth-email-password/password-hashing.ts +43 -0
- package/src/auth-email-password/reset-token.ts +28 -0
- package/src/auth-email-password/seeding.ts +183 -0
- package/src/auth-email-password/signed-token.ts +85 -0
- package/src/auth-email-password/signup-token-store.ts +104 -0
- package/src/auth-email-password/stream-tenant.ts +31 -0
- package/src/auth-email-password/testing.ts +5 -0
- package/src/auth-email-password/token-burn-store.ts +57 -0
- package/src/auth-email-password/verification-token.ts +27 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +51 -0
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +80 -0
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +94 -0
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +108 -0
- package/src/auth-email-password/web/__tests__/session-roles.test.ts +54 -0
- package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +100 -0
- package/src/auth-email-password/web/__tests__/test-utils.tsx +73 -0
- package/src/auth-email-password/web/__tests__/user-menu.test.tsx +55 -0
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +59 -0
- package/src/auth-email-password/web/auth-client.ts +350 -0
- package/src/auth-email-password/web/auth-form-primitives.tsx +70 -0
- package/src/auth-email-password/web/auth-gate.tsx +33 -0
- package/src/auth-email-password/web/client-plugin.ts +48 -0
- package/src/auth-email-password/web/default-topbar-actions.tsx +47 -0
- package/src/auth-email-password/web/forgot-password-screen.tsx +110 -0
- package/src/auth-email-password/web/index.ts +56 -0
- package/src/auth-email-password/web/invite-accept-screen.tsx +220 -0
- package/src/auth-email-password/web/login-screen.tsx +150 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +152 -0
- package/src/auth-email-password/web/session.tsx +171 -0
- package/src/auth-email-password/web/signup-complete-screen.tsx +150 -0
- package/src/auth-email-password/web/signup-screen.tsx +130 -0
- package/src/auth-email-password/web/tenant-switcher.tsx +116 -0
- package/src/auth-email-password/web/use-shell-user.ts +34 -0
- package/src/auth-email-password/web/user-menu.tsx +89 -0
- package/src/auth-email-password/web/verify-email-screen.tsx +102 -0
- package/src/billing-foundation/__tests__/billing-foundation.integration.ts +568 -0
- package/src/billing-foundation/__tests__/feature.test.ts +110 -0
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +199 -0
- package/src/billing-foundation/aggregate-id.ts +21 -0
- package/src/billing-foundation/constants.ts +70 -0
- package/src/billing-foundation/entities.ts +50 -0
- package/src/billing-foundation/events.ts +71 -0
- package/src/billing-foundation/feature.ts +122 -0
- package/src/billing-foundation/get-subscription-for-tenant.ts +39 -0
- package/src/billing-foundation/handlers/create-checkout-session.write.ts +79 -0
- package/src/billing-foundation/handlers/create-portal-session.write.ts +73 -0
- package/src/billing-foundation/handlers/list-subscriptions.query.ts +20 -0
- package/src/billing-foundation/handlers/process-event.write.ts +160 -0
- package/src/billing-foundation/index.ts +42 -0
- package/src/billing-foundation/projection.ts +135 -0
- package/src/billing-foundation/types.ts +157 -0
- package/src/billing-foundation/webhook-handler.ts +184 -0
- package/src/cap-counter/__tests__/cap-counter.integration.ts +566 -0
- package/src/cap-counter/__tests__/enforce-cap.test.ts +422 -0
- package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +265 -0
- package/src/cap-counter/aggregate-id.ts +61 -0
- package/src/cap-counter/constants.ts +32 -0
- package/src/cap-counter/enforce-cap.ts +404 -0
- package/src/cap-counter/entity.ts +48 -0
- package/src/cap-counter/feature.ts +90 -0
- package/src/cap-counter/handlers/get-counter.query.ts +43 -0
- package/src/cap-counter/handlers/increment-rolling.write.ts +79 -0
- package/src/cap-counter/handlers/increment.write.ts +92 -0
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +57 -0
- package/src/cap-counter/index.ts +34 -0
- package/src/cap-counter/with-cap-enforcement.ts +179 -0
- package/src/channel-email/email-channel.ts +48 -0
- package/src/channel-email/feature.ts +15 -0
- package/src/channel-email/index.ts +4 -0
- package/src/channel-email/smtp-transport.ts +65 -0
- package/src/channel-email/types.ts +34 -0
- package/src/channel-in-app/constants.ts +11 -0
- package/src/channel-in-app/feature.ts +30 -0
- package/src/channel-in-app/handlers/inbox.query.ts +28 -0
- package/src/channel-in-app/handlers/mark-all-read.write.ts +21 -0
- package/src/channel-in-app/handlers/mark-read.write.ts +32 -0
- package/src/channel-in-app/handlers/unread-count.query.ts +20 -0
- package/src/channel-in-app/in-app-channel.ts +44 -0
- package/src/channel-in-app/index.ts +4 -0
- package/src/channel-in-app/tables.ts +22 -0
- package/src/channel-push/feature.ts +15 -0
- package/src/channel-push/index.ts +3 -0
- package/src/channel-push/push-channel.ts +33 -0
- package/src/channel-push/types.ts +22 -0
- package/src/config/__tests__/app-overrides.test.ts +118 -0
- package/src/config/__tests__/config.integration.ts +1246 -0
- package/src/config/constants.ts +23 -0
- package/src/config/feature.ts +117 -0
- package/src/config/handlers/__tests__/prepare-config-write.test.ts +209 -0
- package/src/config/handlers/reset.write.ts +45 -0
- package/src/config/handlers/schema.query.ts +22 -0
- package/src/config/handlers/set.write.ts +93 -0
- package/src/config/handlers/values.query.ts +43 -0
- package/src/config/index.ts +15 -0
- package/src/config/resolver.ts +283 -0
- package/src/config/table.ts +35 -0
- package/src/config/write-helpers.ts +268 -0
- package/src/delivery/__tests__/delivery-events.integration.ts +166 -0
- package/src/delivery/__tests__/delivery.integration.ts +1405 -0
- package/src/delivery/constants.ts +33 -0
- package/src/delivery/delivery-service.ts +489 -0
- package/src/delivery/events.ts +18 -0
- package/src/delivery/feature.ts +70 -0
- package/src/delivery/handlers/log.query.ts +21 -0
- package/src/delivery/handlers/preferences.query.ts +18 -0
- package/src/delivery/handlers/set-preference.write.ts +28 -0
- package/src/delivery/index.ts +35 -0
- package/src/delivery/tables.ts +74 -0
- package/src/delivery/testing.ts +47 -0
- package/src/delivery/types.ts +71 -0
- package/src/delivery/unsubscribe.ts +99 -0
- package/src/delivery/upsert-preference.ts +145 -0
- package/src/feature-toggles/__tests__/feature-toggles.integration.ts +687 -0
- package/src/feature-toggles/constants.ts +20 -0
- package/src/feature-toggles/events.ts +18 -0
- package/src/feature-toggles/feature.ts +98 -0
- package/src/feature-toggles/global-feature-state-table.ts +28 -0
- package/src/feature-toggles/handlers/list.query.ts +26 -0
- package/src/feature-toggles/handlers/registered.query.ts +56 -0
- package/src/feature-toggles/handlers/set.write.ts +158 -0
- package/src/feature-toggles/index.ts +9 -0
- package/src/feature-toggles/toggle-runtime.ts +73 -0
- package/src/file-foundation/__tests__/feature.test.ts +35 -0
- package/src/file-foundation/__tests__/file-foundation.integration.ts +235 -0
- package/src/file-foundation/feature.ts +123 -0
- package/src/file-foundation/index.ts +7 -0
- package/src/file-provider-inmemory/__tests__/feature.test.ts +35 -0
- package/src/file-provider-inmemory/feature.ts +73 -0
- package/src/file-provider-inmemory/index.ts +3 -0
- package/src/file-provider-s3/__tests__/feature.test.ts +54 -0
- package/src/file-provider-s3/feature.ts +169 -0
- package/src/file-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/__tests__/env-helper.test.ts +161 -0
- package/src/files-provider-s3/__tests__/s3-provider.integration.ts +134 -0
- package/src/files-provider-s3/__tests__/s3-provider.test.ts +36 -0
- package/src/files-provider-s3/env-helper.ts +49 -0
- package/src/files-provider-s3/index.ts +3 -0
- package/src/files-provider-s3/s3-provider.ts +114 -0
- package/src/foundation-shared/config-helpers.ts +67 -0
- package/src/foundation-shared/index.ts +4 -0
- package/src/jobs/__tests__/job-system-user.integration.ts +194 -0
- package/src/jobs/__tests__/jobs-events.integration.ts +143 -0
- package/src/jobs/__tests__/jobs-feature.integration.ts +342 -0
- package/src/jobs/constants.ts +21 -0
- package/src/jobs/events.ts +39 -0
- package/src/jobs/feature.ts +150 -0
- package/src/jobs/handlers/detail.query.ts +30 -0
- package/src/jobs/handlers/list.query.ts +36 -0
- package/src/jobs/handlers/retry.write.ts +69 -0
- package/src/jobs/handlers/trigger.write.ts +39 -0
- package/src/jobs/index.ts +5 -0
- package/src/jobs/job-run-logger.ts +213 -0
- package/src/jobs/job-run-table.ts +55 -0
- package/src/legal-pages/README.md +195 -0
- package/src/legal-pages/__tests__/legal-pages.integration.ts +361 -0
- package/src/legal-pages/constants.ts +36 -0
- package/src/legal-pages/feature.ts +187 -0
- package/src/legal-pages/index.ts +13 -0
- package/src/legal-pages/markdown.ts +69 -0
- package/src/mail-foundation/__tests__/feature.test.ts +46 -0
- package/src/mail-foundation/__tests__/mail-foundation.integration.ts +247 -0
- package/src/mail-foundation/feature.ts +160 -0
- package/src/mail-foundation/index.ts +14 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +37 -0
- package/src/mail-transport-inmemory/feature.ts +90 -0
- package/src/mail-transport-inmemory/index.ts +3 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +61 -0
- package/src/mail-transport-smtp/feature.ts +182 -0
- package/src/mail-transport-smtp/index.ts +3 -0
- package/src/rate-limiting/__tests__/rate-limiting.integration.ts +84 -0
- package/src/rate-limiting/constants.ts +9 -0
- package/src/rate-limiting/feature.ts +16 -0
- package/src/rate-limiting/handlers/status.query.ts +52 -0
- package/src/rate-limiting/index.ts +2 -0
- package/src/renderer-simple/__tests__/simple-renderer.test.ts +97 -0
- package/src/renderer-simple/feature.ts +12 -0
- package/src/renderer-simple/index.ts +2 -0
- package/src/renderer-simple/simple-renderer.ts +72 -0
- package/src/secrets/__tests__/rotate.integration.ts +176 -0
- package/src/secrets/__tests__/secrets-events.integration.ts +125 -0
- package/src/secrets/__tests__/secrets.integration.ts +118 -0
- package/src/secrets/feature.ts +84 -0
- package/src/secrets/handlers/delete.write.ts +20 -0
- package/src/secrets/handlers/list.query.ts +38 -0
- package/src/secrets/handlers/rotate.job.ts +193 -0
- package/src/secrets/handlers/set.write.ts +50 -0
- package/src/secrets/index.ts +16 -0
- package/src/secrets/secrets-context.ts +296 -0
- package/src/secrets/table.ts +68 -0
- package/src/sessions/__tests__/cleanup.integration.ts +175 -0
- package/src/sessions/__tests__/password-auto-revoke.integration.ts +202 -0
- package/src/sessions/__tests__/sessions.integration.ts +472 -0
- package/src/sessions/__tests__/test-helpers.ts +66 -0
- package/src/sessions/constants.ts +43 -0
- package/src/sessions/feature.ts +84 -0
- package/src/sessions/handlers/cleanup.job.ts +109 -0
- package/src/sessions/handlers/list.query.ts +35 -0
- package/src/sessions/handlers/mine.query.ts +37 -0
- package/src/sessions/handlers/revoke-all-others.write.ts +42 -0
- package/src/sessions/handlers/revoke.write.ts +76 -0
- package/src/sessions/index.ts +17 -0
- package/src/sessions/schema/index.ts +5 -0
- package/src/sessions/schema/user-session.ts +67 -0
- package/src/sessions/session-callbacks.ts +110 -0
- package/src/sessions/testing.ts +42 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +106 -0
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +421 -0
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +388 -0
- package/src/subscription-mollie/constants.ts +33 -0
- package/src/subscription-mollie/feature.ts +144 -0
- package/src/subscription-mollie/index.ts +13 -0
- package/src/subscription-mollie/plugin-methods.ts +79 -0
- package/src/subscription-mollie/verify-webhook.ts +244 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +98 -0
- package/src/subscription-stripe/__tests__/plugin-methods.test.ts +161 -0
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +315 -0
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +306 -0
- package/src/subscription-stripe/constants.ts +20 -0
- package/src/subscription-stripe/feature.ts +120 -0
- package/src/subscription-stripe/index.ts +14 -0
- package/src/subscription-stripe/plugin-methods.ts +91 -0
- package/src/subscription-stripe/verify-webhook.ts +235 -0
- package/src/tenant/__tests__/multi-tenant.integration.ts +278 -0
- package/src/tenant/__tests__/seed-testing.integration.ts +229 -0
- package/src/tenant/__tests__/tenant.integration.ts +347 -0
- package/src/tenant/command-schemas.ts +37 -0
- package/src/tenant/constants.ts +37 -0
- package/src/tenant/feature.ts +109 -0
- package/src/tenant/handlers/active-tenant-ids.query.ts +19 -0
- package/src/tenant/handlers/add-member.write.ts +53 -0
- package/src/tenant/handlers/cancel-invitation.write.ts +87 -0
- package/src/tenant/handlers/create.write.ts +21 -0
- package/src/tenant/handlers/disable.write.ts +18 -0
- package/src/tenant/handlers/invitations.query.ts +31 -0
- package/src/tenant/handlers/list.query.ts +17 -0
- package/src/tenant/handlers/me.query.ts +17 -0
- package/src/tenant/handlers/members.query.ts +22 -0
- package/src/tenant/handlers/memberships.query.ts +24 -0
- package/src/tenant/handlers/remove-member.write.ts +40 -0
- package/src/tenant/handlers/resolve-user-ids.query.ts +43 -0
- package/src/tenant/handlers/update-member-roles.write.ts +54 -0
- package/src/tenant/handlers/update.write.ts +20 -0
- package/src/tenant/index.ts +12 -0
- package/src/tenant/invitation-table.ts +93 -0
- package/src/tenant/membership-table.ts +35 -0
- package/src/tenant/schema/index.ts +5 -0
- package/src/tenant/schema/tenant.ts +27 -0
- package/src/tenant/seeding.ts +155 -0
- package/src/tenant/testing.ts +8 -0
- package/src/text-content/README.md +190 -0
- package/src/text-content/__tests__/text-content.integration.ts +415 -0
- package/src/text-content/api.ts +92 -0
- package/src/text-content/constants.ts +19 -0
- package/src/text-content/feature.ts +29 -0
- package/src/text-content/handlers/by-slug.query.ts +55 -0
- package/src/text-content/handlers/set.write.ts +118 -0
- package/src/text-content/index.ts +14 -0
- package/src/text-content/seeding.ts +91 -0
- package/src/text-content/table.ts +45 -0
- package/src/tier-engine/__tests__/compose-app.test.ts +182 -0
- package/src/tier-engine/__tests__/drift.test.ts +42 -0
- package/src/tier-engine/__tests__/tier-engine.integration.ts +241 -0
- package/src/tier-engine/aggregate-id.ts +27 -0
- package/src/tier-engine/compose-app.ts +150 -0
- package/src/tier-engine/constants.ts +15 -0
- package/src/tier-engine/entity.ts +30 -0
- package/src/tier-engine/feature.ts +72 -0
- package/src/tier-engine/handlers/active-tier.query.ts +23 -0
- package/src/tier-engine/index.ts +22 -0
- package/src/user/__tests__/seed-testing.integration.ts +127 -0
- package/src/user/__tests__/user.integration.ts +198 -0
- package/src/user/command-schemas.ts +15 -0
- package/src/user/constants.ts +23 -0
- package/src/user/feature.ts +32 -0
- package/src/user/handlers/create.write.ts +54 -0
- package/src/user/handlers/detail.query.ts +9 -0
- package/src/user/handlers/find-for-auth.query.ts +38 -0
- package/src/user/handlers/list.query.ts +8 -0
- package/src/user/handlers/me.query.ts +15 -0
- package/src/user/handlers/update.write.ts +54 -0
- package/src/user/index.ts +4 -0
- package/src/user/schema/index.ts +5 -0
- package/src/user/schema/user.ts +69 -0
- package/src/user/seeding.ts +93 -0
- package/src/user/testing.ts +5 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// HMAC-signed single-purpose tokens for out-of-band auth flows
|
|
2
|
+
// (password-reset, email-verification, future: magic-link).
|
|
3
|
+
//
|
|
4
|
+
// Format: <userId>.<expiresAtMs>.<hmac-base64url>
|
|
5
|
+
//
|
|
6
|
+
// The `purpose` is mixed INTO the HMAC input so a token minted for one
|
|
7
|
+
// purpose (e.g. password-reset) can't be replayed against an endpoint that
|
|
8
|
+
// expects another (e.g. verify-email), even if the caller knows the
|
|
9
|
+
// userId and a valid expiry. Purpose is NOT carried in the token body —
|
|
10
|
+
// verify() takes the purpose as argument and recomputes.
|
|
11
|
+
//
|
|
12
|
+
// Timing-safe comparison on verify so a valid-length forgery can't leak
|
|
13
|
+
// signal through a short-circuit.
|
|
14
|
+
|
|
15
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
16
|
+
import { Temporal } from "temporal-polyfill";
|
|
17
|
+
|
|
18
|
+
export type VerifyResult =
|
|
19
|
+
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
20
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
21
|
+
|
|
22
|
+
function sign(input: string, secret: string): string {
|
|
23
|
+
return createHmac("sha256", secret).update(input).digest("base64url");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function payload(purpose: string, userId: string, expiresAtMs: number): string {
|
|
27
|
+
return `${purpose}:${userId}.${expiresAtMs}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function signToken(
|
|
31
|
+
userId: string,
|
|
32
|
+
purpose: string,
|
|
33
|
+
ttlMinutes: number,
|
|
34
|
+
secret: string,
|
|
35
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
36
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
37
|
+
const expiresAt = now.add({ minutes: ttlMinutes });
|
|
38
|
+
const expiresAtMs = expiresAt.epochMilliseconds;
|
|
39
|
+
const signature = sign(payload(purpose, userId, expiresAtMs), secret);
|
|
40
|
+
return {
|
|
41
|
+
token: `${userId}.${expiresAtMs}.${signature}`,
|
|
42
|
+
expiresAt,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function verifyToken(
|
|
47
|
+
token: string,
|
|
48
|
+
purpose: string,
|
|
49
|
+
secret: string,
|
|
50
|
+
now: Temporal.Instant = Temporal.Now.instant(),
|
|
51
|
+
): VerifyResult {
|
|
52
|
+
const parts = token.split(".");
|
|
53
|
+
if (parts.length !== 3) return { ok: false, reason: "malformed" };
|
|
54
|
+
const [userId, expiresAtRaw, providedSig] = parts;
|
|
55
|
+
if (!userId || !expiresAtRaw || !providedSig) return { ok: false, reason: "malformed" };
|
|
56
|
+
|
|
57
|
+
const expiresAtMs = Number(expiresAtRaw);
|
|
58
|
+
if (!Number.isFinite(expiresAtMs) || String(expiresAtMs) !== expiresAtRaw) {
|
|
59
|
+
return { ok: false, reason: "malformed" };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const expected = sign(payload(purpose, userId, expiresAtMs), secret);
|
|
63
|
+
const expectedBuf = Buffer.from(expected, "base64url");
|
|
64
|
+
const providedBuf = Buffer.from(providedSig, "base64url");
|
|
65
|
+
// Length mismatch fails BEFORE timingSafeEqual, which throws on different
|
|
66
|
+
// lengths — but that throw itself leaks via timing. Explicit length check
|
|
67
|
+
// keeps the path uniform.
|
|
68
|
+
if (expectedBuf.length !== providedBuf.length) return { ok: false, reason: "bad_signature" };
|
|
69
|
+
if (!timingSafeEqual(expectedBuf, providedBuf)) return { ok: false, reason: "bad_signature" };
|
|
70
|
+
|
|
71
|
+
if (Temporal.Instant.compare(now, Temporal.Instant.fromEpochMilliseconds(expiresAtMs)) > 0) {
|
|
72
|
+
return { ok: false, reason: "expired" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// expiresAtMs surfaces so callers (burn-store TTL, telemetry, …) don't
|
|
76
|
+
// have to re-parse the token themselves.
|
|
77
|
+
return { ok: true, userId, expiresAtMs };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Canonical purposes baked into the framework. Features that introduce new
|
|
81
|
+
// flows extend this set (or just pass an inline string).
|
|
82
|
+
export const TokenPurpose = {
|
|
83
|
+
passwordReset: "reset",
|
|
84
|
+
emailVerification: "verify",
|
|
85
|
+
} as const;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Redis-backed Pre-Activation-Token-Store für Magic-Link-Signup.
|
|
2
|
+
//
|
|
3
|
+
// Token-Material: opaque random 256-bit aus crypto.randomBytes
|
|
4
|
+
// (siehe signup-request.write.ts → generateToken() aus framework/api).
|
|
5
|
+
// Base64url-codiert zu 43 chars. NICHT no-confusable und NICHT für
|
|
6
|
+
// menschliches Tippen — der User klickt den Mail-Link, niemand tippt
|
|
7
|
+
// den Token ab.
|
|
8
|
+
//
|
|
9
|
+
// Anders als reset/verify-Tokens (HMAC-signed, stateless verifizierbar)
|
|
10
|
+
// brauchen Signup-Tokens einen serverside Lookup: der User existiert
|
|
11
|
+
// noch nicht, also gibt's keinen userId-claim den der HMAC binden
|
|
12
|
+
// könnte. Wir mappen daher Token ↔ Email bidirektional in Redis und
|
|
13
|
+
// löschen das Pair beim Confirm. Bidirektional weil:
|
|
14
|
+
// - by-token: confirm-handler braucht Token → Email
|
|
15
|
+
// - by-email: signup-request muss bei Resend einen existierenden
|
|
16
|
+
// Token wiederverwenden statt einen zweiten parallel laufen zu
|
|
17
|
+
// lassen (sonst hätte der User zwei Mails mit zwei verschiedenen
|
|
18
|
+
// Tokens, beide gültig, beide könnten zu zwei separaten Tenants
|
|
19
|
+
// führen wenn er beide klickt — unnötiges Risiko)
|
|
20
|
+
//
|
|
21
|
+
// TTL-Refresh bei Resend: wenn der Token noch lebt, refreshen wir
|
|
22
|
+
// einfach beide Keys auf die volle TTL — der User bekommt eine neue
|
|
23
|
+
// Mail mit dem GLEICHEN Token, alte Mail bleibt gültig (idempotent
|
|
24
|
+
// für den User).
|
|
25
|
+
//
|
|
26
|
+
// Keine Kollision mit reset/verify-Tokens: alle Signup-Keys haben
|
|
27
|
+
// `signup:`-Prefix.
|
|
28
|
+
|
|
29
|
+
import type Redis from "ioredis";
|
|
30
|
+
|
|
31
|
+
const TOKEN_KEY_PREFIX = "signup:by-token:";
|
|
32
|
+
const EMAIL_KEY_PREFIX = "signup:by-email:";
|
|
33
|
+
const BURN_KEY_PREFIX = "signup:burn:";
|
|
34
|
+
|
|
35
|
+
/** Email-Normalisierung — single source für jede Lookup-Schicht (Store
|
|
36
|
+
* intern UND Caller die im Return-Body / Mail-Send eine konsistente
|
|
37
|
+
* Form brauchen). Vorher zwei Stellen mit `.toLowerCase()` — eine
|
|
38
|
+
* Quelle = kein Drift. */
|
|
39
|
+
export function normalizeEmail(email: string): string {
|
|
40
|
+
return email.toLowerCase();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function tokenKey(token: string): string {
|
|
44
|
+
return `${TOKEN_KEY_PREFIX}${token}`;
|
|
45
|
+
}
|
|
46
|
+
function emailKey(email: string): string {
|
|
47
|
+
return `${EMAIL_KEY_PREFIX}${normalizeEmail(email)}`;
|
|
48
|
+
}
|
|
49
|
+
function burnKey(token: string): string {
|
|
50
|
+
return `${BURN_KEY_PREFIX}${token}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Speichert das Pair bidirektional und setzt TTL auf beiden Keys.
|
|
54
|
+
* Idempotent — re-write derselben Token-Email-Kombi ist OK. */
|
|
55
|
+
export async function storeSignupToken(
|
|
56
|
+
redis: Redis,
|
|
57
|
+
args: { email: string; token: string; ttlSeconds: number },
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
await Promise.all([
|
|
60
|
+
redis.set(tokenKey(args.token), normalizeEmail(args.email), "EX", args.ttlSeconds),
|
|
61
|
+
redis.set(emailKey(args.email), args.token, "EX", args.ttlSeconds),
|
|
62
|
+
]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Lookup: Email für einen Token. Null wenn Token nicht (mehr) existiert
|
|
66
|
+
* (abgelaufen, schon konsumiert, oder ungültig). */
|
|
67
|
+
export async function getEmailForSignupToken(redis: Redis, token: string): Promise<string | null> {
|
|
68
|
+
return redis.get(tokenKey(token));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Lookup: Existierenden Token für eine Email — falls noch valid und
|
|
72
|
+
* noch nicht konsumiert. Für Resend-Idempotenz im signup-request-Handler. */
|
|
73
|
+
export async function getTokenForSignupEmail(redis: Redis, email: string): Promise<string | null> {
|
|
74
|
+
return redis.get(emailKey(email));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Single-Use-Burn: wenn zwei Tabs gleichzeitig den Confirm-Link klicken,
|
|
78
|
+
* gewinnt der erste, der zweite kriegt "already-used". TTL = 1 Stunde
|
|
79
|
+
* (kurz genug damit der Burn-Key Redis nicht dauerhaft belastet, lang
|
|
80
|
+
* genug damit Replays in normalen Race-Windows abgefangen werden). */
|
|
81
|
+
export async function burnSignupToken(
|
|
82
|
+
redis: Redis,
|
|
83
|
+
token: string,
|
|
84
|
+
): Promise<"burned" | "already-used"> {
|
|
85
|
+
// SET NX EX — atomic check-and-set. Returnt "OK" wenn Key neu, null
|
|
86
|
+
// wenn schon da.
|
|
87
|
+
const result = await redis.set(burnKey(token), "1", "EX", 3600, "NX");
|
|
88
|
+
return result === "OK" ? "burned" : "already-used";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Cleanup nach erfolgreichem Confirm — beide Lookup-Keys löschen.
|
|
92
|
+
* Burn-Key bleibt (verhindert Replay innerhalb der Burn-TTL). */
|
|
93
|
+
export async function deleteSignupToken(
|
|
94
|
+
redis: Redis,
|
|
95
|
+
args: { email: string; token: string },
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
await Promise.all([redis.del(tokenKey(args.token)), redis.del(emailKey(args.email))]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Burn-Release für Failed-Confirm-Pfade (DB-Error etc.) damit ein
|
|
101
|
+
* legitimer Retry nicht durch einen stale Burn-Marker geblockt wird. */
|
|
102
|
+
export async function unburnSignupToken(redis: Redis, token: string): Promise<void> {
|
|
103
|
+
await redis.del(burnKey(token));
|
|
104
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// user-feature runs with r.systemScope() but events land on a concrete
|
|
2
|
+
// tenant stream. The row.version tracks whichever stream the user's last
|
|
3
|
+
// modification wrote to — and that tenant is NOT discoverable from the
|
|
4
|
+
// row alone.
|
|
5
|
+
//
|
|
6
|
+
// Strategy: prioritize lastActiveTenantId (most likely holds the latest
|
|
7
|
+
// event), fall through to the remaining memberships in insertion order,
|
|
8
|
+
// and let the caller try each one in sequence. The first stream whose
|
|
9
|
+
// version matches row.version wins; the rest are bypassed.
|
|
10
|
+
//
|
|
11
|
+
// This is pragmatic — the real fix is to scope user events to
|
|
12
|
+
// SYSTEM_TENANT_ID when the feature is r.systemScope(), which is a
|
|
13
|
+
// framework-level change tracked separately. Until then, "try each
|
|
14
|
+
// tenant the user belongs to" is robust against non-deterministic
|
|
15
|
+
// memberships-query ordering (tenant:query:memberships has no ORDER BY).
|
|
16
|
+
|
|
17
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
|
|
19
|
+
export function orderTenantsByPreference(
|
|
20
|
+
memberships: readonly { readonly tenantId: TenantId }[],
|
|
21
|
+
lastActiveTenantId: string | null | undefined,
|
|
22
|
+
): TenantId[] {
|
|
23
|
+
if (memberships.length === 0) return [];
|
|
24
|
+
const ids = memberships.map((m) => m.tenantId);
|
|
25
|
+
if (!lastActiveTenantId) return ids;
|
|
26
|
+
// Move lastActiveTenantId to the front; preserve relative order of rest.
|
|
27
|
+
const preferred = ids.find((id) => id === lastActiveTenantId);
|
|
28
|
+
if (!preferred) return ids;
|
|
29
|
+
const rest = ids.filter((id) => id !== preferred);
|
|
30
|
+
return [preferred, ...rest];
|
|
31
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Single-use enforcement for HMAC-signed auth tokens (password-reset,
|
|
2
|
+
// email-verification).
|
|
3
|
+
//
|
|
4
|
+
// Problem: the token itself carries only userId + expiry + signature.
|
|
5
|
+
// Without server-side burn, the same token can be replayed within its
|
|
6
|
+
// TTL — an attacker with short-lived mailbox access (Shoulder-Surfing,
|
|
7
|
+
// Mail-Forwarding, stolen laptop) can re-use a link AFTER the legitimate
|
|
8
|
+
// user has already consumed it. Industry standard (Auth0, Stripe, GitHub,
|
|
9
|
+
// Google) is single-use.
|
|
10
|
+
//
|
|
11
|
+
// Mechanism: `SET burn:<purpose>:<userId>:<expiresAtMs> "1" EX <ttl> NX`.
|
|
12
|
+
// First caller wins ("OK"), replay loses (`null`). The key's natural TTL
|
|
13
|
+
// matches the token's — once the token would have expired anyway, Redis
|
|
14
|
+
// reclaims the marker.
|
|
15
|
+
//
|
|
16
|
+
// Storage footprint: one small string per used token, auto-evicted. At
|
|
17
|
+
// 10k password-resets/day × 15-min TTL, at any moment ~100 keys live.
|
|
18
|
+
|
|
19
|
+
import type Redis from "ioredis";
|
|
20
|
+
|
|
21
|
+
const BURN_KEY_PREFIX = "kumiko:auth:burn";
|
|
22
|
+
|
|
23
|
+
export type BurnResult = "fresh" | "already-used";
|
|
24
|
+
|
|
25
|
+
export async function burnToken(
|
|
26
|
+
redis: Redis,
|
|
27
|
+
purpose: string,
|
|
28
|
+
userId: string,
|
|
29
|
+
expiresAtMs: number,
|
|
30
|
+
now: number = Date.now(),
|
|
31
|
+
): Promise<BurnResult> {
|
|
32
|
+
// Floor at 60s so a near-expiry token still leaves a burn marker long
|
|
33
|
+
// enough to block a replay; ceil() rounds token-TTL up so we never
|
|
34
|
+
// evict the marker before the token itself becomes invalid.
|
|
35
|
+
const ttlSeconds = Math.max(60, Math.ceil((expiresAtMs - now) / 1000));
|
|
36
|
+
const key = burnKey(purpose, userId, expiresAtMs);
|
|
37
|
+
const set = await redis.set(key, "1", "EX", ttlSeconds, "NX");
|
|
38
|
+
return set === "OK" ? "fresh" : "already-used";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Release a burn marker. Called by handlers when the post-burn write path
|
|
42
|
+
// failed for reasons unrelated to the token (e.g. every tenant stream
|
|
43
|
+
// rejected version_conflict — the token itself was never consumed). Without
|
|
44
|
+
// this, a legit retry with the same mail link would hit `already-used` and
|
|
45
|
+
// lock the user out permanently within TTL.
|
|
46
|
+
export async function unburnToken(
|
|
47
|
+
redis: Redis,
|
|
48
|
+
purpose: string,
|
|
49
|
+
userId: string,
|
|
50
|
+
expiresAtMs: number,
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
await redis.del(burnKey(purpose, userId, expiresAtMs));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function burnKey(purpose: string, userId: string, expiresAtMs: number): string {
|
|
56
|
+
return `${BURN_KEY_PREFIX}:${purpose}:${userId}:${expiresAtMs}`;
|
|
57
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Thin wrapper around signed-token.ts pinning the purpose to "verify".
|
|
2
|
+
// Mirrors reset-token.ts so callers can import a flow-specific helper
|
|
3
|
+
// without knowing the underlying HMAC scheme.
|
|
4
|
+
|
|
5
|
+
import type { Temporal } from "temporal-polyfill";
|
|
6
|
+
import { signToken, TokenPurpose, verifyToken } from "./signed-token";
|
|
7
|
+
|
|
8
|
+
export type VerifyResult =
|
|
9
|
+
| { readonly ok: true; readonly userId: string; readonly expiresAtMs: number }
|
|
10
|
+
| { readonly ok: false; readonly reason: "malformed" | "bad_signature" | "expired" };
|
|
11
|
+
|
|
12
|
+
export function signVerificationToken(
|
|
13
|
+
userId: string,
|
|
14
|
+
ttlMinutes: number,
|
|
15
|
+
secret: string,
|
|
16
|
+
now?: Temporal.Instant,
|
|
17
|
+
): { token: string; expiresAt: Temporal.Instant } {
|
|
18
|
+
return signToken(userId, TokenPurpose.emailVerification, ttlMinutes, secret, now);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function verifyVerificationToken(
|
|
22
|
+
token: string,
|
|
23
|
+
secret: string,
|
|
24
|
+
now?: Temporal.Instant,
|
|
25
|
+
): VerifyResult {
|
|
26
|
+
return verifyToken(token, TokenPurpose.emailVerification, secret, now);
|
|
27
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { screen } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { makeAuthGate } from "../auth-gate";
|
|
6
|
+
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
7
|
+
|
|
8
|
+
describe("makeAuthGate", () => {
|
|
9
|
+
function CustomLogin(): ReactNode {
|
|
10
|
+
return <div data-testid="custom-login">custom-login</div>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
test("loading → renders placeholder, not children, not login", () => {
|
|
14
|
+
const Gate = makeAuthGate(CustomLogin);
|
|
15
|
+
const session = makeSessionApi({ status: "loading", user: null });
|
|
16
|
+
const { container } = renderWithProviders(
|
|
17
|
+
<Gate>
|
|
18
|
+
<div data-testid="protected">secret</div>
|
|
19
|
+
</Gate>,
|
|
20
|
+
{ session },
|
|
21
|
+
);
|
|
22
|
+
expect(screen.queryByTestId("protected")).toBeNull();
|
|
23
|
+
expect(screen.queryByTestId("custom-login")).toBeNull();
|
|
24
|
+
// Placeholder div ist gerendert (kein leerer Tree)
|
|
25
|
+
expect(container.firstChild).not.toBeNull();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("unauthenticated → renders LoginComponent, not children", () => {
|
|
29
|
+
const Gate = makeAuthGate(CustomLogin);
|
|
30
|
+
const session = makeSessionApi({ status: "unauthenticated", user: null });
|
|
31
|
+
renderWithProviders(
|
|
32
|
+
<Gate>
|
|
33
|
+
<div data-testid="protected">secret</div>
|
|
34
|
+
</Gate>,
|
|
35
|
+
{ session },
|
|
36
|
+
);
|
|
37
|
+
expect(screen.getByTestId("custom-login")).toBeTruthy();
|
|
38
|
+
expect(screen.queryByTestId("protected")).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("authenticated → renders children, not login", () => {
|
|
42
|
+
const Gate = makeAuthGate(CustomLogin);
|
|
43
|
+
renderWithProviders(
|
|
44
|
+
<Gate>
|
|
45
|
+
<div data-testid="protected">secret</div>
|
|
46
|
+
</Gate>,
|
|
47
|
+
);
|
|
48
|
+
expect(screen.getByTestId("protected")).toBeTruthy();
|
|
49
|
+
expect(screen.queryByTestId("custom-login")).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { ForgotPasswordScreen } from "../forgot-password-screen";
|
|
5
|
+
import { renderWithProviders } from "./test-utils";
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
// global fetch wird von auth-client.ts gerufen — wir mocken pro Test.
|
|
9
|
+
vi.stubGlobal(
|
|
10
|
+
"fetch",
|
|
11
|
+
vi.fn(async () => new Response(null, { status: 200 })),
|
|
12
|
+
);
|
|
13
|
+
});
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
vi.unstubAllGlobals();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("ForgotPasswordScreen", () => {
|
|
19
|
+
test("rendert title + email-input + submit-button (de)", () => {
|
|
20
|
+
renderWithProviders(<ForgotPasswordScreen />);
|
|
21
|
+
expect(screen.getByText("Passwort zurücksetzen")).toBeTruthy();
|
|
22
|
+
expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
|
|
23
|
+
expect(screen.getByRole("button", { name: "Link anfordern" })).toBeTruthy();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("submit ruft /api/auth/request-password-reset mit der Email", async () => {
|
|
27
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
28
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
29
|
+
|
|
30
|
+
renderWithProviders(<ForgotPasswordScreen />);
|
|
31
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
32
|
+
target: { value: "user@example.com" },
|
|
33
|
+
});
|
|
34
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
35
|
+
|
|
36
|
+
await waitFor(() => {
|
|
37
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
38
|
+
"/api/auth/request-password-reset",
|
|
39
|
+
expect.objectContaining({
|
|
40
|
+
method: "POST",
|
|
41
|
+
body: JSON.stringify({ email: "user@example.com" }),
|
|
42
|
+
}),
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("nach erfolgreichem Submit: success-Banner + 'Zurück zum Login'-Link", async () => {
|
|
48
|
+
renderWithProviders(<ForgotPasswordScreen />);
|
|
49
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
50
|
+
target: { value: "user@example.com" },
|
|
51
|
+
});
|
|
52
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
53
|
+
|
|
54
|
+
await waitFor(() => {
|
|
55
|
+
expect(screen.getByText("Mail gesendet")).toBeTruthy();
|
|
56
|
+
});
|
|
57
|
+
// Link zurück zum Login muss im Success-State da sein.
|
|
58
|
+
expect(screen.getByRole("link", { name: /Zurück zum Login/i })).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("server 5xx → error-banner statt Success-State", async () => {
|
|
62
|
+
vi.stubGlobal(
|
|
63
|
+
"fetch",
|
|
64
|
+
vi.fn(async () => new Response(null, { status: 500 })),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
renderWithProviders(<ForgotPasswordScreen />);
|
|
68
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
69
|
+
target: { value: "user@example.com" },
|
|
70
|
+
});
|
|
71
|
+
fireEvent.click(screen.getByRole("button", { name: "Link anfordern" }));
|
|
72
|
+
|
|
73
|
+
await waitFor(() => {
|
|
74
|
+
// unknownError-Bundle-key → "Etwas ist schief gegangen..."
|
|
75
|
+
expect(screen.getByRole("alert").textContent).toContain("schief");
|
|
76
|
+
});
|
|
77
|
+
// Kein success-state.
|
|
78
|
+
expect(screen.queryByText("Mail gesendet")).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { LoginScreen } from "../login-screen";
|
|
5
|
+
import { makeSessionApi, renderWithProviders } from "./test-utils";
|
|
6
|
+
|
|
7
|
+
describe("LoginScreen", () => {
|
|
8
|
+
test("renders translated title + email + password labels (de)", () => {
|
|
9
|
+
renderWithProviders(<LoginScreen />);
|
|
10
|
+
expect(screen.getByText("Anmelden")).toBeTruthy();
|
|
11
|
+
expect(screen.getByLabelText(/^E-Mail/)).toBeTruthy();
|
|
12
|
+
expect(screen.getByLabelText(/^Passwort/)).toBeTruthy();
|
|
13
|
+
expect(screen.getByRole("button", { name: "Einloggen" })).toBeTruthy();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("submit calls session.login with form values", async () => {
|
|
17
|
+
const session = makeSessionApi({ status: "unauthenticated", user: null });
|
|
18
|
+
renderWithProviders(<LoginScreen />, { session });
|
|
19
|
+
|
|
20
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
21
|
+
target: { value: "demo@example.com" },
|
|
22
|
+
});
|
|
23
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), {
|
|
24
|
+
target: { value: "secret" },
|
|
25
|
+
});
|
|
26
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
27
|
+
|
|
28
|
+
await waitFor(() => {
|
|
29
|
+
expect(session.login).toHaveBeenCalledWith({
|
|
30
|
+
email: "demo@example.com",
|
|
31
|
+
password: "secret",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("invalid_credentials → renders translated error message", async () => {
|
|
37
|
+
const session = makeSessionApi({
|
|
38
|
+
status: "unauthenticated",
|
|
39
|
+
user: null,
|
|
40
|
+
login: vi.fn(async () => ({ ok: false, error: { reason: "invalid_credentials" } })),
|
|
41
|
+
});
|
|
42
|
+
renderWithProviders(<LoginScreen />, { session });
|
|
43
|
+
|
|
44
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
45
|
+
target: { value: "wrong@example.com" },
|
|
46
|
+
});
|
|
47
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), {
|
|
48
|
+
target: { value: "x" },
|
|
49
|
+
});
|
|
50
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
51
|
+
|
|
52
|
+
await waitFor(() => {
|
|
53
|
+
expect(screen.getByRole("alert")).toBeTruthy();
|
|
54
|
+
expect(screen.getByRole("alert").textContent).toBe("E-Mail oder Passwort falsch.");
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("account_locked with retryAfterSeconds renders interpolated minutes", async () => {
|
|
59
|
+
const session = makeSessionApi({
|
|
60
|
+
status: "unauthenticated",
|
|
61
|
+
user: null,
|
|
62
|
+
login: vi.fn(async () => ({
|
|
63
|
+
ok: false,
|
|
64
|
+
error: { reason: "account_locked", retryAfterSeconds: 540 },
|
|
65
|
+
})),
|
|
66
|
+
});
|
|
67
|
+
renderWithProviders(<LoginScreen />, { session });
|
|
68
|
+
|
|
69
|
+
fireEvent.change(screen.getByLabelText(/^E-Mail/), {
|
|
70
|
+
target: { value: "x@example.com" },
|
|
71
|
+
});
|
|
72
|
+
fireEvent.change(screen.getByLabelText(/^Passwort/), {
|
|
73
|
+
target: { value: "x" },
|
|
74
|
+
});
|
|
75
|
+
fireEvent.click(screen.getByRole("button", { name: "Einloggen" }));
|
|
76
|
+
|
|
77
|
+
await waitFor(() => {
|
|
78
|
+
// 540s → 9 Minuten (Math.ceil)
|
|
79
|
+
expect(screen.getByRole("alert").textContent).toMatch(/9 Minuten/);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("forgotPasswordHref-prop → Link rendert mit korrektem href", () => {
|
|
84
|
+
renderWithProviders(<LoginScreen forgotPasswordHref="/forgot-password" />);
|
|
85
|
+
const link = screen.getByRole("link", { name: /Passwort vergessen/i });
|
|
86
|
+
expect(link).toBeTruthy();
|
|
87
|
+
expect(link.getAttribute("href")).toBe("/forgot-password");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("ohne forgotPasswordHref → KEIN Link (Login bleibt minimalistisch)", () => {
|
|
91
|
+
renderWithProviders(<LoginScreen />);
|
|
92
|
+
expect(screen.queryByRole("link", { name: /Passwort vergessen/i })).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
4
|
+
import { ResetPasswordScreen } from "../reset-password-screen";
|
|
5
|
+
import { renderWithProviders } from "./test-utils";
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
vi.stubGlobal(
|
|
9
|
+
"fetch",
|
|
10
|
+
vi.fn(async () => new Response(null, { status: 200 })),
|
|
11
|
+
);
|
|
12
|
+
});
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
vi.unstubAllGlobals();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("ResetPasswordScreen", () => {
|
|
18
|
+
test("ohne Token in URL UND ohne token-Prop → missing-token-Page", () => {
|
|
19
|
+
// jsdom default location is "about:blank" → search = ""
|
|
20
|
+
renderWithProviders(<ResetPasswordScreen />);
|
|
21
|
+
expect(screen.getByText(/enthält keinen Token/i)).toBeTruthy();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("mit token-Prop → Form rendert", () => {
|
|
25
|
+
renderWithProviders(<ResetPasswordScreen token="abc-token" />);
|
|
26
|
+
expect(screen.getByLabelText(/^Neues Passwort/)).toBeTruthy();
|
|
27
|
+
expect(screen.getByLabelText(/^Passwort bestätigen/)).toBeTruthy();
|
|
28
|
+
expect(screen.getByRole("button", { name: "Passwort speichern" })).toBeTruthy();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("Passwort < 8 Zeichen → client-side error, kein fetch-Call", async () => {
|
|
32
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
33
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
34
|
+
|
|
35
|
+
renderWithProviders(<ResetPasswordScreen token="abc" />);
|
|
36
|
+
fireEvent.change(screen.getByLabelText(/^Neues Passwort/), { target: { value: "short" } });
|
|
37
|
+
fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), { target: { value: "short" } });
|
|
38
|
+
fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
|
|
39
|
+
|
|
40
|
+
await waitFor(() => {
|
|
41
|
+
expect(screen.getByRole("alert").textContent).toContain("8 Zeichen");
|
|
42
|
+
});
|
|
43
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("mismatch zwischen Passwort und Confirm → client-side error", async () => {
|
|
47
|
+
renderWithProviders(<ResetPasswordScreen token="abc" />);
|
|
48
|
+
fireEvent.change(screen.getByLabelText(/^Neues Passwort/), {
|
|
49
|
+
target: { value: "validpass1" },
|
|
50
|
+
});
|
|
51
|
+
fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), {
|
|
52
|
+
target: { value: "differentpass" },
|
|
53
|
+
});
|
|
54
|
+
fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
|
|
55
|
+
|
|
56
|
+
await waitFor(() => {
|
|
57
|
+
expect(screen.getByRole("alert").textContent).toContain("nicht überein");
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("happy path: gültiges Passwort → fetch-Call + success-State", async () => {
|
|
62
|
+
const fetchMock = vi.fn(async () => new Response(null, { status: 200 }));
|
|
63
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
64
|
+
|
|
65
|
+
renderWithProviders(<ResetPasswordScreen token="abc-token" />);
|
|
66
|
+
fireEvent.change(screen.getByLabelText(/^Neues Passwort/), {
|
|
67
|
+
target: { value: "validpass1" },
|
|
68
|
+
});
|
|
69
|
+
fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), {
|
|
70
|
+
target: { value: "validpass1" },
|
|
71
|
+
});
|
|
72
|
+
fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
|
|
73
|
+
|
|
74
|
+
await waitFor(() => {
|
|
75
|
+
expect(fetchMock).toHaveBeenCalledWith(
|
|
76
|
+
"/api/auth/reset-password",
|
|
77
|
+
expect.objectContaining({
|
|
78
|
+
method: "POST",
|
|
79
|
+
body: JSON.stringify({ token: "abc-token", newPassword: "validpass1" }),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(screen.getByText("Passwort gesetzt")).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("server invalid_reset_token → mapped i18n-error im UI", async () => {
|
|
87
|
+
const errBody = JSON.stringify({
|
|
88
|
+
error: { code: "invalid_reset_token", details: { reason: "invalid_reset_token" } },
|
|
89
|
+
});
|
|
90
|
+
vi.stubGlobal(
|
|
91
|
+
"fetch",
|
|
92
|
+
vi.fn(async () => new Response(errBody, { status: 422 })),
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
renderWithProviders(<ResetPasswordScreen token="bad" />);
|
|
96
|
+
fireEvent.change(screen.getByLabelText(/^Neues Passwort/), {
|
|
97
|
+
target: { value: "validpass1" },
|
|
98
|
+
});
|
|
99
|
+
fireEvent.change(screen.getByLabelText(/^Passwort bestätigen/), {
|
|
100
|
+
target: { value: "validpass1" },
|
|
101
|
+
});
|
|
102
|
+
fireEvent.click(screen.getByRole("button", { name: "Passwort speichern" }));
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(screen.getByRole("alert").textContent).toContain("ungültig");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|