@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/package.json +11 -5
  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,370 @@
1
+ // Forget-Pfad mit Grace — request-deletion + cancel-deletion (S2.U5a).
2
+ //
3
+ // Pinst die Endpoint-Semantik vor dem Cleanup-Runner (S2.U5b):
4
+ // - Active → DeletionRequested + gracePeriodEnd = now + profile.graceDays
5
+ // - DeletionRequested → Active (nur innerhalb Grace) + gracePeriodEnd = NULL
6
+ // - Idempotenz / falsche State-Transitions / Grace-Period-Expiry
7
+ // - Compliance-Profile-Resolution wirklich greift (eu-dsgvo = 30 Tage)
8
+ //
9
+ // User-Explicit-Anforderung "exporte + fristen" — der Frist-Set-Pfad ist
10
+ // hier; der Frist-Ablauf-Cleanup folgt mit S2.U5b.
11
+
12
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
13
+ import {
14
+ createTestUser,
15
+ setupTestStack,
16
+ type TestStack,
17
+ testTenantId,
18
+ testUserId,
19
+ unsafeCreateEntityTable,
20
+ } from "@cosmicdrift/kumiko-framework/stack";
21
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
22
+ import { eq, sql } from "drizzle-orm";
23
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
24
+ import {
25
+ createComplianceProfilesFeature,
26
+ tenantComplianceProfileEntity,
27
+ } from "../../compliance-profiles";
28
+ import { createDataRetentionFeature } from "../../data-retention";
29
+ import { USER_STATUS, userEntity, userTable } from "../../user";
30
+ import { createUserFeature } from "../../user/feature";
31
+ import { createUserDataRightsFeature } from "../feature";
32
+
33
+ const REQUEST_DELETION = "user-data-rights:write:request-deletion";
34
+ const CANCEL_DELETION = "user-data-rights:write:cancel-deletion";
35
+ const SET_PROFILE = "compliance-profiles:write:set-profile";
36
+
37
+ let stack: TestStack;
38
+
39
+ const tenantA = testTenantId(1);
40
+ // Tenant-Admin fuer set-profile (Profile-Wahl ist privileged).
41
+ const tenantAdmin = createTestUser({
42
+ id: 1,
43
+ tenantId: tenantA,
44
+ roles: ["TenantAdmin"],
45
+ });
46
+ // Normaler User der seinen eigenen Forget-Antrag stellt.
47
+ const aliceUser = createTestUser({
48
+ id: 42,
49
+ tenantId: tenantA,
50
+ roles: ["Member"],
51
+ });
52
+
53
+ const features = [
54
+ createUserFeature(),
55
+ createDataRetentionFeature(),
56
+ createComplianceProfilesFeature(),
57
+ createUserDataRightsFeature(),
58
+ ];
59
+
60
+ beforeAll(async () => {
61
+ stack = await setupTestStack({ features });
62
+ await unsafeCreateEntityTable(stack.db, userEntity);
63
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
64
+ await createEventsTable(stack.db);
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await stack.cleanup();
69
+ });
70
+
71
+ beforeEach(async () => {
72
+ // Hard-clean User-Rows fuer einen sauberen Start je Test. softDelete
73
+ // wuerde sonst row-state aus voherigen Tests einschleppen.
74
+ await stack.db.delete(userTable);
75
+ await stack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
76
+ await stack.db.execute(sql`DELETE FROM kumiko_events`);
77
+ });
78
+
79
+ // gracePeriodEnd ist `instant()` (Temporal.Instant in JS). Nicht JS-Date —
80
+ // die customType-Codec wirft sonst beim Insert "time zone gmt+0200 not
81
+ // recognized".
82
+ type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
83
+
84
+ function instantFromOffsetMs(offsetMs: number): Instant {
85
+ return getTemporal().Instant.fromEpochMilliseconds(Date.now() + offsetMs);
86
+ }
87
+
88
+ async function seedAlice(
89
+ overrides: Partial<{
90
+ status: string;
91
+ gracePeriodEnd: Instant | null;
92
+ }> = {},
93
+ ): Promise<void> {
94
+ await stack.db.insert(userTable).values({
95
+ id: aliceUser.id,
96
+ tenantId: tenantA,
97
+ email: "alice@example.com",
98
+ passwordHash: "hashed",
99
+ displayName: "Alice",
100
+ locale: "de",
101
+ emailVerified: true,
102
+ roles: '["Member"]',
103
+ status: overrides.status ?? USER_STATUS.Active,
104
+ gracePeriodEnd: overrides.gracePeriodEnd ?? null,
105
+ });
106
+ }
107
+
108
+ async function fetchAlice(): Promise<{
109
+ status: string;
110
+ gracePeriodEnd: Instant | null;
111
+ } | null> {
112
+ const rows = (await stack.db
113
+ .select({
114
+ status: userTable["status"],
115
+ gracePeriodEnd: userTable["gracePeriodEnd"],
116
+ })
117
+ .from(userTable)
118
+ .where(eq(userTable["id"], aliceUser.id))
119
+ .limit(1)) as Array<{ status: string; gracePeriodEnd: Instant | null }>;
120
+ return rows[0] ?? null;
121
+ }
122
+
123
+ type RequestDeletionResponse = {
124
+ userId: string;
125
+ status: string;
126
+ // ISO-8601-Timestamp (Temporal.Instant.toString()) — der absolute
127
+ // Cleanup-Trigger-Zeitpunkt, denselben Wert den der Cleanup-Runner
128
+ // (S2.U5b) gegen now() vergleicht.
129
+ gracePeriodEnd: string;
130
+ };
131
+
132
+ type CancelDeletionResponse = {
133
+ userId: string;
134
+ status: string;
135
+ gracePeriodEnd: string | null;
136
+ };
137
+
138
+ describe("POST request-deletion :: happy path", () => {
139
+ test("Active-User → status=deletionRequested + gracePeriodEnd ~30 Tage (eu-dsgvo)", async () => {
140
+ await seedAlice();
141
+ // eu-dsgvo gibt gracePeriod={days:30} — Default-fallback minimal-no-region
142
+ // hat ebenfalls 30 days, aber wir setzen explizit damit Eingang der
143
+ // Profile-Resolution sichtbar ist.
144
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, tenantAdmin);
145
+
146
+ const result = await stack.http.writeOk<RequestDeletionResponse>(
147
+ REQUEST_DELETION,
148
+ {},
149
+ aliceUser,
150
+ );
151
+
152
+ expect(result.status).toBe(USER_STATUS.DeletionRequested);
153
+ expect(result.userId).toBe(aliceUser.id);
154
+
155
+ // gracePeriodEnd liegt zwischen +29d und +31d (Drift-Toleranz). Pinst
156
+ // sowohl API-Contract (Response liefert ISO-Timestamp) als auch DB-
157
+ // State (fetchAlice's Instant) in derselben Frist-Aussage.
158
+ const responseGraceMs = new Date(result.gracePeriodEnd).getTime() - Date.now();
159
+ expect(responseGraceMs).toBeGreaterThan(29 * 24 * 60 * 60 * 1000);
160
+ expect(responseGraceMs).toBeLessThan(31 * 24 * 60 * 60 * 1000);
161
+
162
+ const row = await fetchAlice();
163
+ expect(row?.status).toBe(USER_STATUS.DeletionRequested);
164
+ expect(row?.gracePeriodEnd).not.toBeNull();
165
+ // DB-Wert == Response-Wert: derselbe Cleanup-Trigger-Timestamp.
166
+ expect(row?.gracePeriodEnd?.toString()).toBe(result.gracePeriodEnd);
167
+ });
168
+
169
+ test("ohne explizites Profile → minimal-no-region-Fallback (~30 Tage)", async () => {
170
+ await seedAlice();
171
+ // Kein SET_PROFILE → resolveComplianceProfile fallt auf
172
+ // minimal-no-region (warning="no-profile-selected") zurueck.
173
+ const result = await stack.http.writeOk<RequestDeletionResponse>(
174
+ REQUEST_DELETION,
175
+ {},
176
+ aliceUser,
177
+ );
178
+ const graceMs = new Date(result.gracePeriodEnd).getTime() - Date.now();
179
+ expect(graceMs).toBeGreaterThan(29 * 24 * 60 * 60 * 1000);
180
+ expect(graceMs).toBeLessThan(31 * 24 * 60 * 60 * 1000);
181
+ });
182
+
183
+ // Advisor-Finding S2.U5a: hours-Override wurde stillschweigend auf 30d
184
+ // gefallen weil "days" in spec false war. Pinst den Fix via
185
+ // addDurationSpec — sowohl `{days}` als auch `{hours}` werden korrekt
186
+ // addiert.
187
+ test("Profile-Override {hours: 6} → ~6h Grace (kein 30d-Fallback)", async () => {
188
+ await seedAlice();
189
+ await stack.http.writeOk(
190
+ SET_PROFILE,
191
+ {
192
+ profileKey: "eu-dsgvo",
193
+ override: JSON.stringify({ userRights: { gracePeriod: { hours: 6 } } }),
194
+ },
195
+ tenantAdmin,
196
+ );
197
+
198
+ const result = await stack.http.writeOk<RequestDeletionResponse>(
199
+ REQUEST_DELETION,
200
+ {},
201
+ aliceUser,
202
+ );
203
+ // Toleranzfenster: +5h..+7h (vorher: stilles 30d-Fallback ware ~720h).
204
+ const graceMs = new Date(result.gracePeriodEnd).getTime() - Date.now();
205
+ expect(graceMs).toBeGreaterThan(5 * 60 * 60 * 1000);
206
+ expect(graceMs).toBeLessThan(7 * 60 * 60 * 1000);
207
+ });
208
+ });
209
+
210
+ // UnprocessableError serialisiert als code="unprocessable" + details.reason
211
+ // = unsere konkrete Begruendung. Helper macht assert-Site lesbar.
212
+ function reason(err: { details?: unknown }): string | undefined {
213
+ return (err.details as { reason?: string } | undefined)?.reason;
214
+ }
215
+
216
+ describe("POST request-deletion :: state-transitions", () => {
217
+ test("User existiert nicht → 422 user_not_found", async () => {
218
+ // Alice nicht gesseedet — der Handler liest die Row.
219
+ const err = await stack.http.writeErr(REQUEST_DELETION, {}, aliceUser);
220
+ expect(err.code).toBe("unprocessable");
221
+ expect(err.httpStatus).toBe(422);
222
+ expect(reason(err)).toBe("user_not_found");
223
+ });
224
+
225
+ test("schon im DeletionRequested-State → 422 user_not_in_active_state (idempotenz-guard)", async () => {
226
+ await seedAlice({ status: USER_STATUS.DeletionRequested });
227
+ const err = await stack.http.writeErr(REQUEST_DELETION, {}, aliceUser);
228
+ expect(reason(err)).toBe("user_not_in_active_state");
229
+ expect((err.details as { currentStatus?: string })?.currentStatus).toBe(
230
+ USER_STATUS.DeletionRequested,
231
+ );
232
+ });
233
+
234
+ test("im Restricted-State (Art. 18) → 422 user_not_in_active_state", async () => {
235
+ await seedAlice({ status: USER_STATUS.Restricted });
236
+ const err = await stack.http.writeErr(REQUEST_DELETION, {}, aliceUser);
237
+ expect(reason(err)).toBe("user_not_in_active_state");
238
+ });
239
+
240
+ test("schon Deleted → 422 user_not_in_active_state", async () => {
241
+ await seedAlice({ status: USER_STATUS.Deleted });
242
+ const err = await stack.http.writeErr(REQUEST_DELETION, {}, aliceUser);
243
+ expect(reason(err)).toBe("user_not_in_active_state");
244
+ });
245
+ });
246
+
247
+ describe("POST cancel-deletion :: happy path", () => {
248
+ test("innerhalb Grace → status=Active + gracePeriodEnd=NULL", async () => {
249
+ const futureGrace = instantFromOffsetMs(25 * 24 * 60 * 60 * 1000);
250
+ await seedAlice({
251
+ status: USER_STATUS.DeletionRequested,
252
+ gracePeriodEnd: futureGrace,
253
+ });
254
+
255
+ const result = await stack.http.writeOk<CancelDeletionResponse>(CANCEL_DELETION, {}, aliceUser);
256
+ expect(result.status).toBe(USER_STATUS.Active);
257
+ expect(result.gracePeriodEnd).toBeNull();
258
+
259
+ const row = await fetchAlice();
260
+ expect(row?.status).toBe(USER_STATUS.Active);
261
+ expect(row?.gracePeriodEnd).toBeNull();
262
+ });
263
+
264
+ test("request → cancel Roundtrip ist clean (state komplett zurueck auf Active+NULL)", async () => {
265
+ await seedAlice();
266
+ await stack.http.writeOk(REQUEST_DELETION, {}, aliceUser);
267
+
268
+ const requestedRow = await fetchAlice();
269
+ expect(requestedRow?.status).toBe(USER_STATUS.DeletionRequested);
270
+ expect(requestedRow?.gracePeriodEnd).not.toBeNull();
271
+
272
+ await stack.http.writeOk(CANCEL_DELETION, {}, aliceUser);
273
+ const cancelledRow = await fetchAlice();
274
+ expect(cancelledRow?.status).toBe(USER_STATUS.Active);
275
+ expect(cancelledRow?.gracePeriodEnd).toBeNull();
276
+ });
277
+ });
278
+
279
+ describe("POST cancel-deletion :: state-transitions", () => {
280
+ test("User existiert nicht → 422 user_not_found", async () => {
281
+ const err = await stack.http.writeErr(CANCEL_DELETION, {}, aliceUser);
282
+ expect(err.httpStatus).toBe(422);
283
+ expect(reason(err)).toBe("user_not_found");
284
+ });
285
+
286
+ test("kein pending Forget (status=Active) → 422 no_pending_deletion", async () => {
287
+ await seedAlice();
288
+ const err = await stack.http.writeErr(CANCEL_DELETION, {}, aliceUser);
289
+ expect(reason(err)).toBe("no_pending_deletion");
290
+ expect((err.details as { currentStatus?: string })?.currentStatus).toBe(USER_STATUS.Active);
291
+ });
292
+
293
+ test("im Restricted-State (Art. 18) → 422 no_pending_deletion", async () => {
294
+ await seedAlice({ status: USER_STATUS.Restricted });
295
+ const err = await stack.http.writeErr(CANCEL_DELETION, {}, aliceUser);
296
+ expect(reason(err)).toBe("no_pending_deletion");
297
+ });
298
+
299
+ test("Grace abgelaufen (gracePeriodEnd in Vergangenheit) → 422 grace_period_expired", async () => {
300
+ const pastGrace = instantFromOffsetMs(-60 * 1000);
301
+ await seedAlice({
302
+ status: USER_STATUS.DeletionRequested,
303
+ gracePeriodEnd: pastGrace,
304
+ });
305
+ const err = await stack.http.writeErr(CANCEL_DELETION, {}, aliceUser);
306
+ expect(reason(err)).toBe("grace_period_expired");
307
+ // Bewusst nicht reversibel — Cleanup-Runner darf in der Zwischenzeit
308
+ // schon angelaufen sein, Reversal waere data-loss-Risiko.
309
+ });
310
+ });
311
+
312
+ describe("Cross-Tenant-Account-Semantik", () => {
313
+ // User-Entity ist tenant-agnostisch — `status` und `gracePeriodEnd`
314
+ // sind globale Spalten am User-Row. request-deletion in Tenant A
315
+ // flippt den User-Row global, alle Tenants sehen den User als
316
+ // deletionRequested. Pinst die Default-Semantik fuer DSGVO Art. 17
317
+ // (Account-weite Loeschung, nicht Per-Mandant). Wer nur einen Tenant
318
+ // verlassen will, nutzt leave-tenant — kein Forget-Pfad.
319
+ test("request-deletion in Tenant A flippt User global (sichtbar fuer alle Memberships)", async () => {
320
+ await seedAlice(); // Alice landet als 1 User-Row, status=Active.
321
+
322
+ // request aus Tenant A.
323
+ await stack.http.writeOk(REQUEST_DELETION, {}, aliceUser);
324
+
325
+ // Sicht aus Tenant B — derselbe User-Row, also ist status auch hier
326
+ // deletionRequested. Wir koennen das ueber den User-Row direkt pinnen
327
+ // (kein per-Membership-State); ein zweiter request-from-B wuerde
328
+ // mit "user_not_in_active_state" abgelehnt werden, was die Globalitaet
329
+ // beweist.
330
+ const aliceFromB = createTestUser({
331
+ id: 42,
332
+ tenantId: testTenantId(2),
333
+ roles: ["Member"],
334
+ });
335
+ const err = await stack.http.writeErr(REQUEST_DELETION, {}, aliceFromB);
336
+ expect(reason(err)).toBe("user_not_in_active_state");
337
+ expect((err.details as { currentStatus?: string })?.currentStatus).toBe(
338
+ USER_STATUS.DeletionRequested,
339
+ );
340
+ });
341
+ });
342
+
343
+ describe("Cross-User-Isolation", () => {
344
+ test("Bobs request-deletion ueberschreibt nicht Alices state", async () => {
345
+ await seedAlice();
346
+ const bobUser = createTestUser({
347
+ id: 43,
348
+ tenantId: tenantA,
349
+ roles: ["Member"],
350
+ });
351
+ await stack.db.insert(userTable).values({
352
+ id: testUserId(43),
353
+ tenantId: tenantA,
354
+ email: "bob@example.com",
355
+ passwordHash: "hashed",
356
+ displayName: "Bob",
357
+ locale: "de",
358
+ emailVerified: true,
359
+ roles: '["Member"]',
360
+ status: USER_STATUS.Active,
361
+ });
362
+
363
+ await stack.http.writeOk(REQUEST_DELETION, {}, bobUser);
364
+
365
+ // Alice unverändert.
366
+ const aliceRow = await fetchAlice();
367
+ expect(aliceRow?.status).toBe(USER_STATUS.Active);
368
+ expect(aliceRow?.gracePeriodEnd).toBeNull();
369
+ });
370
+ });
@@ -0,0 +1,179 @@
1
+ // Atom 5b — sendDeletionRequestedEmail Callback (DSGVO Art. 17
2
+ // "Geheimes Versprechen"-Email).
3
+ //
4
+ // Pinst dass createUserDataRightsFeature({ sendDeletionRequestedEmail })
5
+ // die App-Author-Callback bei erfolgreichem deletion-requested-Flip
6
+ // feuert UND best-effort ist (send-failure killt den Status-Flip nicht).
7
+ // Der Code-Comment in handlers/request-deletion.write.ts behauptet beide
8
+ // Properties — dieser Test verifiziert sie end-to-end.
9
+
10
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createTestUser,
13
+ setupTestStack,
14
+ type TestStack,
15
+ testTenantId,
16
+ unsafeCreateEntityTable,
17
+ } from "@cosmicdrift/kumiko-framework/stack";
18
+ import { eq, sql } from "drizzle-orm";
19
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
20
+ import {
21
+ createComplianceProfilesFeature,
22
+ tenantComplianceProfileEntity,
23
+ } from "../../compliance-profiles";
24
+ import { createDataRetentionFeature } from "../../data-retention";
25
+ import { USER_STATUS, userEntity, userTable } from "../../user";
26
+ import { createUserFeature } from "../../user/feature";
27
+ import { createUserDataRightsFeature } from "../feature";
28
+ import type { SendDeletionRequestedEmailFn } from "../handlers/request-deletion.write";
29
+
30
+ const REQUEST_DELETION = "user-data-rights:write:request-deletion";
31
+
32
+ let stack: TestStack;
33
+
34
+ const tenantA = testTenantId(1);
35
+ const aliceUser = createTestUser({
36
+ id: 42,
37
+ tenantId: tenantA,
38
+ roles: ["Member"],
39
+ });
40
+
41
+ // Mutable callback-State pro Test: ein Closure-Hook der pro beforeEach
42
+ // reset wird. Stack-Setup laesst sich nicht pro-Test variieren, deshalb
43
+ // ist die Indirection ueber `state` noetig.
44
+ type CallbackArgs = Parameters<SendDeletionRequestedEmailFn>[0];
45
+ type CallbackState = {
46
+ calls: CallbackArgs[];
47
+ shouldThrow: boolean;
48
+ };
49
+ const state: CallbackState = { calls: [], shouldThrow: false };
50
+
51
+ const sendDeletionRequestedEmail: SendDeletionRequestedEmailFn = async (args) => {
52
+ state.calls.push(args);
53
+ if (state.shouldThrow) {
54
+ throw new Error("synthetic email transport failure");
55
+ }
56
+ };
57
+
58
+ beforeAll(async () => {
59
+ stack = await setupTestStack({
60
+ features: [
61
+ createUserFeature(),
62
+ createDataRetentionFeature(),
63
+ createComplianceProfilesFeature(),
64
+ createUserDataRightsFeature({ sendDeletionRequestedEmail }),
65
+ ],
66
+ });
67
+ await unsafeCreateEntityTable(stack.db, userEntity);
68
+ await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
69
+ await createEventsTable(stack.db);
70
+ });
71
+
72
+ afterAll(async () => {
73
+ await stack.cleanup();
74
+ });
75
+
76
+ beforeEach(async () => {
77
+ state.calls = [];
78
+ state.shouldThrow = false;
79
+ await stack.db.delete(userTable);
80
+ await stack.db.execute(sql`DELETE FROM read_tenant_compliance_profiles`);
81
+ await stack.db.execute(sql`DELETE FROM kumiko_events`);
82
+ });
83
+
84
+ async function seedAlice(email: string = "alice@example.com"): Promise<void> {
85
+ await stack.db.insert(userTable).values({
86
+ id: aliceUser.id,
87
+ tenantId: tenantA,
88
+ email,
89
+ passwordHash: "hashed",
90
+ displayName: "Alice",
91
+ locale: "de",
92
+ emailVerified: true,
93
+ roles: '["Member"]',
94
+ status: USER_STATUS.Active,
95
+ gracePeriodEnd: null,
96
+ });
97
+ }
98
+
99
+ describe("request-deletion :: sendDeletionRequestedEmail callback", () => {
100
+ test("happy: callback feuert mit userEmail + tenantId + gracePeriodEnd nach Status-Flip", async () => {
101
+ const ORIGINAL_EMAIL = "alice.callback.requested@example.com";
102
+ await seedAlice(ORIGINAL_EMAIL);
103
+
104
+ const result = await stack.http.writeOk<{
105
+ userId: string;
106
+ status: string;
107
+ gracePeriodEnd: string;
108
+ }>(REQUEST_DELETION, {}, aliceUser);
109
+
110
+ expect(result.status).toBe(USER_STATUS.DeletionRequested);
111
+
112
+ expect(state.calls).toHaveLength(1);
113
+ const call = state.calls[0];
114
+ expect(call?.userId).toBe(aliceUser.id);
115
+ expect(call?.userEmail).toBe(ORIGINAL_EMAIL);
116
+ expect(call?.tenantId).toBe(tenantA);
117
+ // gracePeriodEnd-Mapping: Response-Wert == Callback-Wert. Beide
118
+ // beziehen sich auf denselben Cleanup-Trigger-Timestamp.
119
+ expect(call?.gracePeriodEnd).toBe(result.gracePeriodEnd);
120
+ });
121
+
122
+ test("best-effort: send-throw killt Status-Flip NICHT (DB-State + Response success)", async () => {
123
+ state.shouldThrow = true;
124
+ await seedAlice();
125
+
126
+ // Trotz callback-Throw bleibt der Write erfolgreich.
127
+ const result = await stack.http.writeOk<{
128
+ userId: string;
129
+ status: string;
130
+ gracePeriodEnd: string;
131
+ }>(REQUEST_DELETION, {}, aliceUser);
132
+ expect(result.status).toBe(USER_STATUS.DeletionRequested);
133
+
134
+ // Callback wurde aufgerufen (vor dem Throw).
135
+ expect(state.calls).toHaveLength(1);
136
+
137
+ // DB-State ist tatsaechlich geflipt — der zentrale "best-effort"-
138
+ // Beweis. Wenn das Write die Email-Failure-Exception bubbelt, waere
139
+ // der Status hier noch Active.
140
+ const rows = (await stack.db
141
+ .select({ status: userTable["status"] })
142
+ .from(userTable)
143
+ .where(eq(userTable["id"], aliceUser.id))
144
+ .limit(1)) as Array<{ status: string }>;
145
+ expect(rows[0]?.status).toBe(USER_STATUS.DeletionRequested);
146
+ });
147
+
148
+ test("422 user_not_found → callback NICHT gefeuert", async () => {
149
+ // Alice nicht gesseedet — der Pre-Check failt.
150
+ await stack.http.writeErr(REQUEST_DELETION, {}, aliceUser);
151
+ expect(state.calls).toHaveLength(0);
152
+ });
153
+
154
+ test("422 user_not_in_active_state → callback NICHT gefeuert", async () => {
155
+ await stack.db.insert(userTable).values({
156
+ id: aliceUser.id,
157
+ tenantId: tenantA,
158
+ email: "alice@example.com",
159
+ passwordHash: "hashed",
160
+ displayName: "Alice",
161
+ locale: "de",
162
+ emailVerified: true,
163
+ roles: '["Member"]',
164
+ status: USER_STATUS.DeletionRequested,
165
+ });
166
+ await stack.http.writeErr(REQUEST_DELETION, {}, aliceUser);
167
+ expect(state.calls).toHaveLength(0);
168
+ });
169
+
170
+ test("user mit leerem email-Feld → callback NICHT gefeuert (skip ohne crash)", async () => {
171
+ // Edge-Case: User-Row hat email="" (z.B. nach voriger Anonymisierung
172
+ // die status haengen liess). Skip schuetzt vor invalid-callback-Args.
173
+ await seedAlice("");
174
+
175
+ const result = await stack.http.writeOk<{ status: string }>(REQUEST_DELETION, {}, aliceUser);
176
+ expect(result.status).toBe(USER_STATUS.DeletionRequested);
177
+ expect(state.calls).toHaveLength(0);
178
+ });
179
+ });