@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.2.3
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 +31 -0
- package/package.json +11 -5
- 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/login.write.ts +31 -1
- package/src/auth-email-password/i18n.ts +4 -0
- 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 +63 -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 +146 -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 +61 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- 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 +33 -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/file-foundation/feature.ts +43 -3
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +8 -10
- 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 +89 -0
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +16 -6
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- 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 +309 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +257 -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 +334 -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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
# @cosmicdrift/kumiko-bundled-features
|
|
2
2
|
|
|
3
|
+
## 0.2.3
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 1dbd038: Fix `db.execute is not a function` crash in `createTierEngineFeature`'s
|
|
8
|
+
auto-default-tier postSave-hook when called via the dispatcher path
|
|
9
|
+
(`tenant:write:create`). The hook used `ctx.db as DbConnection` — a
|
|
10
|
+
type-lie. AppContext.db in the inTransaction-phase is a TenantDb, which
|
|
11
|
+
exposes select/insert/update/delete but not execute(). The event-store-
|
|
12
|
+
append (event-store.ts:102) calls `db.execute(sql\`SELECT pg_notify(...)\`)`,
|
|
13
|
+
which crashed at runtime.
|
|
14
|
+
|
|
15
|
+
Fix: typeguard via `if (!("raw" in ctx.db)) return` then use `ctx.db.raw
|
|
16
|
+
as DbConnection` (pattern matched signup-confirm.write.ts:107).
|
|
17
|
+
|
|
18
|
+
Plus: regression integration-test in `tier-engine/__tests__/auto-default-
|
|
19
|
+
tier.integration.ts` covering the dispatcher path (sysadmin →
|
|
20
|
+
tenant:write:create → tier_assignments-row + idempotency on tenant-update).
|
|
21
|
+
|
|
22
|
+
**Known production gap (separate from this fix):** Self-Signup goes through
|
|
23
|
+
`provisionSignupAccount → seedTenant` (event-store-direct), which bypasses
|
|
24
|
+
the dispatcher → postSave-hooks never fire in production self-signup. This
|
|
25
|
+
fix makes the dispatcher path coherent. Real-signup auto-default needs
|
|
26
|
+
follow-up work (either seedTenant fires hooks or signup-confirm calls
|
|
27
|
+
explicit seed-helpers).
|
|
28
|
+
|
|
29
|
+
- @cosmicdrift/kumiko-framework@0.2.3
|
|
30
|
+
- @cosmicdrift/kumiko-dispatcher-live@0.2.3
|
|
31
|
+
- @cosmicdrift/kumiko-renderer@0.2.3
|
|
32
|
+
- @cosmicdrift/kumiko-renderer-web@0.2.3
|
|
33
|
+
|
|
3
34
|
## 0.2.2
|
|
4
35
|
|
|
5
36
|
### Patch Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -19,7 +19,9 @@
|
|
|
19
19
|
},
|
|
20
20
|
"exports": {
|
|
21
21
|
"./audit": "./src/audit/index.ts",
|
|
22
|
+
"./compliance-profiles": "./src/compliance-profiles/index.ts",
|
|
22
23
|
"./config": "./src/config/index.ts",
|
|
24
|
+
"./data-retention": "./src/data-retention/index.ts",
|
|
23
25
|
"./jobs": "./src/jobs/index.ts",
|
|
24
26
|
"./tier-engine": "./src/tier-engine/index.ts",
|
|
25
27
|
"./cap-counter": "./src/cap-counter/index.ts",
|
|
@@ -33,6 +35,9 @@
|
|
|
33
35
|
"./file-foundation": "./src/file-foundation/index.ts",
|
|
34
36
|
"./file-provider-s3": "./src/file-provider-s3/index.ts",
|
|
35
37
|
"./file-provider-inmemory": "./src/file-provider-inmemory/index.ts",
|
|
38
|
+
"./files": "./src/files/index.ts",
|
|
39
|
+
"./user-data-rights": "./src/user-data-rights/index.ts",
|
|
40
|
+
"./user-data-rights-defaults": "./src/user-data-rights-defaults/index.ts",
|
|
36
41
|
"./tenant": "./src/tenant/index.ts",
|
|
37
42
|
"./tenant/constants": "./src/tenant/constants.ts",
|
|
38
43
|
"./tenant/seeding": "./src/tenant/seeding.ts",
|
|
@@ -62,11 +67,12 @@
|
|
|
62
67
|
},
|
|
63
68
|
"dependencies": {
|
|
64
69
|
"@aws-sdk/client-s3": "^3.700.0",
|
|
70
|
+
"@aws-sdk/lib-storage": "^3.700.0",
|
|
65
71
|
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
|
66
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.2.
|
|
67
|
-
"@cosmicdrift/kumiko-framework": "0.2.
|
|
68
|
-
"@cosmicdrift/kumiko-renderer": "0.2.
|
|
69
|
-
"@cosmicdrift/kumiko-renderer-web": "0.2.
|
|
72
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.2.3",
|
|
73
|
+
"@cosmicdrift/kumiko-framework": "0.2.3",
|
|
74
|
+
"@cosmicdrift/kumiko-renderer": "0.2.3",
|
|
75
|
+
"@cosmicdrift/kumiko-renderer-web": "0.2.3",
|
|
70
76
|
"@mollie/api-client": "^4.5.0",
|
|
71
77
|
"@node-rs/argon2": "^2.0.2",
|
|
72
78
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -21,6 +21,12 @@ export type AuthUserRow = {
|
|
|
21
21
|
// roles gelten (z.B. SystemAdmin, BillingAdmin). Caller deserialisiert via
|
|
22
22
|
// parseRoles() vor dem Merge in die Session.
|
|
23
23
|
readonly roles?: string | null;
|
|
24
|
+
// user.status (S2.U1) — "active" | "restricted" | "deletion_requested" |
|
|
25
|
+
// "deleted". Login.write.ts blockt Restricted (DSGVO Art. 18) sowie
|
|
26
|
+
// DeletionRequested + Deleted. Untyped string hier weil die Quelle
|
|
27
|
+
// user-feature-internes Enum ist; auth importiert den Constants-Wert
|
|
28
|
+
// an der Verwendungsstelle.
|
|
29
|
+
readonly status?: string | null;
|
|
24
30
|
};
|
|
25
31
|
|
|
26
32
|
// Returns the narrowed row or null — mirrors findForAuth's contract where
|
|
@@ -73,6 +73,17 @@ export const AuthErrors = {
|
|
|
73
73
|
// deliberate enumeration trade-off: the lockout event itself is already
|
|
74
74
|
// observable to the attacker, and legit users benefit from a clear signal.
|
|
75
75
|
accountLocked: "account_locked",
|
|
76
|
+
// S2.U6 (DSGVO Art. 18) — Account ist im Restricted-Status. Login wird
|
|
77
|
+
// explicit verweigert mit eigenem Code (nicht zu invalid_credentials
|
|
78
|
+
// collapsen) damit UI sagen kann "Account ist aktuell pausiert, hier
|
|
79
|
+
// klicken zum Aufheben". Enumeration-leak akzeptiert: Restriction ist
|
|
80
|
+
// user-initiiert, der User weiss dass sein Konto restricted ist.
|
|
81
|
+
accountRestricted: "account_restricted",
|
|
82
|
+
// Account ist im DeletionRequested- oder Deleted-Status. Anders als
|
|
83
|
+
// Restricted ist das nicht reversibel via Login → wir collapsen auf
|
|
84
|
+
// invalid_credentials damit Forget-Pfad nicht via Login enumerierbar
|
|
85
|
+
// wird (User der "Konto loeschen" geklickt hat soll nicht erneut sehen
|
|
86
|
+
// dass die Email-Adresse noch in der DB existiert).
|
|
76
87
|
} as const;
|
|
77
88
|
|
|
78
89
|
// Account-lockout defaults — overridable via
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
import { UnprocessableError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
|
|
8
8
|
import { parseRoles } from "@cosmicdrift/kumiko-framework/utils";
|
|
9
9
|
import { z } from "zod";
|
|
10
|
-
import { UserQueries } from "../../user";
|
|
10
|
+
import { USER_STATUS, UserQueries } from "../../user";
|
|
11
11
|
import { parseAuthUserRow } from "../auth-user-row";
|
|
12
12
|
import {
|
|
13
13
|
AUTH_LOCKOUT_DEFAULT_DURATION_MINUTES,
|
|
@@ -53,6 +53,19 @@ function accountLocked(retryAfterSeconds: number) {
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// S2.U6 — DSGVO Art. 18 Account-Freeze. Distinct error code (nicht zu
|
|
57
|
+
// invalid_credentials collapsen) damit UI klar sagen kann "Account ist
|
|
58
|
+
// pausiert, hier klicken zum Aufheben". User weiss schon dass sein Konto
|
|
59
|
+
// restricted ist (er hat selbst die Restriction gesetzt), also kein
|
|
60
|
+
// Enumeration-Leak.
|
|
61
|
+
function accountRestricted() {
|
|
62
|
+
return writeFailure(
|
|
63
|
+
new UnprocessableError(AuthErrors.accountRestricted, {
|
|
64
|
+
i18nKey: "auth.errors.accountRestricted",
|
|
65
|
+
}),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
56
69
|
export type LoginHandlerOptions = {
|
|
57
70
|
// When true, a valid (email + password) login fails with email_not_verified
|
|
58
71
|
// if the user row's emailVerified flag is false. Enumeration-leak is
|
|
@@ -145,6 +158,23 @@ export function createLoginHandler(opts: LoginHandlerOptions = {}) {
|
|
|
145
158
|
return emailNotVerified();
|
|
146
159
|
}
|
|
147
160
|
|
|
161
|
+
// S2.U6 — DSGVO Art. 18 Account-Freeze. Restricted users koennen sich
|
|
162
|
+
// nicht einloggen; lift-restriction-Endpoint ist der einzige Ausgang
|
|
163
|
+
// (siehe lift-restriction.write.ts Header — typisch via Magic-Link
|
|
164
|
+
// oder Operator-Tool, da Login geblockt). Auth-side Block ist hard-
|
|
165
|
+
// requirement; ohne den koennte der User mit Login-Sessions trotz
|
|
166
|
+
// Restriction-Flag durchschreiben.
|
|
167
|
+
//
|
|
168
|
+
// DeletionRequested + Deleted kollabieren bewusst auf invalid_creds
|
|
169
|
+
// (anti-enumeration im Forget-Pfad) — Restricted ist user-initiiert,
|
|
170
|
+
// distinct error ist hier safe.
|
|
171
|
+
if (found.status === USER_STATUS.Restricted) {
|
|
172
|
+
return accountRestricted();
|
|
173
|
+
}
|
|
174
|
+
if (found.status === USER_STATUS.DeletionRequested || found.status === USER_STATUS.Deleted) {
|
|
175
|
+
return invalidCredentials();
|
|
176
|
+
}
|
|
177
|
+
|
|
148
178
|
// Resolve tenant + roles via the tenant feature's memberships query.
|
|
149
179
|
// Returns [] if the user has no memberships — MVP: no login without an
|
|
150
180
|
// invitation, so we refuse with a dedicated error.
|
|
@@ -23,6 +23,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
23
23
|
"auth.errors.accountLocked": "Konto vorübergehend gesperrt.",
|
|
24
24
|
"auth.errors.accountLockedRetry": "Konto gesperrt. Neuer Versuch in {minutes} Minuten.",
|
|
25
25
|
"auth.errors.emailNotVerified": "E-Mail-Adresse noch nicht bestätigt.",
|
|
26
|
+
"auth.errors.accountRestricted":
|
|
27
|
+
"Konto pausiert (Datenschutz Art. 18). Bitte Pause aufheben um wieder einzuloggen.",
|
|
26
28
|
"auth.errors.rateLimited": "Zu viele Login-Versuche. Bitte kurz warten.",
|
|
27
29
|
"auth.errors.invalidBody": "Ungültige Eingabe.",
|
|
28
30
|
"auth.errors.loginFailed": "Login fehlgeschlagen.",
|
|
@@ -116,6 +118,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
116
118
|
"auth.errors.accountLocked": "Account temporarily locked.",
|
|
117
119
|
"auth.errors.accountLockedRetry": "Account locked. Try again in {minutes} minutes.",
|
|
118
120
|
"auth.errors.emailNotVerified": "Email address not yet verified.",
|
|
121
|
+
"auth.errors.accountRestricted":
|
|
122
|
+
"Account paused (GDPR Art. 18). Please lift the restriction to sign in again.",
|
|
119
123
|
"auth.errors.rateLimited": "Too many login attempts. Please wait briefly.",
|
|
120
124
|
"auth.errors.invalidBody": "Invalid input.",
|
|
121
125
|
"auth.errors.loginFailed": "Login failed.",
|
|
@@ -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
|
+
});
|