@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,149 @@
|
|
|
1
|
+
// Degradation test: when ctx.redis is unavailable the login handler must
|
|
2
|
+
// still work — every lockout check/record becomes a no-op. The feature's
|
|
3
|
+
// contract explicitly allows this: lockout is brute-force hardening, not a
|
|
4
|
+
// login prerequisite. A setup without Redis (dev, minimal deployment, or
|
|
5
|
+
// operator-chosen opt-out) should have working auth at the cost of losing
|
|
6
|
+
// this single defense layer; the IP-level rate-limiter is the parallel
|
|
7
|
+
// protection that stays in place regardless.
|
|
8
|
+
//
|
|
9
|
+
// Separate file rather than a case in account-lockout.integration.ts because
|
|
10
|
+
// the stack must be built without `context.redis` — shared `beforeAll`
|
|
11
|
+
// can't mix the two.
|
|
12
|
+
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import {
|
|
17
|
+
createEntityTable,
|
|
18
|
+
pushTables,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
TestUsers,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
24
|
+
import { createConfigFeature } from "../../config";
|
|
25
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
26
|
+
import { configValuesTable } from "../../config/table";
|
|
27
|
+
import { createTenantFeature } from "../../tenant";
|
|
28
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
29
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
30
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
31
|
+
import { UserHandlers } from "../../user";
|
|
32
|
+
import { createUserFeature } from "../../user/feature";
|
|
33
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
34
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
35
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
36
|
+
import { hashPassword } from "../password-hashing";
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
41
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
45
|
+
const resolver = createConfigResolver({ encryption });
|
|
46
|
+
|
|
47
|
+
stack = await setupTestStack({
|
|
48
|
+
features: [
|
|
49
|
+
createConfigFeature(),
|
|
50
|
+
createUserFeature(),
|
|
51
|
+
createTenantFeature(),
|
|
52
|
+
createAuthEmailPasswordFeature({
|
|
53
|
+
accountLockout: { maxFailedAttempts: 2, lockoutDurationMinutes: 1 },
|
|
54
|
+
}),
|
|
55
|
+
],
|
|
56
|
+
// extraContext runs AFTER the default `redis: testRedis.redis` spread,
|
|
57
|
+
// so setting redis:undefined here overrides it on the handler-facing
|
|
58
|
+
// AppContext. Framework internals (rate-limit, idempotency, eventDedup,
|
|
59
|
+
// entityCache) receive the real redis via separate buildServer wiring
|
|
60
|
+
// and stay operational — only the handler's ctx.redis is gone.
|
|
61
|
+
extraContext: () => ({
|
|
62
|
+
configResolver: resolver,
|
|
63
|
+
configEncryption: encryption,
|
|
64
|
+
redis: undefined,
|
|
65
|
+
}),
|
|
66
|
+
authConfig: {
|
|
67
|
+
membershipQuery: "tenant:query:memberships",
|
|
68
|
+
loginHandler: AuthHandlers.login,
|
|
69
|
+
loginErrorStatusMap: {
|
|
70
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
71
|
+
[AuthErrors.noMembership]: 403,
|
|
72
|
+
[AuthErrors.accountLocked]: 423,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await createEntityTable(stack.db, userEntity);
|
|
78
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
79
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await stack.cleanup();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
await stack.db.delete(userTable);
|
|
88
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function seedLoginUser(
|
|
92
|
+
email: string,
|
|
93
|
+
password: string,
|
|
94
|
+
): Promise<{ id: string; tenantId: TenantId }> {
|
|
95
|
+
const hash = await hashPassword(password);
|
|
96
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
97
|
+
UserHandlers.create,
|
|
98
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
|
|
99
|
+
systemAdmin,
|
|
100
|
+
);
|
|
101
|
+
const tenantId: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
102
|
+
await seedTenantMembership(stack.db, {
|
|
103
|
+
userId: created.id,
|
|
104
|
+
tenantId,
|
|
105
|
+
roles: ["User"],
|
|
106
|
+
});
|
|
107
|
+
return { id: created.id, tenantId };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function loginAttempt(email: string, password: string): Promise<Response> {
|
|
111
|
+
return stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe("account-lockout — ctx.redis unset", () => {
|
|
115
|
+
test("correct password → 200 login success (handler doesn't touch redis)", async () => {
|
|
116
|
+
await seedLoginUser("ok@example.com", "right-pw");
|
|
117
|
+
|
|
118
|
+
const res = await loginAttempt("ok@example.com", "right-pw");
|
|
119
|
+
expect(res.status).toBe(200);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("wrong password → 401 invalid_credentials (no crash trying to read lockout state)", async () => {
|
|
123
|
+
await seedLoginUser("wrong@example.com", "right-pw");
|
|
124
|
+
|
|
125
|
+
const res = await loginAttempt("wrong@example.com", "nope");
|
|
126
|
+
expect(res.status).toBe(401);
|
|
127
|
+
const body = await res.json();
|
|
128
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("NO lockout applied even after many attempts beyond threshold", async () => {
|
|
132
|
+
// Threshold is 2 in this setup. Without redis, the counter isn't tracked,
|
|
133
|
+
// so repeated misses all return 401 (invalid_credentials) — never 423
|
|
134
|
+
// (account_locked). The IP-rate-limiter would be the catch in prod; we
|
|
135
|
+
// don't exercise it here (authConfig leaves loginRateLimit at default).
|
|
136
|
+
await seedLoginUser("many@example.com", "right-pw");
|
|
137
|
+
|
|
138
|
+
for (let i = 0; i < 5; i++) {
|
|
139
|
+
const res = await loginAttempt("many@example.com", `wrong-${i}`);
|
|
140
|
+
expect(res.status).toBe(401);
|
|
141
|
+
const body = await res.json();
|
|
142
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// A correct password STILL logs in — no stuck lockout state.
|
|
146
|
+
const ok = await loginAttempt("many@example.com", "right-pw");
|
|
147
|
+
expect(ok.status).toBe(200);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Account-lockout integration tests — prod-readiness welle 3, step 3.4.
|
|
2
|
+
//
|
|
3
|
+
// Covers the brute-force protection contract:
|
|
4
|
+
// - N wrong-password attempts lock the account for a configurable duration
|
|
5
|
+
// - Locked accounts refuse login without password-verify (no timing-oracle)
|
|
6
|
+
// - Auto-unlock after the lock expires; streak resets to 1 on the next miss
|
|
7
|
+
// - Success clears the Redis lockout state
|
|
8
|
+
// - Enumeration surface unchanged for unknown users
|
|
9
|
+
// - Redis unset: handler still works, lockout is silently skipped (degrades
|
|
10
|
+
// gracefully to the IP-rate-limiter at the edge)
|
|
11
|
+
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
14
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
15
|
+
import {
|
|
16
|
+
createEntityTable,
|
|
17
|
+
pushTables,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
TestUsers,
|
|
21
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
22
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
23
|
+
import { createConfigFeature } from "../../config";
|
|
24
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
25
|
+
import { configValuesTable } from "../../config/table";
|
|
26
|
+
import { createTenantFeature } from "../../tenant";
|
|
27
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
28
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
29
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
30
|
+
import { UserHandlers } from "../../user";
|
|
31
|
+
import { createUserFeature } from "../../user/feature";
|
|
32
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
33
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
34
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
35
|
+
import { getLockoutState, type LockoutState } from "../lockout-store";
|
|
36
|
+
import { hashPassword } from "../password-hashing";
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
41
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
42
|
+
|
|
43
|
+
// Tight thresholds for test speed: 3 attempts, 1-minute lock. The default
|
|
44
|
+
// (5/15) is covered implicitly by the config-plumbing test; here we verify
|
|
45
|
+
// the knobs actually land in the handler.
|
|
46
|
+
const MAX_ATTEMPTS = 3;
|
|
47
|
+
const LOCK_MINUTES = 1;
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
51
|
+
const resolver = createConfigResolver({ encryption });
|
|
52
|
+
|
|
53
|
+
stack = await setupTestStack({
|
|
54
|
+
features: [
|
|
55
|
+
createConfigFeature(),
|
|
56
|
+
createUserFeature(),
|
|
57
|
+
createTenantFeature(),
|
|
58
|
+
createAuthEmailPasswordFeature({
|
|
59
|
+
accountLockout: {
|
|
60
|
+
maxFailedAttempts: MAX_ATTEMPTS,
|
|
61
|
+
lockoutDurationMinutes: LOCK_MINUTES,
|
|
62
|
+
},
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
66
|
+
authConfig: {
|
|
67
|
+
membershipQuery: "tenant:query:memberships",
|
|
68
|
+
loginHandler: AuthHandlers.login,
|
|
69
|
+
loginErrorStatusMap: {
|
|
70
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
71
|
+
[AuthErrors.noMembership]: 403,
|
|
72
|
+
[AuthErrors.accountLocked]: 423,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await createEntityTable(stack.db, userEntity);
|
|
78
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
79
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
afterAll(async () => {
|
|
83
|
+
await stack.cleanup();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
beforeEach(async () => {
|
|
87
|
+
await stack.db.delete(userTable);
|
|
88
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
89
|
+
// Clear lockout state between tests — the key prefix is feature-owned, so
|
|
90
|
+
// a scan-and-del is the safe bet even if tests share a Redis namespace.
|
|
91
|
+
await stack.redis.flushNamespace();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
async function seedLoginUser(
|
|
95
|
+
email: string,
|
|
96
|
+
password: string,
|
|
97
|
+
): Promise<{ id: string; tenantId: TenantId }> {
|
|
98
|
+
const hash = await hashPassword(password);
|
|
99
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
100
|
+
UserHandlers.create,
|
|
101
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
|
|
102
|
+
systemAdmin,
|
|
103
|
+
);
|
|
104
|
+
const tenantId: TenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
105
|
+
await seedTenantMembership(stack.db, {
|
|
106
|
+
userId: created.id,
|
|
107
|
+
tenantId,
|
|
108
|
+
roles: ["User"],
|
|
109
|
+
});
|
|
110
|
+
return { id: created.id, tenantId };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function loginAttempt(email: string, password: string): Promise<Response> {
|
|
114
|
+
return stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function readLockoutState(userId: string): Promise<LockoutState | null> {
|
|
118
|
+
return getLockoutState(stack.redis.redis, userId);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("account-lockout — counter increments", () => {
|
|
122
|
+
test("each wrong-password attempt increments the Redis failure counter", async () => {
|
|
123
|
+
const seed = await seedLoginUser("counter@example.com", "right-pw");
|
|
124
|
+
|
|
125
|
+
const r1 = await loginAttempt("counter@example.com", "wrong-1");
|
|
126
|
+
expect(r1.status).toBe(401);
|
|
127
|
+
expect((await readLockoutState(seed.id))?.failureCount).toBe(1);
|
|
128
|
+
|
|
129
|
+
const r2 = await loginAttempt("counter@example.com", "wrong-2");
|
|
130
|
+
expect(r2.status).toBe(401);
|
|
131
|
+
expect((await readLockoutState(seed.id))?.failureCount).toBe(2);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("wrong attempts stay as invalid_credentials until the threshold is crossed", async () => {
|
|
135
|
+
await seedLoginUser("threshold@example.com", "right-pw");
|
|
136
|
+
|
|
137
|
+
for (let i = 0; i < MAX_ATTEMPTS - 1; i++) {
|
|
138
|
+
const r = await loginAttempt("threshold@example.com", `wrong-${i}`);
|
|
139
|
+
expect(r.status).toBe(401);
|
|
140
|
+
const body = await r.json();
|
|
141
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("account-lockout — threshold + lock", () => {
|
|
147
|
+
test("Nth wrong attempt sets lockedUntil in the future", async () => {
|
|
148
|
+
const seed = await seedLoginUser("threshold@example.com", "right-pw");
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
151
|
+
await loginAttempt("threshold@example.com", `wrong-${i}`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const state = await readLockoutState(seed.id);
|
|
155
|
+
expect(state?.failureCount).toBe(MAX_ATTEMPTS);
|
|
156
|
+
expect(state?.lockedUntil).not.toBeNull();
|
|
157
|
+
// Lock-duration ~1 min from now; assert within a generous window.
|
|
158
|
+
const msUntilUnlock = (state?.lockedUntil ?? 0) - Date.now();
|
|
159
|
+
expect(msUntilUnlock).toBeGreaterThan(50_000); // > 50 sec
|
|
160
|
+
expect(msUntilUnlock).toBeLessThan(70_000); // < 70 sec
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("locked account rejects further attempts with account_locked + retryAfterSeconds", async () => {
|
|
164
|
+
await seedLoginUser("locked@example.com", "right-pw");
|
|
165
|
+
|
|
166
|
+
// Trigger lock
|
|
167
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
168
|
+
await loginAttempt("locked@example.com", `wrong-${i}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Next attempt (even with the CORRECT password) is blocked.
|
|
172
|
+
const res = await loginAttempt("locked@example.com", "right-pw");
|
|
173
|
+
expect(res.status).toBe(423);
|
|
174
|
+
const body = await res.json();
|
|
175
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.accountLocked);
|
|
176
|
+
expect(body.error?.details?.retryAfterSeconds).toBeGreaterThan(0);
|
|
177
|
+
expect(body.error?.details?.retryAfterSeconds).toBeLessThanOrEqual(LOCK_MINUTES * 60);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("locked account does not increment the counter on further attempts (no password verify)", async () => {
|
|
181
|
+
const seed = await seedLoginUser("nostack@example.com", "right-pw");
|
|
182
|
+
|
|
183
|
+
// Trigger lock
|
|
184
|
+
for (let i = 0; i < MAX_ATTEMPTS; i++) {
|
|
185
|
+
await loginAttempt("nostack@example.com", `wrong-${i}`);
|
|
186
|
+
}
|
|
187
|
+
const stateBefore = await readLockoutState(seed.id);
|
|
188
|
+
|
|
189
|
+
// Hammer the locked account
|
|
190
|
+
for (let i = 0; i < 5; i++) {
|
|
191
|
+
await loginAttempt("nostack@example.com", `still-wrong-${i}`);
|
|
192
|
+
}
|
|
193
|
+
const stateAfter = await readLockoutState(seed.id);
|
|
194
|
+
|
|
195
|
+
// Counter frozen, lock-until unchanged — no re-locking, no counter inflation.
|
|
196
|
+
expect(stateAfter?.failureCount).toBe(stateBefore?.failureCount);
|
|
197
|
+
expect(stateAfter?.lockedUntil).toBe(stateBefore?.lockedUntil);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("account-lockout — reset on success", () => {
|
|
202
|
+
test("successful login clears the Redis lockout key entirely", async () => {
|
|
203
|
+
const seed = await seedLoginUser("success@example.com", "right-pw");
|
|
204
|
+
|
|
205
|
+
// Build up some failed attempts (but not enough to lock)
|
|
206
|
+
await loginAttempt("success@example.com", "wrong-1");
|
|
207
|
+
await loginAttempt("success@example.com", "wrong-2");
|
|
208
|
+
expect((await readLockoutState(seed.id))?.failureCount).toBe(2);
|
|
209
|
+
|
|
210
|
+
// Correct login clears the streak
|
|
211
|
+
const res = await loginAttempt("success@example.com", "right-pw");
|
|
212
|
+
expect(res.status).toBe(200);
|
|
213
|
+
|
|
214
|
+
expect(await readLockoutState(seed.id)).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("account-lockout — auto-unlock (strict semantic)", () => {
|
|
219
|
+
// Simulate a "lock that just expired" — count-key still holds the pre-lock
|
|
220
|
+
// streak value (count >= threshold), until-key has been naturally TTL'd
|
|
221
|
+
// out by Redis. The counter is monotonic by design, so the next wrong
|
|
222
|
+
// password re-locks immediately without a fresh-streak grace period.
|
|
223
|
+
async function seedExpiredLock(userId: string): Promise<void> {
|
|
224
|
+
await stack.redis.redis.set(
|
|
225
|
+
`kumiko:auth:lockout:count:${userId}`,
|
|
226
|
+
String(MAX_ATTEMPTS),
|
|
227
|
+
"EX",
|
|
228
|
+
3600,
|
|
229
|
+
);
|
|
230
|
+
// Deliberately DO NOT set the until-key — that's what "expired lock"
|
|
231
|
+
// looks like from the store's perspective (Redis auto-reaped it).
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
test("expired lock + wrong password → immediate re-lock (count grows, NO fresh streak)", async () => {
|
|
235
|
+
const seed = await seedLoginUser("expired@example.com", "right-pw");
|
|
236
|
+
await seedExpiredLock(seed.id);
|
|
237
|
+
|
|
238
|
+
// First attempt after auto-unlock: wrong password → counter jumps from
|
|
239
|
+
// MAX_ATTEMPTS to MAX_ATTEMPTS+1. Still at/over threshold → re-locked.
|
|
240
|
+
// The handler response is still 401 (invalid_credentials) because the
|
|
241
|
+
// gate-check at entry saw no active lock; only the NEXT attempt would
|
|
242
|
+
// see the newly-armed lock and get 423.
|
|
243
|
+
const res = await loginAttempt("expired@example.com", "still-wrong");
|
|
244
|
+
expect(res.status).toBe(401);
|
|
245
|
+
const body = await res.json();
|
|
246
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
247
|
+
|
|
248
|
+
const state = await readLockoutState(seed.id);
|
|
249
|
+
expect(state?.failureCount).toBe(MAX_ATTEMPTS + 1);
|
|
250
|
+
expect(state?.lockedUntil).not.toBeNull();
|
|
251
|
+
|
|
252
|
+
// Follow-up attempt surfaces the re-arm as a 423.
|
|
253
|
+
const res2 = await loginAttempt("expired@example.com", "still-wrong-2");
|
|
254
|
+
expect(res2.status).toBe(423);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("expired lock + correct password → success clears both Redis keys", async () => {
|
|
258
|
+
const seed = await seedLoginUser("expired-ok@example.com", "right-pw");
|
|
259
|
+
await seedExpiredLock(seed.id);
|
|
260
|
+
|
|
261
|
+
const res = await loginAttempt("expired-ok@example.com", "right-pw");
|
|
262
|
+
expect(res.status).toBe(200);
|
|
263
|
+
|
|
264
|
+
// Both count-key and until-key are DEL'd — the successful login is the
|
|
265
|
+
// only path that resets the streak. Verified via getLockoutState (null
|
|
266
|
+
// means count-key is missing).
|
|
267
|
+
expect(await readLockoutState(seed.id)).toBeNull();
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("account-lockout — enumeration surface", () => {
|
|
272
|
+
test("unknown email does not leak the lockout code (stays invalid_credentials)", async () => {
|
|
273
|
+
// We haven't seeded this user. If the lockout gate fired before the
|
|
274
|
+
// "user not found" check, probing would tell an attacker "this user
|
|
275
|
+
// exists AND is locked". Gate must stay AFTER the uniform-error branch.
|
|
276
|
+
const res = await loginAttempt("ghost@example.com", "anything");
|
|
277
|
+
expect(res.status).toBe(401);
|
|
278
|
+
const body = await res.json();
|
|
279
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidCredentials);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
describe("account-lockout — race-free counter under concurrent attempts", () => {
|
|
284
|
+
test("parallel wrong-password attempts produce exact count — no under-counting", async () => {
|
|
285
|
+
// The Lua-scripted recordFailedAttempt is the load-bearing claim for
|
|
286
|
+
// "brute-force protection"; a GET/SET-based store would under-count
|
|
287
|
+
// under parallel load (two writers reading count=N both write N+1 →
|
|
288
|
+
// effective N+1 instead of N+2). Here we fire threshold-many attempts
|
|
289
|
+
// in parallel and assert the counter matches exactly.
|
|
290
|
+
const seed = await seedLoginUser("race@example.com", "right-pw");
|
|
291
|
+
|
|
292
|
+
const parallel = Array.from({ length: MAX_ATTEMPTS }, (_, i) =>
|
|
293
|
+
loginAttempt("race@example.com", `wrong-${i}`),
|
|
294
|
+
);
|
|
295
|
+
const results = await Promise.all(parallel);
|
|
296
|
+
// All of them get 401 (either invalid_credentials or account_locked after
|
|
297
|
+
// threshold — either way, none is a 200-success).
|
|
298
|
+
for (const r of results) {
|
|
299
|
+
expect(r.status).not.toBe(200);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const state = await readLockoutState(seed.id);
|
|
303
|
+
// Key claim: exactly MAX_ATTEMPTS increments landed, not fewer.
|
|
304
|
+
expect(state?.failureCount).toBe(MAX_ATTEMPTS);
|
|
305
|
+
// And since count >= threshold, the lock is active.
|
|
306
|
+
expect(state?.lockedUntil).not.toBeNull();
|
|
307
|
+
});
|
|
308
|
+
});
|