@elytracms/forms 0.0.102 → 0.0.104
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
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@elytracms/forms",
|
|
3
3
|
"author": "finaldream <hi@finaldream.de>",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.104",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.18.0"
|
|
7
7
|
},
|
|
@@ -28,13 +28,13 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"lucide-react": "^1.23.0",
|
|
30
30
|
"zod": "^4.4.3",
|
|
31
|
-
"@elytracms/core": "^0.0.
|
|
31
|
+
"@elytracms/core": "^0.0.104"
|
|
32
32
|
},
|
|
33
33
|
"peerDependencies": {
|
|
34
34
|
"convex": "^1.42.1",
|
|
35
35
|
"react": "^19.0.0",
|
|
36
|
-
"@elytracms/next": "^0.0.
|
|
37
|
-
"@elytracms/studio": "^0.0.
|
|
36
|
+
"@elytracms/next": "^0.0.104",
|
|
37
|
+
"@elytracms/studio": "^0.0.104"
|
|
38
38
|
},
|
|
39
39
|
"peerDependenciesMeta": {
|
|
40
40
|
"@elytracms/next": {
|
|
@@ -59,8 +59,8 @@
|
|
|
59
59
|
"react": "^19.2.7",
|
|
60
60
|
"react-dom": "^19.2.7",
|
|
61
61
|
"typescript": "^6.0.3",
|
|
62
|
-
"@elytracms/next": "^0.0.
|
|
63
|
-
"@elytracms/studio": "^0.0.
|
|
62
|
+
"@elytracms/next": "^0.0.104",
|
|
63
|
+
"@elytracms/studio": "^0.0.104"
|
|
64
64
|
},
|
|
65
65
|
"files": [
|
|
66
66
|
"dist",
|
|
@@ -145,7 +145,7 @@ async function leftovers(t: Backend) {
|
|
|
145
145
|
describe('pnpm dev:ingest — every scenario against the real engine (convex-test)', () => {
|
|
146
146
|
it('each scenario’s expectations hold; nothing printed holds the secret; no runner row is left', async () => {
|
|
147
147
|
const core = await runner()
|
|
148
|
-
expect(core.INGEST_SCENARIOS.map((scenario) => scenario.name)).toEqual(['ec671', 'ec685-causal', 'ec685-release', 'storage-sha256', 'ec618-burst', 'ec695'])
|
|
148
|
+
expect(core.INGEST_SCENARIOS.map((scenario) => scenario.name)).toEqual(['ec671', 'ec685-causal', 'ec685-release', 'storage-sha256', 'ec618-burst', 'ec695', 'ec750'])
|
|
149
149
|
for (const scenario of core.INGEST_SCENARIOS) {
|
|
150
150
|
const t = await backend()
|
|
151
151
|
const lines: string[] = []
|
|
@@ -195,6 +195,28 @@ describe('pnpm dev:ingest — every scenario against the real engine (convex-tes
|
|
|
195
195
|
expect(await t.run(async (ctx) => (await ctx.db.system.query('_storage').collect()).length)).toBe(0)
|
|
196
196
|
})
|
|
197
197
|
|
|
198
|
+
it('the EC-750 scenario: 12/12 — a sibling forged by forms:save with B’s files is removed, B keeps its bytes and binding (the close’s expected lines)', async () => {
|
|
199
|
+
const core = await runner()
|
|
200
|
+
const t = await backend()
|
|
201
|
+
const lines: string[] = []
|
|
202
|
+
const scenario = core.INGEST_SCENARIOS.find((entry) => entry.name === 'ec750')!
|
|
203
|
+
const result = await core.runIngestScenario(scenario, port(core, t), { say: (line) => lines.push(line), wire, runId: 'r', now: () => T0 })
|
|
204
|
+
expect(result).toEqual({ ok: true, passed: 12, failed: [] })
|
|
205
|
+
expect(lines).toEqual(
|
|
206
|
+
expect.arrayContaining([
|
|
207
|
+
' ✓ B’s binding records B as its owner: true',
|
|
208
|
+
' ✓ …and B’s kind: true', // EC-762: the owner is the record's kind AND id
|
|
209
|
+
' ✓ A, created by forms:save, lists B’s file: true',
|
|
210
|
+
' ✓ removing A keeps B’s bytes: true',
|
|
211
|
+
' ✓ …and B’s binding: true',
|
|
212
|
+
' ✓ removing B deletes B’s file: false',
|
|
213
|
+
'✓ ec750: 12/12 expectation(s) held',
|
|
214
|
+
]),
|
|
215
|
+
)
|
|
216
|
+
expect(await leftovers(t)).toEqual({ submissions: 0, bindings: 0, assets: 0 })
|
|
217
|
+
expect(await t.run(async (ctx) => (await ctx.db.system.query('_storage').collect()).length)).toBe(0)
|
|
218
|
+
})
|
|
219
|
+
|
|
198
220
|
it('negative control: signed under ANOTHER secret, every scenario fails visibly (the receiver refuses it)', async () => {
|
|
199
221
|
const core = await runner()
|
|
200
222
|
for (const scenario of core.INGEST_SCENARIOS) {
|
package/src/convex/index.ts
CHANGED
|
@@ -65,8 +65,9 @@ import type { FormDeliveryRequest } from '../shared/delivery'
|
|
|
65
65
|
* bound, so no claimed bytes ever sit without a row and a refusal deletes nothing (a
|
|
66
66
|
* predictable file another intent stored can never be deleted through a rollback). The
|
|
67
67
|
* one deletion, `remove`, goes through the engine's `releaseIngestUploads`, which deletes
|
|
68
|
-
* only bytes a claim bound to
|
|
69
|
-
* NAMES is
|
|
68
|
+
* only bytes a claim bound (EC-671) to THAT row (EC-750 — every binding records the row
|
|
69
|
+
* that claimed it, by its kind and id, EC-762): an id a visitor or an editor merely NAMES is
|
|
70
|
+
* never deleted, even another row's claimed file;
|
|
70
71
|
* - **`deliver`** — the scheduled action per row: it asks the site's delivery route to send
|
|
71
72
|
* (`attemptFormDelivery`) and `recordDelivery` logs the answer per recipient and schedules
|
|
72
73
|
* the next attempt (`applyDeliveryOutcome`);
|
|
@@ -137,10 +138,12 @@ export const save = mutation({
|
|
|
137
138
|
})
|
|
138
139
|
|
|
139
140
|
/**
|
|
140
|
-
* Delete a submission AND the files it claimed (no claimed bytes outlive their row). EC-671
|
|
141
|
-
* through `releaseIngestUploads`,
|
|
142
|
-
*
|
|
143
|
-
*
|
|
141
|
+
* Delete a submission AND the files it claimed (no claimed bytes outlive their row). EC-671 /
|
|
142
|
+
* EC-750: through `releaseIngestUploads`, asked for THIS row — the submission kind and its id
|
|
143
|
+
* (EC-762) — it deletes only the files this row's claim bound, read by owner, never the row's
|
|
144
|
+
* `files` column (an editor's write can put any id there, another row's file included, or clear
|
|
145
|
+
* it) — and, EC-685, keeps a claimed file an editor has since made an asset of (the asset owns
|
|
146
|
+
* it now).
|
|
144
147
|
*/
|
|
145
148
|
export const remove = mutation({
|
|
146
149
|
args: { [kind.idKey]: v.string() },
|
|
@@ -148,13 +151,11 @@ export const remove = mutation({
|
|
|
148
151
|
await requireCapability(ctx, kind.access.write)
|
|
149
152
|
const doc = await findRecordDoc(ctx, kind, args[kind.idKey] as string)
|
|
150
153
|
if (!doc) return
|
|
151
|
-
await releaseIngestUploads(ctx,
|
|
154
|
+
await releaseIngestUploads(ctx, kind, doc.recordId)
|
|
152
155
|
await ctx.db.delete(doc._id)
|
|
153
156
|
},
|
|
154
157
|
})
|
|
155
158
|
|
|
156
|
-
const storageIds = (files: readonly { storageId: string }[]) => files.map((file) => file.storageId)
|
|
157
|
-
|
|
158
159
|
// --- The receiver's handler ------------------------------------------------------------------
|
|
159
160
|
|
|
160
161
|
let mintSubmissionUuid: (() => string) | undefined
|
|
@@ -178,10 +179,15 @@ export const ingest = internalMutation({
|
|
|
178
179
|
const read = readSubmissionPayload(body, { hasFiles: (body.uploads?.length ?? 0) > 0 })
|
|
179
180
|
if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
|
|
180
181
|
|
|
182
|
+
// EC-578: the one id factory (UUID v4) — never a clock or a counter. EC-750: minted BEFORE
|
|
183
|
+
// the claim, which binds every file to this row — its kind and this id, EC-762 (a refused
|
|
184
|
+
// claim leaves the id unused; a retried request re-mints).
|
|
185
|
+
mintSubmissionUuid ??= createUuidIdFactory()
|
|
186
|
+
const id = `${kind.idPrefix}_${mintSubmissionUuid()}`
|
|
181
187
|
// EC-695: every upload claimed in ONE engine call, after every check above that can refuse —
|
|
182
188
|
// all bound, or none (a refused claim refuses the submission, and nothing was bound, so
|
|
183
189
|
// nothing is released: this handler never deletes bytes). Nothing after it refuses.
|
|
184
|
-
const claim = await claimIngestUploads(ctx, body.uploads ?? [], body.target, receivedAt)
|
|
190
|
+
const claim = await claimIngestUploads(ctx, body.uploads ?? [], body.target, receivedAt, kind, id)
|
|
185
191
|
if (!claim.ok) return claim
|
|
186
192
|
const files: SubmissionFile[] = []
|
|
187
193
|
const namesUsed = new Map<string, number>()
|
|
@@ -200,9 +206,6 @@ export const ingest = internalMutation({
|
|
|
200
206
|
})
|
|
201
207
|
}
|
|
202
208
|
|
|
203
|
-
// EC-578: the one id factory (UUID v4) — never a clock or a counter.
|
|
204
|
-
mintSubmissionUuid ??= createUuidIdFactory()
|
|
205
|
-
const id = `${kind.idPrefix}_${mintSubmissionUuid()}`
|
|
206
209
|
const spam = read.columns.spam !== 'clean'
|
|
207
210
|
const deliveryState: DeliveryState = spam ? 'skipped' : 'pending'
|
|
208
211
|
await saveRecord(
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import type { ConvexHttpClient } from 'convex/browser'
|
|
4
|
+
import { makeFunctionReference } from 'convex/server'
|
|
5
|
+
import type { FunctionReference } from 'convex/server'
|
|
6
|
+
import { INGEST_RECEIVER_PATH, ingestUploadSha256, signedIngestRequest } from '@elytracms/core/ingest'
|
|
7
|
+
import type { IngestReceiverBody } from '@elytracms/core/ingest'
|
|
8
|
+
import schema from '../../../studio/convex/schema'
|
|
9
|
+
import * as assetsModule from '../../../studio/convex/assets'
|
|
10
|
+
import { defineStudioHttp } from '../../../studio/convex/http'
|
|
11
|
+
import { ConvexPersistenceAdapter } from '../../../studio/src/lib/persistence/convex-adapter'
|
|
12
|
+
import * as formsModule from './index'
|
|
13
|
+
import { submissionRecordKind } from '../shared/kind'
|
|
14
|
+
import type { SubmissionFile, SubmissionRecord } from '../shared/kind'
|
|
15
|
+
import { FORMS_DELIVERY_URL_ENV } from '../shared/delivery'
|
|
16
|
+
import { FORMS_HONEYPOT_KEY } from '../shared/submission'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* EC-750 (Codex round 9 F46) — the first EXECUTABLE probe of F46's static trace: removing one
|
|
20
|
+
* submission deleted ANOTHER submission's claimed file, because a binding recorded that a file
|
|
21
|
+
* was claimed, not which submission owned it, and release trusted the removed row's mutable
|
|
22
|
+
* `files` ids.
|
|
23
|
+
*
|
|
24
|
+
* 1. Submission B claims file f (a genuine upload: an ingest binding exists, no asset owns f).
|
|
25
|
+
* 2. A content writer creates submission A through the public `forms:save`, carrying B's `files`.
|
|
26
|
+
* 3. Removing A must delete only what A claimed — nothing — and keep B's bytes AND B's binding
|
|
27
|
+
* (the binding is also what keeps `assets:deleteBlob` off the bytes).
|
|
28
|
+
*
|
|
29
|
+
* Run only inside convex-test, generated fixture ids, an unreachable `.invalid` delivery host;
|
|
30
|
+
* honeypot filled, so nothing is ever sent.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const SECRET = 'ec750-ingest-secret'
|
|
34
|
+
const PROJECT_ID = 'forms-test'
|
|
35
|
+
const ADMIN = 'user_admin'
|
|
36
|
+
const T0 = '2026-11-03T12:00:00.000Z'
|
|
37
|
+
|
|
38
|
+
function env(): Record<string, string | undefined> {
|
|
39
|
+
return (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.useFakeTimers()
|
|
44
|
+
vi.setSystemTime(new Date(T0))
|
|
45
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
46
|
+
env()[FORMS_DELIVERY_URL_ENV] = 'https://site.invalid/api/forms'
|
|
47
|
+
})
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.useRealTimers()
|
|
50
|
+
delete env()['ELYTRA_INGEST_SECRET']
|
|
51
|
+
delete env()[FORMS_DELIVERY_URL_ENV]
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const silentAuth = { addHttpRoutes: () => {} }
|
|
55
|
+
const modules = {
|
|
56
|
+
'/convex/_generated/api.ts': async () => ({}),
|
|
57
|
+
'/convex/assets.ts': async () => assetsModule,
|
|
58
|
+
'/convex/forms.ts': async () => formsModule,
|
|
59
|
+
'/convex/http.ts': async () => ({
|
|
60
|
+
default: defineStudioHttp({ auth: silentAuth, ingest: { submission: formsModule.formsIngest } }),
|
|
61
|
+
}),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function backend() {
|
|
65
|
+
const t = convexTest(schema, modules)
|
|
66
|
+
await t.run(async (ctx) => {
|
|
67
|
+
await ctx.db.insert('members', { userId: ADMIN, role: 'admin', createdAt: T0, updatedAt: T0 })
|
|
68
|
+
})
|
|
69
|
+
return t
|
|
70
|
+
}
|
|
71
|
+
type Backend = Awaited<ReturnType<typeof backend>>
|
|
72
|
+
|
|
73
|
+
const asAdmin = (t: Backend) => t.withIdentity({ subject: ADMIN, issuer: 'https://convex.test' })
|
|
74
|
+
|
|
75
|
+
async function post(t: Backend, body: IngestReceiverBody) {
|
|
76
|
+
const signed = await signedIngestRequest(body, SECRET)
|
|
77
|
+
return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Bytes as an issued upload URL stores them (convex-test records size only; the real upload records the type). */
|
|
81
|
+
async function storeFile(t: Backend, bytes: number, contentType: string): Promise<string> {
|
|
82
|
+
return t.run(async (ctx) => {
|
|
83
|
+
const id = await ctx.storage.store(new Blob([new Uint8Array(bytes)], { type: contentType }))
|
|
84
|
+
await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
|
|
85
|
+
return id
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A genuine submission with one upload of its own: intent, bytes, claim. Returns the row's id and its file. */
|
|
90
|
+
async function genuineSubmission(t: Backend, bytes = 800): Promise<{ id: string; file: string }> {
|
|
91
|
+
const digest = await ingestUploadSha256(new Uint8Array(bytes))
|
|
92
|
+
if (!digest.ok) throw new Error(digest.message)
|
|
93
|
+
const intent = await post(t, { kind: 'upload', target: 'contact', field: 'cv', contentType: 'application/pdf', size: bytes, sha256: digest.sha256 })
|
|
94
|
+
expect(intent.status).toBe(200)
|
|
95
|
+
const { uploadId } = (await intent.json()) as { uploadId: string }
|
|
96
|
+
const file = await storeFile(t, bytes, 'application/pdf')
|
|
97
|
+
const answer = await post(t, {
|
|
98
|
+
kind: 'submission',
|
|
99
|
+
target: 'contact',
|
|
100
|
+
data: { email: 'bea@example.test', cv: 'cv.pdf', [FORMS_HONEYPOT_KEY]: 'filled' },
|
|
101
|
+
uploads: [{ uploadId, storageId: file }],
|
|
102
|
+
})
|
|
103
|
+
expect(answer.status).toBe(200)
|
|
104
|
+
const { id } = (await answer.json()) as { id: string }
|
|
105
|
+
return { id, file }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The editor's client — the record store's own adapter over `forms:save` / `forms:remove`. */
|
|
109
|
+
function editor(t: Backend) {
|
|
110
|
+
const client = {
|
|
111
|
+
query: (ref: FunctionReference<'query'>, args?: Record<string, unknown>) => asAdmin(t).query(ref, args ?? {}),
|
|
112
|
+
mutation: (ref: FunctionReference<'mutation'>, args?: Record<string, unknown>) => asAdmin(t).mutation(ref, args ?? {}),
|
|
113
|
+
} as unknown as ConvexHttpClient
|
|
114
|
+
return new ConvexPersistenceAdapter(client, PROJECT_ID).records(submissionRecordKind)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function row(t: Backend, id: string): Promise<SubmissionRecord | undefined> {
|
|
118
|
+
return (await editor(t).find(PROJECT_ID, id)) as SubmissionRecord | undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** A content writer's whole-record create through `forms:save`, carrying `files` it did not claim. */
|
|
122
|
+
async function forgeSibling(t: Backend, id: string, files: readonly SubmissionFile[]) {
|
|
123
|
+
await editor(t).save(PROJECT_ID, {
|
|
124
|
+
id,
|
|
125
|
+
form: 'contact',
|
|
126
|
+
payload: [],
|
|
127
|
+
files: [...files],
|
|
128
|
+
status: 'new',
|
|
129
|
+
spam: 'clean',
|
|
130
|
+
deliveryState: 'skipped',
|
|
131
|
+
submittedAt: T0,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const survives = (t: Backend, storageId: string) => t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
|
|
136
|
+
const bindings = (t: Backend) =>
|
|
137
|
+
t.run(async (ctx) =>
|
|
138
|
+
(await ctx.db.query('records').collect())
|
|
139
|
+
.filter((record) => record.kind === 'ingestBinding')
|
|
140
|
+
.map((record) => {
|
|
141
|
+
const data = record.data as { ownerKind?: string; owner?: string }
|
|
142
|
+
return { storageId: record.recordId, ownerKind: data.ownerKind, owner: data.owner }
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
|
|
146
|
+
|
|
147
|
+
/** An asset an editor makes over stored bytes (`assets:upsert`, the real write path). */
|
|
148
|
+
async function promote(t: Backend, assetId: string, storageKey: string) {
|
|
149
|
+
await asAdmin(t).mutation(makeFunctionReference<'mutation'>('assets:upsert'), {
|
|
150
|
+
assetId,
|
|
151
|
+
filename: `${assetId}.pdf`,
|
|
152
|
+
contentType: 'application/pdf',
|
|
153
|
+
byteSize: 800,
|
|
154
|
+
storageKey,
|
|
155
|
+
createdAt: T0,
|
|
156
|
+
updatedAt: T0,
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe('EC-750 probe — removing a submission deletes only the files THAT submission claimed', () => {
|
|
161
|
+
it('a claim binds each file to the submission that claimed it (the owner — its kind and id — not the form)', async () => {
|
|
162
|
+
const t = await backend()
|
|
163
|
+
const b = await genuineSubmission(t)
|
|
164
|
+
// EC-762: the owner is the record's kind AND id (a record is both).
|
|
165
|
+
expect(await bindings(t)).toEqual([{ storageId: b.file, ownerKind: 'submission', owner: b.id }]) // base: owner undefined (the form key only)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('Codex’s trace: A is created by forms:save carrying B’s files; removing A keeps B’s bytes and B’s binding', async () => {
|
|
169
|
+
const t = await backend()
|
|
170
|
+
const b = await genuineSubmission(t)
|
|
171
|
+
const bFiles = (await row(t, b.id))!.files!
|
|
172
|
+
expect(bFiles.map((file) => file.storageId)).toEqual([b.file])
|
|
173
|
+
await forgeSibling(t, 'submission_forged', bFiles)
|
|
174
|
+
await editor(t).remove(PROJECT_ID, 'submission_forged')
|
|
175
|
+
expect(await row(t, 'submission_forged')).toBeUndefined()
|
|
176
|
+
// Measured on the base (0b7bb049), counterexample form: both lines below read false / [].
|
|
177
|
+
expect(await survives(t, b.file)).toBe(true) // base: false — A's removal deleted B's bytes
|
|
178
|
+
expect(await bindings(t)).toEqual([{ storageId: b.file, ownerKind: 'submission', owner: b.id }]) // base: [] — B's binding stripped
|
|
179
|
+
// B still holds its file: its row is intact, and its own removal deletes it (below).
|
|
180
|
+
expect((await row(t, b.id))!.files!.map((file) => file.storageId)).toEqual([b.file])
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
it('control: removing B itself deletes B’s file and its binding (the instrument sees a deletion)', async () => {
|
|
184
|
+
const t = await backend()
|
|
185
|
+
const b = await genuineSubmission(t)
|
|
186
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
187
|
+
expect(await survives(t, b.file)).toBe(false)
|
|
188
|
+
expect(await bindings(t)).toEqual([])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('…and after the forged A is removed, removing B still deletes its file (no residue)', async () => {
|
|
192
|
+
const t = await backend()
|
|
193
|
+
const b = await genuineSubmission(t)
|
|
194
|
+
await forgeSibling(t, 'submission_forged', (await row(t, b.id))!.files!)
|
|
195
|
+
await editor(t).remove(PROJECT_ID, 'submission_forged')
|
|
196
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
197
|
+
expect(await survives(t, b.file)).toBe(false)
|
|
198
|
+
expect(await bindings(t)).toEqual([])
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
it('release reads the OWNER, never the row’s files: B with `files: null` written by an editor still deletes its file on removal', async () => {
|
|
202
|
+
const t = await backend()
|
|
203
|
+
const b = await genuineSubmission(t)
|
|
204
|
+
await editor(t).save(PROJECT_ID, { id: b.id, files: null })
|
|
205
|
+
expect((await row(t, b.id))!.files).toBeUndefined()
|
|
206
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
207
|
+
expect(await survives(t, b.file)).toBe(false) // base: true — release got [] and B's bytes stayed forever
|
|
208
|
+
expect(await bindings(t)).toEqual([])
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('a LEGACY binding (bound before bindings recorded their owner) is never deleted — residue, never loss', async () => {
|
|
212
|
+
const t = await backend()
|
|
213
|
+
const legacyFile = await storeFile(t, 300, 'application/pdf')
|
|
214
|
+
// The binding a claim wrote before EC-750: no owner column, no key slot.
|
|
215
|
+
await t.run(async (ctx) => {
|
|
216
|
+
await ctx.db.insert('records', {
|
|
217
|
+
kind: 'ingestBinding',
|
|
218
|
+
recordId: legacyFile,
|
|
219
|
+
data: { uploadId: 'upload_legacy', target: 'contact', boundAt: T0 },
|
|
220
|
+
at: T0,
|
|
221
|
+
createdAt: T0,
|
|
222
|
+
updatedAt: T0,
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
await forgeSibling(t, 'submission_legacy', [{ field: 'cv', storageId: legacyFile, contentType: 'application/pdf', size: 300 }])
|
|
226
|
+
await editor(t).remove(PROJECT_ID, 'submission_legacy')
|
|
227
|
+
expect(await survives(t, legacyFile)).toBe(true) // base: false — any bound id the row named was deleted
|
|
228
|
+
expect(await bindings(t)).toEqual([{ storageId: legacyFile, ownerKind: undefined, owner: undefined }])
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('EC-762: a KIND-LESS binding (its owner’s id alone, as EC-750 wrote it) is never deleted by its submission’s removal — residue, never loss', async () => {
|
|
232
|
+
const t = await backend()
|
|
233
|
+
const kindlessFile = await storeFile(t, 300, 'application/pdf')
|
|
234
|
+
const id = 'submission_kindless'
|
|
235
|
+
// The binding a claim wrote between EC-750 and EC-762: the owner's id in `key1`, no kind, no `key2`.
|
|
236
|
+
await t.run(async (ctx) => {
|
|
237
|
+
await ctx.db.insert('records', {
|
|
238
|
+
kind: 'ingestBinding',
|
|
239
|
+
recordId: kindlessFile,
|
|
240
|
+
data: { uploadId: 'upload_kindless', target: 'contact', owner: id, boundAt: T0 },
|
|
241
|
+
key1: id,
|
|
242
|
+
at: T0,
|
|
243
|
+
createdAt: T0,
|
|
244
|
+
updatedAt: T0,
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
await forgeSibling(t, id, [{ field: 'cv', storageId: kindlessFile, contentType: 'application/pdf', size: 300 }])
|
|
248
|
+
await editor(t).remove(PROJECT_ID, id)
|
|
249
|
+
expect(await row(t, id)).toBeUndefined()
|
|
250
|
+
// No owner-only fallback (it would re-open F51): the bytes stay, and the binding keeps holding them.
|
|
251
|
+
expect(await survives(t, kindlessFile)).toBe(true)
|
|
252
|
+
expect(await bindings(t)).toEqual([{ storageId: kindlessFile, ownerKind: undefined, owner: id }])
|
|
253
|
+
await promote(t, 'kindless-asset', kindlessFile)
|
|
254
|
+
expect(await asAdmin(t).mutation(deleteBlob, { storageKey: kindlessFile })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
255
|
+
expect(await survives(t, kindlessFile)).toBe(true)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('assets:deleteBlob still keeps bytes a binding holds — after the forged A’s removal, B’s binding still protects f', async () => {
|
|
259
|
+
const t = await backend()
|
|
260
|
+
const b = await genuineSubmission(t)
|
|
261
|
+
await forgeSibling(t, 'submission_forged', (await row(t, b.id))!.files!)
|
|
262
|
+
await editor(t).remove(PROJECT_ID, 'submission_forged')
|
|
263
|
+
// An editor then makes an asset over f and deletes that asset's bytes: B's binding keeps them.
|
|
264
|
+
await promote(t, 'cv-asset', b.file)
|
|
265
|
+
expect(await asAdmin(t).mutation(deleteBlob, { storageKey: b.file })).toEqual({ deleted: false, heldBy: 'ingest' }) // base: the bytes were already gone
|
|
266
|
+
expect(await survives(t, b.file)).toBe(true)
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('control: release keeps an owner’s file an asset references now (EC-685) — the binding is spent, the bytes stay', async () => {
|
|
270
|
+
const t = await backend()
|
|
271
|
+
const b = await genuineSubmission(t)
|
|
272
|
+
await promote(t, 'cv-asset', b.file)
|
|
273
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
274
|
+
expect(await survives(t, b.file)).toBe(true)
|
|
275
|
+
expect(await bindings(t)).toEqual([])
|
|
276
|
+
expect(await asAdmin(t).mutation(deleteBlob, { storageKey: b.file })).toEqual({ deleted: true })
|
|
277
|
+
})
|
|
278
|
+
})
|