@cosmicdrift/kumiko-bundled-features 0.187.0 → 0.188.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 -7
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +1 -1
- package/src/custom-fields/__tests__/retention.integration.test.ts +2 -2
- package/src/form-draft/__tests__/cleanup.integration.test.ts +254 -0
- package/src/form-draft/__tests__/discard-file-refs.integration.test.ts +165 -0
- package/src/form-draft/__tests__/form-draft.integration.test.ts +232 -0
- package/src/form-draft/changes.json +1 -0
- package/src/form-draft/constants.ts +31 -0
- package/src/form-draft/db/queries/cleanup.ts +50 -0
- package/src/form-draft/db/queries/owned-file-refs.ts +26 -0
- package/src/form-draft/entity.ts +51 -0
- package/src/form-draft/executor.ts +7 -0
- package/src/form-draft/feature.ts +42 -0
- package/src/form-draft/handlers/cleanup.job.ts +120 -0
- package/src/form-draft/handlers/discard.write.ts +55 -0
- package/src/form-draft/handlers/get.query.ts +26 -0
- package/src/form-draft/handlers/list.query.ts +40 -0
- package/src/form-draft/handlers/save.write.ts +60 -0
- package/src/form-draft/i18n.ts +3 -0
- package/src/form-draft/index.ts +28 -0
- package/src/form-draft/lookup.ts +53 -0
- package/src/form-draft/release-file-refs.ts +69 -0
- package/src/form-draft/schemas.ts +47 -0
- package/src/form-draft-user-data/__tests__/hooks.integration.test.ts +146 -0
- package/src/form-draft-user-data/changes.json +1 -0
- package/src/form-draft-user-data/hooks.ts +49 -0
- package/src/form-draft-user-data/index.ts +21 -0
- package/src/inbound-mail-foundation/__tests__/inbound-mail-foundation.integration.test.ts +1 -1
- package/src/user-data-rights/__tests__/boot-checks.test.ts +14 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.188.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>",
|
|
@@ -45,6 +45,8 @@
|
|
|
45
45
|
"./notes-history": "./src/notes-history/index.ts",
|
|
46
46
|
"./notes-history/web": "./src/notes-history/web/index.ts",
|
|
47
47
|
"./notes-history-user-data": "./src/notes-history-user-data/index.ts",
|
|
48
|
+
"./form-draft": "./src/form-draft/index.ts",
|
|
49
|
+
"./form-draft-user-data": "./src/form-draft-user-data/index.ts",
|
|
48
50
|
"./template-resolver-user-data": "./src/template-resolver-user-data/index.ts",
|
|
49
51
|
"./folders-user-data": "./src/folders-user-data/index.ts",
|
|
50
52
|
"./ledger": "./src/ledger/index.ts",
|
|
@@ -121,12 +123,12 @@
|
|
|
121
123
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
122
124
|
},
|
|
123
125
|
"dependencies": {
|
|
124
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
125
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
126
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
127
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
128
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
129
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
126
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.188.0",
|
|
127
|
+
"@cosmicdrift/kumiko-framework": "0.188.0",
|
|
128
|
+
"@cosmicdrift/kumiko-headless": "0.188.0",
|
|
129
|
+
"@cosmicdrift/kumiko-renderer": "0.188.0",
|
|
130
|
+
"@cosmicdrift/kumiko-renderer-web": "0.188.0",
|
|
131
|
+
"@cosmicdrift/kumiko-types": "0.188.0",
|
|
130
132
|
"@mollie/api-client": "^4.5.0",
|
|
131
133
|
"imapflow": "^1.3.3",
|
|
132
134
|
"mailparser": "^3.9.8",
|
|
@@ -728,7 +728,7 @@ describe("scenario 10: PII is encrypted at rest, not just erasable on destroy",
|
|
|
728
728
|
subscriptionAggregateId(admin.tenantId),
|
|
729
729
|
admin.tenantId,
|
|
730
730
|
);
|
|
731
|
-
const ciphertextBefore = (esEventsBefore[0]
|
|
731
|
+
const ciphertextBefore = (esEventsBefore[0]!.payload as { providerCustomerId?: string })
|
|
732
732
|
.providerCustomerId as string;
|
|
733
733
|
|
|
734
734
|
// This is exactly what tenant-lifecycle's "subject-keys" pipeline stage
|
|
@@ -210,7 +210,7 @@ describe("T1.5d: per-field retention sweep", () => {
|
|
|
210
210
|
});
|
|
211
211
|
|
|
212
212
|
const row = await readRow(propertyId);
|
|
213
|
-
expect((row
|
|
213
|
+
expect((row!["custom_fields"] as Record<string, unknown>)["recent"]).toBe("keep-me");
|
|
214
214
|
});
|
|
215
215
|
|
|
216
216
|
test("field without retention policy: untouched even on ancient rows", async () => {
|
|
@@ -233,7 +233,7 @@ describe("T1.5d: per-field retention sweep", () => {
|
|
|
233
233
|
|
|
234
234
|
expect(report.rowsUpdated).toBe(0);
|
|
235
235
|
const row = await readRow(propertyId);
|
|
236
|
-
expect((row
|
|
236
|
+
expect((row!["custom_fields"] as Record<string, unknown>)["forever"]).toBe("keep-me-always");
|
|
237
237
|
});
|
|
238
238
|
|
|
239
239
|
test("mixed: only expired-with-policy keys are stripped, others stay", async () => {
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
// form-draft cleanup job — real Postgres + real BullMQ dispatch (via
|
|
2
|
+
// stack.jobRunner), no hand-fed context. Covers the #1891 acceptance
|
|
3
|
+
// criteria: an old draft is deleted, a fresh one survives, and the
|
|
4
|
+
// retention window actually comes from the config key (not a hardcoded
|
|
5
|
+
// constant). Also covers #1915: FileRefs in a stale draft's blob are
|
|
6
|
+
// released through the storage provider before the row is deleted, resolved
|
|
7
|
+
// per-row's OWN tenant (not the job's SYSTEM_TENANT_ID run-tenant) — a
|
|
8
|
+
// two-tenant, two-provider setup below proves rows never cross providers.
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
13
|
+
import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
14
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
15
|
+
import {
|
|
16
|
+
createInMemoryFileProvider,
|
|
17
|
+
fileRefEntity,
|
|
18
|
+
type InMemoryFileProvider,
|
|
19
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
20
|
+
import {
|
|
21
|
+
createTestUser,
|
|
22
|
+
setupTestStack,
|
|
23
|
+
type TestStack,
|
|
24
|
+
testTenantId,
|
|
25
|
+
unsafeCreateEntityTable,
|
|
26
|
+
unsafePushTables,
|
|
27
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
28
|
+
import { createTestEnvelopeCipher, waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
29
|
+
import { ConfigHandlers } from "../../config/constants";
|
|
30
|
+
import { createConfigAccessorFactory, createConfigFeature } from "../../config/feature";
|
|
31
|
+
import { createConfigResolver } from "../../config/resolver";
|
|
32
|
+
import { configValuesTable } from "../../config/table";
|
|
33
|
+
import { FormDraftHandlers } from "../constants";
|
|
34
|
+
import { formDraftEntity } from "../entity";
|
|
35
|
+
import { formDraftFeature } from "../feature";
|
|
36
|
+
import { FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY } from "../handlers/cleanup.job";
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
|
|
40
|
+
const systemAdmin = createTestUser({ id: 1, roles: ["SystemAdmin"] });
|
|
41
|
+
const owner = createTestUser({ id: 2, roles: ["TenantMember"] });
|
|
42
|
+
|
|
43
|
+
const TENANT_B: TenantId = testTenantId(2);
|
|
44
|
+
const ownerTenantB = createTestUser({ id: 3, roles: ["TenantMember"], tenantId: TENANT_B });
|
|
45
|
+
|
|
46
|
+
const providerA: InMemoryFileProvider = createInMemoryFileProvider();
|
|
47
|
+
const providerB: InMemoryFileProvider = createInMemoryFileProvider();
|
|
48
|
+
|
|
49
|
+
beforeAll(async () => {
|
|
50
|
+
const testEncryptionKey = randomBytes(32).toString("base64");
|
|
51
|
+
const encryption = createTestEnvelopeCipher(testEncryptionKey);
|
|
52
|
+
const resolver = createConfigResolver({ cipher: encryption });
|
|
53
|
+
|
|
54
|
+
stack = await setupTestStack({
|
|
55
|
+
features: [formDraftFeature, createConfigFeature()],
|
|
56
|
+
jobs: {
|
|
57
|
+
consumerLane: "worker",
|
|
58
|
+
queueNamePrefix: `kumiko-form-draft-cleanup-test-${Date.now()}`,
|
|
59
|
+
},
|
|
60
|
+
extraContext: ({ registry }) => ({
|
|
61
|
+
configResolver: resolver,
|
|
62
|
+
configEncryption: encryption,
|
|
63
|
+
_configAccessorFactory: createConfigAccessorFactory(registry, resolver),
|
|
64
|
+
// Two providers, keyed by tenant — proves the cleanup job resolves
|
|
65
|
+
// each stale row's OWN tenant rather than reusing a single
|
|
66
|
+
// job-level ctx.files (which would be bound to SYSTEM_TENANT_ID, the
|
|
67
|
+
// tenant this system-wide job actually dispatches under).
|
|
68
|
+
_fileProviderResolver: (tenantId: TenantId) =>
|
|
69
|
+
Promise.resolve(tenantId === TENANT_B ? providerB : providerA),
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
await unsafeCreateEntityTable(stack.db, formDraftEntity);
|
|
73
|
+
await unsafeCreateEntityTable(stack.db, fileRefEntity, "fileRef");
|
|
74
|
+
await unsafePushTables(stack.db, { configValuesTable });
|
|
75
|
+
await createEventsTable(stack.db);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterAll(async () => {
|
|
79
|
+
await stack.cleanup();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
beforeEach(async () => {
|
|
83
|
+
providerA.clear();
|
|
84
|
+
providerB.clear();
|
|
85
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
86
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_form_drafts");
|
|
87
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_config_values");
|
|
88
|
+
await asRawClient(stack.db).unsafe("DELETE FROM file_refs");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
async function saveDraft(
|
|
92
|
+
draftKey: string,
|
|
93
|
+
values: Record<string, unknown> = { note: "x" },
|
|
94
|
+
user = owner,
|
|
95
|
+
): Promise<void> {
|
|
96
|
+
await stack.http.writeOk(FormDraftHandlers.save, { draftKey, values, stepIndex: 0 }, user);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function fileRefPointer(storageKey: string) {
|
|
100
|
+
return { id: "file-1", storageKey, fileName: "photo.jpg", mimeType: "image/jpeg", size: 3 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// The real file_refs row a genuine POST /files upload would have created —
|
|
104
|
+
// filterOwnedStorageKeys requires this to exist before a release proceeds.
|
|
105
|
+
async function seedFileRef(storageKey: string, uploader = owner): Promise<void> {
|
|
106
|
+
await asRawClient(stack.db).unsafe(
|
|
107
|
+
`INSERT INTO "file_refs" ("tenant_id", "storage_key", "file_name", "mime_type", "size", "inserted_by_id")
|
|
108
|
+
VALUES ($1, $2, 'photo.jpg', 'image/jpeg', 3, $3)`,
|
|
109
|
+
[uploader.tenantId, storageKey, uploader.id],
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function backdate(draftKey: string, daysAgo: number): Promise<void> {
|
|
114
|
+
await asRawClient(stack.db).unsafe(
|
|
115
|
+
`UPDATE "read_form_drafts"
|
|
116
|
+
SET "inserted_at" = now() - ($1::int * interval '1 day'),
|
|
117
|
+
"modified_at" = now() - ($1::int * interval '1 day')
|
|
118
|
+
WHERE "draft_key" = $2`,
|
|
119
|
+
[daysAgo, draftKey],
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function draftExists(draftKey: string): Promise<boolean> {
|
|
124
|
+
const rows = await asRawClient(stack.db).unsafe(
|
|
125
|
+
"SELECT 1 FROM read_form_drafts WHERE draft_key = $1",
|
|
126
|
+
[draftKey],
|
|
127
|
+
);
|
|
128
|
+
return (rows as unknown[]).length > 0;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function setRetentionDays(days: number): Promise<void> {
|
|
132
|
+
await stack.http.writeOk(
|
|
133
|
+
ConfigHandlers.set,
|
|
134
|
+
{ key: FORM_DRAFT_RETENTION_DAYS_CONFIG_KEY, value: days },
|
|
135
|
+
systemAdmin,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function dispatchCleanup(): Promise<void> {
|
|
140
|
+
if (!stack.jobRunner) throw new Error("jobRunner not wired — check setupTestStack jobs option");
|
|
141
|
+
await stack.jobRunner.dispatch("form-draft:job:cleanup", {});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
describe("form-draft cleanup job", () => {
|
|
145
|
+
test("deletes a draft older than the default retention window, leaves a fresh one alone", async () => {
|
|
146
|
+
await saveDraft("wizard:old");
|
|
147
|
+
await backdate("wizard:old", 31);
|
|
148
|
+
await saveDraft("wizard:fresh");
|
|
149
|
+
await dispatchCleanup();
|
|
150
|
+
|
|
151
|
+
await waitFor(async () => {
|
|
152
|
+
expect(await draftExists("wizard:old")).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
expect(await draftExists("wizard:fresh")).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("honours a retention window set via the config key", async () => {
|
|
158
|
+
await setRetentionDays(2);
|
|
159
|
+
await saveDraft("wizard:stale");
|
|
160
|
+
await backdate("wizard:stale", 3);
|
|
161
|
+
await saveDraft("wizard:recent");
|
|
162
|
+
await backdate("wizard:recent", 1);
|
|
163
|
+
await dispatchCleanup();
|
|
164
|
+
|
|
165
|
+
await waitFor(async () => {
|
|
166
|
+
expect(await draftExists("wizard:stale")).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
expect(await draftExists("wizard:recent")).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("without a configured override, the same 3-day-old draft survives under the 30-day default", async () => {
|
|
172
|
+
await saveDraft("wizard:three-days-old");
|
|
173
|
+
await backdate("wizard:three-days-old", 3);
|
|
174
|
+
await saveDraft("wizard:sentinel");
|
|
175
|
+
await backdate("wizard:sentinel", 31);
|
|
176
|
+
await dispatchCleanup();
|
|
177
|
+
|
|
178
|
+
// Positive completion signal via the sentinel — proves the run finished
|
|
179
|
+
// without relying on a fixed sleep, then asserts the config-boundary case.
|
|
180
|
+
await waitFor(async () => {
|
|
181
|
+
expect(await draftExists("wizard:sentinel")).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
expect(await draftExists("wizard:three-days-old")).toBe(true);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("form-draft cleanup job — FileRef release (#1915)", () => {
|
|
188
|
+
test("releases FileRefs from a stale draft, leaves a FileRef-less stale draft and a fresh draft unaffected", async () => {
|
|
189
|
+
const key = "tenant-a/vehicle/photo.jpg";
|
|
190
|
+
await providerA.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
191
|
+
await seedFileRef(key);
|
|
192
|
+
|
|
193
|
+
await saveDraft("wizard:stale-with-photo", { photo: fileRefPointer(key) });
|
|
194
|
+
await backdate("wizard:stale-with-photo", 31);
|
|
195
|
+
await saveDraft("wizard:stale-no-photo", { note: "plain" });
|
|
196
|
+
await backdate("wizard:stale-no-photo", 31);
|
|
197
|
+
await saveDraft("wizard:fresh", { note: "keep" });
|
|
198
|
+
|
|
199
|
+
await dispatchCleanup();
|
|
200
|
+
|
|
201
|
+
await waitFor(async () => {
|
|
202
|
+
expect(await draftExists("wizard:stale-with-photo")).toBe(false);
|
|
203
|
+
});
|
|
204
|
+
expect(await draftExists("wizard:stale-no-photo")).toBe(false);
|
|
205
|
+
expect(await draftExists("wizard:fresh")).toBe(true);
|
|
206
|
+
expect(providerA.keys()).not.toContain(key);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("resolves each stale row's own tenant provider — never the job's SYSTEM_TENANT_ID run-tenant, never another row's tenant", async () => {
|
|
210
|
+
const keyA = "tenant-a/vehicle/photo-a.jpg";
|
|
211
|
+
const keyB = "tenant-b/vehicle/photo-b.jpg";
|
|
212
|
+
await providerA.write(keyA, new Uint8Array([1]), "image/jpeg");
|
|
213
|
+
await providerB.write(keyB, new Uint8Array([2]), "image/jpeg");
|
|
214
|
+
await seedFileRef(keyA, owner);
|
|
215
|
+
await seedFileRef(keyB, ownerTenantB);
|
|
216
|
+
|
|
217
|
+
await saveDraft("wizard:tenant-a-stale", { photo: fileRefPointer(keyA) }, owner);
|
|
218
|
+
await backdate("wizard:tenant-a-stale", 31);
|
|
219
|
+
await saveDraft("wizard:tenant-b-stale", { photo: fileRefPointer(keyB) }, ownerTenantB);
|
|
220
|
+
await backdate("wizard:tenant-b-stale", 31);
|
|
221
|
+
|
|
222
|
+
await dispatchCleanup();
|
|
223
|
+
|
|
224
|
+
await waitFor(async () => {
|
|
225
|
+
expect(await draftExists("wizard:tenant-a-stale")).toBe(false);
|
|
226
|
+
});
|
|
227
|
+
await waitFor(async () => {
|
|
228
|
+
expect(await draftExists("wizard:tenant-b-stale")).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(providerA.keys()).not.toContain(keyA);
|
|
232
|
+
expect(providerB.keys()).not.toContain(keyB);
|
|
233
|
+
// Cross-tenant guard: neither key ever lands in the other tenant's provider.
|
|
234
|
+
expect(providerA.keys()).not.toContain(keyB);
|
|
235
|
+
expect(providerB.keys()).not.toContain(keyA);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test("does NOT release a storageKey with no owned file_refs row (forged draft value)", async () => {
|
|
239
|
+
const forgedKey = "tenant-a/vehicle/victim.jpg";
|
|
240
|
+
await providerA.write(forgedKey, new Uint8Array([9, 9, 9]), "image/jpeg");
|
|
241
|
+
// Deliberately no seedFileRef — the stale draft's `values` claims a
|
|
242
|
+
// storageKey its owner never actually uploaded.
|
|
243
|
+
|
|
244
|
+
await saveDraft("wizard:stale-forged", { photo: fileRefPointer(forgedKey) });
|
|
245
|
+
await backdate("wizard:stale-forged", 31);
|
|
246
|
+
|
|
247
|
+
await dispatchCleanup();
|
|
248
|
+
|
|
249
|
+
await waitFor(async () => {
|
|
250
|
+
expect(await draftExists("wizard:stale-forged")).toBe(false);
|
|
251
|
+
});
|
|
252
|
+
expect(providerA.keys()).toContain(forgedKey);
|
|
253
|
+
});
|
|
254
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Discard releases FileRefs held in the draft blob (issue #1915), but ONLY
|
|
2
|
+
// when the caller explicitly opts in via releaseFiles: true, AND only for
|
|
3
|
+
// storageKeys backed by a real file_refs row owned by the caller. A photo
|
|
4
|
+
// upload during a wizard-mode form is written to storage immediately — the
|
|
5
|
+
// draft blob only holds the FileRef pointer ({ id, storageKey, ... }, the
|
|
6
|
+
// shape POST /files returns). render-edit.tsx's only two current call sites
|
|
7
|
+
// both discard right after a *successful* entity submit, which already
|
|
8
|
+
// carries the same storageKeys into the domain entity — releasing
|
|
9
|
+
// unconditionally there would destroy files the entity now depends on.
|
|
10
|
+
// releaseFiles is for an actual abandon/abort flow instead (see schemas.ts).
|
|
11
|
+
//
|
|
12
|
+
// The ownership check matters independently of releaseFiles: `values` is
|
|
13
|
+
// free-form, client-supplied JSON — without verifying a real, owned
|
|
14
|
+
// file_refs row exists, a forged { storageKey: "<victim's key>" } would
|
|
15
|
+
// reach the storage provider's delete() unchecked (see
|
|
16
|
+
// db/queries/owned-file-refs.ts).
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
21
|
+
import {
|
|
22
|
+
createInMemoryFileProvider,
|
|
23
|
+
fileRefEntity,
|
|
24
|
+
type InMemoryFileProvider,
|
|
25
|
+
} from "@cosmicdrift/kumiko-framework/files";
|
|
26
|
+
import {
|
|
27
|
+
createTestUser,
|
|
28
|
+
setupTestStack,
|
|
29
|
+
type TestStack,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
32
|
+
import { createConfigFeature } from "../../config";
|
|
33
|
+
import { FormDraftHandlers, FormDraftQueries } from "../constants";
|
|
34
|
+
import { formDraftEntity } from "../entity";
|
|
35
|
+
import { formDraftFeature } from "../feature";
|
|
36
|
+
import type { GetDraftResult } from "../handlers/get.query";
|
|
37
|
+
|
|
38
|
+
let stack: TestStack;
|
|
39
|
+
let provider: InMemoryFileProvider;
|
|
40
|
+
|
|
41
|
+
const owner = createTestUser({ id: 1, roles: ["TenantMember"] });
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
provider = createInMemoryFileProvider();
|
|
45
|
+
stack = await setupTestStack({
|
|
46
|
+
features: [formDraftFeature, createConfigFeature()],
|
|
47
|
+
files: { storageProvider: provider },
|
|
48
|
+
});
|
|
49
|
+
await unsafeCreateEntityTable(stack.db, formDraftEntity);
|
|
50
|
+
await unsafeCreateEntityTable(stack.db, fileRefEntity, "fileRef");
|
|
51
|
+
await createEventsTable(stack.db);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterAll(async () => {
|
|
55
|
+
await stack.cleanup();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
provider.clear();
|
|
60
|
+
await asRawClient(stack.db).unsafe("DELETE FROM kumiko_events");
|
|
61
|
+
await asRawClient(stack.db).unsafe("DELETE FROM read_form_drafts");
|
|
62
|
+
await asRawClient(stack.db).unsafe("DELETE FROM file_refs");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
async function saveDraft(draftKey: string, values: Record<string, unknown>): Promise<void> {
|
|
66
|
+
await stack.http.writeOk(FormDraftHandlers.save, { draftKey, values, stepIndex: 0 }, owner);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function getDraft(draftKey: string): Promise<GetDraftResult> {
|
|
70
|
+
return stack.http.queryOk<GetDraftResult>(FormDraftQueries.get, { draftKey }, owner);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function discardDraft(draftKey: string, releaseFiles?: boolean): Promise<unknown> {
|
|
74
|
+
return stack.http.writeOk(FormDraftHandlers.discard, { draftKey, releaseFiles }, owner);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function fileRefPointer(storageKey: string) {
|
|
78
|
+
return { id: "file-1", storageKey, fileName: "photo.jpg", mimeType: "image/jpeg", size: 3 };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The real file_refs row a genuine POST /files upload would have created —
|
|
82
|
+
// filterOwnedStorageKeys requires this to exist before a release proceeds.
|
|
83
|
+
async function seedFileRef(storageKey: string, ownerUser = owner): Promise<void> {
|
|
84
|
+
await asRawClient(stack.db).unsafe(
|
|
85
|
+
`INSERT INTO "file_refs" ("tenant_id", "storage_key", "file_name", "mime_type", "size", "inserted_by_id")
|
|
86
|
+
VALUES ($1, $2, 'photo.jpg', 'image/jpeg', 3, $3)`,
|
|
87
|
+
[ownerUser.tenantId, storageKey, ownerUser.id],
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
describe("form-draft discard — FileRef release", () => {
|
|
92
|
+
test("discarding with releaseFiles: true and a single-file field releases its storage binary", async () => {
|
|
93
|
+
const key = "tenant/vehicle/photo/one.jpg";
|
|
94
|
+
await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
95
|
+
await seedFileRef(key);
|
|
96
|
+
|
|
97
|
+
await saveDraft("wizard:one-photo", { photo: fileRefPointer(key) });
|
|
98
|
+
expect(provider.keys()).toContain(key);
|
|
99
|
+
|
|
100
|
+
await discardDraft("wizard:one-photo", true);
|
|
101
|
+
|
|
102
|
+
expect(provider.keys()).not.toContain(key);
|
|
103
|
+
expect((await getDraft("wizard:one-photo")).draft).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("discarding with releaseFiles: true and a multi-file field releases every storage binary", async () => {
|
|
107
|
+
const keyA = "tenant/vehicle/photos/a.jpg";
|
|
108
|
+
const keyB = "tenant/vehicle/photos/b.jpg";
|
|
109
|
+
await provider.write(keyA, new Uint8Array([1]), "image/jpeg");
|
|
110
|
+
await provider.write(keyB, new Uint8Array([2]), "image/jpeg");
|
|
111
|
+
await seedFileRef(keyA);
|
|
112
|
+
await seedFileRef(keyB);
|
|
113
|
+
|
|
114
|
+
await saveDraft("wizard:many-photos", {
|
|
115
|
+
photos: [fileRefPointer(keyA), fileRefPointer(keyB)],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
await discardDraft("wizard:many-photos", true);
|
|
119
|
+
|
|
120
|
+
expect(provider.keys()).not.toContain(keyA);
|
|
121
|
+
expect(provider.keys()).not.toContain(keyB);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("discarding without releaseFiles leaves the storage binary intact (successful-submit default)", async () => {
|
|
125
|
+
const key = "tenant/vehicle/photo/kept.jpg";
|
|
126
|
+
await provider.write(key, new Uint8Array([1, 2, 3]), "image/jpeg");
|
|
127
|
+
await seedFileRef(key);
|
|
128
|
+
|
|
129
|
+
await saveDraft("wizard:submitted", { photo: fileRefPointer(key) });
|
|
130
|
+
await discardDraft("wizard:submitted");
|
|
131
|
+
|
|
132
|
+
expect(provider.keys()).toContain(key);
|
|
133
|
+
expect((await getDraft("wizard:submitted")).draft).toBeNull();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("discarding with releaseFiles: true does NOT release a storageKey with no owned file_refs row", async () => {
|
|
137
|
+
const forgedKey = "tenant/vehicle/photo/victim.jpg";
|
|
138
|
+
await provider.write(forgedKey, new Uint8Array([9, 9, 9]), "image/jpeg");
|
|
139
|
+
// Deliberately no seedFileRef(forgedKey) — the draft's `values` claims a
|
|
140
|
+
// storageKey the caller never actually uploaded (e.g. a guessed/leaked
|
|
141
|
+
// key belonging to another user's real upload).
|
|
142
|
+
|
|
143
|
+
await saveDraft("wizard:forged-pointer", { photo: fileRefPointer(forgedKey) });
|
|
144
|
+
await discardDraft("wizard:forged-pointer", true);
|
|
145
|
+
|
|
146
|
+
expect(provider.keys()).toContain(forgedKey);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("discarding a draft without any FileRefs completes without error", async () => {
|
|
150
|
+
await saveDraft("wizard:text-only", { note: "no photos here" });
|
|
151
|
+
|
|
152
|
+
await discardDraft("wizard:text-only", true);
|
|
153
|
+
|
|
154
|
+
expect((await getDraft("wizard:text-only")).draft).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("discarding a draftKey that was never saved is a no-op and touches no storage", async () => {
|
|
158
|
+
const key = "tenant/vehicle/photo/untouched.jpg";
|
|
159
|
+
await provider.write(key, new Uint8Array([1]), "image/jpeg");
|
|
160
|
+
|
|
161
|
+
await expect(discardDraft("wizard:never-saved", true)).resolves.toBeTruthy();
|
|
162
|
+
|
|
163
|
+
expect(provider.keys()).toContain(key);
|
|
164
|
+
});
|
|
165
|
+
});
|