@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
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ensureTemporalPolyfill, getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
2
|
+
import { beforeAll, describe, expect, test } from "vitest";
|
|
3
|
+
import { computeCutoff, InvalidKeepForError, isPastCutoff } from "../keep-for";
|
|
4
|
+
|
|
5
|
+
beforeAll(async () => {
|
|
6
|
+
await ensureTemporalPolyfill();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// Funktion statt const — Temporal ist erst nach beforeAll verfuegbar.
|
|
10
|
+
function now() {
|
|
11
|
+
return getTemporal().Instant.from("2026-05-07T12:00:00Z");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("computeCutoff", () => {
|
|
15
|
+
test("30d → 30 Tage zurück", () => {
|
|
16
|
+
const cutoff = computeCutoff("30d", now());
|
|
17
|
+
expect(cutoff.toString()).toBe("2026-04-07T12:00:00Z");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("24h → 24 Stunden zurück", () => {
|
|
21
|
+
const cutoff = computeCutoff("24h", now());
|
|
22
|
+
expect(cutoff.toString()).toBe("2026-05-06T12:00:00Z");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("1w → 7 Tage zurück", () => {
|
|
26
|
+
const cutoff = computeCutoff("1w", now());
|
|
27
|
+
expect(cutoff.toString()).toBe("2026-04-30T12:00:00Z");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("6m → 180 Tage zurück (Approximation)", () => {
|
|
31
|
+
const cutoff = computeCutoff("6m", now());
|
|
32
|
+
expect(cutoff.toString()).toBe("2025-11-08T12:00:00Z");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("10y → 3650 Tage zurück", () => {
|
|
36
|
+
const cutoff = computeCutoff("10y", now());
|
|
37
|
+
// Approximation: 10×365=3650 Tage. Differenz zu echtem Datum durch
|
|
38
|
+
// Schaltjahre: ~2-3 Tage. Akzeptabel für Retention-Cleanup.
|
|
39
|
+
expect(cutoff.toString()).toBe("2016-05-09T12:00:00Z");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("0d → now (Edge-Case, akzeptiert)", () => {
|
|
43
|
+
const cutoff = computeCutoff("0d", now());
|
|
44
|
+
expect(cutoff.toString()).toBe(now().toString());
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("invalid format wirft InvalidKeepForError", () => {
|
|
48
|
+
expect(() => computeCutoff("30days", now())).toThrow(InvalidKeepForError);
|
|
49
|
+
expect(() => computeCutoff("abc", now())).toThrow(InvalidKeepForError);
|
|
50
|
+
expect(() => computeCutoff("", now())).toThrow(InvalidKeepForError);
|
|
51
|
+
expect(() => computeCutoff("30", now())).toThrow(InvalidKeepForError);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("isPastCutoff", () => {
|
|
56
|
+
test("Row 31 Tage alt + keepFor 30d + now jetzt → past cutoff (true)", () => {
|
|
57
|
+
const past = now().subtract({ hours: 31 * 24 });
|
|
58
|
+
expect(isPastCutoff({ referenceTimestamp: past, keepFor: "30d", now: now() })).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Row 29 Tage alt + keepFor 30d → noch nicht abgelaufen (false)", () => {
|
|
62
|
+
const recent = now().subtract({ hours: 29 * 24 });
|
|
63
|
+
expect(isPastCutoff({ referenceTimestamp: recent, keepFor: "30d", now: now() })).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("Row exakt am Cutoff → false (strict less-than-Check)", () => {
|
|
67
|
+
const exact = now().subtract({ hours: 30 * 24 });
|
|
68
|
+
expect(isPastCutoff({ referenceTimestamp: exact, keepFor: "30d", now: now() })).toBe(false);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("Row 11 Jahre alt + keepFor 10y → past (Aufbewahrungspflicht abgelaufen)", () => {
|
|
72
|
+
const elevenYearsAgo = now().subtract({ hours: 11 * 365 * 24 });
|
|
73
|
+
expect(isPastCutoff({ referenceTimestamp: elevenYearsAgo, keepFor: "10y", now: now() })).toBe(
|
|
74
|
+
true,
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Tests fuer retentionOverrideSchema (S2.D2.5 M2+M3) — strict-Zod
|
|
2
|
+
// faengt Sub-Level-Tippfehler + Strategy-Enum-Drift + keepFor-Format-Drift.
|
|
3
|
+
|
|
4
|
+
import { describe, expect, test } from "vitest";
|
|
5
|
+
import { retentionOverrideSchema } from "../override-schema";
|
|
6
|
+
|
|
7
|
+
describe("retentionOverrideSchema — accept-Faelle", () => {
|
|
8
|
+
test("Empty Object ist valid (alle Felder optional, Resolver-Fallback)", () => {
|
|
9
|
+
const result = retentionOverrideSchema.safeParse({});
|
|
10
|
+
expect(result.success).toBe(true);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("Nur keepFor — ist valid", () => {
|
|
14
|
+
const result = retentionOverrideSchema.safeParse({ keepFor: "30d" });
|
|
15
|
+
expect(result.success).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test("keepFor + strategy + reference komplett", () => {
|
|
19
|
+
const result = retentionOverrideSchema.safeParse({
|
|
20
|
+
keepFor: "10y",
|
|
21
|
+
strategy: "blockDelete",
|
|
22
|
+
reference: "completedAt",
|
|
23
|
+
});
|
|
24
|
+
expect(result.success).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("Alle 4 strategy-Werte akzeptiert", () => {
|
|
28
|
+
for (const strategy of ["hardDelete", "softDelete", "anonymize", "blockDelete"]) {
|
|
29
|
+
const result = retentionOverrideSchema.safeParse({ strategy });
|
|
30
|
+
expect(result.success).toBe(true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("Verschiedene keepFor-Formate (h/d/w/m/y)", () => {
|
|
35
|
+
for (const keepFor of ["24h", "30d", "1w", "6m", "10y"]) {
|
|
36
|
+
const result = retentionOverrideSchema.safeParse({ keepFor });
|
|
37
|
+
expect(result.success).toBe(true);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("retentionOverrideSchema — reject-Faelle (Drift-Schutz)", () => {
|
|
43
|
+
test('M3: strategy: "delete" wird rejected (kein gueltiger Strategy-Wert)', () => {
|
|
44
|
+
const result = retentionOverrideSchema.safeParse({ strategy: "delete" });
|
|
45
|
+
expect(result.success).toBe(false);
|
|
46
|
+
if (!result.success) {
|
|
47
|
+
expect(result.error.issues[0]?.path).toEqual(["strategy"]);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('strategy: "anonymise" (UK-Spelling) rejected', () => {
|
|
52
|
+
const result = retentionOverrideSchema.safeParse({ strategy: "anonymise" });
|
|
53
|
+
expect(result.success).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("Top-Level-Tippfehler keepfor (lowercase) rejected via .strict()", () => {
|
|
57
|
+
const result = retentionOverrideSchema.safeParse({ keepfor: "30d" });
|
|
58
|
+
expect(result.success).toBe(false);
|
|
59
|
+
if (!result.success) {
|
|
60
|
+
// strict() reportet unrecognized_keys
|
|
61
|
+
expect(result.error.issues[0]?.code).toBe("unrecognized_keys");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('keepFor-Format-Drift "30days" rejected via regex', () => {
|
|
66
|
+
const result = retentionOverrideSchema.safeParse({ keepFor: "30days" });
|
|
67
|
+
expect(result.success).toBe(false);
|
|
68
|
+
if (!result.success) {
|
|
69
|
+
expect(result.error.issues[0]?.path).toEqual(["keepFor"]);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("keepFor leerer String rejected", () => {
|
|
74
|
+
const result = retentionOverrideSchema.safeParse({ keepFor: "" });
|
|
75
|
+
expect(result.success).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("keepFor nur Zahl ohne Suffix rejected", () => {
|
|
79
|
+
const result = retentionOverrideSchema.safeParse({ keepFor: "30" });
|
|
80
|
+
expect(result.success).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("reference leerer String rejected", () => {
|
|
84
|
+
const result = retentionOverrideSchema.safeParse({ reference: "" });
|
|
85
|
+
expect(result.success).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("Unbekanntes Top-Level-Property rejected (extraField)", () => {
|
|
89
|
+
const result = retentionOverrideSchema.safeParse({
|
|
90
|
+
keepFor: "30d",
|
|
91
|
+
strategy: "hardDelete",
|
|
92
|
+
extraField: "noise",
|
|
93
|
+
});
|
|
94
|
+
expect(result.success).toBe(false);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// retention:query:policy-for Integration-Test (S2.D3) — Cross-Feature-
|
|
2
|
+
// API für Forget-Flow + Cleanup-Job. Round-trip: Override in DB seeden,
|
|
3
|
+
// Query rufen, verify dass resolver Override greift.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
createEventStoreExecutor,
|
|
7
|
+
createTenantDb,
|
|
8
|
+
type DbConnection,
|
|
9
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
10
|
+
import {
|
|
11
|
+
createTestUser,
|
|
12
|
+
setupTestStack,
|
|
13
|
+
type TestStack,
|
|
14
|
+
TestUsers,
|
|
15
|
+
testTenantId,
|
|
16
|
+
unsafeCreateEntityTable,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
18
|
+
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
19
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../feature";
|
|
20
|
+
import { tenantRetentionOverrideTable } from "../schema/tenant-retention-override";
|
|
21
|
+
|
|
22
|
+
const POLICY_FOR = "data-retention:query:policy-for";
|
|
23
|
+
|
|
24
|
+
let stack: TestStack;
|
|
25
|
+
let db: DbConnection;
|
|
26
|
+
|
|
27
|
+
const feature = createDataRetentionFeature();
|
|
28
|
+
|
|
29
|
+
const overrideExecutor = createEventStoreExecutor(
|
|
30
|
+
tenantRetentionOverrideTable,
|
|
31
|
+
tenantRetentionOverrideEntity,
|
|
32
|
+
{ entityName: "tenant-retention-override" },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
async function seedOverride(
|
|
36
|
+
tenantId: string,
|
|
37
|
+
entityName: string,
|
|
38
|
+
config: Record<string, unknown>,
|
|
39
|
+
reason = "test-setup",
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const by = { ...TestUsers.systemAdmin, tenantId };
|
|
42
|
+
const tdb = createTenantDb(db, tenantId, "system");
|
|
43
|
+
const result = await overrideExecutor.create(
|
|
44
|
+
{
|
|
45
|
+
entityName,
|
|
46
|
+
config: JSON.stringify(config),
|
|
47
|
+
reason,
|
|
48
|
+
tenantId,
|
|
49
|
+
},
|
|
50
|
+
by,
|
|
51
|
+
tdb,
|
|
52
|
+
);
|
|
53
|
+
if (!result.isSuccess) {
|
|
54
|
+
throw new Error(`seedOverride failed: ${JSON.stringify(result)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
stack = await setupTestStack({ features: [feature] });
|
|
60
|
+
db = stack.db;
|
|
61
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterAll(async () => {
|
|
65
|
+
await stack.cleanup();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("data-retention :: policy-for query (S2.D3)", () => {
|
|
69
|
+
test("ohne Override + ohne Entity-Default + ohne Preset → source=none", async () => {
|
|
70
|
+
const user = createTestUser({
|
|
71
|
+
id: 1,
|
|
72
|
+
tenantId: testTenantId(1),
|
|
73
|
+
roles: ["TenantAdmin"],
|
|
74
|
+
});
|
|
75
|
+
const result = await stack.http.queryOk<{
|
|
76
|
+
entityName: string;
|
|
77
|
+
policy: unknown;
|
|
78
|
+
source: string;
|
|
79
|
+
}>(POLICY_FOR, { entityName: "ghost-entity" }, user);
|
|
80
|
+
expect(result.entityName).toBe("ghost-entity");
|
|
81
|
+
expect(result.policy).toBeNull();
|
|
82
|
+
expect(result.source).toBe("none");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("mit Override → resolver liefert source=override + override-Werte", async () => {
|
|
86
|
+
const tenantId = testTenantId(2);
|
|
87
|
+
const user = createTestUser({ id: 2, tenantId, roles: ["TenantAdmin"] });
|
|
88
|
+
|
|
89
|
+
await seedOverride(tenantId, "session", {
|
|
90
|
+
keepFor: "60d",
|
|
91
|
+
strategy: "hardDelete",
|
|
92
|
+
reference: "lastSeenAt",
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const result = await stack.http.queryOk<{
|
|
96
|
+
policy: { keepFor: string; strategy: string; reference?: string } | null;
|
|
97
|
+
source: string;
|
|
98
|
+
}>(POLICY_FOR, { entityName: "session" }, user);
|
|
99
|
+
|
|
100
|
+
expect(result.source).toBe("override");
|
|
101
|
+
expect(result.policy?.keepFor).toBe("60d");
|
|
102
|
+
expect(result.policy?.strategy).toBe("hardDelete");
|
|
103
|
+
expect(result.policy?.reference).toBe("lastSeenAt");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("Override nur strategy + keine Base → source=override-incomplete (Sprint 2.D1 Audit-Cycle-Fix greift)", async () => {
|
|
107
|
+
const tenantId = testTenantId(3);
|
|
108
|
+
const user = createTestUser({ id: 3, tenantId, roles: ["TenantAdmin"] });
|
|
109
|
+
|
|
110
|
+
await seedOverride(
|
|
111
|
+
tenantId,
|
|
112
|
+
"ghost-incomplete",
|
|
113
|
+
{ strategy: "hardDelete" }, // keepFor fehlt, kein Preset, kein Entity-Default
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const result = await stack.http.queryOk<{
|
|
117
|
+
policy: unknown;
|
|
118
|
+
source: string;
|
|
119
|
+
}>(POLICY_FOR, { entityName: "ghost-incomplete" }, user);
|
|
120
|
+
|
|
121
|
+
expect(result.source).toBe("override-incomplete");
|
|
122
|
+
expect(result.policy).toBeNull();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("Override mit Schema-Violation in DB → console.warn, fallback (source=none)", async () => {
|
|
126
|
+
const tenantId = testTenantId(4);
|
|
127
|
+
const user = createTestUser({ id: 4, tenantId, roles: ["TenantAdmin"] });
|
|
128
|
+
|
|
129
|
+
// Test-Name korrigiert in S2.D2.5-Audit (N3): "invalid JSON" war
|
|
130
|
+
// missleading — der Test prueft Schema-Violation (gueltiges JSON
|
|
131
|
+
// mit ungueltigem strategy-Enum-Wert), nicht JSON-Parse-Fehler.
|
|
132
|
+
// DB-Direct-Insert via seedOverride mit strategy="delete" — Zod
|
|
133
|
+
// retentionOverrideSchema rejected das.
|
|
134
|
+
await seedOverride(
|
|
135
|
+
tenantId,
|
|
136
|
+
"ghost-corrupt",
|
|
137
|
+
{ strategy: "delete" }, // invalid enum value
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const result = await stack.http.queryOk<{
|
|
141
|
+
policy: unknown;
|
|
142
|
+
source: string;
|
|
143
|
+
}>(POLICY_FOR, { entityName: "ghost-corrupt" }, user);
|
|
144
|
+
|
|
145
|
+
// Schema-Validation rejected → policy=null + source=none (kein
|
|
146
|
+
// Override betrachtet, faellt auf Layer 2/1 zurueck — beide leer)
|
|
147
|
+
expect(result.source).toBe("none");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("Cross-Tenant-Isolation: Tenant A's Override greift nicht für Tenant B", async () => {
|
|
151
|
+
const tenantA = testTenantId(5);
|
|
152
|
+
const tenantB = testTenantId(6);
|
|
153
|
+
const userA = createTestUser({ id: 5, tenantId: tenantA, roles: ["TenantAdmin"] });
|
|
154
|
+
const userB = createTestUser({ id: 6, tenantId: tenantB, roles: ["TenantAdmin"] });
|
|
155
|
+
|
|
156
|
+
await seedOverride(tenantA, "report", { keepFor: "1y", strategy: "hardDelete" });
|
|
157
|
+
|
|
158
|
+
const resultA = await stack.http.queryOk<{ source: string }>(
|
|
159
|
+
POLICY_FOR,
|
|
160
|
+
{ entityName: "report" },
|
|
161
|
+
userA,
|
|
162
|
+
);
|
|
163
|
+
const resultB = await stack.http.queryOk<{ source: string }>(
|
|
164
|
+
POLICY_FOR,
|
|
165
|
+
{ entityName: "report" },
|
|
166
|
+
userB,
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
expect(resultA.source).toBe("override");
|
|
170
|
+
expect(resultB.source).toBe("none"); // Tenant B sieht Tenant A's Override nicht
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// Unit-Tests für resolveRetentionPolicy — pure function, keine
|
|
2
|
+
// Test-Stack-Abhängigkeit.
|
|
3
|
+
|
|
4
|
+
import { createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import { resolveRetentionPolicy } from "../resolver";
|
|
7
|
+
|
|
8
|
+
describe("resolveRetentionPolicy — Layer-Resolution", () => {
|
|
9
|
+
test("Layer 1 Entity-Default greift wenn weder Preset noch Override", () => {
|
|
10
|
+
const entity = createEntity({
|
|
11
|
+
fields: { foo: createTextField() },
|
|
12
|
+
retention: { keepFor: "30d", strategy: "hardDelete", reference: "createdAt" },
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const result = resolveRetentionPolicy({
|
|
16
|
+
entityName: "session",
|
|
17
|
+
entityDef: entity,
|
|
18
|
+
tenantPreset: null,
|
|
19
|
+
tenantOverride: null,
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
expect(result.source).toBe("entity-default");
|
|
23
|
+
expect(result.policy).toEqual({
|
|
24
|
+
keepFor: "30d",
|
|
25
|
+
strategy: "hardDelete",
|
|
26
|
+
reference: "createdAt",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("Layer 2 Preset überschreibt Entity-Default", () => {
|
|
31
|
+
const entity = createEntity({
|
|
32
|
+
fields: { foo: createTextField() },
|
|
33
|
+
retention: { keepFor: "7d", strategy: "hardDelete" },
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const result = resolveRetentionPolicy({
|
|
37
|
+
entityName: "session",
|
|
38
|
+
entityDef: entity,
|
|
39
|
+
tenantPreset: "dsgvo-basic",
|
|
40
|
+
tenantOverride: null,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(result.source).toBe("preset");
|
|
44
|
+
// dsgvo-basic.session = 30d / hardDelete / lastSeenAt
|
|
45
|
+
expect(result.policy?.keepFor).toBe("30d");
|
|
46
|
+
expect(result.policy?.reference).toBe("lastSeenAt");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("Layer 3 Override überschreibt Preset komplett", () => {
|
|
50
|
+
const result = resolveRetentionPolicy({
|
|
51
|
+
entityName: "session",
|
|
52
|
+
entityDef: null,
|
|
53
|
+
tenantPreset: "dsgvo-basic",
|
|
54
|
+
tenantOverride: { keepFor: "7d", strategy: "hardDelete" },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(result.source).toBe("override");
|
|
58
|
+
expect(result.policy?.keepFor).toBe("7d");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("Override mit nur keepFor übernimmt strategy/reference vom Preset", () => {
|
|
62
|
+
const result = resolveRetentionPolicy({
|
|
63
|
+
entityName: "session",
|
|
64
|
+
entityDef: null,
|
|
65
|
+
tenantPreset: "dsgvo-basic",
|
|
66
|
+
tenantOverride: { keepFor: "60d" },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.source).toBe("override");
|
|
70
|
+
expect(result.policy?.keepFor).toBe("60d");
|
|
71
|
+
// Aus dsgvo-basic.session geerbt:
|
|
72
|
+
expect(result.policy?.strategy).toBe("hardDelete");
|
|
73
|
+
expect(result.policy?.reference).toBe("lastSeenAt");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("Entity ohne retention + kein Preset + kein Override → policy=null + source=none", () => {
|
|
77
|
+
const entity = createEntity({ fields: { foo: createTextField() } });
|
|
78
|
+
|
|
79
|
+
const result = resolveRetentionPolicy({
|
|
80
|
+
entityName: "ticket",
|
|
81
|
+
entityDef: entity,
|
|
82
|
+
tenantPreset: "dsgvo-basic", // hat kein "ticket" drin
|
|
83
|
+
tenantOverride: null,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result.source).toBe("none");
|
|
87
|
+
expect(result.policy).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("dsgvo-hgb invoice ist blockDelete 10y (Aufbewahrungspflicht)", () => {
|
|
91
|
+
const result = resolveRetentionPolicy({
|
|
92
|
+
entityName: "invoice",
|
|
93
|
+
entityDef: null,
|
|
94
|
+
tenantPreset: "dsgvo-hgb",
|
|
95
|
+
tenantOverride: null,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
expect(result.source).toBe("preset");
|
|
99
|
+
expect(result.policy?.keepFor).toBe("10y");
|
|
100
|
+
expect(result.policy?.strategy).toBe("blockDelete");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("dsgvo-hgb order ist anonymize 6y (Order-PII raus, Geschäftsdaten bleiben)", () => {
|
|
104
|
+
const result = resolveRetentionPolicy({
|
|
105
|
+
entityName: "order",
|
|
106
|
+
entityDef: null,
|
|
107
|
+
tenantPreset: "dsgvo-hgb",
|
|
108
|
+
tenantOverride: null,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.source).toBe("preset");
|
|
112
|
+
expect(result.policy?.keepFor).toBe("6y");
|
|
113
|
+
expect(result.policy?.strategy).toBe("anonymize");
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("Override für Anwaltskanzlei: caseFile 6y blockDelete (nicht im Preset)", () => {
|
|
117
|
+
const result = resolveRetentionPolicy({
|
|
118
|
+
entityName: "caseFile",
|
|
119
|
+
entityDef: null,
|
|
120
|
+
tenantPreset: "dsgvo-basic",
|
|
121
|
+
tenantOverride: { keepFor: "6y", strategy: "blockDelete", reference: "closedAt" },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(result.source).toBe("override");
|
|
125
|
+
expect(result.policy).toEqual({
|
|
126
|
+
keepFor: "6y",
|
|
127
|
+
strategy: "blockDelete",
|
|
128
|
+
reference: "closedAt",
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe("resolveRetentionPolicy — override-incomplete-Guard", () => {
|
|
134
|
+
test("Override ohne keepFor + keine Base → policy=null, source=override-incomplete", () => {
|
|
135
|
+
const result = resolveRetentionPolicy({
|
|
136
|
+
entityName: "ticket",
|
|
137
|
+
entityDef: null,
|
|
138
|
+
tenantPreset: "dsgvo-basic", // hat kein "ticket"
|
|
139
|
+
tenantOverride: { strategy: "hardDelete" }, // keepFor fehlt
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
expect(result.source).toBe("override-incomplete");
|
|
143
|
+
expect(result.policy).toBeNull();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("Override ohne strategy + keine Base → policy=null, source=override-incomplete", () => {
|
|
147
|
+
const result = resolveRetentionPolicy({
|
|
148
|
+
entityName: "ticket",
|
|
149
|
+
entityDef: null,
|
|
150
|
+
tenantPreset: null,
|
|
151
|
+
tenantOverride: { keepFor: "30d" },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(result.source).toBe("override-incomplete");
|
|
155
|
+
expect(result.policy).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("Override ohne keepFor aber Preset hat ein → fällt zurück + source=override", () => {
|
|
159
|
+
const result = resolveRetentionPolicy({
|
|
160
|
+
entityName: "session",
|
|
161
|
+
entityDef: null,
|
|
162
|
+
tenantPreset: "dsgvo-basic", // session: 30d
|
|
163
|
+
tenantOverride: { strategy: "softDelete" }, // keepFor erbt von Preset
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
expect(result.source).toBe("override");
|
|
167
|
+
expect(result.policy?.keepFor).toBe("30d");
|
|
168
|
+
expect(result.policy?.strategy).toBe("softDelete");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("resolveRetentionPolicy — Edge-Cases", () => {
|
|
173
|
+
test("default-Preset ist leer → fällt zurück auf entity-default", () => {
|
|
174
|
+
const entity = createEntity({
|
|
175
|
+
fields: { foo: createTextField() },
|
|
176
|
+
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const result = resolveRetentionPolicy({
|
|
180
|
+
entityName: "session",
|
|
181
|
+
entityDef: entity,
|
|
182
|
+
tenantPreset: "default",
|
|
183
|
+
tenantOverride: null,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(result.source).toBe("entity-default");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("Preset hat eintrag, override hat anderen entityName → override greift NUR für seinen entityName", () => {
|
|
190
|
+
const result = resolveRetentionPolicy({
|
|
191
|
+
entityName: "auditLog",
|
|
192
|
+
entityDef: null,
|
|
193
|
+
tenantPreset: "dsgvo-hgb",
|
|
194
|
+
tenantOverride: null, // kein override für auditLog
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// dsgvo-hgb.auditLog = 1y / hardDelete
|
|
198
|
+
expect(result.source).toBe("preset");
|
|
199
|
+
expect(result.policy?.keepFor).toBe("1y");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Geteilter Override-Parser fuer policy-for-Query und resolve-for-tenant-
|
|
2
|
+
// Helper. War vorher 1:1 in beiden Files dupliziert (Memory
|
|
3
|
+
// `feedback_bulk_patterns` — Drift-Risiko bei Schema-Aenderungen).
|
|
4
|
+
|
|
5
|
+
import { retentionOverrideSchema } from "../override-schema";
|
|
6
|
+
import type { RetentionOverride } from "../resolver";
|
|
7
|
+
|
|
8
|
+
export function parseRetentionOverrideOrNull(
|
|
9
|
+
raw: string | null,
|
|
10
|
+
tenantId: string,
|
|
11
|
+
callerLabel: string,
|
|
12
|
+
): RetentionOverride | null {
|
|
13
|
+
if (!raw || raw.trim() === "") return null;
|
|
14
|
+
let parsed: unknown;
|
|
15
|
+
try {
|
|
16
|
+
parsed = JSON.parse(raw);
|
|
17
|
+
} catch (e) {
|
|
18
|
+
// biome-ignore lint/suspicious/noConsole: operator visibility for DB-corruption edge-case
|
|
19
|
+
console.warn(
|
|
20
|
+
`[${callerLabel}] tenant ${tenantId}: stored override is not valid JSON, ignoring. Reason: ${(e as Error).message}`,
|
|
21
|
+
);
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const validation = retentionOverrideSchema.safeParse(parsed);
|
|
25
|
+
if (!validation.success) {
|
|
26
|
+
// biome-ignore lint/suspicious/noConsole: operator visibility for schema-drift
|
|
27
|
+
console.warn(
|
|
28
|
+
`[${callerLabel}] tenant ${tenantId}: stored override fails schema validation, ignoring. Issue: ${validation.error.issues[0]?.path.join(".")}: ${validation.error.issues[0]?.message}`,
|
|
29
|
+
);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return validation.data;
|
|
33
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { policyForQuery } from "./handlers/policy-for.query";
|
|
3
|
+
import { tenantRetentionOverrideEntity } from "./schema/tenant-retention-override";
|
|
4
|
+
|
|
5
|
+
export { retentionOverrideSchema } from "./override-schema";
|
|
6
|
+
export {
|
|
7
|
+
RETENTION_PRESETS,
|
|
8
|
+
type RetentionPreset,
|
|
9
|
+
type RetentionPresetKey,
|
|
10
|
+
SELECTABLE_RETENTION_PRESETS,
|
|
11
|
+
} from "./presets";
|
|
12
|
+
export {
|
|
13
|
+
type ResolveForTenantArgs,
|
|
14
|
+
resolveRetentionPolicyForTenant,
|
|
15
|
+
} from "./resolve-for-tenant";
|
|
16
|
+
export {
|
|
17
|
+
type EffectiveRetentionPolicy,
|
|
18
|
+
type ResolveRetentionPolicyArgs,
|
|
19
|
+
type RetentionOverride,
|
|
20
|
+
resolveRetentionPolicy,
|
|
21
|
+
} from "./resolver";
|
|
22
|
+
export {
|
|
23
|
+
tenantRetentionOverrideEntity,
|
|
24
|
+
tenantRetentionOverrideTable,
|
|
25
|
+
} from "./schema/tenant-retention-override";
|
|
26
|
+
|
|
27
|
+
// data-retention — automatisierte Aufbewahrung + Löschung pro Entity.
|
|
28
|
+
//
|
|
29
|
+
// Sprint 2.D1 (this commit):
|
|
30
|
+
// - 3-Schicht-Resolver (Entity-Default → Tenant-Preset → Tenant-Override)
|
|
31
|
+
// - Retention-Presets (dsgvo-basic, dsgvo-hgb, swiss-dsg, default)
|
|
32
|
+
// - tenantRetentionOverride-Entity für per-Tenant Edge-Cases
|
|
33
|
+
//
|
|
34
|
+
// Sprint 2.D2 (kommt):
|
|
35
|
+
// - Cleanup-Job mit Batch-Logik
|
|
36
|
+
// - Anonymize-Strategy + blockDelete-Frist-Check
|
|
37
|
+
//
|
|
38
|
+
// Sprint 2.D3 (kommt):
|
|
39
|
+
// - r.exposesApi("retention.policyFor") für user-data-rights
|
|
40
|
+
//
|
|
41
|
+
// Cross-Feature-Hinweis: Plan-Roadmap docs/plans/datenschutz/
|
|
42
|
+
// core-data-retention.md — Forget-Flow konsultiert blockDelete via
|
|
43
|
+
// retention.policyFor → anonymize statt hardDelete bei Aufbewahrungs-
|
|
44
|
+
// pflicht. Das Wiring kommt in Sprint 2.U5.
|
|
45
|
+
export function createDataRetentionFeature(): FeatureDefinition {
|
|
46
|
+
return defineFeature("data-retention", (r) => {
|
|
47
|
+
r.entity("tenant-retention-override", tenantRetentionOverrideEntity);
|
|
48
|
+
|
|
49
|
+
// S2.D3: Cross-Feature-API fuer Forget-Flow + Cleanup-Job
|
|
50
|
+
r.exposesApi("retention.policyFor");
|
|
51
|
+
r.queryHandler(policyForQuery);
|
|
52
|
+
|
|
53
|
+
// S2.D2b wird hier den Cleanup-Job registrieren:
|
|
54
|
+
// r.job("retention-cleanup", { trigger: { cron: "0 3 * * *" } }, ...)
|
|
55
|
+
// + tenant-config-key fuer Preset-Auswahl.
|
|
56
|
+
});
|
|
57
|
+
}
|