@cosmicdrift/kumiko-framework 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 +2 -0
- package/package.json +3 -2
- package/src/auth/__tests__/roles.test.ts +24 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/roles.ts +42 -0
- package/src/compliance/__tests__/duration-spec.test.ts +72 -0
- package/src/compliance/__tests__/profiles.test.ts +308 -0
- package/src/compliance/__tests__/sub-processors.test.ts +139 -0
- package/src/compliance/duration-spec.ts +44 -0
- package/src/compliance/index.ts +31 -0
- package/src/compliance/override-schema.ts +136 -0
- package/src/compliance/profiles.ts +427 -0
- package/src/compliance/sub-processors.ts +152 -0
- package/src/db/__tests__/big-int-field.test.ts +131 -0
- package/src/db/table-builder.ts +18 -1
- package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
- package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
- package/src/engine/boot-validator.ts +276 -0
- package/src/engine/define-feature.ts +39 -0
- package/src/engine/extension-names.ts +105 -0
- package/src/engine/extensions/user-data.ts +106 -0
- package/src/engine/factories.ts +15 -5
- package/src/engine/feature-ast/extractors.ts +40 -0
- package/src/engine/feature-ast/parse.ts +6 -0
- package/src/engine/feature-ast/patterns.ts +22 -0
- package/src/engine/feature-ast/render.ts +14 -0
- package/src/engine/index.ts +21 -0
- package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
- package/src/engine/pattern-library/library.ts +36 -0
- package/src/engine/schema-builder.ts +8 -0
- package/src/engine/types/feature.ts +51 -0
- package/src/engine/types/fields.ts +134 -10
- package/src/engine/types/index.ts +3 -0
- package/src/files/__tests__/read-stream.test.ts +105 -0
- package/src/files/__tests__/write-stream.test.ts +233 -0
- package/src/files/__tests__/zip-stream.test.ts +357 -0
- package/src/files/in-memory-provider.ts +38 -0
- package/src/files/index.ts +3 -0
- package/src/files/local-provider.ts +58 -1
- package/src/files/types.ts +34 -6
- package/src/files/zip-stream.ts +251 -0
package/CHANGELOG.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.2.
|
|
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": "0.2.
|
|
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",
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// DurationSpec ↔ SQL-Interval ↔ Temporal.Duration converter.
|
|
2
|
+
//
|
|
3
|
+
// Hintergrund: ComplianceProfile.userRights.gracePeriod ist
|
|
4
|
+
// `{ days: number } | { hours: number }` (Discriminated Union). Caller
|
|
5
|
+
// die das in eine Postgres-`interval`-SQL einsetzen brauchen einen
|
|
6
|
+
// einzigen vertrauenswuerdigen Punkt, sonst springt ein
|
|
7
|
+
// `{ hours: 6 }`-Override stillschweigend auf einen days-Default.
|
|
8
|
+
//
|
|
9
|
+
// Single source of truth: hier. Andere Spec-Forms (months/years) im
|
|
10
|
+
// retention-Pfad gehen ueber den breiteren `keep-for`-Parser, sind hier
|
|
11
|
+
// bewusst nicht abgedeckt — das engt den Type ein und macht die SQL-
|
|
12
|
+
// Renderung total.
|
|
13
|
+
|
|
14
|
+
import { getTemporal } from "../time";
|
|
15
|
+
import type { DurationSpec } from "./profiles";
|
|
16
|
+
|
|
17
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
18
|
+
|
|
19
|
+
// Spec in Millisekunden. Source of truth fuer alle Konvertierungen
|
|
20
|
+
// (Instant-add, optional fuer JS-Datumsrechnung).
|
|
21
|
+
export function durationSpecToMs(spec: DurationSpec): number {
|
|
22
|
+
if ("days" in spec) return spec.days * 24 * 60 * 60 * 1000;
|
|
23
|
+
return spec.hours * 60 * 60 * 1000;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Frist berechnen ohne DB-now() — der App-Server-Clock ist
|
|
27
|
+
// authoritative. Fuer Forget-Grace, Token-TTLs und Frist-Setzungen
|
|
28
|
+
// where eine Toleranz von wenigen ms zwischen App und DB irrelevant
|
|
29
|
+
// ist (Grace-Periods >= 6h, Tokens >= Minuten).
|
|
30
|
+
//
|
|
31
|
+
// Schreibt direkt in `instant()`-customType-Spalten — kein interval-
|
|
32
|
+
// SQL-Fragment, kein Codec-Bypass.
|
|
33
|
+
export function addDurationSpec(now: Instant, spec: DurationSpec): Instant {
|
|
34
|
+
return getTemporal().Instant.fromEpochMilliseconds(
|
|
35
|
+
now.epochMilliseconds + durationSpecToMs(spec),
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Lesbare Beschreibung fuer Logs / Error-Messages. Nicht i18n —
|
|
40
|
+
// English-only-Operator-Output.
|
|
41
|
+
export function describeDurationSpec(spec: DurationSpec): string {
|
|
42
|
+
if ("days" in spec) return `${spec.days} day${spec.days === 1 ? "" : "s"}`;
|
|
43
|
+
return `${spec.hours} hour${spec.hours === 1 ? "" : "s"}`;
|
|
44
|
+
}
|