@cosmicdrift/kumiko-bundled-features 0.2.2 → 0.3.0

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 (162) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/package.json +22 -13
  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/change-password.write.ts +1 -1
  6. package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
  7. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
  8. package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
  9. package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
  10. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
  11. package/src/auth-email-password/handlers/login.write.ts +32 -2
  12. package/src/auth-email-password/handlers/logout.write.ts +2 -2
  13. package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
  14. package/src/auth-email-password/i18n.ts +4 -0
  15. package/src/auth-email-password/web/auth-client.ts +1 -1
  16. package/src/billing-foundation/events.ts +1 -1
  17. package/src/billing-foundation/feature.ts +44 -47
  18. package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
  19. package/src/billing-foundation/handlers/process-event.write.ts +3 -3
  20. package/src/billing-foundation/projection.ts +1 -1
  21. package/src/billing-foundation/webhook-handler.ts +1 -1
  22. package/src/cap-counter/constants.ts +1 -1
  23. package/src/cap-counter/enforce-cap.ts +1 -1
  24. package/src/cap-counter/feature.ts +3 -7
  25. package/src/cap-counter/handlers/get-counter.query.ts +1 -1
  26. package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
  27. package/src/cap-counter/handlers/increment.write.ts +3 -3
  28. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  29. package/src/channel-email/email-channel.ts +1 -1
  30. package/src/channel-email/types.ts +1 -1
  31. package/src/compliance-profiles/README.md +88 -0
  32. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +308 -0
  33. package/src/compliance-profiles/__tests__/seeding.integration.ts +93 -0
  34. package/src/compliance-profiles/feature.ts +51 -0
  35. package/src/compliance-profiles/handlers/for-tenant.query.ts +64 -0
  36. package/src/compliance-profiles/handlers/list-profiles.query.ts +44 -0
  37. package/src/compliance-profiles/handlers/needs-profile.query.ts +56 -0
  38. package/src/compliance-profiles/handlers/set-profile.write.ts +144 -0
  39. package/src/compliance-profiles/handlers/sub-processors.query.ts +43 -0
  40. package/src/compliance-profiles/index.ts +6 -0
  41. package/src/compliance-profiles/resolve-for-tenant.ts +63 -0
  42. package/src/compliance-profiles/schema/profile-selection.ts +52 -0
  43. package/src/compliance-profiles/seeding.ts +96 -0
  44. package/src/config/resolver.ts +1 -1
  45. package/src/data-retention/__tests__/data-retention.integration.ts +49 -0
  46. package/src/data-retention/__tests__/keep-for.test.ts +77 -0
  47. package/src/data-retention/__tests__/override-schema.test.ts +96 -0
  48. package/src/data-retention/__tests__/policy-for.integration.ts +172 -0
  49. package/src/data-retention/__tests__/resolver.test.ts +201 -0
  50. package/src/data-retention/_internal/parse-override.ts +34 -0
  51. package/src/data-retention/feature.ts +57 -0
  52. package/src/data-retention/handlers/policy-for.query.ts +57 -0
  53. package/src/data-retention/index.ts +18 -0
  54. package/src/data-retention/keep-for.ts +75 -0
  55. package/src/data-retention/override-schema.ts +37 -0
  56. package/src/data-retention/presets.ts +72 -0
  57. package/src/data-retention/resolve-for-tenant.ts +50 -0
  58. package/src/data-retention/resolver.ts +107 -0
  59. package/src/data-retention/schema/tenant-retention-override.ts +47 -0
  60. package/src/delivery/feature.ts +1 -1
  61. package/src/delivery/testing.ts +1 -2
  62. package/src/delivery/upsert-preference.ts +1 -1
  63. package/src/feature-toggles/feature.ts +1 -1
  64. package/src/feature-toggles/handlers/list.query.ts +1 -1
  65. package/src/feature-toggles/handlers/registered.query.ts +9 -2
  66. package/src/feature-toggles/handlers/set.write.ts +3 -3
  67. package/src/file-foundation/feature.ts +44 -4
  68. package/src/file-foundation/index.ts +1 -0
  69. package/src/file-provider-inmemory/feature.ts +6 -3
  70. package/src/file-provider-s3/feature.ts +10 -12
  71. package/src/files/README.md +50 -0
  72. package/src/files/__tests__/files.integration.ts +157 -0
  73. package/src/files/feature.ts +34 -0
  74. package/src/files/index.ts +1 -0
  75. package/src/files/schema/file-ref.ts +58 -0
  76. package/src/files-provider-s3/s3-provider.ts +90 -1
  77. package/src/jobs/handlers/list.query.ts +3 -3
  78. package/src/jobs/handlers/trigger.write.ts +1 -1
  79. package/src/legal-pages/constants.ts +1 -0
  80. package/src/legal-pages/web/client-plugin.ts +42 -0
  81. package/src/legal-pages/web/index.ts +4 -0
  82. package/src/mail-foundation/feature.ts +1 -1
  83. package/src/mail-transport-smtp/feature.ts +2 -2
  84. package/src/renderer-simple/simple-renderer.ts +1 -1
  85. package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
  86. package/src/secrets/feature.ts +10 -6
  87. package/src/secrets/handlers/rotate.job.ts +2 -2
  88. package/src/sessions/constants.ts +4 -0
  89. package/src/sessions/feature.ts +3 -0
  90. package/src/sessions/handlers/cleanup.job.ts +2 -2
  91. package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
  92. package/src/step-dispatcher/feature.ts +62 -0
  93. package/src/step-dispatcher/index.ts +16 -0
  94. package/src/step-dispatcher/mail-runner.ts +32 -0
  95. package/src/step-dispatcher/webhook-runner.ts +67 -0
  96. package/src/subscription-mollie/plugin-methods.ts +1 -1
  97. package/src/subscription-mollie/verify-webhook.ts +9 -5
  98. package/src/subscription-stripe/verify-webhook.ts +3 -3
  99. package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
  100. package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
  101. package/src/tenant/handlers/remove-member.write.ts +1 -1
  102. package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
  103. package/src/tenant/handlers/update-member-roles.write.ts +3 -3
  104. package/src/text-content/constants.ts +2 -0
  105. package/src/text-content/feature.ts +20 -4
  106. package/src/text-content/handlers/by-tenant.query.ts +56 -0
  107. package/src/text-content/handlers/set.write.ts +1 -1
  108. package/src/text-content/web/client-plugin.ts +113 -0
  109. package/src/text-content/web/index.ts +8 -0
  110. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
  111. package/src/tier-engine/feature.ts +23 -13
  112. package/src/user/__tests__/user-status.test.ts +39 -0
  113. package/src/user/handlers/find-for-auth.query.ts +1 -1
  114. package/src/user/index.ts +11 -1
  115. package/src/user/schema/user.ts +76 -0
  116. package/src/user/seeding.ts +2 -2
  117. package/src/user-data-rights/COMPLIANCE.md +182 -0
  118. package/src/user-data-rights/README.md +109 -0
  119. package/src/user-data-rights/__tests__/audit-log.integration.ts +199 -0
  120. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +349 -0
  121. package/src/user-data-rights/__tests__/download.integration.ts +565 -0
  122. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +244 -0
  123. package/src/user-data-rights/__tests__/export-job-schema.test.ts +163 -0
  124. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +30 -0
  125. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +370 -0
  126. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +179 -0
  127. package/src/user-data-rights/__tests__/request-export.integration.ts +269 -0
  128. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +309 -0
  129. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +1124 -0
  130. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +703 -0
  131. package/src/user-data-rights/__tests__/run-user-export.integration.ts +291 -0
  132. package/src/user-data-rights/__tests__/token-helpers.test.ts +63 -0
  133. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +57 -0
  134. package/src/user-data-rights/__tests__/zip-path.test.ts +119 -0
  135. package/src/user-data-rights/audit-download.ts +125 -0
  136. package/src/user-data-rights/feature.ts +310 -0
  137. package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
  138. package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
  139. package/src/user-data-rights/handlers/download-by-token.query.ts +255 -0
  140. package/src/user-data-rights/handlers/export-status.query.ts +76 -0
  141. package/src/user-data-rights/handlers/lift-restriction.write.ts +68 -0
  142. package/src/user-data-rights/handlers/list-download-attempts.query.ts +53 -0
  143. package/src/user-data-rights/handlers/my-audit-log.query.ts +63 -0
  144. package/src/user-data-rights/handlers/request-deletion.write.ts +123 -0
  145. package/src/user-data-rights/handlers/request-export.write.ts +155 -0
  146. package/src/user-data-rights/handlers/restrict-account.write.ts +81 -0
  147. package/src/user-data-rights/handlers/run-forget-cleanup.write.ts +61 -0
  148. package/src/user-data-rights/i18n.ts +37 -0
  149. package/src/user-data-rights/index.ts +19 -0
  150. package/src/user-data-rights/run-export-jobs.ts +878 -0
  151. package/src/user-data-rights/run-forget-cleanup.ts +333 -0
  152. package/src/user-data-rights/run-user-export.ts +211 -0
  153. package/src/user-data-rights/schema/download-attempt.ts +37 -0
  154. package/src/user-data-rights/schema/download-token.ts +111 -0
  155. package/src/user-data-rights/schema/export-job.ts +166 -0
  156. package/src/user-data-rights/token-helpers.ts +67 -0
  157. package/src/user-data-rights/zip-path.ts +94 -0
  158. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +337 -0
  159. package/src/user-data-rights-defaults/feature.ts +40 -0
  160. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +109 -0
  161. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +91 -0
  162. package/src/user-data-rights-defaults/index.ts +6 -0
@@ -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
+ });