@cosmicdrift/kumiko-bundled-features 0.110.0 → 0.112.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 (43) hide show
  1. package/package.json +6 -6
  2. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.test.ts +3 -3
  3. package/src/auth-email-password/__tests__/account-lockout.integration.test.ts +3 -3
  4. package/src/auth-email-password/__tests__/auth-claims.integration.test.ts +9 -10
  5. package/src/auth-email-password/__tests__/auth.integration.test.ts +5 -5
  6. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +7 -4
  7. package/src/auth-email-password/__tests__/identity-v3-login.integration.test.ts +3 -3
  8. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +3 -4
  9. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +3 -3
  10. package/src/auth-email-password/__tests__/session-callbacks.integration.test.ts +3 -3
  11. package/src/config/__tests__/config.integration.test.ts +7 -8
  12. package/src/config/__tests__/encrypted-legacy-migration.integration.test.ts +179 -0
  13. package/src/config/__tests__/inherited-redaction.integration.test.ts +4 -3
  14. package/src/config/feature.ts +14 -8
  15. package/src/config/handlers/reencrypt.job.ts +197 -0
  16. package/src/config/handlers/set.write.ts +2 -2
  17. package/src/config/index.ts +4 -3
  18. package/src/config/resolver.ts +34 -10
  19. package/src/file-foundation/__tests__/file-foundation.integration.test.ts +4 -3
  20. package/src/folders/__tests__/folders.integration.test.ts +3 -0
  21. package/src/folders-user-data/__tests__/hooks.integration.test.ts +1 -0
  22. package/src/jobs/__tests__/job-system-user.integration.test.ts +4 -8
  23. package/src/mail-foundation/__tests__/mail-foundation.integration.test.ts +4 -3
  24. package/src/personal-access-tokens/__tests__/pat.integration.test.ts +3 -4
  25. package/src/readiness/__tests__/readiness.integration.test.ts +4 -3
  26. package/src/secrets/handlers/rotate.job.ts +77 -98
  27. package/src/secrets/secrets-context.ts +7 -38
  28. package/src/secrets/table.ts +6 -7
  29. package/src/sessions/__tests__/password-auto-revoke.integration.test.ts +6 -4
  30. package/src/sessions/__tests__/sessions.integration.test.ts +3 -3
  31. package/src/shared/chunked-entity-migration.ts +89 -0
  32. package/src/shared/index.ts +7 -0
  33. package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +6 -5
  34. package/src/tenant/__tests__/tenant.integration.test.ts +8 -4
  35. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +7 -4
  36. package/src/user-data-rights/__tests__/download.integration.test.ts +7 -4
  37. package/src/user-data-rights/__tests__/export-encrypted-fields.integration.test.ts +152 -0
  38. package/src/user-data-rights/__tests__/forget-hook-registry.integration.test.ts +108 -0
  39. package/src/user-data-rights/__tests__/read-users-rebuild-survives-lifecycle.integration.test.ts +3 -3
  40. package/src/user-data-rights/__tests__/restriction-flow.integration.test.ts +3 -3
  41. package/src/user-data-rights/run-forget-cleanup.ts +1 -0
  42. package/src/user-data-rights/run-user-export.ts +43 -4
  43. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +31 -9
@@ -0,0 +1,152 @@
1
+ // Art. 20 + encrypted entity fields: export hooks read raw rows past the
2
+ // executor's decrypt layer, so runUserExport must decrypt encrypted fields
3
+ // centrally — plaintext with a configured cipher, an explicit marker
4
+ // (never the raw ciphertext) without one.
5
+
6
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
+ import {
9
+ buildEntityTable,
10
+ configureEntityFieldEncryption,
11
+ resetEntityFieldEncryptionCacheForTests,
12
+ } from "@cosmicdrift/kumiko-framework/db";
13
+ import {
14
+ createEntity,
15
+ createRegistry,
16
+ createTextField,
17
+ defineFeature,
18
+ EXT_USER_DATA,
19
+ type UserDataExportHook,
20
+ } from "@cosmicdrift/kumiko-framework/engine";
21
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
22
+ import { createTestEnvelopeCipher } from "@cosmicdrift/kumiko-framework/testing";
23
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
25
+ import { createDataRetentionFeature } from "../../data-retention";
26
+ import { createSessionsFeature } from "../../sessions";
27
+ import { createUserFeature } from "../../user";
28
+ import { createUserDataRightsFeature } from "../feature";
29
+ import { runUserExport } from "../run-user-export";
30
+
31
+ const TENANT = "00000000-0000-4000-8000-00000000000a";
32
+ const USER_ID = "cccccccc-cccc-4ccc-8ccc-000000000001";
33
+ const PLAINTEXT_NOTE = "my private vault note";
34
+
35
+ const cipher = createTestEnvelopeCipher();
36
+
37
+ const vaultNoteEntity = createEntity({
38
+ table: "read_vault_notes",
39
+ fields: {
40
+ ownerId: createTextField({ required: true }),
41
+ note: createTextField({ encrypted: true }),
42
+ },
43
+ });
44
+
45
+ const vaultNotesTableRef = buildEntityTable("vault-note", vaultNoteEntity);
46
+
47
+ // Export hook mirrors real hooks (folders-user-data pattern): raw select,
48
+ // no decrypt of its own.
49
+ const vaultExportHook: UserDataExportHook = async (ctx) => {
50
+ const rows = await selectMany<Record<string, unknown>>(ctx.db, vaultNotesTableRef, {
51
+ ownerId: ctx.userId,
52
+ });
53
+ if (rows.length === 0) return null;
54
+ return { entity: "vault-note", rows };
55
+ };
56
+
57
+ const vaultFeature = defineFeature("vault", (r) => {
58
+ const entity = r.entity("vault-note", vaultNoteEntity);
59
+ r.useExtension(EXT_USER_DATA, entity, {
60
+ export: vaultExportHook,
61
+ delete: async () => undefined,
62
+ });
63
+ });
64
+
65
+ let stack: TestStack;
66
+ let registry: ReturnType<typeof createRegistry>;
67
+
68
+ beforeAll(async () => {
69
+ stack = await setupTestStack({ features: [] });
70
+ // user-data-rights hosts EXT_USER_DATA — without it the vault feature's
71
+ // useExtension reference fails registry creation. Its hard requires
72
+ // (user/data-retention/compliance-profiles/sessions) come along.
73
+ registry = createRegistry([
74
+ createUserFeature(),
75
+ createDataRetentionFeature(),
76
+ createComplianceProfilesFeature(),
77
+ createSessionsFeature(),
78
+ createUserDataRightsFeature(),
79
+ vaultFeature,
80
+ ]);
81
+
82
+ await asRawClient(stack.db).unsafe(`
83
+ CREATE TABLE IF NOT EXISTS read_tenant_memberships (
84
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
85
+ tenant_id UUID NOT NULL,
86
+ user_id TEXT NOT NULL,
87
+ version INTEGER NOT NULL DEFAULT 0,
88
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
89
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
90
+ inserted_by_id TEXT,
91
+ modified_by_id TEXT,
92
+ is_deleted BOOLEAN NOT NULL DEFAULT false,
93
+ deleted_at TIMESTAMPTZ,
94
+ deleted_by_id TEXT,
95
+ roles TEXT NOT NULL DEFAULT '[]',
96
+ UNIQUE(user_id, tenant_id)
97
+ )
98
+ `);
99
+ await asRawClient(stack.db).unsafe(
100
+ `INSERT INTO read_tenant_memberships (tenant_id, user_id) VALUES ('${TENANT}', '${USER_ID}')
101
+ ON CONFLICT DO NOTHING`,
102
+ );
103
+
104
+ await asRawClient(stack.db).unsafe(`
105
+ CREATE TABLE IF NOT EXISTS read_vault_notes (
106
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
107
+ tenant_id UUID NOT NULL,
108
+ version INTEGER NOT NULL DEFAULT 0,
109
+ inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
110
+ modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
111
+ inserted_by_id TEXT,
112
+ modified_by_id TEXT,
113
+ owner_id TEXT NOT NULL,
114
+ note TEXT
115
+ )
116
+ `);
117
+ const storedNote = await cipher.encrypt(PLAINTEXT_NOTE);
118
+ await asRawClient(stack.db).unsafe(
119
+ `INSERT INTO read_vault_notes (tenant_id, owner_id, note) VALUES ('${TENANT}', '${USER_ID}', '${storedNote.replaceAll("'", "''")}')`,
120
+ );
121
+ });
122
+
123
+ afterEach(() => {
124
+ resetEntityFieldEncryptionCacheForTests();
125
+ });
126
+
127
+ afterAll(async () => {
128
+ await stack.cleanup();
129
+ });
130
+
131
+ async function exportedNote(): Promise<unknown> {
132
+ const bundle = await runUserExport({
133
+ db: stack.db,
134
+ registry,
135
+ userId: USER_ID,
136
+ now: getTemporal().Now.instant(),
137
+ });
138
+ const snippet = bundle.tenants.flatMap((t) => t.entities).find((e) => e.entity === "vault-note");
139
+ return snippet?.rows[0]?.["note"];
140
+ }
141
+
142
+ describe("user export with encrypted entity fields", () => {
143
+ test("decrypts encrypted fields when the cipher is configured", async () => {
144
+ configureEntityFieldEncryption(cipher);
145
+ expect(await exportedNote()).toBe(PLAINTEXT_NOTE);
146
+ });
147
+
148
+ test("exports an explicit marker — never ciphertext — without a cipher", async () => {
149
+ const value = await exportedNote();
150
+ expect(value).toBe("[encrypted:unavailable]");
151
+ });
152
+ });
@@ -0,0 +1,108 @@
1
+ // A forget delete-hook receives the app registry so it can cascade custom child
2
+ // projections for the executor's `<entity>.forgotten` event (runProjectionsForEvent).
3
+ // executor.forget purges only the entity's OWN projection, and the forget pipeline
4
+ // is a job — not a dispatched command — so the dispatcher's post-command projection
5
+ // pass never fires. Without registry in the hook ctx the cascade is unreachable and
6
+ // child read-model rows (m:n joins, per-parent detail projections) are orphaned on
7
+ // live forget (a DSGVO gap). This pins that the plumbing delivers the real,
8
+ // functional registry instance; the end-to-end cascade is covered consumer-side.
9
+
10
+ import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
11
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
12
+ import {
13
+ createEntity,
14
+ createTextField,
15
+ defineFeature,
16
+ EXT_USER_DATA,
17
+ type Registry,
18
+ type UserDataDeleteHook,
19
+ } from "@cosmicdrift/kumiko-framework/engine";
20
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
21
+ import {
22
+ setupTestStack,
23
+ type TestStack,
24
+ unsafeCreateEntityTable,
25
+ } from "@cosmicdrift/kumiko-framework/stack";
26
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
27
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
28
+ import { createSessionsFeature } from "../../sessions";
29
+ import { createUserFeature, userEntity } from "../../user";
30
+ import { createUserDataRightsFeature } from "../feature";
31
+ import { runForgetCleanup } from "../run-forget-cleanup";
32
+ import {
33
+ createForgetSeeders,
34
+ nowInstant,
35
+ READ_TENANT_MEMBERSHIPS_DDL,
36
+ TENANT_SYSTEM,
37
+ } from "./forget-test-helpers";
38
+
39
+ const PROBE = "forget-registry-probe";
40
+ let capturedRegistry: Registry | undefined;
41
+
42
+ const captureHook: UserDataDeleteHook = async (ctx) => {
43
+ capturedRegistry = ctx.registry;
44
+ };
45
+
46
+ const probeEntity = createEntity({
47
+ table: `read_${PROBE.replace(/-/g, "_")}`,
48
+ fields: { name: createTextField({ required: true }) },
49
+ });
50
+
51
+ const probeFeature = defineFeature(PROBE, (r) => {
52
+ r.entity(PROBE, probeEntity);
53
+ r.useExtension(EXT_USER_DATA, PROBE, {
54
+ export: async () => null,
55
+ delete: captureHook,
56
+ });
57
+ });
58
+
59
+ const FORGET_USER = "cccccccc-cccc-4ccc-8ccc-0000000000b1";
60
+ let stack: TestStack;
61
+ const seed = (db: unknown) =>
62
+ // biome-ignore lint/suspicious/noExplicitAny: dummy writer; this seeder never writes binaries.
63
+ createForgetSeeders(db as any, { write: async () => {} });
64
+
65
+ beforeAll(async () => {
66
+ stack = await setupTestStack({
67
+ features: [
68
+ createUserFeature(),
69
+ createSessionsFeature(),
70
+ createDataRetentionFeature(),
71
+ createComplianceProfilesFeature(),
72
+ createUserDataRightsFeature(),
73
+ probeFeature,
74
+ ],
75
+ });
76
+ await unsafeCreateEntityTable(stack.db, userEntity);
77
+ await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
78
+ await unsafeCreateEntityTable(stack.db, probeEntity);
79
+ await createEventsTable(stack.db);
80
+ await asRawClient(stack.db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
81
+ });
82
+
83
+ afterAll(async () => {
84
+ await stack.cleanup();
85
+ });
86
+
87
+ beforeEach(() => {
88
+ capturedRegistry = undefined;
89
+ });
90
+
91
+ test("forget delete-hook receives the app registry instance", async () => {
92
+ await seed(stack.db).seedForgetUser(FORGET_USER);
93
+ await seed(stack.db).seedMembership(FORGET_USER, TENANT_SYSTEM);
94
+
95
+ const result = await runForgetCleanup({
96
+ db: stack.db,
97
+ registry: stack.registry,
98
+ now: nowInstant(),
99
+ });
100
+
101
+ expect(result.errors, JSON.stringify(result.errors)).toHaveLength(0);
102
+ expect(result.processedUserIds).toContain(FORGET_USER);
103
+ // The hook ran and got the exact registry passed to runForgetCleanup — not
104
+ // undefined, not a stand-in — so runProjectionsForEvent(event, ctx.registry, db)
105
+ // reaches the real projections.
106
+ expect(capturedRegistry).toBe(stack.registry);
107
+ expect(capturedRegistry?.getProjectionsForSource(PROBE)).toBeDefined();
108
+ });
@@ -14,7 +14,6 @@
14
14
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
15
15
  import { randomBytes } from "node:crypto";
16
16
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
17
- import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
18
17
  import {
19
18
  createRegistry,
20
19
  type Registry,
@@ -40,6 +39,7 @@ import {
40
39
  } from "@cosmicdrift/kumiko-framework/stack";
41
40
  import {
42
41
  createLateBoundHolder,
42
+ createTestEnvelopeCipher,
43
43
  resetTestTables,
44
44
  updateRows,
45
45
  } from "@cosmicdrift/kumiko-framework/testing";
@@ -97,8 +97,8 @@ function buildFeatures() {
97
97
  }
98
98
 
99
99
  beforeAll(async () => {
100
- const encryption = createEncryptionProvider(encryptionKey);
101
- const resolver = createConfigResolver({ encryption });
100
+ const encryption = createTestEnvelopeCipher(encryptionKey);
101
+ const resolver = createConfigResolver({ cipher: encryption });
102
102
  const bound = sessionCallbacksFromLateBound(callbacks);
103
103
 
104
104
  stack = await setupTestStack({
@@ -13,7 +13,6 @@
13
13
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
14
14
  import { randomBytes } from "node:crypto";
15
15
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
16
- import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
17
16
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
18
17
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
19
18
  import {
@@ -26,6 +25,7 @@ import {
26
25
  } from "@cosmicdrift/kumiko-framework/stack";
27
26
  import {
28
27
  createLateBoundHolder,
28
+ createTestEnvelopeCipher,
29
29
  resetTestTables,
30
30
  updateRows,
31
31
  } from "@cosmicdrift/kumiko-framework/testing";
@@ -65,8 +65,8 @@ const ALICE_EMAIL = "alice.restrict@example.com";
65
65
  const ALICE_PW = "alice-pw-long-enough";
66
66
 
67
67
  beforeAll(async () => {
68
- const encryption = createEncryptionProvider(encryptionKey);
69
- const resolver = createConfigResolver({ encryption });
68
+ const encryption = createTestEnvelopeCipher(encryptionKey);
69
+ const resolver = createConfigResolver({ cipher: encryption });
70
70
  const bound = sessionCallbacksFromLateBound(callbacks);
71
71
 
72
72
  stack = await setupTestStack({
@@ -316,6 +316,7 @@ async function processUser(args: {
316
316
  await entry.deleteHook(
317
317
  {
318
318
  db: tx,
319
+ registry,
319
320
  tenantId,
320
321
  userId,
321
322
  buildStorageProvider,
@@ -22,6 +22,11 @@
22
22
 
23
23
  import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
24
24
  import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
25
+ import {
26
+ collectEncryptedFieldNames,
27
+ configuredEntityFieldEncryption,
28
+ decryptEntityFieldValues,
29
+ } from "@cosmicdrift/kumiko-framework/db";
25
30
  import {
26
31
  EXT_USER_DATA,
27
32
  type Registry,
@@ -137,8 +142,9 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
137
142
  for (const tenantId of tenantList) {
138
143
  const entities: UserDataExportSnippet[] = [];
139
144
  for (const entry of hookEntries) {
140
- const snippet = await entry.exportHook({ db, tenantId, userId });
141
- if (snippet === null) continue;
145
+ const rawSnippet = await entry.exportHook({ db, registry, tenantId, userId });
146
+ if (rawSnippet === null) continue;
147
+ const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
142
148
  entities.push(snippet);
143
149
  if (snippet.fileRefs) {
144
150
  for (const ref of snippet.fileRefs) {
@@ -169,12 +175,14 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
169
175
  if (tenantList.length === 0 && hookEntries.length > 0) {
170
176
  const orphanEntities: UserDataExportSnippet[] = [];
171
177
  for (const entry of hookEntries) {
172
- const snippet = await entry.exportHook({
178
+ const rawSnippet = await entry.exportHook({
173
179
  db,
180
+ registry,
174
181
  tenantId: SYSTEM_TENANT_ID_FOR_ORPHANS,
175
182
  userId,
176
183
  });
177
- if (snippet === null) continue;
184
+ if (rawSnippet === null) continue;
185
+ const snippet = await decryptSnippetFields(registry, entry.entityName, rawSnippet);
178
186
  orphanEntities.push(snippet);
179
187
  if (snippet.fileRefs) {
180
188
  for (const ref of snippet.fileRefs) {
@@ -203,6 +211,37 @@ export async function runUserExport(args: RunUserExportArgs): Promise<UserExport
203
211
  };
204
212
  }
205
213
 
214
+ // Art. 20 verlangt die DATEN, nicht deren Ciphertext: Export-Hooks lesen
215
+ // raw rows an der Executor-Decrypt-Schicht vorbei, darum entschluesselt
216
+ // dieser zentrale Pass encrypted entity fields nach jedem Hook. Ohne
217
+ // konfigurierten Cipher wird ein expliziter Marker exportiert statt den
218
+ // base64-Blob als "Wert" auszuliefern (leak-by-confusion).
219
+ const ENCRYPTED_UNAVAILABLE = "[encrypted:unavailable]";
220
+
221
+ async function decryptSnippetFields(
222
+ registry: Registry,
223
+ hookEntityName: string,
224
+ snippet: UserDataExportSnippet,
225
+ ): Promise<UserDataExportSnippet> {
226
+ const entity = registry.getEntity(snippet.entity) ?? registry.getEntity(hookEntityName);
227
+ if (!entity) return snippet;
228
+ const encryptedFields = collectEncryptedFieldNames(entity);
229
+ if (encryptedFields.size === 0) return snippet;
230
+
231
+ const cipher = configuredEntityFieldEncryption();
232
+ const rows = await Promise.all(
233
+ snippet.rows.map(async (row) => {
234
+ if (cipher) return decryptEntityFieldValues(row, encryptedFields, cipher);
235
+ const out = { ...row };
236
+ for (const name of encryptedFields) {
237
+ if (typeof out[name] === "string") out[name] = ENCRYPTED_UNAVAILABLE;
238
+ }
239
+ return out;
240
+ }),
241
+ );
242
+ return { ...snippet, rows };
243
+ }
244
+
206
245
  // Pseudo-Tenant fuer User ohne aktive Memberships. Identisch zum
207
246
  // Pattern in run-forget-cleanup.ts — RFC4122-Null-UUID. Tenant-scoped
208
247
  // Hooks finden hier nichts (no-op).
@@ -162,6 +162,7 @@ describe("S2.H1 :: userExportHook", () => {
162
162
 
163
163
  const result = await userExportHook({
164
164
  db: stack.db,
165
+ registry: stack.registry,
165
166
  tenantId: TENANT_A,
166
167
  userId: uuid(1001),
167
168
  });
@@ -182,6 +183,7 @@ describe("S2.H1 :: userExportHook", () => {
182
183
  test("returns null wenn User nicht existiert", async () => {
183
184
  const result = await userExportHook({
184
185
  db: stack.db,
186
+ registry: stack.registry,
185
187
  tenantId: TENANT_A,
186
188
  userId: uuid(1002),
187
189
  });
@@ -193,7 +195,10 @@ describe("S2.H1 :: userDeleteHook", () => {
193
195
  test('strategy="delete" → softDelete + email/displayName anonymisiert + status=deleted', async () => {
194
196
  await seedUser(uuid(1003));
195
197
 
196
- await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1003) }, "delete");
198
+ await userDeleteHook(
199
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1003) },
200
+ "delete",
201
+ );
197
202
 
198
203
  const row = await fetchUser(uuid(1003));
199
204
  expect(row).not.toBeNull();
@@ -209,7 +214,10 @@ describe("S2.H1 :: userDeleteHook", () => {
209
214
  test('strategy="anonymize" → email/displayName anonymisiert aber status bleibt active', async () => {
210
215
  await seedUser(uuid(1004));
211
216
 
212
- await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1004) }, "anonymize");
217
+ await userDeleteHook(
218
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1004) },
219
+ "anonymize",
220
+ );
213
221
 
214
222
  const row = await fetchUser(uuid(1004));
215
223
  if (!row) throw new Error("row should exist");
@@ -222,13 +230,19 @@ describe("S2.H1 :: userDeleteHook", () => {
222
230
  test("idempotent: zweiter delete-Call crasht nicht UND State bleibt korrekt deleted", async () => {
223
231
  await seedUser(uuid(1005));
224
232
 
225
- await userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete");
233
+ await userDeleteHook(
234
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
235
+ "delete",
236
+ );
226
237
  const afterFirst = await fetchUser(uuid(1005));
227
238
  if (!afterFirst) throw new Error("user should exist after first delete");
228
239
 
229
240
  // Zweiter Call: kein Crash + State unverändert
230
241
  await expect(
231
- userDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: uuid(1005) }, "delete"),
242
+ userDeleteHook(
243
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: uuid(1005) },
244
+ "delete",
245
+ ),
232
246
  ).resolves.toBeUndefined();
233
247
 
234
248
  // State-Verifikation (S2.H1+H2-Audit N3): Row weiterhin deleted,
@@ -249,6 +263,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
249
263
 
250
264
  const result = await fileRefExportHook({
251
265
  db: stack.db,
266
+ registry: stack.registry,
252
267
  tenantId: TENANT_A,
253
268
  userId: "user-files-1",
254
269
  });
@@ -263,6 +278,7 @@ describe("S2.H2 :: fileRefExportHook", () => {
263
278
  test("returns null wenn User keine Files hat", async () => {
264
279
  const result = await fileRefExportHook({
265
280
  db: stack.db,
281
+ registry: stack.registry,
266
282
  tenantId: TENANT_A,
267
283
  userId: "ghost-user-no-files",
268
284
  });
@@ -276,7 +292,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
276
292
  await seedFileRef(uuid(202), TENANT_A, "user-delete-files", "f2.pdf");
277
293
 
278
294
  await fileRefDeleteHook(
279
- { db: stack.db, tenantId: TENANT_A, userId: "user-delete-files" },
295
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-delete-files" },
280
296
  "delete",
281
297
  );
282
298
 
@@ -288,7 +304,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
288
304
  await seedFileRef(uuid(203), TENANT_A, "user-anon-files", "shared.pdf");
289
305
 
290
306
  await fileRefDeleteHook(
291
- { db: stack.db, tenantId: TENANT_A, userId: "user-anon-files" },
307
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-anon-files" },
292
308
  "anonymize",
293
309
  );
294
310
 
@@ -305,7 +321,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
305
321
  await seedFileRef(uuid(302), TENANT_B, "shared-user", "tenantB.pdf");
306
322
 
307
323
  // Tenant A loescht alle Files von "shared-user"
308
- await fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "shared-user" }, "delete");
324
+ await fileRefDeleteHook(
325
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "shared-user" },
326
+ "delete",
327
+ );
309
328
 
310
329
  const aRemaining = await fetchFileRefs(TENANT_A, "shared-user");
311
330
  const bRemaining = await fetchFileRefs(TENANT_B, "shared-user");
@@ -319,7 +338,7 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
319
338
  await seedFileRef(uuid(401), TENANT_A, "user-idem-files", "f.pdf");
320
339
 
321
340
  await fileRefDeleteHook(
322
- { db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" },
341
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
323
342
  "delete",
324
343
  );
325
344
  const afterFirst = await fetchFileRefs(TENANT_A, "user-idem-files");
@@ -327,7 +346,10 @@ describe("S2.H2 :: fileRefDeleteHook", () => {
327
346
 
328
347
  // Zweiter Call: kein Crash + State weiter 0 Files
329
348
  await expect(
330
- fileRefDeleteHook({ db: stack.db, tenantId: TENANT_A, userId: "user-idem-files" }, "delete"),
349
+ fileRefDeleteHook(
350
+ { db: stack.db, registry: stack.registry, tenantId: TENANT_A, userId: "user-idem-files" },
351
+ "delete",
352
+ ),
331
353
  ).resolves.toBeUndefined();
332
354
  const afterSecond = await fetchFileRefs(TENANT_A, "user-idem-files");
333
355
  expect(afterSecond).toHaveLength(0);