@cosmicdrift/kumiko-bundled-features 0.221.0 → 0.222.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 (72) hide show
  1. package/package.json +10 -9
  2. package/src/audit/__tests__/audit-log-screen.test.tsx +5 -5
  3. package/src/audit/__tests__/audit-screens.boot.test.ts +2 -1
  4. package/src/audit/__tests__/audit.integration.test.ts +3 -1
  5. package/src/audit/__tests__/constants.test.ts +6 -1
  6. package/src/audit/constants.ts +10 -1
  7. package/src/audit/feature.ts +2 -0
  8. package/src/audit/i18n.ts +2 -0
  9. package/src/audit/web/audit-log-detail-screen.tsx +7 -3
  10. package/src/audit/web/audit-log-screen.tsx +27 -29
  11. package/src/auth-email-password/handlers/invite-create.write.ts +7 -1
  12. package/src/auth-email-password/seeding.ts +3 -1
  13. package/src/auth-mfa/hmac-token-codec.ts +75 -0
  14. package/src/auth-mfa/mfa-challenge-token.ts +10 -65
  15. package/src/auth-mfa/mfa-preauth-setup-token.ts +10 -61
  16. package/src/cap-counter/stock-cap-guard.ts +1 -1
  17. package/src/channel-email/__tests__/email-channel.test.ts +19 -0
  18. package/src/channel-email/__tests__/pii-guard.test.ts +11 -0
  19. package/src/channel-email/email-channel.ts +14 -7
  20. package/src/channel-email/pii-guard.ts +8 -0
  21. package/src/compliance-profiles/handlers/set-profile.write.ts +1 -4
  22. package/src/crypto-shredding/constants.ts +1 -0
  23. package/src/crypto-shredding/feature.ts +6 -1
  24. package/src/crypto-shredding/handlers/forget-subject.write.ts +25 -1
  25. package/src/crypto-shredding/index.ts +2 -0
  26. package/src/custom-fields/__tests__/audit-integration.integration.test.ts +9 -1
  27. package/src/file-derivatives/feature.ts +2 -2
  28. package/src/file-derivatives/handlers/public-variant.query.ts +4 -5
  29. package/src/files-provider-s3/__tests__/s3-provider.test.ts +58 -1
  30. package/src/files-provider-s3/s3-provider.ts +45 -0
  31. package/src/files-tenant-data/__tests__/hooks.integration.test.ts +271 -0
  32. package/src/files-tenant-data/__tests__/sweep-orphaned-derivatives.job.integration.test.ts +275 -0
  33. package/src/files-tenant-data/changes.json +1 -0
  34. package/src/files-tenant-data/handlers/sweep-orphaned-derivatives.job.ts +173 -0
  35. package/src/files-tenant-data/hooks.ts +93 -0
  36. package/src/files-tenant-data/index.ts +58 -0
  37. package/src/form-draft/db/queries/cleanup.ts +17 -0
  38. package/src/form-draft/handlers/cleanup.job.ts +20 -10
  39. package/src/form-draft/handlers/discard.write.ts +25 -7
  40. package/src/form-draft/handlers/get.query.ts +4 -2
  41. package/src/form-draft/handlers/list.query.ts +4 -1
  42. package/src/form-draft/lookup.ts +12 -5
  43. package/src/inbound-mail-foundation/watch-supervisor.ts +28 -4
  44. package/src/jobs/__tests__/jobs-pii-kms.integration.test.ts +128 -3
  45. package/src/jobs/constants.ts +1 -0
  46. package/src/jobs/handlers/retry.write.ts +26 -6
  47. package/src/jobs/i18n.ts +3 -0
  48. package/src/sessions/session-callbacks.ts +1 -0
  49. package/src/tags/__tests__/tag-screens.boot.test.ts +13 -0
  50. package/src/tenant/__tests__/tenant-timezone-boot.integration.test.ts +165 -0
  51. package/src/tenant/changes.json +1 -1
  52. package/src/tenant/constants.ts +5 -1
  53. package/src/tenant/handlers/remove-member.write.ts +12 -0
  54. package/src/tenant/handlers/update-member-roles.write.ts +10 -37
  55. package/src/tenant/last-tenant-admin.ts +39 -0
  56. package/src/tenant/screens.ts +2 -0
  57. package/src/tenant/seeding.ts +3 -1
  58. package/src/tenant-lifecycle/feature.ts +1 -0
  59. package/src/tenant-lifecycle/handlers/cancel-destruction.write.ts +1 -5
  60. package/src/tenant-lifecycle/run-tenant-destroy.ts +5 -0
  61. package/src/tenant-lifecycle/stages.ts +5 -0
  62. package/src/tier-engine/web/tier-admin-screen.tsx +1 -2
  63. package/src/user/__tests__/email-unique-blind-index.integration.test.ts +25 -13
  64. package/src/user/changes.json +12 -5
  65. package/src/user-data-rights/handlers/cancel-deletion.write.ts +3 -10
  66. package/src/user-data-rights/handlers/confirm-deletion-by-token.write.ts +2 -8
  67. package/src/user-data-rights/handlers/deletion-grace-period.ts +2 -2
  68. package/src/user-data-rights/handlers/lift-restriction.write.ts +2 -2
  69. package/src/user-data-rights/handlers/restrict-account.write.ts +3 -3
  70. package/src/user-data-rights/web/__tests__/privacy-center-screen.test.tsx +10 -6
  71. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +114 -1
  72. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +63 -12
@@ -0,0 +1,271 @@
1
+ // files-tenant-data — EXT_TENANT_DATA (fileRef row purge) + EXT_STORAGE_PROVIDER
2
+ // (full tenant storage-prefix wipe) coverage for tenant-destroy (#2474).
3
+ //
4
+ // Seeds the "destroying" tenant state directly (status update + a
5
+ // TENANT_DESTRUCTION_STARTED_EVENT_QN append) instead of going through the
6
+ // `request-destruction` write handler — same pattern as tenant-lifecycle's
7
+ // own "pipeline abandon / destroyFailed" describe block, which sidesteps that
8
+ // handler's user/auth/sessions feature requirements entirely.
9
+
10
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
11
+ import { randomBytes } from "node:crypto";
12
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
13
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
14
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
15
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
16
+ import {
17
+ append,
18
+ createEventsTable,
19
+ loadAggregate,
20
+ } from "@cosmicdrift/kumiko-framework/event-store";
21
+ import {
22
+ buildStorageKey,
23
+ createInMemoryFileProvider,
24
+ fileRefEntity,
25
+ fileRefsTable,
26
+ type InMemoryFileProvider,
27
+ } from "@cosmicdrift/kumiko-framework/files";
28
+ import {
29
+ setupTestStack,
30
+ type TestStack,
31
+ TestUsers,
32
+ unsafeCreateEntityTable,
33
+ unsafePushTables,
34
+ } from "@cosmicdrift/kumiko-framework/stack";
35
+ import {
36
+ createTestEnvelopeCipher,
37
+ resetTestTables,
38
+ updateRows,
39
+ } from "@cosmicdrift/kumiko-framework/testing";
40
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
41
+ import {
42
+ createComplianceProfilesFeature,
43
+ tenantComplianceProfileEntity,
44
+ tenantComplianceProfileTable,
45
+ } from "../../compliance-profiles";
46
+ import { createConfigFeature } from "../../config";
47
+ import { createConfigResolver } from "../../config/resolver";
48
+ import { configValuesTable } from "../../config/table";
49
+ import { createFilesFeature } from "../../files";
50
+ import { tenantMembershipEntity } from "../../tenant";
51
+ import { TenantHandlers } from "../../tenant/constants";
52
+ import { createTenantFeature } from "../../tenant/feature";
53
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
54
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
55
+ import {
56
+ TENANT_AGGREGATE_TYPE,
57
+ TENANT_DESTRUCTION_STARTED_EVENT_QN,
58
+ } from "../../tenant-lifecycle/constants";
59
+ import { runTenantDestructionSweep } from "../../tenant-lifecycle/run-tenant-destroy";
60
+ import { sweepOrphanedDerivativesJob } from "../handlers/sweep-orphaned-derivatives.job";
61
+ import { createFilesTenantDataFeature } from "../index";
62
+
63
+ const SET_PROFILE = "compliance-profiles:write:set-profile";
64
+ const SWEEP_JOB = "files-tenant-data:job:sweep-orphaned-derivatives";
65
+
66
+ const fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
67
+ entityName: "fileRef",
68
+ });
69
+
70
+ let stack: TestStack;
71
+ let db: DbConnection;
72
+ let provider: InMemoryFileProvider;
73
+
74
+ const tenantA = TestUsers.admin;
75
+ const tenantB = TestUsers.otherTenant;
76
+
77
+ beforeAll(async () => {
78
+ provider = createInMemoryFileProvider();
79
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
80
+ const resolver = createConfigResolver({ cipher: encryption });
81
+
82
+ stack = await setupTestStack({
83
+ features: [
84
+ createConfigFeature(),
85
+ createTenantFeature(),
86
+ createComplianceProfilesFeature(),
87
+ createTenantLifecycleFeature(),
88
+ createFilesFeature(),
89
+ createFilesTenantDataFeature(),
90
+ ],
91
+ extraContext: { configResolver: resolver, configEncryption: encryption },
92
+ files: { storageProvider: provider },
93
+ });
94
+ db = stack.db;
95
+
96
+ await unsafeCreateEntityTable(db, tenantEntity);
97
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
98
+ await unsafeCreateEntityTable(db, tenantMembershipEntity);
99
+ await unsafeCreateEntityTable(db, fileRefEntity);
100
+ await createEventsTable(db);
101
+ await unsafePushTables(db, { configValuesTable });
102
+ });
103
+
104
+ afterAll(async () => {
105
+ await stack.cleanup();
106
+ });
107
+
108
+ beforeEach(async () => {
109
+ stack.events.reset();
110
+ provider.clear();
111
+ await resetTestTables(db, [tenantTable, tenantComplianceProfileTable]);
112
+ await stack.db.unsafe?.(`TRUNCATE kumiko_events, file_refs RESTART IDENTITY CASCADE`);
113
+ });
114
+
115
+ async function seedTenant(user: typeof tenantA): Promise<void> {
116
+ await stack.http.writeOk(
117
+ TenantHandlers.create,
118
+ { id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
119
+ TestUsers.systemAdmin,
120
+ );
121
+ await stack.http.writeOk(SET_PROFILE, { profileKey: "eu-dsgvo" }, user);
122
+ }
123
+
124
+ async function seedFileRef(tenantId: TenantId, storageKey: string) {
125
+ const user = createSystemUser(tenantId);
126
+ const tdb = createTenantDb(db, tenantId, "system");
127
+ const result = await fileRefCrud.create(
128
+ { storageKey, fileName: "photo.jpg", mimeType: "image/jpeg", size: 10 },
129
+ user,
130
+ tdb,
131
+ );
132
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.message}`);
133
+ return { id: result.data.id };
134
+ }
135
+
136
+ // Sidesteps the `request-destruction` write handler (needs user/auth/sessions
137
+ // features wired) by seeding the same "destroying" state it would produce —
138
+ // same pattern as tenant-lifecycle's own poison-pill destroy tests.
139
+ async function seedDestroyingTenant(tenantId: TenantId): Promise<void> {
140
+ const now = getTemporal().Now.instant();
141
+ await updateRows(
142
+ db,
143
+ tenantTable,
144
+ { status: "destroying", destroyStartedAt: now },
145
+ { id: tenantId },
146
+ );
147
+ await append(db, {
148
+ aggregateId: tenantId,
149
+ aggregateType: TENANT_AGGREGATE_TYPE,
150
+ tenantId,
151
+ expectedVersion: (await loadAggregate(db, tenantId, tenantId)).at(-1)?.version ?? 0,
152
+ type: TENANT_DESTRUCTION_STARTED_EVENT_QN,
153
+ payload: { startedAt: now.toString() },
154
+ metadata: { userId: "system", requestId: "test:destruction-started" },
155
+ });
156
+ }
157
+
158
+ async function driveDestructionToCompletion(
159
+ tenantId: TenantId,
160
+ fileProviderResolver?: (tenantId: TenantId) => Promise<InMemoryFileProvider>,
161
+ ): Promise<string> {
162
+ const farFuture = getTemporal()
163
+ .Now.instant()
164
+ .add({ hours: 24 * 3650 });
165
+ let status = "";
166
+ for (let i = 0; i < 20; i++) {
167
+ await runTenantDestructionSweep({
168
+ db: stack.db,
169
+ registry: stack.registry,
170
+ now: farFuture,
171
+ ...(fileProviderResolver ? { fileProviderResolver } : {}),
172
+ });
173
+ const rows = await selectMany(db, tenantTable, { id: tenantId });
174
+ status = String(rows[0]?.["status"]);
175
+ if (status === "destroyed" || status === "destroyFailed") break;
176
+ }
177
+ return status;
178
+ }
179
+
180
+ describe("files-tenant-data :: job registration", () => {
181
+ test("registers sweepOrphanedDerivativesJob as files-tenant-data:job:sweep-orphaned-derivatives — the name jobs:write:trigger dispatches by", () => {
182
+ const job = stack.registry.getJob(SWEEP_JOB);
183
+ expect(job).toBeDefined();
184
+ expect(job?.handler).toBe(sweepOrphanedDerivativesJob);
185
+ expect(job?.trigger).toEqual({ manual: true });
186
+ });
187
+ });
188
+
189
+ describe("files-tenant-data :: tenant destroy", () => {
190
+ test("purges fileRef rows and wipes every storage key under the tenant prefix, leaving other tenants untouched", async () => {
191
+ await seedTenant(tenantA);
192
+ await seedTenant(tenantB);
193
+
194
+ const originalKeyA = buildStorageKey(
195
+ tenantA.tenantId,
196
+ "fileRef",
197
+ 1,
198
+ "attachment",
199
+ "photo.jpg",
200
+ "u1",
201
+ );
202
+ const derivativeKeyA = `${originalKeyA.replace(/\.jpg$/, "")}.thumb-0123456789abcdef.jpg`;
203
+ const unrelatedKeyA = `${tenantA.tenantId}/some-other-file.bin`;
204
+ const { id: fileRefIdA } = await seedFileRef(tenantA.tenantId, originalKeyA);
205
+ await provider.write(originalKeyA, new Uint8Array([1]));
206
+ await provider.write(derivativeKeyA, new Uint8Array([2]));
207
+ await provider.write(unrelatedKeyA, new Uint8Array([3]));
208
+
209
+ const originalKeyB = buildStorageKey(
210
+ tenantB.tenantId,
211
+ "fileRef",
212
+ 1,
213
+ "attachment",
214
+ "photo.jpg",
215
+ "u2",
216
+ );
217
+ await seedFileRef(tenantB.tenantId, originalKeyB);
218
+ await provider.write(originalKeyB, new Uint8Array([4]));
219
+
220
+ await seedDestroyingTenant(tenantA.tenantId);
221
+
222
+ const finalStatus = await driveDestructionToCompletion(tenantA.tenantId, async () => provider);
223
+ expect(finalStatus).toBe("destroyed");
224
+
225
+ // Row purge (EXT_TENANT_DATA "app-data" stage) — hard-purged via forget(),
226
+ // not just soft-deleted, so a plain selectMany with no isDeleted filter
227
+ // already proves it (a soft-delete would still show the row).
228
+ const rowsA = await selectMany(db, fileRefsTable, { tenantId: tenantA.tenantId });
229
+ expect(rowsA).toHaveLength(0);
230
+
231
+ // Storage wipe (EXT_STORAGE_PROVIDER "files" stage) — every key under the
232
+ // tenant's prefix is gone, including the unrelated non-fileRef-tracked key.
233
+ expect(await provider.list(`${tenantA.tenantId}/`)).toHaveLength(0);
234
+
235
+ // Tenant B's fileRef row and storage key are completely untouched.
236
+ const rowsB = await selectMany(db, fileRefsTable, { tenantId: tenantB.tenantId });
237
+ expect(rowsB).toHaveLength(1);
238
+ expect(await provider.exists(originalKeyB)).toBe(true);
239
+
240
+ // The forgotten row's aggregate carries a `.forgotten` event — proves
241
+ // this went through the executor (rebuild-safe), not a raw deleteMany.
242
+ const events = await loadAggregate(db, String(fileRefIdA), tenantA.tenantId);
243
+ expect(events.some((e) => e.type === "fileRef.forgotten")).toBe(true);
244
+ });
245
+
246
+ test("no fileProviderResolver wired: row purge still runs, storage stage no-ops without failing the pipeline", async () => {
247
+ await seedTenant(tenantA);
248
+ const originalKeyA = buildStorageKey(
249
+ tenantA.tenantId,
250
+ "fileRef",
251
+ 1,
252
+ "attachment",
253
+ "photo.jpg",
254
+ "u1",
255
+ );
256
+ await seedFileRef(tenantA.tenantId, originalKeyA);
257
+ await provider.write(originalKeyA, new Uint8Array([1]));
258
+
259
+ await seedDestroyingTenant(tenantA.tenantId);
260
+
261
+ const finalStatus = await driveDestructionToCompletion(tenantA.tenantId);
262
+ expect(finalStatus).toBe("destroyed");
263
+
264
+ const rowsA = await selectMany(db, fileRefsTable, { tenantId: tenantA.tenantId });
265
+ expect(rowsA).toHaveLength(0);
266
+ // No resolver was passed — the binary was never asked to be deleted, but
267
+ // that must degrade gracefully (not fail-closed), same as the per-user
268
+ // forget hook's resolution-failure stance.
269
+ expect(await provider.exists(originalKeyA)).toBe(true);
270
+ });
271
+ });
@@ -0,0 +1,275 @@
1
+ // Backfill/GC job coverage (#2474): sweeps derivative-shaped storage keys
2
+ // with no owning fileRef row, for tenants forgotten/destroyed before #2461
3
+ // wired binary cleanup into forget/tenant-destroy. Manual job (not perTenant,
4
+ // not fanned-out) — called directly with a hand-built JobContext, same
5
+ // pattern as jobs/__tests__/reindex-entity-job.integration.test.ts, since
6
+ // setupTestStack's jobRunner has nothing extra to wire for a plain manual job.
7
+
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
9
+ import { randomBytes } from "node:crypto";
10
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
11
+ import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
12
+ import type { JobContext, TenantId } from "@cosmicdrift/kumiko-framework/engine";
13
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
14
+ import {
15
+ buildStorageKey,
16
+ createInMemoryFileProvider,
17
+ fileRefEntity,
18
+ fileRefsTable,
19
+ type InMemoryFileProvider,
20
+ } from "@cosmicdrift/kumiko-framework/files";
21
+ import {
22
+ setupTestStack,
23
+ type TestStack,
24
+ TestUsers,
25
+ unsafeCreateEntityTable,
26
+ unsafePushTables,
27
+ } from "@cosmicdrift/kumiko-framework/stack";
28
+ import {
29
+ bridgeStub,
30
+ createTestEnvelopeCipher,
31
+ resetTestTables,
32
+ } from "@cosmicdrift/kumiko-framework/testing";
33
+ import { createConfigFeature } from "../../config";
34
+ import { createConfigResolver } from "../../config/resolver";
35
+ import { configValuesTable } from "../../config/table";
36
+ import { createFilesFeature } from "../../files";
37
+ import { TenantHandlers } from "../../tenant/constants";
38
+ import { createTenantFeature } from "../../tenant/feature";
39
+ import { tenantEntity, tenantTable } from "../../tenant/schema/tenant";
40
+ import { sweepOrphanedDerivativesJob } from "../handlers/sweep-orphaned-derivatives.job";
41
+
42
+ const fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
43
+ entityName: "fileRef",
44
+ });
45
+
46
+ let stack: TestStack;
47
+ let provider: InMemoryFileProvider;
48
+
49
+ const noopLogger: JobContext["log"] = {
50
+ info() {},
51
+ warn() {},
52
+ error() {},
53
+ debug() {},
54
+ child() {
55
+ return noopLogger;
56
+ },
57
+ };
58
+
59
+ const tenantA = TestUsers.admin;
60
+ const tenantB = TestUsers.otherTenant;
61
+
62
+ function makeCtx(
63
+ fileProviderResolver?: JobContext["_fileProviderResolver"],
64
+ log: JobContext["log"] = noopLogger,
65
+ ): JobContext {
66
+ return {
67
+ db: stack.db,
68
+ registry: stack.registry,
69
+ systemUser: tenantA,
70
+ log,
71
+ triggeredBy: null,
72
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
73
+ ...bridgeStub(),
74
+ };
75
+ }
76
+
77
+ function makeCapturingLogger(): { log: JobContext["log"]; infoLines: string[] } {
78
+ const infoLines: string[] = [];
79
+ const log: JobContext["log"] = {
80
+ info(message: string) {
81
+ infoLines.push(message);
82
+ },
83
+ warn() {},
84
+ error() {},
85
+ debug() {},
86
+ child() {
87
+ return log;
88
+ },
89
+ };
90
+ return { log, infoLines };
91
+ }
92
+
93
+ async function seedFileRef(tenantId: TenantId, storageKey: string): Promise<void> {
94
+ const user = TestUsers.systemAdmin;
95
+ const tdb = createTenantDb(stack.db, tenantId, "system");
96
+ const result = await fileRefCrud.create(
97
+ { storageKey, fileName: "photo.jpg", mimeType: "image/jpeg", size: 10 },
98
+ { ...user, tenantId },
99
+ tdb,
100
+ );
101
+ if (!result.isSuccess) throw new Error(`seed failed: ${result.error.message}`);
102
+ }
103
+
104
+ async function seedTenant(user: typeof tenantA): Promise<void> {
105
+ await stack.http.writeOk(
106
+ TenantHandlers.create,
107
+ { id: user.tenantId, key: `t-${user.tenantId}`, name: "Tenant" },
108
+ TestUsers.systemAdmin,
109
+ );
110
+ }
111
+
112
+ function derivativeOf(originalKey: string): string {
113
+ const lastDot = originalKey.lastIndexOf(".");
114
+ return `${originalKey.slice(0, lastDot)}.thumb-0123456789abcdef${originalKey.slice(lastDot)}`;
115
+ }
116
+
117
+ beforeAll(async () => {
118
+ provider = createInMemoryFileProvider();
119
+ const encryption = createTestEnvelopeCipher(randomBytes(32).toString("base64"));
120
+ const resolver = createConfigResolver({ cipher: encryption });
121
+
122
+ stack = await setupTestStack({
123
+ features: [createConfigFeature(), createTenantFeature(), createFilesFeature()],
124
+ extraContext: { configResolver: resolver, configEncryption: encryption },
125
+ files: { storageProvider: provider },
126
+ });
127
+ await unsafeCreateEntityTable(stack.db, tenantEntity);
128
+ await unsafeCreateEntityTable(stack.db, fileRefEntity);
129
+ await createEventsTable(stack.db);
130
+ await unsafePushTables(stack.db, { configValuesTable });
131
+ });
132
+
133
+ afterAll(async () => {
134
+ await stack.cleanup();
135
+ });
136
+
137
+ beforeEach(async () => {
138
+ provider.clear();
139
+ await resetTestTables(stack.db, [tenantTable]);
140
+ await stack.db.unsafe?.(`TRUNCATE kumiko_events, file_refs RESTART IDENTITY CASCADE`);
141
+ });
142
+
143
+ describe("sweepOrphanedDerivativesJob", () => {
144
+ test("deletes a derivative with no owning fileRef row (orphan)", async () => {
145
+ await seedTenant(tenantA);
146
+ const original = buildStorageKey(tenantA.tenantId, "fileRef", 1, "attachment", "a.jpg", "u1");
147
+ const orphan = derivativeOf(original);
148
+ await provider.write(orphan, new Uint8Array([1]));
149
+ // No fileRef row seeded for `original` — the derivative has no owner.
150
+
151
+ await sweepOrphanedDerivativesJob(
152
+ {},
153
+ makeCtx(async () => provider),
154
+ );
155
+
156
+ expect(await provider.exists(orphan)).toBe(false);
157
+ });
158
+
159
+ test("keeps a derivative whose original fileRef row still exists", async () => {
160
+ await seedTenant(tenantA);
161
+ const original = buildStorageKey(tenantA.tenantId, "fileRef", 1, "attachment", "a.jpg", "u1");
162
+ const derivative = derivativeOf(original);
163
+ await seedFileRef(tenantA.tenantId, original);
164
+ await provider.write(original, new Uint8Array([1]));
165
+ await provider.write(derivative, new Uint8Array([2]));
166
+
167
+ await sweepOrphanedDerivativesJob(
168
+ {},
169
+ makeCtx(async () => provider),
170
+ );
171
+
172
+ expect(await provider.exists(derivative)).toBe(true);
173
+ expect(await provider.exists(original)).toBe(true);
174
+ });
175
+
176
+ test("keeps a derivative whose original fileRef row is soft-deleted (trashed, not forgotten)", async () => {
177
+ await seedTenant(tenantA);
178
+ const original = buildStorageKey(tenantA.tenantId, "fileRef", 1, "attachment", "a.jpg", "u1");
179
+ const derivative = derivativeOf(original);
180
+ await seedFileRef(tenantA.tenantId, original);
181
+ await provider.write(derivative, new Uint8Array([2]));
182
+
183
+ const rows = await selectMany<{ id: string }>(
184
+ stack.db,
185
+ fileRefsTable,
186
+ { tenantId: tenantA.tenantId },
187
+ { limit: 1 },
188
+ );
189
+ const rowId = rows[0]?.id;
190
+ expect(rowId).toBeDefined();
191
+ const tdb = createTenantDb(stack.db, tenantA.tenantId, "system");
192
+ if (rowId) {
193
+ const deleteResult = await fileRefCrud.delete({ id: rowId }, TestUsers.systemAdmin, tdb);
194
+ expect(deleteResult.isSuccess).toBe(true);
195
+ }
196
+
197
+ await sweepOrphanedDerivativesJob(
198
+ {},
199
+ makeCtx(async () => provider),
200
+ );
201
+
202
+ // A trashed (soft-deleted) row still legitimately backs its derivatives —
203
+ // only an ABSENT row makes a derivative orphaned.
204
+ expect(await provider.exists(derivative)).toBe(true);
205
+ });
206
+
207
+ test("dryRun: true reports without deleting anything", async () => {
208
+ await seedTenant(tenantA);
209
+ const original = buildStorageKey(tenantA.tenantId, "fileRef", 1, "attachment", "a.jpg", "u1");
210
+ const orphan = derivativeOf(original);
211
+ await provider.write(orphan, new Uint8Array([1]));
212
+ const { log, infoLines } = makeCapturingLogger();
213
+
214
+ await sweepOrphanedDerivativesJob(
215
+ { dryRun: true },
216
+ makeCtx(async () => provider, log),
217
+ );
218
+
219
+ // Existence alone can't distinguish "correctly found and would delete the
220
+ // orphan" from "found nothing" — both leave the key in place. Assert the
221
+ // sweep actually counted the candidate.
222
+ expect(infoLines.some((line) => line.includes("wouldDelete=1"))).toBe(true);
223
+ expect(await provider.exists(orphan)).toBe(true);
224
+ });
225
+
226
+ test("pages across multiple tenants and only sweeps each tenant's own orphans", async () => {
227
+ await seedTenant(tenantA);
228
+ await seedTenant(tenantB);
229
+
230
+ const originalA = buildStorageKey(tenantA.tenantId, "fileRef", 1, "attachment", "a.jpg", "u1");
231
+ const orphanA = derivativeOf(originalA);
232
+ await provider.write(orphanA, new Uint8Array([1]));
233
+
234
+ const originalB = buildStorageKey(tenantB.tenantId, "fileRef", 1, "attachment", "b.jpg", "u2");
235
+ const derivativeB = derivativeOf(originalB);
236
+ await seedFileRef(tenantB.tenantId, originalB);
237
+ await provider.write(originalB, new Uint8Array([2]));
238
+ await provider.write(derivativeB, new Uint8Array([3]));
239
+
240
+ await sweepOrphanedDerivativesJob(
241
+ {},
242
+ makeCtx(async () => provider),
243
+ );
244
+
245
+ expect(await provider.exists(orphanA)).toBe(false);
246
+ expect(await provider.exists(derivativeB)).toBe(true);
247
+ expect(await provider.exists(originalB)).toBe(true);
248
+ });
249
+
250
+ test("no _fileProviderResolver wired: skips the run entirely, no throw", async () => {
251
+ await seedTenant(tenantA);
252
+ const original = buildStorageKey(tenantA.tenantId, "fileRef", 1, "attachment", "a.jpg", "u1");
253
+ const orphan = derivativeOf(original);
254
+ await provider.write(orphan, new Uint8Array([1]));
255
+
256
+ await expect(sweepOrphanedDerivativesJob({}, makeCtx())).resolves.toBeUndefined();
257
+
258
+ expect(await provider.exists(orphan)).toBe(true);
259
+ });
260
+
261
+ test("a non-derivative-shaped key (the original itself) is never touched", async () => {
262
+ await seedTenant(tenantA);
263
+ const original = buildStorageKey(tenantA.tenantId, "fileRef", 1, "attachment", "a.jpg", "u1");
264
+ await provider.write(original, new Uint8Array([1]));
265
+ // No fileRef row seeded — if parseDerivativeKey ever mis-parsed a plain
266
+ // original as derivative-shaped, this would wrongly delete it.
267
+
268
+ await sweepOrphanedDerivativesJob(
269
+ {},
270
+ makeCtx(async () => provider),
271
+ );
272
+
273
+ expect(await provider.exists(original)).toBe(true);
274
+ });
275
+ });
@@ -0,0 +1 @@
1
+ []