@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.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/CHANGELOG.md +91 -0
- package/package.json +22 -13
- package/src/auth-email-password/auth-user-row.ts +6 -0
- package/src/auth-email-password/constants.ts +11 -0
- package/src/auth-email-password/handlers/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +32 -2
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- package/src/compliance-profiles/README.md +88 -0
- package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
- package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
- package/src/compliance-profiles/feature.ts +51 -0
- package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
- package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
- package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
- package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
- package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
- package/src/compliance-profiles/index.ts +6 -0
- package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/config/resolver.ts +1 -1
- package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
- package/src/data-retention/__tests__/keep-for.test.ts +77 -0
- package/src/data-retention/__tests__/override-schema.test.ts +96 -0
- package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
- package/src/data-retention/__tests__/resolver.test.ts +201 -0
- package/src/data-retention/_internal/parse-override.ts +34 -0
- package/src/data-retention/feature.ts +57 -0
- package/src/data-retention/handlers/policy-for.query.ts +57 -0
- package/src/data-retention/index.ts +18 -0
- package/src/data-retention/keep-for.ts +75 -0
- package/src/data-retention/override-schema.ts +37 -0
- package/src/data-retention/presets.ts +72 -0
- package/src/data-retention/resolve-for-tenant.ts +50 -0
- package/src/data-retention/resolver.ts +107 -0
- package/src/data-retention/schema/tenant-retention-override.ts +47 -0
- package/src/delivery/feature.ts +1 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +44 -4
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +10 -12
- package/src/files/README.md +50 -0
- package/src/files/__tests__/files.integration.ts +157 -0
- package/src/files/feature.ts +34 -0
- package/src/files/index.ts +1 -0
- package/src/files/schema/file-ref.ts +58 -0
- package/src/files-provider-s3/s3-provider.ts +90 -1
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +42 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-tenant.query.ts +56 -0
- package/src/text-content/handlers/set.write.ts +1 -1
- package/src/text-content/web/client-plugin.ts +113 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +23 -13
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user/seeding.ts +2 -2
- package/src/user-data-rights/COMPLIANCE.md +182 -0
- package/src/user-data-rights/README.md +109 -0
- package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
- package/src/user-data-rights/__tests__/download.integration.ts +565 -0
- package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
- package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
- package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
- package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
- package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
- package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
- package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
- package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
- package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
- package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
- package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
- package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
- package/src/user-data-rights/audit-download.ts +125 -0
- package/src/user-data-rights/feature.ts +310 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
- package/src/user-data-rights/handlers/export-status.query.ts +76 -0
- package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
- package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
- package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
- package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
- package/src/user-data-rights/handlers/request-export.write.ts +155 -0
- package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
- package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
- package/src/user-data-rights/i18n.ts +37 -0
- package/src/user-data-rights/index.ts +19 -0
- package/src/user-data-rights/run-export-jobs.ts +878 -0
- package/src/user-data-rights/run-forget-cleanup.ts +333 -0
- package/src/user-data-rights/run-user-export.ts +211 -0
- package/src/user-data-rights/schema/download-attempt.ts +37 -0
- package/src/user-data-rights/schema/download-token.ts +111 -0
- package/src/user-data-rights/schema/export-job.ts +166 -0
- package/src/user-data-rights/token-helpers.ts +67 -0
- package/src/user-data-rights/zip-path.ts +94 -0
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
- package/src/user-data-rights-defaults/feature.ts +40 -0
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
- package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
- package/src/user-data-rights-defaults/index.ts +6 -0
|
@@ -12,7 +12,7 @@ export const CAP_COUNTER_ROLLING_AGGREGATE_TYPE = "cap-counter-rolling" as const
|
|
|
12
12
|
// Custom event-type für Rolling-Window-Counter. Symmetrisches Paar:
|
|
13
13
|
// _SHORT — passt zu `r.defineEvent(short, schema)` im Registrar
|
|
14
14
|
// (Framework prefixt automatisch zu QN)
|
|
15
|
-
// _QN — qualifizierte Form für `ctx.
|
|
15
|
+
// _QN — qualifizierte Form für `ctx.unsafeAppendEvent({type})`
|
|
16
16
|
// + `events.type`-Spalte + `registry.getEvent(qn)`-Lookup
|
|
17
17
|
// Beide MÜSSEN konsistent sein (drift-pin im feature-test).
|
|
18
18
|
export const ROLLING_INCREMENTED_EVENT_SHORT = "rolling-incremented" as const;
|
|
@@ -118,7 +118,7 @@ export async function enforceCap(
|
|
|
118
118
|
.limit(1);
|
|
119
119
|
|
|
120
120
|
const row = rows[0];
|
|
121
|
-
const value = row ? (row["value"] as number) : 0;
|
|
121
|
+
const value = row ? (row["value"] as number) : 0; // @cast-boundary db-row
|
|
122
122
|
|
|
123
123
|
if (value >= hardThreshold) {
|
|
124
124
|
throw new CapExceededError(options.capName, options.limit, value, tolerance);
|
|
@@ -46,11 +46,7 @@
|
|
|
46
46
|
// config, kein secrets, kein tenant-feature nötig. Tenant-Scoping kommt
|
|
47
47
|
// vom Framework-Default (Base-Column tenantId).
|
|
48
48
|
|
|
49
|
-
import {
|
|
50
|
-
defineEntityListHandler,
|
|
51
|
-
defineFeature,
|
|
52
|
-
type FeatureDefinition,
|
|
53
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
49
|
+
import { defineEntityListHandler, defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
54
50
|
import { CAP_COUNTER_FEATURE, ROLLING_INCREMENTED_EVENT_SHORT } from "./constants";
|
|
55
51
|
import { capCounterEntity } from "./entity";
|
|
56
52
|
import { getCounterQuery } from "./handlers/get-counter.query";
|
|
@@ -63,11 +59,11 @@ import { markSoftWarnedHandler } from "./handlers/mark-soft-warned.write";
|
|
|
63
59
|
|
|
64
60
|
const sysadminAccess = { access: { roles: ["SystemAdmin"] } } as const;
|
|
65
61
|
|
|
66
|
-
export const capCounterFeature
|
|
62
|
+
export const capCounterFeature = defineFeature(CAP_COUNTER_FEATURE, (r) => {
|
|
67
63
|
r.entity("cap-counter", capCounterEntity);
|
|
68
64
|
|
|
69
65
|
// Custom Domain-Event für Rolling-Counter. r.defineEvent registriert
|
|
70
|
-
// das Schema beim Registry; ctx.
|
|
66
|
+
// das Schema beim Registry; ctx.unsafeAppendEvent im Handler nutzt
|
|
71
67
|
// dasselbe Schema für Append-Time-Validation. QN nach Prefixing:
|
|
72
68
|
// "cap-counter:event:rolling-incremented" (siehe
|
|
73
69
|
// ROLLING_INCREMENTED_EVENT_QN).
|
|
@@ -22,7 +22,7 @@ export const getCounterQuery: QueryHandlerDef = {
|
|
|
22
22
|
schema: getCounterSchema,
|
|
23
23
|
access: { roles: ["TenantAdmin", "SystemAdmin"] },
|
|
24
24
|
handler: async (query, ctx) => {
|
|
25
|
-
const { capName, periodStartIso } = query.payload as z.infer<typeof getCounterSchema>;
|
|
25
|
+
const { capName, periodStartIso } = query.payload as z.infer<typeof getCounterSchema>; // @cast-boundary engine-payload
|
|
26
26
|
|
|
27
27
|
// ctx.db is tenant-scoped; filter by capName + periodStart explicitly.
|
|
28
28
|
const rows = await ctx.db
|
|
@@ -60,11 +60,11 @@ export const incrementRollingCapHandler: WriteHandlerDef = {
|
|
|
60
60
|
const payload = event.payload as IncrementRollingPayload;
|
|
61
61
|
const aggregateId = rollingCapAggregateId(event.user.tenantId, payload.capName);
|
|
62
62
|
|
|
63
|
-
//
|
|
63
|
+
// unsafeAppendEvent — bundled-features-Pfad (apps mit yarn kumiko
|
|
64
64
|
// codegen kriegen den strict-typed appendEvent-Wrapper). Schema-
|
|
65
65
|
// Validation läuft trotzdem, weil r.defineEvent das Schema
|
|
66
66
|
// registriert hat.
|
|
67
|
-
await ctx.
|
|
67
|
+
await ctx.unsafeAppendEvent({
|
|
68
68
|
aggregateId,
|
|
69
69
|
aggregateType: CAP_COUNTER_ROLLING_AGGREGATE_TYPE,
|
|
70
70
|
type: ROLLING_INCREMENTED_EVENT_QN,
|
|
@@ -46,7 +46,7 @@ export const incrementCapHandler: WriteHandlerDef = {
|
|
|
46
46
|
// which subsystem incremented.
|
|
47
47
|
access: { roles: ["SystemAdmin"] },
|
|
48
48
|
handler: async (event, ctx) => {
|
|
49
|
-
const payload = event.payload as IncrementPayload;
|
|
49
|
+
const payload = event.payload as IncrementPayload; // @cast-boundary engine-payload
|
|
50
50
|
const aggregateId = capCounterAggregateId(
|
|
51
51
|
event.user.tenantId,
|
|
52
52
|
payload.capName,
|
|
@@ -77,8 +77,8 @@ export const incrementCapHandler: WriteHandlerDef = {
|
|
|
77
77
|
// clearer than a possibly-null deref later.
|
|
78
78
|
throw new Error("cap-counter:increment: row vanished between length-check and read");
|
|
79
79
|
}
|
|
80
|
-
const currentValue = currentRow["value"] as number;
|
|
81
|
-
const currentVersion = currentRow["version"] as number;
|
|
80
|
+
const currentValue = currentRow["value"] as number; // @cast-boundary db-row
|
|
81
|
+
const currentVersion = currentRow["version"] as number; // @cast-boundary db-row
|
|
82
82
|
return executor.update(
|
|
83
83
|
{
|
|
84
84
|
id: aggregateId,
|
|
@@ -25,7 +25,7 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
|
|
|
25
25
|
schema: markSoftWarnedSchema,
|
|
26
26
|
access: { roles: ["SystemAdmin"] },
|
|
27
27
|
handler: async (event, ctx) => {
|
|
28
|
-
const payload = event.payload as z.infer<typeof markSoftWarnedSchema>;
|
|
28
|
+
const payload = event.payload as z.infer<typeof markSoftWarnedSchema>; // @cast-boundary engine-payload
|
|
29
29
|
const aggregateId = capCounterAggregateId(
|
|
30
30
|
event.user.tenantId,
|
|
31
31
|
payload.capName,
|
|
@@ -42,7 +42,7 @@ export const markSoftWarnedHandler: WriteHandlerDef = {
|
|
|
42
42
|
if (!row) {
|
|
43
43
|
throw new Error("cap-counter:mark-soft-warned: row vanished between length-check and read");
|
|
44
44
|
}
|
|
45
|
-
const currentVersion = row["version"] as number;
|
|
45
|
+
const currentVersion = row["version"] as number; // @cast-boundary db-row
|
|
46
46
|
|
|
47
47
|
return executor.update(
|
|
48
48
|
{
|
|
@@ -34,7 +34,7 @@ export function createEmailChannel(options: EmailChannelOptions): DeliveryChanne
|
|
|
34
34
|
template: message.notificationType,
|
|
35
35
|
variables,
|
|
36
36
|
});
|
|
37
|
-
const subject = (variables["subject"] as string) ?? message.title;
|
|
37
|
+
const subject = (variables["subject"] as string) ?? message.title; // @cast-boundary dynamic-key
|
|
38
38
|
|
|
39
39
|
await transport.send({
|
|
40
40
|
to: address,
|
|
@@ -20,7 +20,7 @@ export function createInMemoryTransport(): EmailTransport & {
|
|
|
20
20
|
const sent: EmailMessage[] = [];
|
|
21
21
|
const transport = {
|
|
22
22
|
sent,
|
|
23
|
-
failNext: null as null | { message: string },
|
|
23
|
+
failNext: null as null | { message: string }, // @cast-boundary generic-record
|
|
24
24
|
async send(message: EmailMessage) {
|
|
25
25
|
if (transport.failNext) {
|
|
26
26
|
const err = new Error(transport.failNext.message);
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# compliance-profiles
|
|
2
|
+
|
|
3
|
+
Tenant-weite DSGVO/Compliance-Profile-Wahl. Pflicht beim Tenant-Onboarding —
|
|
4
|
+
Profile bündelt User-Rights-Grace, Notification-Sprachen, Breach-
|
|
5
|
+
Disclosure, Audit-Retention und Sub-Processor-Anforderungen in eine
|
|
6
|
+
Auswahl.
|
|
7
|
+
|
|
8
|
+
**Status:** Sprint 1 (S1.1 + S1.3). Sub-Processor-Endpoint (S1.4) und
|
|
9
|
+
Onboarding-Banner-API (S1.5) folgen in derselben Sprint-Iteration.
|
|
10
|
+
|
|
11
|
+
## MVP-Set: 3 Profile
|
|
12
|
+
|
|
13
|
+
- **`eu-dsgvo`** — Foundation-Profile, DSGVO Standard, BlnBDI Berlin
|
|
14
|
+
- **`swiss-dsg`** — extends `eu-dsgvo` mit DE/FR/IT/EN-Sprachen + EDÖB
|
|
15
|
+
- **`de-hr-dsgvo-hgb`** — extends `eu-dsgvo` mit HR-Spezifika (HGB
|
|
16
|
+
10y-Audit-Retention, Betriebsrat-Notification, 60d-Tenant-Destroy)
|
|
17
|
+
|
|
18
|
+
Plus `minimal-no-region` als Default-Fallback (NICHT auswählbar) bis
|
|
19
|
+
Tenant-Admin eine Wahl trifft.
|
|
20
|
+
|
|
21
|
+
Erweiterungen wie `uk-gdpr`, `ca-pipeda`, `ca-quebec-l25`, `us-ccpa`,
|
|
22
|
+
`hipaa-healthcare` kommen on-demand wenn Customer fragt — der
|
|
23
|
+
`extends`-Mechanismus macht sie zu 30-Zeilen-Adds.
|
|
24
|
+
|
|
25
|
+
## API
|
|
26
|
+
|
|
27
|
+
### Queries
|
|
28
|
+
|
|
29
|
+
- `compliance-profiles:query:list-profiles` — `openToAll`. Liefert die
|
|
30
|
+
3 wählbaren Profile mit Region + Aufsicht + Sprachen, für Onboarding-UI.
|
|
31
|
+
- `compliance-profiles:query:for-tenant` — `openToAll`. Liefert das
|
|
32
|
+
effektive Profile für den aktuellen Tenant inkl. Override (deep-merge).
|
|
33
|
+
Wenn kein Profile gesetzt: `minimal-no-region` + `warning="no-profile-selected"`.
|
|
34
|
+
|
|
35
|
+
### Writes
|
|
36
|
+
|
|
37
|
+
- `compliance-profiles:write:set-profile` — `roles=[TenantAdmin]`.
|
|
38
|
+
Upsert: setzt `profileKey` (+ optional `override`-JSON) für den
|
|
39
|
+
aktuellen Tenant. Idempotent, zweiter Call updated.
|
|
40
|
+
|
|
41
|
+
### Cross-Feature (`r.exposesApi`)
|
|
42
|
+
|
|
43
|
+
- `compliance.forTenant` — Marker. Andere Features (Sprint 2
|
|
44
|
+
`user-data-rights`, Sprint 5 `tenant-lifecycle`) rufen den Resolver via
|
|
45
|
+
QN-Pattern (`app.fetch("/api/query")` mit `type=compliance-profiles:query:for-tenant`).
|
|
46
|
+
Boot-Validator checkt dass jeder `r.usesApi("compliance.forTenant")`-
|
|
47
|
+
Caller das Feature in `requires/optionalRequires` hat.
|
|
48
|
+
|
|
49
|
+
## Override-Semantik
|
|
50
|
+
|
|
51
|
+
`override` wird als JSON-String gespeichert und beim Resolver
|
|
52
|
+
deep-merged auf das gewählte Profile. Atomic-Paths (gracePeriod /
|
|
53
|
+
auskunftFrist / retention / authorityNotificationDeadline /
|
|
54
|
+
tenantDestroyGracePeriod) ersetzen komplett statt rekursiv zu mergen,
|
|
55
|
+
weil sie diskriminierte Union-Objects sind (`{ months } | { years }` vs
|
|
56
|
+
`{ days } | { hours }`).
|
|
57
|
+
|
|
58
|
+
```typescript
|
|
59
|
+
// Tenant-Admin override:
|
|
60
|
+
{
|
|
61
|
+
"userRights": { "gracePeriod": { "days": 60 } }
|
|
62
|
+
}
|
|
63
|
+
// Effekt auf eu-dsgvo:
|
|
64
|
+
// userRights.gracePeriod = { days: 60 } (overridden)
|
|
65
|
+
// userRights.restrictionAllowed = true (geerbt)
|
|
66
|
+
// userRights.portabilityFormat = ["json"] (geerbt)
|
|
67
|
+
// ...alle anderen userRights-Felder unverändert
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Architektur-Note
|
|
71
|
+
|
|
72
|
+
Profile-Selection lebt als **separate Entity** (`tenantComplianceProfileEntity`)
|
|
73
|
+
im compliance-profiles-Feature, nicht als config-key im tenant-Feature.
|
|
74
|
+
Begründung in `schema/profile-selection.ts` — kurz: Override ist
|
|
75
|
+
strukturiertes JSON, Profile-Wechsel ist audit-relevant (Event-Store
|
|
76
|
+
liefert das automatisch für Entity-Writes), Plan-Files nennen sie
|
|
77
|
+
explizit als eigene Entity.
|
|
78
|
+
|
|
79
|
+
## Tests
|
|
80
|
+
|
|
81
|
+
`__tests__/compliance-profiles.integration.ts` — 9 full-stack Tests via
|
|
82
|
+
`setupTestStack` + echte HTTP-Calls (Memory: `feedback_no_fake_dispatcher`):
|
|
83
|
+
list-profiles, for-tenant ohne/mit Setting, set-profile als TenantAdmin /
|
|
84
|
+
Member (403) / mit Override / mit invalidem JSON / mit Array statt Object /
|
|
85
|
+
idempotent-Update.
|
|
86
|
+
|
|
87
|
+
Plus Unit-Tests für Profile-Constants + Override-Resolver in
|
|
88
|
+
`framework/src/compliance/__tests__/profiles.test.ts` (16 Tests).
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
3
|
+
import {
|
|
4
|
+
createTestUser,
|
|
5
|
+
setupTestStack,
|
|
6
|
+
type TestStack,
|
|
7
|
+
testTenantId,
|
|
8
|
+
unsafeCreateEntityTable,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
11
|
+
import { createComplianceProfilesFeature, tenantComplianceProfileEntity } from "../feature";
|
|
12
|
+
|
|
13
|
+
const SET_PROFILE = "compliance-profiles:write:set-profile";
|
|
14
|
+
const FOR_TENANT = "compliance-profiles:query:for-tenant";
|
|
15
|
+
const LIST_PROFILES = "compliance-profiles:query:list-profiles";
|
|
16
|
+
const SUB_PROCESSORS = "compliance-profiles:query:sub-processors";
|
|
17
|
+
const NEEDS_PROFILE = "compliance-profiles:query:needs-profile";
|
|
18
|
+
|
|
19
|
+
// S1.8 N5: Isolierten Tenant-Admin pro Test bauen — verhindert
|
|
20
|
+
// Cross-Test-Interferenz uber gemeinsamen Default-tenantId aus
|
|
21
|
+
// TestUsers.admin. Eindeutige numerische ID + parallele tenantId.
|
|
22
|
+
function createIsolatedTenantAdmin(n: number, roles: string[] = ["TenantAdmin"]) {
|
|
23
|
+
return createTestUser({ id: n, tenantId: testTenantId(n), roles });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let stack: TestStack;
|
|
27
|
+
let db: DbConnection;
|
|
28
|
+
|
|
29
|
+
const tenantAdmin = createTestUser({ id: 2, roles: ["TenantAdmin"] });
|
|
30
|
+
const normalUser = createTestUser({ id: 3, roles: ["Member"] });
|
|
31
|
+
|
|
32
|
+
const feature = createComplianceProfilesFeature();
|
|
33
|
+
|
|
34
|
+
beforeAll(async () => {
|
|
35
|
+
stack = await setupTestStack({ features: [feature] });
|
|
36
|
+
db = stack.db;
|
|
37
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
38
|
+
await createEventsTable(db);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(async () => {
|
|
42
|
+
await stack.cleanup();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("compliance-profiles :: list-profiles", () => {
|
|
46
|
+
test("liefert die 3 wählbaren Profile mit Region + Sprachen", async () => {
|
|
47
|
+
const result = await stack.http.queryOk<{
|
|
48
|
+
profiles: Array<{ key: string; region: string; languages: string[] }>;
|
|
49
|
+
}>(LIST_PROFILES, {}, tenantAdmin);
|
|
50
|
+
expect(result.profiles).toHaveLength(3);
|
|
51
|
+
const keys = result.profiles.map((p) => p.key);
|
|
52
|
+
expect(keys).toEqual(["eu-dsgvo", "swiss-dsg", "de-hr-dsgvo-hgb"]);
|
|
53
|
+
const swiss = result.profiles.find((p) => p.key === "swiss-dsg");
|
|
54
|
+
expect(swiss?.region).toBe("CH");
|
|
55
|
+
expect(swiss?.languages).toContain("fr");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("minimal-no-region ist NICHT in der Liste (kein Production-Default)", async () => {
|
|
59
|
+
const result = await stack.http.queryOk<{ profiles: Array<{ key: string }> }>(
|
|
60
|
+
LIST_PROFILES,
|
|
61
|
+
{},
|
|
62
|
+
tenantAdmin,
|
|
63
|
+
);
|
|
64
|
+
expect(result.profiles.find((p) => p.key === "minimal-no-region")).toBeUndefined();
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("compliance-profiles :: for-tenant", () => {
|
|
69
|
+
test("ohne Setting → minimal-no-region + warning=no-profile-selected", async () => {
|
|
70
|
+
const result = await stack.http.queryOk<{
|
|
71
|
+
profile: { key: string };
|
|
72
|
+
warning?: string;
|
|
73
|
+
}>(FOR_TENANT, {}, normalUser);
|
|
74
|
+
expect(result.profile.key).toBe("minimal-no-region");
|
|
75
|
+
expect(result.warning).toBe("no-profile-selected");
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("compliance-profiles :: set-profile", () => {
|
|
80
|
+
test("TenantAdmin kann Profile auf eu-dsgvo setzen", async () => {
|
|
81
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
|
|
82
|
+
|
|
83
|
+
const result = await stack.http.queryOk<{
|
|
84
|
+
profile: { key: string; region: string; breach: { authorityContact: string } };
|
|
85
|
+
warning?: string;
|
|
86
|
+
}>(FOR_TENANT, {}, tenantAdmin);
|
|
87
|
+
expect(result.profile.key).toBe("eu-dsgvo");
|
|
88
|
+
expect(result.profile.region).toBe("EU");
|
|
89
|
+
expect(result.profile.breach.authorityContact).toBe("BlnBDI Berlin");
|
|
90
|
+
expect(result.warning).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("set-profile ist idempotent — zweiter Call wechselt Profile", async () => {
|
|
94
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
|
|
95
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "swiss-dsg" }, tenantAdmin);
|
|
96
|
+
|
|
97
|
+
const result = await stack.http.queryOk<{
|
|
98
|
+
profile: { key: string; region: string; breach: { authorityContact: string } };
|
|
99
|
+
}>(FOR_TENANT, {}, tenantAdmin);
|
|
100
|
+
expect(result.profile.key).toBe("swiss-dsg");
|
|
101
|
+
expect(result.profile.breach.authorityContact).toBe("EDÖB Bern");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("set-profile mit Override merged auf base-profile", async () => {
|
|
105
|
+
await stack.http.writeOk(
|
|
106
|
+
SET_PROFILE,
|
|
107
|
+
{
|
|
108
|
+
profileKey: "eu-dsgvo",
|
|
109
|
+
override: JSON.stringify({
|
|
110
|
+
userRights: { gracePeriod: { days: 60 } },
|
|
111
|
+
}),
|
|
112
|
+
},
|
|
113
|
+
tenantAdmin,
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await stack.http.queryOk<{
|
|
117
|
+
profile: { userRights: { gracePeriod: { days: number }; portabilityFormat: string[] } };
|
|
118
|
+
}>(FOR_TENANT, {}, tenantAdmin);
|
|
119
|
+
expect(result.profile.userRights.gracePeriod).toEqual({ days: 60 });
|
|
120
|
+
// Andere userRights-Felder bleiben aus eu-dsgvo
|
|
121
|
+
expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("Member ohne TenantAdmin-Rolle bekommt 403 beim set-profile", async () => {
|
|
125
|
+
const result = await stack.http.write(SET_PROFILE, { profileKey: "eu-dsgvo" }, normalUser);
|
|
126
|
+
expect(result.status).toBe(403);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("set-profile mit invalid JSON-Override wirft Error", async () => {
|
|
130
|
+
const result = await stack.http.write(
|
|
131
|
+
SET_PROFILE,
|
|
132
|
+
{ profileKey: "eu-dsgvo", override: "not-valid-json" },
|
|
133
|
+
tenantAdmin,
|
|
134
|
+
);
|
|
135
|
+
expect(result.status).toBeGreaterThanOrEqual(400);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("set-profile mit Array statt Object als Override wirft Error", async () => {
|
|
139
|
+
const result = await stack.http.write(
|
|
140
|
+
SET_PROFILE,
|
|
141
|
+
{ profileKey: "eu-dsgvo", override: JSON.stringify([{ foo: 1 }]) },
|
|
142
|
+
tenantAdmin,
|
|
143
|
+
);
|
|
144
|
+
expect(result.status).toBeGreaterThanOrEqual(400);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// S1.7 X1: Schema engt sich auf SELECTABLE_PROFILE_KEYS
|
|
148
|
+
test("set-profile mit minimal-no-region wird abgelehnt (X1)", async () => {
|
|
149
|
+
const result = await stack.http.write(
|
|
150
|
+
SET_PROFILE,
|
|
151
|
+
{ profileKey: "minimal-no-region" },
|
|
152
|
+
tenantAdmin,
|
|
153
|
+
);
|
|
154
|
+
expect(result.status).toBeGreaterThanOrEqual(400);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// S1.7 X2: Override mit unbekannten Top-Level-Keys
|
|
158
|
+
test("set-profile mit unbekanntem Top-Level-Override-Key wirft Error (X2)", async () => {
|
|
159
|
+
const result = await stack.http.write(
|
|
160
|
+
SET_PROFILE,
|
|
161
|
+
{
|
|
162
|
+
profileKey: "eu-dsgvo",
|
|
163
|
+
override: JSON.stringify({ userrights: { gracePeriod: { days: 60 } } }), // typo lowercase
|
|
164
|
+
},
|
|
165
|
+
tenantAdmin,
|
|
166
|
+
);
|
|
167
|
+
expect(result.status).toBeGreaterThanOrEqual(400);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// S1.9 Z3 + S1.10 M3: Override-Sub-Level-Tippfehler (Schema-Strict)
|
|
171
|
+
// mit path-spezifischer Assertion via ValidationError.details.fields.
|
|
172
|
+
test("set-profile mit Sub-Level-Tippfehler liefert validation_error mit Path (Z3+M3)", async () => {
|
|
173
|
+
const err = await stack.http.writeErr(
|
|
174
|
+
SET_PROFILE,
|
|
175
|
+
{
|
|
176
|
+
profileKey: "eu-dsgvo",
|
|
177
|
+
override: JSON.stringify({ userRights: { weeks: 3 } }), // weeks gibt's nicht
|
|
178
|
+
},
|
|
179
|
+
tenantAdmin,
|
|
180
|
+
);
|
|
181
|
+
expect(err.code).toBe("validation_error");
|
|
182
|
+
expect(err.httpStatus).toBe(400);
|
|
183
|
+
const fields = (err.details as { fields: Array<{ path: string }> })?.fields;
|
|
184
|
+
expect(fields).toBeDefined();
|
|
185
|
+
// Schema-strict wirft auf "userRights.weeks" als unrecognized_keys — ODER
|
|
186
|
+
// generelle "userRights" wenn Zod das so reportet. Beide Pfade decken den
|
|
187
|
+
// Bug ab.
|
|
188
|
+
expect(fields?.some((f) => f.path.includes("userRights"))).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("set-profile mit invalid retention-shape liefert validation_error mit Path (Z3+M3)", async () => {
|
|
192
|
+
const err = await stack.http.writeErr(
|
|
193
|
+
SET_PROFILE,
|
|
194
|
+
{
|
|
195
|
+
profileKey: "eu-dsgvo",
|
|
196
|
+
override: JSON.stringify({
|
|
197
|
+
userRights: { gracePeriod: { days: 30, hours: 24 } }, // strict-disjunction
|
|
198
|
+
}),
|
|
199
|
+
},
|
|
200
|
+
tenantAdmin,
|
|
201
|
+
);
|
|
202
|
+
expect(err.code).toBe("validation_error");
|
|
203
|
+
const fields = (err.details as { fields: Array<{ path: string }> })?.fields;
|
|
204
|
+
expect(fields?.some((f) => f.path.includes("gracePeriod"))).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// S1.7 F2: SystemAdmin kann Profile setzen
|
|
208
|
+
test("SystemAdmin kann Profile setzen (Plattform-Operator-Pfad)", async () => {
|
|
209
|
+
const sysAdmin = createIsolatedTenantAdmin(50, ["SystemAdmin"]);
|
|
210
|
+
const result = await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, sysAdmin);
|
|
211
|
+
expect(result).toMatchObject({ profileKey: "eu-dsgvo", isNew: true });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// S1.7 F3: tenantIdOverride als SystemAdmin → für Customer-Tenant
|
|
215
|
+
test("SystemAdmin kann mit tenantIdOverride für anderen Tenant Profile setzen (F3)", async () => {
|
|
216
|
+
const sysAdmin = createIsolatedTenantAdmin(51, ["SystemAdmin"]);
|
|
217
|
+
const targetTenantAdmin = createIsolatedTenantAdmin(52);
|
|
218
|
+
|
|
219
|
+
await stack.http.writeOk(
|
|
220
|
+
SET_PROFILE,
|
|
221
|
+
{ profileKey: "swiss-dsg", tenantIdOverride: targetTenantAdmin.tenantId },
|
|
222
|
+
sysAdmin,
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const result = await stack.http.queryOk<{ profile: { key: string; region: string } }>(
|
|
226
|
+
FOR_TENANT,
|
|
227
|
+
{},
|
|
228
|
+
targetTenantAdmin,
|
|
229
|
+
);
|
|
230
|
+
expect(result.profile.key).toBe("swiss-dsg");
|
|
231
|
+
expect(result.profile.region).toBe("CH");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// S1.7 F3: tenantIdOverride als TenantAdmin → 403
|
|
235
|
+
test("TenantAdmin mit tenantIdOverride bekommt 403 (F3)", async () => {
|
|
236
|
+
const someTenantAdmin = createIsolatedTenantAdmin(53);
|
|
237
|
+
const result = await stack.http.write(
|
|
238
|
+
SET_PROFILE,
|
|
239
|
+
{ profileKey: "eu-dsgvo", tenantIdOverride: testTenantId(54) },
|
|
240
|
+
someTenantAdmin,
|
|
241
|
+
);
|
|
242
|
+
expect(result.status).toBe(403);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe("compliance-profiles :: sub-processors (S1.4)", () => {
|
|
247
|
+
test("liefert active + planned Sub-Processors mit Pflicht-Feldern", async () => {
|
|
248
|
+
const result = await stack.http.queryOk<{
|
|
249
|
+
active: Array<{ name: string; region: string; dpa: string; sccRequired?: boolean }>;
|
|
250
|
+
planned: Array<{ name: string; status: string }>;
|
|
251
|
+
total: number;
|
|
252
|
+
generatedAt: string;
|
|
253
|
+
}>(SUB_PROCESSORS, {}, normalUser);
|
|
254
|
+
|
|
255
|
+
expect(result.active.length).toBeGreaterThan(0);
|
|
256
|
+
const hetzner = result.active.find((s) => s.name.includes("Hetzner"));
|
|
257
|
+
expect(hetzner).toBeDefined();
|
|
258
|
+
expect(hetzner?.dpa).toMatch(/^https:\/\//);
|
|
259
|
+
expect(hetzner?.region).toContain("Germany");
|
|
260
|
+
|
|
261
|
+
const cloudflare = result.active.find((s) => s.name.includes("Cloudflare"));
|
|
262
|
+
expect(cloudflare?.sccRequired).toBe(true);
|
|
263
|
+
|
|
264
|
+
expect(result.planned.length).toBeGreaterThan(0);
|
|
265
|
+
expect(result.planned.every((p) => p.status === "planned")).toBe(true);
|
|
266
|
+
|
|
267
|
+
expect(result.total).toBe(result.active.length + result.planned.length);
|
|
268
|
+
expect(result.generatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("compliance-profiles :: needs-profile (S1.5 — Onboarding-Banner)", () => {
|
|
273
|
+
test("Tenant ohne Profile-Wahl → needsSelection=true, reason=no_profile_selected", async () => {
|
|
274
|
+
// Frischer Tenant-Admin (eigener Tenant ID damit kein Profile gesetzt
|
|
275
|
+
// ist — sonst sieht er den Eintrag aus den vorherigen set-profile-Tests).
|
|
276
|
+
const freshTenantAdmin = createIsolatedTenantAdmin(99);
|
|
277
|
+
const result = await stack.http.queryOk<{
|
|
278
|
+
needsSelection: boolean;
|
|
279
|
+
currentProfile: string | null;
|
|
280
|
+
reason?: string;
|
|
281
|
+
}>(NEEDS_PROFILE, {}, freshTenantAdmin);
|
|
282
|
+
expect(result.needsSelection).toBe(true);
|
|
283
|
+
expect(result.currentProfile).toBeNull();
|
|
284
|
+
expect(result.reason).toBe("no_profile_selected");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("Tenant mit eu-dsgvo-Wahl → needsSelection=false", async () => {
|
|
288
|
+
const setupAdmin = createIsolatedTenantAdmin(100);
|
|
289
|
+
await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, setupAdmin);
|
|
290
|
+
|
|
291
|
+
const result = await stack.http.queryOk<{
|
|
292
|
+
needsSelection: boolean;
|
|
293
|
+
currentProfile: string | null;
|
|
294
|
+
}>(NEEDS_PROFILE, {}, setupAdmin);
|
|
295
|
+
expect(result.needsSelection).toBe(false);
|
|
296
|
+
expect(result.currentProfile).toBe("eu-dsgvo");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// S1.8 O3: minimal-no-region-Defensiv-Pfad in needs-profile.query.ts
|
|
300
|
+
// entfernt (toter Code nach S1.7 X1 — Zod blockt minimal-no-region).
|
|
301
|
+
// Wenn Sprint 2 einen seedComplianceProfile-Helper bringt der den
|
|
302
|
+
// Migration-Edge-Case einführt, kommt hier ein neuer Test rein.
|
|
303
|
+
|
|
304
|
+
test("Member-Rolle bekommt 403 (Banner ist Admin-only)", async () => {
|
|
305
|
+
const result = await stack.http.query(NEEDS_PROFILE, {}, normalUser);
|
|
306
|
+
expect(result.status).toBe(403);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// seedComplianceProfile-Helper-Tests (S1.9 Z2).
|
|
2
|
+
//
|
|
3
|
+
// Beweist:
|
|
4
|
+
// 1. Helper umgeht set-profile-Zod-Engung (kann minimal-no-region
|
|
5
|
+
// setzen für Migration-Edge-Case-Tests in Sprint 2+)
|
|
6
|
+
// 2. Idempotent: zweiter Call mit gleichem tenantId updated den
|
|
7
|
+
// bestehenden Eintrag
|
|
8
|
+
// 3. Override wird als JSON-String persistiert + via for-tenant
|
|
9
|
+
// korrekt zurueckgelesen
|
|
10
|
+
|
|
11
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
12
|
+
import {
|
|
13
|
+
createTestUser,
|
|
14
|
+
setupTestStack,
|
|
15
|
+
type TestStack,
|
|
16
|
+
testTenantId,
|
|
17
|
+
unsafeCreateEntityTable,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
19
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
20
|
+
import { createComplianceProfilesFeature, tenantComplianceProfileEntity } from "../feature";
|
|
21
|
+
import { seedComplianceProfile } from "../seeding";
|
|
22
|
+
|
|
23
|
+
const FOR_TENANT = "compliance-profiles:query:for-tenant";
|
|
24
|
+
|
|
25
|
+
let stack: TestStack;
|
|
26
|
+
|
|
27
|
+
const feature = createComplianceProfilesFeature();
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
stack = await setupTestStack({ features: [feature] });
|
|
31
|
+
await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
|
|
32
|
+
await createEventsTable(stack.db);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(async () => {
|
|
36
|
+
await stack.cleanup();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("seedComplianceProfile", () => {
|
|
40
|
+
test("kann eu-dsgvo direkt seeden, for-tenant liefert das Profile", async () => {
|
|
41
|
+
const tenantId = testTenantId(200);
|
|
42
|
+
const user = createTestUser({ id: 200, tenantId, roles: ["TenantAdmin"] });
|
|
43
|
+
|
|
44
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
45
|
+
|
|
46
|
+
const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
|
|
47
|
+
expect(result.profile.key).toBe("eu-dsgvo");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("idempotent: zweiter Call updated den bestehenden Eintrag", async () => {
|
|
51
|
+
const tenantId = testTenantId(201);
|
|
52
|
+
const user = createTestUser({ id: 201, tenantId, roles: ["TenantAdmin"] });
|
|
53
|
+
|
|
54
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "eu-dsgvo" });
|
|
55
|
+
await seedComplianceProfile(stack.db, { tenantId, profileKey: "swiss-dsg" });
|
|
56
|
+
|
|
57
|
+
const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
|
|
58
|
+
expect(result.profile.key).toBe("swiss-dsg");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("kann minimal-no-region direkt seeden (Migration-Edge-Case, ohne set-profile-Zod-Engung)", async () => {
|
|
62
|
+
const tenantId = testTenantId(202);
|
|
63
|
+
const user = createTestUser({ id: 202, tenantId, roles: ["TenantAdmin"] });
|
|
64
|
+
|
|
65
|
+
// set-profile (Sprint 1.7 X1) wuerde minimal-no-region rejecten —
|
|
66
|
+
// seedComplianceProfile umgeht das fuer Test-Migration-Szenarien.
|
|
67
|
+
await seedComplianceProfile(stack.db, {
|
|
68
|
+
tenantId,
|
|
69
|
+
profileKey: "minimal-no-region",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const result = await stack.http.queryOk<{ profile: { key: string } }>(FOR_TENANT, {}, user);
|
|
73
|
+
expect(result.profile.key).toBe("minimal-no-region");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("Override wird persistiert + im for-tenant deep-merged zurueckgelesen", async () => {
|
|
77
|
+
const tenantId = testTenantId(203);
|
|
78
|
+
const user = createTestUser({ id: 203, tenantId, roles: ["TenantAdmin"] });
|
|
79
|
+
|
|
80
|
+
await seedComplianceProfile(stack.db, {
|
|
81
|
+
tenantId,
|
|
82
|
+
profileKey: "eu-dsgvo",
|
|
83
|
+
override: { userRights: { gracePeriod: { days: 90 } } },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await stack.http.queryOk<{
|
|
87
|
+
profile: { userRights: { gracePeriod: { days: number }; portabilityFormat: string[] } };
|
|
88
|
+
}>(FOR_TENANT, {}, user);
|
|
89
|
+
expect(result.profile.userRights.gracePeriod).toEqual({ days: 90 });
|
|
90
|
+
// Andere userRights bleiben aus eu-dsgvo
|
|
91
|
+
expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { forTenantQuery } from "./handlers/for-tenant.query";
|
|
3
|
+
import { listProfilesQuery } from "./handlers/list-profiles.query";
|
|
4
|
+
import { needsProfileQuery } from "./handlers/needs-profile.query";
|
|
5
|
+
import { setProfileWrite } from "./handlers/set-profile.write";
|
|
6
|
+
import { subProcessorsQuery } from "./handlers/sub-processors.query";
|
|
7
|
+
import { tenantComplianceProfileEntity } from "./schema/profile-selection";
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
tenantComplianceProfileEntity,
|
|
11
|
+
tenantComplianceProfileTable,
|
|
12
|
+
} from "./schema/profile-selection";
|
|
13
|
+
|
|
14
|
+
// compliance-profiles — Tenant-weite DSGVO/Compliance-Profile-Wahl.
|
|
15
|
+
//
|
|
16
|
+
// Pflicht beim Tenant-Onboarding (Sprint 1.5 Banner-API). Profile
|
|
17
|
+
// buendelt User-Rights-Grace, Notification-Sprache, Breach-Disclosure,
|
|
18
|
+
// Audit-Retention und Sub-Processor-Anforderungen.
|
|
19
|
+
//
|
|
20
|
+
// Cross-Feature-API: r.exposesApi("compliance.forTenant") — andere
|
|
21
|
+
// Features (user-data-rights in Sprint 2, tenant-lifecycle in Sprint 5)
|
|
22
|
+
// rufen den Profile-Resolver via QN-Pattern (siehe legal-pages →
|
|
23
|
+
// text-content fuer Pattern-Beispiel).
|
|
24
|
+
//
|
|
25
|
+
// Architektur-Note: Profile-Selection lebt als separate Entity
|
|
26
|
+
// (tenantComplianceProfile), nicht als config-key im tenant-Feature.
|
|
27
|
+
// Begruendung in schema/profile-selection.ts.
|
|
28
|
+
export function createComplianceProfilesFeature(): FeatureDefinition {
|
|
29
|
+
return defineFeature("compliance-profiles", (r) => {
|
|
30
|
+
// Standalone — kein r.requires noetig: tenantId kommt aus dem User-
|
|
31
|
+
// Context, Profile-Selection ist eigene Entity, sub-processor-Liste
|
|
32
|
+
// sind Constants. Wenn S1.4+ Cross-Feature-Reads dazukommen, kommt
|
|
33
|
+
// r.requires hier rein.
|
|
34
|
+
r.entity("tenant-compliance-profile", tenantComplianceProfileEntity);
|
|
35
|
+
|
|
36
|
+
r.exposesApi("compliance.forTenant");
|
|
37
|
+
|
|
38
|
+
const handlers = {
|
|
39
|
+
setProfile: r.writeHandler(setProfileWrite),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const queries = {
|
|
43
|
+
forTenant: r.queryHandler(forTenantQuery),
|
|
44
|
+
listProfiles: r.queryHandler(listProfilesQuery),
|
|
45
|
+
subProcessors: r.queryHandler(subProcessorsQuery),
|
|
46
|
+
needsProfile: r.queryHandler(needsProfileQuery),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return { handlers, queries };
|
|
50
|
+
});
|
|
51
|
+
}
|