@cosmicdrift/kumiko-bundled-features 0.165.0 → 2.0.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 +10 -6
- package/src/__tests__/access-invalidation-consumer.integration.test.ts +279 -0
- package/src/__tests__/access-invalidation-stream.integration.test.ts +217 -0
- package/src/audit/__tests__/audit.integration.test.ts +50 -21
- package/src/audit/handlers/list.query.ts +1 -0
- package/src/auth-email-password/__tests__/auth.integration.test.ts +6 -0
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +34 -0
- package/src/auth-email-password/__tests__/self-registration-toggle.integration.test.ts +9 -14
- package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +16 -5
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +54 -0
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -1
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +2 -1
- package/src/auth-email-password/handlers/login.write.ts +8 -0
- package/src/auth-email-password/handlers/self-registration-status.query.ts +6 -3
- package/src/auth-email-password/handlers/signup-request.write.ts +12 -1
- package/src/auth-email-password/i18n.ts +2 -0
- package/src/auth-email-password/seeding.ts +41 -23
- package/src/auth-email-password/web/__tests__/auth-client.test.ts +13 -0
- package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +55 -8
- package/src/auth-email-password/web/__tests__/client-plugin.test.tsx +34 -0
- package/src/auth-email-password/web/__tests__/confirm-account-unlock-screen.test.tsx +8 -1
- package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/invite-accept-screen.test.tsx +3 -0
- package/src/auth-email-password/web/__tests__/request-account-unlock-screen.test.tsx +8 -2
- package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +5 -1
- package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +5 -1
- package/src/auth-email-password/web/auth-client.ts +32 -10
- package/src/auth-email-password/web/auth-gate.tsx +17 -21
- package/src/auth-email-password/web/client-plugin.ts +15 -2
- package/src/auth-email-password/web/index.ts +5 -1
- package/src/auth-email-password/web/login-screen.tsx +7 -3
- package/src/auth-email-password/web/request-account-unlock-screen.tsx +1 -1
- package/src/auth-foundation/__tests__/session-store.test.ts +2 -4
- package/src/auth-foundation/__tests__/tenant-providers.test.ts +24 -0
- package/src/auth-foundation/__tests__/token-verifier.test.ts +36 -3
- package/src/auth-foundation/boot-checks.ts +74 -69
- package/src/auth-foundation/resolve-anonymous-access.ts +13 -5
- package/src/auth-foundation/types.ts +3 -3
- package/src/auth-mfa/__tests__/enable-confirm-preauth.integration.test.ts +61 -1
- package/src/auth-mfa/__tests__/no-redis.integration.test.ts +209 -0
- package/src/auth-mfa/__tests__/reencrypt-job.integration.test.ts +79 -1
- package/src/auth-mfa/handlers/disable.write.ts +16 -1
- package/src/auth-mfa/handlers/enable-confirm-preauth.write.ts +72 -42
- package/src/auth-mfa/handlers/enable-confirm.write.ts +23 -12
- package/src/auth-mfa/handlers/reencrypt.job.ts +26 -17
- package/src/auth-mfa/handlers/regenerate-recovery.write.ts +15 -1
- package/src/auth-mfa/handlers/verify.write.ts +28 -24
- package/src/auth-mfa/web/__tests__/mfa-client.test.ts +5 -1
- package/src/auth-mfa/web/__tests__/mfa-enable-screen.test.tsx +10 -1
- package/src/auth-mfa/web/__tests__/mfa-setup-preauth-screen.test.tsx +104 -1
- package/src/auth-mfa/web/__tests__/mfa-verify-screen.test.tsx +5 -1
- package/src/auth-mfa/web/i18n.ts +4 -0
- package/src/auth-mfa/web/mfa-client.ts +13 -2
- package/src/auth-mfa/web/mfa-enable-screen.tsx +1 -1
- package/src/auth-mfa/web/mfa-setup-preauth-screen.tsx +25 -2
- package/src/billing-foundation/__tests__/subscription-provider-contract.ts +36 -12
- package/src/config/__tests__/backing-secrets.integration.test.ts +4 -0
- package/src/config/__tests__/cascade.integration.test.ts +13 -2
- package/src/config/__tests__/pii-encrypted.integration.test.ts +6 -6
- package/src/config/__tests__/reencrypt-job-kek-rotation.integration.test.ts +78 -5
- package/src/config/db/queries/resolver.ts +3 -3
- package/src/config/handlers/reencrypt.job.ts +36 -24
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +14 -0
- package/src/document-ingest-foundation/__tests__/feature.test.ts +38 -0
- package/src/document-ingest-foundation/__tests__/pages.test.ts +37 -0
- package/src/document-ingest-foundation/entity.ts +10 -3
- package/src/document-ingest-foundation/feature.ts +26 -8
- package/src/document-ingest-foundation/index.ts +1 -0
- package/src/document-ingest-foundation/pages.ts +34 -0
- package/src/feature-toggles/__tests__/compose-tier-resolver.test.ts +114 -11
- package/src/feature-toggles/compose-tier-resolver.ts +77 -8
- package/src/feature-toggles/global-feature-state-table.ts +15 -12
- package/src/files-provider-s3/s3-provider.ts +12 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +78 -1
- package/src/inbound-mail-foundation/__tests__/inbound-mail-provider-contract.ts +58 -40
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +9 -1
- package/src/inbound-mail-foundation/handlers/ingest-message.write.ts +22 -12
- package/src/inbound-provider-imap/__tests__/imap-live.integration.test.ts +1 -1
- package/src/inbound-provider-imap/__tests__/plugin-mocked.test.ts +35 -8
- package/src/inbound-provider-inmemory/__tests__/feature.test.ts +37 -33
- package/src/jobs/__tests__/job-instant.test.ts +15 -0
- package/src/jobs/__tests__/reindex-entity-job.integration.test.ts +19 -9
- package/src/jobs/feature.ts +6 -5
- package/src/jobs/job-instant.ts +9 -0
- package/src/mail-foundation/__tests__/mail-transport-contract.ts +27 -11
- package/src/mail-transport-inmemory/__tests__/feature.test.ts +10 -6
- package/src/mail-transport-smtp/__tests__/feature.test.ts +10 -5
- package/src/page-render/__tests__/markdown.test.ts +29 -0
- package/src/page-render/markdown.ts +1 -9
- package/src/personal-access-tokens/feature.ts +1 -1
- package/src/sessions/__tests__/rebuild-survival.integration.test.ts +3 -1
- package/src/sessions/__tests__/sessions-screens.boot.test.ts +5 -10
- package/src/sessions/__tests__/sessions.integration.test.ts +72 -1
- package/src/sessions/feature.ts +12 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +51 -12
- package/src/sessions/handlers/revoke-all-others.write.ts +19 -1
- package/src/sessions/handlers/revoke.write.ts +13 -0
- package/src/sessions/index.ts +7 -0
- package/src/sessions/session-callbacks.ts +58 -5
- package/src/sessions/session-revoked-event.ts +29 -0
- package/src/shared/__tests__/chunked-entity-migration.test.ts +4 -4
- package/src/shared/chunked-entity-migration.ts +2 -2
- package/src/shared/grace-period.test.ts +26 -0
- package/src/shared/grace-period.ts +20 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/map-with-concurrency.test.ts +104 -0
- package/src/shared/map-with-concurrency.ts +38 -0
- package/src/subscription-mollie/__tests__/feature.test.ts +16 -6
- package/src/subscription-mollie/__tests__/verify-webhook.test.ts +21 -0
- package/src/subscription-mollie/verify-webhook.ts +1 -0
- package/src/subscription-stripe/__tests__/feature.test.ts +14 -7
- package/src/subscription-stripe/__tests__/verify-webhook.test.ts +20 -0
- package/src/subscription-stripe/verify-webhook.ts +3 -2
- package/src/tenant/__tests__/members-query-kms.integration.test.ts +2 -2
- package/src/tenant/__tests__/multi-tenant.integration.test.ts +1 -1
- package/src/tenant/handlers/active-tenant-ids.query.ts +8 -2
- package/src/tenant/handlers/invitations.query.ts +13 -15
- package/src/tenant/handlers/members.query.ts +12 -12
- package/src/tenant/seeding.ts +110 -16
- package/src/tenant-lifecycle/boot-checks.ts +11 -4
- package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +4 -6
- package/src/tenant-settings/__tests__/tenant-settings.integration.test.ts +115 -0
- package/src/tenant-settings/config.ts +44 -0
- package/src/tenant-settings/constants.ts +16 -0
- package/src/tenant-settings/feature.ts +49 -0
- package/src/tenant-settings/index.ts +8 -0
- package/src/tenant-settings/tenant-defaults.ts +67 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/seeding.ts +8 -0
- package/src/user-data-rights/COMPLIANCE.md +1 -1
- package/src/user-data-rights/__tests__/audit-log.integration.test.ts +65 -0
- package/src/user-data-rights/__tests__/boot-checks.test.ts +15 -17
- package/src/user-data-rights/__tests__/download-by-token-resolver-trust.integration.test.ts +9 -5
- package/src/user-data-rights/__tests__/download.integration.test.ts +12 -41
- package/src/user-data-rights/__tests__/inspector-screens.boot.test.ts +7 -10
- package/src/user-data-rights/__tests__/mail-default-bridge.integration.test.ts +15 -1
- package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +120 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.test.ts +9 -4
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +74 -8
- package/src/user-data-rights/db/queries/__tests__/forget-cleanup.integration.test.ts +69 -0
- package/src/user-data-rights/db/queries/forget-cleanup.ts +9 -2
- package/src/user-data-rights/feature.ts +45 -45
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +8 -11
- package/src/user-data-rights/handlers/download-by-job.query.ts +6 -6
- package/src/user-data-rights/handlers/download-by-token.query.ts +6 -6
- package/src/user-data-rights/handlers/lift-restriction.write.ts +33 -2
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +1 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +1 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +33 -4
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +2 -0
- package/src/user-data-rights/lib/is-admin-actor.ts +19 -0
- package/src/user-data-rights/run-forget-cleanup.ts +27 -7
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// Verifies the core promise: set a tenant's tenant-settings:config:currency
|
|
2
|
+
// to CHF, and a new entity without an explicit currency inherits CHF instead
|
|
3
|
+
// of a hard-coded EUR literal.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, expect, test } from "bun:test";
|
|
6
|
+
import {
|
|
7
|
+
configValuesTable,
|
|
8
|
+
createConfigAccessorFactory,
|
|
9
|
+
createConfigFeature,
|
|
10
|
+
createConfigResolver,
|
|
11
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
12
|
+
import {
|
|
13
|
+
createEntity,
|
|
14
|
+
createMoneyField,
|
|
15
|
+
createSelectField,
|
|
16
|
+
createTextField,
|
|
17
|
+
defineFeature,
|
|
18
|
+
type SessionUser,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
pushEntityProjectionTables,
|
|
23
|
+
setupTestStack,
|
|
24
|
+
type TestStack,
|
|
25
|
+
testTenantId,
|
|
26
|
+
unsafeCreateEntityTable,
|
|
27
|
+
unsafePushTables,
|
|
28
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
|
+
import { createTenantSettingsFeature } from "../feature";
|
|
30
|
+
import { defineCreateWithTenantDefaults } from "../tenant-defaults";
|
|
31
|
+
|
|
32
|
+
const invoiceEntity = createEntity({
|
|
33
|
+
table: "read_invoices",
|
|
34
|
+
fields: {
|
|
35
|
+
title: createTextField({ required: true }),
|
|
36
|
+
amount: createMoneyField({ required: true }),
|
|
37
|
+
language: createSelectField({ options: ["en", "de"] as const }),
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const ACCESS = { roles: ["Admin"] } as const;
|
|
42
|
+
|
|
43
|
+
const invoiceFeature = defineFeature("invoice", (r) => {
|
|
44
|
+
r.entity("invoice", invoiceEntity);
|
|
45
|
+
r.writeHandler(
|
|
46
|
+
defineCreateWithTenantDefaults("invoice", invoiceEntity, {
|
|
47
|
+
access: ACCESS,
|
|
48
|
+
currencyFields: ["amount"],
|
|
49
|
+
localeField: "language",
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const tenantId = testTenantId(1);
|
|
55
|
+
const admin: SessionUser = { id: "admin-1", tenantId, roles: ["Admin"] };
|
|
56
|
+
|
|
57
|
+
let stack: TestStack;
|
|
58
|
+
|
|
59
|
+
beforeAll(async () => {
|
|
60
|
+
const resolver = createConfigResolver();
|
|
61
|
+
stack = await setupTestStack({
|
|
62
|
+
features: [createConfigFeature(), createTenantSettingsFeature(), invoiceFeature],
|
|
63
|
+
extraContext: ({ registry }) => ({
|
|
64
|
+
configResolver: resolver,
|
|
65
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
66
|
+
}),
|
|
67
|
+
});
|
|
68
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
69
|
+
await unsafeCreateEntityTable(stack.db, invoiceEntity);
|
|
70
|
+
await createEventsTable(stack.db);
|
|
71
|
+
await pushEntityProjectionTables(stack, stack.registry);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterAll(async () => {
|
|
75
|
+
await stack.cleanup();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("ohne Tenant-Override übernimmt eine neue Entity den Feature-Default (EUR/en)", async () => {
|
|
79
|
+
const invoice = await stack.http.writeOk<{
|
|
80
|
+
data: { amount: { amount: number; currency: string }; language: string };
|
|
81
|
+
}>("invoice:write:invoice:create", { title: "Rechnung 1", amount: { amount: 1000 } }, admin);
|
|
82
|
+
expect(invoice.data.amount).toEqual({ amount: 1000, currency: "EUR" });
|
|
83
|
+
expect(invoice.data.language).toBe("en");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("Tenant setzt CHF/de — neue Entity ohne explizite Werte übernimmt die Tenant-Config, nicht das Feature-Default", async () => {
|
|
87
|
+
await stack.http.writeOk(
|
|
88
|
+
"config:write:set",
|
|
89
|
+
{ key: "tenant-settings:config:currency", value: "CHF" },
|
|
90
|
+
admin,
|
|
91
|
+
);
|
|
92
|
+
await stack.http.writeOk(
|
|
93
|
+
"config:write:set",
|
|
94
|
+
{ key: "tenant-settings:config:locale", value: "de" },
|
|
95
|
+
admin,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const invoice = await stack.http.writeOk<{
|
|
99
|
+
data: { amount: { amount: number; currency: string }; language: string };
|
|
100
|
+
}>("invoice:write:invoice:create", { title: "Rechnung 2", amount: { amount: 2000 } }, admin);
|
|
101
|
+
|
|
102
|
+
expect(invoice.data.amount).toEqual({ amount: 2000, currency: "CHF" });
|
|
103
|
+
expect(invoice.data.language).toBe("de");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("expliziter Wert im Payload gewinnt gegen die Tenant-Config", async () => {
|
|
107
|
+
const invoice = await stack.http.writeOk<{
|
|
108
|
+
data: { amount: { amount: number; currency: string } };
|
|
109
|
+
}>(
|
|
110
|
+
"invoice:write:invoice:create",
|
|
111
|
+
{ title: "Rechnung 3", amount: { amount: 3000, currency: "USD" } },
|
|
112
|
+
admin,
|
|
113
|
+
);
|
|
114
|
+
expect(invoice.data.amount).toEqual({ amount: 3000, currency: "USD" });
|
|
115
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {
|
|
2
|
+
access,
|
|
3
|
+
type ConfigKeyDefinition,
|
|
4
|
+
createTenantConfig,
|
|
5
|
+
DEFAULT_CURRENCIES,
|
|
6
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
+
|
|
8
|
+
// ISO-639-1, optional ISO-3166-1 region ("de" or "de-DE") — apps constrain
|
|
9
|
+
// further via their own i18n setup; this only guards against garbage input.
|
|
10
|
+
const LOCALE_PATTERN = { regex: "^[a-z]{2}(-[A-Z]{2})?$" } as const;
|
|
11
|
+
|
|
12
|
+
export type TenantSettingsKeyOptions = {
|
|
13
|
+
/** Selectable currencies. Default: DEFAULT_CURRENCIES (engine). */
|
|
14
|
+
readonly currencies?: readonly string[];
|
|
15
|
+
/** Fallback when a tenant never set the key. Default: "EUR". */
|
|
16
|
+
readonly defaultCurrency?: string;
|
|
17
|
+
/** Fallback when a tenant never set the key. Default: "en". */
|
|
18
|
+
readonly defaultLocale?: string;
|
|
19
|
+
/** Who may change the tenant's settings. Default: access.admin. */
|
|
20
|
+
readonly write?: readonly string[];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function buildTenantSettingsKeys(
|
|
24
|
+
opts: TenantSettingsKeyOptions = {},
|
|
25
|
+
): Record<string, ConfigKeyDefinition> {
|
|
26
|
+
const write = opts.write ?? access.admin;
|
|
27
|
+
return {
|
|
28
|
+
// `mask` surfaces the key in the self-populating Settings-Hub (Tenant-
|
|
29
|
+
// Audience) without a hand-written r.screen/r.nav — see
|
|
30
|
+
// samples/recipes/managed-config for the mechanism.
|
|
31
|
+
currency: createTenantConfig("select", {
|
|
32
|
+
default: opts.defaultCurrency ?? "EUR",
|
|
33
|
+
options: opts.currencies ?? DEFAULT_CURRENCIES,
|
|
34
|
+
write,
|
|
35
|
+
mask: { title: "tenant-settings.currency", icon: "coins", order: 1 },
|
|
36
|
+
}),
|
|
37
|
+
locale: createTenantConfig("text", {
|
|
38
|
+
default: opts.defaultLocale ?? "en",
|
|
39
|
+
pattern: LOCALE_PATTERN,
|
|
40
|
+
write,
|
|
41
|
+
mask: { title: "tenant-settings.locale", icon: "languages", order: 2 },
|
|
42
|
+
}),
|
|
43
|
+
} satisfies Record<string, ConfigKeyDefinition>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// tenant-settings bundle constants — feature-name + qualified config names.
|
|
2
|
+
//
|
|
3
|
+
// Motivation (solon#P19): apps kept hard-coding a single currency/locale
|
|
4
|
+
// literal per money/select field ("EUR", "de") instead of one per-tenant
|
|
5
|
+
// setting — expensive to retrofit once a second tenant needs a different
|
|
6
|
+
// default. This bundle is the one place that setting lives.
|
|
7
|
+
|
|
8
|
+
export const TENANT_SETTINGS_FEATURE_NAME = "tenant-settings";
|
|
9
|
+
|
|
10
|
+
// Qualified config key names (QN format: scope:config:name) for
|
|
11
|
+
// ctx.config(...) reads. Clients reference the object instead of magic
|
|
12
|
+
// strings (mirror tags' Handlers/Queries constants).
|
|
13
|
+
export const TenantSettingsConfig = {
|
|
14
|
+
currency: "tenant-settings:config:currency",
|
|
15
|
+
locale: "tenant-settings:config:locale",
|
|
16
|
+
} as const;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// tenant-settings — per-tenant Currency + Locale defaults.
|
|
2
|
+
//
|
|
3
|
+
// Any handler that needs a default (money-field currency, a locale-select
|
|
4
|
+
// field, ...) reads it via `ctx.config(TenantSettingsConfig.currency)` /
|
|
5
|
+
// `ctx.config(TenantSettingsConfig.locale)` — or use
|
|
6
|
+
// `defineCreateWithTenantDefaults` (tenant-defaults.ts) to wire it into an
|
|
7
|
+
// entity's create-handler without writing that plumbing by hand.
|
|
8
|
+
//
|
|
9
|
+
// No entities, no hand-written screen — `mask` on both keys makes them
|
|
10
|
+
// surface in the self-populating Settings-Hub (Tenant-Audience) instead;
|
|
11
|
+
// `buildConfigFeatureSchema` (config feature) derives the configEdit
|
|
12
|
+
// screen + nav entry from the key's scope/type/mask, same mechanism as
|
|
13
|
+
// samples/recipes/managed-config.
|
|
14
|
+
|
|
15
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { buildTenantSettingsKeys, type TenantSettingsKeyOptions } from "./config";
|
|
17
|
+
import { TENANT_SETTINGS_FEATURE_NAME } from "./constants";
|
|
18
|
+
|
|
19
|
+
export type TenantSettingsFeatureOptions = TenantSettingsKeyOptions;
|
|
20
|
+
|
|
21
|
+
export function createTenantSettingsFeature(
|
|
22
|
+
opts: TenantSettingsFeatureOptions = {},
|
|
23
|
+
): FeatureDefinition {
|
|
24
|
+
return defineFeature(TENANT_SETTINGS_FEATURE_NAME, (r) => {
|
|
25
|
+
r.requires("config");
|
|
26
|
+
r.describe(
|
|
27
|
+
"Per-tenant Currency + Locale defaults, exposed as two config keys (tenant-settings:config:currency, tenant-settings:config:locale) that surface in the self-populating Settings-Hub via `mask`. Pair with defineCreateWithTenantDefaults to auto-fill a money field's currency or a locale field on create instead of hard-coding a literal per entity.",
|
|
28
|
+
);
|
|
29
|
+
r.config({ keys: buildTenantSettingsKeys(opts) });
|
|
30
|
+
r.translations({
|
|
31
|
+
keys: {
|
|
32
|
+
"tenant-settings.currency": { de: "Standard-Währung", en: "Default Currency" },
|
|
33
|
+
"tenant-settings.locale": { de: "Standard-Sprache", en: "Default Locale" },
|
|
34
|
+
// Settings-Hub-derived configEdit screen title (buildConfigFeatureSchema) —
|
|
35
|
+
// required by the i18n boot-validator whenever a feature masks config keys.
|
|
36
|
+
"screen:tenant-settings-system.title": {
|
|
37
|
+
de: "Tenant-Einstellungen",
|
|
38
|
+
en: "Tenant Settings",
|
|
39
|
+
},
|
|
40
|
+
"screen:tenant-settings-tenant.title": {
|
|
41
|
+
de: "Tenant-Einstellungen",
|
|
42
|
+
en: "Tenant Settings",
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const tenantSettingsFeature: FeatureDefinition = createTenantSettingsFeature();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { buildTenantSettingsKeys, type TenantSettingsKeyOptions } from "./config";
|
|
2
|
+
export { TENANT_SETTINGS_FEATURE_NAME, TenantSettingsConfig } from "./constants";
|
|
3
|
+
export {
|
|
4
|
+
createTenantSettingsFeature,
|
|
5
|
+
type TenantSettingsFeatureOptions,
|
|
6
|
+
tenantSettingsFeature,
|
|
7
|
+
} from "./feature";
|
|
8
|
+
export { defineCreateWithTenantDefaults } from "./tenant-defaults";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Wrapper around defineEntityCreateHandler: fills currency/locale fields from
|
|
2
|
+
// tenant-settings config when the caller omits them — instead of repeating
|
|
3
|
+
// field literals ("EUR"/"de") on every entity.
|
|
4
|
+
//
|
|
5
|
+
// buildInsertSchema always requires `currency` inside a money field (even when
|
|
6
|
+
// the field itself is optional), so the caller cannot simply omit currency
|
|
7
|
+
// without failing schema validation before the handler runs. This helper uses
|
|
8
|
+
// a schema extension that makes those sub-keys optional, instead of the
|
|
9
|
+
// generic defineEntityCreateHandler.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type AccessRule,
|
|
13
|
+
buildInsertSchema,
|
|
14
|
+
createEntityExecutor,
|
|
15
|
+
type EntityDefinition,
|
|
16
|
+
type WriteHandlerDef,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { z } from "zod";
|
|
19
|
+
import { TenantSettingsConfig } from "./constants";
|
|
20
|
+
|
|
21
|
+
const OPTIONAL_CURRENCY_MONEY = z.object({ amount: z.number(), currency: z.string().optional() });
|
|
22
|
+
|
|
23
|
+
export function defineCreateWithTenantDefaults(
|
|
24
|
+
entityName: string,
|
|
25
|
+
entity: EntityDefinition,
|
|
26
|
+
options: {
|
|
27
|
+
readonly access?: AccessRule;
|
|
28
|
+
/** Money field names whose `currency` is filled from tenant-settings when missing. */
|
|
29
|
+
readonly currencyFields?: readonly string[];
|
|
30
|
+
/** Field name (select/text) that receives the tenant locale when missing. */
|
|
31
|
+
readonly localeField?: string;
|
|
32
|
+
},
|
|
33
|
+
): WriteHandlerDef {
|
|
34
|
+
const { executor } = createEntityExecutor(entityName, entity);
|
|
35
|
+
const relax: Record<string, z.ZodTypeAny> = {};
|
|
36
|
+
for (const field of options.currencyFields ?? []) {
|
|
37
|
+
const def = entity.fields[field];
|
|
38
|
+
if (!def) throw new Error(`defineCreateWithTenantDefaults: unknown field "${field}"`);
|
|
39
|
+
const isRequired = "required" in def && def.required === true;
|
|
40
|
+
relax[field] = isRequired ? OPTIONAL_CURRENCY_MONEY : OPTIONAL_CURRENCY_MONEY.optional();
|
|
41
|
+
}
|
|
42
|
+
const baseSchema = buildInsertSchema(entity);
|
|
43
|
+
const schema = Object.keys(relax).length > 0 ? baseSchema.extend(relax) : baseSchema;
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: `${entityName}:create`,
|
|
47
|
+
schema,
|
|
48
|
+
...(options.access && { access: options.access }),
|
|
49
|
+
handler: async (event, ctx) => {
|
|
50
|
+
const payload = { ...(event.payload as Record<string, unknown>) };
|
|
51
|
+
|
|
52
|
+
for (const field of options.currencyFields ?? []) {
|
|
53
|
+
const value = payload[field] as { amount?: number; currency?: string } | undefined;
|
|
54
|
+
if (value && typeof value === "object" && !value.currency) {
|
|
55
|
+
const currency = await ctx.config?.(TenantSettingsConfig.currency);
|
|
56
|
+
if (typeof currency === "string") payload[field] = { ...value, currency };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (options.localeField && !payload[options.localeField]) {
|
|
60
|
+
const locale = await ctx.config?.(TenantSettingsConfig.locale);
|
|
61
|
+
if (typeof locale === "string") payload[options.localeField] = locale;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return executor.create(payload, event.user, ctx.db);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -28,7 +28,7 @@ export const findForAuthQuery = defineQueryHandler({
|
|
|
28
28
|
),
|
|
29
29
|
access: { roles: access.system },
|
|
30
30
|
handler: async (query, ctx) => {
|
|
31
|
-
const where =
|
|
31
|
+
const where: { email: string } | { id: string } =
|
|
32
32
|
query.payload.email !== undefined
|
|
33
33
|
? { email: query.payload.email }
|
|
34
34
|
: { id: query.payload.id as string }; // @cast-boundary engine-payload
|
package/src/user/seeding.ts
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
19
|
import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
20
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
|
21
|
+
// kumiko-lint-ignore cross-feature-import shares the postSave-hooks fixture contract with tenant/seeding (#1478)
|
|
22
|
+
import { fireEntityPostSave, type SeedTenantHooks } from "../tenant/seeding";
|
|
21
23
|
import { userEntity, userTable } from "./schema/user";
|
|
22
24
|
|
|
23
25
|
const userExecutor = createEventStoreExecutor(userTable, userEntity, { entityName: "user" });
|
|
@@ -52,6 +54,11 @@ export type SeedUserOptions = {
|
|
|
52
54
|
export async function seedUser(
|
|
53
55
|
db: DbConnection,
|
|
54
56
|
options: SeedUserOptions,
|
|
57
|
+
// Optional, append-only — same rationale as tenant/seeding's seedTenant
|
|
58
|
+
// hooks param (#1478): fires user's postSave hooks (e.g. a Welcome-
|
|
59
|
+
// notification hook keyed off `allOf: "user"`) for fixtures/self-signup
|
|
60
|
+
// that opt in. Omit to keep today's hook-less behavior.
|
|
61
|
+
hooks?: SeedTenantHooks,
|
|
55
62
|
): Promise<{ id: string }> {
|
|
56
63
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
57
64
|
// executor.create erwartet eine TenantDb (mit .insert()-API). User
|
|
@@ -81,6 +88,7 @@ export async function seedUser(
|
|
|
81
88
|
`seedUser failed: ${result.error.code} — ${JSON.stringify(result.error.details ?? {})}`,
|
|
82
89
|
);
|
|
83
90
|
}
|
|
91
|
+
await fireEntityPostSave(hooks, "user:seed", result.data);
|
|
84
92
|
return { id: extractId(result.data, "seedUser") };
|
|
85
93
|
}
|
|
86
94
|
|
|
@@ -20,7 +20,7 @@ Dieses Dokument bündelt die **technischen Fakten** zur DSGVO-Pipeline
|
|
|
20
20
|
|---------|-------------------|--------------|--------------|
|
|
21
21
|
| **Art. 15 (Auskunft, light)** | `user-data-rights:query:my-audit-log` | User | Liest eigene Framework-Events aus `kumiko_events` (account-weit über alle Memberships). Filterbar nach `eventType`, `aggregateType`, `from`/`to`. Domain-Entities ohne `ctx.appendEvent` erscheinen NICHT — die kommen im Export-Bundle (Art. 20). |
|
|
22
22
|
| **Art. 15 + 20 (Auskunft + Portabilität)** | `user-data-rights:write:request-export` | User | Async Job → ZIP mit user-Profil + fileRefs + alle EXT_USER_DATA-Provider-Daten (cross-tenant) + signed Magic-Link per Email. Idempotent: nur 1 active Job pro User (`ACTIVE_JOB_CONSTRAINT`). |
|
|
23
|
-
| **Art. 15 + 20** | `GET /user-export/by-token
|
|
23
|
+
| **Art. 15 + 20** | `GET /user-export/by-token` (interstitial) + `POST /user-export/by-token` (exchange) | Anonym (Magic-Link-Pfad) | Email-Link trägt den Token als URL-Fragment (`#token=`, nie an den Server gesendet, issue #1271); Interstitial-Script liest das Fragment client-seitig und tauscht es per POST-Body gegen die signed Storage-URL. Kein `?token=`-Query-Fallback (#1512) — Query-Param landet unveränderlich in Proxy-/Access-Logs. Multi-use innerhalb TTL. Audit: `lastUsedAt`, `lastUsedFromIp`, `lastUsedUserAgent`. |
|
|
24
24
|
| **Art. 15 + 20** | `GET /user-export/by-job/:jobId` | User (Session-Auth) | UI-Klick-Pfad. Cross-tenant-same-user: User in Tenant B kann Job aus Tenant A laden wenn er der Owner ist. |
|
|
25
25
|
| **Art. 17 (Löschung)** | `user-data-rights:write:request-deletion` | User | Soft-Delete: `status=DeletionRequested` + `gracePeriodEnd = now + profile.gracePeriod`. Cron `run-forget-cleanup` führt nach Grace die Anonymisierung durch. |
|
|
26
26
|
| **Art. 17** | `user-data-rights:write:cancel-deletion` | User | Während Grace: status zurück auf `Active`. |
|
|
@@ -22,6 +22,8 @@ import { createSessionsFeature } from "../../sessions";
|
|
|
22
22
|
import { USER_STATUS, userEntity, userTable } from "../../user";
|
|
23
23
|
import { createUserFeature } from "../../user/feature";
|
|
24
24
|
import { createUserDataRightsFeature } from "../feature";
|
|
25
|
+
import { listDownloadAttemptsQuery } from "../handlers/list-download-attempts.query";
|
|
26
|
+
import { myAuditLogQuery } from "../handlers/my-audit-log.query";
|
|
25
27
|
import { downloadAttemptEntity, downloadAttemptsTable } from "../schema/download-attempt";
|
|
26
28
|
|
|
27
29
|
const MY_AUDIT = "user-data-rights:query:my-audit-log";
|
|
@@ -158,6 +160,69 @@ describe("my-audit-log", () => {
|
|
|
158
160
|
});
|
|
159
161
|
});
|
|
160
162
|
|
|
163
|
+
describe("my-audit-log — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
164
|
+
test("from/to filter works without deleting globalThis.Temporal for handler-internal state", async () => {
|
|
165
|
+
// Seed with Temporal intact — only the handler call runs ambient-free,
|
|
166
|
+
// mirroring the audit feature's #1525 regression test (going through
|
|
167
|
+
// full HTTP dispatch would also break buildHandlerContext's unrelated
|
|
168
|
+
// ctx.tz setup, which isn't what this pins down).
|
|
169
|
+
await seedEvent(alice.id, tenantA, "user.x", { from: "range-test" });
|
|
170
|
+
|
|
171
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
172
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
173
|
+
try {
|
|
174
|
+
const res = await myAuditLogQuery.handler(
|
|
175
|
+
{
|
|
176
|
+
payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
|
|
177
|
+
user: alice,
|
|
178
|
+
} as Parameters<typeof myAuditLogQuery.handler>[0],
|
|
179
|
+
{ db: { raw: stack.db } } as unknown as Parameters<typeof myAuditLogQuery.handler>[1],
|
|
180
|
+
);
|
|
181
|
+
expect(res.rows.length).toBeGreaterThanOrEqual(1);
|
|
182
|
+
} finally {
|
|
183
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
184
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe("list-download-attempts — kumiko-framework#1525: no ambient Temporal global", () => {
|
|
190
|
+
test("from/to filter works without relying on globalThis.Temporal", async () => {
|
|
191
|
+
const T = await import("@cosmicdrift/kumiko-framework/time");
|
|
192
|
+
const now = T.getTemporal().Now.instant();
|
|
193
|
+
await seedRows(stack.db, downloadAttemptsTable, [
|
|
194
|
+
{
|
|
195
|
+
id: "33333333-3333-4333-8333-333333333333",
|
|
196
|
+
tenantId: tenantA,
|
|
197
|
+
result: "notFound",
|
|
198
|
+
via: "token",
|
|
199
|
+
tokenHash: "abc",
|
|
200
|
+
jobId: null,
|
|
201
|
+
attemptedByUserId: null,
|
|
202
|
+
ip: "1.2.3.4",
|
|
203
|
+
userAgent: "test",
|
|
204
|
+
attemptedAt: now,
|
|
205
|
+
},
|
|
206
|
+
]);
|
|
207
|
+
|
|
208
|
+
const savedGlobal = (globalThis as { Temporal?: unknown }).Temporal;
|
|
209
|
+
delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
210
|
+
try {
|
|
211
|
+
const res = await listDownloadAttemptsQuery.handler(
|
|
212
|
+
{
|
|
213
|
+
payload: { limit: 50, from: "2020-01-01T00:00:00Z", to: "2030-01-01T00:00:00Z" },
|
|
214
|
+
user: admin,
|
|
215
|
+
} as Parameters<typeof listDownloadAttemptsQuery.handler>[0],
|
|
216
|
+
{ db: stack.db } as unknown as Parameters<typeof listDownloadAttemptsQuery.handler>[1],
|
|
217
|
+
);
|
|
218
|
+
expect(res.rows.length).toBeGreaterThanOrEqual(1);
|
|
219
|
+
} finally {
|
|
220
|
+
if (savedGlobal === undefined) delete (globalThis as { Temporal?: unknown }).Temporal;
|
|
221
|
+
else (globalThis as { Temporal?: unknown }).Temporal = savedGlobal;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
161
226
|
describe("download-attempt retention :: disk-bomb-Schutz bei Brute-Force", () => {
|
|
162
227
|
test("Entity-Default ist 90d hardDelete (kein unbounded growth)", () => {
|
|
163
228
|
expect(downloadAttemptEntity.retention).toBeDefined();
|
|
@@ -10,13 +10,22 @@ import {
|
|
|
10
10
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
11
|
import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
|
|
12
12
|
import { z } from "zod";
|
|
13
|
-
import {
|
|
13
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
14
14
|
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
15
15
|
import { createConfigFeature } from "../../config/feature";
|
|
16
16
|
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
17
|
+
import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
|
|
17
18
|
import { createSessionsFeature } from "../../sessions/feature";
|
|
18
19
|
import { createTenantFeature } from "../../tenant/feature";
|
|
19
20
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle/feature";
|
|
21
|
+
|
|
22
|
+
function projectionProbeTable(name: string): SchemaTable {
|
|
23
|
+
return table(name, {
|
|
24
|
+
id: uuid("id").primaryKey(),
|
|
25
|
+
count: integer("count").notNull().default(0),
|
|
26
|
+
}) as unknown as SchemaTable; // @cast-boundary test-fixture — minimal probe shape, real schema-table irrelevant here
|
|
27
|
+
}
|
|
28
|
+
|
|
20
29
|
import { createUserFeature } from "../../user/feature";
|
|
21
30
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
|
|
22
31
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -30,18 +39,13 @@ import { createUserDataRightsFeature } from "../feature";
|
|
|
30
39
|
// Unlike the old direct-function tests, these only run inside a real
|
|
31
40
|
// validateBoot() pass — exercising the actual `r.bootCheck()` plumbing, not
|
|
32
41
|
// just the guard logic.
|
|
33
|
-
// Stub instead of the real auth-foundation — these boot-checks are about
|
|
34
|
-
// GDPR-storage guards, not auth; only sessions' sessionStore registration
|
|
35
|
-
// needs an owner present.
|
|
36
|
-
const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
|
|
37
|
-
r.extendsRegistrar(EXT_SESSION_STORE, {});
|
|
38
|
-
});
|
|
39
|
-
|
|
40
42
|
function baseFeatures() {
|
|
41
43
|
return [
|
|
42
44
|
createConfigFeature(),
|
|
43
45
|
createUserFeature(),
|
|
44
|
-
|
|
46
|
+
createTenantFeature(),
|
|
47
|
+
authFoundationFeature,
|
|
48
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
45
49
|
createSessionsFeature(),
|
|
46
50
|
createDataRetentionFeature(),
|
|
47
51
|
createComplianceProfilesFeature(),
|
|
@@ -84,10 +88,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
84
88
|
});
|
|
85
89
|
|
|
86
90
|
test("V3: pii field on a projection-only entity (no r.entity) is still caught", () => {
|
|
87
|
-
const projectionTable =
|
|
88
|
-
id: uuid("id").primaryKey(),
|
|
89
|
-
count: integer("count").notNull().default(0),
|
|
90
|
-
}) as unknown as SchemaTable;
|
|
91
|
+
const projectionTable = projectionProbeTable("crm_contact_summary");
|
|
91
92
|
const bad = defineFeature("crm", (r) => {
|
|
92
93
|
const contactCreated = r.defineEvent("contact-created", z.unknown());
|
|
93
94
|
r.projection({
|
|
@@ -102,10 +103,7 @@ describe("GDPR-storage boot guards V2-V4 (via r.bootCheck)", () => {
|
|
|
102
103
|
});
|
|
103
104
|
|
|
104
105
|
test("V3: projection-only pii entity WITH EXT_USER_DATA hook registered for its entity name → no throw", () => {
|
|
105
|
-
const projectionTable =
|
|
106
|
-
id: uuid("id").primaryKey(),
|
|
107
|
-
count: integer("count").notNull().default(0),
|
|
108
|
-
}) as unknown as SchemaTable;
|
|
106
|
+
const projectionTable = projectionProbeTable("crm_contact_summary_hooked");
|
|
109
107
|
const hooked = defineFeature("crm-hooked", (r) => {
|
|
110
108
|
r.requires("user-data-rights");
|
|
111
109
|
const contactCreated = r.defineEvent("contact-hooked-created", z.unknown());
|
|
@@ -155,11 +155,15 @@ describe("download-by-token under resolverTrust: authoritative, no defaultTenant
|
|
|
155
155
|
}
|
|
156
156
|
|
|
157
157
|
const res = await stack.app.fetch(
|
|
158
|
-
new Request(
|
|
158
|
+
new Request("http://test/user-export/by-token", {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: { "content-type": "application/json" },
|
|
161
|
+
body: JSON.stringify({ token: plainToken }),
|
|
162
|
+
}),
|
|
159
163
|
);
|
|
160
|
-
expect(res.status).toBe(
|
|
161
|
-
const
|
|
162
|
-
expect(
|
|
163
|
-
expect(
|
|
164
|
+
expect(res.status).toBe(200);
|
|
165
|
+
const body = (await res.json()) as { data?: { url?: string } };
|
|
166
|
+
expect(body.data?.url).toMatch(/^memory:\/\//);
|
|
167
|
+
expect(body.data?.url).toContain(`${jobTenant}/exports/${jobId}.zip`);
|
|
164
168
|
});
|
|
165
169
|
});
|
|
@@ -437,8 +437,8 @@ describe("download-by-job :: happy path", () => {
|
|
|
437
437
|
});
|
|
438
438
|
|
|
439
439
|
describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
440
|
-
test("
|
|
441
|
-
const { plainToken } = await seedDoneJobWithToken();
|
|
440
|
+
test("`?token=` in query is ignored — serves interstitial HTML instead of resolving it (no access-log leak)", async () => {
|
|
441
|
+
const { jobId, plainToken } = await seedDoneJobWithToken();
|
|
442
442
|
|
|
443
443
|
const res = await stack.app.fetch(
|
|
444
444
|
new Request(`http://test/user-export/by-token?token=${plainToken}`, {
|
|
@@ -449,21 +449,15 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
449
449
|
},
|
|
450
450
|
}),
|
|
451
451
|
);
|
|
452
|
-
expect(res.status).toBe(
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
expect(
|
|
456
|
-
});
|
|
452
|
+
expect(res.status).toBe(200);
|
|
453
|
+
expect(res.headers.get("content-type")).toContain("text/html");
|
|
454
|
+
const body = await res.text();
|
|
455
|
+
expect(body).toContain("/user-export/by-token.js");
|
|
457
456
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
}),
|
|
463
|
-
);
|
|
464
|
-
expect(res.status).toBe(404);
|
|
465
|
-
const body = (await res.json()) as { error?: { i18nKey?: string } };
|
|
466
|
-
expect(body.error?.i18nKey).toBe("userDataRights.errors.download.notFound");
|
|
457
|
+
const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
|
|
458
|
+
useCount: number;
|
|
459
|
+
}>;
|
|
460
|
+
expect(row?.useCount).toBe(0);
|
|
467
461
|
});
|
|
468
462
|
|
|
469
463
|
test("no query token (fragment-Pfad) → serviert Interstitial-HTML statt 400", async () => {
|
|
@@ -487,29 +481,6 @@ describe("r.httpRoute :: /user-export/by-token (Magic-Link e2e)", () => {
|
|
|
487
481
|
expect(body).toContain("/user-export/by-token");
|
|
488
482
|
});
|
|
489
483
|
|
|
490
|
-
test("Audit-Update: useCount + IP/UA aus httpRoute-Headers (nicht aus payload)", async () => {
|
|
491
|
-
const { jobId, plainToken } = await seedDoneJobWithToken();
|
|
492
|
-
await stack.app.fetch(
|
|
493
|
-
new Request(`http://test/user-export/by-token?token=${plainToken}`, {
|
|
494
|
-
method: "GET",
|
|
495
|
-
headers: {
|
|
496
|
-
"user-agent": "e2e-test/2.0",
|
|
497
|
-
"x-forwarded-for": "198.51.100.7, 10.0.0.1",
|
|
498
|
-
},
|
|
499
|
-
}),
|
|
500
|
-
);
|
|
501
|
-
|
|
502
|
-
const [row] = (await selectMany(stack.db, exportDownloadTokensTable, { jobId })) as Array<{
|
|
503
|
-
useCount: number;
|
|
504
|
-
lastUsedFromIp: string | null;
|
|
505
|
-
lastUsedUserAgent: string | null;
|
|
506
|
-
}>;
|
|
507
|
-
expect(row?.useCount).toBe(1);
|
|
508
|
-
// X-Forwarded-For: erster Wert, comma-trimmed
|
|
509
|
-
expect(row?.lastUsedFromIp).toBe("198.51.100.7");
|
|
510
|
-
expect(row?.lastUsedUserAgent).toBe("e2e-test/2.0");
|
|
511
|
-
});
|
|
512
|
-
|
|
513
484
|
test("POST-Exchange (Fragment-Pfad): happy-path + IP/UA-Audit aus httpRoute-Headers", async () => {
|
|
514
485
|
const { jobId, plainToken } = await seedDoneJobWithToken();
|
|
515
486
|
|
|
@@ -569,8 +540,8 @@ describe("download-by-job :: cross-user + cross-tenant", () => {
|
|
|
569
540
|
});
|
|
570
541
|
|
|
571
542
|
test("provider ohne getSignedUrl → 422 unprocessable signedUrlNotSupported (job-Pfad)", async () => {
|
|
572
|
-
//
|
|
573
|
-
//
|
|
543
|
+
// Same as the token path: the operator misconfig must also surface as
|
|
544
|
+
// 422 on the UI click path, not 404.
|
|
574
545
|
const { jobId } = await seedDoneJobWithToken();
|
|
575
546
|
await stack.http.writeOk(
|
|
576
547
|
ConfigHandlers.set,
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { access,
|
|
2
|
+
import { access, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { createFilesFeature } from "@cosmicdrift/kumiko-framework/files";
|
|
4
|
-
import {
|
|
4
|
+
import { authFoundationFeature } from "../../auth-foundation";
|
|
5
5
|
import { createComplianceProfilesFeature } from "../../compliance-profiles/feature";
|
|
6
6
|
import { createConfigFeature } from "../../config/feature";
|
|
7
7
|
import { createDataRetentionFeature } from "../../data-retention/feature";
|
|
8
|
+
import { createPersonalAccessTokensFeature } from "../../personal-access-tokens";
|
|
8
9
|
import { createSessionsFeature } from "../../sessions/feature";
|
|
10
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
9
11
|
import { createUserFeature } from "../../user/feature";
|
|
10
12
|
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults/feature";
|
|
11
13
|
import { createUserDataRightsFeature } from "../feature";
|
|
@@ -18,13 +20,6 @@ import { createUserDataRightsFeature } from "../feature";
|
|
|
18
20
|
// entities (export-job, download-attempt) are r.entity, not direct-write stores,
|
|
19
21
|
// so an entityList binding is rebuild-safe (unlike jobs/sessions read-models).
|
|
20
22
|
|
|
21
|
-
// Stub instead of the real auth-foundation — this test is about screen
|
|
22
|
-
// wiring, not auth; only sessions' sessionStore registration needs an owner
|
|
23
|
-
// present.
|
|
24
|
-
const sessionStoreOwnerStub = defineFeature("auth-foundation", (r) => {
|
|
25
|
-
r.extendsRegistrar(EXT_SESSION_STORE, {});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
23
|
describe("user-data-rights read-only inspector screens", () => {
|
|
29
24
|
// A realistic assembly: mounting user-data-rights without its default hooks
|
|
30
25
|
// now fails the V3 boot gate (the framework `user` entity holds PII with no
|
|
@@ -33,7 +28,9 @@ describe("user-data-rights read-only inspector screens", () => {
|
|
|
33
28
|
const features = [
|
|
34
29
|
createConfigFeature(),
|
|
35
30
|
createUserFeature(),
|
|
36
|
-
|
|
31
|
+
createTenantFeature(),
|
|
32
|
+
authFoundationFeature,
|
|
33
|
+
createPersonalAccessTokensFeature({ scopes: {} }),
|
|
37
34
|
createSessionsFeature(),
|
|
38
35
|
createDataRetentionFeature(),
|
|
39
36
|
createComplianceProfilesFeature(),
|