@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,256 @@
|
|
|
1
|
+
// Multi-Rollen — globale user.roles parallel zu tenant-membership.roles.
|
|
2
|
+
//
|
|
3
|
+
// Pin: ein User mit `users.roles = ["SystemAdmin"]` + Membership auf
|
|
4
|
+
// Tenant A mit ["Admin"] hat in der Session BEIDE Rollen. Switch zu
|
|
5
|
+
// Tenant B mit ["User"] → Session hat ["SystemAdmin", "User"]. Globale
|
|
6
|
+
// Rollen bleiben tenant-unabhängig stabil.
|
|
7
|
+
//
|
|
8
|
+
// Gegen-Beweis: User OHNE globale Rollen verhält sich wie vorher
|
|
9
|
+
// (nur tenant-membership-roles in der Session).
|
|
10
|
+
|
|
11
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
12
|
+
import {
|
|
13
|
+
createEntityTable,
|
|
14
|
+
pushTables,
|
|
15
|
+
setupTestStack,
|
|
16
|
+
type TestStack,
|
|
17
|
+
TestUsers,
|
|
18
|
+
testTenantId,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
21
|
+
import { createConfigFeature } from "../../config";
|
|
22
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
23
|
+
import { configValuesTable } from "../../config/table";
|
|
24
|
+
import { createTenantFeature } from "../../tenant";
|
|
25
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
26
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
27
|
+
import { UserHandlers, UserQueries } from "../../user";
|
|
28
|
+
import { createUserFeature } from "../../user/feature";
|
|
29
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
30
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
31
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
32
|
+
import { hashPassword } from "../password-hashing";
|
|
33
|
+
|
|
34
|
+
let stack: TestStack;
|
|
35
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
36
|
+
const tenantA: TenantId = testTenantId(1);
|
|
37
|
+
const tenantB: TenantId = testTenantId(2);
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
const resolver = createConfigResolver();
|
|
41
|
+
|
|
42
|
+
stack = await setupTestStack({
|
|
43
|
+
features: [
|
|
44
|
+
createConfigFeature(),
|
|
45
|
+
createUserFeature(),
|
|
46
|
+
createTenantFeature(),
|
|
47
|
+
createAuthEmailPasswordFeature(),
|
|
48
|
+
],
|
|
49
|
+
extraContext: { configResolver: resolver },
|
|
50
|
+
authConfig: {
|
|
51
|
+
membershipQuery: "tenant:query:memberships",
|
|
52
|
+
// KRITISCH: ohne userQuery wired ruft switch-tenant keinen
|
|
53
|
+
// user-row-lookup → globale Rollen leaken nicht durch zum neuen
|
|
54
|
+
// tenant. Hier explizit setzen damit der merge greift.
|
|
55
|
+
userQuery: UserQueries.findForAuth,
|
|
56
|
+
loginHandler: AuthHandlers.login,
|
|
57
|
+
loginErrorStatusMap: {
|
|
58
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
59
|
+
[AuthErrors.noMembership]: 403,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await createEntityTable(stack.db, userEntity);
|
|
65
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
66
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
afterAll(async () => {
|
|
70
|
+
await stack.cleanup();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
await stack.db.delete(userTable);
|
|
75
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
async function seedUser(
|
|
79
|
+
email: string,
|
|
80
|
+
password: string,
|
|
81
|
+
globalRoles: readonly string[] = [],
|
|
82
|
+
): Promise<string> {
|
|
83
|
+
const hash = await hashPassword(password);
|
|
84
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
85
|
+
UserHandlers.create,
|
|
86
|
+
{
|
|
87
|
+
email,
|
|
88
|
+
passwordHash: hash,
|
|
89
|
+
displayName: email.split("@")[0] ?? "user",
|
|
90
|
+
// user.roles im create wird privileged geprüft — systemAdmin hat
|
|
91
|
+
// SystemAdmin-Rolle (siehe TestUsers.systemAdmin).
|
|
92
|
+
roles: JSON.stringify(globalRoles),
|
|
93
|
+
},
|
|
94
|
+
systemAdmin,
|
|
95
|
+
);
|
|
96
|
+
return created.id;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function addMembership(
|
|
100
|
+
userId: string,
|
|
101
|
+
tenantId: TenantId,
|
|
102
|
+
roles: readonly string[],
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
await stack.db.insert(tenantMembershipsTable).values({
|
|
105
|
+
userId,
|
|
106
|
+
tenantId,
|
|
107
|
+
roles: JSON.stringify(roles),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function login(
|
|
112
|
+
email: string,
|
|
113
|
+
password: string,
|
|
114
|
+
): Promise<{ token: string; user: { id: string; tenantId: string; roles: string[] } }> {
|
|
115
|
+
const res = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
116
|
+
expect(res.status).toBe(200);
|
|
117
|
+
const body = (await res.json()) as {
|
|
118
|
+
token: string;
|
|
119
|
+
user: { id: string; tenantId: string; roles: string[] };
|
|
120
|
+
};
|
|
121
|
+
return body;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("multi-roles: login mergt globale + membership-roles", () => {
|
|
125
|
+
test("user mit ['SystemAdmin'] global + ['Admin'] auf tenantA → session hat beide", async () => {
|
|
126
|
+
const userId = await seedUser("syadmin@example.com", "pw-long-enough", ["SystemAdmin"]);
|
|
127
|
+
await addMembership(userId, tenantA, ["Admin"]);
|
|
128
|
+
|
|
129
|
+
// Pin write-path: roles MUSS in DB landen, sonst ist der session-merge
|
|
130
|
+
// nur Zufall (z.B. wenn login-handler hardcoded SystemAdmin reinpacken
|
|
131
|
+
// würde). Direct DB-read schließt das aus.
|
|
132
|
+
const { eq } = await import("drizzle-orm");
|
|
133
|
+
const dbRow = await stack.db
|
|
134
|
+
.select({ roles: userTable["roles"] })
|
|
135
|
+
.from(userTable)
|
|
136
|
+
.where(eq(userTable["id"], userId));
|
|
137
|
+
expect(dbRow[0]?.roles).toBe(JSON.stringify(["SystemAdmin"]));
|
|
138
|
+
|
|
139
|
+
const { user } = await login("syadmin@example.com", "pw-long-enough");
|
|
140
|
+
expect(user.tenantId).toBe(tenantA);
|
|
141
|
+
expect(user.roles.sort()).toEqual(["Admin", "SystemAdmin"]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("user OHNE globale rollen → nur membership-roles in der session", async () => {
|
|
145
|
+
const userId = await seedUser("plain@example.com", "pw-long-enough");
|
|
146
|
+
await addMembership(userId, tenantA, ["Admin"]);
|
|
147
|
+
|
|
148
|
+
const { user } = await login("plain@example.com", "pw-long-enough");
|
|
149
|
+
expect(user.roles).toEqual(["Admin"]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("globale rollen + tenant-rollen mit overlap → dedupliziert (kein doppeltes Admin)", async () => {
|
|
153
|
+
const userId = await seedUser("dup@example.com", "pw-long-enough", ["Admin", "SystemAdmin"]);
|
|
154
|
+
await addMembership(userId, tenantA, ["Admin", "User"]);
|
|
155
|
+
|
|
156
|
+
const { user } = await login("dup@example.com", "pw-long-enough");
|
|
157
|
+
expect(user.roles.sort()).toEqual(["Admin", "SystemAdmin", "User"]);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("multi-roles: privilege-escalation blocked via field-level write-access", () => {
|
|
162
|
+
test("Tenant-Admin (ohne SystemAdmin) kann user.roles NICHT setzen via update", async () => {
|
|
163
|
+
// Setup: User mit ["Admin"] auf tenantA (Tenant-Admin) + ein Target-User
|
|
164
|
+
// mit leeren globalRoles. Tenant-Admin versucht den Target via
|
|
165
|
+
// user:write:user:update auf SystemAdmin zu eskalieren.
|
|
166
|
+
const adminId = await seedUser("ta@example.com", "pw-long-enough");
|
|
167
|
+
await addMembership(adminId, tenantA, ["Admin"]);
|
|
168
|
+
const tenantAdminSession = { id: adminId, tenantId: tenantA, roles: ["Admin"] };
|
|
169
|
+
|
|
170
|
+
// Target user mit version 1 (frisch erstellt).
|
|
171
|
+
const targetId = await seedUser("victim@example.com", "pw-long-enough");
|
|
172
|
+
|
|
173
|
+
const res = await stack.http.write(
|
|
174
|
+
UserHandlers.update,
|
|
175
|
+
{
|
|
176
|
+
id: targetId,
|
|
177
|
+
version: 1,
|
|
178
|
+
changes: { roles: JSON.stringify(["SystemAdmin"]) },
|
|
179
|
+
},
|
|
180
|
+
tenantAdminSession,
|
|
181
|
+
);
|
|
182
|
+
// Field-level guard greift VOR dem handler — AccessDeniedError mit
|
|
183
|
+
// field=roles. Das pinst: kein "silent drop", sondern hard-fail.
|
|
184
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
185
|
+
expect(res.status).toBeLessThan(500);
|
|
186
|
+
const body = (await res.json()) as {
|
|
187
|
+
error?: { details?: { field?: string; reason?: string } };
|
|
188
|
+
};
|
|
189
|
+
expect(body.error?.details?.field).toBe("roles");
|
|
190
|
+
expect(body.error?.details?.reason).toBe("field_access_denied");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("Self-update mit roles → blocked (kein Privilege-Selbst-Erteilung)", async () => {
|
|
194
|
+
// Auch wenn isSelf-check im handler den ownership-guard umgeht,
|
|
195
|
+
// greift der field-level write-access auf roles unabhängig: User
|
|
196
|
+
// kann sich nicht selbst zum SystemAdmin machen.
|
|
197
|
+
const userId = await seedUser("selfescalate@example.com", "pw-long-enough");
|
|
198
|
+
await addMembership(userId, tenantA, ["User"]);
|
|
199
|
+
const userSession = { id: userId, tenantId: tenantA, roles: ["User"] };
|
|
200
|
+
|
|
201
|
+
const res = await stack.http.write(
|
|
202
|
+
UserHandlers.update,
|
|
203
|
+
{
|
|
204
|
+
id: userId,
|
|
205
|
+
version: 1,
|
|
206
|
+
changes: { roles: JSON.stringify(["SystemAdmin"]) },
|
|
207
|
+
},
|
|
208
|
+
userSession,
|
|
209
|
+
);
|
|
210
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
211
|
+
expect(res.status).toBeLessThan(500);
|
|
212
|
+
const body = (await res.json()) as {
|
|
213
|
+
error?: { details?: { field?: string } };
|
|
214
|
+
};
|
|
215
|
+
expect(body.error?.details?.field).toBe("roles");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("multi-roles: switch-tenant erhält globale rollen", () => {
|
|
220
|
+
test("switch von tenantA → tenantB → SystemAdmin bleibt, tenant-roles wechseln", async () => {
|
|
221
|
+
const userId = await seedUser("syadmin2@example.com", "pw-long-enough", ["SystemAdmin"]);
|
|
222
|
+
await addMembership(userId, tenantA, ["Admin"]);
|
|
223
|
+
await addMembership(userId, tenantB, ["User"]);
|
|
224
|
+
|
|
225
|
+
const { token } = await login("syadmin2@example.com", "pw-long-enough");
|
|
226
|
+
|
|
227
|
+
const switchRes = await stack.http.raw(
|
|
228
|
+
"POST",
|
|
229
|
+
"/api/auth/switch-tenant",
|
|
230
|
+
{ tenantId: tenantB },
|
|
231
|
+
{ authorization: `Bearer ${token}` },
|
|
232
|
+
);
|
|
233
|
+
expect(switchRes.status).toBe(200);
|
|
234
|
+
const switchBody = (await switchRes.json()) as { tenantId: string; roles: string[] };
|
|
235
|
+
expect(switchBody.tenantId).toBe(tenantB);
|
|
236
|
+
expect([...switchBody.roles].sort()).toEqual(["SystemAdmin", "User"]);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("switch ohne globale rollen → roles wechseln 1:1 zu membership", async () => {
|
|
240
|
+
const userId = await seedUser("plain2@example.com", "pw-long-enough");
|
|
241
|
+
await addMembership(userId, tenantA, ["Admin"]);
|
|
242
|
+
await addMembership(userId, tenantB, ["User"]);
|
|
243
|
+
|
|
244
|
+
const { token } = await login("plain2@example.com", "pw-long-enough");
|
|
245
|
+
|
|
246
|
+
const switchRes = await stack.http.raw(
|
|
247
|
+
"POST",
|
|
248
|
+
"/api/auth/switch-tenant",
|
|
249
|
+
{ tenantId: tenantB },
|
|
250
|
+
{ authorization: `Bearer ${token}` },
|
|
251
|
+
);
|
|
252
|
+
expect(switchRes.status).toBe(200);
|
|
253
|
+
const switchBody = (await switchRes.json()) as { roles: string[] };
|
|
254
|
+
expect(switchBody.roles).toEqual(["User"]);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
4
|
+
import {
|
|
5
|
+
createEntityTable,
|
|
6
|
+
pushTables,
|
|
7
|
+
setupTestStack,
|
|
8
|
+
type TestStack,
|
|
9
|
+
TestUsers,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
11
|
+
import { Temporal } from "temporal-polyfill";
|
|
12
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
13
|
+
import { createConfigFeature } from "../../config";
|
|
14
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
15
|
+
import { configValuesTable } from "../../config/table";
|
|
16
|
+
import { createSessionsFeature, userSessionTable } from "../../sessions";
|
|
17
|
+
import { createTenantFeature } from "../../tenant";
|
|
18
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
19
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
20
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
21
|
+
import { UserHandlers } from "../../user";
|
|
22
|
+
import { createUserFeature } from "../../user/feature";
|
|
23
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
24
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
25
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
26
|
+
import { hashPassword, verifyPassword } from "../password-hashing";
|
|
27
|
+
import { signResetToken } from "../reset-token";
|
|
28
|
+
|
|
29
|
+
// Signed tokens are forwarded out-of-band (email). In the test we grab them
|
|
30
|
+
// from the sendResetEmail callback instead.
|
|
31
|
+
const capturedEmails: Array<{ email: string; resetUrl: string; expiresAt: string }> = [];
|
|
32
|
+
|
|
33
|
+
// Records the userId every time the sessions feature's auto-revoke hook
|
|
34
|
+
// fires after a password change. The session-revoke tests assert on this
|
|
35
|
+
// list — we don't need a full session store, just proof the hook fired.
|
|
36
|
+
const autoRevokeCalls: string[] = [];
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
40
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
41
|
+
const resetSecret = randomBytes(32).toString("base64");
|
|
42
|
+
const appResetUrl = "https://app.example.com/reset";
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
46
|
+
const resolver = createConfigResolver({ encryption });
|
|
47
|
+
|
|
48
|
+
stack = await setupTestStack({
|
|
49
|
+
features: [
|
|
50
|
+
createConfigFeature(),
|
|
51
|
+
createUserFeature(),
|
|
52
|
+
createTenantFeature(),
|
|
53
|
+
createAuthEmailPasswordFeature({
|
|
54
|
+
passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15 },
|
|
55
|
+
}),
|
|
56
|
+
// Sessions feature wires the cross-feature entityHook on
|
|
57
|
+
// "user.postSave" that triggers autoRevokeOnPasswordChange whenever
|
|
58
|
+
// the passwordHash delta is present. Integration-test proves the
|
|
59
|
+
// reset-flow's changes.passwordHash triggers the same hook.
|
|
60
|
+
createSessionsFeature({
|
|
61
|
+
autoRevokeOnPasswordChange: async (userId) => {
|
|
62
|
+
autoRevokeCalls.push(userId);
|
|
63
|
+
return 0; // no real session store behind this spy
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
],
|
|
67
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
68
|
+
authConfig: {
|
|
69
|
+
membershipQuery: "tenant:query:memberships",
|
|
70
|
+
loginHandler: AuthHandlers.login,
|
|
71
|
+
passwordReset: {
|
|
72
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
73
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
74
|
+
appResetUrl,
|
|
75
|
+
sendResetEmail: async (args) => {
|
|
76
|
+
capturedEmails.push(args);
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
await createEntityTable(stack.db, userEntity);
|
|
83
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
84
|
+
await pushTables(stack.db, {
|
|
85
|
+
configValuesTable,
|
|
86
|
+
tenantMembershipsTable,
|
|
87
|
+
userSessionTable,
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
afterAll(async () => {
|
|
92
|
+
await stack.cleanup();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
beforeEach(async () => {
|
|
96
|
+
await stack.db.delete(userTable);
|
|
97
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
98
|
+
await stack.db.delete(userSessionTable);
|
|
99
|
+
capturedEmails.length = 0;
|
|
100
|
+
autoRevokeCalls.length = 0;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
async function seedUser(opts: {
|
|
104
|
+
email: string;
|
|
105
|
+
password: string;
|
|
106
|
+
tenantId?: TenantId;
|
|
107
|
+
}): Promise<{ id: string; tenantId: TenantId }> {
|
|
108
|
+
const hash = await hashPassword(opts.password);
|
|
109
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
110
|
+
UserHandlers.create,
|
|
111
|
+
{
|
|
112
|
+
email: opts.email,
|
|
113
|
+
passwordHash: hash,
|
|
114
|
+
displayName: opts.email.split("@")[0] ?? "user",
|
|
115
|
+
},
|
|
116
|
+
systemAdmin,
|
|
117
|
+
);
|
|
118
|
+
const tenantId = opts.tenantId ?? "00000000-0000-4000-8000-000000000001";
|
|
119
|
+
await seedTenantMembership(stack.db, {
|
|
120
|
+
userId: created.id,
|
|
121
|
+
tenantId,
|
|
122
|
+
roles: ["User"],
|
|
123
|
+
});
|
|
124
|
+
return { id: created.id, tenantId };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function post(path: string, body: unknown): Promise<Response> {
|
|
128
|
+
return stack.http.raw("POST", path, body);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- request-password-reset -----------------------------------------------
|
|
132
|
+
|
|
133
|
+
describe("POST /auth/request-password-reset", () => {
|
|
134
|
+
test("known email → 200, email callback invoked with reset URL", async () => {
|
|
135
|
+
await seedUser({ email: "alice@example.com", password: "initial-pw!" });
|
|
136
|
+
|
|
137
|
+
const res = await post("/api/auth/request-password-reset", { email: "alice@example.com" });
|
|
138
|
+
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
expect(await res.json()).toEqual({ isSuccess: true });
|
|
141
|
+
expect(capturedEmails).toHaveLength(1);
|
|
142
|
+
const [captured] = capturedEmails;
|
|
143
|
+
if (!captured) throw new Error("no email captured");
|
|
144
|
+
expect(captured.email).toBe("alice@example.com");
|
|
145
|
+
expect(captured.resetUrl.startsWith(`${appResetUrl}?token=`)).toBe(true);
|
|
146
|
+
expect(typeof captured.expiresAt).toBe("string");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unknown email → 200 with NO sendResetEmail side-effect (enumeration-safe)", async () => {
|
|
150
|
+
const res = await post("/api/auth/request-password-reset", { email: "ghost@example.com" });
|
|
151
|
+
|
|
152
|
+
expect(res.status).toBe(200);
|
|
153
|
+
expect(await res.json()).toEqual({ isSuccess: true });
|
|
154
|
+
expect(capturedEmails).toHaveLength(0);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("malformed body → 200 (silent success, no enumeration via error shape)", async () => {
|
|
158
|
+
const res = await post("/api/auth/request-password-reset", { wrong: "shape" });
|
|
159
|
+
expect(res.status).toBe(200);
|
|
160
|
+
expect(await res.json()).toEqual({ isSuccess: true });
|
|
161
|
+
expect(capturedEmails).toHaveLength(0);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// --- reset-password --------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
describe("POST /auth/reset-password", () => {
|
|
168
|
+
test("valid token → password set; login works with new password", async () => {
|
|
169
|
+
const seed = await seedUser({ email: "bob@example.com", password: "old-pw-1234" });
|
|
170
|
+
|
|
171
|
+
// Generate the token the same way the handler does — bypassing the email
|
|
172
|
+
// hop keeps the test deterministic.
|
|
173
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
174
|
+
|
|
175
|
+
const res = await post("/api/auth/reset-password", {
|
|
176
|
+
token,
|
|
177
|
+
newPassword: "brand-new-pw-9876",
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
expect(res.status).toBe(200);
|
|
181
|
+
expect(await res.json()).toEqual({ isSuccess: true });
|
|
182
|
+
|
|
183
|
+
// Proof: the new password actually hashes in. Read the row, verify the
|
|
184
|
+
// hash matches the new plaintext.
|
|
185
|
+
const row = (await stack.db.select().from(userTable)).find((r) => r["id"] === seed.id);
|
|
186
|
+
if (!row?.["passwordHash"]) throw new Error("user row / hash missing");
|
|
187
|
+
expect(await verifyPassword(row["passwordHash"] as string, "brand-new-pw-9876")).toBe(true);
|
|
188
|
+
expect(await verifyPassword(row["passwordHash"] as string, "old-pw-1234")).toBe(false);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("tampered token → 422 invalid_reset_token", async () => {
|
|
192
|
+
const seed = await seedUser({ email: "carol@example.com", password: "keep-me!" });
|
|
193
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
194
|
+
const tampered = `${token.slice(0, -3)}XXX`;
|
|
195
|
+
|
|
196
|
+
const res = await post("/api/auth/reset-password", {
|
|
197
|
+
token: tampered,
|
|
198
|
+
newPassword: "new-password-1234",
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
expect(res.status).toBe(422);
|
|
202
|
+
const body = await res.json();
|
|
203
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
|
|
204
|
+
|
|
205
|
+
// Old password still wins.
|
|
206
|
+
const row = (await stack.db.select().from(userTable)).find((r) => r["id"] === seed.id);
|
|
207
|
+
if (!row?.["passwordHash"]) throw new Error("user row / hash missing");
|
|
208
|
+
expect(await verifyPassword(row["passwordHash"] as string, "keep-me!")).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("token signed with different secret → 422 (not auth via other deployments' tokens)", async () => {
|
|
212
|
+
const seed = await seedUser({ email: "dave@example.com", password: "original" });
|
|
213
|
+
const { token } = signResetToken(seed.id, 15, "wrong-secret-wrong-secret-wrong!!");
|
|
214
|
+
|
|
215
|
+
const res = await post("/api/auth/reset-password", {
|
|
216
|
+
token,
|
|
217
|
+
newPassword: "should-not-stick-1234",
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(res.status).toBe(422);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("too-short newPassword → 400 (schema rejects <8 chars)", async () => {
|
|
224
|
+
const seed = await seedUser({ email: "eve@example.com", password: "original" });
|
|
225
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
226
|
+
|
|
227
|
+
const res = await post("/api/auth/reset-password", {
|
|
228
|
+
token,
|
|
229
|
+
newPassword: "tiny",
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
expect(res.status).toBe(400);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("expired token via the route → 422 invalid_reset_token", async () => {
|
|
236
|
+
const seed = await seedUser({ email: "time@example.com", password: "once-valid-1234" });
|
|
237
|
+
// Sign with now set far in the past so expiry already fired.
|
|
238
|
+
const past = Temporal.Now.instant().subtract({ minutes: 30 });
|
|
239
|
+
const { token } = signResetToken(seed.id, 15, resetSecret, past);
|
|
240
|
+
|
|
241
|
+
const res = await post("/api/auth/reset-password", {
|
|
242
|
+
token,
|
|
243
|
+
newPassword: "brand-new-pw-time",
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
expect(res.status).toBe(422);
|
|
247
|
+
const body = await res.json();
|
|
248
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("reset that fails before the write is retryable (burn is released on failure)", async () => {
|
|
252
|
+
// The burn marker goes down BEFORE the state change so a racing replay
|
|
253
|
+
// can't slip through. But if the state change itself fails — e.g. no
|
|
254
|
+
// memberships in the row, every tenant stream rejected, DB error —
|
|
255
|
+
// the token was never actually consumed. The handler releases the
|
|
256
|
+
// burn in those branches so the user can click the link again
|
|
257
|
+
// without hitting a stuck "already-used".
|
|
258
|
+
//
|
|
259
|
+
// Repro: drop the user's membership → tenantOrder is empty →
|
|
260
|
+
// invalidToken + unburn. Re-insert membership → second attempt
|
|
261
|
+
// with the same token succeeds (proves the burn was released).
|
|
262
|
+
const seed = await seedUser({ email: "retry@example.com", password: "pw-retry-1234" });
|
|
263
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
264
|
+
|
|
265
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
266
|
+
const firstAttempt = await post("/api/auth/reset-password", {
|
|
267
|
+
token,
|
|
268
|
+
newPassword: "never-lands-1234",
|
|
269
|
+
});
|
|
270
|
+
expect(firstAttempt.status).toBe(422);
|
|
271
|
+
|
|
272
|
+
// Re-insert the membership. Same userId, same token still valid.
|
|
273
|
+
await seedTenantMembership(stack.db, {
|
|
274
|
+
userId: seed.id,
|
|
275
|
+
tenantId: seed.tenantId,
|
|
276
|
+
roles: ["User"],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const secondAttempt = await post("/api/auth/reset-password", {
|
|
280
|
+
token,
|
|
281
|
+
newPassword: "finally-lands-1234",
|
|
282
|
+
});
|
|
283
|
+
expect(secondAttempt.status).toBe(200);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("replayed reset-token → 422 invalid_reset_token (single-use burn)", async () => {
|
|
287
|
+
// Reset tokens are single-use: the handler burns them in Redis via
|
|
288
|
+
// SETNX before the state change. First click wins; replay within TTL
|
|
289
|
+
// collapses to the same invalid_reset_token code as a tampered or
|
|
290
|
+
// expired token — no leak that "this token was legitimately used".
|
|
291
|
+
const seed = await seedUser({ email: "twice@example.com", password: "first-pw-1234" });
|
|
292
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
293
|
+
|
|
294
|
+
const first = await post("/api/auth/reset-password", { token, newPassword: "next-pw-1234" });
|
|
295
|
+
expect(first.status).toBe(200);
|
|
296
|
+
|
|
297
|
+
const second = await post("/api/auth/reset-password", {
|
|
298
|
+
token,
|
|
299
|
+
newPassword: "yet-another-pw-1234",
|
|
300
|
+
});
|
|
301
|
+
expect(second.status).toBe(422);
|
|
302
|
+
const body = await second.json();
|
|
303
|
+
expect(body.error?.details?.reason).toBe(AuthErrors.invalidResetToken);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// --- session auto-revoke (H.3 cross-feature hook) -------------------------
|
|
308
|
+
|
|
309
|
+
describe("reset-password triggers session auto-revoke", () => {
|
|
310
|
+
test("successful reset fires the sessions-feature entityHook on user", async () => {
|
|
311
|
+
const seed = await seedUser({
|
|
312
|
+
email: "revokeme@example.com",
|
|
313
|
+
password: "hack-exposed-1234",
|
|
314
|
+
});
|
|
315
|
+
const { token } = signResetToken(seed.id, 15, resetSecret);
|
|
316
|
+
|
|
317
|
+
const res = await post("/api/auth/reset-password", {
|
|
318
|
+
token,
|
|
319
|
+
newPassword: "fresh-secure-1234",
|
|
320
|
+
});
|
|
321
|
+
expect(res.status).toBe(200);
|
|
322
|
+
|
|
323
|
+
// The sessions feature registered r.entityHook("postSave", "user", ...)
|
|
324
|
+
// with autoRevokeOnPasswordChange. Reset writes changes.passwordHash
|
|
325
|
+
// through user:update → hook fires → spy records the userId. Without
|
|
326
|
+
// this assertion the commit's "session revocation" claim is unverified.
|
|
327
|
+
expect(autoRevokeCalls).toEqual([seed.id]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("failed reset (invalid token) does NOT trigger auto-revoke", async () => {
|
|
331
|
+
const seed = await seedUser({
|
|
332
|
+
email: "keepme@example.com",
|
|
333
|
+
password: "still-mine-1234",
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
const res = await post("/api/auth/reset-password", {
|
|
337
|
+
token: "fake.1234567890.whatever",
|
|
338
|
+
newPassword: "does-not-matter-1234",
|
|
339
|
+
});
|
|
340
|
+
expect(res.status).toBe(422);
|
|
341
|
+
// No passwordHash write → no hook → no revoke. Otherwise a garbage-
|
|
342
|
+
// token spammer could log everyone out.
|
|
343
|
+
expect(autoRevokeCalls).toEqual([]);
|
|
344
|
+
expect(seed.id).toBeTruthy(); // silence lint on unused var
|
|
345
|
+
});
|
|
346
|
+
});
|