@cosmicdrift/kumiko-bundled-features 0.158.2 → 0.160.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 +7 -6
- package/src/auth-email-password/__tests__/account-unlock.integration.test.ts +283 -0
- package/src/auth-email-password/__tests__/auth-user-row.test.ts +36 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +21 -0
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +1 -1
- package/src/auth-email-password/__tests__/password-policy.test.ts +15 -0
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +15 -1
- package/src/auth-email-password/__tests__/session-strict-mode.integration.test.ts +7 -14
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +12 -0
- package/src/auth-email-password/constants.ts +15 -0
- package/src/auth-email-password/email-templates.ts +32 -0
- package/src/auth-email-password/errors.ts +9 -0
- package/src/auth-email-password/feature.ts +37 -5
- package/src/auth-email-password/handlers/change-password.write.ts +2 -1
- package/src/auth-email-password/handlers/confirm-account-unlock.write.ts +59 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +1 -0
- package/src/auth-email-password/handlers/invite-accept.write.ts +6 -2
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +1 -0
- package/src/auth-email-password/handlers/request-account-unlock.write.ts +38 -0
- package/src/auth-email-password/handlers/reset-password.write.ts +2 -1
- package/src/auth-email-password/handlers/signup-confirm.write.ts +2 -1
- package/src/auth-email-password/i18n.ts +42 -0
- package/src/auth-email-password/index.ts +2 -0
- package/src/auth-email-password/lockout-store.ts +10 -5
- package/src/auth-email-password/password-policy.ts +2108 -0
- package/src/auth-email-password/signed-token.ts +1 -0
- package/src/auth-email-password/unlock-token.ts +28 -0
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +525 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +65 -3
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +57 -0
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +126 -2
- package/src/auth-email-password/web/__tests__/login-screen.test.tsx +125 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +131 -0
- package/src/auth-email-password/web/auth-client.ts +33 -0
- package/src/auth-email-password/web/confirm-account-unlock-screen.tsx +95 -0
- package/src/auth-email-password/web/index.ts +6 -0
- package/src/auth-email-password/web/login-screen.tsx +15 -0
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +112 -0
- package/src/auth-foundation/__tests__/feature.test.ts +33 -0
- package/src/auth-foundation/__tests__/session-store.test.ts +78 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +117 -0
- package/src/auth-foundation/boot-checks.ts +91 -0
- package/src/auth-foundation/feature.ts +103 -0
- package/src/auth-foundation/index.ts +23 -0
- package/src/auth-foundation/types.ts +107 -0
- package/src/auth-mfa/__tests__/errors-i18n-parity.test.ts +8 -0
- package/src/auth-mfa/__tests__/verify.integration.test.ts +44 -0
- package/src/auth-mfa/handlers/verify.write.ts +1 -1
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +118 -0
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +173 -0
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +100 -0
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +69 -0
- package/src/channel-email/pii-guard.ts +6 -3
- package/src/config/__tests__/app-overrides.test.ts +11 -0
- package/src/config/__tests__/backing-secrets.integration.test.ts +18 -0
- package/src/config/__tests__/cascade.integration.test.ts +39 -0
- package/src/config/__tests__/config.integration.test.ts +14 -0
- package/src/config/__tests__/pii-encrypted.integration.test.ts +23 -2
- package/src/config/__tests__/reencrypt-job-concurrent-write-race.integration.test.ts +20 -3
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +2 -3
- package/src/config/feature.ts +2 -4
- package/src/config/handlers/set.write.ts +7 -1
- package/src/config/resolver.ts +1 -1
- package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +2 -0
- package/src/delivery/handlers/log.query.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +7 -0
- package/src/folders/web/__tests__/folder-manager.test.tsx +27 -2
- package/src/inbound-mail-foundation/__tests__/account-state.test.ts +30 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +57 -6
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +93 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +154 -0
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +60 -50
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +12 -0
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +331 -0
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +48 -0
- package/src/inbound-provider-inmemory/feature.ts +2 -2
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +53 -1
- package/src/jobs/handlers/detail.query.ts +1 -1
- package/src/jobs/handlers/list.query.ts +1 -1
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +42 -0
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +19 -0
- package/src/mail-transport-smtp/__tests__/feature.test.ts +60 -0
- package/src/managed-pages/handlers/branding.query.ts +5 -1
- package/src/personal-access-tokens/__tests__/feature.test.ts +18 -0
- package/src/personal-access-tokens/__tests__/pat.integration.test.ts +8 -11
- package/src/personal-access-tokens/feature.ts +23 -8
- package/src/personal-access-tokens/handlers/list.query.ts +1 -1
- package/src/personal-access-tokens/index.ts +0 -14
- package/src/personal-access-tokens/resolver.ts +2 -2
- package/src/secrets/__tests__/rotate.integration.test.ts +31 -0
- package/src/sessions/__tests__/cleanup.integration.test.ts +2 -1
- package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +5 -3
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +11 -2
- package/src/sessions/__tests__/session-store-registration.test.ts +21 -0
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +10 -1
- package/src/sessions/__tests__/sessions.integration.test.ts +28 -17
- package/src/sessions/__tests__/test-helpers.ts +15 -2
- package/src/sessions/feature.ts +31 -3
- package/src/sessions/handlers/detail.query.ts +2 -2
- package/src/sessions/handlers/list.query.ts +4 -2
- package/src/sessions/handlers/mine.query.ts +4 -2
- package/src/sessions/testing.ts +14 -1
- package/src/shared/__tests__/chunked-entity-migration.test.ts +145 -0
- package/src/shared/decrypt-stored-pii.ts +12 -5
- package/src/subscription-mollie/__tests__/feature.test.ts +87 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +120 -1
- package/src/tags/web/__tests__/tag-manager.test.tsx +277 -8
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +98 -0
- package/src/tenant/handlers/invitations.query.ts +4 -2
- package/src/tenant/handlers/members.query.ts +2 -2
- package/src/tenant/seeding.ts +2 -2
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +2 -0
- package/src/tenant-lifecycle/boot-checks.ts +53 -0
- package/src/tenant-lifecycle/feature.ts +7 -0
- package/src/tenant-lifecycle/handlers/request-destruction.write.ts +1 -1
- package/src/tier-engine/feature.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +20 -0
- package/src/user-data-rights/__tests__/anonymous-deletion-kms.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/anonymous-deletion.integration.test.ts +3 -0
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +172 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/download.integration.test.ts +59 -4
- package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/export-job-list-cross-tenant.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +10 -1
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +15 -7
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/request-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +48 -15
- package/src/user-data-rights/__tests__/run-export-jobs-cron-context.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +184 -1
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/tenant-model-erasure.integration.test.ts +2 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.test.ts +9 -1
- package/src/user-data-rights/boot-checks.ts +86 -0
- package/src/user-data-rights/feature.ts +116 -6
- package/src/user-data-rights/handlers/deletion-grace-period.ts +1 -1
- package/src/user-data-rights/handlers/download-by-token.query.ts +1 -1
- package/src/user-data-rights/handlers/lift-restriction.write.ts +13 -19
- package/src/user-data-rights/handlers/restrict-account.write.ts +30 -8
- package/src/user-data-rights/run-export-jobs.ts +3 -3
- package/src/user-data-rights/run-forget-cleanup.ts +1 -1
- package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +1 -0
- package/src/user-data-rights-defaults/__tests__/bundled-entities.userdata-hooks.integration.test.ts +3 -0
- package/src/user-data-rights-defaults/__tests__/feature-mounted.test.ts +24 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +2 -0
- package/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +1 -1
- package/src/user-profile/__tests__/change-email.integration.test.ts +2 -0
- package/src/user-profile/__tests__/profile-screen.test.tsx +1 -0
- package/src/user-profile/handlers/change-email.write.ts +1 -1
- package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +0 -179
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
// feature.ts contract tests for subscription-stripe.
|
|
2
2
|
|
|
3
|
-
import { describe, expect, test } from "bun:test";
|
|
3
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
4
|
+
import type { HandlerContext } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import Stripe from "stripe";
|
|
6
|
+
import { describeSubscriptionProviderContract } from "../../billing-foundation/__tests__/subscription-provider-contract";
|
|
4
7
|
import { STRIPE_PROVIDER_NAME, StripeEventTypes, SUBSCRIPTION_STRIPE_FEATURE } from "../constants";
|
|
5
8
|
import { createSubscriptionStripeFeature } from "../feature";
|
|
9
|
+
import {
|
|
10
|
+
createStripeCancelSubscription,
|
|
11
|
+
createStripeCheckoutSession,
|
|
12
|
+
createStripePortalSession,
|
|
13
|
+
} from "../plugin-methods";
|
|
14
|
+
import type { StripeCtxRuntime } from "../runtime";
|
|
15
|
+
import { verifyAndParseStripeWebhook } from "../verify-webhook";
|
|
6
16
|
|
|
7
17
|
const OPTIONS = {
|
|
8
18
|
priceToTier: { price_test: "pro" },
|
|
@@ -69,6 +79,8 @@ describe("subscription-stripe — plugin-registration", () => {
|
|
|
69
79
|
// "method not supported"-error brechen — type-check würde es nicht
|
|
70
80
|
// fangen weil die useExtension-options als `unknown` durchgereicht
|
|
71
81
|
// werden.
|
|
82
|
+
// Checks the mounted feature — the contract below builds its plugin
|
|
83
|
+
// by hand-wiring, so it would not catch a wiring gap here.
|
|
72
84
|
const feature = createSubscriptionStripeFeature(OPTIONS);
|
|
73
85
|
const usage = feature.extensionUsages.find((u) => u.entityName === STRIPE_PROVIDER_NAME);
|
|
74
86
|
expect(usage).toBeDefined();
|
|
@@ -100,3 +112,110 @@ describe("constants — Stripe-event-types die wir mappen", () => {
|
|
|
100
112
|
]);
|
|
101
113
|
});
|
|
102
114
|
});
|
|
115
|
+
|
|
116
|
+
// =============================================================================
|
|
117
|
+
// SubscriptionProviderPlugin contract
|
|
118
|
+
// =============================================================================
|
|
119
|
+
//
|
|
120
|
+
// Built directly from the plugin-method constructors (not from
|
|
121
|
+
// createSubscriptionStripeFeature's mounted plugin) — the mounted plugin
|
|
122
|
+
// resolves its Stripe client per-call from ctx.config/ctx.secrets, which
|
|
123
|
+
// can't be spied on from outside. plugin-methods.test.ts already exercises
|
|
124
|
+
// this same runtime-injection pattern for the individual methods.
|
|
125
|
+
|
|
126
|
+
const CONTRACT_API_KEY = "sk_test_contract_dummy";
|
|
127
|
+
const CONTRACT_WEBHOOK_SECRET = "whsec_contract_test_secret";
|
|
128
|
+
|
|
129
|
+
function contractCtxRuntime(stripe: Stripe): StripeCtxRuntime {
|
|
130
|
+
return { clientForCtx: async () => stripe, assertBillingLive: async () => {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function buildStripeContractFixture() {
|
|
134
|
+
const stripe = new Stripe(CONTRACT_API_KEY);
|
|
135
|
+
spyOn(stripe.checkout.sessions, "create").mockResolvedValue({
|
|
136
|
+
url: "https://checkout.stripe.com/c/pay/contract-test",
|
|
137
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
138
|
+
} as any);
|
|
139
|
+
spyOn(stripe.billingPortal.sessions, "create").mockResolvedValue({
|
|
140
|
+
url: "https://billing.stripe.com/p/session/contract-test",
|
|
141
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
142
|
+
} as any);
|
|
143
|
+
spyOn(stripe.subscriptions, "cancel").mockResolvedValue({
|
|
144
|
+
id: "sub_contract",
|
|
145
|
+
status: "canceled",
|
|
146
|
+
// biome-ignore lint/suspicious/noExplicitAny: Stripe-SDK-typed mock-return
|
|
147
|
+
} as any);
|
|
148
|
+
const runtime = contractCtxRuntime(stripe);
|
|
149
|
+
|
|
150
|
+
const stripeForWebhookFixture = new Stripe(CONTRACT_API_KEY);
|
|
151
|
+
const webhookEvent = {
|
|
152
|
+
id: "evt_contract_001",
|
|
153
|
+
object: "event",
|
|
154
|
+
api_version: "2026-04-22.dahlia",
|
|
155
|
+
created: 1_770_000_000,
|
|
156
|
+
type: StripeEventTypes.customerSubscriptionCreated,
|
|
157
|
+
livemode: false,
|
|
158
|
+
pending_webhooks: 1,
|
|
159
|
+
request: { id: null, idempotency_key: null },
|
|
160
|
+
data: {
|
|
161
|
+
object: {
|
|
162
|
+
id: "sub_contract_001",
|
|
163
|
+
object: "subscription",
|
|
164
|
+
customer: "cus_contract_001",
|
|
165
|
+
status: "active",
|
|
166
|
+
metadata: { tenantId: "tenant-contract" },
|
|
167
|
+
items: {
|
|
168
|
+
object: "list",
|
|
169
|
+
data: [
|
|
170
|
+
{
|
|
171
|
+
id: "si_contract",
|
|
172
|
+
object: "subscription_item",
|
|
173
|
+
current_period_end: 1_780_000_000,
|
|
174
|
+
price: { id: "price_test", object: "price" },
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
has_more: false,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
const rawBody = JSON.stringify(webhookEvent);
|
|
183
|
+
const signature = await stripeForWebhookFixture.webhooks.generateTestHeaderStringAsync({
|
|
184
|
+
payload: rawBody,
|
|
185
|
+
secret: CONTRACT_WEBHOOK_SECRET,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
plugin: {
|
|
190
|
+
verifyAndParseWebhook: verifyAndParseStripeWebhook(
|
|
191
|
+
{
|
|
192
|
+
resolve: async () => ({
|
|
193
|
+
stripe: stripeForWebhookFixture,
|
|
194
|
+
webhookSecret: CONTRACT_WEBHOOK_SECRET,
|
|
195
|
+
}),
|
|
196
|
+
},
|
|
197
|
+
{ priceToTier: OPTIONS.priceToTier },
|
|
198
|
+
),
|
|
199
|
+
createCheckoutSession: createStripeCheckoutSession(runtime),
|
|
200
|
+
createPortalSession: createStripePortalSession(runtime),
|
|
201
|
+
cancelSubscription: createStripeCancelSubscription(runtime),
|
|
202
|
+
},
|
|
203
|
+
ctx: {} as HandlerContext,
|
|
204
|
+
checkout: {
|
|
205
|
+
priceId: "price_test",
|
|
206
|
+
tenantId: "tenant-contract",
|
|
207
|
+
successUrl: "https://example.com/success",
|
|
208
|
+
cancelUrl: "https://example.com/cancel",
|
|
209
|
+
},
|
|
210
|
+
portal: { providerCustomerId: "cus_contract", returnUrl: "https://example.com/return" },
|
|
211
|
+
cancelSubscriptionId: "sub_contract",
|
|
212
|
+
webhook: {
|
|
213
|
+
rawBody,
|
|
214
|
+
headers: { "stripe-signature": signature },
|
|
215
|
+
expectedTenantId: "tenant-contract",
|
|
216
|
+
expectedTier: "pro",
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
describeSubscriptionProviderContract("subscription-stripe", buildStripeContractFixture);
|
|
@@ -5,31 +5,65 @@ import {
|
|
|
5
5
|
PrimitivesProvider,
|
|
6
6
|
} from "@cosmicdrift/kumiko-renderer";
|
|
7
7
|
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
-
import { fireEvent, render, screen } from "@testing-library/react";
|
|
8
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
9
9
|
import type { ReactNode } from "react";
|
|
10
|
+
import { TagsHandlers, TagsQueries } from "../../constants";
|
|
10
11
|
import { defaultTranslations } from "../i18n";
|
|
11
12
|
import { TagManager } from "../tag-manager";
|
|
12
13
|
|
|
13
14
|
type TagRow = { id: string; name: string; color?: string; scope?: string; version: number };
|
|
15
|
+
type AssignmentRow = { tagId: string };
|
|
14
16
|
|
|
15
17
|
let catalogRows: readonly TagRow[] = [];
|
|
18
|
+
let assignmentRows: readonly AssignmentRow[] = [];
|
|
19
|
+
let catalogLoading = false;
|
|
20
|
+
let catalogData: { rows: readonly TagRow[] } | null = { rows: catalogRows };
|
|
21
|
+
let catalogError: { i18nKey: string; i18nParams?: Record<string, unknown> } | null = null;
|
|
22
|
+
let assignmentError: { i18nKey: string; i18nParams?: Record<string, unknown> } | null = null;
|
|
16
23
|
|
|
17
24
|
beforeEach(() => {
|
|
18
25
|
catalogRows = [{ id: "t1", name: "urgent", color: "#ef4444", version: 1 }];
|
|
26
|
+
assignmentRows = [{ tagId: "t1" }, { tagId: "t1" }];
|
|
27
|
+
catalogLoading = false;
|
|
28
|
+
catalogData = { rows: catalogRows };
|
|
29
|
+
catalogError = null;
|
|
30
|
+
assignmentError = null;
|
|
31
|
+
dispatchSpy.mockClear();
|
|
32
|
+
catalogRefetch.mockClear();
|
|
33
|
+
assignmentsRefetch.mockClear();
|
|
19
34
|
});
|
|
20
35
|
|
|
21
|
-
const dispatchSpy = mock(async () =>
|
|
36
|
+
const dispatchSpy = mock(async (type: string) =>
|
|
37
|
+
type === TagsHandlers.createTag
|
|
38
|
+
? { isSuccess: true, data: { id: "tag-new" } }
|
|
39
|
+
: { isSuccess: true, data: undefined },
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const catalogRefetch = mock(async () => {});
|
|
43
|
+
const assignmentsRefetch = mock(async () => {});
|
|
44
|
+
|
|
45
|
+
const useQuerySpy = mock((type: string) => {
|
|
46
|
+
if (type === TagsQueries.tagList) {
|
|
47
|
+
return {
|
|
48
|
+
data: catalogData,
|
|
49
|
+
loading: catalogLoading,
|
|
50
|
+
error: catalogError,
|
|
51
|
+
refetch: catalogRefetch,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
data: { rows: assignmentRows },
|
|
56
|
+
loading: false,
|
|
57
|
+
error: assignmentError,
|
|
58
|
+
refetch: assignmentsRefetch,
|
|
59
|
+
};
|
|
60
|
+
});
|
|
22
61
|
|
|
23
62
|
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
24
63
|
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
25
64
|
...actual_renderer,
|
|
26
65
|
useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
|
|
27
|
-
useQuery:
|
|
28
|
-
data: { rows: catalogRows },
|
|
29
|
-
loading: false,
|
|
30
|
-
error: null,
|
|
31
|
-
refetch: mock(),
|
|
32
|
-
})),
|
|
66
|
+
useQuery: useQuerySpy,
|
|
33
67
|
}));
|
|
34
68
|
|
|
35
69
|
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
@@ -40,6 +74,174 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
|
40
74
|
);
|
|
41
75
|
}
|
|
42
76
|
|
|
77
|
+
describe("TagManager — loading and errors", () => {
|
|
78
|
+
test("shows loading banner while catalog is loading with no data yet", () => {
|
|
79
|
+
catalogLoading = true;
|
|
80
|
+
catalogData = null;
|
|
81
|
+
render(
|
|
82
|
+
<Wrapper>
|
|
83
|
+
<TagManager />
|
|
84
|
+
</Wrapper>,
|
|
85
|
+
);
|
|
86
|
+
expect(screen.getByTestId("tag-manager-loading")).toBeTruthy();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("shows error banner when catalog query fails", () => {
|
|
90
|
+
catalogError = { i18nKey: "tags.section.empty" };
|
|
91
|
+
render(
|
|
92
|
+
<Wrapper>
|
|
93
|
+
<TagManager />
|
|
94
|
+
</Wrapper>,
|
|
95
|
+
);
|
|
96
|
+
expect(screen.getByTestId("tag-manager-error")).toBeTruthy();
|
|
97
|
+
expect(screen.getByText("No tags found.")).toBeTruthy();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("shows empty banner when catalog has no rows", () => {
|
|
101
|
+
catalogRows = [];
|
|
102
|
+
catalogData = { rows: catalogRows };
|
|
103
|
+
render(
|
|
104
|
+
<Wrapper>
|
|
105
|
+
<TagManager />
|
|
106
|
+
</Wrapper>,
|
|
107
|
+
);
|
|
108
|
+
expect(screen.getByTestId("tag-manager-empty")).toBeTruthy();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("TagManager — catalog CRUD", () => {
|
|
113
|
+
test("renders tag row with scope label and usage count", () => {
|
|
114
|
+
catalogRows = [{ id: "t2", name: "note-tag", color: "#22c55e", scope: "note", version: 1 }];
|
|
115
|
+
catalogData = { rows: catalogRows };
|
|
116
|
+
assignmentRows = [{ tagId: "t2" }];
|
|
117
|
+
render(
|
|
118
|
+
<Wrapper>
|
|
119
|
+
<TagManager />
|
|
120
|
+
</Wrapper>,
|
|
121
|
+
);
|
|
122
|
+
expect(screen.getByTestId("tag-manager-row-t2")).toBeTruthy();
|
|
123
|
+
expect(screen.getByText("@note")).toBeTruthy();
|
|
124
|
+
expect(screen.getByText("1×")).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("create dispatches write with trimmed name/color/scope and refetches", async () => {
|
|
128
|
+
render(
|
|
129
|
+
<Wrapper>
|
|
130
|
+
<TagManager />
|
|
131
|
+
</Wrapper>,
|
|
132
|
+
);
|
|
133
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
134
|
+
target: { value: " new-tag " },
|
|
135
|
+
});
|
|
136
|
+
fireEvent.click(screen.getByTestId("tag-manager-new-color-swatch-#22c55e"));
|
|
137
|
+
fireEvent.change(document.getElementById("tag-manager-new-scope") as HTMLInputElement, {
|
|
138
|
+
target: { value: " note " },
|
|
139
|
+
});
|
|
140
|
+
fireEvent.click(screen.getByTestId("tag-manager-create"));
|
|
141
|
+
|
|
142
|
+
await waitFor(() =>
|
|
143
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.createTag, {
|
|
144
|
+
name: "new-tag",
|
|
145
|
+
color: "#22c55e",
|
|
146
|
+
scope: "note",
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
await waitFor(() => expect(catalogRefetch).toHaveBeenCalled());
|
|
150
|
+
await waitFor(() => expect(assignmentsRefetch).toHaveBeenCalled());
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("create button stays disabled for empty trimmed name", () => {
|
|
154
|
+
render(
|
|
155
|
+
<Wrapper>
|
|
156
|
+
<TagManager />
|
|
157
|
+
</Wrapper>,
|
|
158
|
+
);
|
|
159
|
+
const create = screen.getByTestId("tag-manager-create") as HTMLButtonElement;
|
|
160
|
+
expect(create.disabled).toBe(true);
|
|
161
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
162
|
+
target: { value: " " },
|
|
163
|
+
});
|
|
164
|
+
expect(create.disabled).toBe(true);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("save edit dispatches update and closes edit form", async () => {
|
|
168
|
+
render(
|
|
169
|
+
<Wrapper>
|
|
170
|
+
<TagManager />
|
|
171
|
+
</Wrapper>,
|
|
172
|
+
);
|
|
173
|
+
fireEvent.click(screen.getByTestId("tag-manager-edit-btn-t1"));
|
|
174
|
+
expect(screen.getByTestId("tag-manager-edit-t1")).toBeTruthy();
|
|
175
|
+
|
|
176
|
+
fireEvent.change(document.getElementById("tag-edit-name-t1") as HTMLInputElement, {
|
|
177
|
+
target: { value: "renamed" },
|
|
178
|
+
});
|
|
179
|
+
fireEvent.click(screen.getByTestId("tag-edit-color-t1-swatch-#3b82f6"));
|
|
180
|
+
fireEvent.click(screen.getByTestId("tag-manager-save-t1"));
|
|
181
|
+
|
|
182
|
+
await waitFor(() =>
|
|
183
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.updateTag, {
|
|
184
|
+
id: "t1",
|
|
185
|
+
version: 1,
|
|
186
|
+
name: "renamed",
|
|
187
|
+
color: "#3b82f6",
|
|
188
|
+
scope: "",
|
|
189
|
+
}),
|
|
190
|
+
);
|
|
191
|
+
await waitFor(() => expect(screen.queryByTestId("tag-manager-edit-t1")).toBeNull());
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("cancel edit closes edit form without write", () => {
|
|
195
|
+
render(
|
|
196
|
+
<Wrapper>
|
|
197
|
+
<TagManager />
|
|
198
|
+
</Wrapper>,
|
|
199
|
+
);
|
|
200
|
+
fireEvent.click(screen.getByTestId("tag-manager-edit-btn-t1"));
|
|
201
|
+
fireEvent.click(screen.getByText("Cancel"));
|
|
202
|
+
expect(screen.queryByTestId("tag-manager-edit-t1")).toBeNull();
|
|
203
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("delete confirm dispatches deleteTag and refetches", async () => {
|
|
207
|
+
render(
|
|
208
|
+
<Wrapper>
|
|
209
|
+
<TagManager />
|
|
210
|
+
</Wrapper>,
|
|
211
|
+
);
|
|
212
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-btn-t1"));
|
|
213
|
+
expect(screen.getByTestId("tag-manager-delete-dialog")).toBeTruthy();
|
|
214
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-dialog-confirm"));
|
|
215
|
+
|
|
216
|
+
await waitFor(() =>
|
|
217
|
+
expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.deleteTag, { id: "t1" }),
|
|
218
|
+
);
|
|
219
|
+
await waitFor(() => expect(catalogRefetch).toHaveBeenCalled());
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("failed write shows action error banner", async () => {
|
|
223
|
+
dispatchSpy.mockImplementationOnce(
|
|
224
|
+
async () =>
|
|
225
|
+
({ isSuccess: false, error: { i18nKey: "tags.section.empty" } }) as {
|
|
226
|
+
isSuccess: boolean;
|
|
227
|
+
data?: { id: string } | undefined;
|
|
228
|
+
error?: { i18nKey: string };
|
|
229
|
+
},
|
|
230
|
+
);
|
|
231
|
+
render(
|
|
232
|
+
<Wrapper>
|
|
233
|
+
<TagManager />
|
|
234
|
+
</Wrapper>,
|
|
235
|
+
);
|
|
236
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
237
|
+
target: { value: "fail-tag" },
|
|
238
|
+
});
|
|
239
|
+
fireEvent.click(screen.getByTestId("tag-manager-create"));
|
|
240
|
+
|
|
241
|
+
await waitFor(() => expect(screen.getByTestId("tag-manager-action-error")).toBeTruthy());
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
43
245
|
// 695/4: saveEdit silently no-ops on an empty trimmed name — the Save button
|
|
44
246
|
// must reflect that in its disabled state, not just in the click handler.
|
|
45
247
|
describe("TagManager — edit Save button", () => {
|
|
@@ -62,3 +264,70 @@ describe("TagManager — edit Save button", () => {
|
|
|
62
264
|
expect(save.disabled).toBe(false);
|
|
63
265
|
});
|
|
64
266
|
});
|
|
267
|
+
|
|
268
|
+
describe("TagManager — selection mode", () => {
|
|
269
|
+
test("filters tags by entityType scope and toggles selection", () => {
|
|
270
|
+
catalogRows = [
|
|
271
|
+
{ id: "t1", name: "global", version: 1 },
|
|
272
|
+
{ id: "t2", name: "note-only", scope: "note", version: 1 },
|
|
273
|
+
{ id: "t3", name: "other", scope: "task", version: 1 },
|
|
274
|
+
];
|
|
275
|
+
catalogData = { rows: catalogRows };
|
|
276
|
+
const onChange = mock((next: readonly string[]) => next);
|
|
277
|
+
let selected: readonly string[] = ["t1"];
|
|
278
|
+
|
|
279
|
+
render(
|
|
280
|
+
<Wrapper>
|
|
281
|
+
<TagManager
|
|
282
|
+
entityType="note"
|
|
283
|
+
selection={{
|
|
284
|
+
value: selected,
|
|
285
|
+
onChange: (next) => {
|
|
286
|
+
selected = next;
|
|
287
|
+
onChange(next);
|
|
288
|
+
},
|
|
289
|
+
}}
|
|
290
|
+
/>
|
|
291
|
+
</Wrapper>,
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
expect(screen.getByTestId("tag-manager-row-t1")).toBeTruthy();
|
|
295
|
+
expect(screen.getByTestId("tag-manager-row-t2")).toBeTruthy();
|
|
296
|
+
expect(screen.queryByTestId("tag-manager-row-t3")).toBeNull();
|
|
297
|
+
expect(screen.queryByTestId("tag-manager-new-scope")).toBeNull();
|
|
298
|
+
|
|
299
|
+
fireEvent.click(screen.getByTestId("tag-manager-toggle-t2"));
|
|
300
|
+
expect(onChange).toHaveBeenCalledWith(["t1", "t2"]);
|
|
301
|
+
|
|
302
|
+
fireEvent.click(screen.getByTestId("tag-manager-toggle-t1"));
|
|
303
|
+
expect(onChange).toHaveBeenCalledWith([]);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("create in selection mode adds new tag id to selection", async () => {
|
|
307
|
+
const onChange = mock((next: readonly string[]) => next);
|
|
308
|
+
render(
|
|
309
|
+
<Wrapper>
|
|
310
|
+
<TagManager entityType="note" selection={{ value: [], onChange }} />
|
|
311
|
+
</Wrapper>,
|
|
312
|
+
);
|
|
313
|
+
fireEvent.change(document.getElementById("tag-manager-new-name") as HTMLInputElement, {
|
|
314
|
+
target: { value: "picked" },
|
|
315
|
+
});
|
|
316
|
+
fireEvent.click(screen.getByTestId("tag-manager-create"));
|
|
317
|
+
|
|
318
|
+
await waitFor(() => expect(onChange).toHaveBeenCalledWith(["tag-new"]));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("delete removes tag from selection when it was selected", async () => {
|
|
322
|
+
const onChange = mock((next: readonly string[]) => next);
|
|
323
|
+
render(
|
|
324
|
+
<Wrapper>
|
|
325
|
+
<TagManager selection={{ value: ["t1"], onChange }} />
|
|
326
|
+
</Wrapper>,
|
|
327
|
+
);
|
|
328
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-btn-t1"));
|
|
329
|
+
fireEvent.click(screen.getByTestId("tag-manager-delete-dialog-confirm"));
|
|
330
|
+
|
|
331
|
+
await waitFor(() => expect(onChange).toHaveBeenCalledWith([]));
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// members.query.ts's decrypt-on-read fix (#1252): before the fix, an
|
|
2
|
+
// active PII-subject KMS meant the query returned raw ciphertext for
|
|
3
|
+
// email/displayName instead of decrypting them like every other PII read
|
|
4
|
+
// path. This pins the decrypt-on-read roundtrip with a real KMS adapter
|
|
5
|
+
// configured, so a later refactor can't silently re-introduce the
|
|
6
|
+
// ciphertext leak.
|
|
7
|
+
|
|
8
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configurePiiSubjectKms,
|
|
12
|
+
InMemoryKmsAdapter,
|
|
13
|
+
isPiiCiphertext,
|
|
14
|
+
resetPiiSubjectKmsForTests,
|
|
15
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
16
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
|
+
import {
|
|
18
|
+
setupTestStack,
|
|
19
|
+
type TestStack,
|
|
20
|
+
testTenantId,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
unsafePushTables,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
25
|
+
import { createConfigFeature } from "../../config";
|
|
26
|
+
import { configValuesTable } from "../../config/table";
|
|
27
|
+
import { createUserFeature } from "../../user/feature";
|
|
28
|
+
import { userEntity, userTable } from "../../user/schema/user";
|
|
29
|
+
import { seedUser } from "../../user/seeding";
|
|
30
|
+
import { TenantQueries } from "../constants";
|
|
31
|
+
import { createTenantFeature } from "../feature";
|
|
32
|
+
import { tenantMembershipsTable } from "../membership-table";
|
|
33
|
+
import { tenantEntity } from "../schema/tenant";
|
|
34
|
+
import { seedTenant, seedTenantMembership } from "../seeding";
|
|
35
|
+
|
|
36
|
+
const tenantId = testTenantId(1) as TenantId;
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
function tenantAdmin(userId: string): SessionUser {
|
|
41
|
+
return { id: userId, tenantId, roles: ["TenantAdmin"] };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
stack = await setupTestStack({
|
|
46
|
+
features: [createConfigFeature(), createUserFeature(), createTenantFeature()],
|
|
47
|
+
});
|
|
48
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
49
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
50
|
+
await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
await resetTestTables(stack.db, [userTable, tenantMembershipsTable]);
|
|
55
|
+
await seedTenant(stack.db, { id: tenantId, key: "tenant-kms-members", name: "KMS Tenant" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
resetPiiSubjectKmsForTests();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(async () => {
|
|
63
|
+
await stack.cleanup();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("members query — decrypt-on-read with an active KMS (#1252)", () => {
|
|
67
|
+
test("email and displayName come back as plaintext, not ciphertext", async () => {
|
|
68
|
+
// KMS active BEFORE the seed — seedUser runs through the executor, so
|
|
69
|
+
// the row reflects the encrypted prod state.
|
|
70
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
71
|
+
const { id: userId } = await seedUser(stack.db, {
|
|
72
|
+
email: "kms-member@example.com",
|
|
73
|
+
displayName: "KMS Member",
|
|
74
|
+
emailVerified: true,
|
|
75
|
+
});
|
|
76
|
+
await seedTenantMembership(stack.db, { userId, tenantId, roles: ["User"] });
|
|
77
|
+
|
|
78
|
+
// Prove the row is actually ciphertext at rest — otherwise a no-op
|
|
79
|
+
// encryption path would make this test pass while proving nothing.
|
|
80
|
+
const rawRows = await selectMany<{ email: unknown; displayName: unknown }>(
|
|
81
|
+
stack.db,
|
|
82
|
+
userTable,
|
|
83
|
+
{
|
|
84
|
+
id: userId,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
expect(isPiiCiphertext(rawRows[0]?.email)).toBe(true);
|
|
88
|
+
expect(isPiiCiphertext(rawRows[0]?.displayName)).toBe(true);
|
|
89
|
+
|
|
90
|
+
const members = await stack.http.queryOk<
|
|
91
|
+
readonly { userId: string; email: string | null; displayName: string | null }[]
|
|
92
|
+
>(TenantQueries.members, {}, tenantAdmin(userId));
|
|
93
|
+
|
|
94
|
+
const self = members.find((m) => m.userId === userId);
|
|
95
|
+
expect(self?.email).toBe("kms-member@example.com");
|
|
96
|
+
expect(self?.displayName).toBe("KMS Member");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -31,10 +31,12 @@ export const invitationsQuery = defineQueryHandler({
|
|
|
31
31
|
const email = row["email"];
|
|
32
32
|
const invitedBy = row["invitedBy"];
|
|
33
33
|
const decryptedEmail =
|
|
34
|
-
typeof email === "string"
|
|
34
|
+
typeof email === "string"
|
|
35
|
+
? await decryptStoredPii(email, "email", "tenant:invitations")
|
|
36
|
+
: email;
|
|
35
37
|
const decryptedInvitedBy =
|
|
36
38
|
typeof invitedBy === "string"
|
|
37
|
-
? await decryptStoredPii(invitedBy, "tenant:invitations")
|
|
39
|
+
? await decryptStoredPii(invitedBy, "invitedBy", "tenant:invitations")
|
|
38
40
|
: invitedBy;
|
|
39
41
|
out.push({ ...row, email: decryptedEmail, invitedBy: decryptedInvitedBy });
|
|
40
42
|
}
|
|
@@ -33,11 +33,11 @@ export const membersQuery = defineQueryHandler({
|
|
|
33
33
|
for (const user of users) {
|
|
34
34
|
const email =
|
|
35
35
|
typeof user.email === "string"
|
|
36
|
-
? await decryptStoredPii(user.email, "tenant:members")
|
|
36
|
+
? await decryptStoredPii(user.email, "email", "tenant:members")
|
|
37
37
|
: null;
|
|
38
38
|
const displayName =
|
|
39
39
|
typeof user.displayName === "string"
|
|
40
|
-
? await decryptStoredPii(user.displayName, "tenant:members")
|
|
40
|
+
? await decryptStoredPii(user.displayName, "displayName", "tenant:members")
|
|
41
41
|
: null;
|
|
42
42
|
decryptedByUserId.set(String(user.id), { email, displayName });
|
|
43
43
|
}
|
package/src/tenant/seeding.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
type DbRunner,
|
|
36
36
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
37
37
|
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
38
|
-
import {
|
|
38
|
+
import { getUnscopedAggregateStreamMaxVersion } from "@cosmicdrift/kumiko-framework/event-store";
|
|
39
39
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
40
40
|
import { assertAssignableMembershipRoles } from "./membership-roles";
|
|
41
41
|
import { tenantMembershipEntity, tenantMembershipsTable } from "./membership-table";
|
|
@@ -99,7 +99,7 @@ export async function seedTenant(
|
|
|
99
99
|
// (Projection-Drift nach rebuild, manuellem DELETE, oder async-lag). Wenn
|
|
100
100
|
// Stream-Version > 0 → kein create() — wäre version_conflict. Caller
|
|
101
101
|
// bekommt die ID, Projection wird beim nächsten Dispatcher-Cycle aufgebaut.
|
|
102
|
-
const streamVersion = await
|
|
102
|
+
const streamVersion = await getUnscopedAggregateStreamMaxVersion(db, options.id);
|
|
103
103
|
if (streamVersion > 0) return { id: options.id };
|
|
104
104
|
|
|
105
105
|
const result = await tenantExecutor.create(
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
|
+
import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
|
|
3
4
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
4
5
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
5
6
|
import {
|
|
@@ -76,6 +77,7 @@ beforeAll(async () => {
|
|
|
76
77
|
createUserFeature(),
|
|
77
78
|
createTenantFeature(),
|
|
78
79
|
createComplianceProfilesFeature(),
|
|
80
|
+
authFoundationFeature,
|
|
79
81
|
createSessionsFeature(),
|
|
80
82
|
createTenantLifecycleFeature(),
|
|
81
83
|
],
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type EntityDefinition,
|
|
3
|
+
EXT_TENANT_DATA,
|
|
4
|
+
type FeatureDefinition,
|
|
5
|
+
type PiiAnnotations,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// r.entity(...) is not the only way a feature exposes an entity shape:
|
|
9
|
+
// r.projection(...) can carry an optional `entity` too (raw read-models with
|
|
10
|
+
// no executor, e.g. billing-foundation's subscription table). Need every
|
|
11
|
+
// entity a feature declares, not just the r.entity ones, or a tenantOwned
|
|
12
|
+
// field on a projection-only entity is invisible to the guard below.
|
|
13
|
+
function entitiesOf(
|
|
14
|
+
feature: FeatureDefinition,
|
|
15
|
+
): ReadonlyArray<readonly [string, EntityDefinition]> {
|
|
16
|
+
const fromEntities = Object.entries(feature.entities ?? {});
|
|
17
|
+
const fromProjections = Object.values(feature.projections ?? {})
|
|
18
|
+
.filter((p): p is typeof p & { entity: EntityDefinition } => p.entity !== undefined)
|
|
19
|
+
.map((p) => [p.name, p.entity] as const);
|
|
20
|
+
return [...fromEntities, ...fromProjections];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// V4: tenantOwned-entity-without-hook gate. Mirrors user-data-rights' V3
|
|
24
|
+
// (validateGdprPiiHookCoverage) but for EXT_TENANT_DATA. Registered as this
|
|
25
|
+
// feature's own `r.bootCheck()` (#1314, moved off the framework-internal
|
|
26
|
+
// boot-validator) — tenant-lifecycle owns EXT_TENANT_DATA
|
|
27
|
+
// (r.extendsRegistrar), so its own mount is the trigger, matching the
|
|
28
|
+
// original guard's "tenant-lifecycle mounted" gate exactly (unlike gating on
|
|
29
|
+
// a sibling feature, which would silently narrow coverage).
|
|
30
|
+
export function validateTenantDataHookCoverage(features: readonly FeatureDefinition[]): void {
|
|
31
|
+
const hookedEntities = new Set<string>();
|
|
32
|
+
for (const f of features) {
|
|
33
|
+
for (const usage of f.extensionUsages) {
|
|
34
|
+
if (usage.extensionName === EXT_TENANT_DATA) hookedEntities.add(usage.entityName);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const feature of features) {
|
|
39
|
+
for (const [entityName, entity] of entitiesOf(feature)) {
|
|
40
|
+
if (hookedEntities.has(entityName)) continue;
|
|
41
|
+
const tenantSubjectFields = Object.entries(entity.fields)
|
|
42
|
+
.filter(([, field]) => {
|
|
43
|
+
const annot = field as PiiAnnotations;
|
|
44
|
+
return Boolean(annot.tenantOwned);
|
|
45
|
+
})
|
|
46
|
+
.map(([name]) => name);
|
|
47
|
+
if (tenantSubjectFields.length === 0) continue;
|
|
48
|
+
throw new Error(
|
|
49
|
+
`[kumiko:boot] Entity "${entityName}" (feature "${feature.name}") has tenant-subject fields (${tenantSubjectFields.join(", ")}) but no feature registers an EXT_TENANT_DATA destroy hook for it — tenant destroy never erases this data. Register r.useExtension(EXT_TENANT_DATA, "${entityName}", { destroy }) or a documented no-op if crypto-shredding covers it.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|