@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.
- package/CHANGELOG.md +91 -0
- package/package.json +22 -13
- 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/change-password.write.ts +1 -1
- package/src/auth-email-password/handlers/confirm-token-flow.ts +1 -1
- package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +7 -7
- package/src/auth-email-password/handlers/invite-accept.write.ts +7 -6
- package/src/auth-email-password/handlers/invite-create.write.ts +3 -3
- package/src/auth-email-password/handlers/invite-signup-complete.write.ts +4 -4
- package/src/auth-email-password/handlers/login.write.ts +32 -2
- package/src/auth-email-password/handlers/logout.write.ts +2 -2
- package/src/auth-email-password/handlers/signup-confirm.write.ts +1 -1
- package/src/auth-email-password/i18n.ts +4 -0
- package/src/auth-email-password/web/auth-client.ts +1 -1
- package/src/billing-foundation/events.ts +1 -1
- package/src/billing-foundation/feature.ts +44 -47
- package/src/billing-foundation/handlers/create-portal-session.write.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +3 -3
- package/src/billing-foundation/projection.ts +1 -1
- package/src/billing-foundation/webhook-handler.ts +1 -1
- package/src/cap-counter/constants.ts +1 -1
- package/src/cap-counter/enforce-cap.ts +1 -1
- package/src/cap-counter/feature.ts +3 -7
- package/src/cap-counter/handlers/get-counter.query.ts +1 -1
- package/src/cap-counter/handlers/increment-rolling.write.ts +2 -2
- package/src/cap-counter/handlers/increment.write.ts +3 -3
- package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
- package/src/channel-email/email-channel.ts +1 -1
- package/src/channel-email/types.ts +1 -1
- 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 +64 -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 +144 -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 +63 -0
- package/src/compliance-profiles/schema/profile-selection.ts +52 -0
- package/src/compliance-profiles/seeding.ts +96 -0
- package/src/config/resolver.ts +1 -1
- 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 +34 -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/delivery/feature.ts +1 -1
- package/src/delivery/testing.ts +1 -2
- package/src/delivery/upsert-preference.ts +1 -1
- package/src/feature-toggles/feature.ts +1 -1
- package/src/feature-toggles/handlers/list.query.ts +1 -1
- package/src/feature-toggles/handlers/registered.query.ts +9 -2
- package/src/feature-toggles/handlers/set.write.ts +3 -3
- package/src/file-foundation/feature.ts +44 -4
- package/src/file-foundation/index.ts +1 -0
- package/src/file-provider-inmemory/feature.ts +6 -3
- package/src/file-provider-s3/feature.ts +10 -12
- 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 +90 -1
- package/src/jobs/handlers/list.query.ts +3 -3
- package/src/jobs/handlers/trigger.write.ts +1 -1
- package/src/legal-pages/constants.ts +1 -0
- package/src/legal-pages/web/client-plugin.ts +42 -0
- package/src/legal-pages/web/index.ts +4 -0
- package/src/mail-foundation/feature.ts +1 -1
- package/src/mail-transport-smtp/feature.ts +2 -2
- package/src/renderer-simple/simple-renderer.ts +1 -1
- package/src/secrets/__tests__/require-secrets-context.test.ts +81 -0
- package/src/secrets/feature.ts +10 -6
- package/src/secrets/handlers/rotate.job.ts +2 -2
- package/src/sessions/constants.ts +4 -0
- package/src/sessions/feature.ts +3 -0
- package/src/sessions/handlers/cleanup.job.ts +2 -2
- package/src/sessions/handlers/revoke-all-for-user.write.ts +42 -0
- package/src/step-dispatcher/feature.ts +62 -0
- package/src/step-dispatcher/index.ts +16 -0
- package/src/step-dispatcher/mail-runner.ts +32 -0
- package/src/step-dispatcher/webhook-runner.ts +67 -0
- package/src/subscription-mollie/plugin-methods.ts +1 -1
- package/src/subscription-mollie/verify-webhook.ts +9 -5
- package/src/subscription-stripe/verify-webhook.ts +3 -3
- package/src/tenant/handlers/active-tenant-ids.query.ts +1 -1
- package/src/tenant/handlers/cancel-invitation.write.ts +1 -1
- package/src/tenant/handlers/remove-member.write.ts +1 -1
- package/src/tenant/handlers/resolve-user-ids.query.ts +1 -1
- package/src/tenant/handlers/update-member-roles.write.ts +3 -3
- package/src/text-content/constants.ts +2 -0
- package/src/text-content/feature.ts +20 -4
- package/src/text-content/handlers/by-tenant.query.ts +56 -0
- package/src/text-content/handlers/set.write.ts +1 -1
- package/src/text-content/web/client-plugin.ts +113 -0
- package/src/text-content/web/index.ts +8 -0
- package/src/tier-engine/__tests__/auto-default-tier.integration.ts +118 -0
- package/src/tier-engine/feature.ts +23 -13
- package/src/user/__tests__/user-status.test.ts +39 -0
- package/src/user/handlers/find-for-auth.query.ts +1 -1
- package/src/user/index.ts +11 -1
- package/src/user/schema/user.ts +76 -0
- package/src/user/seeding.ts +2 -2
- 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 +310 -0
- package/src/user-data-rights/handlers/cancel-deletion.write.ts +84 -0
- package/src/user-data-rights/handlers/download-by-job.query.ts +206 -0
- package/src/user-data-rights/handlers/download-by-token.query.ts +255 -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 +333 -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,703 @@
|
|
|
1
|
+
// Forget-Cleanup-Runner Integration-Test (S2.U5b).
|
|
2
|
+
//
|
|
3
|
+
// User-Explicit-Anforderung "nach Löschfrist sollte es keine Daten mehr
|
|
4
|
+
// haben + Cross-Data-Matrix": Dieser Test fuehrt den Pipeline-Lauf aus
|
|
5
|
+
// und beweist:
|
|
6
|
+
// - Abgelaufene Grace + DeletionRequested → User-Row anonymisiert,
|
|
7
|
+
// status=Deleted, alle file_refs des Users in ALLEN Tenants weg.
|
|
8
|
+
// - Cross-Tenant: Alice in Tenant A + B, Forget triggert Hook-Iteration
|
|
9
|
+
// ueber beide Memberships.
|
|
10
|
+
// - Future-Grace: User mit gracePeriodEnd > now bleibt unangetastet.
|
|
11
|
+
// - Other-User-Isolation: Bob (active) keine Daten verloren.
|
|
12
|
+
// - Idempotent: zweiter Run ist no-op.
|
|
13
|
+
//
|
|
14
|
+
// Strategy-Mapping (retention.strategy → UserDataDeleteStrategy) ist im
|
|
15
|
+
// Unit-Test pinned (policy-to-strategy.test.ts), nicht hier. Hier nur
|
|
16
|
+
// der end-to-end-Default-Pfad (delete).
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
unsafeCreateEntityTable,
|
|
22
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
23
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
24
|
+
import { sql } from "drizzle-orm";
|
|
25
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
|
|
26
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
27
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
28
|
+
import { createFilesFeature } from "../../files";
|
|
29
|
+
import {
|
|
30
|
+
createUserFeature,
|
|
31
|
+
USER_ANONYMIZED_DISPLAY_NAME,
|
|
32
|
+
USER_DELETED_DISPLAY_NAME,
|
|
33
|
+
USER_STATUS,
|
|
34
|
+
userEntity,
|
|
35
|
+
userTable,
|
|
36
|
+
} from "../../user";
|
|
37
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
38
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
39
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
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
|
+
// Deterministische UUIDs fuer Tests — gleiche Helper wie in
|
|
48
|
+
// user-data-rights-defaults.
|
|
49
|
+
function uuid(suffix: number): string {
|
|
50
|
+
return `aaaaaaaa-aaaa-4aaa-8aaa-${suffix.toString(16).padStart(12, "0")}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const ALICE_ID = uuid(1);
|
|
54
|
+
const BOB_ID = uuid(2);
|
|
55
|
+
const FUTURE_USER_ID = uuid(3);
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
stack = await setupTestStack({
|
|
59
|
+
features: [
|
|
60
|
+
createUserFeature(),
|
|
61
|
+
createFilesFeature(),
|
|
62
|
+
createDataRetentionFeature(),
|
|
63
|
+
createComplianceProfilesFeature(),
|
|
64
|
+
createUserDataRightsFeature(),
|
|
65
|
+
createUserDataRightsDefaultsFeature(),
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
70
|
+
await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
|
|
71
|
+
// tenant-membership-Tabelle (von tenant-feature) manuell anlegen weil
|
|
72
|
+
// wir ohne tenant-feature im stack arbeiten — minimaler Setup.
|
|
73
|
+
await stack.db.execute(sql`
|
|
74
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
75
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
76
|
+
tenant_id UUID NOT NULL,
|
|
77
|
+
user_id TEXT NOT NULL,
|
|
78
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
80
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
81
|
+
inserted_by_id TEXT,
|
|
82
|
+
modified_by_id TEXT,
|
|
83
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
84
|
+
deleted_at TIMESTAMPTZ,
|
|
85
|
+
deleted_by_id TEXT,
|
|
86
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
87
|
+
UNIQUE(user_id, tenant_id)
|
|
88
|
+
)
|
|
89
|
+
`);
|
|
90
|
+
await stack.db.execute(sql`
|
|
91
|
+
CREATE TABLE IF NOT EXISTS file_refs (
|
|
92
|
+
id UUID PRIMARY KEY,
|
|
93
|
+
tenant_id UUID NOT NULL,
|
|
94
|
+
storage_key TEXT NOT NULL,
|
|
95
|
+
file_name TEXT NOT NULL,
|
|
96
|
+
mime_type TEXT NOT NULL,
|
|
97
|
+
size INTEGER NOT NULL,
|
|
98
|
+
entity_type TEXT,
|
|
99
|
+
entity_id TEXT,
|
|
100
|
+
field_name TEXT,
|
|
101
|
+
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
102
|
+
inserted_by_id TEXT
|
|
103
|
+
)
|
|
104
|
+
`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
afterAll(async () => {
|
|
108
|
+
await stack.cleanup();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
beforeEach(async () => {
|
|
112
|
+
await stack.db.delete(userTable);
|
|
113
|
+
await stack.db.execute(sql`DELETE FROM read_tenant_memberships`);
|
|
114
|
+
await stack.db.execute(sql`DELETE FROM file_refs`);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
118
|
+
|
|
119
|
+
function instantFromOffsetMs(offsetMs: number): Instant {
|
|
120
|
+
return getTemporal().Instant.fromEpochMilliseconds(Date.now() + offsetMs);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
124
|
+
|
|
125
|
+
async function seedUser(
|
|
126
|
+
id: string,
|
|
127
|
+
overrides: {
|
|
128
|
+
status?: string;
|
|
129
|
+
gracePeriodEnd?: Instant | null;
|
|
130
|
+
email?: string;
|
|
131
|
+
displayName?: string;
|
|
132
|
+
} = {},
|
|
133
|
+
): Promise<void> {
|
|
134
|
+
await stack.db.insert(userTable).values({
|
|
135
|
+
id,
|
|
136
|
+
tenantId: TENANT_SYSTEM,
|
|
137
|
+
email: overrides.email ?? `user-${id}@example.com`,
|
|
138
|
+
passwordHash: "hashed",
|
|
139
|
+
displayName: overrides.displayName ?? `User ${id}`,
|
|
140
|
+
locale: "de",
|
|
141
|
+
emailVerified: true,
|
|
142
|
+
roles: '["Member"]',
|
|
143
|
+
status: overrides.status ?? USER_STATUS.Active,
|
|
144
|
+
gracePeriodEnd: overrides.gracePeriodEnd ?? null,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
149
|
+
await stack.db.execute(sql`
|
|
150
|
+
INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
151
|
+
VALUES (${tenantId}, ${userId}, '["Member"]')
|
|
152
|
+
ON CONFLICT (user_id, tenant_id) DO NOTHING
|
|
153
|
+
`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function seedFileRef(
|
|
157
|
+
id: string,
|
|
158
|
+
tenantId: string,
|
|
159
|
+
insertedById: string | null,
|
|
160
|
+
fileName: string,
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
await stack.db.execute(sql`
|
|
163
|
+
INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
164
|
+
VALUES (${id}, ${tenantId}, ${`storage/${id}`}, ${fileName}, 'application/pdf', 1024, ${insertedById})
|
|
165
|
+
ON CONFLICT (id) DO NOTHING
|
|
166
|
+
`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function fetchUser(id: string): Promise<{
|
|
170
|
+
email: string;
|
|
171
|
+
display_name: string;
|
|
172
|
+
password_hash: string | null;
|
|
173
|
+
status: string;
|
|
174
|
+
deleted_at: string | null;
|
|
175
|
+
} | null> {
|
|
176
|
+
const result = await stack.db.execute(sql`
|
|
177
|
+
SELECT email, display_name, password_hash, status, deleted_at
|
|
178
|
+
FROM read_users WHERE id = ${id}
|
|
179
|
+
`);
|
|
180
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
181
|
+
const rows = ((result as any).rows ?? result) as Array<{
|
|
182
|
+
email: string;
|
|
183
|
+
display_name: string;
|
|
184
|
+
password_hash: string | null;
|
|
185
|
+
status: string;
|
|
186
|
+
deleted_at: string | null;
|
|
187
|
+
}>;
|
|
188
|
+
return rows[0] ?? null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function fetchFileRefsForUser(tenantId: string, userId: string): Promise<unknown[]> {
|
|
192
|
+
const result = await stack.db.execute(sql`
|
|
193
|
+
SELECT id, file_name, inserted_by_id
|
|
194
|
+
FROM file_refs WHERE tenant_id = ${tenantId} AND inserted_by_id = ${userId}
|
|
195
|
+
`);
|
|
196
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
197
|
+
return ((result as any).rows ?? result) as unknown[];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function fetchAllFileRefs(tenantId: string): Promise<unknown[]> {
|
|
201
|
+
const result = await stack.db.execute(sql`
|
|
202
|
+
SELECT id, file_name, inserted_by_id FROM file_refs WHERE tenant_id = ${tenantId}
|
|
203
|
+
`);
|
|
204
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
205
|
+
return ((result as any).rows ?? result) as unknown[];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
describe("runForgetCleanup :: happy path (Cross-Tenant Account-Deletion)", () => {
|
|
209
|
+
test("Alice (DeletionRequested + grace expired) → user anonymized + files weg in beiden Tenants", async () => {
|
|
210
|
+
// Alice in Tenant A + B, mit files in beiden.
|
|
211
|
+
await seedUser(ALICE_ID, {
|
|
212
|
+
status: USER_STATUS.DeletionRequested,
|
|
213
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000), // 1min ago
|
|
214
|
+
email: "alice@example.com",
|
|
215
|
+
displayName: "Alice",
|
|
216
|
+
});
|
|
217
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
218
|
+
await seedMembership(ALICE_ID, TENANT_B);
|
|
219
|
+
await seedFileRef(uuid(101), TENANT_A, ALICE_ID, "alice-a-doc.pdf");
|
|
220
|
+
await seedFileRef(uuid(102), TENANT_A, ALICE_ID, "alice-a-other.pdf");
|
|
221
|
+
await seedFileRef(uuid(103), TENANT_B, ALICE_ID, "alice-b-doc.pdf");
|
|
222
|
+
|
|
223
|
+
// Bob (active) als Negative-Control mit eigenen files — sollen NICHT
|
|
224
|
+
// angetastet werden.
|
|
225
|
+
await seedUser(BOB_ID);
|
|
226
|
+
await seedMembership(BOB_ID, TENANT_A);
|
|
227
|
+
await seedFileRef(uuid(201), TENANT_A, BOB_ID, "bob-a-doc.pdf");
|
|
228
|
+
|
|
229
|
+
const result = await runForgetCleanup({
|
|
230
|
+
db: stack.db,
|
|
231
|
+
registry: stack.registry,
|
|
232
|
+
now: NOW(),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
236
|
+
expect(result.errors).toEqual([]);
|
|
237
|
+
|
|
238
|
+
// User-Row anonymisiert + status=Deleted (default-strategy=delete).
|
|
239
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
240
|
+
expect(aliceRow).not.toBeNull();
|
|
241
|
+
expect(aliceRow?.status).toBe(USER_STATUS.Deleted);
|
|
242
|
+
// userDeleteHook setzt email/displayName auf Pseudonyme (siehe
|
|
243
|
+
// user-data-rights-defaults/hooks/user.userdata-hook). Pruefen dass
|
|
244
|
+
// ORIGINAL-PII raus ist — das ist der harte DSGVO-Punkt.
|
|
245
|
+
expect(aliceRow?.email).not.toContain("alice@example.com");
|
|
246
|
+
expect(aliceRow?.email).toContain("anonymized.invalid");
|
|
247
|
+
expect([USER_DELETED_DISPLAY_NAME, USER_ANONYMIZED_DISPLAY_NAME]).toContain(
|
|
248
|
+
aliceRow?.display_name,
|
|
249
|
+
);
|
|
250
|
+
expect(aliceRow?.password_hash).toBeNull();
|
|
251
|
+
|
|
252
|
+
// Alice's files in Tenant A + B beide weg (Cross-Tenant beweis).
|
|
253
|
+
expect(await fetchFileRefsForUser(TENANT_A, ALICE_ID)).toHaveLength(0);
|
|
254
|
+
expect(await fetchFileRefsForUser(TENANT_B, ALICE_ID)).toHaveLength(0);
|
|
255
|
+
|
|
256
|
+
// Bob's file in Tenant A bleibt.
|
|
257
|
+
const bobFiles = await fetchFileRefsForUser(TENANT_A, BOB_ID);
|
|
258
|
+
expect(bobFiles).toHaveLength(1);
|
|
259
|
+
const bobUser = await fetchUser(BOB_ID);
|
|
260
|
+
expect(bobUser?.status).toBe(USER_STATUS.Active);
|
|
261
|
+
expect(bobUser?.email).toBe(`user-${BOB_ID}@example.com`);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe("runForgetCleanup :: time-window guards", () => {
|
|
266
|
+
test("Future-grace User wird NICHT bearbeitet", async () => {
|
|
267
|
+
await seedUser(FUTURE_USER_ID, {
|
|
268
|
+
status: USER_STATUS.DeletionRequested,
|
|
269
|
+
gracePeriodEnd: instantFromOffsetMs(7 * 24 * 60 * 60 * 1000), // +7d
|
|
270
|
+
});
|
|
271
|
+
await seedMembership(FUTURE_USER_ID, TENANT_A);
|
|
272
|
+
|
|
273
|
+
const result = await runForgetCleanup({
|
|
274
|
+
db: stack.db,
|
|
275
|
+
registry: stack.registry,
|
|
276
|
+
now: NOW(),
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(result.processedUserIds).toEqual([]);
|
|
280
|
+
expect(result.hookCallsAttempted).toBe(0);
|
|
281
|
+
|
|
282
|
+
const userRow = await fetchUser(FUTURE_USER_ID);
|
|
283
|
+
expect(userRow?.status).toBe(USER_STATUS.DeletionRequested);
|
|
284
|
+
expect(userRow?.email).toBe(`user-${FUTURE_USER_ID}@example.com`);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("Active-User (kein Forget-Antrag) wird NICHT bearbeitet", async () => {
|
|
288
|
+
await seedUser(ALICE_ID, { status: USER_STATUS.Active });
|
|
289
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
290
|
+
|
|
291
|
+
const result = await runForgetCleanup({
|
|
292
|
+
db: stack.db,
|
|
293
|
+
registry: stack.registry,
|
|
294
|
+
now: NOW(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result.processedUserIds).toEqual([]);
|
|
298
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
299
|
+
expect(aliceRow?.status).toBe(USER_STATUS.Active);
|
|
300
|
+
expect(aliceRow?.email).toBe(`user-${ALICE_ID}@example.com`);
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
describe("runForgetCleanup :: idempotenz", () => {
|
|
305
|
+
test("zweiter Run nach Cleanup ist no-op", async () => {
|
|
306
|
+
await seedUser(ALICE_ID, {
|
|
307
|
+
status: USER_STATUS.DeletionRequested,
|
|
308
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
309
|
+
});
|
|
310
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
311
|
+
await seedFileRef(uuid(301), TENANT_A, ALICE_ID, "alice-doc.pdf");
|
|
312
|
+
|
|
313
|
+
const firstRun = await runForgetCleanup({
|
|
314
|
+
db: stack.db,
|
|
315
|
+
registry: stack.registry,
|
|
316
|
+
now: NOW(),
|
|
317
|
+
});
|
|
318
|
+
expect(firstRun.processedUserIds).toContain(ALICE_ID);
|
|
319
|
+
|
|
320
|
+
const secondRun = await runForgetCleanup({
|
|
321
|
+
db: stack.db,
|
|
322
|
+
registry: stack.registry,
|
|
323
|
+
now: NOW(),
|
|
324
|
+
});
|
|
325
|
+
// Keine User mehr im DeletionRequested-Status nach Run 1 → Run 2
|
|
326
|
+
// findet nichts.
|
|
327
|
+
expect(secondRun.processedUserIds).toEqual([]);
|
|
328
|
+
expect(secondRun.hookCallsAttempted).toBe(0);
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
describe("runForgetCleanup :: PII-Audit nach Cleanup", () => {
|
|
333
|
+
test("nach Cleanup ist KEINE Original-PII (email + displayName) mehr in DB", async () => {
|
|
334
|
+
// Cross-Data-Matrix-Check: simulieren dass mehrere Datenpunkte mit
|
|
335
|
+
// Alice's Identity verbunden sind, danach beweisen dass keine
|
|
336
|
+
// davon ihre Original-Werte traegt.
|
|
337
|
+
const ORIGINAL_EMAIL = "alice.unique.audit@example.com";
|
|
338
|
+
const ORIGINAL_NAME = "Alice Audit Mueller";
|
|
339
|
+
await seedUser(ALICE_ID, {
|
|
340
|
+
status: USER_STATUS.DeletionRequested,
|
|
341
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
342
|
+
email: ORIGINAL_EMAIL,
|
|
343
|
+
displayName: ORIGINAL_NAME,
|
|
344
|
+
});
|
|
345
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
346
|
+
await seedFileRef(uuid(401), TENANT_A, ALICE_ID, "alice-medical-record.pdf");
|
|
347
|
+
|
|
348
|
+
await runForgetCleanup({
|
|
349
|
+
db: stack.db,
|
|
350
|
+
registry: stack.registry,
|
|
351
|
+
now: NOW(),
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Cross-Tabellen-PII-Check: koennen wir noch IRGENDWO Original-Werte finden?
|
|
355
|
+
const userRows = await stack.db.execute(sql`
|
|
356
|
+
SELECT id FROM read_users WHERE email = ${ORIGINAL_EMAIL} OR display_name = ${ORIGINAL_NAME}
|
|
357
|
+
`);
|
|
358
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
359
|
+
const userMatches = (userRows as any).rows ?? userRows;
|
|
360
|
+
expect(userMatches).toHaveLength(0);
|
|
361
|
+
|
|
362
|
+
const fileRows = await fetchAllFileRefs(TENANT_A);
|
|
363
|
+
expect(fileRows).toHaveLength(0);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("runForgetCleanup :: 0-Memberships orphan-Pfad", () => {
|
|
368
|
+
// Advisor-Finding S2.U5b.fix1: vor dem Fix flippte der orphan-Pfad
|
|
369
|
+
// status=Deleted ohne userDeleteHook → email/displayName/passwordHash
|
|
370
|
+
// blieben original. "Sah compliant aus, war es nicht."
|
|
371
|
+
test("User ohne Memberships → trotzdem PII anonymisiert (kein leerer status-Flip)", async () => {
|
|
372
|
+
const ORIGINAL_EMAIL = "orphan.unique@example.com";
|
|
373
|
+
const ORIGINAL_NAME = "Orphan Original";
|
|
374
|
+
await seedUser(ALICE_ID, {
|
|
375
|
+
status: USER_STATUS.DeletionRequested,
|
|
376
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
377
|
+
email: ORIGINAL_EMAIL,
|
|
378
|
+
displayName: ORIGINAL_NAME,
|
|
379
|
+
});
|
|
380
|
+
// KEINE seedMembership-Aufrufe — User ist orphan.
|
|
381
|
+
|
|
382
|
+
const result = await runForgetCleanup({
|
|
383
|
+
db: stack.db,
|
|
384
|
+
registry: stack.registry,
|
|
385
|
+
now: NOW(),
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
389
|
+
expect(result.errors).toEqual([]);
|
|
390
|
+
|
|
391
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
392
|
+
expect(aliceRow?.status).toBe(USER_STATUS.Deleted);
|
|
393
|
+
// Harter PII-Check: Original-Werte sind weg, Pseudonyme sind drin.
|
|
394
|
+
expect(aliceRow?.email).not.toBe(ORIGINAL_EMAIL);
|
|
395
|
+
expect(aliceRow?.email).toContain("anonymized.invalid");
|
|
396
|
+
expect(aliceRow?.display_name).not.toBe(ORIGINAL_NAME);
|
|
397
|
+
expect(aliceRow?.password_hash).toBeNull();
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("runForgetCleanup :: sendDeletionExecutedEmail callback (Atom 5b)", () => {
|
|
402
|
+
test("happy: callback fires mit userEmail PRE-tx + tenantIds + executedAt nach success", async () => {
|
|
403
|
+
const ORIGINAL_EMAIL = "alice.callback@example.com";
|
|
404
|
+
await seedUser(ALICE_ID, {
|
|
405
|
+
status: USER_STATUS.DeletionRequested,
|
|
406
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
407
|
+
email: ORIGINAL_EMAIL,
|
|
408
|
+
});
|
|
409
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
410
|
+
await seedMembership(ALICE_ID, TENANT_B);
|
|
411
|
+
|
|
412
|
+
type CallbackCall = {
|
|
413
|
+
userId: string;
|
|
414
|
+
userEmail: string;
|
|
415
|
+
tenantIds: readonly string[];
|
|
416
|
+
executedAt: string;
|
|
417
|
+
};
|
|
418
|
+
const calls: CallbackCall[] = [];
|
|
419
|
+
const sendDeletionExecutedEmail = async (args: CallbackCall): Promise<void> => {
|
|
420
|
+
calls.push(args);
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
const result = await runForgetCleanup({
|
|
424
|
+
db: stack.db,
|
|
425
|
+
registry: stack.registry,
|
|
426
|
+
now: NOW(),
|
|
427
|
+
sendDeletionExecutedEmail,
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
431
|
+
expect(calls).toHaveLength(1);
|
|
432
|
+
expect(calls[0]?.userId).toBe(ALICE_ID);
|
|
433
|
+
// PRE-tx-Cache: Original-Email gereicht, NICHT die anonymized-Version
|
|
434
|
+
// die der user-Hook waehrend der Tx setzt.
|
|
435
|
+
expect(calls[0]?.userEmail).toBe(ORIGINAL_EMAIL);
|
|
436
|
+
// Cross-Tenant-Beweis: callback bekommt beide Tenants.
|
|
437
|
+
expect(calls[0]?.tenantIds).toHaveLength(2);
|
|
438
|
+
expect(calls[0]?.tenantIds).toContain(TENANT_A);
|
|
439
|
+
expect(calls[0]?.tenantIds).toContain(TENANT_B);
|
|
440
|
+
expect(calls[0]?.executedAt).toBeTruthy();
|
|
441
|
+
|
|
442
|
+
// Anonymisierung lief trotzdem durch (Callback ist nach success).
|
|
443
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
444
|
+
expect(aliceRow?.email).not.toBe(ORIGINAL_EMAIL);
|
|
445
|
+
expect(aliceRow?.email).toContain("anonymized.invalid");
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
test("kein callback-Optional → success ohne crash, processedUserIds enthaelt User", async () => {
|
|
449
|
+
await seedUser(ALICE_ID, {
|
|
450
|
+
status: USER_STATUS.DeletionRequested,
|
|
451
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
452
|
+
});
|
|
453
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
454
|
+
|
|
455
|
+
const result = await runForgetCleanup({
|
|
456
|
+
db: stack.db,
|
|
457
|
+
registry: stack.registry,
|
|
458
|
+
now: NOW(),
|
|
459
|
+
// KEIN sendDeletionExecutedEmail
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
463
|
+
expect(result.errors).toEqual([]);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test("failed Sub-Tx (synthetic Hook-Throw) → callback NICHT gefeuert", async () => {
|
|
467
|
+
await seedUser(ALICE_ID, {
|
|
468
|
+
status: USER_STATUS.DeletionRequested,
|
|
469
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
470
|
+
});
|
|
471
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
472
|
+
|
|
473
|
+
type CallbackCall = { userId: string };
|
|
474
|
+
const calls: CallbackCall[] = [];
|
|
475
|
+
|
|
476
|
+
const usages = stack.registry.getExtensionUsages("userData");
|
|
477
|
+
const userUsage = usages.find((u) => u.entityName === "user");
|
|
478
|
+
if (!userUsage?.options) throw new Error("user usage not found");
|
|
479
|
+
const originalUserDelete = (
|
|
480
|
+
userUsage.options as {
|
|
481
|
+
delete: (
|
|
482
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
483
|
+
strategy: string,
|
|
484
|
+
) => Promise<void>;
|
|
485
|
+
}
|
|
486
|
+
).delete;
|
|
487
|
+
(
|
|
488
|
+
userUsage.options as {
|
|
489
|
+
delete: (
|
|
490
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
491
|
+
strategy: string,
|
|
492
|
+
) => Promise<void>;
|
|
493
|
+
}
|
|
494
|
+
).delete = async () => {
|
|
495
|
+
throw new Error("synthetic hook failure");
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
try {
|
|
499
|
+
const result = await runForgetCleanup({
|
|
500
|
+
db: stack.db,
|
|
501
|
+
registry: stack.registry,
|
|
502
|
+
now: NOW(),
|
|
503
|
+
sendDeletionExecutedEmail: async (args) => {
|
|
504
|
+
calls.push({ userId: args.userId });
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
509
|
+
expect(result.processedUserIds).not.toContain(ALICE_ID);
|
|
510
|
+
// Kern-Aussage: failed-Sub-Tx → keine Notification (sonst kommen
|
|
511
|
+
// Email-Versendungen fuer User die *nicht* tatsaechlich geloescht
|
|
512
|
+
// wurden, was DSGVO-Mismatch zwischen User-Erwartung + DB-State
|
|
513
|
+
// verursacht).
|
|
514
|
+
expect(calls).toHaveLength(0);
|
|
515
|
+
} finally {
|
|
516
|
+
(
|
|
517
|
+
userUsage.options as {
|
|
518
|
+
delete: (
|
|
519
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
520
|
+
strategy: string,
|
|
521
|
+
) => Promise<void>;
|
|
522
|
+
}
|
|
523
|
+
).delete = originalUserDelete;
|
|
524
|
+
}
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
test("best-effort: callback-Throw fuer User A killt Batch NICHT — User B trotzdem verarbeitet", async () => {
|
|
528
|
+
// Asymmetrie-Schutz analog request-deletion (Atom 5b): wenn sendEmail
|
|
529
|
+
// fuer User A throwt, Batch-Cleanup laeuft fuer User B weiter. Der
|
|
530
|
+
// erste User wurde bereits geloescht (Sub-Tx committed), Throw waere
|
|
531
|
+
// ein Bug — r.job-Wrap markiert den Run failed, retry findet keine
|
|
532
|
+
// expired-User mehr (alle Deleted) → silent miss.
|
|
533
|
+
await seedUser(ALICE_ID, {
|
|
534
|
+
status: USER_STATUS.DeletionRequested,
|
|
535
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
536
|
+
email: "alice.throws@example.com",
|
|
537
|
+
});
|
|
538
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
539
|
+
await seedUser(BOB_ID, {
|
|
540
|
+
status: USER_STATUS.DeletionRequested,
|
|
541
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
542
|
+
email: "bob.success@example.com",
|
|
543
|
+
});
|
|
544
|
+
await seedMembership(BOB_ID, TENANT_A);
|
|
545
|
+
|
|
546
|
+
const calls: Array<{ userId: string }> = [];
|
|
547
|
+
const result = await runForgetCleanup({
|
|
548
|
+
db: stack.db,
|
|
549
|
+
registry: stack.registry,
|
|
550
|
+
now: NOW(),
|
|
551
|
+
sendDeletionExecutedEmail: async (args) => {
|
|
552
|
+
calls.push({ userId: args.userId });
|
|
553
|
+
if (args.userId === ALICE_ID) {
|
|
554
|
+
throw new Error("synthetic email transport failure for alice");
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Beide User wurden processed — Throw bei Alice hat Bob nicht
|
|
560
|
+
// mitgerissen. Beweis dass try/catch das Bubbling stoppt.
|
|
561
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
562
|
+
expect(result.processedUserIds).toContain(BOB_ID);
|
|
563
|
+
expect(result.errors).toEqual([]);
|
|
564
|
+
|
|
565
|
+
// Beide Callbacks angerufen.
|
|
566
|
+
expect(calls.map((c) => c.userId).sort()).toEqual([ALICE_ID, BOB_ID].sort());
|
|
567
|
+
|
|
568
|
+
// Beide DB-Rows tatsaechlich geloescht (callback-throw hat den
|
|
569
|
+
// Cleanup nicht zurueckgerollt — Sub-Tx ist VOR dem callback-call
|
|
570
|
+
// committed).
|
|
571
|
+
expect((await fetchUser(ALICE_ID))?.status).toBe(USER_STATUS.Deleted);
|
|
572
|
+
expect((await fetchUser(BOB_ID))?.status).toBe(USER_STATUS.Deleted);
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
test("User ohne email-Field (NULL) → callback NICHT gefeuert (skip ohne crash)", async () => {
|
|
576
|
+
// Edge-Case: Email-Spalte ist NULL (kann passieren wenn user-Hook in
|
|
577
|
+
// einem vorigen Run schon anonymisiert hat aber status haengen blieb,
|
|
578
|
+
// oder durch external Migration). Skip schuetzt vor crashing-callback
|
|
579
|
+
// mit invaliden Args.
|
|
580
|
+
await stack.db.insert(userTable).values({
|
|
581
|
+
id: ALICE_ID,
|
|
582
|
+
tenantId: TENANT_SYSTEM,
|
|
583
|
+
email: "",
|
|
584
|
+
passwordHash: "hashed",
|
|
585
|
+
displayName: "Alice",
|
|
586
|
+
locale: "de",
|
|
587
|
+
emailVerified: true,
|
|
588
|
+
roles: '["Member"]',
|
|
589
|
+
status: USER_STATUS.DeletionRequested,
|
|
590
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
591
|
+
});
|
|
592
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
593
|
+
|
|
594
|
+
const calls: Array<{ userId: string }> = [];
|
|
595
|
+
const result = await runForgetCleanup({
|
|
596
|
+
db: stack.db,
|
|
597
|
+
registry: stack.registry,
|
|
598
|
+
now: NOW(),
|
|
599
|
+
sendDeletionExecutedEmail: async (args) => {
|
|
600
|
+
calls.push({ userId: args.userId });
|
|
601
|
+
},
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
expect(result.processedUserIds).toContain(ALICE_ID);
|
|
605
|
+
expect(calls).toHaveLength(0);
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
|
|
609
|
+
describe("runForgetCleanup :: per-User-Sub-Tx-Isolation (advisor-pinned Architektur)", () => {
|
|
610
|
+
// Pinst die load-bearing Property: ein failing Hook bei User A darf
|
|
611
|
+
// nicht User B mit zurueckrollen. Wenn jemand die Sub-Tx in
|
|
612
|
+
// run-forget-cleanup.ts wieder rausnimmt, faellt dieser Test um.
|
|
613
|
+
test("failing Hook bei User A → User B trotzdem cleaned, A bleibt im DeletionRequested", async () => {
|
|
614
|
+
const ORIGINAL_A_EMAIL = "alice.failing.hook@example.com";
|
|
615
|
+
const ORIGINAL_B_EMAIL = "bob.success.hook@example.com";
|
|
616
|
+
|
|
617
|
+
await seedUser(ALICE_ID, {
|
|
618
|
+
status: USER_STATUS.DeletionRequested,
|
|
619
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
620
|
+
email: ORIGINAL_A_EMAIL,
|
|
621
|
+
});
|
|
622
|
+
await seedMembership(ALICE_ID, TENANT_A);
|
|
623
|
+
|
|
624
|
+
await seedUser(BOB_ID, {
|
|
625
|
+
status: USER_STATUS.DeletionRequested,
|
|
626
|
+
gracePeriodEnd: instantFromOffsetMs(-60 * 1000),
|
|
627
|
+
email: ORIGINAL_B_EMAIL,
|
|
628
|
+
});
|
|
629
|
+
await seedMembership(BOB_ID, TENANT_A);
|
|
630
|
+
|
|
631
|
+
// Failing Hook der nur fuer Alice wirft. Wir injizieren ihn
|
|
632
|
+
// ueber eine eigene Pseudo-Extension-Usage in der Registry.
|
|
633
|
+
// (registry.getExtensionUsages liefert Liste — wir nutzen einen
|
|
634
|
+
// Spy am ersten existierenden Hook.)
|
|
635
|
+
const usages = stack.registry.getExtensionUsages("userData");
|
|
636
|
+
const userUsage = usages.find((u) => u.entityName === "user");
|
|
637
|
+
if (!userUsage?.options) throw new Error("user usage not found");
|
|
638
|
+
const originalUserDelete = (
|
|
639
|
+
userUsage.options as {
|
|
640
|
+
delete: (
|
|
641
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
642
|
+
strategy: string,
|
|
643
|
+
) => Promise<void>;
|
|
644
|
+
}
|
|
645
|
+
).delete;
|
|
646
|
+
(
|
|
647
|
+
userUsage.options as {
|
|
648
|
+
delete: (
|
|
649
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
650
|
+
strategy: string,
|
|
651
|
+
) => Promise<void>;
|
|
652
|
+
}
|
|
653
|
+
).delete = async (ctx: { userId: string; tenantId: string; db: unknown }, strategy: string) => {
|
|
654
|
+
if (ctx.userId === ALICE_ID) {
|
|
655
|
+
throw new Error("synthetic hook failure for alice");
|
|
656
|
+
}
|
|
657
|
+
return originalUserDelete(ctx, strategy);
|
|
658
|
+
};
|
|
659
|
+
|
|
660
|
+
try {
|
|
661
|
+
const result = await runForgetCleanup({
|
|
662
|
+
db: stack.db,
|
|
663
|
+
registry: stack.registry,
|
|
664
|
+
now: NOW(),
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
// Bob durchgegangen, Alice nicht.
|
|
668
|
+
expect(result.processedUserIds).toContain(BOB_ID);
|
|
669
|
+
expect(result.processedUserIds).not.toContain(ALICE_ID);
|
|
670
|
+
expect(result.errors.some((e) => e.userId === ALICE_ID)).toBe(true);
|
|
671
|
+
|
|
672
|
+
// Alice unverändert (Sub-Tx zurueckgerollt → Original-PII intakt,
|
|
673
|
+
// status weiter DeletionRequested damit naechster Run retried).
|
|
674
|
+
const aliceRow = await fetchUser(ALICE_ID);
|
|
675
|
+
expect(aliceRow?.status).toBe(USER_STATUS.DeletionRequested);
|
|
676
|
+
expect(aliceRow?.email).toBe(ORIGINAL_A_EMAIL);
|
|
677
|
+
|
|
678
|
+
// Bob anonymisiert + Deleted.
|
|
679
|
+
const bobRow = await fetchUser(BOB_ID);
|
|
680
|
+
expect(bobRow?.status).toBe(USER_STATUS.Deleted);
|
|
681
|
+
expect(bobRow?.email).not.toBe(ORIGINAL_B_EMAIL);
|
|
682
|
+
expect(bobRow?.email).toContain("anonymized.invalid");
|
|
683
|
+
|
|
684
|
+
// Error-Detail traegt den richtigen Tenant + Entity-Namen
|
|
685
|
+
// (advisor-Finding: vorher waren das "<sub-transaction>"-Pseudos).
|
|
686
|
+
const aliceError = result.errors.find((e) => e.userId === ALICE_ID);
|
|
687
|
+
expect(aliceError?.tenantId).toBe(TENANT_A);
|
|
688
|
+
expect(aliceError?.entityName).toBe("user");
|
|
689
|
+
} finally {
|
|
690
|
+
// Hook-Spy zuruecksetzen damit andere Tests im selben File nicht
|
|
691
|
+
// davon betroffen sind. (beforeEach cleared eh die DB; aber die
|
|
692
|
+
// Registry ist ueber alle Tests dieselbe.)
|
|
693
|
+
(
|
|
694
|
+
userUsage.options as {
|
|
695
|
+
delete: (
|
|
696
|
+
ctx: { userId: string; tenantId: string; db: unknown },
|
|
697
|
+
strategy: string,
|
|
698
|
+
) => Promise<void>;
|
|
699
|
+
}
|
|
700
|
+
).delete = originalUserDelete;
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
});
|