@cosmicdrift/kumiko-bundled-features 0.308.0 → 0.310.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/package.json +9 -9
- package/src/auth-email-password/__tests__/seed-admin-guarded.integration.test.ts +31 -0
- package/src/auth-email-password/changes.json +6 -0
- package/src/auth-email-password/seeding.ts +16 -3
- package/src/data-retention/__tests__/retention-cleanup-files.integration.test.ts +454 -0
- package/src/data-retention/changes.json +6 -0
- package/src/data-retention/feature.ts +1 -0
- package/src/data-retention/run-retention-cleanup.ts +312 -5
- package/src/file-derivatives/__tests__/public-variant-cross-tenant.integration.test.ts +224 -0
- package/src/file-derivatives/changes.json +6 -0
- package/src/file-derivatives/feature.ts +38 -4
- package/src/file-derivatives/handlers/public-variant-by-file-ref.query.ts +40 -0
- package/src/file-derivatives/handlers/public-variant.query.ts +16 -7
- package/src/file-derivatives/index.ts +5 -1
- package/src/tenant-lifecycle/__tests__/tenant-lifecycle.integration.test.ts +8 -24
- package/src/user/seeding.ts +29 -20
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +8 -61
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.310.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -133,12 +133,12 @@
|
|
|
133
133
|
"./workflow-runner": "./src/workflow-runner/index.ts"
|
|
134
134
|
},
|
|
135
135
|
"dependencies": {
|
|
136
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
137
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
138
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
139
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
140
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
141
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
136
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.310.0",
|
|
137
|
+
"@cosmicdrift/kumiko-framework": "0.310.0",
|
|
138
|
+
"@cosmicdrift/kumiko-headless": "0.310.0",
|
|
139
|
+
"@cosmicdrift/kumiko-renderer": "0.310.0",
|
|
140
|
+
"@cosmicdrift/kumiko-renderer-web": "0.310.0",
|
|
141
|
+
"@cosmicdrift/kumiko-types": "0.310.0",
|
|
142
142
|
"@mollie/api-client": "^4.5.0",
|
|
143
143
|
"@node-rs/argon2": "^2.0.2",
|
|
144
144
|
"@types/mailparser": "^3.4.6",
|
|
@@ -167,8 +167,8 @@
|
|
|
167
167
|
],
|
|
168
168
|
"devDependencies": {
|
|
169
169
|
"@testing-library/user-event": "^14.6.1",
|
|
170
|
-
"@cosmicdrift/kumiko-locale-de": "0.
|
|
171
|
-
"@cosmicdrift/kumiko-locale-es": "0.
|
|
170
|
+
"@cosmicdrift/kumiko-locale-de": "0.310.0",
|
|
171
|
+
"@cosmicdrift/kumiko-locale-es": "0.310.0",
|
|
172
172
|
"jsqr": "^1.4.0"
|
|
173
173
|
}
|
|
174
174
|
}
|
|
@@ -179,4 +179,35 @@ describe("seedAdminGuarded (offlot#114)", () => {
|
|
|
179
179
|
expect(await userIds()).toHaveLength(2);
|
|
180
180
|
expect((await userIds()).slice().sort()).toEqual([unrelated.id, admin.id].sort());
|
|
181
181
|
});
|
|
182
|
+
|
|
183
|
+
test("a KMS without a blind-index key aborts instead of seeding a blind duplicate", async () => {
|
|
184
|
+
await seedAdminGuarded(stack.db, seedOptions);
|
|
185
|
+
resetBlindIndexKeyForTests();
|
|
186
|
+
|
|
187
|
+
await expect(seedAdminGuarded(stack.db, seedOptions)).rejects.toThrow(/blind-index key/);
|
|
188
|
+
expect(await userIds()).toHaveLength(1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("existing-user branch reconciles emailVerified onto the canonical row", async () => {
|
|
192
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
193
|
+
await blankBlindIndex();
|
|
194
|
+
|
|
195
|
+
const second = await seedAdminGuarded(stack.db, { ...seedOptions, emailVerified: true });
|
|
196
|
+
|
|
197
|
+
expect(second.id).toBe(first.id);
|
|
198
|
+
expect(await userIds()).toEqual([first.id]);
|
|
199
|
+
const rows = await selectMany<{ id: string; emailVerified: boolean }>(stack.db, userTable);
|
|
200
|
+
expect(rows.find((r) => r.id === first.id)?.emailVerified).toBe(true);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("without a configured KMS, seeding runs the plaintext path unguarded", async () => {
|
|
204
|
+
resetPiiSubjectKmsForTests();
|
|
205
|
+
resetBlindIndexKeyForTests();
|
|
206
|
+
|
|
207
|
+
const first = await seedAdminGuarded(stack.db, seedOptions);
|
|
208
|
+
const second = await seedAdminGuarded(stack.db, seedOptions);
|
|
209
|
+
|
|
210
|
+
expect(second.id).toBe(first.id);
|
|
211
|
+
expect(await userIds()).toEqual([first.id]);
|
|
212
|
+
});
|
|
182
213
|
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.309.0",
|
|
4
|
+
"type": "fix",
|
|
5
|
+
"title": "Boot admin seed no longer duplicates the admin when the email blind-index lookup misses (fw#3101)",
|
|
6
|
+
"detail": "runProdApp and runDevApp used to call the unguarded `seedAdmin`, whose idempotency check is a plain email fetchOne that the query layer rewrites into `(email = $plaintext OR email_bidx = $hmac)` once a blind-index key is configured. Under KMS encryption `email` holds per-row ciphertext, so only the bidx arm can ever match — and it misses whenever a row's `email_bidx` is NULL (written before the key existed, or after a subject-key erase), silently inserting a second admin account for the same email. Both boot paths now call `seedAdminGuarded`, which under KMS decrypt-scans the active users for the email and reuses the oldest matching row instead of duplicating it. `seedAdminGuarded` also now aborts with a named error if a PII KMS is configured but no blind-index key is — that combination makes the lookup blind by construction — and reconciles `emailVerified: true` onto the existing row it resolves through the decrypt-scan branch, matching the existing-row reconcile `seedUser` already does on its own idempotency hit."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.307.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
import { fetchOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
16
|
import {
|
|
17
|
+
configuredBlindIndexKey,
|
|
17
18
|
configuredPiiSubjectKms,
|
|
18
19
|
decryptPiiFieldValues,
|
|
19
20
|
type LocalKeyKmsAdapter,
|
|
@@ -28,7 +29,7 @@ import { type SeedTenantHooks, seedTenant, seedTenantMembership } from "../tenan
|
|
|
28
29
|
// kumiko-lint-ignore cross-feature-import signup create-only guard reads the user projection by email
|
|
29
30
|
import { USER_STATUS, userTable } from "../user/schema/user";
|
|
30
31
|
// kumiko-lint-ignore cross-feature-import auth-tests need user+tenant seed-helpers
|
|
31
|
-
import { seedUser } from "../user/seeding";
|
|
32
|
+
import { reconcileSeededUserEmailVerified, seedUser } from "../user/seeding";
|
|
32
33
|
|
|
33
34
|
// Re-export für ergonomische Single-Import-Site in tests/seed-scripts.
|
|
34
35
|
// Das Auth-Feature ist der natürliche Aufrufer für "seed admin user mit
|
|
@@ -257,8 +258,8 @@ export async function seedAdmin(
|
|
|
257
258
|
// seedAdminGuarded is a separate, additive boot-seed guard — NOT a change
|
|
258
259
|
// to seedUser/seedAdmin/provisionSignupAccount, which also run on every
|
|
259
260
|
// real self-signup request and can't afford a decrypt-scan fallback there.
|
|
260
|
-
//
|
|
261
|
-
//
|
|
261
|
+
// It's what runProdApp/runDevApp call for their admin boot-seed; keep it
|
|
262
|
+
// off the login/signup hot path.
|
|
262
263
|
|
|
263
264
|
type ActiveUserRow = {
|
|
264
265
|
readonly id: string;
|
|
@@ -332,10 +333,22 @@ export async function seedAdminGuarded(
|
|
|
332
333
|
const kms = configuredPiiSubjectKms();
|
|
333
334
|
if (kms === undefined) return seedAdmin(db, options);
|
|
334
335
|
|
|
336
|
+
// Without a bidx key every row seeded here gets a NULL email_bidx, which
|
|
337
|
+
// neither seedAdmin's lookup nor the partial unique index can ever see.
|
|
338
|
+
if (configuredBlindIndexKey() === undefined) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
"seedAdminGuarded: a PII KMS is configured but no blind-index key — the email lookup would be blind and the seed would insert a duplicate admin. Configure KUMIKO_BLIND_INDEX_KEY.",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
335
344
|
const existingId = await findExistingUserIdForEmail(db, kms, options.email);
|
|
336
345
|
if (existingId === undefined) return seedAdmin(db, options);
|
|
337
346
|
|
|
338
347
|
const by = options.by ?? TestUsers.systemAdmin;
|
|
348
|
+
if (options.emailVerified === true) {
|
|
349
|
+
const existingRow = await fetchOne(db, userTable, { id: existingId });
|
|
350
|
+
if (existingRow) await reconcileSeededUserEmailVerified(db, existingRow, by);
|
|
351
|
+
}
|
|
339
352
|
for (const m of options.memberships) {
|
|
340
353
|
await seedTenant(db, { id: m.tenantId, key: m.tenantKey, name: m.tenantName, by });
|
|
341
354
|
await seedTenantMembership(db, {
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
// kumiko-framework#3089 — hardDelete deletes a row's file/image/files/images
|
|
2
|
+
// bytes (+ derivatives) and fileRef rows, not just the row itself.
|
|
3
|
+
//
|
|
4
|
+
// Verifies the negative cases where a data-loss/leak bug hides:
|
|
5
|
+
// - fresh rows + other rows' files are NEVER touched
|
|
6
|
+
// - a fileRef SHARED with another row of the same entity survives (bytes +
|
|
7
|
+
// row) even though the row that's being purged references it too
|
|
8
|
+
// - a storage-delete failure fails the row closed (row + fileRef survive,
|
|
9
|
+
// skipped "file_delete_failed") and converges on the next run
|
|
10
|
+
// - no file storage wired at all → row stays, skipped "missing_file_storage"
|
|
11
|
+
// - softDelete/anonymize entities with file fields never touch bytes
|
|
12
|
+
// - the registered cron handler forwards ctx.files, not just direct calls
|
|
13
|
+
|
|
14
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
15
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
16
|
+
import { createEventStoreExecutor, createTenantDb } from "@cosmicdrift/kumiko-framework/db";
|
|
17
|
+
import {
|
|
18
|
+
createEntity,
|
|
19
|
+
createFilesField,
|
|
20
|
+
createImageField,
|
|
21
|
+
createSystemUser,
|
|
22
|
+
createTextField,
|
|
23
|
+
defineFeature,
|
|
24
|
+
type JobContext,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
26
|
+
import {
|
|
27
|
+
createFileContext,
|
|
28
|
+
createInMemoryFileProvider,
|
|
29
|
+
deriveKey,
|
|
30
|
+
type FileStorageProvider,
|
|
31
|
+
fileRefEntity,
|
|
32
|
+
fileRefsTable,
|
|
33
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
34
|
+
import {
|
|
35
|
+
setupTestStack,
|
|
36
|
+
type TestStack,
|
|
37
|
+
unsafeCreateEntityTable,
|
|
38
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
39
|
+
import { bridgeStub } from "@cosmicdrift/kumiko-framework/testing";
|
|
40
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
41
|
+
import { createDataRetentionFeature } from "../feature";
|
|
42
|
+
import { runRetentionCleanup } from "../run-retention-cleanup";
|
|
43
|
+
|
|
44
|
+
// hardDelete entity with BOTH a single (image) and a plural (files) field —
|
|
45
|
+
// covers the column-value path AND the file_refs-lookup path in one entity.
|
|
46
|
+
const docEntity = createEntity({
|
|
47
|
+
table: "read_c8_doc",
|
|
48
|
+
fields: {
|
|
49
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
50
|
+
cover: createImageField(),
|
|
51
|
+
attachments: createFilesField(),
|
|
52
|
+
},
|
|
53
|
+
retention: { keepFor: "30d", strategy: "hardDelete" },
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// softDelete entity with a file field — the strategy's own path (mark
|
|
57
|
+
// is_deleted) must never touch bytes; only hardDelete does.
|
|
58
|
+
const softDocEntity = createEntity({
|
|
59
|
+
table: "read_c8_soft_doc",
|
|
60
|
+
softDelete: true,
|
|
61
|
+
fields: {
|
|
62
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
63
|
+
cover: createImageField(),
|
|
64
|
+
},
|
|
65
|
+
retention: { keepFor: "30d", strategy: "softDelete" },
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// anonymize entity with a file field — same guarantee for the anonymize path.
|
|
69
|
+
const anonDocEntity = createEntity({
|
|
70
|
+
table: "read_c8_anon_doc",
|
|
71
|
+
fields: {
|
|
72
|
+
label: {
|
|
73
|
+
...createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
74
|
+
anonymize: () => "[ANONYMIZED]",
|
|
75
|
+
},
|
|
76
|
+
cover: createImageField(),
|
|
77
|
+
},
|
|
78
|
+
retention: { keepFor: "30d", strategy: "anonymize" },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const c8Feature = defineFeature("c8-retention-file-fixtures", (r) => {
|
|
82
|
+
r.entity("c8-doc", docEntity);
|
|
83
|
+
r.entity("c8-soft-doc", softDocEntity);
|
|
84
|
+
r.entity("c8-anon-doc", anonDocEntity);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const noopLogger: JobContext["log"] = {
|
|
88
|
+
info() {},
|
|
89
|
+
warn() {},
|
|
90
|
+
error() {},
|
|
91
|
+
debug() {},
|
|
92
|
+
child() {
|
|
93
|
+
return noopLogger;
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
const T1 = "33333333-3333-3333-3333-333333333333";
|
|
98
|
+
const T2 = "44444444-4444-4444-4444-444444444444";
|
|
99
|
+
|
|
100
|
+
let stack: TestStack;
|
|
101
|
+
let provider: ReturnType<typeof createInMemoryFileProvider>;
|
|
102
|
+
let now: ReturnType<ReturnType<typeof getTemporal>["Now"]["instant"]>;
|
|
103
|
+
let pastIso: string;
|
|
104
|
+
let withinIso: string;
|
|
105
|
+
let fileRefCrud: ReturnType<typeof createEventStoreExecutor>;
|
|
106
|
+
|
|
107
|
+
beforeAll(async () => {
|
|
108
|
+
provider = createInMemoryFileProvider();
|
|
109
|
+
stack = await setupTestStack({
|
|
110
|
+
features: [createDataRetentionFeature(), c8Feature],
|
|
111
|
+
files: { storageProvider: provider },
|
|
112
|
+
});
|
|
113
|
+
for (const e of [docEntity, softDocEntity, anonDocEntity]) {
|
|
114
|
+
await unsafeCreateEntityTable(stack.db, e);
|
|
115
|
+
}
|
|
116
|
+
fileRefCrud = createEventStoreExecutor(fileRefsTable, fileRefEntity, { entityName: "fileRef" });
|
|
117
|
+
now = getTemporal().Now.instant();
|
|
118
|
+
pastIso = now.subtract({ hours: 60 * 24 }).toString();
|
|
119
|
+
withinIso = now.subtract({ hours: 10 * 24 }).toString();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
afterAll(async () => {
|
|
123
|
+
await stack.cleanup();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
function filesCtx(p: FileStorageProvider = provider) {
|
|
127
|
+
return createFileContext(() => Promise.resolve(p));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function seedDoc(
|
|
131
|
+
table: string,
|
|
132
|
+
tenantId: string,
|
|
133
|
+
label: string,
|
|
134
|
+
insertedAtIso: string,
|
|
135
|
+
): Promise<string> {
|
|
136
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
137
|
+
`INSERT INTO ${table} (tenant_id, label, inserted_at) VALUES ($1, $2, $3::timestamptz) RETURNING id`,
|
|
138
|
+
[tenantId, label, insertedAtIso],
|
|
139
|
+
)) as { id: string | number }[];
|
|
140
|
+
return String(rows[0]?.id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function setCover(table: string, rowId: string, fileRefId: string | null): Promise<void> {
|
|
144
|
+
await asRawClient(stack.db).unsafe(`UPDATE ${table} SET cover = $1 WHERE id = $2`, [
|
|
145
|
+
fileRefId,
|
|
146
|
+
rowId,
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function seedFileRef(args: {
|
|
151
|
+
readonly tenantId: string;
|
|
152
|
+
readonly entityName: string | null;
|
|
153
|
+
readonly entityId: string | null;
|
|
154
|
+
readonly fieldName: string | null;
|
|
155
|
+
readonly key: string;
|
|
156
|
+
}): Promise<string> {
|
|
157
|
+
await provider.write(args.key, new Uint8Array([1, 2, 3]), "application/octet-stream");
|
|
158
|
+
const tdb = createTenantDb(stack.db, args.tenantId, "system");
|
|
159
|
+
const systemUser = createSystemUser(args.tenantId);
|
|
160
|
+
const id = crypto.randomUUID();
|
|
161
|
+
const res = await fileRefCrud.create(
|
|
162
|
+
{
|
|
163
|
+
id,
|
|
164
|
+
storageKey: args.key,
|
|
165
|
+
fileName: "f.bin",
|
|
166
|
+
mimeType: "application/octet-stream",
|
|
167
|
+
size: 3,
|
|
168
|
+
entityType: args.entityName,
|
|
169
|
+
entityId: args.entityId,
|
|
170
|
+
fieldName: args.fieldName,
|
|
171
|
+
},
|
|
172
|
+
systemUser,
|
|
173
|
+
tdb,
|
|
174
|
+
);
|
|
175
|
+
expect(res.isSuccess).toBe(true);
|
|
176
|
+
return id;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function fileRefExists(id: string): Promise<boolean> {
|
|
180
|
+
const rows = await asRawClient(stack.db).unsafe(`SELECT id FROM file_refs WHERE id = $1`, [id]);
|
|
181
|
+
return (rows as unknown[]).length > 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function labels(table: string, tenantId: string): Promise<string[]> {
|
|
185
|
+
const rows = (await asRawClient(stack.db).unsafe(
|
|
186
|
+
`SELECT label FROM ${table} WHERE tenant_id = $1 ORDER BY label`,
|
|
187
|
+
[tenantId],
|
|
188
|
+
)) as { label: string }[];
|
|
189
|
+
return rows.map((r) => r.label);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
beforeEach(async () => {
|
|
193
|
+
for (const t of ["read_c8_doc", "read_c8_soft_doc", "read_c8_anon_doc"]) {
|
|
194
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM ${t}`);
|
|
195
|
+
}
|
|
196
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM file_refs`);
|
|
197
|
+
provider.clear();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe("runRetentionCleanup :: hardDelete purges file bytes + derivatives + fileRefs", () => {
|
|
201
|
+
test("expired row: bytes + derivatives + fileRefs (single + plural field) gone; fresh + other-tenant untouched", async () => {
|
|
202
|
+
const expiredId = await seedDoc("read_c8_doc", T1, "expired", pastIso);
|
|
203
|
+
const coverKey = "t1/expired/cover/orig.jpg";
|
|
204
|
+
const coverId = await seedFileRef({
|
|
205
|
+
tenantId: T1,
|
|
206
|
+
entityName: "c8-doc",
|
|
207
|
+
entityId: expiredId,
|
|
208
|
+
fieldName: "cover",
|
|
209
|
+
key: coverKey,
|
|
210
|
+
});
|
|
211
|
+
await setCover("read_c8_doc", expiredId, coverId);
|
|
212
|
+
// Derivative-suffix grammar is "<name>-<16 hex chars>" (variantSuffix),
|
|
213
|
+
// not a bare variant name — see isDerivativeKeyOf/DERIVATIVE_SUFFIX_PATTERN.
|
|
214
|
+
const thumbKey = deriveKey(coverKey, "thumb-0123456789abcdef");
|
|
215
|
+
await provider.write(thumbKey, new Uint8Array([9]), "image/jpeg");
|
|
216
|
+
|
|
217
|
+
const attKey = "t1/expired/attachments/a.pdf";
|
|
218
|
+
const attId = await seedFileRef({
|
|
219
|
+
tenantId: T1,
|
|
220
|
+
entityName: "c8-doc",
|
|
221
|
+
entityId: expiredId,
|
|
222
|
+
fieldName: "attachments",
|
|
223
|
+
key: attKey,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const freshId = await seedDoc("read_c8_doc", T1, "fresh", withinIso);
|
|
227
|
+
const freshCoverKey = "t1/fresh/cover/orig.jpg";
|
|
228
|
+
const freshCoverId = await seedFileRef({
|
|
229
|
+
tenantId: T1,
|
|
230
|
+
entityName: "c8-doc",
|
|
231
|
+
entityId: freshId,
|
|
232
|
+
fieldName: "cover",
|
|
233
|
+
key: freshCoverKey,
|
|
234
|
+
});
|
|
235
|
+
await setCover("read_c8_doc", freshId, freshCoverId);
|
|
236
|
+
|
|
237
|
+
const otherTenantId = await seedDoc("read_c8_doc", T2, "expired-t2", pastIso);
|
|
238
|
+
const otherTenantKey = "t2/expired/cover/orig.jpg";
|
|
239
|
+
const otherTenantCoverId = await seedFileRef({
|
|
240
|
+
tenantId: T2,
|
|
241
|
+
entityName: "c8-doc",
|
|
242
|
+
entityId: otherTenantId,
|
|
243
|
+
fieldName: "cover",
|
|
244
|
+
key: otherTenantKey,
|
|
245
|
+
});
|
|
246
|
+
await setCover("read_c8_doc", otherTenantId, otherTenantCoverId);
|
|
247
|
+
|
|
248
|
+
const result = await runRetentionCleanup({
|
|
249
|
+
db: stack.db,
|
|
250
|
+
registry: stack.registry,
|
|
251
|
+
tenantId: T1,
|
|
252
|
+
tenantPreset: null,
|
|
253
|
+
now,
|
|
254
|
+
files: filesCtx(),
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.hardDeleted).toBe(1);
|
|
258
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["fresh"]);
|
|
259
|
+
|
|
260
|
+
expect(await provider.exists(coverKey)).toBe(false);
|
|
261
|
+
expect(await provider.exists(thumbKey)).toBe(false);
|
|
262
|
+
expect(await provider.exists(attKey)).toBe(false);
|
|
263
|
+
expect(await fileRefExists(coverId)).toBe(false);
|
|
264
|
+
expect(await fileRefExists(attId)).toBe(false);
|
|
265
|
+
|
|
266
|
+
expect(await provider.exists(freshCoverKey)).toBe(true);
|
|
267
|
+
expect(await fileRefExists(freshCoverId)).toBe(true);
|
|
268
|
+
|
|
269
|
+
expect(await provider.exists(otherTenantKey)).toBe(true);
|
|
270
|
+
expect(await fileRefExists(otherTenantCoverId)).toBe(true);
|
|
271
|
+
expect(await labels("read_c8_doc", T2)).toEqual(["expired-t2"]);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("shared fileRef (same entity, second row) survives even though its owning row is purged", async () => {
|
|
275
|
+
const rowA = await seedDoc("read_c8_doc", T1, "expired-shared", pastIso);
|
|
276
|
+
const rowB = await seedDoc("read_c8_doc", T1, "fresh-shared", withinIso);
|
|
277
|
+
const sharedKey = "t1/shared/cover/orig.jpg";
|
|
278
|
+
const sharedId = await seedFileRef({
|
|
279
|
+
tenantId: T1,
|
|
280
|
+
entityName: "c8-doc",
|
|
281
|
+
entityId: rowA,
|
|
282
|
+
fieldName: "cover",
|
|
283
|
+
key: sharedKey,
|
|
284
|
+
});
|
|
285
|
+
await setCover("read_c8_doc", rowA, sharedId);
|
|
286
|
+
await setCover("read_c8_doc", rowB, sharedId);
|
|
287
|
+
|
|
288
|
+
const result = await runRetentionCleanup({
|
|
289
|
+
db: stack.db,
|
|
290
|
+
registry: stack.registry,
|
|
291
|
+
tenantId: T1,
|
|
292
|
+
tenantPreset: null,
|
|
293
|
+
now,
|
|
294
|
+
files: filesCtx(),
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
expect(result.hardDeleted).toBe(1);
|
|
298
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["fresh-shared"]);
|
|
299
|
+
expect(await provider.exists(sharedKey)).toBe(true);
|
|
300
|
+
expect(await fileRefExists(sharedId)).toBe(true);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("storage-delete failure fails the row closed; skipped + convergence on retry", async () => {
|
|
304
|
+
const rowId = await seedDoc("read_c8_doc", T1, "expired-fails", pastIso);
|
|
305
|
+
const key = "t1/fails/cover/orig.jpg";
|
|
306
|
+
const fileRefId = await seedFileRef({
|
|
307
|
+
tenantId: T1,
|
|
308
|
+
entityName: "c8-doc",
|
|
309
|
+
entityId: rowId,
|
|
310
|
+
fieldName: "cover",
|
|
311
|
+
key,
|
|
312
|
+
});
|
|
313
|
+
await setCover("read_c8_doc", rowId, fileRefId);
|
|
314
|
+
|
|
315
|
+
const failingProvider: FileStorageProvider = {
|
|
316
|
+
...provider,
|
|
317
|
+
delete: async (k: string) => {
|
|
318
|
+
if (k === key) throw new Error("boom");
|
|
319
|
+
return provider.delete(k);
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
const failed = await runRetentionCleanup({
|
|
324
|
+
db: stack.db,
|
|
325
|
+
registry: stack.registry,
|
|
326
|
+
tenantId: T1,
|
|
327
|
+
tenantPreset: null,
|
|
328
|
+
now,
|
|
329
|
+
files: filesCtx(failingProvider),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
expect(failed.hardDeleted).toBe(0);
|
|
333
|
+
expect(failed.skipped).toContainEqual({ entityName: "c8-doc", reason: "file_delete_failed" });
|
|
334
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["expired-fails"]);
|
|
335
|
+
expect(await fileRefExists(fileRefId)).toBe(true);
|
|
336
|
+
expect(await provider.exists(key)).toBe(true);
|
|
337
|
+
|
|
338
|
+
const retried = await runRetentionCleanup({
|
|
339
|
+
db: stack.db,
|
|
340
|
+
registry: stack.registry,
|
|
341
|
+
tenantId: T1,
|
|
342
|
+
tenantPreset: null,
|
|
343
|
+
now,
|
|
344
|
+
files: filesCtx(),
|
|
345
|
+
});
|
|
346
|
+
expect(retried.hardDeleted).toBe(1);
|
|
347
|
+
expect(await labels("read_c8_doc", T1)).toEqual([]);
|
|
348
|
+
expect(await fileRefExists(fileRefId)).toBe(false);
|
|
349
|
+
expect(await provider.exists(key)).toBe(false);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("no file storage wired → row stays, skipped missing_file_storage", async () => {
|
|
353
|
+
const rowId = await seedDoc("read_c8_doc", T1, "expired-no-storage", pastIso);
|
|
354
|
+
const key = "t1/no-storage/cover/orig.jpg";
|
|
355
|
+
const fileRefId = await seedFileRef({
|
|
356
|
+
tenantId: T1,
|
|
357
|
+
entityName: "c8-doc",
|
|
358
|
+
entityId: rowId,
|
|
359
|
+
fieldName: "cover",
|
|
360
|
+
key,
|
|
361
|
+
});
|
|
362
|
+
await setCover("read_c8_doc", rowId, fileRefId);
|
|
363
|
+
|
|
364
|
+
const result = await runRetentionCleanup({
|
|
365
|
+
db: stack.db,
|
|
366
|
+
registry: stack.registry,
|
|
367
|
+
tenantId: T1,
|
|
368
|
+
tenantPreset: null,
|
|
369
|
+
now,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(result.hardDeleted).toBe(0);
|
|
373
|
+
expect(result.skipped).toContainEqual({
|
|
374
|
+
entityName: "c8-doc",
|
|
375
|
+
reason: "missing_file_storage",
|
|
376
|
+
});
|
|
377
|
+
expect(await labels("read_c8_doc", T1)).toEqual(["expired-no-storage"]);
|
|
378
|
+
expect(await fileRefExists(fileRefId)).toBe(true);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
test("softDelete and anonymize entities with a file field never touch bytes", async () => {
|
|
382
|
+
const softRowId = await seedDoc("read_c8_soft_doc", T1, "soft-expired", pastIso);
|
|
383
|
+
const softKey = "t1/soft/cover/orig.jpg";
|
|
384
|
+
const softFileRefId = await seedFileRef({
|
|
385
|
+
tenantId: T1,
|
|
386
|
+
entityName: "c8-soft-doc",
|
|
387
|
+
entityId: softRowId,
|
|
388
|
+
fieldName: "cover",
|
|
389
|
+
key: softKey,
|
|
390
|
+
});
|
|
391
|
+
await setCover("read_c8_soft_doc", softRowId, softFileRefId);
|
|
392
|
+
|
|
393
|
+
const anonRowId = await seedDoc("read_c8_anon_doc", T1, "anon-expired", pastIso);
|
|
394
|
+
const anonKey = "t1/anon/cover/orig.jpg";
|
|
395
|
+
const anonFileRefId = await seedFileRef({
|
|
396
|
+
tenantId: T1,
|
|
397
|
+
entityName: "c8-anon-doc",
|
|
398
|
+
entityId: anonRowId,
|
|
399
|
+
fieldName: "cover",
|
|
400
|
+
key: anonKey,
|
|
401
|
+
});
|
|
402
|
+
await setCover("read_c8_anon_doc", anonRowId, anonFileRefId);
|
|
403
|
+
|
|
404
|
+
const result = await runRetentionCleanup({
|
|
405
|
+
db: stack.db,
|
|
406
|
+
registry: stack.registry,
|
|
407
|
+
tenantId: T1,
|
|
408
|
+
tenantPreset: null,
|
|
409
|
+
now,
|
|
410
|
+
files: filesCtx(),
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
expect(result.softDeleted).toBe(1);
|
|
414
|
+
expect(result.anonymized).toBe(1);
|
|
415
|
+
expect(await provider.exists(softKey)).toBe(true);
|
|
416
|
+
expect(await fileRefExists(softFileRefId)).toBe(true);
|
|
417
|
+
expect(await provider.exists(anonKey)).toBe(true);
|
|
418
|
+
expect(await fileRefExists(anonFileRefId)).toBe(true);
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
test("the registered job handler forwards ctx.files — bytes gone after the cron runs", async () => {
|
|
422
|
+
const rowId = await seedDoc("read_c8_doc", T1, "expired-via-job", pastIso);
|
|
423
|
+
const key = "t1/via-job/cover/orig.jpg";
|
|
424
|
+
const fileRefId = await seedFileRef({
|
|
425
|
+
tenantId: T1,
|
|
426
|
+
entityName: "c8-doc",
|
|
427
|
+
entityId: rowId,
|
|
428
|
+
fieldName: "cover",
|
|
429
|
+
key,
|
|
430
|
+
});
|
|
431
|
+
await setCover("read_c8_doc", rowId, fileRefId);
|
|
432
|
+
|
|
433
|
+
const job = stack.registry.getJob("data-retention:job:retention-cleanup");
|
|
434
|
+
expect(job).toBeDefined();
|
|
435
|
+
if (!job) return;
|
|
436
|
+
|
|
437
|
+
const ctx: JobContext = {
|
|
438
|
+
db: createTenantDb(stack.db, T1, "tenant", undefined, undefined, undefined, {
|
|
439
|
+
unsafeRaw: { reason: "per-tenant fan-out cleanup using raw helpers" },
|
|
440
|
+
}),
|
|
441
|
+
registry: stack.registry,
|
|
442
|
+
systemUser: { id: "system", tenantId: T1, roles: ["all"] },
|
|
443
|
+
log: noopLogger,
|
|
444
|
+
triggeredBy: null,
|
|
445
|
+
files: filesCtx(),
|
|
446
|
+
...bridgeStub(),
|
|
447
|
+
};
|
|
448
|
+
await job.handler({}, ctx);
|
|
449
|
+
|
|
450
|
+
expect(await labels("read_c8_doc", T1)).toEqual([]);
|
|
451
|
+
expect(await fileRefExists(fileRefId)).toBe(false);
|
|
452
|
+
expect(await provider.exists(key)).toBe(false);
|
|
453
|
+
});
|
|
454
|
+
});
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.309.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "data-retention hardDelete now purges file bytes + fileRef rows, not just the entity row (fw#3089)",
|
|
6
|
+
"migration": "hardDelete previously deleted only the entity row and left every file/image/files/images field's storage bytes and `file_refs` row behind — a DSGVO Art. 17 gap. As of this release, an expired row's un-shared fileRefs (same tenant, bound to this row, not referenced by another row of the same entity) have their storage bytes deleted (including derivatives/thumbnails), then their `file_refs` row, then the entity row itself. A fileRef still referenced by another row, or bound to a different entity/record, is left untouched. Without a resolvable file-storage provider in the retention cron's job context, an entity with pending file deletions is skipped entirely (`skipped` reason `missing_file_storage`) rather than silently dropping the row with orphaned bytes; a storage-delete failure skips the row too (`file_delete_failed`) and retries on the next run. Before upgrading, a consumer relying on hardDelete NOT touching file storage should: list entities with `retention.strategy: \"hardDelete\"` and at least one file/image/files/images field, count rows already past `keepFor` for each, and confirm the referenced files are safe to delete (not needed elsewhere) before the next cron run."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.167.1",
|
|
4
10
|
"type": "fix",
|
|
@@ -109,6 +109,7 @@ export function createDataRetentionFeature(): FeatureDefinition {
|
|
|
109
109
|
tenantId,
|
|
110
110
|
tenantPreset,
|
|
111
111
|
now: T.Now.instant(),
|
|
112
|
+
files: ctx.files,
|
|
112
113
|
});
|
|
113
114
|
if (result.skipped.length > 0) {
|
|
114
115
|
// biome-ignore lint/suspicious/noConsole: operator-visibility for skipped entities
|