@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,144 @@
|
|
|
1
|
+
// Proves the L2 rate-limit (authEndpointRateLimit, Sprint G.5) actually
|
|
2
|
+
// covers the public password-reset + email-verification routes. Without
|
|
3
|
+
// this test the commit message's "Rate-Limit via L2" claim is just a
|
|
4
|
+
// comment — a regression that silently moves the routes out of /api/auth/*
|
|
5
|
+
// or tightens loginRateLimit but forgets these would sail through.
|
|
6
|
+
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
|
|
9
|
+
import {
|
|
10
|
+
createEntityTable,
|
|
11
|
+
pushTables,
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
15
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
16
|
+
import { createConfigFeature } from "../../config";
|
|
17
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
18
|
+
import { configValuesTable } from "../../config/table";
|
|
19
|
+
import { createTenantFeature } from "../../tenant";
|
|
20
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
21
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
22
|
+
import { createUserFeature } from "../../user/feature";
|
|
23
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
24
|
+
import { AuthHandlers } from "../constants";
|
|
25
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
26
|
+
|
|
27
|
+
let stack: TestStack;
|
|
28
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
29
|
+
const resetSecret = randomBytes(32).toString("base64");
|
|
30
|
+
const verifySecret = randomBytes(32).toString("base64");
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
34
|
+
const resolver = createConfigResolver({ encryption });
|
|
35
|
+
|
|
36
|
+
stack = await setupTestStack({
|
|
37
|
+
features: [
|
|
38
|
+
createConfigFeature(),
|
|
39
|
+
createUserFeature(),
|
|
40
|
+
createTenantFeature(),
|
|
41
|
+
createAuthEmailPasswordFeature({
|
|
42
|
+
passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15 },
|
|
43
|
+
emailVerification: { hmacSecret: verifySecret, tokenTtlMinutes: 60, mode: "strict" },
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
47
|
+
authConfig: {
|
|
48
|
+
membershipQuery: "tenant:query:memberships",
|
|
49
|
+
loginHandler: AuthHandlers.login,
|
|
50
|
+
passwordReset: {
|
|
51
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
52
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
53
|
+
appResetUrl: "https://app.example.com/reset",
|
|
54
|
+
sendResetEmail: async () => {},
|
|
55
|
+
},
|
|
56
|
+
emailVerification: {
|
|
57
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
58
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
59
|
+
appVerifyUrl: "https://app.example.com/verify",
|
|
60
|
+
sendVerificationEmail: async () => {},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
// Tight limit so the test trips it with a small number of requests,
|
|
64
|
+
// short window so a flaky re-run doesn't keep the bucket full.
|
|
65
|
+
rateLimit: {
|
|
66
|
+
auth: { limit: 2, windowSeconds: 60, onFailClosed: () => {} },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await createEntityTable(stack.db, userEntity);
|
|
71
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
72
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterAll(async () => {
|
|
76
|
+
await stack.cleanup();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
beforeEach(async () => {
|
|
80
|
+
await stack.db.delete(userTable);
|
|
81
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Unique IP per test so buckets don't cross-contaminate. L2 default bucket
|
|
85
|
+
// is ip+path; we rely on that to keep password-reset and verify-email
|
|
86
|
+
// independent in their own test.
|
|
87
|
+
function withIp(ip: string): HeadersInit {
|
|
88
|
+
return { "Content-Type": "application/json", "x-forwarded-for": ip };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function postFrom(path: string, ip: string, body: unknown): Promise<Response> {
|
|
92
|
+
return stack.app.request(path, {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: withIp(ip),
|
|
95
|
+
body: JSON.stringify(body),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("L2 rate-limit covers public token routes", () => {
|
|
100
|
+
test("/auth/request-password-reset → 429 after 2 hits from same IP", async () => {
|
|
101
|
+
const ip = "10.50.0.1";
|
|
102
|
+
const path = "/api/auth/request-password-reset";
|
|
103
|
+
|
|
104
|
+
const a = await postFrom(path, ip, { email: "a@example.com" });
|
|
105
|
+
const b = await postFrom(path, ip, { email: "b@example.com" });
|
|
106
|
+
const c = await postFrom(path, ip, { email: "c@example.com" });
|
|
107
|
+
|
|
108
|
+
expect(a.status).toBe(200);
|
|
109
|
+
expect(b.status).toBe(200);
|
|
110
|
+
expect(c.status).toBe(429);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("/auth/verify-email → 429 after 2 hits from same IP", async () => {
|
|
114
|
+
const ip = "10.50.0.2";
|
|
115
|
+
const path = "/api/auth/verify-email";
|
|
116
|
+
|
|
117
|
+
const a = await postFrom(path, ip, { token: "not-a-real-token.1.sig" });
|
|
118
|
+
const b = await postFrom(path, ip, { token: "not-a-real-token.1.sig" });
|
|
119
|
+
const c = await postFrom(path, ip, { token: "not-a-real-token.1.sig" });
|
|
120
|
+
|
|
121
|
+
// 422 / 400 for the first two — the handler rejects the garbage token.
|
|
122
|
+
// The important assertion: the THIRD hit comes back 429 before reaching
|
|
123
|
+
// the handler, proving the L2 middleware is in front of the route.
|
|
124
|
+
expect(a.status).not.toBe(429);
|
|
125
|
+
expect(b.status).not.toBe(429);
|
|
126
|
+
expect(c.status).toBe(429);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("different IPs buckets independently — one flooder doesn't lock out another", async () => {
|
|
130
|
+
const attacker = "10.50.0.100";
|
|
131
|
+
const victim = "10.50.0.101";
|
|
132
|
+
const path = "/api/auth/request-password-reset";
|
|
133
|
+
|
|
134
|
+
// Burn the attacker's quota.
|
|
135
|
+
await postFrom(path, attacker, { email: "bad@example.com" });
|
|
136
|
+
await postFrom(path, attacker, { email: "bad@example.com" });
|
|
137
|
+
const attackerBlocked = await postFrom(path, attacker, { email: "bad@example.com" });
|
|
138
|
+
expect(attackerBlocked.status).toBe(429);
|
|
139
|
+
|
|
140
|
+
// Victim IP still has a fresh bucket.
|
|
141
|
+
const victimFirst = await postFrom(path, victim, { email: "good@example.com" });
|
|
142
|
+
expect(victimFirst.status).toBe(200);
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Tests für die seedAdmin-Convenience aus auth-email-password/testing.
|
|
2
|
+
//
|
|
3
|
+
// Wert: seedAdmin orchestriert seedTenant × N + seedUserWithPassword +
|
|
4
|
+
// seedTenantMembership × N. Die Einzel-Helper haben jeweils eigene Tests
|
|
5
|
+
// (tenant/seed-testing, user/seed-testing). Hier prüfen wir nur was
|
|
6
|
+
// SEEDADMIN selber zusagt:
|
|
7
|
+
// 1. Reihenfolge stimmt — Tenants vor User vor Memberships.
|
|
8
|
+
// 2. Password wird mit argon2 gehasht und ist via verifyPassword(plain, hash) gültig.
|
|
9
|
+
// 3. Re-Run ist idempotent (für persistent-DB-Modus im dev-server).
|
|
10
|
+
// 4. Rollen pro Tenant landen korrekt (unterschiedliche Rollen-Listen
|
|
11
|
+
// pro Membership).
|
|
12
|
+
|
|
13
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
+
import {
|
|
16
|
+
createEntityTable,
|
|
17
|
+
pushTables,
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
import { and, eq } from "drizzle-orm";
|
|
22
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
23
|
+
import { createConfigFeature } from "../../config/feature";
|
|
24
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
25
|
+
import { configValuesTable } from "../../config/table";
|
|
26
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
27
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
28
|
+
import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
|
|
29
|
+
import { createUserFeature } from "../../user/feature";
|
|
30
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
31
|
+
import { verifyPassword } from "../password-hashing";
|
|
32
|
+
import { seedAdmin } from "../testing";
|
|
33
|
+
|
|
34
|
+
let stack: TestStack;
|
|
35
|
+
|
|
36
|
+
const TENANT_DEV: TenantId = "00000000-0000-4000-8000-000000000d11" as TenantId;
|
|
37
|
+
const TENANT_BETA: TenantId = "00000000-0000-4000-8000-000000000be1" as TenantId;
|
|
38
|
+
|
|
39
|
+
beforeAll(async () => {
|
|
40
|
+
const resolver = createConfigResolver();
|
|
41
|
+
stack = await setupTestStack({
|
|
42
|
+
features: [createConfigFeature(), createUserFeature(), createTenantFeature()],
|
|
43
|
+
extraContext: { configResolver: resolver },
|
|
44
|
+
});
|
|
45
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
46
|
+
await createEntityTable(stack.db, userEntity);
|
|
47
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
48
|
+
await createEventsTable(stack.db);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterAll(async () => {
|
|
52
|
+
await stack.cleanup();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
57
|
+
await stack.db.delete(tenantTable);
|
|
58
|
+
await stack.db.delete(userTable);
|
|
59
|
+
await stack.db.delete(eventsTable);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("seedAdmin", () => {
|
|
63
|
+
test("legt Tenants, User mit gehashtem Password und Memberships an — Login-Roundtrip funktioniert", async () => {
|
|
64
|
+
const userId = await seedAdmin(stack.db, {
|
|
65
|
+
email: "admin@example.com",
|
|
66
|
+
password: "secret-pw",
|
|
67
|
+
displayName: "Admin",
|
|
68
|
+
memberships: [
|
|
69
|
+
{
|
|
70
|
+
tenantId: TENANT_DEV,
|
|
71
|
+
tenantKey: "dev",
|
|
72
|
+
tenantName: "Dev",
|
|
73
|
+
roles: ["Admin"],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
tenantId: TENANT_BETA,
|
|
77
|
+
tenantKey: "beta",
|
|
78
|
+
tenantName: "Beta",
|
|
79
|
+
roles: ["User"],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Tenants angelegt
|
|
85
|
+
const tenants = await stack.db.select().from(tenantTable);
|
|
86
|
+
expect(tenants.map((t) => t["id"]).sort()).toEqual([TENANT_DEV, TENANT_BETA].sort());
|
|
87
|
+
|
|
88
|
+
// User angelegt mit Hash (NICHT plain-Password)
|
|
89
|
+
const [user] = await stack.db
|
|
90
|
+
.select()
|
|
91
|
+
.from(userTable)
|
|
92
|
+
.where(eq(userTable["email"], "admin@example.com"));
|
|
93
|
+
expect(user?.["id"]).toBe(userId);
|
|
94
|
+
expect(user?.["passwordHash"]).not.toBe("secret-pw");
|
|
95
|
+
expect(user?.["passwordHash"]).toMatch(/^\$argon2/);
|
|
96
|
+
|
|
97
|
+
// verifyPassword(hash, plain) — Login-Pfad würde durchgehen.
|
|
98
|
+
const valid = await verifyPassword(user?.["passwordHash"] as string, "secret-pw");
|
|
99
|
+
expect(valid).toBe(true);
|
|
100
|
+
const invalid = await verifyPassword(user?.["passwordHash"] as string, "wrong-pw");
|
|
101
|
+
expect(invalid).toBe(false);
|
|
102
|
+
|
|
103
|
+
// Memberships pro Tenant mit unterschiedlichen Rollen
|
|
104
|
+
const devMembership = await stack.db
|
|
105
|
+
.select()
|
|
106
|
+
.from(tenantMembershipsTable)
|
|
107
|
+
.where(
|
|
108
|
+
and(
|
|
109
|
+
eq(tenantMembershipsTable.userId, userId),
|
|
110
|
+
eq(tenantMembershipsTable.tenantId, TENANT_DEV),
|
|
111
|
+
),
|
|
112
|
+
);
|
|
113
|
+
expect(devMembership[0]?.["roles"]).toBe(JSON.stringify(["Admin"]));
|
|
114
|
+
|
|
115
|
+
const betaMembership = await stack.db
|
|
116
|
+
.select()
|
|
117
|
+
.from(tenantMembershipsTable)
|
|
118
|
+
.where(
|
|
119
|
+
and(
|
|
120
|
+
eq(tenantMembershipsTable.userId, userId),
|
|
121
|
+
eq(tenantMembershipsTable.tenantId, TENANT_BETA),
|
|
122
|
+
),
|
|
123
|
+
);
|
|
124
|
+
expect(betaMembership[0]?.["roles"]).toBe(JSON.stringify(["User"]));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("idempotent: zweiter Aufruf no-op (kein Crash, Stand bleibt)", async () => {
|
|
128
|
+
// Erstaufruf
|
|
129
|
+
const userId1 = await seedAdmin(stack.db, {
|
|
130
|
+
email: "admin@example.com",
|
|
131
|
+
password: "pw1",
|
|
132
|
+
displayName: "Admin",
|
|
133
|
+
memberships: [
|
|
134
|
+
{ tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
|
|
135
|
+
],
|
|
136
|
+
});
|
|
137
|
+
// Zweiter Aufruf — gleicher Email, anderes Password (würde theoretisch
|
|
138
|
+
// einen neuen Hash erzeugen und neu schreiben, der idempotent-Check
|
|
139
|
+
// greift VOR dem Insert).
|
|
140
|
+
const userId2 = await seedAdmin(stack.db, {
|
|
141
|
+
email: "admin@example.com",
|
|
142
|
+
password: "pw2",
|
|
143
|
+
displayName: "Admin",
|
|
144
|
+
memberships: [
|
|
145
|
+
{ tenantId: TENANT_DEV, tenantKey: "dev", tenantName: "Dev", roles: ["Admin"] },
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
expect(userId2).toBe(userId1);
|
|
149
|
+
|
|
150
|
+
// Genau ein User-Row, original-Hash (passt zu pw1, nicht pw2).
|
|
151
|
+
const users = await stack.db.select().from(userTable);
|
|
152
|
+
expect(users).toHaveLength(1);
|
|
153
|
+
const valid = await verifyPassword(users[0]?.["passwordHash"] as string, "pw1");
|
|
154
|
+
expect(valid).toBe(true);
|
|
155
|
+
const invalid = await verifyPassword(users[0]?.["passwordHash"] as string, "pw2");
|
|
156
|
+
expect(invalid).toBe(false);
|
|
157
|
+
|
|
158
|
+
// Genau ein Membership-Row.
|
|
159
|
+
const memberships = await stack.db.select().from(tenantMembershipsTable);
|
|
160
|
+
expect(memberships).toHaveLength(1);
|
|
161
|
+
|
|
162
|
+
// Genau ein .created-Event pro Aggregat-Typ.
|
|
163
|
+
const events = await stack.db.select().from(eventsTable);
|
|
164
|
+
const createdByType = events
|
|
165
|
+
.filter((e) => e.type.endsWith(".created"))
|
|
166
|
+
.reduce<Record<string, number>>((acc, e) => {
|
|
167
|
+
acc[e.aggregateType] = (acc[e.aggregateType] ?? 0) + 1;
|
|
168
|
+
return acc;
|
|
169
|
+
}, {});
|
|
170
|
+
expect(createdByType).toEqual({
|
|
171
|
+
tenant: 1,
|
|
172
|
+
user: 1,
|
|
173
|
+
"tenant-membership": 1,
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
@@ -0,0 +1,310 @@
|
|
|
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 * as jose from "jose";
|
|
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 { createTenantFeature } from "../../tenant";
|
|
17
|
+
import { tenantMembershipsTable } from "../../tenant/membership-table";
|
|
18
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
19
|
+
import { seedTenantMembership } from "../../tenant/testing";
|
|
20
|
+
import { UserHandlers } from "../../user";
|
|
21
|
+
import { createUserFeature } from "../../user/feature";
|
|
22
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
23
|
+
import { AuthErrors, AuthHandlers } from "../constants";
|
|
24
|
+
import { createAuthEmailPasswordFeature } from "../feature";
|
|
25
|
+
import { hashPassword } from "../password-hashing";
|
|
26
|
+
|
|
27
|
+
// In-memory fake of a real sessions-store — just enough to observe that the
|
|
28
|
+
// framework calls the callbacks at the right moments and threads the sid back
|
|
29
|
+
// through the JWT. The real sessions feature will persist to a table; this
|
|
30
|
+
// test cares only about the wiring.
|
|
31
|
+
type FakeStore = {
|
|
32
|
+
live: Set<string>;
|
|
33
|
+
created: Array<{ sid: string; userId: string; tenantId: TenantId; ip: string; ua: string }>;
|
|
34
|
+
revoked: string[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function createFakeStore(): FakeStore {
|
|
38
|
+
return { live: new Set(), created: [], revoked: [] };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createSessionCallbacks(
|
|
42
|
+
store: FakeStore,
|
|
43
|
+
idStream: Iterator<string>,
|
|
44
|
+
): {
|
|
45
|
+
sessionCreator: (
|
|
46
|
+
user: { id: string; tenantId: TenantId },
|
|
47
|
+
meta: { ip: string; userAgent: string },
|
|
48
|
+
) => Promise<string>;
|
|
49
|
+
sessionRevoker: (sid: string) => Promise<void>;
|
|
50
|
+
} {
|
|
51
|
+
return {
|
|
52
|
+
async sessionCreator(user, meta) {
|
|
53
|
+
const next = idStream.next();
|
|
54
|
+
if (next.done) throw new Error("ran out of deterministic sids");
|
|
55
|
+
const sid = next.value;
|
|
56
|
+
store.live.add(sid);
|
|
57
|
+
store.created.push({
|
|
58
|
+
sid,
|
|
59
|
+
userId: user.id,
|
|
60
|
+
tenantId: user.tenantId,
|
|
61
|
+
ip: meta.ip,
|
|
62
|
+
ua: meta.userAgent,
|
|
63
|
+
});
|
|
64
|
+
return sid;
|
|
65
|
+
},
|
|
66
|
+
async sessionRevoker(sid) {
|
|
67
|
+
store.live.delete(sid);
|
|
68
|
+
store.revoked.push(sid);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function* deterministicSids(): Generator<string> {
|
|
74
|
+
let i = 1;
|
|
75
|
+
while (true) {
|
|
76
|
+
yield `sid-${String(i).padStart(4, "0")}`;
|
|
77
|
+
i++;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let stack: TestStack;
|
|
82
|
+
let store: FakeStore;
|
|
83
|
+
let sidStream: Generator<string>;
|
|
84
|
+
|
|
85
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
86
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
87
|
+
|
|
88
|
+
const TENANT_A: TenantId = "00000000-0000-4000-8000-0000000000a1";
|
|
89
|
+
const TENANT_B: TenantId = "00000000-0000-4000-8000-0000000000b1";
|
|
90
|
+
|
|
91
|
+
beforeAll(async () => {
|
|
92
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
93
|
+
const resolver = createConfigResolver({ encryption });
|
|
94
|
+
|
|
95
|
+
store = createFakeStore();
|
|
96
|
+
sidStream = deterministicSids();
|
|
97
|
+
const callbacks = createSessionCallbacks(store, sidStream);
|
|
98
|
+
|
|
99
|
+
stack = await setupTestStack({
|
|
100
|
+
features: [
|
|
101
|
+
createConfigFeature(),
|
|
102
|
+
createUserFeature(),
|
|
103
|
+
createTenantFeature(),
|
|
104
|
+
createAuthEmailPasswordFeature(),
|
|
105
|
+
],
|
|
106
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
107
|
+
authConfig: {
|
|
108
|
+
membershipQuery: "tenant:query:memberships",
|
|
109
|
+
loginHandler: AuthHandlers.login,
|
|
110
|
+
loginErrorStatusMap: {
|
|
111
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
112
|
+
[AuthErrors.noMembership]: 403,
|
|
113
|
+
},
|
|
114
|
+
sessionCreator: callbacks.sessionCreator,
|
|
115
|
+
sessionRevoker: callbacks.sessionRevoker,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
await createEntityTable(stack.db, userEntity);
|
|
120
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
121
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
afterAll(async () => {
|
|
125
|
+
await stack.cleanup();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
beforeEach(async () => {
|
|
129
|
+
await stack.db.delete(userTable);
|
|
130
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
131
|
+
// Reset the in-memory store but KEEP sidStream running — otherwise a test
|
|
132
|
+
// that leaks a sid into another test would produce confusing collisions.
|
|
133
|
+
store.live.clear();
|
|
134
|
+
store.created.length = 0;
|
|
135
|
+
store.revoked.length = 0;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
async function seedUser(opts: {
|
|
139
|
+
email: string;
|
|
140
|
+
password: string;
|
|
141
|
+
tenants: { id: TenantId; roles: string[] }[];
|
|
142
|
+
}): Promise<{ userId: string }> {
|
|
143
|
+
const hash = await hashPassword(opts.password);
|
|
144
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
145
|
+
UserHandlers.create,
|
|
146
|
+
{
|
|
147
|
+
email: opts.email,
|
|
148
|
+
passwordHash: hash,
|
|
149
|
+
displayName: opts.email.split("@")[0] ?? "user",
|
|
150
|
+
},
|
|
151
|
+
systemAdmin,
|
|
152
|
+
);
|
|
153
|
+
for (const t of opts.tenants) {
|
|
154
|
+
await seedTenantMembership(stack.db, {
|
|
155
|
+
userId: created.id,
|
|
156
|
+
tenantId: t.id,
|
|
157
|
+
roles: t.roles,
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
return { userId: created.id };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function login(email: string, password: string, headers?: Record<string, string>) {
|
|
164
|
+
const res = await stack.http.raw("POST", "/api/auth/login", { email, password }, headers);
|
|
165
|
+
expect(res.status).toBe(200);
|
|
166
|
+
const body = (await res.json()) as { token: string; user: { id: string; tenantId: TenantId } };
|
|
167
|
+
return body;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Pure decode — we only care about the payload shape, not signature validity,
|
|
171
|
+
// because the auth-middleware is the one that checks the signature. Using
|
|
172
|
+
// jose.decodeJwt keeps the test from needing the secret.
|
|
173
|
+
function decode(token: string): { sub: string; jti?: string; tenantId: TenantId } {
|
|
174
|
+
const payload = jose.decodeJwt(token);
|
|
175
|
+
const result: { sub: string; jti?: string; tenantId: TenantId } = {
|
|
176
|
+
sub: payload.sub ?? "",
|
|
177
|
+
tenantId: payload["tenantId"] as TenantId,
|
|
178
|
+
};
|
|
179
|
+
if (typeof payload.jti === "string") result.jti = payload.jti;
|
|
180
|
+
return result;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// --- scenario 1: login goes through sessionCreator + carries jti ---
|
|
184
|
+
|
|
185
|
+
describe("login wires into sessionCreator and embeds jti", () => {
|
|
186
|
+
test("login creates a session, and the JWT carries that sid in its jti claim", async () => {
|
|
187
|
+
const { userId } = await seedUser({
|
|
188
|
+
email: "first@example.com",
|
|
189
|
+
password: "correct-horse-battery",
|
|
190
|
+
tenants: [{ id: TENANT_A, roles: ["User"] }],
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const res = await login("first@example.com", "correct-horse-battery", {
|
|
194
|
+
"x-forwarded-for": "198.51.100.42",
|
|
195
|
+
"user-agent": "Mozilla/5.0 SessionTest",
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// Creator was invoked exactly once with the right user + meta
|
|
199
|
+
expect(store.created).toHaveLength(1);
|
|
200
|
+
expect(store.created[0]).toMatchObject({
|
|
201
|
+
userId,
|
|
202
|
+
tenantId: TENANT_A,
|
|
203
|
+
ip: "198.51.100.42",
|
|
204
|
+
ua: "Mozilla/5.0 SessionTest",
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// The sid ended up in the JWT's jti claim
|
|
208
|
+
const decoded = decode(res.token);
|
|
209
|
+
expect(decoded.jti).toBe(store.created[0]?.sid);
|
|
210
|
+
expect(decoded.sub).toBe(userId);
|
|
211
|
+
|
|
212
|
+
// And the session is live on the server side
|
|
213
|
+
expect(store.live.has(decoded.jti ?? "")).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// --- scenario 2: logout revokes the session via the revoker callback ---
|
|
218
|
+
|
|
219
|
+
describe("logout routes through sessionRevoker", () => {
|
|
220
|
+
test("POST /auth/logout deletes the sid carried by the caller's JWT", async () => {
|
|
221
|
+
await seedUser({
|
|
222
|
+
email: "logout@example.com",
|
|
223
|
+
password: "bye-bye-session",
|
|
224
|
+
tenants: [{ id: TENANT_A, roles: ["User"] }],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const loginRes = await login("logout@example.com", "bye-bye-session");
|
|
228
|
+
const sidBefore = decode(loginRes.token).jti;
|
|
229
|
+
expect(sidBefore).toBeDefined();
|
|
230
|
+
expect(store.live.has(sidBefore ?? "")).toBe(true);
|
|
231
|
+
|
|
232
|
+
const logoutRes = await stack.http.raw("POST", "/api/auth/logout", undefined, {
|
|
233
|
+
Authorization: `Bearer ${loginRes.token}`,
|
|
234
|
+
});
|
|
235
|
+
expect(logoutRes.status).toBe(200);
|
|
236
|
+
|
|
237
|
+
// Revoker was called with exactly the sid from the caller's JWT
|
|
238
|
+
expect(store.revoked).toEqual([sidBefore]);
|
|
239
|
+
expect(store.live.has(sidBefore ?? "")).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("logout without a bearer token → 401 (middleware blocks it)", async () => {
|
|
243
|
+
const res = await stack.http.raw("POST", "/api/auth/logout");
|
|
244
|
+
expect(res.status).toBe(401);
|
|
245
|
+
expect(store.revoked).toHaveLength(0);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// --- scenario 3: switch-tenant rotates the sid (old revoked, new created) ---
|
|
250
|
+
|
|
251
|
+
describe("switch-tenant rotates the session", () => {
|
|
252
|
+
test("switching from A → B revokes the A-sid and creates a B-sid, in that order", async () => {
|
|
253
|
+
await seedUser({
|
|
254
|
+
email: "switcher@example.com",
|
|
255
|
+
password: "multi-tenant-life",
|
|
256
|
+
tenants: [
|
|
257
|
+
{ id: TENANT_A, roles: ["User"] },
|
|
258
|
+
{ id: TENANT_B, roles: ["Admin"] },
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const first = await login("switcher@example.com", "multi-tenant-life");
|
|
263
|
+
const sidA = decode(first.token).jti;
|
|
264
|
+
expect(sidA).toBeDefined();
|
|
265
|
+
|
|
266
|
+
// Ask to switch to tenant B
|
|
267
|
+
const switchRes = await stack.http.raw(
|
|
268
|
+
"POST",
|
|
269
|
+
"/api/auth/switch-tenant",
|
|
270
|
+
{ tenantId: TENANT_B },
|
|
271
|
+
{ Authorization: `Bearer ${first.token}` },
|
|
272
|
+
);
|
|
273
|
+
expect(switchRes.status).toBe(200);
|
|
274
|
+
const switched = (await switchRes.json()) as { token: string; tenantId: TenantId };
|
|
275
|
+
expect(switched.tenantId).toBe(TENANT_B);
|
|
276
|
+
|
|
277
|
+
const sidB = decode(switched.token).jti;
|
|
278
|
+
expect(sidB).toBeDefined();
|
|
279
|
+
expect(sidB).not.toBe(sidA);
|
|
280
|
+
|
|
281
|
+
// Old sid is revoked; new sid is live
|
|
282
|
+
expect(store.revoked).toContain(sidA);
|
|
283
|
+
expect(store.live.has(sidA ?? "")).toBe(false);
|
|
284
|
+
expect(store.live.has(sidB ?? "")).toBe(true);
|
|
285
|
+
|
|
286
|
+
// The B-session was created with tenant B on the user object — that's the
|
|
287
|
+
// whole point of running claims under the new tenant scope, so let's pin it.
|
|
288
|
+
const createdB = store.created.find((c) => c.sid === sidB);
|
|
289
|
+
expect(createdB?.tenantId).toBe(TENANT_B);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// --- scenario 4: meta forwarding — missing headers fall back to "unknown" ---
|
|
294
|
+
|
|
295
|
+
describe("sessionMetadata falls back to 'unknown' on missing headers", () => {
|
|
296
|
+
test("no x-forwarded-for and no user-agent → both default to 'unknown'", async () => {
|
|
297
|
+
await seedUser({
|
|
298
|
+
email: "anon@example.com",
|
|
299
|
+
password: "hdr-less",
|
|
300
|
+
tenants: [{ id: TENANT_A, roles: ["User"] }],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// The test helper still injects Content-Type but not forwarded-for or UA.
|
|
304
|
+
await login("anon@example.com", "hdr-less");
|
|
305
|
+
|
|
306
|
+
const created = store.created.at(-1);
|
|
307
|
+
expect(created?.ip).toBe("unknown");
|
|
308
|
+
expect(created?.ua).toBe("unknown");
|
|
309
|
+
});
|
|
310
|
+
});
|