@cosmicdrift/kumiko-bundled-features 0.24.1 → 0.26.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 (53) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/env-schemas.test.ts +53 -11
  3. package/src/auth-email-password/__tests__/email-verification.integration.test.ts +75 -11
  4. package/src/auth-email-password/__tests__/password-reset.integration.test.ts +86 -16
  5. package/src/auth-email-password/handlers/confirm-token-flow.ts +12 -8
  6. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +28 -1
  7. package/src/custom-fields/__tests__/custom-fields.integration.test.ts +139 -0
  8. package/src/custom-fields/__tests__/drift.test.ts +43 -0
  9. package/src/custom-fields/__tests__/field-definition-row.test.ts +62 -0
  10. package/src/custom-fields/__tests__/retention.integration.test.ts +76 -0
  11. package/src/custom-fields/__tests__/user-data-rights.integration.test.ts +196 -75
  12. package/src/custom-fields/__tests__/wire-for-entity.test.ts +29 -0
  13. package/src/custom-fields/constants.ts +8 -7
  14. package/src/custom-fields/db/queries/field-access.ts +1 -1
  15. package/src/custom-fields/db/queries/projection.ts +13 -5
  16. package/src/custom-fields/db/queries/quota.ts +1 -1
  17. package/src/custom-fields/db/queries/retention.ts +20 -6
  18. package/src/custom-fields/executor.ts +10 -0
  19. package/src/custom-fields/feature.ts +32 -39
  20. package/src/custom-fields/handlers/clear-custom-field.write.ts +5 -1
  21. package/src/custom-fields/handlers/define-system-field.write.ts +5 -22
  22. package/src/custom-fields/handlers/define-tenant-field.write.ts +13 -23
  23. package/src/custom-fields/handlers/delete-system-field.write.ts +3 -9
  24. package/src/custom-fields/handlers/delete-tenant-field.write.ts +3 -9
  25. package/src/custom-fields/lib/field-access.ts +4 -0
  26. package/src/custom-fields/lib/field-definition-row.ts +33 -0
  27. package/src/custom-fields/run-retention.ts +6 -5
  28. package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +45 -10
  29. package/src/custom-fields/web/client-plugin.tsx +2 -0
  30. package/src/custom-fields/web/custom-fields-form-section.tsx +19 -9
  31. package/src/custom-fields/web/i18n.ts +30 -0
  32. package/src/custom-fields/wire-for-entity.ts +1 -1
  33. package/src/custom-fields/wire-user-data-rights.ts +9 -0
  34. package/src/feature-toggles/handlers/set.write.ts +13 -8
  35. package/src/file-provider-inmemory/__tests__/feature.test.ts +55 -0
  36. package/src/file-provider-s3/__tests__/feature.test.ts +27 -0
  37. package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +54 -12
  38. package/src/foundation-shared/__tests__/config-helpers.test.ts +72 -0
  39. package/src/foundation-shared/config-helpers.ts +7 -3
  40. package/src/secrets/feature.ts +4 -11
  41. package/src/subscription-stripe/feature.ts +2 -2
  42. package/src/template-resolver/handlers/list.query.ts +12 -10
  43. package/src/tenant/__tests__/seed-testing.integration.test.ts +26 -0
  44. package/src/tenant/seeding.ts +3 -3
  45. package/src/tier-engine/__tests__/tier-engine.integration.test.ts +55 -0
  46. package/src/tier-engine/feature.ts +8 -2
  47. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +26 -74
  48. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +211 -0
  49. package/src/user-data-rights/__tests__/forget-cleanup-hook-ordering.integration.test.ts +272 -0
  50. package/src/user-data-rights/__tests__/forget-test-helpers.ts +86 -0
  51. package/src/user-data-rights/db/queries/export-jobs.ts +1 -0
  52. package/src/user-data-rights/run-forget-cleanup.ts +77 -36
  53. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +21 -6
@@ -223,6 +223,61 @@ describe("scenario 6: access control", () => {
223
223
  expectErrorIncludes(error, "access_denied");
224
224
  });
225
225
 
226
+ test("TenantAdmin (ohne SystemAdmin) cannot create a tier-assignment — Self-Upgrade-Schutz", async () => {
227
+ // Tier-Wechsel ist Plattform-/Billing-Hoheit: ein Tenant-Admin darf
228
+ // seinen eigenen Tier NICHT setzen (sonst Gratis-Self-Upgrade).
229
+ const tenantAdmin = createTestUser({
230
+ id: 310,
231
+ tenantId: testTenantId(310),
232
+ roles: ["TenantAdmin"],
233
+ });
234
+
235
+ const error = await stack.http.writeErr(
236
+ TierEngineHandlers.create,
237
+ { tier: "pro" },
238
+ tenantAdmin,
239
+ );
240
+
241
+ expectErrorIncludes(error, "access_denied");
242
+ });
243
+
244
+ test("TenantAdmin cannot update a tier-assignment", async () => {
245
+ const sysadmin = createTestUser({
246
+ id: 311,
247
+ tenantId: testTenantId(311),
248
+ roles: ["SystemAdmin"],
249
+ });
250
+ const created = await stack.http.writeOk(TierEngineHandlers.create, { tier: "free" }, sysadmin);
251
+ const id = (created!["data"] as Record<string, unknown>)["id"] as string;
252
+
253
+ // Selber Tenant, aber reiner TenantAdmin → darf den Tier nicht ändern.
254
+ const tenantAdmin = createTestUser({
255
+ id: 312,
256
+ tenantId: testTenantId(311),
257
+ roles: ["TenantAdmin"],
258
+ });
259
+
260
+ const error = await stack.http.writeErr(
261
+ TierEngineHandlers.update,
262
+ { id, version: 1, changes: { tier: "agency" } },
263
+ tenantAdmin,
264
+ );
265
+
266
+ expectErrorIncludes(error, "access_denied");
267
+ });
268
+
269
+ test("SystemAdmin (ohne TenantAdmin) CAN create a tier-assignment", async () => {
270
+ const sysadmin = createTestUser({
271
+ id: 313,
272
+ tenantId: testTenantId(313),
273
+ roles: ["SystemAdmin"],
274
+ });
275
+
276
+ const result = await stack.http.writeOk(TierEngineHandlers.create, { tier: "team" }, sysadmin);
277
+
278
+ expect((result!["data"] as Record<string, unknown>)["tier"]).toBe("team");
279
+ });
280
+
226
281
  test("query handlers carry the admin-only access rule (config-level check)", () => {
227
282
  // Read-access is enforced by the same role-rule set on the query handler.
228
283
  // We assert the rule is registered correctly — covers regression when
@@ -81,6 +81,12 @@ const tierAssignmentExecutor = createEventStoreExecutor(tierAssignmentTable, tie
81
81
  });
82
82
 
83
83
  const adminAccess = { access: { roles: ["TenantAdmin", "SystemAdmin"] } } as const;
84
+ // Tier-Wechsel ist Plattform-/Billing-Hoheit — ein Tenant-Admin darf den
85
+ // eigenen Tier NIE setzen (sonst Gratis-Self-Upgrade). Daher sind die
86
+ // Writes SystemAdmin-only; Reads (list, get-active-tier) bleiben
87
+ // TenantAdmin-sichtbar. Auto-default-Hook + Billing schreiben als System,
88
+ // hängen also nicht an diesem Handler-Access.
89
+ const writeAccess = { access: { roles: ["SystemAdmin"] } } as const;
84
90
 
85
91
  /**
86
92
  * Options for createTierEngineFeature. Both fields optional — wenn beide
@@ -167,8 +173,8 @@ export function createTierEngineFeature<
167
173
  r.entity("tier-assignment", tierAssignmentEntity);
168
174
 
169
175
  // Standard-CRUD via Helper.
170
- r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
171
- r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, adminAccess));
176
+ r.writeHandler(defineEntityCreateHandler("tier-assignment", tierAssignmentEntity, writeAccess));
177
+ r.writeHandler(defineEntityUpdateHandler("tier-assignment", tierAssignmentEntity, writeAccess));
172
178
 
173
179
  // Reads.
174
180
  r.queryHandler(defineEntityListHandler("tier-assignment", tierAssignmentEntity, adminAccess));
@@ -6,7 +6,7 @@
6
6
  // Datei ihre Bytes dauerhaft auf Disk (Issue gefunden im Review zu #177).
7
7
 
8
8
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
- import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
10
10
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
11
11
  import {
12
12
  createInMemoryFileProvider,
@@ -20,31 +20,32 @@ import {
20
20
  unsafePushTables,
21
21
  } from "@cosmicdrift/kumiko-framework/stack";
22
22
  import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
23
- import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
24
23
  import { createComplianceProfilesFeature } from "../../compliance-profiles";
25
24
  import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
26
25
  import { createFilesFeature } from "../../files";
27
26
  import { createSessionsFeature } from "../../sessions";
28
- import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
27
+ import { createUserFeature, userEntity, userTable } from "../../user";
29
28
  import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
30
29
  import { createUserDataRightsFeature } from "../feature";
31
30
  import { runForgetCleanup } from "../run-forget-cleanup";
31
+ import {
32
+ createForgetSeeders,
33
+ type ForgetSeeders,
34
+ nowInstant,
35
+ READ_TENANT_MEMBERSHIPS_DDL,
36
+ } from "./forget-test-helpers";
32
37
 
33
38
  let stack: TestStack;
34
39
  let db: DbConnection;
35
40
  let provider: InMemoryFileProvider;
41
+ let seed: ForgetSeeders;
36
42
 
37
43
  const TENANT = "00000000-0000-4000-8000-00000000000c";
38
- const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
39
44
 
40
45
  function uuid(suffix: number): string {
41
46
  return `bbbbbbbb-bbbb-4bbb-8bbb-${suffix.toString(16).padStart(12, "0")}`;
42
47
  }
43
48
 
44
- type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
45
- const NOW = (): Instant => getTemporal().Now.instant();
46
- const pastInstant = (): Instant => getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
47
-
48
49
  beforeAll(async () => {
49
50
  provider = createInMemoryFileProvider();
50
51
  stack = await setupTestStack({
@@ -60,27 +61,12 @@ beforeAll(async () => {
60
61
  files: { storageProvider: provider },
61
62
  });
62
63
  db = stack.db;
64
+ seed = createForgetSeeders(db, provider);
63
65
 
64
66
  await unsafeCreateEntityTable(db, userEntity);
65
67
  await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
66
68
  await unsafePushTables(db, { fileRefsTable });
67
- await asRawClient(db).unsafe(`
68
- CREATE TABLE IF NOT EXISTS read_tenant_memberships (
69
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
70
- tenant_id UUID NOT NULL,
71
- user_id TEXT NOT NULL,
72
- version INTEGER NOT NULL DEFAULT 0,
73
- inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
74
- modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
75
- inserted_by_id TEXT,
76
- modified_by_id TEXT,
77
- is_deleted BOOLEAN NOT NULL DEFAULT false,
78
- deleted_at TIMESTAMPTZ,
79
- deleted_by_id TEXT,
80
- roles TEXT NOT NULL DEFAULT '[]',
81
- UNIQUE(user_id, tenant_id)
82
- )
83
- `);
69
+ await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
84
70
  });
85
71
 
86
72
  afterAll(async () => {
@@ -92,49 +78,15 @@ beforeEach(async () => {
92
78
  await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
93
79
  });
94
80
 
95
- async function seedForgetUser(id: string): Promise<void> {
96
- await insertOne(db, userTable, {
97
- id,
98
- tenantId: TENANT_SYSTEM,
99
- email: `user-${id}@example.com`,
100
- passwordHash: "hashed",
101
- displayName: `User ${id}`,
102
- locale: "de",
103
- emailVerified: true,
104
- roles: '["Member"]',
105
- status: USER_STATUS.DeletionRequested,
106
- gracePeriodEnd: pastInstant(),
107
- });
108
- }
109
-
110
- async function seedMembership(userId: string, tenantId: string): Promise<void> {
111
- await asRawClient(db).unsafe(
112
- `INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
113
- VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
114
- [tenantId, userId],
115
- );
116
- }
117
-
118
- async function seedFile(id: string, tenantId: string, insertedById: string): Promise<string> {
119
- const storageKey = `storage/${id}`;
120
- await provider.write(storageKey, new Uint8Array([1, 2, 3, 4]), "application/pdf");
121
- await asRawClient(db).unsafe(
122
- `INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
123
- VALUES ($1, $2, $3, $4, 'application/pdf', 4, $5) ON CONFLICT (id) DO NOTHING`,
124
- [id, tenantId, storageKey, `${id}.pdf`, insertedById],
125
- );
126
- return storageKey;
127
- }
128
-
129
81
  describe("forget-binary-cleanup :: storage.delete fires before row hard-delete", () => {
130
82
  test("Forget deletes the binary from the storage provider", async () => {
131
83
  const userId = uuid(1);
132
- await seedForgetUser(userId);
133
- await seedMembership(userId, TENANT);
134
- const key = await seedFile(uuid(101), TENANT, userId);
84
+ await seed.seedForgetUser(userId);
85
+ await seed.seedMembership(userId, TENANT);
86
+ const key = await seed.seedFile(uuid(101), TENANT, userId);
135
87
  expect(await provider.exists(key)).toBe(true);
136
88
 
137
- const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
89
+ const result = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
138
90
 
139
91
  expect(result.processedUserIds).toContain(userId);
140
92
  expect(await provider.exists(key)).toBe(false);
@@ -143,16 +95,16 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
143
95
 
144
96
  test("Multiple files from the same user — all binaries cleaned up", async () => {
145
97
  const userId = uuid(2);
146
- await seedForgetUser(userId);
147
- await seedMembership(userId, TENANT);
98
+ await seed.seedForgetUser(userId);
99
+ await seed.seedMembership(userId, TENANT);
148
100
  const keys = await Promise.all([
149
- seedFile(uuid(201), TENANT, userId),
150
- seedFile(uuid(202), TENANT, userId),
151
- seedFile(uuid(203), TENANT, userId),
101
+ seed.seedFile(uuid(201), TENANT, userId),
102
+ seed.seedFile(uuid(202), TENANT, userId),
103
+ seed.seedFile(uuid(203), TENANT, userId),
152
104
  ]);
153
105
  expect(provider.keys()).toHaveLength(3);
154
106
 
155
- await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
107
+ await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
156
108
 
157
109
  for (const key of keys) {
158
110
  expect(await provider.exists(key)).toBe(false);
@@ -163,14 +115,14 @@ describe("forget-binary-cleanup :: storage.delete fires before row hard-delete",
163
115
  test("Other tenants' files stay untouched", async () => {
164
116
  const userId = uuid(3);
165
117
  const otherTenant = "00000000-0000-4000-8000-00000000000d";
166
- await seedForgetUser(userId);
167
- await seedMembership(userId, TENANT);
168
- const myKey = await seedFile(uuid(301), TENANT, userId);
169
- const otherKey = await seedFile(uuid(302), otherTenant, "another-user");
118
+ await seed.seedForgetUser(userId);
119
+ await seed.seedMembership(userId, TENANT);
120
+ const myKey = await seed.seedFile(uuid(301), TENANT, userId);
121
+ const otherKey = await seed.seedFile(uuid(302), otherTenant, "another-user");
170
122
  // The other-tenant file is owned by a different user; the forget run for
171
123
  // userId must NOT touch it.
172
124
 
173
- await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
125
+ await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
174
126
 
175
127
  expect(await provider.exists(myKey)).toBe(false);
176
128
  expect(await provider.exists(otherKey)).toBe(true);
@@ -0,0 +1,211 @@
1
+ // Forget-Hook fail-closed Integration-Test.
2
+ //
3
+ // Beweist den Vertrag von runForgetCleanup für den fileRef-delete-Hook: wenn
4
+ // `storageProvider.delete()` fehlschlägt, wirft der Hook → die per-User-Sub-Tx
5
+ // rollt zurück → der User bleibt `DeletionRequested`, die Row + Binary bleiben,
6
+ // der Fehler landet im `errors`-Array. Der nächste Run (Storage wieder ok)
7
+ // konvergiert sauber, weil `delete` idempotent ist. Ohne den Throw würde ein
8
+ // transienter Storage-Fehler die Row permanent hard-löschen, den User auf
9
+ // `Deleted` flippen und die Binary dauerhaft verwaisen lassen (Art.-17-Erasure
10
+ // still unvollständig).
11
+
12
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { asRawClient, fetchOne } from "@cosmicdrift/kumiko-framework/bun-db";
14
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
15
+ import {
16
+ createInMemoryFileProvider,
17
+ fileRefsTable,
18
+ type InMemoryFileProvider,
19
+ } from "@cosmicdrift/kumiko-framework/files";
20
+ import {
21
+ setupTestStack,
22
+ type TestStack,
23
+ unsafeCreateEntityTable,
24
+ unsafePushTables,
25
+ } from "@cosmicdrift/kumiko-framework/stack";
26
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
27
+ import { createComplianceProfilesFeature } from "../../compliance-profiles";
28
+ import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
29
+ import { createFilesFeature } from "../../files";
30
+ import { createSessionsFeature } from "../../sessions";
31
+ import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
32
+ import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
33
+ import { createUserDataRightsFeature } from "../feature";
34
+ import { runForgetCleanup } from "../run-forget-cleanup";
35
+ import {
36
+ createForgetSeeders,
37
+ type ForgetSeeders,
38
+ nowInstant,
39
+ READ_TENANT_MEMBERSHIPS_DDL,
40
+ } from "./forget-test-helpers";
41
+
42
+ let stack: TestStack;
43
+ let db: DbConnection;
44
+ let base: InMemoryFileProvider;
45
+ let seed: ForgetSeeders;
46
+ // `delete` throws while set; flip off to simulate storage recovery on retry.
47
+ let failDeletes = true;
48
+
49
+ const TENANT = "00000000-0000-4000-8000-00000000000e";
50
+
51
+ beforeAll(async () => {
52
+ base = createInMemoryFileProvider();
53
+ // Spread the real provider (all methods bound to its store) and override
54
+ // only `delete` to fail on demand — lets one test prove abort + retry-convergence.
55
+ const flakyProvider: InMemoryFileProvider = {
56
+ ...base,
57
+ async delete(key) {
58
+ if (failDeletes) throw new Error("storage unavailable (test)");
59
+ return base.delete(key);
60
+ },
61
+ };
62
+ stack = await setupTestStack({
63
+ features: [
64
+ createUserFeature(),
65
+ createFilesFeature(),
66
+ createDataRetentionFeature(),
67
+ createComplianceProfilesFeature(),
68
+ createSessionsFeature(),
69
+ createUserDataRightsFeature(),
70
+ createUserDataRightsDefaultsFeature({ storageProvider: flakyProvider }),
71
+ ],
72
+ files: { storageProvider: flakyProvider },
73
+ });
74
+ db = stack.db;
75
+ // Seeders write binaries through the real store (`base`), not the flaky
76
+ // wrapper — the wrapper's `delete` failure is what the test exercises.
77
+ seed = createForgetSeeders(db, base);
78
+
79
+ await unsafeCreateEntityTable(db, userEntity);
80
+ await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
81
+ await unsafePushTables(db, { fileRefsTable });
82
+ await asRawClient(db).unsafe(READ_TENANT_MEMBERSHIPS_DDL);
83
+ });
84
+
85
+ afterAll(async () => {
86
+ await stack.cleanup();
87
+ });
88
+
89
+ beforeEach(async () => {
90
+ failDeletes = true;
91
+ base.clear();
92
+ await resetTestTables(db, [userTable, "read_tenant_memberships", fileRefsTable]);
93
+ });
94
+
95
+ async function fileRowCount(tenantId: string, insertedById: string): Promise<number> {
96
+ const rows = await asRawClient(db).unsafe(
97
+ `SELECT 1 FROM file_refs WHERE tenant_id = $1 AND inserted_by_id = $2`,
98
+ [tenantId, insertedById],
99
+ );
100
+ return (rows as ReadonlyArray<unknown>).length;
101
+ }
102
+
103
+ describe("forget fail-closed :: storage.delete failure aborts the row hard-delete", () => {
104
+ test("storage delete fails → user stays DeletionRequested, row + binary remain, error surfaced", async () => {
105
+ const userId = "cccccccc-cccc-4ccc-8ccc-000000000001";
106
+ await seed.seedForgetUser(userId);
107
+ await seed.seedMembership(userId, TENANT);
108
+ const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000001", TENANT, userId);
109
+
110
+ const result = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
111
+
112
+ // NOT flipped to Deleted — the sub-tx rolled back.
113
+ expect(result.processedUserIds).not.toContain(userId);
114
+ // Failure surfaced for operator visibility.
115
+ expect(result.errors.some((e) => e.userId === userId && e.entityName === "fileRef")).toBe(true);
116
+ // Row NOT hard-deleted; binary NOT orphaned.
117
+ expect(await fileRowCount(TENANT, userId)).toBe(1);
118
+ expect(await base.exists(key)).toBe(true);
119
+ // User still pending deletion in the DB.
120
+ const row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
121
+ expect(row?.status).toBe(USER_STATUS.DeletionRequested);
122
+ });
123
+
124
+ test("next run with storage healthy converges (idempotent delete) — user deleted, binary gone", async () => {
125
+ const userId = "cccccccc-cccc-4ccc-8ccc-000000000002";
126
+ await seed.seedForgetUser(userId);
127
+ await seed.seedMembership(userId, TENANT);
128
+ const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000002", TENANT, userId);
129
+
130
+ // First run: storage down → abort.
131
+ const first = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
132
+ expect(first.processedUserIds).not.toContain(userId);
133
+ expect(await base.exists(key)).toBe(true);
134
+
135
+ // Storage recovers; retry converges.
136
+ failDeletes = false;
137
+ const second = await runForgetCleanup({ db, registry: stack.registry, now: nowInstant() });
138
+
139
+ expect(second.processedUserIds).toContain(userId);
140
+ expect(await base.exists(key)).toBe(false);
141
+ expect(await fileRowCount(TENANT, userId)).toBe(0);
142
+ const row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
143
+ expect(row?.status).toBe(USER_STATUS.Deleted);
144
+ });
145
+ });
146
+
147
+ // Same contract, but driven through the REAL dispatcher (POST run-forget-cleanup)
148
+ // rather than calling runForgetCleanup with a top-level connection. The
149
+ // dispatcher wraps the handler in an outer transaction, so ctx.db.raw is a
150
+ // TransactionSql — which has `.savepoint`, not `.begin`. The per-user sub-tx
151
+ // must open as a SAVEPOINT here; the previous `.begin`-only path threw on every
152
+ // user when invoked this way (the cron path), so production deleted nobody while
153
+ // these direct-connection tests stayed green. #214.
154
+ describe("forget-cleanup through the real dispatcher :: per-user savepoint nests under the handler tx", () => {
155
+ const systemUser = {
156
+ id: "00000000-0000-4000-8000-0000000000ff",
157
+ tenantId: TENANT,
158
+ roles: ["SystemAdmin"],
159
+ };
160
+ type CleanupResult = {
161
+ readonly processedUserIds: readonly string[];
162
+ readonly hookCallsAttempted: number;
163
+ readonly errorCount: number;
164
+ readonly errors: ReadonlyArray<{ readonly userId: string; readonly entityName: string }>;
165
+ };
166
+ const RUN_FORGET = "user-data-rights:write:run-forget-cleanup";
167
+
168
+ test("dispatcher POST flips a due user to Deleted (SAVEPOINT inside the outer handler tx)", async () => {
169
+ failDeletes = false;
170
+ const userId = "cccccccc-cccc-4ccc-8ccc-000000000003";
171
+ await seed.seedForgetUser(userId);
172
+ await seed.seedMembership(userId, TENANT);
173
+ const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000003", TENANT, userId);
174
+
175
+ const result = await stack.http.writeOk<CleanupResult>(RUN_FORGET, {}, systemUser);
176
+
177
+ // Pre-fix this list was always empty — `.begin` is absent on the
178
+ // dispatcher's TransactionSql, so the per-user sub-tx threw for every user.
179
+ expect(result.processedUserIds).toContain(userId);
180
+ expect(result.errorCount).toBe(0);
181
+ expect(await base.exists(key)).toBe(false);
182
+ expect(await fileRowCount(TENANT, userId)).toBe(0);
183
+ const row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
184
+ expect(row?.status).toBe(USER_STATUS.Deleted);
185
+ });
186
+
187
+ test("fail-closed + retry-convergence through the dispatcher (savepoint rolls back one user)", async () => {
188
+ const userId = "cccccccc-cccc-4ccc-8ccc-000000000004";
189
+ await seed.seedForgetUser(userId);
190
+ await seed.seedMembership(userId, TENANT);
191
+ const key = await seed.seedFile("dddddddd-dddd-4ddd-8ddd-000000000004", TENANT, userId);
192
+
193
+ // Storage down → fileRef hook throws → ROLLBACK TO SAVEPOINT undoes just
194
+ // this user; the outer handler tx still commits the run. User stays pending.
195
+ failDeletes = true;
196
+ const failed = await stack.http.writeOk<CleanupResult>(RUN_FORGET, {}, systemUser);
197
+ expect(failed.processedUserIds).not.toContain(userId);
198
+ expect(failed.errors.some((e) => e.userId === userId && e.entityName === "fileRef")).toBe(true);
199
+ expect(await base.exists(key)).toBe(true);
200
+ let row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
201
+ expect(row?.status).toBe(USER_STATUS.DeletionRequested);
202
+
203
+ // Storage recovers → retry converges (idempotent delete).
204
+ failDeletes = false;
205
+ const ok = await stack.http.writeOk<CleanupResult>(RUN_FORGET, {}, systemUser);
206
+ expect(ok.processedUserIds).toContain(userId);
207
+ expect(await base.exists(key)).toBe(false);
208
+ row = await fetchOne<{ status: string }>(db, userTable, { id: userId });
209
+ expect(row?.status).toBe(USER_STATUS.Deleted);
210
+ });
211
+ });