@cosmicdrift/kumiko-bundled-features 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 (100) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/package.json +12 -6
  3. package/src/auth-email-password/auth-user-row.ts +6 -0
  4. package/src/auth-email-password/constants.ts +11 -0
  5. package/src/auth-email-password/handlers/login.write.ts +31 -1
  6. package/src/auth-email-password/i18n.ts +4 -0
  7. package/src/compliance-profiles/README.md +88 -0
  8. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  9. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  10. package/src/compliance-profiles/feature.ts +51 -0
  11. package/src/compliance-profiles/handlers/for-tenant.query.ts +63 -0
  12. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  13. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  14. package/src/compliance-profiles/handlers/set-profile.write.ts +146 -0
  15. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  16. package/src/compliance-profiles/index.ts +6 -0
  17. package/src/compliance-profiles/resolve-for-tenant.ts +61 -0
  18. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  19. package/src/compliance-profiles/seeding.ts +96 -0
  20. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  21. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  22. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  23. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  24. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  25. package/src/data-retention/_internal/parse-override.ts +33 -0
  26. package/src/data-retention/feature.ts +57 -0
  27. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  28. package/src/data-retention/index.ts +18 -0
  29. package/src/data-retention/keep-for.ts +75 -0
  30. package/src/data-retention/override-schema.ts +37 -0
  31. package/src/data-retention/presets.ts +72 -0
  32. package/src/data-retention/resolve-for-tenant.ts +50 -0
  33. package/src/data-retention/resolver.ts +107 -0
  34. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  35. package/src/file-foundation/feature.ts +43 -3
  36. package/src/file-foundation/index.ts +1 -0
  37. package/src/file-provider-inmemory/feature.ts +6 -3
  38. package/src/file-provider-s3/feature.ts +8 -10
  39. package/src/files/README.md +50 -0
  40. package/src/files/__tests__/files.integration.ts +157 -0
  41. package/src/files/feature.ts +34 -0
  42. package/src/files/index.ts +1 -0
  43. package/src/files/schema/file-ref.ts +58 -0
  44. package/src/files-provider-s3/s3-provider.ts +89 -0
  45. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  46. package/src/secrets/feature.ts +10 -6
  47. package/src/sessions/constants.ts +4 -0
  48. package/src/sessions/feature.ts +3 -0
  49. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  50. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  51. package/src/tier-engine/feature.ts +16 -6
  52. package/src/user/__tests__/user-status.test.ts +39 -0
  53. package/src/user/index.ts +11 -1
  54. package/src/user/schema/user.ts +76 -0
  55. package/src/user-data-rights/COMPLIANCE.md +182 -0
  56. package/src/user-data-rights/README.md +109 -0
  57. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  58. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  59. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  60. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  61. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  62. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  63. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  64. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  65. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  66. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  67. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  68. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  69. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  70. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  71. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  72. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  73. package/src/user-data-rights/audit-download.ts +125 -0
  74. package/src/user-data-rights/feature.ts +309 -0
  75. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  76. package/src/user-data-rights/handlers/download-by-job.query.ts +209 -0
  77. package/src/user-data-rights/handlers/download-by-token.query.ts +257 -0
  78. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  79. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  80. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  81. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  82. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  83. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  84. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  85. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  86. package/src/user-data-rights/i18n.ts +37 -0
  87. package/src/user-data-rights/index.ts +19 -0
  88. package/src/user-data-rights/run-export-jobs.ts +878 -0
  89. package/src/user-data-rights/run-forget-cleanup.ts +334 -0
  90. package/src/user-data-rights/run-user-export.ts +211 -0
  91. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  92. package/src/user-data-rights/schema/download-token.ts +111 -0
  93. package/src/user-data-rights/schema/export-job.ts +166 -0
  94. package/src/user-data-rights/token-helpers.ts +67 -0
  95. package/src/user-data-rights/zip-path.ts +94 -0
  96. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  97. package/src/user-data-rights-defaults/feature.ts +40 -0
  98. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  99. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  100. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -0,0 +1,199 @@
1
+ // S2.U7 — my-audit-log + invalid-attempt-audit + list-download-attempts.
2
+
3
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
4
+ import {
5
+ createTestUser,
6
+ setupTestStack,
7
+ type TestStack,
8
+ testTenantId,
9
+ unsafeCreateEntityTable,
10
+ } from "@cosmicdrift/kumiko-framework/stack";
11
+ import { sql } from "drizzle-orm";
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
13
+ import {
14
+ createComplianceProfilesFeature,
15
+ tenantComplianceProfileEntity,
16
+ } from "../../compliance-profiles";
17
+ import { createDataRetentionFeature } from "../../data-retention";
18
+ import { USER_STATUS, userEntity, userTable } from "../../user";
19
+ import { createUserFeature } from "../../user/feature";
20
+ import { createUserDataRightsFeature } from "../feature";
21
+ import { downloadAttemptEntity, downloadAttemptsTable } from "../schema/download-attempt";
22
+
23
+ const MY_AUDIT = "user-data-rights:query:my-audit-log";
24
+ const LIST_ATTEMPTS = "user-data-rights:query:list-download-attempts";
25
+
26
+ let stack: TestStack;
27
+
28
+ const tenantA = testTenantId(1);
29
+ const alice = createTestUser({ id: 42, tenantId: tenantA, roles: ["Member"] });
30
+ const bob = createTestUser({ id: 43, tenantId: tenantA, roles: ["Member"] });
31
+ const admin = createTestUser({ id: 1, tenantId: tenantA, roles: ["Admin"] });
32
+
33
+ beforeAll(async () => {
34
+ stack = await setupTestStack({
35
+ features: [
36
+ createUserFeature(),
37
+ createDataRetentionFeature(),
38
+ createComplianceProfilesFeature(),
39
+ createUserDataRightsFeature(),
40
+ ],
41
+ });
42
+ await unsafeCreateEntityTable(stack.db, userEntity);
43
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
44
+ await unsafeCreateEntityTable(stack.db, downloadAttemptEntity);
45
+ await createEventsTable(stack.db);
46
+ });
47
+
48
+ afterAll(async () => {
49
+ await stack.cleanup();
50
+ });
51
+
52
+ beforeEach(async () => {
53
+ await stack.db.delete(userTable);
54
+ await stack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
55
+ await stack.db.execute(sql`DELETE FROM read_download_attempts`);
56
+ await stack.db.execute(sql`DELETE FROM kumiko_events`);
57
+ });
58
+
59
+ async function seedUser(u: typeof alice, email: string): Promise<void> {
60
+ await stack.db.insert(userTable).values({
61
+ id: u.id,
62
+ tenantId: u.tenantId,
63
+ email,
64
+ passwordHash: "h",
65
+ displayName: email,
66
+ locale: "de",
67
+ emailVerified: true,
68
+ roles: '["Member"]',
69
+ status: USER_STATUS.Active,
70
+ });
71
+ }
72
+
73
+ let _eventVersion = 0;
74
+ async function seedEvent(
75
+ createdBy: string,
76
+ tenantId: string,
77
+ type: string,
78
+ payload: object,
79
+ ): Promise<void> {
80
+ _eventVersion += 1;
81
+ await stack.db.execute(sql`
82
+ INSERT INTO kumiko_events
83
+ (tenant_id, aggregate_type, aggregate_id, version, type, payload, metadata, created_at, created_by)
84
+ VALUES (${tenantId}, ${"test-aggregate"}, ${"00000000-0000-4000-8000-00000000aaaa"},
85
+ ${_eventVersion}, ${type}, ${JSON.stringify(payload)}, ${"{}"}, now(), ${createdBy})
86
+ `);
87
+ }
88
+
89
+ describe("my-audit-log", () => {
90
+ test("user sieht nur seine eigenen events (cross-user-Isolation)", async () => {
91
+ await seedEvent(alice.id, tenantA, "user.requested-deletion", { foo: "alice" });
92
+ await seedEvent(bob.id, tenantA, "user.requested-deletion", { foo: "bob" });
93
+
94
+ const aliceLog = await stack.http.queryOk<{ rows: Array<{ payload: unknown }> }>(
95
+ MY_AUDIT,
96
+ {},
97
+ alice,
98
+ );
99
+ const bobLog = await stack.http.queryOk<{ rows: Array<{ payload: unknown }> }>(
100
+ MY_AUDIT,
101
+ {},
102
+ bob,
103
+ );
104
+ expect(aliceLog.rows.length).toBe(1);
105
+ expect(bobLog.rows.length).toBe(1);
106
+ // Payload-pinning beweist die Cross-User-Filterung: alice sieht nur
107
+ // ihre Payload, bob nur seine.
108
+ expect((aliceLog.rows[0]?.payload as { foo: string }).foo).toBe("alice");
109
+ expect((bobLog.rows[0]?.payload as { foo: string }).foo).toBe("bob");
110
+ });
111
+
112
+ test("Account-weite Sicht: User sieht events aus anderen Tenants (DSGVO Art. 15)", async () => {
113
+ const tenantB = testTenantId(2);
114
+ await seedEvent(alice.id, tenantA, "user.x", { from: "tenantA" });
115
+ await seedEvent(alice.id, tenantB, "user.y", { from: "tenantB" });
116
+
117
+ const log = await stack.http.queryOk<{
118
+ rows: Array<{ payload: { from: string } }>;
119
+ }>(MY_AUDIT, {}, alice);
120
+
121
+ expect(log.rows.length).toBe(2);
122
+ const fromTenants = log.rows.map((r) => r.payload.from).sort();
123
+ expect(fromTenants).toEqual(["tenantA", "tenantB"]);
124
+ });
125
+
126
+ test("filter eventType + payload kommt mit", async () => {
127
+ await seedEvent(alice.id, tenantA, "user.requested-deletion", { gracePeriodEnd: "2026-06-01" });
128
+ await seedEvent(alice.id, tenantA, "user.lifted-restriction", {});
129
+
130
+ const filtered = await stack.http.queryOk<{ rows: Array<{ type: string }> }>(
131
+ MY_AUDIT,
132
+ { eventType: "user.requested-deletion" },
133
+ alice,
134
+ );
135
+ expect(filtered.rows.length).toBe(1);
136
+ expect(filtered.rows[0]?.type).toBe("user.requested-deletion");
137
+ });
138
+ });
139
+
140
+ describe("download-attempt retention :: disk-bomb-Schutz bei Brute-Force", () => {
141
+ test("Entity-Default ist 90d hardDelete (kein unbounded growth)", () => {
142
+ expect(downloadAttemptEntity.retention).toBeDefined();
143
+ expect(downloadAttemptEntity.retention?.keepFor).toBe("90d");
144
+ expect(downloadAttemptEntity.retention?.strategy).toBe("hardDelete");
145
+ expect(downloadAttemptEntity.retention?.reference).toBe("attemptedAt");
146
+ });
147
+ });
148
+
149
+ describe("list-download-attempts (DPO operator-query)", () => {
150
+ test("Admin kann queryen, Member nicht", async () => {
151
+ await seedUser(alice, "alice@example.com");
152
+ // Admin allowed
153
+ const ok = await stack.http.queryOk<{ rows: unknown[] }>(LIST_ATTEMPTS, {}, admin);
154
+ expect(Array.isArray(ok.rows)).toBe(true);
155
+ // Member blocked
156
+ const res = await stack.http.query(LIST_ATTEMPTS, {}, alice);
157
+ expect([401, 403]).toContain(res.status);
158
+ });
159
+
160
+ test("filter result=notFound", async () => {
161
+ // Direct-INSERT in attempts (simuliert was die download-handler schreiben).
162
+ const T = await import("@cosmicdrift/kumiko-framework/time");
163
+ const now = T.getTemporal().Now.instant();
164
+ await stack.db.insert(downloadAttemptsTable).values([
165
+ {
166
+ id: "11111111-1111-4111-8111-111111111111",
167
+ tenantId: tenantA,
168
+ result: "notFound",
169
+ via: "token",
170
+ tokenHash: "abc",
171
+ jobId: null,
172
+ attemptedByUserId: null,
173
+ ip: "1.2.3.4",
174
+ userAgent: "test",
175
+ attemptedAt: now,
176
+ },
177
+ {
178
+ id: "22222222-2222-4222-8222-222222222222",
179
+ tenantId: tenantA,
180
+ result: "expired",
181
+ via: "token",
182
+ tokenHash: "def",
183
+ jobId: null,
184
+ attemptedByUserId: null,
185
+ ip: "1.2.3.4",
186
+ userAgent: "test",
187
+ attemptedAt: now,
188
+ },
189
+ ]);
190
+
191
+ const filtered = await stack.http.queryOk<{ rows: Array<{ result: string }> }>(
192
+ LIST_ATTEMPTS,
193
+ { result: "notFound" },
194
+ admin,
195
+ );
196
+ expect(filtered.rows.length).toBe(1);
197
+ expect(filtered.rows[0]?.result).toBe("notFound");
198
+ });
199
+ });
@@ -0,0 +1,349 @@
1
+ // Cross-Data-Matrix Integration-Test (S2.T1).
2
+ //
3
+ // Pinst dass eine App-eigene Domain-Entity ueber EXT_USER_DATA sauber in
4
+ // Export- + Forget-Pipeline integriert. Synthetic "note"-Entity steht
5
+ // stellvertretend fuer "Chat-Message", "Blog-Post", "Order-Line" etc.
6
+ //
7
+ // Matrix:
8
+ // - Export bundelt user + fileRef + note (3 Provider-Features) cross-
9
+ // tenant fuer einen User.
10
+ // - Forget cleant user (anonymized) + fileRef (deleted) + note
11
+ // (deleted) cross-tenant fuer denselben User.
12
+ // - Other-User-Isolation: Bobs notes/files bleiben unangetastet bei
13
+ // Alices Forget; Bobs Daten landen NICHT in Alices Export-Bundle.
14
+
15
+ import {
16
+ defineFeature,
17
+ EXT_USER_DATA,
18
+ type UserDataDeleteHook,
19
+ type UserDataExportHook,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
21
+ import {
22
+ setupTestStack,
23
+ type TestStack,
24
+ unsafeCreateEntityTable,
25
+ } from "@cosmicdrift/kumiko-framework/stack";
26
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
27
+ import { sql } from "drizzle-orm";
28
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
29
+ import {
30
+ createComplianceProfilesFeature,
31
+ tenantComplianceProfileEntity,
32
+ } from "../../compliance-profiles";
33
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
34
+ import { createFilesFeature } from "../../files";
35
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
36
+ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
37
+ import { createUserDataRightsFeature } from "../feature";
38
+ import { runForgetCleanup } from "../run-forget-cleanup";
39
+ import { runUserExport } from "../run-user-export";
40
+
41
+ let stack: TestStack;
42
+
43
+ const TENANT_A = "00000000-0000-4000-8000-00000000000a";
44
+ const TENANT_B = "00000000-0000-4000-8000-00000000000b";
45
+ const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
46
+
47
+ function uuid(suffix: number): string {
48
+ return `cccccccc-cccc-4ccc-8ccc-${suffix.toString(16).padStart(12, "0")}`;
49
+ }
50
+
51
+ const ALICE_ID = uuid(1);
52
+ const BOB_ID = uuid(2);
53
+
54
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
55
+ const NOW = (): Instant => getTemporal().Now.instant();
56
+ const PAST = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
57
+
58
+ // Synthetic third-party Domain-Feature: "note" mit export- + delete-Hook.
59
+ // Stellvertretend fuer App-spezifische Entities (Chat-Message, Blog-Post
60
+ // etc.), die ueber EXT_USER_DATA sauber in die Pipeline integrieren.
61
+ const exportNotes: UserDataExportHook = async (ctx) => {
62
+ const result = await ctx.db.execute(sql`
63
+ SELECT id, title, body
64
+ FROM test_notes
65
+ WHERE tenant_id = ${ctx.tenantId} AND author_id = ${ctx.userId}
66
+ `);
67
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
68
+ const rows = ((result as any).rows ?? result) as Array<{
69
+ id: string;
70
+ title: string;
71
+ body: string;
72
+ }>;
73
+ if (rows.length === 0) return null;
74
+ return {
75
+ entity: "note",
76
+ rows: rows.map((r) => ({ id: r.id, title: r.title, body: r.body })),
77
+ };
78
+ };
79
+
80
+ const deleteNotes: UserDataDeleteHook = async (ctx, _strategy) => {
81
+ await ctx.db.execute(sql`
82
+ DELETE FROM test_notes
83
+ WHERE tenant_id = ${ctx.tenantId} AND author_id = ${ctx.userId}
84
+ `);
85
+ };
86
+
87
+ const testNotesFeature = defineFeature("test-notes", (r) => {
88
+ r.useExtension(EXT_USER_DATA, "note", {
89
+ export: exportNotes,
90
+ delete: deleteNotes,
91
+ });
92
+ });
93
+
94
+ beforeAll(async () => {
95
+ stack = await setupTestStack({
96
+ features: [
97
+ createUserFeature(),
98
+ createFilesFeature(),
99
+ createDataRetentionFeature(),
100
+ createComplianceProfilesFeature(),
101
+ createUserDataRightsFeature(),
102
+ createUserDataRightsDefaultsFeature(),
103
+ testNotesFeature,
104
+ ],
105
+ });
106
+
107
+ await unsafeCreateEntityTable(stack.db, userEntity);
108
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
109
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
110
+ await stack.db.execute(sql`
111
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
112
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
113
+ tenant_id UUID NOT NULL,
114
+ user_id TEXT NOT NULL,
115
+ version INTEGER NOT NULL DEFAULT 0,
116
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
117
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
118
+ inserted_by_id TEXT,
119
+ modified_by_id TEXT,
120
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
121
+ deleted_at TIMESTAMPTZ,
122
+ deleted_by_id TEXT,
123
+ roles TEXT NOT NULL DEFAULT '[]',
124
+ UNIQUE(user_id, tenant_id)
125
+ )
126
+ `);
127
+ await stack.db.execute(sql`
128
+ CREATE TABLE IF NOT EXISTS file_refs (
129
+ id UUID PRIMARY KEY,
130
+ tenant_id UUID NOT NULL,
131
+ storage_key TEXT NOT NULL,
132
+ file_name TEXT NOT NULL,
133
+ mime_type TEXT NOT NULL,
134
+ size INTEGER NOT NULL,
135
+ entity_type TEXT,
136
+ entity_id TEXT,
137
+ field_name TEXT,
138
+ inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
139
+ inserted_by_id TEXT
140
+ )
141
+ `);
142
+ await stack.db.execute(sql`
143
+ CREATE TABLE IF NOT EXISTS test_notes (
144
+ id UUID PRIMARY KEY,
145
+ tenant_id UUID NOT NULL,
146
+ author_id TEXT NOT NULL,
147
+ title TEXT NOT NULL,
148
+ body TEXT NOT NULL
149
+ )
150
+ `);
151
+ });
152
+
153
+ afterAll(async () => {
154
+ await stack.cleanup();
155
+ });
156
+
157
+ beforeEach(async () => {
158
+ await stack.db.delete(userTable);
159
+ await stack.db.execute(sql`DELETE FROM read_tenant_memberships`);
160
+ await stack.db.execute(sql`DELETE FROM file_refs`);
161
+ await stack.db.execute(sql`DELETE FROM test_notes`);
162
+ });
163
+
164
+ async function seedUser(
165
+ id: string,
166
+ overrides: {
167
+ status?: string;
168
+ gracePeriodEnd?: Instant | null;
169
+ email?: string;
170
+ displayName?: string;
171
+ } = {},
172
+ ): Promise<void> {
173
+ await stack.db.insert(userTable).values({
174
+ id,
175
+ tenantId: TENANT_SYSTEM,
176
+ email: overrides.email ?? `user-${id}@example.com`,
177
+ passwordHash: "hashed",
178
+ displayName: overrides.displayName ?? `User ${id}`,
179
+ locale: "de",
180
+ emailVerified: true,
181
+ roles: '["Member"]',
182
+ status: overrides.status ?? USER_STATUS.Active,
183
+ gracePeriodEnd: overrides.gracePeriodEnd ?? null,
184
+ });
185
+ }
186
+
187
+ async function seedMembership(userId: string, tenantId: string): Promise<void> {
188
+ await stack.db.execute(sql`
189
+ INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
190
+ VALUES (${tenantId}, ${userId}, '["Member"]')
191
+ ON CONFLICT (user_id, tenant_id) DO NOTHING
192
+ `);
193
+ }
194
+
195
+ async function seedFileRef(
196
+ id: string,
197
+ tenantId: string,
198
+ userId: string,
199
+ name: string,
200
+ ): Promise<void> {
201
+ await stack.db.execute(sql`
202
+ INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
203
+ VALUES (${id}, ${tenantId}, ${`storage/${id}`}, ${name}, 'application/pdf', 1024, ${userId})
204
+ `);
205
+ }
206
+
207
+ async function seedNote(
208
+ id: string,
209
+ tenantId: string,
210
+ userId: string,
211
+ title: string,
212
+ ): Promise<void> {
213
+ await stack.db.execute(sql`
214
+ INSERT INTO test_notes (id, tenant_id, author_id, title, body)
215
+ VALUES (${id}, ${tenantId}, ${userId}, ${title}, ${`body for ${title}`})
216
+ `);
217
+ }
218
+
219
+ async function fetchNotes(tenantId: string, userId: string): Promise<unknown[]> {
220
+ const result = await stack.db.execute(sql`
221
+ SELECT id, title FROM test_notes WHERE tenant_id = ${tenantId} AND author_id = ${userId}
222
+ `);
223
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
224
+ return ((result as any).rows ?? result) as unknown[];
225
+ }
226
+
227
+ describe("Cross-Data-Matrix :: Export bundelt 3 Provider-Features (user + fileRef + note)", () => {
228
+ test("Alice (Tenant A + B) → Bundle hat alle 3 Entitaeten cross-tenant", async () => {
229
+ await seedUser(ALICE_ID, { email: "alice@example.com", displayName: "Alice" });
230
+ await seedMembership(ALICE_ID, TENANT_A);
231
+ await seedMembership(ALICE_ID, TENANT_B);
232
+
233
+ await seedFileRef(uuid(101), TENANT_A, ALICE_ID, "alice-a.pdf");
234
+ await seedFileRef(uuid(102), TENANT_B, ALICE_ID, "alice-b.pdf");
235
+
236
+ await seedNote(uuid(201), TENANT_A, ALICE_ID, "note-A-1");
237
+ await seedNote(uuid(202), TENANT_A, ALICE_ID, "note-A-2");
238
+ await seedNote(uuid(203), TENANT_B, ALICE_ID, "note-B-1");
239
+
240
+ const bundle = await runUserExport({
241
+ db: stack.db,
242
+ registry: stack.registry,
243
+ userId: ALICE_ID,
244
+ now: NOW(),
245
+ });
246
+
247
+ expect(bundle.tenants).toHaveLength(2);
248
+
249
+ const tenantA = bundle.tenants.find((t) => t.tenantId === TENANT_A);
250
+ const tenantB = bundle.tenants.find((t) => t.tenantId === TENANT_B);
251
+ expect(tenantA).toBeDefined();
252
+ expect(tenantB).toBeDefined();
253
+
254
+ // Tenant A: user + fileRef + 2 notes
255
+ const entitiesA = (tenantA?.entities ?? []).map((e) => e.entity);
256
+ expect(entitiesA).toContain("user");
257
+ expect(entitiesA).toContain("fileRef");
258
+ expect(entitiesA).toContain("note");
259
+ const noteSnippetA = tenantA?.entities.find((e) => e.entity === "note");
260
+ expect(noteSnippetA?.rows).toHaveLength(2);
261
+
262
+ // Tenant B: user + fileRef + 1 note
263
+ const noteSnippetB = tenantB?.entities.find((e) => e.entity === "note");
264
+ expect(noteSnippetB?.rows).toHaveLength(1);
265
+ expect(String(noteSnippetB?.rows[0]?.["title"])).toBe("note-B-1");
266
+ });
267
+
268
+ test("Other-User-Isolation: Bobs notes nicht in Alices Bundle", async () => {
269
+ await seedUser(ALICE_ID, { email: "alice@example.com" });
270
+ await seedUser(BOB_ID, { email: "bob@example.com" });
271
+ await seedMembership(ALICE_ID, TENANT_A);
272
+ await seedMembership(BOB_ID, TENANT_A);
273
+
274
+ await seedNote(uuid(301), TENANT_A, ALICE_ID, "alice-secret");
275
+ await seedNote(uuid(302), TENANT_A, BOB_ID, "bob-secret");
276
+
277
+ const bundle = await runUserExport({
278
+ db: stack.db,
279
+ registry: stack.registry,
280
+ userId: ALICE_ID,
281
+ now: NOW(),
282
+ });
283
+
284
+ const serialized = JSON.stringify(bundle);
285
+ expect(serialized).toContain("alice-secret");
286
+ expect(serialized).not.toContain("bob-secret");
287
+ });
288
+ });
289
+
290
+ describe("Cross-Data-Matrix :: Forget cleant 3 Provider-Features cross-tenant", () => {
291
+ test("Alice DeletionRequested + grace expired → notes weg in beiden Tenants, Bobs Daten unangetastet", async () => {
292
+ await seedUser(ALICE_ID, {
293
+ status: USER_STATUS.DeletionRequested,
294
+ gracePeriodEnd: PAST(),
295
+ email: "alice@example.com",
296
+ });
297
+ await seedUser(BOB_ID, { email: "bob@example.com" });
298
+ await seedMembership(ALICE_ID, TENANT_A);
299
+ await seedMembership(ALICE_ID, TENANT_B);
300
+ await seedMembership(BOB_ID, TENANT_A);
301
+
302
+ await seedFileRef(uuid(401), TENANT_A, ALICE_ID, "alice-a.pdf");
303
+ await seedFileRef(uuid(402), TENANT_A, BOB_ID, "bob-a.pdf");
304
+ await seedNote(uuid(501), TENANT_A, ALICE_ID, "alice-A");
305
+ await seedNote(uuid(502), TENANT_B, ALICE_ID, "alice-B");
306
+ await seedNote(uuid(503), TENANT_A, BOB_ID, "bob-A");
307
+
308
+ const result = await runForgetCleanup({
309
+ db: stack.db,
310
+ registry: stack.registry,
311
+ now: NOW(),
312
+ });
313
+
314
+ expect(result.processedUserIds).toContain(ALICE_ID);
315
+ expect(result.errors).toHaveLength(0);
316
+
317
+ // Alices notes in beiden Tenants weg
318
+ expect(await fetchNotes(TENANT_A, ALICE_ID)).toHaveLength(0);
319
+ expect(await fetchNotes(TENANT_B, ALICE_ID)).toHaveLength(0);
320
+
321
+ // Alices fileRef in Tenant A weg
322
+ const aliceFiles = await stack.db.execute(sql`
323
+ SELECT id FROM file_refs WHERE tenant_id = ${TENANT_A} AND inserted_by_id = ${ALICE_ID}
324
+ `);
325
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
326
+ expect((((aliceFiles as any).rows ?? aliceFiles) as unknown[]).length).toBe(0);
327
+
328
+ // Bobs notes + files unangetastet
329
+ expect(await fetchNotes(TENANT_A, BOB_ID)).toHaveLength(1);
330
+ const bobFiles = await stack.db.execute(sql`
331
+ SELECT id FROM file_refs WHERE tenant_id = ${TENANT_A} AND inserted_by_id = ${BOB_ID}
332
+ `);
333
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
334
+ expect((((bobFiles as any).rows ?? bobFiles) as unknown[]).length).toBe(1);
335
+
336
+ // Alice-User-Row anonymisiert (DSGVO-Kern: PII raus, Sentinel-Email).
337
+ const aliceRow = await stack.db.execute(sql`
338
+ SELECT email, status FROM read_users WHERE id = ${ALICE_ID}
339
+ `);
340
+ // biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
341
+ const aliceRows = ((aliceRow as any).rows ?? aliceRow) as Array<{
342
+ email: string;
343
+ status: string;
344
+ }>;
345
+ expect(aliceRows[0]?.status).toBe(USER_STATUS.Deleted);
346
+ expect(aliceRows[0]?.email).toMatch(/^deleted-.*@anonymized\.invalid$/);
347
+ expect(aliceRows[0]?.email).not.toContain("alice@example.com");
348
+ });
349
+ });