@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,512 @@
|
|
|
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 { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import {
|
|
6
|
+
createEntityTable,
|
|
7
|
+
pushTables,
|
|
8
|
+
setupTestStack,
|
|
9
|
+
type TestStack,
|
|
10
|
+
TestUsers,
|
|
11
|
+
testTenantId,
|
|
12
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
13
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
14
|
+
import { createConfigFeature } from "../../config";
|
|
15
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
16
|
+
import { configValuesTable } from "../../config/table";
|
|
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 } from "../password-hashing";
|
|
27
|
+
|
|
28
|
+
// Sample-style extension feature that shows the real-world shape of a claims
|
|
29
|
+
// hook: look something up in a tenant-scoped table and stuff it into the JWT.
|
|
30
|
+
// Keeping the hook inline (no separate file) so the test stays self-contained
|
|
31
|
+
// and a reader can see in one glance what's being asserted.
|
|
32
|
+
function makeProfileFeature(data: {
|
|
33
|
+
getSegment: (userId: string, tenantId: TenantId) => string | undefined;
|
|
34
|
+
}) {
|
|
35
|
+
return defineFeature("profile", (r) => {
|
|
36
|
+
// A hook returning a feature-scoped claim — `segment` lands under
|
|
37
|
+
// `user.claims["profile:segment"]` after login thanks to the auto-prefix.
|
|
38
|
+
r.authClaims(async (user) => {
|
|
39
|
+
const seg = data.getSegment(user.id, user.tenantId);
|
|
40
|
+
return seg ? { segment: seg } : {};
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Second feature — proves two independent hooks can coexist with no collisions
|
|
46
|
+
// because the prefix is the feature name.
|
|
47
|
+
function makeBillingFeature(plans: Map<TenantId, string>) {
|
|
48
|
+
return defineFeature("billing", (r) => {
|
|
49
|
+
r.authClaims(async (user) => {
|
|
50
|
+
const plan = plans.get(user.tenantId);
|
|
51
|
+
return plan ? { plan } : {};
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let stack: TestStack;
|
|
57
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
58
|
+
const encryptionKey = randomBytes(32).toString("base64");
|
|
59
|
+
const tenantA = testTenantId(1);
|
|
60
|
+
const tenantB = testTenantId(2);
|
|
61
|
+
|
|
62
|
+
// Segment data keyed by (userId, tenantId) so different tenants return
|
|
63
|
+
// different claim values for the SAME user — this is what we assert in the
|
|
64
|
+
// switch-tenant test.
|
|
65
|
+
const segmentsByUserAndTenant = new Map<string, string>();
|
|
66
|
+
const plansByTenant = new Map<TenantId, string>();
|
|
67
|
+
|
|
68
|
+
beforeAll(async () => {
|
|
69
|
+
const encryption = createEncryptionProvider(encryptionKey);
|
|
70
|
+
const resolver = createConfigResolver({ encryption });
|
|
71
|
+
|
|
72
|
+
stack = await setupTestStack({
|
|
73
|
+
features: [
|
|
74
|
+
createConfigFeature(),
|
|
75
|
+
createUserFeature(),
|
|
76
|
+
createTenantFeature(),
|
|
77
|
+
createAuthEmailPasswordFeature(),
|
|
78
|
+
makeProfileFeature({
|
|
79
|
+
getSegment: (userId, tenantId) => segmentsByUserAndTenant.get(`${userId}|${tenantId}`),
|
|
80
|
+
}),
|
|
81
|
+
makeBillingFeature(plansByTenant),
|
|
82
|
+
],
|
|
83
|
+
extraContext: { configResolver: resolver, configEncryption: encryption },
|
|
84
|
+
authConfig: {
|
|
85
|
+
membershipQuery: "tenant:query:memberships",
|
|
86
|
+
loginHandler: AuthHandlers.login,
|
|
87
|
+
loginErrorStatusMap: {
|
|
88
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
89
|
+
[AuthErrors.noMembership]: 403,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await createEntityTable(stack.db, userEntity);
|
|
95
|
+
await createEntityTable(stack.db, tenantEntity);
|
|
96
|
+
await pushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterAll(async () => {
|
|
100
|
+
await stack.cleanup();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
beforeEach(async () => {
|
|
104
|
+
await stack.db.delete(userTable);
|
|
105
|
+
await stack.db.delete(tenantMembershipsTable);
|
|
106
|
+
segmentsByUserAndTenant.clear();
|
|
107
|
+
plansByTenant.clear();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
async function seedUser(email: string, password: string): Promise<string> {
|
|
111
|
+
const hash = await hashPassword(password);
|
|
112
|
+
const created = await stack.http.writeOk<{ id: string }>(
|
|
113
|
+
UserHandlers.create,
|
|
114
|
+
{ email, passwordHash: hash, displayName: email.split("@")[0] ?? "user" },
|
|
115
|
+
systemAdmin,
|
|
116
|
+
);
|
|
117
|
+
return created.id;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function addMembership(userId: string, tenantId: TenantId, roles: string[]): Promise<void> {
|
|
121
|
+
await stack.db.insert(tenantMembershipsTable).values({
|
|
122
|
+
userId,
|
|
123
|
+
tenantId,
|
|
124
|
+
roles: JSON.stringify(roles),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("scenario 1: login populates claims via r.authClaims hooks", () => {
|
|
129
|
+
test("two features each contribute their prefixed claim into the JWT", async () => {
|
|
130
|
+
const userId = await seedUser("joe@example.com", "pw-long-enough");
|
|
131
|
+
await addMembership(userId, tenantA, ["User"]);
|
|
132
|
+
|
|
133
|
+
// Feature data set-up: profile knows this user's segment in tenantA;
|
|
134
|
+
// billing knows this tenant's plan.
|
|
135
|
+
segmentsByUserAndTenant.set(`${userId}|${tenantA}`, "premium");
|
|
136
|
+
plansByTenant.set(tenantA, "pro");
|
|
137
|
+
|
|
138
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
139
|
+
email: "joe@example.com",
|
|
140
|
+
password: "pw-long-enough",
|
|
141
|
+
});
|
|
142
|
+
expect(res.status).toBe(200);
|
|
143
|
+
const body = await res.json();
|
|
144
|
+
expect(body.isSuccess).toBe(true);
|
|
145
|
+
|
|
146
|
+
// Verify the JWT actually carries the claims — decode via the test stack's jwt helper.
|
|
147
|
+
const payload = await stack.jwt.verify(body.token);
|
|
148
|
+
expect(payload.claims).toEqual({
|
|
149
|
+
"profile:segment": "premium",
|
|
150
|
+
"billing:plan": "pro",
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("no feature returns data → token has no claims field (not an empty object)", async () => {
|
|
155
|
+
const userId = await seedUser("nomatch@example.com", "pw-long-enough");
|
|
156
|
+
await addMembership(userId, tenantA, ["User"]);
|
|
157
|
+
// Both feature data maps are empty (cleared in beforeEach) so each hook
|
|
158
|
+
// returns {} — and the merged record is also {}.
|
|
159
|
+
|
|
160
|
+
const res = await stack.http.raw("POST", "/api/auth/login", {
|
|
161
|
+
email: "nomatch@example.com",
|
|
162
|
+
password: "pw-long-enough",
|
|
163
|
+
});
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
const body = await res.json();
|
|
166
|
+
const payload = await stack.jwt.verify(body.token);
|
|
167
|
+
// Absent is explicit — the JWT layer only adds `claims` when there's at
|
|
168
|
+
// least one key, so the client never sees `claims: {}` dead weight.
|
|
169
|
+
expect(payload.claims).toBeUndefined();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("scenario 2: switch-tenant recomputes claims (no stale tenant-A claims in tenant-B token)", () => {
|
|
174
|
+
test("switching to tenant B wipes tenant-A claims and reads tenant-B data", async () => {
|
|
175
|
+
const userId = await seedUser("multi@example.com", "pw-long-enough");
|
|
176
|
+
await addMembership(userId, tenantA, ["User"]);
|
|
177
|
+
await addMembership(userId, tenantB, ["Admin"]);
|
|
178
|
+
|
|
179
|
+
// Tenant-scoped state: user has a "premium" segment in A, "starter" in B.
|
|
180
|
+
// Billing has "pro" plan in A, "free" in B. If switch-tenant merely
|
|
181
|
+
// re-signed the old claims instead of recomputing, we'd see A's claims
|
|
182
|
+
// leak into B's token — a real identity-leak bug.
|
|
183
|
+
segmentsByUserAndTenant.set(`${userId}|${tenantA}`, "premium");
|
|
184
|
+
segmentsByUserAndTenant.set(`${userId}|${tenantB}`, "starter");
|
|
185
|
+
plansByTenant.set(tenantA, "pro");
|
|
186
|
+
plansByTenant.set(tenantB, "free");
|
|
187
|
+
|
|
188
|
+
// Login lands in tenantA (first membership by default).
|
|
189
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
190
|
+
email: "multi@example.com",
|
|
191
|
+
password: "pw-long-enough",
|
|
192
|
+
});
|
|
193
|
+
const loginBody = await loginRes.json();
|
|
194
|
+
const tokenA = loginBody.token as string;
|
|
195
|
+
const payloadA = await stack.jwt.verify(tokenA);
|
|
196
|
+
expect(payloadA.tenantId).toBe(tenantA);
|
|
197
|
+
expect(payloadA.claims).toEqual({
|
|
198
|
+
"profile:segment": "premium",
|
|
199
|
+
"billing:plan": "pro",
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
// Switch to tenantB.
|
|
203
|
+
const switchRes = await stack.http.raw(
|
|
204
|
+
"POST",
|
|
205
|
+
"/api/auth/switch-tenant",
|
|
206
|
+
{ tenantId: tenantB },
|
|
207
|
+
{ Authorization: `Bearer ${tokenA}` },
|
|
208
|
+
);
|
|
209
|
+
expect(switchRes.status).toBe(200);
|
|
210
|
+
const switchBody = await switchRes.json();
|
|
211
|
+
const tokenB = switchBody.token as string;
|
|
212
|
+
|
|
213
|
+
const payloadB = await stack.jwt.verify(tokenB);
|
|
214
|
+
expect(payloadB.tenantId).toBe(tenantB);
|
|
215
|
+
expect(payloadB.roles).toEqual(["Admin"]);
|
|
216
|
+
|
|
217
|
+
// The hard part: claims are recomputed from tenant B data, not carried
|
|
218
|
+
// forward from tenant A. "premium"/"pro" MUST NOT appear.
|
|
219
|
+
expect(payloadB.claims).toEqual({
|
|
220
|
+
"profile:segment": "starter",
|
|
221
|
+
"billing:plan": "free",
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("switching to a tenant with no matching claim data → claims field absent on the new JWT", async () => {
|
|
226
|
+
const userId = await seedUser("sparse@example.com", "pw-long-enough");
|
|
227
|
+
await addMembership(userId, tenantA, ["User"]);
|
|
228
|
+
await addMembership(userId, tenantB, ["Admin"]);
|
|
229
|
+
|
|
230
|
+
// Only tenant A has data.
|
|
231
|
+
segmentsByUserAndTenant.set(`${userId}|${tenantA}`, "premium");
|
|
232
|
+
plansByTenant.set(tenantA, "pro");
|
|
233
|
+
|
|
234
|
+
const loginRes = await stack.http.raw("POST", "/api/auth/login", {
|
|
235
|
+
email: "sparse@example.com",
|
|
236
|
+
password: "pw-long-enough",
|
|
237
|
+
});
|
|
238
|
+
const tokenA = (await loginRes.json()).token as string;
|
|
239
|
+
const payloadA = await stack.jwt.verify(tokenA);
|
|
240
|
+
expect(payloadA.claims).toEqual({
|
|
241
|
+
"profile:segment": "premium",
|
|
242
|
+
"billing:plan": "pro",
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
const switchRes = await stack.http.raw(
|
|
246
|
+
"POST",
|
|
247
|
+
"/api/auth/switch-tenant",
|
|
248
|
+
{ tenantId: tenantB },
|
|
249
|
+
{ Authorization: `Bearer ${tokenA}` },
|
|
250
|
+
);
|
|
251
|
+
const tokenB = (await switchRes.json()).token as string;
|
|
252
|
+
const payloadB = await stack.jwt.verify(tokenB);
|
|
253
|
+
|
|
254
|
+
// Both hooks return {} under tenant B → no claims field on the JWT.
|
|
255
|
+
expect(payloadB.claims).toBeUndefined();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("scenario 2.5: reserved separator + multi-feature isolation", () => {
|
|
260
|
+
test("one feature returns key with ':' → only that key dropped, other features merge cleanly", async () => {
|
|
261
|
+
const localEncryption = createEncryptionProvider(encryptionKey);
|
|
262
|
+
const localResolver = createConfigResolver({ encryption: localEncryption });
|
|
263
|
+
// A polluted feature that tries to smuggle in a qualified-name-shaped
|
|
264
|
+
// inner key (injecting ":" would otherwise let it spoof another
|
|
265
|
+
// feature's prefix). Plus a clean companion to prove the drop is
|
|
266
|
+
// per-key, not per-feature.
|
|
267
|
+
const polluter = defineFeature("polluter", (r) => {
|
|
268
|
+
r.authClaims(async () => ({
|
|
269
|
+
"other:teamId": "injected", // must be dropped
|
|
270
|
+
legitKey: "ok", // must survive
|
|
271
|
+
}));
|
|
272
|
+
});
|
|
273
|
+
const clean = defineFeature("cleanliness", (r) => {
|
|
274
|
+
r.authClaims(async () => ({ mood: "tidy" }));
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const localStack = await setupTestStack({
|
|
278
|
+
features: [
|
|
279
|
+
createConfigFeature(),
|
|
280
|
+
createUserFeature(),
|
|
281
|
+
createTenantFeature(),
|
|
282
|
+
createAuthEmailPasswordFeature(),
|
|
283
|
+
polluter,
|
|
284
|
+
clean,
|
|
285
|
+
],
|
|
286
|
+
extraContext: { configResolver: localResolver },
|
|
287
|
+
authConfig: {
|
|
288
|
+
membershipQuery: "tenant:query:memberships",
|
|
289
|
+
loginHandler: AuthHandlers.login,
|
|
290
|
+
loginErrorStatusMap: {
|
|
291
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
292
|
+
[AuthErrors.noMembership]: 403,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
try {
|
|
297
|
+
await createEntityTable(localStack.db, userEntity);
|
|
298
|
+
await createEntityTable(localStack.db, tenantEntity);
|
|
299
|
+
await pushTables(localStack.db, { configValuesTable, tenantMembershipsTable });
|
|
300
|
+
|
|
301
|
+
const hash = await hashPassword("pw-long-enough");
|
|
302
|
+
const created = await localStack.http.writeOk<{ id: string }>(
|
|
303
|
+
UserHandlers.create,
|
|
304
|
+
{ email: "sep@example.com", passwordHash: hash, displayName: "Sep" },
|
|
305
|
+
systemAdmin,
|
|
306
|
+
);
|
|
307
|
+
await seedTenantMembership(localStack.db, {
|
|
308
|
+
userId: created.id,
|
|
309
|
+
tenantId: tenantA,
|
|
310
|
+
roles: ["User"],
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const res = await localStack.http.raw("POST", "/api/auth/login", {
|
|
314
|
+
email: "sep@example.com",
|
|
315
|
+
password: "pw-long-enough",
|
|
316
|
+
});
|
|
317
|
+
expect(res.status).toBe(200);
|
|
318
|
+
const { token } = (await res.json()) as { token: string };
|
|
319
|
+
const payload = await localStack.jwt.verify(token);
|
|
320
|
+
|
|
321
|
+
// The colon-laden key is gone. The polluter's other key + the clean
|
|
322
|
+
// feature's key both made it through under their own prefixes.
|
|
323
|
+
expect(payload.claims).toEqual({
|
|
324
|
+
"polluter:legitKey": "ok",
|
|
325
|
+
"cleanliness:mood": "tidy",
|
|
326
|
+
});
|
|
327
|
+
// And critically: no "other:teamId" survived — the polluter cannot
|
|
328
|
+
// spoof another feature's namespace by embedding the separator.
|
|
329
|
+
expect(payload.claims).not.toHaveProperty("other:teamId");
|
|
330
|
+
} finally {
|
|
331
|
+
await localStack.cleanup();
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("scenario 2.6: multi-feature drift warnings fire independently", () => {
|
|
337
|
+
test("feature A + feature B both return undeclared keys → each logs its own warning", async () => {
|
|
338
|
+
const localEncryption = createEncryptionProvider(encryptionKey);
|
|
339
|
+
const localResolver = createConfigResolver({ encryption: localEncryption });
|
|
340
|
+
|
|
341
|
+
// Capture warn() calls to verify drift warnings fire per-feature, not
|
|
342
|
+
// globally collapsed.
|
|
343
|
+
const warnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
344
|
+
const testLogger = {
|
|
345
|
+
info: () => {},
|
|
346
|
+
warn: (msg: string, data?: Record<string, unknown>) => {
|
|
347
|
+
warnCalls.push({ msg, ...(data && { data }) });
|
|
348
|
+
},
|
|
349
|
+
error: () => {},
|
|
350
|
+
debug: () => {},
|
|
351
|
+
child() {
|
|
352
|
+
return this;
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Feature A declares `teamId` but returns both `teamId` AND undeclared
|
|
357
|
+
// `stray`. Feature B declares `plan` but returns both `plan` AND
|
|
358
|
+
// undeclared `extra`. Both should fire drift warnings — distinct feature
|
|
359
|
+
// names, distinct keys.
|
|
360
|
+
const featA = defineFeature("alpha", (r) => {
|
|
361
|
+
r.claimKey("teamId", { type: "string" });
|
|
362
|
+
r.authClaims(async () => ({ teamId: "t1", stray: "drift-a" }));
|
|
363
|
+
});
|
|
364
|
+
const featB = defineFeature("beta", (r) => {
|
|
365
|
+
r.claimKey("plan", { type: "string" });
|
|
366
|
+
r.authClaims(async () => ({ plan: "pro", extra: "drift-b" }));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const localStack = await setupTestStack({
|
|
370
|
+
features: [
|
|
371
|
+
createConfigFeature(),
|
|
372
|
+
createUserFeature(),
|
|
373
|
+
createTenantFeature(),
|
|
374
|
+
createAuthEmailPasswordFeature(),
|
|
375
|
+
featA,
|
|
376
|
+
featB,
|
|
377
|
+
],
|
|
378
|
+
extraContext: { configResolver: localResolver, log: testLogger },
|
|
379
|
+
authConfig: {
|
|
380
|
+
membershipQuery: "tenant:query:memberships",
|
|
381
|
+
loginHandler: AuthHandlers.login,
|
|
382
|
+
loginErrorStatusMap: {
|
|
383
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
384
|
+
[AuthErrors.noMembership]: 403,
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
try {
|
|
389
|
+
await createEntityTable(localStack.db, userEntity);
|
|
390
|
+
await createEntityTable(localStack.db, tenantEntity);
|
|
391
|
+
await pushTables(localStack.db, { configValuesTable, tenantMembershipsTable });
|
|
392
|
+
|
|
393
|
+
const hash = await hashPassword("pw-long-enough");
|
|
394
|
+
const created = await localStack.http.writeOk<{ id: string }>(
|
|
395
|
+
UserHandlers.create,
|
|
396
|
+
{ email: "drift@example.com", passwordHash: hash, displayName: "Drift" },
|
|
397
|
+
systemAdmin,
|
|
398
|
+
);
|
|
399
|
+
await seedTenantMembership(localStack.db, {
|
|
400
|
+
userId: created.id,
|
|
401
|
+
tenantId: tenantA,
|
|
402
|
+
roles: ["User"],
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const res = await localStack.http.raw("POST", "/api/auth/login", {
|
|
406
|
+
email: "drift@example.com",
|
|
407
|
+
password: "pw-long-enough",
|
|
408
|
+
});
|
|
409
|
+
expect(res.status).toBe(200);
|
|
410
|
+
|
|
411
|
+
// Two distinct drift warnings — per-feature, per-key, not collapsed.
|
|
412
|
+
const drifts = warnCalls.filter((w) => w.msg.includes("not declared via r.claimKey()"));
|
|
413
|
+
expect(drifts).toHaveLength(2);
|
|
414
|
+
const features = drifts.map((w) => w.data?.["featureName"]).sort();
|
|
415
|
+
expect(features).toEqual(["alpha", "beta"]);
|
|
416
|
+
const keys = drifts.map((w) => w.data?.["undeclaredKey"]).sort();
|
|
417
|
+
expect(keys).toEqual(["extra", "stray"]);
|
|
418
|
+
} finally {
|
|
419
|
+
await localStack.cleanup();
|
|
420
|
+
}
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
describe("scenario 3: a broken claims hook does not break login", () => {
|
|
425
|
+
test("hook throws → login still succeeds, broken feature simply contributes nothing, warn logged with featureName+err", async () => {
|
|
426
|
+
const localEncryption = createEncryptionProvider(encryptionKey);
|
|
427
|
+
const localResolver = createConfigResolver({ encryption: localEncryption });
|
|
428
|
+
|
|
429
|
+
// Capture warn-calls so the test can verify the resolver actually
|
|
430
|
+
// logged the broken hook with featureName + err.message — previously
|
|
431
|
+
// the test only asserted "login still succeeded", which would pass
|
|
432
|
+
// even if the log statement silently disappeared.
|
|
433
|
+
const warnCalls: Array<{ msg: string; data?: Record<string, unknown> }> = [];
|
|
434
|
+
const testLogger = {
|
|
435
|
+
info: () => {},
|
|
436
|
+
warn: (msg: string, data?: Record<string, unknown>) => {
|
|
437
|
+
warnCalls.push({ msg, ...(data && { data }) });
|
|
438
|
+
},
|
|
439
|
+
error: () => {},
|
|
440
|
+
debug: () => {},
|
|
441
|
+
child() {
|
|
442
|
+
return this;
|
|
443
|
+
},
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
const brokenFeature = defineFeature("broken", (r) => {
|
|
447
|
+
r.authClaims(async () => {
|
|
448
|
+
throw new Error("pretend the DB exploded");
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
const healthyFeature = defineFeature("drivers", (r) => {
|
|
452
|
+
r.authClaims(async () => ({ teamId: "t-42" }));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const localStack = await setupTestStack({
|
|
456
|
+
features: [
|
|
457
|
+
createConfigFeature(),
|
|
458
|
+
createUserFeature(),
|
|
459
|
+
createTenantFeature(),
|
|
460
|
+
createAuthEmailPasswordFeature(),
|
|
461
|
+
brokenFeature,
|
|
462
|
+
healthyFeature,
|
|
463
|
+
],
|
|
464
|
+
extraContext: { configResolver: localResolver, log: testLogger },
|
|
465
|
+
authConfig: {
|
|
466
|
+
membershipQuery: "tenant:query:memberships",
|
|
467
|
+
loginHandler: AuthHandlers.login,
|
|
468
|
+
loginErrorStatusMap: {
|
|
469
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
470
|
+
[AuthErrors.noMembership]: 403,
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
});
|
|
474
|
+
try {
|
|
475
|
+
await createEntityTable(localStack.db, userEntity);
|
|
476
|
+
await createEntityTable(localStack.db, tenantEntity);
|
|
477
|
+
await pushTables(localStack.db, { configValuesTable, tenantMembershipsTable });
|
|
478
|
+
|
|
479
|
+
const hash = await hashPassword("pw-long-enough");
|
|
480
|
+
const created = await localStack.http.writeOk<{ id: string }>(
|
|
481
|
+
UserHandlers.create,
|
|
482
|
+
{ email: "broken@example.com", passwordHash: hash, displayName: "Broken" },
|
|
483
|
+
systemAdmin,
|
|
484
|
+
);
|
|
485
|
+
await seedTenantMembership(localStack.db, {
|
|
486
|
+
userId: created.id,
|
|
487
|
+
tenantId: tenantA,
|
|
488
|
+
roles: ["User"],
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
const res = await localStack.http.raw("POST", "/api/auth/login", {
|
|
492
|
+
email: "broken@example.com",
|
|
493
|
+
password: "pw-long-enough",
|
|
494
|
+
});
|
|
495
|
+
expect(res.status).toBe(200);
|
|
496
|
+
const body = await res.json();
|
|
497
|
+
expect(body.isSuccess).toBe(true);
|
|
498
|
+
|
|
499
|
+
const payload = await localStack.jwt.verify(body.token);
|
|
500
|
+
expect(payload.claims).toEqual({ "drivers:teamId": "t-42" });
|
|
501
|
+
|
|
502
|
+
// The resolver MUST have logged the failure with feature name and
|
|
503
|
+
// error message — ops needs that breadcrumb to find the bug.
|
|
504
|
+
const threwLog = warnCalls.find((w) => w.msg.includes("hook threw"));
|
|
505
|
+
expect(threwLog).toBeDefined();
|
|
506
|
+
expect(threwLog?.data?.["featureName"]).toBe("broken");
|
|
507
|
+
expect(threwLog?.data?.["err"]).toBe("pretend the DB exploded");
|
|
508
|
+
} finally {
|
|
509
|
+
await localStack.cleanup();
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|