@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,1246 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { createEncryptionProvider, type DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
3
|
+
import {
|
|
4
|
+
access,
|
|
5
|
+
createSystemConfig,
|
|
6
|
+
createTenantConfig,
|
|
7
|
+
createUserConfig,
|
|
8
|
+
defineFeature,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
10
|
+
import { eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
11
|
+
import {
|
|
12
|
+
createTestUser,
|
|
13
|
+
pushTables,
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
TestUsers,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { expectErrorIncludes } from "@cosmicdrift/kumiko-framework/testing";
|
|
19
|
+
import { eq } from "drizzle-orm";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
21
|
+
import { z } from "zod";
|
|
22
|
+
import { ConfigHandlers, ConfigQueries } from "../constants";
|
|
23
|
+
import { createConfigAccessor, createConfigAccessorFactory, createConfigFeature } from "../feature";
|
|
24
|
+
import { type ConfigResolver, createConfigResolver, validateAppOverrides } from "../resolver";
|
|
25
|
+
import { configValuesTable } from "../table";
|
|
26
|
+
|
|
27
|
+
// --- Setup ---
|
|
28
|
+
|
|
29
|
+
let stack: TestStack;
|
|
30
|
+
let db: DbConnection;
|
|
31
|
+
let resolver: ConfigResolver;
|
|
32
|
+
|
|
33
|
+
const systemAdmin = TestUsers.systemAdmin;
|
|
34
|
+
const tenantAdmin = createTestUser({ id: 2 });
|
|
35
|
+
const billingUser = createTestUser({ id: 3, roles: ["Billing"] });
|
|
36
|
+
const normalUser = createTestUser({ id: 4, roles: ["User"] });
|
|
37
|
+
const otherTenantAdmin = createTestUser({
|
|
38
|
+
id: 5,
|
|
39
|
+
tenantId: "00000000-0000-4000-8000-000000000002",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// --- Features that register config keys (based on 6 real scenarios) ---
|
|
43
|
+
|
|
44
|
+
// Scenario 1: System URL — one value for the whole system
|
|
45
|
+
// Scenario 2: Mail server — system default + tenant override
|
|
46
|
+
const appFeature = defineFeature("app", (r) => {
|
|
47
|
+
r.requires("config");
|
|
48
|
+
|
|
49
|
+
r.config({
|
|
50
|
+
keys: {
|
|
51
|
+
// Scenario 1: system-only URL
|
|
52
|
+
serviceUrl: createSystemConfig("text", {
|
|
53
|
+
default: "https://default.example.com",
|
|
54
|
+
write: access.systemAdmin,
|
|
55
|
+
}),
|
|
56
|
+
// Scenario 2: mail server with system default + tenant override
|
|
57
|
+
mailServer: createTenantConfig("text", {
|
|
58
|
+
default: "smtp.default.com",
|
|
59
|
+
write: access.roles("SystemAdmin", "Admin"),
|
|
60
|
+
read: access.admin,
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Scenario 3: Tenant mail signature — admin can change
|
|
67
|
+
// Scenario 4: Tenant invoice pattern — billing can change
|
|
68
|
+
const invoicingFeature = defineFeature("invoicing", (r) => {
|
|
69
|
+
r.requires("config");
|
|
70
|
+
|
|
71
|
+
r.config({
|
|
72
|
+
keys: {
|
|
73
|
+
// Scenario 3
|
|
74
|
+
mailSignature: createTenantConfig("text", {
|
|
75
|
+
default: "Best regards",
|
|
76
|
+
write: access.roles("Admin"),
|
|
77
|
+
}),
|
|
78
|
+
// Scenario 4
|
|
79
|
+
invoicePattern: createTenantConfig("text", {
|
|
80
|
+
default: "INV-{year}-{number}",
|
|
81
|
+
write: access.roles("Billing"),
|
|
82
|
+
read: access.roles("Admin", "Billing"),
|
|
83
|
+
}),
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Scenario 5: User push notification setting. Setup-return becomes
|
|
89
|
+
// `notificationsFeature.exports` (defineFeature generic) — that's how the
|
|
90
|
+
// probe handler below reaches the typed handle without module-capture.
|
|
91
|
+
const notificationsFeature = defineFeature("notifications", (r) => {
|
|
92
|
+
r.requires("config");
|
|
93
|
+
return r.config({
|
|
94
|
+
keys: {
|
|
95
|
+
pushEnabled: createUserConfig("boolean", { default: true }),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// Scenario 6: Feature setting per tenant
|
|
101
|
+
const ordersFeature = defineFeature("orders", (r) => {
|
|
102
|
+
r.requires("config");
|
|
103
|
+
return r.config({
|
|
104
|
+
keys: {
|
|
105
|
+
maxOrderCount: createTenantConfig("number", { default: 100, write: access.roles("Admin") }),
|
|
106
|
+
// Scenario 7: numeric key with bounds — reject-path for out-of-range values.
|
|
107
|
+
maxUploadSizeMB: createTenantConfig("number", {
|
|
108
|
+
default: 10,
|
|
109
|
+
bounds: { min: 1, max: 1000 },
|
|
110
|
+
write: access.roles("Admin"),
|
|
111
|
+
}),
|
|
112
|
+
// Scenario 9: computed key — simulates plan-based quota. Fake-lookup
|
|
113
|
+
// by tenantId suffix so the test stays hermetic. In a real app,
|
|
114
|
+
// computed would `ctx.db.select()...` a subscription table.
|
|
115
|
+
planBasedQuotaGB: createTenantConfig("number", {
|
|
116
|
+
default: 1,
|
|
117
|
+
write: access.roles("Admin"),
|
|
118
|
+
computed: async ({ tenantId }) => {
|
|
119
|
+
// Tenant 2 = "Pro" plan, everyone else gets basic.
|
|
120
|
+
if (tenantId.endsWith("0000000002")) return 500;
|
|
121
|
+
return 50;
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Probe feature: a real writeHandler reads two configs through ctx.config
|
|
129
|
+
// so the dispatcher-wiring path is exercised end-to-end (not just the
|
|
130
|
+
// factory in isolation). Captures the resolved values for the test to assert.
|
|
131
|
+
const probe: { orders: number | undefined; push: boolean | undefined } = {
|
|
132
|
+
orders: undefined,
|
|
133
|
+
push: undefined,
|
|
134
|
+
};
|
|
135
|
+
const probeFeature = defineFeature("probe", (r) => {
|
|
136
|
+
r.requires("config");
|
|
137
|
+
r.requires("orders");
|
|
138
|
+
r.requires("notifications");
|
|
139
|
+
|
|
140
|
+
r.writeHandler(
|
|
141
|
+
"read-config",
|
|
142
|
+
z.object({}),
|
|
143
|
+
async (_event, ctx) => {
|
|
144
|
+
if (!ctx.config) throw new Error("ctx.config not wired — _configAccessorFactory missing");
|
|
145
|
+
probe.orders = await ctx.config(ordersFeature.exports.maxOrderCount);
|
|
146
|
+
probe.push = await ctx.config(notificationsFeature.exports.pushEnabled);
|
|
147
|
+
return { isSuccess: true, data: { orders: probe.orders, push: probe.push } };
|
|
148
|
+
},
|
|
149
|
+
{ access: { openToAll: true } },
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Encrypted config key
|
|
154
|
+
const integrationFeature = defineFeature("integration", (r) => {
|
|
155
|
+
r.requires("config");
|
|
156
|
+
|
|
157
|
+
r.config({
|
|
158
|
+
keys: {
|
|
159
|
+
apiSecret: createTenantConfig("text", {
|
|
160
|
+
write: access.systemAdmin,
|
|
161
|
+
read: access.systemAdmin,
|
|
162
|
+
encrypted: true,
|
|
163
|
+
}),
|
|
164
|
+
// Dedicated key for the lifecycle-event tests below. Kept in its own
|
|
165
|
+
// key so `.created` / `.updated` assertions don't race with earlier
|
|
166
|
+
// scenarios that mutate shared keys (max-order-count etc.).
|
|
167
|
+
lifecycleProbe: createTenantConfig("text", {
|
|
168
|
+
default: "initial",
|
|
169
|
+
write: access.roles("Admin"),
|
|
170
|
+
}),
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const configFeature = createConfigFeature();
|
|
176
|
+
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
177
|
+
|
|
178
|
+
beforeAll(async () => {
|
|
179
|
+
const encryption = createEncryptionProvider(testEncryptionKey);
|
|
180
|
+
resolver = createConfigResolver({ encryption });
|
|
181
|
+
|
|
182
|
+
stack = await setupTestStack({
|
|
183
|
+
features: [
|
|
184
|
+
configFeature,
|
|
185
|
+
appFeature,
|
|
186
|
+
invoicingFeature,
|
|
187
|
+
notificationsFeature,
|
|
188
|
+
ordersFeature,
|
|
189
|
+
integrationFeature,
|
|
190
|
+
probeFeature,
|
|
191
|
+
],
|
|
192
|
+
// Wire `ctx.config()` for real handlers: pass the resolver-bound factory
|
|
193
|
+
// so the dispatcher can mint a per-user accessor inside buildHandlerContext.
|
|
194
|
+
extraContext: ({ registry }) => ({
|
|
195
|
+
configResolver: resolver,
|
|
196
|
+
configEncryption: encryption,
|
|
197
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
198
|
+
}),
|
|
199
|
+
});
|
|
200
|
+
db = stack.db;
|
|
201
|
+
|
|
202
|
+
await pushTables(db, { configValuesTable });
|
|
203
|
+
// setupTestStack already calls createEventsTable + createArchivedStreamsTable
|
|
204
|
+
// for us; nothing extra needed for the config-changed event-store writes.
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
afterAll(async () => {
|
|
208
|
+
await stack.cleanup();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// --- Scenario 1: System URL — einmal pro System ---
|
|
212
|
+
|
|
213
|
+
describe("scenario 1: system-scoped service URL", () => {
|
|
214
|
+
test("returns default when no value is set", async () => {
|
|
215
|
+
const configFn = createConfigAccessor(
|
|
216
|
+
stack.registry,
|
|
217
|
+
resolver,
|
|
218
|
+
tenantAdmin.tenantId,
|
|
219
|
+
tenantAdmin.id,
|
|
220
|
+
db,
|
|
221
|
+
);
|
|
222
|
+
const value = await configFn("app:config:service-url");
|
|
223
|
+
expect(value).toBe("https://default.example.com");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("SystemAdmin can set system-scoped value", async () => {
|
|
227
|
+
await stack.http.writeOk(
|
|
228
|
+
ConfigHandlers.set,
|
|
229
|
+
{
|
|
230
|
+
key: "app:config:service-url",
|
|
231
|
+
value: "https://custom.example.com",
|
|
232
|
+
},
|
|
233
|
+
systemAdmin,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const configFn = createConfigAccessor(
|
|
237
|
+
stack.registry,
|
|
238
|
+
resolver,
|
|
239
|
+
tenantAdmin.tenantId,
|
|
240
|
+
tenantAdmin.id,
|
|
241
|
+
db,
|
|
242
|
+
);
|
|
243
|
+
const value = await configFn("app:config:service-url");
|
|
244
|
+
expect(value).toBe("https://custom.example.com");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("tenant Admin cannot set system-scoped value", async () => {
|
|
248
|
+
const error = await stack.http.writeErr(
|
|
249
|
+
ConfigHandlers.set,
|
|
250
|
+
{
|
|
251
|
+
key: "app:config:service-url",
|
|
252
|
+
value: "https://hacked.com",
|
|
253
|
+
},
|
|
254
|
+
tenantAdmin,
|
|
255
|
+
);
|
|
256
|
+
expectErrorIncludes(error, "access_denied");
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// --- Scenario 2: Mail Server — system default + tenant override ---
|
|
261
|
+
|
|
262
|
+
describe("scenario 2: tenant-scoped mail server with system fallback", () => {
|
|
263
|
+
test("returns declared default when nothing is set", async () => {
|
|
264
|
+
const configFn = createConfigAccessor(
|
|
265
|
+
stack.registry,
|
|
266
|
+
resolver,
|
|
267
|
+
tenantAdmin.tenantId,
|
|
268
|
+
tenantAdmin.id,
|
|
269
|
+
db,
|
|
270
|
+
);
|
|
271
|
+
const value = await configFn("app:config:mail-server");
|
|
272
|
+
expect(value).toBe("smtp.default.com");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("SystemAdmin sets system-level value (acts as global default)", async () => {
|
|
276
|
+
await stack.http.writeOk(
|
|
277
|
+
ConfigHandlers.set,
|
|
278
|
+
{
|
|
279
|
+
key: "app:config:mail-server",
|
|
280
|
+
value: "smtp.company.com",
|
|
281
|
+
scope: "system",
|
|
282
|
+
},
|
|
283
|
+
systemAdmin,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
// Both tenants see the system value
|
|
287
|
+
const configT1 = createConfigAccessor(
|
|
288
|
+
stack.registry,
|
|
289
|
+
resolver,
|
|
290
|
+
"00000000-0000-4000-8000-000000000001",
|
|
291
|
+
tenantAdmin.id,
|
|
292
|
+
db,
|
|
293
|
+
);
|
|
294
|
+
expect(await configT1("app:config:mail-server")).toBe("smtp.company.com");
|
|
295
|
+
|
|
296
|
+
const configT2 = createConfigAccessor(
|
|
297
|
+
stack.registry,
|
|
298
|
+
resolver,
|
|
299
|
+
"00000000-0000-4000-8000-000000000002",
|
|
300
|
+
otherTenantAdmin.id,
|
|
301
|
+
db,
|
|
302
|
+
);
|
|
303
|
+
expect(await configT2("app:config:mail-server")).toBe("smtp.company.com");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("tenant Admin overrides with tenant-level value", async () => {
|
|
307
|
+
await stack.http.writeOk(
|
|
308
|
+
ConfigHandlers.set,
|
|
309
|
+
{
|
|
310
|
+
key: "app:config:mail-server",
|
|
311
|
+
value: "smtp.tenant1.com",
|
|
312
|
+
scope: "tenant",
|
|
313
|
+
},
|
|
314
|
+
tenantAdmin,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
// Tenant 1 sees override, tenant 2 still sees system value
|
|
318
|
+
const configT1 = createConfigAccessor(
|
|
319
|
+
stack.registry,
|
|
320
|
+
resolver,
|
|
321
|
+
"00000000-0000-4000-8000-000000000001",
|
|
322
|
+
tenantAdmin.id,
|
|
323
|
+
db,
|
|
324
|
+
);
|
|
325
|
+
expect(await configT1("app:config:mail-server")).toBe("smtp.tenant1.com");
|
|
326
|
+
|
|
327
|
+
const configT2 = createConfigAccessor(
|
|
328
|
+
stack.registry,
|
|
329
|
+
resolver,
|
|
330
|
+
"00000000-0000-4000-8000-000000000002",
|
|
331
|
+
otherTenantAdmin.id,
|
|
332
|
+
db,
|
|
333
|
+
);
|
|
334
|
+
expect(await configT2("app:config:mail-server")).toBe("smtp.company.com");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("reset tenant value falls back to system value", async () => {
|
|
338
|
+
await stack.http.writeOk(
|
|
339
|
+
ConfigHandlers.reset,
|
|
340
|
+
{
|
|
341
|
+
key: "app:config:mail-server",
|
|
342
|
+
scope: "tenant",
|
|
343
|
+
},
|
|
344
|
+
tenantAdmin,
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
const configFn = createConfigAccessor(
|
|
348
|
+
stack.registry,
|
|
349
|
+
resolver,
|
|
350
|
+
"00000000-0000-4000-8000-000000000001",
|
|
351
|
+
tenantAdmin.id,
|
|
352
|
+
db,
|
|
353
|
+
);
|
|
354
|
+
expect(await configFn("app:config:mail-server")).toBe("smtp.company.com");
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
// --- Scenario 3: Tenant mail signature — Admin can change ---
|
|
359
|
+
|
|
360
|
+
describe("scenario 3: tenant mail signature", () => {
|
|
361
|
+
test("Admin can set tenant-level signature", async () => {
|
|
362
|
+
await stack.http.writeOk(
|
|
363
|
+
ConfigHandlers.set,
|
|
364
|
+
{
|
|
365
|
+
key: "invoicing:config:mail-signature",
|
|
366
|
+
value: "Mit freundlichen Grüßen, Firma ABC",
|
|
367
|
+
},
|
|
368
|
+
tenantAdmin,
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
const configFn = createConfigAccessor(
|
|
372
|
+
stack.registry,
|
|
373
|
+
resolver,
|
|
374
|
+
"00000000-0000-4000-8000-000000000001",
|
|
375
|
+
normalUser.id,
|
|
376
|
+
db,
|
|
377
|
+
);
|
|
378
|
+
expect(await configFn("invoicing:config:mail-signature")).toBe(
|
|
379
|
+
"Mit freundlichen Grüßen, Firma ABC",
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("normal User cannot change signature", async () => {
|
|
384
|
+
const error = await stack.http.writeErr(
|
|
385
|
+
ConfigHandlers.set,
|
|
386
|
+
{
|
|
387
|
+
key: "invoicing:config:mail-signature",
|
|
388
|
+
value: "Hacked signature",
|
|
389
|
+
},
|
|
390
|
+
normalUser,
|
|
391
|
+
);
|
|
392
|
+
expectErrorIncludes(error, "access_denied");
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// --- Scenario 4: Invoice pattern — Billing can change ---
|
|
397
|
+
|
|
398
|
+
describe("scenario 4: tenant invoice pattern (Billing role)", () => {
|
|
399
|
+
test("Billing user can set invoice pattern", async () => {
|
|
400
|
+
await stack.http.writeOk(
|
|
401
|
+
ConfigHandlers.set,
|
|
402
|
+
{
|
|
403
|
+
key: "invoicing:config:invoice-pattern",
|
|
404
|
+
value: "RE-{year}/{number}",
|
|
405
|
+
},
|
|
406
|
+
billingUser,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const configFn = createConfigAccessor(
|
|
410
|
+
stack.registry,
|
|
411
|
+
resolver,
|
|
412
|
+
"00000000-0000-4000-8000-000000000001",
|
|
413
|
+
billingUser.id,
|
|
414
|
+
db,
|
|
415
|
+
);
|
|
416
|
+
expect(await configFn("invoicing:config:invoice-pattern")).toBe("RE-{year}/{number}");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("Admin cannot change invoice pattern (only Billing)", async () => {
|
|
420
|
+
const error = await stack.http.writeErr(
|
|
421
|
+
ConfigHandlers.set,
|
|
422
|
+
{
|
|
423
|
+
key: "invoicing:config:invoice-pattern",
|
|
424
|
+
value: "ADM-{number}",
|
|
425
|
+
},
|
|
426
|
+
tenantAdmin,
|
|
427
|
+
);
|
|
428
|
+
expectErrorIncludes(error, "access_denied");
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// --- Scenario 5: User push notification setting ---
|
|
433
|
+
|
|
434
|
+
describe("scenario 5: user-scoped push notifications", () => {
|
|
435
|
+
test("default is true", async () => {
|
|
436
|
+
const configFn = createConfigAccessor(
|
|
437
|
+
stack.registry,
|
|
438
|
+
resolver,
|
|
439
|
+
"00000000-0000-4000-8000-000000000001",
|
|
440
|
+
normalUser.id,
|
|
441
|
+
db,
|
|
442
|
+
);
|
|
443
|
+
expect(await configFn("notifications:config:push-enabled")).toBe(true);
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("user can disable for themselves", async () => {
|
|
447
|
+
await stack.http.writeOk(
|
|
448
|
+
ConfigHandlers.set,
|
|
449
|
+
{
|
|
450
|
+
key: "notifications:config:push-enabled",
|
|
451
|
+
value: false,
|
|
452
|
+
},
|
|
453
|
+
normalUser,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
// This user sees false
|
|
457
|
+
const configUser = createConfigAccessor(
|
|
458
|
+
stack.registry,
|
|
459
|
+
resolver,
|
|
460
|
+
"00000000-0000-4000-8000-000000000001",
|
|
461
|
+
normalUser.id,
|
|
462
|
+
db,
|
|
463
|
+
);
|
|
464
|
+
expect(await configUser("notifications:config:push-enabled")).toBe(false);
|
|
465
|
+
|
|
466
|
+
// Other user still sees default (true)
|
|
467
|
+
const configOther = createConfigAccessor(
|
|
468
|
+
stack.registry,
|
|
469
|
+
resolver,
|
|
470
|
+
"00000000-0000-4000-8000-000000000001",
|
|
471
|
+
tenantAdmin.id,
|
|
472
|
+
db,
|
|
473
|
+
);
|
|
474
|
+
expect(await configOther("notifications:config:push-enabled")).toBe(true);
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("tenant-level default overrides declared default", async () => {
|
|
478
|
+
// Admin sets tenant-level default to false
|
|
479
|
+
await stack.http.writeOk(
|
|
480
|
+
ConfigHandlers.set,
|
|
481
|
+
{
|
|
482
|
+
key: "notifications:config:push-enabled",
|
|
483
|
+
value: false,
|
|
484
|
+
scope: "tenant",
|
|
485
|
+
},
|
|
486
|
+
tenantAdmin,
|
|
487
|
+
);
|
|
488
|
+
|
|
489
|
+
// User who hasn't set their own value now sees false (tenant default)
|
|
490
|
+
const configAdmin = createConfigAccessor(
|
|
491
|
+
stack.registry,
|
|
492
|
+
resolver,
|
|
493
|
+
"00000000-0000-4000-8000-000000000001",
|
|
494
|
+
tenantAdmin.id,
|
|
495
|
+
db,
|
|
496
|
+
);
|
|
497
|
+
expect(await configAdmin("notifications:config:push-enabled")).toBe(false);
|
|
498
|
+
|
|
499
|
+
// User who already set their value still sees their value (false)
|
|
500
|
+
const configUser = createConfigAccessor(
|
|
501
|
+
stack.registry,
|
|
502
|
+
resolver,
|
|
503
|
+
"00000000-0000-4000-8000-000000000001",
|
|
504
|
+
normalUser.id,
|
|
505
|
+
db,
|
|
506
|
+
);
|
|
507
|
+
expect(await configUser("notifications:config:push-enabled")).toBe(false);
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
// --- Scenario 6: Feature setting per tenant ---
|
|
512
|
+
|
|
513
|
+
describe("scenario 6: feature number setting per tenant", () => {
|
|
514
|
+
test("returns typed number default", async () => {
|
|
515
|
+
const configFn = createConfigAccessor(
|
|
516
|
+
stack.registry,
|
|
517
|
+
resolver,
|
|
518
|
+
"00000000-0000-4000-8000-000000000001",
|
|
519
|
+
normalUser.id,
|
|
520
|
+
db,
|
|
521
|
+
);
|
|
522
|
+
const value = await configFn("orders:config:max-order-count");
|
|
523
|
+
expect(value).toBe(100);
|
|
524
|
+
expect(typeof value).toBe("number");
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("Admin sets number value, code can do > comparison", async () => {
|
|
528
|
+
await stack.http.writeOk(
|
|
529
|
+
ConfigHandlers.set,
|
|
530
|
+
{
|
|
531
|
+
key: "orders:config:max-order-count",
|
|
532
|
+
value: 50,
|
|
533
|
+
},
|
|
534
|
+
tenantAdmin,
|
|
535
|
+
);
|
|
536
|
+
|
|
537
|
+
const configFn = createConfigAccessor(
|
|
538
|
+
stack.registry,
|
|
539
|
+
resolver,
|
|
540
|
+
"00000000-0000-4000-8000-000000000001",
|
|
541
|
+
normalUser.id,
|
|
542
|
+
db,
|
|
543
|
+
);
|
|
544
|
+
const maxOrders = await configFn("orders:config:max-order-count");
|
|
545
|
+
|
|
546
|
+
expect(typeof maxOrders).toBe("number");
|
|
547
|
+
expect((maxOrders as number) > 25).toBe(true);
|
|
548
|
+
expect((maxOrders as number) > 75).toBe(false);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("different tenants have different values", async () => {
|
|
552
|
+
await stack.http.writeOk(
|
|
553
|
+
ConfigHandlers.set,
|
|
554
|
+
{
|
|
555
|
+
key: "orders:config:max-order-count",
|
|
556
|
+
value: 200,
|
|
557
|
+
},
|
|
558
|
+
otherTenantAdmin,
|
|
559
|
+
);
|
|
560
|
+
|
|
561
|
+
const configT1 = createConfigAccessor(
|
|
562
|
+
stack.registry,
|
|
563
|
+
resolver,
|
|
564
|
+
"00000000-0000-4000-8000-000000000001",
|
|
565
|
+
normalUser.id,
|
|
566
|
+
db,
|
|
567
|
+
);
|
|
568
|
+
const configT2 = createConfigAccessor(
|
|
569
|
+
stack.registry,
|
|
570
|
+
resolver,
|
|
571
|
+
"00000000-0000-4000-8000-000000000002",
|
|
572
|
+
otherTenantAdmin.id,
|
|
573
|
+
db,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
expect(await configT1("orders:config:max-order-count")).toBe(50);
|
|
577
|
+
expect(await configT2("orders:config:max-order-count")).toBe(200);
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// --- ctx.config() integration ---
|
|
582
|
+
|
|
583
|
+
describe("ctx.config() in handler context", () => {
|
|
584
|
+
test("handler can read config via ctx.config()", async () => {
|
|
585
|
+
const configFn = createConfigAccessor(
|
|
586
|
+
stack.registry,
|
|
587
|
+
resolver,
|
|
588
|
+
"00000000-0000-4000-8000-000000000001",
|
|
589
|
+
tenantAdmin.id,
|
|
590
|
+
db,
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
const maxOrders = await configFn("orders:config:max-order-count");
|
|
594
|
+
const currentOrders = 60;
|
|
595
|
+
|
|
596
|
+
expect(typeof maxOrders).toBe("number");
|
|
597
|
+
expect(maxOrders).toBe(50);
|
|
598
|
+
expect(currentOrders > (maxOrders as number)).toBe(true);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("handle from r.config() carries the qualified key the registry stores", () => {
|
|
602
|
+
expect(ordersFeature.exports.maxOrderCount.name).toBe("orders:config:max-order-count");
|
|
603
|
+
expect(notificationsFeature.exports.pushEnabled.name).toBe("notifications:config:push-enabled");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("ctx.config(handle) is wired through the dispatcher into real handlers", async () => {
|
|
607
|
+
// Reset so prior test order doesn't pollute the assertion.
|
|
608
|
+
probe.orders = undefined;
|
|
609
|
+
probe.push = undefined;
|
|
610
|
+
await stack.http.writeOk("probe:write:read-config", {}, tenantAdmin);
|
|
611
|
+
// The probe handler ran ctx.config(handle) for both keys — typeof
|
|
612
|
+
// catches the regression where the handle path silently returns the
|
|
613
|
+
// broad union instead of the narrowed primitive.
|
|
614
|
+
expect(typeof probe.orders).toBe("number");
|
|
615
|
+
expect(typeof probe.push).toBe("boolean");
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// --- config.values query ---
|
|
620
|
+
|
|
621
|
+
describe("config.values query handler", () => {
|
|
622
|
+
test("returns all visible config values for user", async () => {
|
|
623
|
+
const values = await stack.http.queryOk<Record<string, { value: unknown; scope: string }>>(
|
|
624
|
+
ConfigQueries.values,
|
|
625
|
+
{},
|
|
626
|
+
tenantAdmin,
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
expect(values["app:config:service-url"]).toBeDefined();
|
|
630
|
+
expect(values["app:config:mail-server"]).toBeDefined();
|
|
631
|
+
expect(values["invoicing:config:mail-signature"]).toBeDefined();
|
|
632
|
+
expect(values["notifications:config:push-enabled"]).toBeDefined();
|
|
633
|
+
expect(values["orders:config:max-order-count"]).toBeDefined();
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("filters by read access", async () => {
|
|
637
|
+
const values = await stack.http.queryOk<Record<string, { value: unknown; scope: string }>>(
|
|
638
|
+
ConfigQueries.values,
|
|
639
|
+
{},
|
|
640
|
+
normalUser,
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
// normalUser (role: User) should see "all" read access keys
|
|
644
|
+
expect(values["invoicing:config:mail-signature"]).toBeDefined();
|
|
645
|
+
expect(values["notifications:config:push-enabled"]).toBeDefined();
|
|
646
|
+
expect(values["orders:config:max-order-count"]).toBeDefined();
|
|
647
|
+
|
|
648
|
+
// But NOT keys restricted to Admin/SystemAdmin
|
|
649
|
+
expect(values["app:config:service-url"]).toBeUndefined();
|
|
650
|
+
expect(values["app:config:mail-server"]).toBeUndefined();
|
|
651
|
+
});
|
|
652
|
+
});
|
|
653
|
+
|
|
654
|
+
// --- config.schema query ---
|
|
655
|
+
|
|
656
|
+
describe("config.schema query handler", () => {
|
|
657
|
+
test("returns key definitions filtered by read access", async () => {
|
|
658
|
+
const schema = await stack.http.queryOk<Record<string, unknown>>(
|
|
659
|
+
ConfigQueries.schema,
|
|
660
|
+
{},
|
|
661
|
+
normalUser,
|
|
662
|
+
);
|
|
663
|
+
|
|
664
|
+
expect(schema["invoicing:config:mail-signature"]).toBeDefined();
|
|
665
|
+
expect(schema["app:config:service-url"]).toBeUndefined();
|
|
666
|
+
});
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// --- Type validation ---
|
|
670
|
+
|
|
671
|
+
describe("type validation", () => {
|
|
672
|
+
test("rejects string for number key", async () => {
|
|
673
|
+
const error = await stack.http.writeErr(
|
|
674
|
+
ConfigHandlers.set,
|
|
675
|
+
{
|
|
676
|
+
key: "orders:config:max-order-count",
|
|
677
|
+
value: "not a number",
|
|
678
|
+
},
|
|
679
|
+
tenantAdmin,
|
|
680
|
+
);
|
|
681
|
+
expectErrorIncludes(error, "invalid_type");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("rejects number for boolean key", async () => {
|
|
685
|
+
const error = await stack.http.writeErr(
|
|
686
|
+
ConfigHandlers.set,
|
|
687
|
+
{
|
|
688
|
+
key: "notifications:config:push-enabled",
|
|
689
|
+
value: 42,
|
|
690
|
+
},
|
|
691
|
+
normalUser,
|
|
692
|
+
);
|
|
693
|
+
expectErrorIncludes(error, "invalid_type");
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
test("rejects unknown config key", async () => {
|
|
697
|
+
const error = await stack.http.writeErr(
|
|
698
|
+
ConfigHandlers.set,
|
|
699
|
+
{
|
|
700
|
+
key: "nonexistent:config:key",
|
|
701
|
+
value: "test",
|
|
702
|
+
},
|
|
703
|
+
systemAdmin,
|
|
704
|
+
);
|
|
705
|
+
// unknown config key maps to NotFoundError — reason includes the snake entity name
|
|
706
|
+
expectErrorIncludes(error, "config_key_not_found");
|
|
707
|
+
});
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
// --- Encrypted config ---
|
|
711
|
+
|
|
712
|
+
describe("encrypted config", () => {
|
|
713
|
+
test("encrypted value is stored encrypted in DB, read back decrypted", async () => {
|
|
714
|
+
await stack.http.writeOk(
|
|
715
|
+
ConfigHandlers.set,
|
|
716
|
+
{
|
|
717
|
+
key: "integration:config:api-secret",
|
|
718
|
+
value: "sk-super-secret-key-12345",
|
|
719
|
+
},
|
|
720
|
+
systemAdmin,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
// Read via config accessor — should be decrypted
|
|
724
|
+
const configFn = createConfigAccessor(
|
|
725
|
+
stack.registry,
|
|
726
|
+
resolver,
|
|
727
|
+
"00000000-0000-4000-8000-000000000001",
|
|
728
|
+
systemAdmin.id,
|
|
729
|
+
db,
|
|
730
|
+
);
|
|
731
|
+
const value = await configFn("integration:config:api-secret");
|
|
732
|
+
expect(value).toBe("sk-super-secret-key-12345");
|
|
733
|
+
|
|
734
|
+
// Verify raw DB value is NOT plaintext
|
|
735
|
+
const { eq } = await import("drizzle-orm");
|
|
736
|
+
const [raw] = await db
|
|
737
|
+
.select({ value: configValuesTable.value })
|
|
738
|
+
.from(configValuesTable)
|
|
739
|
+
.where(eq(configValuesTable.key, "integration:config:api-secret"));
|
|
740
|
+
const rawValue = raw?.value as string;
|
|
741
|
+
expect(rawValue).not.toBe("sk-super-secret-key-12345");
|
|
742
|
+
expect(rawValue).not.toContain("sk-super-secret");
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
test("non-SystemAdmin cannot read encrypted key", async () => {
|
|
746
|
+
const values = await stack.http.queryOk<Record<string, unknown>>(
|
|
747
|
+
ConfigQueries.values,
|
|
748
|
+
{},
|
|
749
|
+
tenantAdmin,
|
|
750
|
+
);
|
|
751
|
+
expect(values["integration:config:api-secret"]).toBeUndefined();
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
test("config.values returns masked value for encrypted key even with read access", async () => {
|
|
755
|
+
const values = await stack.http.queryOk<Record<string, { value: unknown; scope: string }>>(
|
|
756
|
+
ConfigQueries.values,
|
|
757
|
+
{},
|
|
758
|
+
systemAdmin,
|
|
759
|
+
);
|
|
760
|
+
expect(values["integration:config:api-secret"]).toBeDefined();
|
|
761
|
+
expect(values["integration:config:api-secret"]?.value).toBe("••••••");
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("ctx.config() returns decrypted value", async () => {
|
|
765
|
+
const configFn = createConfigAccessor(
|
|
766
|
+
stack.registry,
|
|
767
|
+
resolver,
|
|
768
|
+
"00000000-0000-4000-8000-000000000001",
|
|
769
|
+
systemAdmin.id,
|
|
770
|
+
db,
|
|
771
|
+
);
|
|
772
|
+
const value = await configFn("integration:config:api-secret");
|
|
773
|
+
expect(value).toBe("sk-super-secret-key-12345");
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
|
|
777
|
+
// --- Config lifecycle events ---
|
|
778
|
+
//
|
|
779
|
+
// Post-ES refactor: each (key, scope) pair is its own aggregate stream with
|
|
780
|
+
// auto-lifecycle events `configValue.created / .updated / .deleted`. The
|
|
781
|
+
// pre-ES flat "config:event:config-changed" stream on a per-tenant
|
|
782
|
+
// aggregate is gone — subscribers listen to the auto-events via
|
|
783
|
+
// r.multiStreamProjection instead, and per-key replay/asOf falls out of the
|
|
784
|
+
// per-value stream granularity.
|
|
785
|
+
|
|
786
|
+
describe("configValue lifecycle events", () => {
|
|
787
|
+
test("set emits configValue.updated carrying the serialized new value", async () => {
|
|
788
|
+
await stack.http.writeOk(
|
|
789
|
+
ConfigHandlers.set,
|
|
790
|
+
{ key: "orders:config:max-order-count", value: 250 },
|
|
791
|
+
tenantAdmin,
|
|
792
|
+
);
|
|
793
|
+
const events = await db
|
|
794
|
+
.select()
|
|
795
|
+
.from(eventsTable)
|
|
796
|
+
.where(eq(eventsTable.aggregateType, "config-value"));
|
|
797
|
+
// The first set in the suite created the row; subsequent sets update it.
|
|
798
|
+
// Look at the most recent update carrying our value to verify the
|
|
799
|
+
// serialized JSON lands in the event payload (key stays on the row,
|
|
800
|
+
// only value moves on updates — the executor emits a changes/previous
|
|
801
|
+
// diff).
|
|
802
|
+
const updates = events.filter(
|
|
803
|
+
(e) =>
|
|
804
|
+
e.type === "config-value.updated" &&
|
|
805
|
+
(e.payload as { previous?: { key?: string } })?.previous?.key ===
|
|
806
|
+
"orders:config:max-order-count",
|
|
807
|
+
);
|
|
808
|
+
expect(updates.length).toBeGreaterThanOrEqual(1);
|
|
809
|
+
const last = updates[updates.length - 1];
|
|
810
|
+
expect((last?.payload as { changes?: { value?: string } })?.changes?.value).toBe(
|
|
811
|
+
JSON.stringify(250),
|
|
812
|
+
);
|
|
813
|
+
});
|
|
814
|
+
|
|
815
|
+
test("reset emits configValue.deleted for the value row", async () => {
|
|
816
|
+
// Set first so reset has something to roll back.
|
|
817
|
+
await stack.http.writeOk(
|
|
818
|
+
ConfigHandlers.set,
|
|
819
|
+
{ key: "invoicing:config:mail-signature", value: "Cheers" },
|
|
820
|
+
tenantAdmin,
|
|
821
|
+
);
|
|
822
|
+
await stack.http.writeOk(
|
|
823
|
+
ConfigHandlers.reset,
|
|
824
|
+
{ key: "invoicing:config:mail-signature" },
|
|
825
|
+
tenantAdmin,
|
|
826
|
+
);
|
|
827
|
+
const events = await db
|
|
828
|
+
.select()
|
|
829
|
+
.from(eventsTable)
|
|
830
|
+
.where(eq(eventsTable.aggregateType, "config-value"));
|
|
831
|
+
const deletes = events.filter(
|
|
832
|
+
(e) =>
|
|
833
|
+
e.type === "config-value.deleted" &&
|
|
834
|
+
(e.payload as { previous?: { key?: string } })?.previous?.key ===
|
|
835
|
+
"invoicing:config:mail-signature",
|
|
836
|
+
);
|
|
837
|
+
expect(deletes.length).toBeGreaterThanOrEqual(1);
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("first set on a fresh key emits configValue.created with key + serialized value", async () => {
|
|
841
|
+
// Uses a dedicated key (integration:config:lifecycle-probe) that no
|
|
842
|
+
// earlier scenario touches — guarantees the FIRST event is a .created,
|
|
843
|
+
// not a .updated, so the assertion reaches the create-path of the
|
|
844
|
+
// executor without depending on test execution order.
|
|
845
|
+
await stack.http.writeOk(
|
|
846
|
+
ConfigHandlers.set,
|
|
847
|
+
{ key: "integration:config:lifecycle-probe", value: "alpha" },
|
|
848
|
+
tenantAdmin,
|
|
849
|
+
);
|
|
850
|
+
const events = await db
|
|
851
|
+
.select()
|
|
852
|
+
.from(eventsTable)
|
|
853
|
+
.where(eq(eventsTable.aggregateType, "config-value"));
|
|
854
|
+
const created = events.filter(
|
|
855
|
+
(e) =>
|
|
856
|
+
e.type === "config-value.created" &&
|
|
857
|
+
(e.payload as { key?: string })?.key === "integration:config:lifecycle-probe",
|
|
858
|
+
);
|
|
859
|
+
expect(created.length).toBe(1);
|
|
860
|
+
expect(created[0]?.payload).toMatchObject({
|
|
861
|
+
key: "integration:config:lifecycle-probe",
|
|
862
|
+
value: JSON.stringify("alpha"),
|
|
863
|
+
});
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
test("subsequent set emits configValue.updated carrying both changes and previous", async () => {
|
|
867
|
+
// Change the value we seeded above to exercise the .updated-event
|
|
868
|
+
// shape: the executor stamps BOTH halves of the diff onto the payload
|
|
869
|
+
// (changes = what the user sent, previous = the pre-update row). MSPs
|
|
870
|
+
// reading across aggregates need `previous` to decrement / undo when
|
|
871
|
+
// a parent-FK moves — dropping it would break replays.
|
|
872
|
+
await stack.http.writeOk(
|
|
873
|
+
ConfigHandlers.set,
|
|
874
|
+
{ key: "integration:config:lifecycle-probe", value: "beta" },
|
|
875
|
+
tenantAdmin,
|
|
876
|
+
);
|
|
877
|
+
const events = await db
|
|
878
|
+
.select()
|
|
879
|
+
.from(eventsTable)
|
|
880
|
+
.where(eq(eventsTable.aggregateType, "config-value"));
|
|
881
|
+
const updates = events.filter(
|
|
882
|
+
(e) =>
|
|
883
|
+
e.type === "config-value.updated" &&
|
|
884
|
+
(e.payload as { previous?: { key?: string } })?.previous?.key ===
|
|
885
|
+
"integration:config:lifecycle-probe",
|
|
886
|
+
);
|
|
887
|
+
expect(updates.length).toBeGreaterThanOrEqual(1);
|
|
888
|
+
const last = updates[updates.length - 1];
|
|
889
|
+
const payload = last?.payload as {
|
|
890
|
+
changes?: { value?: string };
|
|
891
|
+
previous?: { value?: string; key?: string };
|
|
892
|
+
};
|
|
893
|
+
expect(payload.changes?.value).toBe(JSON.stringify("beta"));
|
|
894
|
+
expect(payload.previous?.value).toBe(JSON.stringify("alpha"));
|
|
895
|
+
expect(payload.previous?.key).toBe("integration:config:lifecycle-probe");
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
test("encrypted-key plaintext never appears in the event payload", async () => {
|
|
899
|
+
await stack.http.writeOk(
|
|
900
|
+
ConfigHandlers.set,
|
|
901
|
+
{ key: "integration:config:api-secret", value: "rotated-secret-987" },
|
|
902
|
+
systemAdmin,
|
|
903
|
+
);
|
|
904
|
+
const events = await db
|
|
905
|
+
.select()
|
|
906
|
+
.from(eventsTable)
|
|
907
|
+
.where(eq(eventsTable.aggregateType, "config-value"));
|
|
908
|
+
const created = events.filter(
|
|
909
|
+
(e) =>
|
|
910
|
+
e.type === "config-value.created" &&
|
|
911
|
+
(e.payload as { key?: string })?.key === "integration:config:api-secret",
|
|
912
|
+
);
|
|
913
|
+
expect(created.length).toBeGreaterThanOrEqual(1);
|
|
914
|
+
const last = created[created.length - 1];
|
|
915
|
+
// The serialized ciphertext (not plaintext) is what landed in the
|
|
916
|
+
// payload — the resolver wraps set() in the encryption provider before
|
|
917
|
+
// the executor hands flatData to the event writer.
|
|
918
|
+
const serializedPayload = JSON.stringify(last?.payload);
|
|
919
|
+
expect(serializedPayload).not.toContain("rotated-secret-987");
|
|
920
|
+
});
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
// --- Scenario 7: Bounds enforcement on numeric keys ---
|
|
924
|
+
//
|
|
925
|
+
// orders:config:max-upload-size-mb declares bounds: { min: 1, max: 1000 }.
|
|
926
|
+
// A tenant-admin SET with a value outside that range must hard-reject with
|
|
927
|
+
// a validation error — silent clamping is explicitly ruled out (see
|
|
928
|
+
// types/config.ts comment on ConfigBounds).
|
|
929
|
+
|
|
930
|
+
describe("scenario 7: bounds enforcement", () => {
|
|
931
|
+
const boundedKey = "orders:config:max-upload-size-mb";
|
|
932
|
+
|
|
933
|
+
test("accepts value inside bounds", async () => {
|
|
934
|
+
const result = await stack.http.writeOk(
|
|
935
|
+
ConfigHandlers.set,
|
|
936
|
+
{ key: boundedKey, value: 500 },
|
|
937
|
+
tenantAdmin,
|
|
938
|
+
);
|
|
939
|
+
expect(result).toMatchObject({ value: 500 });
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("accepts boundary values (min + max exact)", async () => {
|
|
943
|
+
const atMin = await stack.http.writeOk(
|
|
944
|
+
ConfigHandlers.set,
|
|
945
|
+
{ key: boundedKey, value: 1 },
|
|
946
|
+
tenantAdmin,
|
|
947
|
+
);
|
|
948
|
+
expect(atMin).toMatchObject({ value: 1 });
|
|
949
|
+
const atMax = await stack.http.writeOk(
|
|
950
|
+
ConfigHandlers.set,
|
|
951
|
+
{ key: boundedKey, value: 1000 },
|
|
952
|
+
tenantAdmin,
|
|
953
|
+
);
|
|
954
|
+
expect(atMax).toMatchObject({ value: 1000 });
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("rejects value below min with out_of_bounds", async () => {
|
|
958
|
+
const error = await stack.http.writeErr(
|
|
959
|
+
ConfigHandlers.set,
|
|
960
|
+
{ key: boundedKey, value: 0 },
|
|
961
|
+
tenantAdmin,
|
|
962
|
+
);
|
|
963
|
+
expectErrorIncludes(error, "out_of_bounds");
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
test("rejects value above max with out_of_bounds", async () => {
|
|
967
|
+
const error = await stack.http.writeErr(
|
|
968
|
+
ConfigHandlers.set,
|
|
969
|
+
{ key: boundedKey, value: 10_000 },
|
|
970
|
+
tenantAdmin,
|
|
971
|
+
);
|
|
972
|
+
expectErrorIncludes(error, "out_of_bounds");
|
|
973
|
+
});
|
|
974
|
+
|
|
975
|
+
test("rejects even when caller has write-role — bounds override role-grants", async () => {
|
|
976
|
+
// tenantAdmin has Admin role → passes access check, then fails bounds.
|
|
977
|
+
const error = await stack.http.writeErr(
|
|
978
|
+
ConfigHandlers.set,
|
|
979
|
+
{ key: boundedKey, value: -1 },
|
|
980
|
+
tenantAdmin,
|
|
981
|
+
);
|
|
982
|
+
expectErrorIncludes(error, "out_of_bounds");
|
|
983
|
+
});
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
// --- Scenario 8: App-Boot-Overrides ---
|
|
987
|
+
//
|
|
988
|
+
// buildServer-level overrides sit between the scope-specific rows and the
|
|
989
|
+
// feature-declared default. Key rule: a deliberate Set from a tenant-admin
|
|
990
|
+
// still wins. The override is the *better default for this deploy*, not a
|
|
991
|
+
// hard policy.
|
|
992
|
+
|
|
993
|
+
describe("scenario 8: app-boot overrides", () => {
|
|
994
|
+
const OVERRIDE_KEY = "orders:config:max-order-count";
|
|
995
|
+
|
|
996
|
+
test("validateAppOverrides throws synchronously for bad values (prevents broken deploy)", () => {
|
|
997
|
+
expect(() =>
|
|
998
|
+
validateAppOverrides(stack.registry, {
|
|
999
|
+
"orders:config:max-upload-size-mb": 99_999, // above bounds.max = 1000
|
|
1000
|
+
}),
|
|
1001
|
+
).toThrow(/above bounds\.max/i);
|
|
1002
|
+
|
|
1003
|
+
expect(() =>
|
|
1004
|
+
validateAppOverrides(stack.registry, {
|
|
1005
|
+
"does-not-exist:config:foo": 1,
|
|
1006
|
+
}),
|
|
1007
|
+
).toThrow(/unknown config key/i);
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
test("override is returned when no row exists for the key", async () => {
|
|
1011
|
+
// Fresh tenant-id that earlier tests haven't touched — so the cascade
|
|
1012
|
+
// finds no tenant-row, no system-row, and falls through to the override.
|
|
1013
|
+
const freshTenant = "00000000-0000-4000-8000-0000000000aa";
|
|
1014
|
+
const resolverWithOverride = createConfigResolver({
|
|
1015
|
+
appOverrides: validateAppOverrides(stack.registry, {
|
|
1016
|
+
[OVERRIDE_KEY]: 250,
|
|
1017
|
+
}),
|
|
1018
|
+
});
|
|
1019
|
+
|
|
1020
|
+
const keyDef = stack.registry.getConfigKey(OVERRIDE_KEY);
|
|
1021
|
+
if (!keyDef) throw new Error("key missing");
|
|
1022
|
+
const value = await resolverWithOverride.get(
|
|
1023
|
+
OVERRIDE_KEY,
|
|
1024
|
+
keyDef,
|
|
1025
|
+
// biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
|
|
1026
|
+
freshTenant as any,
|
|
1027
|
+
"00000000-0000-4000-8000-0000000000aa",
|
|
1028
|
+
db,
|
|
1029
|
+
);
|
|
1030
|
+
expect(value).toBe(250);
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
test("tenant-row wins over app-boot-override (admin intent > deploy default)", async () => {
|
|
1034
|
+
// Set a tenant-row first.
|
|
1035
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: OVERRIDE_KEY, value: 77 }, tenantAdmin);
|
|
1036
|
+
|
|
1037
|
+
// Now build a resolver with a different override value.
|
|
1038
|
+
const resolverWithOverride = createConfigResolver({
|
|
1039
|
+
appOverrides: validateAppOverrides(stack.registry, {
|
|
1040
|
+
[OVERRIDE_KEY]: 250,
|
|
1041
|
+
}),
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
const keyDef = stack.registry.getConfigKey(OVERRIDE_KEY);
|
|
1045
|
+
if (!keyDef) throw new Error("key missing");
|
|
1046
|
+
const value = await resolverWithOverride.get(
|
|
1047
|
+
OVERRIDE_KEY,
|
|
1048
|
+
keyDef,
|
|
1049
|
+
tenantAdmin.tenantId,
|
|
1050
|
+
tenantAdmin.id,
|
|
1051
|
+
db,
|
|
1052
|
+
);
|
|
1053
|
+
// Row wins: 77, not 250.
|
|
1054
|
+
expect(value).toBe(77);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test("falls back to feature-declared default when no row AND no override", async () => {
|
|
1058
|
+
const resolverNoOverride = createConfigResolver();
|
|
1059
|
+
// Use a fresh tenant id so no tenant-row interferes.
|
|
1060
|
+
const freshTenant = "00000000-0000-4000-8000-000000000999";
|
|
1061
|
+
const keyDef = stack.registry.getConfigKey(OVERRIDE_KEY);
|
|
1062
|
+
if (!keyDef) throw new Error("key missing");
|
|
1063
|
+
const value = await resolverNoOverride.get(
|
|
1064
|
+
OVERRIDE_KEY,
|
|
1065
|
+
keyDef,
|
|
1066
|
+
// biome-ignore lint/suspicious/noExplicitAny: TenantId brand for a throwaway test value
|
|
1067
|
+
freshTenant as any,
|
|
1068
|
+
"00000000-0000-4000-8000-000000000999",
|
|
1069
|
+
db,
|
|
1070
|
+
);
|
|
1071
|
+
expect(value).toBe(100); // keyDef.default
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
// --- Scenario 9: Computed resolver (plan-based values) ---
|
|
1076
|
+
//
|
|
1077
|
+
// `computed` sits between app-override and default in the cascade. Row
|
|
1078
|
+
// still wins — a tenant-admin SET beats the plan. This matches the
|
|
1079
|
+
// documented "admin intent > deploy default > plan default > hard default"
|
|
1080
|
+
// hierarchy from configuration-layers.md.
|
|
1081
|
+
|
|
1082
|
+
describe("scenario 9: computed resolver", () => {
|
|
1083
|
+
const COMPUTED_KEY = "orders:config:plan-based-quota-gb";
|
|
1084
|
+
|
|
1085
|
+
test("computed returns Pro-plan value for Tenant 2 (endsWith 0002)", async () => {
|
|
1086
|
+
const keyDef = stack.registry.getConfigKey(COMPUTED_KEY);
|
|
1087
|
+
if (!keyDef) throw new Error("key missing");
|
|
1088
|
+
const value = await resolver.get(
|
|
1089
|
+
COMPUTED_KEY,
|
|
1090
|
+
keyDef,
|
|
1091
|
+
otherTenantAdmin.tenantId, // ends in 0002 → Pro
|
|
1092
|
+
otherTenantAdmin.id,
|
|
1093
|
+
db,
|
|
1094
|
+
);
|
|
1095
|
+
expect(value).toBe(500);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test("computed returns basic-plan value for Tenant 1 (no Pro suffix)", async () => {
|
|
1099
|
+
const keyDef = stack.registry.getConfigKey(COMPUTED_KEY);
|
|
1100
|
+
if (!keyDef) throw new Error("key missing");
|
|
1101
|
+
// Tenant admin's tenantId doesn't end in 0002 — gets basic.
|
|
1102
|
+
const value = await resolver.get(
|
|
1103
|
+
COMPUTED_KEY,
|
|
1104
|
+
keyDef,
|
|
1105
|
+
tenantAdmin.tenantId,
|
|
1106
|
+
tenantAdmin.id,
|
|
1107
|
+
db,
|
|
1108
|
+
);
|
|
1109
|
+
expect(value).toBe(50);
|
|
1110
|
+
});
|
|
1111
|
+
|
|
1112
|
+
test("row wins over computed — admin SET beats plan-default", async () => {
|
|
1113
|
+
// Tenant admin manually sets a value.
|
|
1114
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: COMPUTED_KEY, value: 999 }, tenantAdmin);
|
|
1115
|
+
|
|
1116
|
+
const keyDef = stack.registry.getConfigKey(COMPUTED_KEY);
|
|
1117
|
+
if (!keyDef) throw new Error("key missing");
|
|
1118
|
+
const value = await resolver.get(
|
|
1119
|
+
COMPUTED_KEY,
|
|
1120
|
+
keyDef,
|
|
1121
|
+
tenantAdmin.tenantId,
|
|
1122
|
+
tenantAdmin.id,
|
|
1123
|
+
db,
|
|
1124
|
+
);
|
|
1125
|
+
// computed would return 50, row says 999 → row wins.
|
|
1126
|
+
expect(value).toBe(999);
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
test("app-override on a computed key is rejected at validation time", async () => {
|
|
1130
|
+
// Post Task-17: combining computed with an app-override silently bypasses
|
|
1131
|
+
// the plan-logic. validateAppOverrides refuses at boot. This test pins
|
|
1132
|
+
// that guarantee — a future relaxation would need to update it explicitly.
|
|
1133
|
+
expect(() =>
|
|
1134
|
+
validateAppOverrides(stack.registry, {
|
|
1135
|
+
[COMPUTED_KEY]: 77,
|
|
1136
|
+
}),
|
|
1137
|
+
).toThrow(/computed resolver.*app-overrides would silently bypass/i);
|
|
1138
|
+
});
|
|
1139
|
+
});
|
|
1140
|
+
|
|
1141
|
+
// --- Scenario 10: getWithSource — Debug/Ops-Introspection ---
|
|
1142
|
+
//
|
|
1143
|
+
// Same cascade as get() but also reports WHICH layer produced the value.
|
|
1144
|
+
// Ops-tooling needs this for "warum ist mein Wert X?" — debugging a
|
|
1145
|
+
// cascade with 6 possible sources by hand is no fun.
|
|
1146
|
+
|
|
1147
|
+
describe("scenario 10: getWithSource reports source-of-truth", () => {
|
|
1148
|
+
const TENANT_KEY = "orders:config:max-order-count"; // default: 100, scope: tenant
|
|
1149
|
+
|
|
1150
|
+
test("source=tenant-row when a tenant-row exists", async () => {
|
|
1151
|
+
await stack.http.writeOk(ConfigHandlers.set, { key: TENANT_KEY, value: 77 }, tenantAdmin);
|
|
1152
|
+
const keyDef = stack.registry.getConfigKey(TENANT_KEY);
|
|
1153
|
+
if (!keyDef) throw new Error("key missing");
|
|
1154
|
+
const traced = await resolver.getWithSource(
|
|
1155
|
+
TENANT_KEY,
|
|
1156
|
+
keyDef,
|
|
1157
|
+
tenantAdmin.tenantId,
|
|
1158
|
+
tenantAdmin.id,
|
|
1159
|
+
db,
|
|
1160
|
+
);
|
|
1161
|
+
expect(traced.value).toBe(77);
|
|
1162
|
+
expect(traced.source).toBe("tenant-row");
|
|
1163
|
+
});
|
|
1164
|
+
|
|
1165
|
+
test("source=default when no row, no override, no computed", async () => {
|
|
1166
|
+
// Fresh tenant with no row for this key → falls through to keyDef.default.
|
|
1167
|
+
const freshTenant = "00000000-0000-4000-8000-0000000000dd";
|
|
1168
|
+
const keyDef = stack.registry.getConfigKey(TENANT_KEY);
|
|
1169
|
+
if (!keyDef) throw new Error("key missing");
|
|
1170
|
+
const traced = await resolver.getWithSource(
|
|
1171
|
+
TENANT_KEY,
|
|
1172
|
+
keyDef,
|
|
1173
|
+
// biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
|
|
1174
|
+
freshTenant as any,
|
|
1175
|
+
"00000000-0000-4000-8000-0000000000dd",
|
|
1176
|
+
db,
|
|
1177
|
+
);
|
|
1178
|
+
expect(traced.value).toBe(100); // keyDef.default
|
|
1179
|
+
expect(traced.source).toBe("default");
|
|
1180
|
+
});
|
|
1181
|
+
|
|
1182
|
+
test("source=app-override when appOverrides has the key and no row exists", async () => {
|
|
1183
|
+
const resolverWithOverride = createConfigResolver({
|
|
1184
|
+
appOverrides: validateAppOverrides(stack.registry, { [TENANT_KEY]: 333 }),
|
|
1185
|
+
});
|
|
1186
|
+
const freshTenant = "00000000-0000-4000-8000-0000000000ee";
|
|
1187
|
+
const keyDef = stack.registry.getConfigKey(TENANT_KEY);
|
|
1188
|
+
if (!keyDef) throw new Error("key missing");
|
|
1189
|
+
const traced = await resolverWithOverride.getWithSource(
|
|
1190
|
+
TENANT_KEY,
|
|
1191
|
+
keyDef,
|
|
1192
|
+
// biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
|
|
1193
|
+
freshTenant as any,
|
|
1194
|
+
"00000000-0000-4000-8000-0000000000ee",
|
|
1195
|
+
db,
|
|
1196
|
+
);
|
|
1197
|
+
expect(traced.value).toBe(333);
|
|
1198
|
+
expect(traced.source).toBe("app-override");
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("source=computed when no row AND no override AND keyDef.computed exists", async () => {
|
|
1202
|
+
const freshTenant = "00000000-0000-4000-8000-0000000000ff";
|
|
1203
|
+
const keyDef = stack.registry.getConfigKey("orders:config:plan-based-quota-gb");
|
|
1204
|
+
if (!keyDef) throw new Error("key missing");
|
|
1205
|
+
const traced = await resolver.getWithSource(
|
|
1206
|
+
"orders:config:plan-based-quota-gb",
|
|
1207
|
+
keyDef,
|
|
1208
|
+
// biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
|
|
1209
|
+
freshTenant as any,
|
|
1210
|
+
"00000000-0000-4000-8000-0000000000ff",
|
|
1211
|
+
db,
|
|
1212
|
+
);
|
|
1213
|
+
expect(traced.source).toBe("computed");
|
|
1214
|
+
expect(traced.value).toBe(50); // basic plan
|
|
1215
|
+
});
|
|
1216
|
+
|
|
1217
|
+
test("source=missing when no row, no override, no computed, no default", async () => {
|
|
1218
|
+
// Key with no default + no row + no override.
|
|
1219
|
+
const noDefaultKey = createTenantConfig("number");
|
|
1220
|
+
const freshTenant = "00000000-0000-4000-8000-000000000011";
|
|
1221
|
+
const traced = await resolver.getWithSource(
|
|
1222
|
+
"throwaway:config:no-default",
|
|
1223
|
+
noDefaultKey,
|
|
1224
|
+
// biome-ignore lint/suspicious/noExplicitAny: throwaway TenantId brand
|
|
1225
|
+
freshTenant as any,
|
|
1226
|
+
"00000000-0000-4000-8000-000000000011",
|
|
1227
|
+
db,
|
|
1228
|
+
);
|
|
1229
|
+
expect(traced.value).toBeUndefined();
|
|
1230
|
+
expect(traced.source).toBe("missing");
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
test("get() and getWithSource() return equivalent values for the same cascade", async () => {
|
|
1234
|
+
const keyDef = stack.registry.getConfigKey(TENANT_KEY);
|
|
1235
|
+
if (!keyDef) throw new Error("key missing");
|
|
1236
|
+
const flat = await resolver.get(TENANT_KEY, keyDef, tenantAdmin.tenantId, tenantAdmin.id, db);
|
|
1237
|
+
const traced = await resolver.getWithSource(
|
|
1238
|
+
TENANT_KEY,
|
|
1239
|
+
keyDef,
|
|
1240
|
+
tenantAdmin.tenantId,
|
|
1241
|
+
tenantAdmin.id,
|
|
1242
|
+
db,
|
|
1243
|
+
);
|
|
1244
|
+
expect(traced.value).toBe(flat);
|
|
1245
|
+
});
|
|
1246
|
+
});
|