@cosmicdrift/kumiko-framework 0.2.1 → 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.
Files changed (42) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +4 -3
  3. package/src/auth/__tests__/roles.test.ts +24 -0
  4. package/src/auth/index.ts +7 -0
  5. package/src/auth/roles.ts +42 -0
  6. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  7. package/src/compliance/__tests__/profiles.test.ts +308 -0
  8. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  9. package/src/compliance/duration-spec.ts +44 -0
  10. package/src/compliance/index.ts +31 -0
  11. package/src/compliance/override-schema.ts +136 -0
  12. package/src/compliance/profiles.ts +427 -0
  13. package/src/compliance/sub-processors.ts +152 -0
  14. package/src/db/__tests__/big-int-field.test.ts +131 -0
  15. package/src/db/table-builder.ts +18 -1
  16. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  17. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  18. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  19. package/src/engine/boot-validator.ts +276 -0
  20. package/src/engine/define-feature.ts +39 -0
  21. package/src/engine/extension-names.ts +105 -0
  22. package/src/engine/extensions/user-data.ts +106 -0
  23. package/src/engine/factories.ts +15 -5
  24. package/src/engine/feature-ast/extractors.ts +40 -0
  25. package/src/engine/feature-ast/parse.ts +6 -0
  26. package/src/engine/feature-ast/patterns.ts +22 -0
  27. package/src/engine/feature-ast/render.ts +14 -0
  28. package/src/engine/index.ts +21 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
  30. package/src/engine/pattern-library/library.ts +36 -0
  31. package/src/engine/schema-builder.ts +8 -0
  32. package/src/engine/types/feature.ts +51 -0
  33. package/src/engine/types/fields.ts +134 -10
  34. package/src/engine/types/index.ts +3 -0
  35. package/src/files/__tests__/read-stream.test.ts +105 -0
  36. package/src/files/__tests__/write-stream.test.ts +233 -0
  37. package/src/files/__tests__/zip-stream.test.ts +357 -0
  38. package/src/files/in-memory-provider.ts +38 -0
  39. package/src/files/index.ts +3 -0
  40. package/src/files/local-provider.ts +58 -1
  41. package/src/files/types.ts +34 -6
  42. package/src/files/zip-stream.ts +251 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,52 @@
1
+ # @cosmicdrift/kumiko-framework
2
+
3
+ ## 0.2.3
4
+
5
+ ## 0.2.2
6
+
7
+ ### Patch Changes
8
+
9
+ - 7a7da3e: Re-publish 0.2.1 → 0.2.2 mit korrekt aufgelösten cross-package-Versionen.
10
+ 0.2.1 hatte `workspace:*` als Wert in den dependencies (npm publish ohne
11
+ yarn-pack rewrite), Konsumenten bekamen "Workspace not found".
12
+
13
+ publish-with-oidc.sh nutzt jetzt `yarn pack` (rewrited workspace:\*) +
14
+ `npm publish <tarball>` (OIDC + provenance).
15
+
16
+ ## 0.2.1
17
+
18
+ ### Patch Changes
19
+
20
+ - 48b7f6a: CI: switch publish to npm-CLI with OIDC Trusted Publishing + provenance.
21
+ No source changes — verifies the new publish path produces a verified-
22
+ provenance attestation on npmjs.com instead of token-based publish.
23
+
24
+ ## 0.2.0
25
+
26
+ ### Minor Changes
27
+
28
+ - 6c70b6f: fix(tenant): seedTenant idempotent gegen Event-Store-Projection-Drift.
29
+
30
+ Verhindert version_conflict beim App-Boot wenn Aggregat existiert aber
31
+ Projection-Row fehlt (rebuild-drift, async-lag, manueller DB-Eingriff).
32
+
33
+ ## 0.1.0
34
+
35
+ ### Minor Changes
36
+
37
+ - 59ba6d7: Initial public release of Kumiko — AI-native backend builder.
38
+
39
+ What ships in 0.1.0:
40
+
41
+ - **Engine** (`@cosmicdrift/kumiko-framework`): `defineFeature`, `r.entity`, `r.writeHandler`, `r.queryHandler`, `r.projection`, `r.multiStreamProjection`, `r.hook`, `r.translations`, `r.crud`, `r.referenceData`, `r.screen`, `r.nav`, `r.authClaims`, full lifecycle pipeline with field-level access checks
42
+ - **Pipeline** (`@cosmicdrift/kumiko-framework`): `createDispatcher`, JWT auth via jose, Zod schema validation, role-based access checks, command/write/query split
43
+ - **DB** (`@cosmicdrift/kumiko-framework`): Drizzle helpers (`buildDrizzleTable`, `applyCursorQuery`), CRUD executor, Postgres dialect, optimistic locking, soft delete, multi-tenant scoping
44
+ - **Event sourcing** (`@cosmicdrift/kumiko-framework`): aggregate streams, single + multi-stream projections, event upcasters, asOf queries, archive support, AsyncDaemon-pattern dispatcher
45
+ - **Bundled features** (`@cosmicdrift/kumiko-bundled-features`): auth-email-password, sessions, tenants, users, jobs, secrets, file-provider-s3, mail-transport-smtp/inmemory, billing-foundation, cap-counter, channel-in-app, delivery, feature-toggles, legal-pages
46
+ - **Renderer** (`@cosmicdrift/kumiko-renderer`, `@cosmicdrift/kumiko-renderer-web`): schema-driven CRUD UI for React + Expo Web, override paths, list debounce, theme tokens
47
+ - **Headless** (`@cosmicdrift/kumiko-headless`): view-models for list/edit screens, locale-aware
48
+ - **Dev server** (`@cosmicdrift/kumiko-dev-server`): `runDevApp`, `runProdApp`, `kumiko-build` for production bundles (client + server), Docker-ready
49
+ - **Realtime** (`@cosmicdrift/kumiko-dispatcher-live`): SSE broadcast across tenants, Redis Pub/Sub backend
50
+ - **CLI** (`bin/kumiko.ts`): interactive dev menu, test runners, check pipeline (Biome + TypeScript + 18 guards + Vitest)
51
+
52
+ This is a pre-1.0 release — APIs may change between minor versions. Breaking changes will be documented per release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -28,6 +28,7 @@
28
28
  "runtime": "runtime"
29
29
  },
30
30
  "exports": {
31
+ "./compliance": "./src/compliance/index.ts",
31
32
  "./engine": "./src/engine/index.ts",
32
33
  "./engine/types": "./src/engine/types/index.ts",
33
34
  "./errors": "./src/errors/index.ts",
@@ -73,7 +74,7 @@
73
74
  "zod": "^4.3.6"
74
75
  },
75
76
  "devDependencies": {
76
- "@cosmicdrift/kumiko-dispatcher-live": "workspace:*",
77
+ "@cosmicdrift/kumiko-dispatcher-live": "0.2.3",
77
78
  "@types/uuid": "^11.0.0",
78
79
  "bun-types": "^1.2.9",
79
80
  "drizzle-kit": "^0.31.0",
@@ -88,4 +89,4 @@
88
89
  "README.md",
89
90
  "LICENSE"
90
91
  ]
91
- }
92
+ }
@@ -0,0 +1,24 @@
1
+ // Snapshot-Tests fuer ROLES — faengt stille Drift ab.
2
+
3
+ import { describe, expect, test } from "vitest";
4
+ import { ROLES } from "../roles";
5
+
6
+ describe("ROLES constants", () => {
7
+ test("Snapshot — explizit zu updaten bei Aenderungen", () => {
8
+ expect(ROLES).toMatchInlineSnapshot(`
9
+ {
10
+ "DataProtectionOfficer": "DataProtectionOfficer",
11
+ "Member": "Member",
12
+ "SystemAdmin": "SystemAdmin",
13
+ "TenantAdmin": "TenantAdmin",
14
+ "TenantOwner": "TenantOwner",
15
+ }
16
+ `);
17
+ });
18
+
19
+ test("ROLES-Werte sind identisch zu den Keys (keine Drift im Mapping)", () => {
20
+ for (const [key, value] of Object.entries(ROLES)) {
21
+ expect(value).toBe(key);
22
+ }
23
+ });
24
+ });
@@ -0,0 +1,7 @@
1
+ // `@cosmicdrift/kumiko-framework/auth` — Auth-Foundation (Roles, Token-
2
+ // Helpers, Session-Types). Wird von Bundled-Features (auth-email-password,
3
+ // tenant, user) und Datenschutz-Sprints (S1+ user-data-rights, S5 tenant-
4
+ // lifecycle, S6 legal-hold) genutzt.
5
+
6
+ export type { Role } from "./roles";
7
+ export { ROLES } from "./roles";
@@ -0,0 +1,42 @@
1
+ // Zentrale Role-Konstanten der Plattform.
2
+ //
3
+ // Hintergrund: Bundled-Features driften zwischen "Admin" (text-content,
4
+ // secrets, ai-foundation, file-provider-s3) und "TenantAdmin" (tenant-
5
+ // handler, publicstatus, platform). App-Builder fallen in diese Falle.
6
+ // Diese Datei ist die Single Source of Truth — Bundled-Features
7
+ // migrieren schrittweise auf die ROLES-Constants in den Sprint-
8
+ // Touchpoints, an denen sie ohnehin angefasst werden.
9
+ //
10
+ // Canonical-Names:
11
+ // TenantOwner — Volle Tenant-Hoheit, einzige Rolle die
12
+ // Tenant-Destroy triggern darf.
13
+ // TenantAdmin — Tenant-Konfiguration + User-Management.
14
+ // Bestehender String "Admin" wird hierauf
15
+ // migriert.
16
+ // DataProtectionOfficer — DPO; setzt Legal-Holds, sieht Authority-
17
+ // Audit-Stream auch im silentMode (Sprint 6).
18
+ // SystemAdmin — Plattform-Operator (NICHT Tenant-scoped).
19
+ // Authority-Export, KMS-Recovery, Cross-
20
+ // Tenant-Setup. Matched den bestehenden
21
+ // "SystemAdmin"-String in text-content,
22
+ // secrets, auth-email-password, etc.
23
+ // Member — Standard-Mitglied eines Tenants ohne
24
+ // Admin-Rechte.
25
+
26
+ /**
27
+ * Plattform-weit standardisierte Role-Namen. Alle Datenschutz-Sprints
28
+ * (1+) nutzen ausschliesslich diese Constants statt String-Literale.
29
+ */
30
+ export const ROLES = {
31
+ TenantOwner: "TenantOwner",
32
+ TenantAdmin: "TenantAdmin",
33
+ DataProtectionOfficer: "DataProtectionOfficer",
34
+ SystemAdmin: "SystemAdmin",
35
+ Member: "Member",
36
+ } as const;
37
+
38
+ /**
39
+ * Type-Union aller bekannten Role-Namen (Compile-Time-Check fuer
40
+ * Handler-Access-Rules + Ownership-Maps).
41
+ */
42
+ export type Role = (typeof ROLES)[keyof typeof ROLES];
@@ -0,0 +1,72 @@
1
+ // Unit-Tests fuer DurationSpec-Helpers (S2.U5a.fix2).
2
+ //
3
+ // Pinst beide Discriminated-Union-Branches (`{days}` + `{hours}`) plus
4
+ // Edge-Cases die in den Integration-Tests nicht auftauchen (0-Werte,
5
+ // Singular/Plural). Der Bug aus dem U5a-Review (`{hours: 6}` fiel auf
6
+ // 30d-Default) wird hier zentral verhindert.
7
+
8
+ import { ensureTemporalPolyfill, getTemporal } from "@cosmicdrift/kumiko-framework/time";
9
+ import { beforeAll, describe, expect, test } from "vitest";
10
+ import { addDurationSpec, describeDurationSpec, durationSpecToMs } from "../duration-spec";
11
+
12
+ beforeAll(async () => {
13
+ await ensureTemporalPolyfill();
14
+ });
15
+
16
+ describe("durationSpecToMs", () => {
17
+ test("days → days * 86_400_000", () => {
18
+ expect(durationSpecToMs({ days: 30 })).toBe(30 * 24 * 60 * 60 * 1000);
19
+ expect(durationSpecToMs({ days: 1 })).toBe(24 * 60 * 60 * 1000);
20
+ });
21
+
22
+ test("hours → hours * 3_600_000", () => {
23
+ expect(durationSpecToMs({ hours: 6 })).toBe(6 * 60 * 60 * 1000);
24
+ expect(durationSpecToMs({ hours: 72 })).toBe(72 * 60 * 60 * 1000);
25
+ });
26
+
27
+ test("0-Werte ergeben 0", () => {
28
+ expect(durationSpecToMs({ days: 0 })).toBe(0);
29
+ expect(durationSpecToMs({ hours: 0 })).toBe(0);
30
+ });
31
+ });
32
+
33
+ describe("addDurationSpec", () => {
34
+ test("days addiert exakt zu Instant.epochMilliseconds", () => {
35
+ const T = getTemporal();
36
+ const t0 = T.Instant.fromEpochMilliseconds(1_700_000_000_000);
37
+ const t1 = addDurationSpec(t0, { days: 30 });
38
+ expect(t1.epochMilliseconds - t0.epochMilliseconds).toBe(30 * 24 * 60 * 60 * 1000);
39
+ });
40
+
41
+ test("hours addiert exakt zu Instant.epochMilliseconds", () => {
42
+ const T = getTemporal();
43
+ const t0 = T.Instant.fromEpochMilliseconds(1_700_000_000_000);
44
+ const t1 = addDurationSpec(t0, { hours: 6 });
45
+ expect(t1.epochMilliseconds - t0.epochMilliseconds).toBe(6 * 60 * 60 * 1000);
46
+ });
47
+
48
+ // Regression-Guard fuer den U5a-Bug: vorher fiel `{hours: 6}` auf
49
+ // `30 * 86_400_000`-Default zurueck. Wenn jemand den Branch wieder
50
+ // verliert, faellt dieser Test sofort um.
51
+ test("hours-Branch ist NICHT auf days-Default mappable (U5a-Regression)", () => {
52
+ const T = getTemporal();
53
+ const t0 = T.Instant.fromEpochMilliseconds(1_700_000_000_000);
54
+ const tHours = addDurationSpec(t0, { hours: 6 });
55
+ const tDaysDefault = addDurationSpec(t0, { days: 30 });
56
+ expect(tHours.epochMilliseconds).not.toBe(tDaysDefault.epochMilliseconds);
57
+ });
58
+ });
59
+
60
+ describe("describeDurationSpec", () => {
61
+ test("days mit Pluralisierung", () => {
62
+ expect(describeDurationSpec({ days: 30 })).toBe("30 days");
63
+ expect(describeDurationSpec({ days: 1 })).toBe("1 day");
64
+ expect(describeDurationSpec({ days: 0 })).toBe("0 days");
65
+ });
66
+
67
+ test("hours mit Pluralisierung", () => {
68
+ expect(describeDurationSpec({ hours: 72 })).toBe("72 hours");
69
+ expect(describeDurationSpec({ hours: 1 })).toBe("1 hour");
70
+ expect(describeDurationSpec({ hours: 0 })).toBe("0 hours");
71
+ });
72
+ });
@@ -0,0 +1,308 @@
1
+ // Tests fuer Compliance-Profile-Constants + extends-Resolver.
2
+ //
3
+ // Edge-Cases (advisor-pinned 2026-05-06):
4
+ // 1. Default-Fallback bei selection=undefined → minimal-no-region + warning
5
+ // 2. extends-Chain max 1 Level (Cycle-/Tiefe-Schutz)
6
+ // 3. Deep-merge-Semantik beim Override (rekursiv, Top-Level-Replace nicht)
7
+ // 4. Override darf einzelne Felder gezielt setzen ohne Required-Drops
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { complianceProfileOverrideSchema } from "../override-schema";
11
+ import {
12
+ COMPLIANCE_PROFILES,
13
+ OVERRIDABLE_PROFILE_KEYS,
14
+ resolveComplianceProfile,
15
+ SELECTABLE_PROFILE_KEYS,
16
+ } from "../profiles";
17
+
18
+ // Identifikations-Keys die ein Override NICHT modifizieren darf — sie
19
+ // definieren die Profile-Identität. Werden vom Drift-Test ausgeschlossen.
20
+ const IDENTIFICATION_KEYS = new Set(["key", "region", "label", "extends"]);
21
+
22
+ describe("COMPLIANCE_PROFILES — Pre-baked", () => {
23
+ test("eu-dsgvo hat alle Required-Felder", () => {
24
+ const p = COMPLIANCE_PROFILES["eu-dsgvo"];
25
+ expect(p.key).toBe("eu-dsgvo");
26
+ expect(p.region).toBe("EU");
27
+ expect(p.userRights.gracePeriod).toEqual({ days: 30 });
28
+ expect(p.notifications.languages).toContain("de");
29
+ expect(p.breach.authorityContact).toBe("BlnBDI Berlin");
30
+ expect(p.tenantDestroyGracePeriod).toEqual({ days: 30 });
31
+ });
32
+
33
+ test("swiss-dsg extends eu-dsgvo — übernimmt Base-Felder", () => {
34
+ const p = COMPLIANCE_PROFILES["swiss-dsg"];
35
+ expect(p.key).toBe("swiss-dsg");
36
+ expect(p.region).toBe("CH");
37
+ // Override: andere Sprachen + andere Aufsicht
38
+ expect(p.notifications.languages).toEqual(["de", "fr", "it", "en"]);
39
+ expect(p.breach.authorityContact).toBe("EDÖB Bern");
40
+ // Geerbt von eu-dsgvo
41
+ expect(p.userRights.gracePeriod).toEqual({ days: 30 });
42
+ expect(p.userRights.restrictionAllowed).toBe(true);
43
+ expect(p.tenantDestroyGracePeriod).toEqual({ days: 30 });
44
+ });
45
+
46
+ test("de-hr-dsgvo-hgb extends eu-dsgvo — HR-Override greift, Base bleibt", () => {
47
+ const p = COMPLIANCE_PROFILES["de-hr-dsgvo-hgb"];
48
+ expect(p.region).toBe("DE");
49
+ expect(p.userRights.employeeAccessRight).toBe(true);
50
+ expect(p.breach.worksCouncilNotificationRequired).toBe(true);
51
+ expect(p.subProcessor.worksCouncilApprovalRequired).toBe(true);
52
+ expect(p.auditLog.retention).toEqual({ years: 10 });
53
+ expect(p.tenantDestroyGracePeriod).toEqual({ days: 60 });
54
+ // Geerbt: portabilityFormat aus eu-dsgvo bleibt
55
+ expect(p.userRights.portabilityFormat).toEqual(["json"]);
56
+ });
57
+
58
+ test("minimal-no-region ist eigenständig (kein extends)", () => {
59
+ const p = COMPLIANCE_PROFILES["minimal-no-region"];
60
+ expect(p.region).toBe("—");
61
+ expect(p.notifications.mandatoryBreachNotification).toBe(false);
62
+ expect(p.breach.authorityNotificationDeadline).toBe("manual");
63
+ });
64
+ });
65
+
66
+ describe("SELECTABLE_PROFILE_KEYS", () => {
67
+ test('enthält 3 Profile, ohne "minimal-no-region" (kein Production-Default)', () => {
68
+ expect(SELECTABLE_PROFILE_KEYS).toEqual(["eu-dsgvo", "swiss-dsg", "de-hr-dsgvo-hgb"]);
69
+ expect(SELECTABLE_PROFILE_KEYS).not.toContain("minimal-no-region");
70
+ });
71
+ });
72
+
73
+ describe("OVERRIDABLE_PROFILE_KEYS — Drift-Guard (S1.8 N2)", () => {
74
+ // Wenn ein neues Top-Level-Property zu ComplianceProfile kommt
75
+ // (oder eines wegfaellt), muss OVERRIDABLE_PROFILE_KEYS synchron
76
+ // gehalten werden — sonst lehnt set-profile valide Overrides ab
77
+ // ODER akzeptiert ungueltige. Dieser Test detektiert den Drift in
78
+ // beide Richtungen anhand des voll aufgeloesten eu-dsgvo-Profile.
79
+ test("Override-Whitelist matched die uebrigen Top-Level-Keys von eu-dsgvo (minus Identifikations-Felder)", () => {
80
+ const allTopLevelKeys = new Set(Object.keys(COMPLIANCE_PROFILES["eu-dsgvo"]));
81
+ for (const idKey of IDENTIFICATION_KEYS) {
82
+ allTopLevelKeys.delete(idKey);
83
+ }
84
+ const expected = [...allTopLevelKeys].sort();
85
+ const actual = [...OVERRIDABLE_PROFILE_KEYS].sort();
86
+ expect(actual).toEqual(expected);
87
+ });
88
+
89
+ test("Identifikations-Keys (key, region, label, extends) sind NICHT in OVERRIDABLE_PROFILE_KEYS", () => {
90
+ for (const idKey of IDENTIFICATION_KEYS) {
91
+ expect(OVERRIDABLE_PROFILE_KEYS.has(idKey)).toBe(false);
92
+ }
93
+ });
94
+ });
95
+
96
+ describe("complianceProfileOverrideSchema — Schema↔Profile-Konsistenz (S1.10 M2)", () => {
97
+ // Wenn Profile-Type erweitert wird (neues Sub-Property in userRights /
98
+ // breach / auditLog / etc.) ohne dass override-schema.ts mitgepflegt
99
+ // wird, fail dieser Test. Prinzip: ein voll aufgelöstes Profile ist
100
+ // ein triviales valides Override-Object — wenn Schema das ablehnt, ist
101
+ // entweder Schema oder Profile out of sync.
102
+ test("Schema akzeptiert ein vollständig aufgelöstes Profile als Override (Drift-Guard)", () => {
103
+ // Identifikations-Felder rausnehmen weil die nicht override-bar sind.
104
+ const fullProfile = COMPLIANCE_PROFILES["eu-dsgvo"];
105
+ const {
106
+ key: _key,
107
+ region: _region,
108
+ label: _label,
109
+ extends: _extends,
110
+ ...overridable
111
+ } = fullProfile;
112
+
113
+ const result = complianceProfileOverrideSchema.safeParse(overridable);
114
+ if (!result.success) {
115
+ throw new Error(
116
+ `Schema↔Profile-Drift: vollständig aufgelöstes eu-dsgvo wird vom Override-Schema abgelehnt. ` +
117
+ `Issue at "${result.error.issues[0]?.path.join(".")}": ${result.error.issues[0]?.message}. ` +
118
+ "Entweder ComplianceProfile-Type erweitert ohne Schema-Update, oder Schema strikter als Profile-Type.",
119
+ );
120
+ }
121
+ expect(result.success).toBe(true);
122
+ });
123
+
124
+ test("Schema akzeptiert auch swiss-dsg + de-hr-dsgvo-hgb (extends-Profile mit Override-Feldern)", () => {
125
+ for (const key of ["swiss-dsg", "de-hr-dsgvo-hgb"] as const) {
126
+ const fullProfile = COMPLIANCE_PROFILES[key];
127
+ const {
128
+ key: _key,
129
+ region: _region,
130
+ label: _label,
131
+ extends: _extends,
132
+ ...overridable
133
+ } = fullProfile;
134
+ const result = complianceProfileOverrideSchema.safeParse(overridable);
135
+ expect(result.success, `Schema lehnt voll aufgelöstes ${key} ab`).toBe(true);
136
+ }
137
+ });
138
+ });
139
+
140
+ describe("resolveComplianceProfile — Default-Fallback", () => {
141
+ test("selection=undefined → minimal-no-region + warning=no-profile-selected", () => {
142
+ const result = resolveComplianceProfile({});
143
+ expect(result.profile.key).toBe("minimal-no-region");
144
+ expect(result.warning).toBe("no-profile-selected");
145
+ });
146
+
147
+ test("selection=minimal-no-region (DB-Edge-Case) → kein warning, fallback aktiv", () => {
148
+ // Sprint 1.7 X1: minimal-no-region ist ueber set-profile nicht mehr
149
+ // setzbar. Wer den State trotzdem in der DB hat (Migration, Direct-
150
+ // Insert) bekommt das minimal-Profile zurueck — der needs-profile-
151
+ // Banner-Endpoint markiert ihn separat als "needsSelection=true".
152
+ // Resolver selber zieht keine warning, weil "explicit selection".
153
+ const result = resolveComplianceProfile({ selection: "minimal-no-region" });
154
+ expect(result.profile.key).toBe("minimal-no-region");
155
+ expect(result.warning).toBeUndefined();
156
+ });
157
+
158
+ test("selection=eu-dsgvo + kein override → keine warning", () => {
159
+ const result = resolveComplianceProfile({ selection: "eu-dsgvo" });
160
+ expect(result.profile.key).toBe("eu-dsgvo");
161
+ expect(result.warning).toBeUndefined();
162
+ });
163
+ });
164
+
165
+ describe("resolveComplianceProfile — Override deep-merge", () => {
166
+ test("Override gracePeriod.days überschreibt rekursiv, andere userRights bleiben", () => {
167
+ const result = resolveComplianceProfile({
168
+ selection: "eu-dsgvo",
169
+ override: {
170
+ userRights: { gracePeriod: { days: 60 } },
171
+ },
172
+ });
173
+ expect(result.profile.userRights.gracePeriod).toEqual({ days: 60 });
174
+ // Andere userRights-Felder unverändert
175
+ expect(result.profile.userRights.restrictionAllowed).toBe(true);
176
+ expect(result.profile.userRights.objectionAllowed).toBe(true);
177
+ expect(result.profile.userRights.portabilityFormat).toEqual(["json"]);
178
+ expect(result.profile.userRights.auskunftFrist).toEqual({ days: 30 });
179
+ });
180
+
181
+ test("Override breach.authorityContact ändert nur diesen einen Pfad", () => {
182
+ const result = resolveComplianceProfile({
183
+ selection: "eu-dsgvo",
184
+ override: {
185
+ breach: { authorityContact: "Hamburgischer DSB" },
186
+ },
187
+ });
188
+ expect(result.profile.breach.authorityContact).toBe("Hamburgischer DSB");
189
+ // Rest des breach-Objects unverändert
190
+ expect(result.profile.breach.authorityNotificationDeadline).toEqual({ hours: 72 });
191
+ expect(result.profile.breach.userNotificationRequired).toBe("if-high-risk");
192
+ });
193
+
194
+ test("Override mehrerer Top-Level-Pfade gleichzeitig", () => {
195
+ const result = resolveComplianceProfile({
196
+ selection: "swiss-dsg",
197
+ override: {
198
+ userRights: { gracePeriod: { days: 45 } },
199
+ tenantDestroyGracePeriod: { days: 90 },
200
+ },
201
+ });
202
+ expect(result.profile.userRights.gracePeriod).toEqual({ days: 45 });
203
+ expect(result.profile.tenantDestroyGracePeriod).toEqual({ days: 90 });
204
+ // swiss-dsg extends eu-dsgvo war drin
205
+ expect(result.profile.region).toBe("CH");
206
+ expect(result.profile.breach.authorityContact).toBe("EDÖB Bern");
207
+ });
208
+
209
+ test("Atomic path: retention {months} → {years} ersetzt komplett (kein object-merge)", () => {
210
+ // Bug-Regression: deepMerge auf Diskriminierten-Union-Objects
211
+ // wuerde sonst { months: 24, years: 10 } produzieren — semantisch
212
+ // Nonsense, retention ist EINE Wahl.
213
+ const result = resolveComplianceProfile({
214
+ selection: "eu-dsgvo",
215
+ override: {
216
+ auditLog: { retention: { years: 10 } },
217
+ },
218
+ });
219
+ expect(result.profile.auditLog.retention).toEqual({ years: 10 });
220
+ // reportFrequency darf NICHT mit ersetzt werden — auditLog ist
221
+ // nicht atomic, nur retention darunter.
222
+ expect(result.profile.auditLog.reportFrequency).toBe("quarterly");
223
+ });
224
+
225
+ test("Atomic path: gracePeriod {days} → {hours} ersetzt komplett", () => {
226
+ const result = resolveComplianceProfile({
227
+ selection: "eu-dsgvo",
228
+ override: {
229
+ userRights: { gracePeriod: { hours: 24 } },
230
+ },
231
+ });
232
+ expect(result.profile.userRights.gracePeriod).toEqual({ hours: 24 });
233
+ expect(result.profile.userRights.gracePeriod).not.toHaveProperty("days");
234
+ });
235
+
236
+ test("Array-Property im Override ersetzt komplett (Top-Level-Replace, kein concat)", () => {
237
+ const result = resolveComplianceProfile({
238
+ selection: "eu-dsgvo",
239
+ override: {
240
+ notifications: { languages: ["en"] },
241
+ },
242
+ });
243
+ // Replace, nicht append: ["de", "en"] wird zu ["en"]
244
+ expect(result.profile.notifications.languages).toEqual(["en"]);
245
+ // Andere notifications-Felder bleiben
246
+ expect(result.profile.notifications.mandatoryBreachNotification).toBe(true);
247
+ });
248
+ });
249
+
250
+ describe("Profile-Definition-Snapshots — fängt Drift ab", () => {
251
+ test("eu-dsgvo voll-resolved", () => {
252
+ expect(COMPLIANCE_PROFILES["eu-dsgvo"]).toMatchInlineSnapshot(`
253
+ {
254
+ "auditLog": {
255
+ "reportFrequency": "quarterly",
256
+ "retention": {
257
+ "months": 24,
258
+ },
259
+ },
260
+ "breach": {
261
+ "authorityContact": "BlnBDI Berlin",
262
+ "authorityNotificationDeadline": {
263
+ "hours": 72,
264
+ },
265
+ "userNotificationRequired": "if-high-risk",
266
+ },
267
+ "forgetDiscovery": {
268
+ "enabled": false,
269
+ },
270
+ "key": "eu-dsgvo",
271
+ "label": "EU — DSGVO Standard",
272
+ "notifications": {
273
+ "languages": [
274
+ "de",
275
+ "en",
276
+ ],
277
+ "mandatoryBreachNotification": true,
278
+ },
279
+ "region": "EU",
280
+ "subProcessor": {
281
+ "changeNotificationLeadDays": 30,
282
+ "consentRequired": false,
283
+ },
284
+ "tenantDestroyGracePeriod": {
285
+ "days": 30,
286
+ },
287
+ "userRights": {
288
+ "auskunftFrist": {
289
+ "days": 30,
290
+ },
291
+ "exportDownloadTtl": {
292
+ "days": 7,
293
+ },
294
+ "exportStaleTimeoutMinutes": 30,
295
+ "exportStorageCleanupGraceHours": 24,
296
+ "gracePeriod": {
297
+ "days": 30,
298
+ },
299
+ "objectionAllowed": true,
300
+ "portabilityFormat": [
301
+ "json",
302
+ ],
303
+ "restrictionAllowed": true,
304
+ },
305
+ }
306
+ `);
307
+ });
308
+ });
@@ -0,0 +1,139 @@
1
+ // Snapshot-Tests fuer KUMIKO_SUB_PROCESSORS. Fangen still-Drift ab —
2
+ // jede Aenderung an der Liste muss bewusst durch ein Snapshot-Update
3
+ // gehen, damit niemand versehentlich einen Sub-Processor entfernt
4
+ // oder hinzufuegt ohne dass die DPA/Tenant-Notification-Pipeline das
5
+ // mitbekommt.
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import {
9
+ getActiveSubProcessors,
10
+ getPlannedSubProcessors,
11
+ KUMIKO_SUB_PROCESSORS,
12
+ } from "../sub-processors";
13
+
14
+ describe("KUMIKO_SUB_PROCESSORS", () => {
15
+ test("Liste ist nicht leer (Plattform hat mindestens Hetzner+Cloudflare)", () => {
16
+ expect(KUMIKO_SUB_PROCESSORS.length).toBeGreaterThan(0);
17
+ });
18
+
19
+ test("Alle Eintraege haben Pflicht-Felder (name, purpose, region, dpa, addedAt, appliesTo)", () => {
20
+ for (const sp of KUMIKO_SUB_PROCESSORS) {
21
+ expect(sp.name).toBeTruthy();
22
+ expect(sp.purpose).toBeTruthy();
23
+ expect(sp.region).toBeTruthy();
24
+ expect(sp.dpa).toMatch(/^https?:\/\//);
25
+ expect(sp.addedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
26
+ expect(sp.appliesTo.length).toBeGreaterThan(0);
27
+ }
28
+ });
29
+
30
+ test("US-Sub-Processors haben sccRequired: true (DSGVO Art. 44+ Drittland)", () => {
31
+ const usProcessors = KUMIKO_SUB_PROCESSORS.filter(
32
+ (sp) => sp.region.includes("US") && !sp.region.startsWith("EU"),
33
+ );
34
+ for (const sp of usProcessors) {
35
+ expect(
36
+ sp.sccRequired,
37
+ `Sub-Processor "${sp.name}" with US-region should have sccRequired: true`,
38
+ ).toBe(true);
39
+ }
40
+ });
41
+
42
+ test("Snapshot — explizit zu updaten bei jeder Liste-Aenderung", () => {
43
+ const summary = KUMIKO_SUB_PROCESSORS.map((sp) => ({
44
+ name: sp.name,
45
+ region: sp.region,
46
+ status: sp.status ?? "active",
47
+ appliesTo: sp.appliesTo,
48
+ sccRequired: sp.sccRequired ?? false,
49
+ optInOnly: sp.optInOnly ?? false,
50
+ }));
51
+ expect(summary).toMatchInlineSnapshot(`
52
+ [
53
+ {
54
+ "appliesTo": [
55
+ "all-tiers",
56
+ ],
57
+ "name": "Hetzner Online GmbH",
58
+ "optInOnly": false,
59
+ "region": "EU (Germany)",
60
+ "sccRequired": false,
61
+ "status": "active",
62
+ },
63
+ {
64
+ "appliesTo": [
65
+ "all-tiers",
66
+ ],
67
+ "name": "Cloudflare, Inc.",
68
+ "optInOnly": false,
69
+ "region": "Global (US-headquartered)",
70
+ "sccRequired": true,
71
+ "status": "active",
72
+ },
73
+ {
74
+ "appliesTo": [
75
+ "standard",
76
+ "business",
77
+ "enterprise",
78
+ ],
79
+ "name": "Sendinblue SAS (Brevo)",
80
+ "optInOnly": false,
81
+ "region": "EU (France)",
82
+ "sccRequired": false,
83
+ "status": "active",
84
+ },
85
+ {
86
+ "appliesTo": [
87
+ "all-tiers",
88
+ ],
89
+ "name": "Heinlein Hosting (Mailbox.org)",
90
+ "optInOnly": false,
91
+ "region": "EU (Germany)",
92
+ "sccRequired": false,
93
+ "status": "active",
94
+ },
95
+ {
96
+ "appliesTo": [
97
+ "business",
98
+ "enterprise",
99
+ ],
100
+ "name": "Anthropic PBC",
101
+ "optInOnly": true,
102
+ "region": "US",
103
+ "sccRequired": true,
104
+ "status": "planned",
105
+ },
106
+ {
107
+ "appliesTo": [
108
+ "all-tiers",
109
+ ],
110
+ "name": "Stripe, Inc.",
111
+ "optInOnly": false,
112
+ "region": "Global (US-headquartered)",
113
+ "sccRequired": true,
114
+ "status": "planned",
115
+ },
116
+ ]
117
+ `);
118
+ });
119
+ });
120
+
121
+ describe("getActiveSubProcessors / getPlannedSubProcessors", () => {
122
+ test("active + planned partitionieren die Gesamt-Liste vollstaendig", () => {
123
+ const active = getActiveSubProcessors();
124
+ const planned = getPlannedSubProcessors();
125
+ expect(active.length + planned.length).toBe(KUMIKO_SUB_PROCESSORS.length);
126
+ });
127
+
128
+ test("kein Sub-Processor in active hat status=planned", () => {
129
+ for (const sp of getActiveSubProcessors()) {
130
+ expect(sp.status).not.toBe("planned");
131
+ }
132
+ });
133
+
134
+ test("alle planned-Eintraege haben status=planned", () => {
135
+ for (const sp of getPlannedSubProcessors()) {
136
+ expect(sp.status).toBe("planned");
137
+ }
138
+ });
139
+ });