@elytracms/forms 0.0.102 → 0.0.103
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.103",
|
|
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.103"
|
|
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.103",
|
|
37
|
+
"@elytracms/studio": "^0.0.103"
|
|
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.103",
|
|
63
|
+
"@elytracms/studio": "^0.0.103"
|
|
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,27 @@ 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: 11/11 — 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: 11, failed: [] })
|
|
205
|
+
expect(lines).toEqual(
|
|
206
|
+
expect.arrayContaining([
|
|
207
|
+
' ✓ B’s binding records B as its owner: true',
|
|
208
|
+
' ✓ A, created by forms:save, lists B’s file: true',
|
|
209
|
+
' ✓ removing A keeps B’s bytes: true',
|
|
210
|
+
' ✓ …and B’s binding: true',
|
|
211
|
+
' ✓ removing B deletes B’s file: false',
|
|
212
|
+
'✓ ec750: 11/11 expectation(s) held',
|
|
213
|
+
]),
|
|
214
|
+
)
|
|
215
|
+
expect(await leftovers(t)).toEqual({ submissions: 0, bindings: 0, assets: 0 })
|
|
216
|
+
expect(await t.run(async (ctx) => (await ctx.db.system.query('_storage').collect()).length)).toBe(0)
|
|
217
|
+
})
|
|
218
|
+
|
|
198
219
|
it('negative control: signed under ANOTHER secret, every scenario fails visibly (the receiver refuses it)', async () => {
|
|
199
220
|
const core = await runner()
|
|
200
221
|
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 never deleted
|
|
68
|
+
* only bytes a claim bound (EC-671) to THAT row (EC-750 — every binding records the row
|
|
69
|
+
* that claimed it): an id a visitor or an editor merely NAMES is never deleted, even
|
|
70
|
+
* 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,11 @@ 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's id — it deletes only the files
|
|
143
|
+
* this row's claim bound, read by owner, never the row's `files` column (an editor's write can
|
|
144
|
+
* put any id there, another row's file included, or clear it) — and, EC-685, keeps a claimed
|
|
145
|
+
* file an editor has since made an asset of (the asset owns it now).
|
|
144
146
|
*/
|
|
145
147
|
export const remove = mutation({
|
|
146
148
|
args: { [kind.idKey]: v.string() },
|
|
@@ -148,13 +150,11 @@ export const remove = mutation({
|
|
|
148
150
|
await requireCapability(ctx, kind.access.write)
|
|
149
151
|
const doc = await findRecordDoc(ctx, kind, args[kind.idKey] as string)
|
|
150
152
|
if (!doc) return
|
|
151
|
-
await releaseIngestUploads(ctx,
|
|
153
|
+
await releaseIngestUploads(ctx, doc.recordId)
|
|
152
154
|
await ctx.db.delete(doc._id)
|
|
153
155
|
},
|
|
154
156
|
})
|
|
155
157
|
|
|
156
|
-
const storageIds = (files: readonly { storageId: string }[]) => files.map((file) => file.storageId)
|
|
157
|
-
|
|
158
158
|
// --- The receiver's handler ------------------------------------------------------------------
|
|
159
159
|
|
|
160
160
|
let mintSubmissionUuid: (() => string) | undefined
|
|
@@ -178,10 +178,15 @@ export const ingest = internalMutation({
|
|
|
178
178
|
const read = readSubmissionPayload(body, { hasFiles: (body.uploads?.length ?? 0) > 0 })
|
|
179
179
|
if (!read.ok) return { ok: false, status: read.status, message: read.message, issues: read.issues }
|
|
180
180
|
|
|
181
|
+
// EC-578: the one id factory (UUID v4) — never a clock or a counter. EC-750: minted BEFORE
|
|
182
|
+
// the claim, which binds every file to this row (a refused claim leaves the id unused; a
|
|
183
|
+
// retried request re-mints).
|
|
184
|
+
mintSubmissionUuid ??= createUuidIdFactory()
|
|
185
|
+
const id = `${kind.idPrefix}_${mintSubmissionUuid()}`
|
|
181
186
|
// EC-695: every upload claimed in ONE engine call, after every check above that can refuse —
|
|
182
187
|
// all bound, or none (a refused claim refuses the submission, and nothing was bound, so
|
|
183
188
|
// nothing is released: this handler never deletes bytes). Nothing after it refuses.
|
|
184
|
-
const claim = await claimIngestUploads(ctx, body.uploads ?? [], body.target, receivedAt)
|
|
189
|
+
const claim = await claimIngestUploads(ctx, body.uploads ?? [], body.target, receivedAt, id)
|
|
185
190
|
if (!claim.ok) return claim
|
|
186
191
|
const files: SubmissionFile[] = []
|
|
187
192
|
const namesUsed = new Map<string, number>()
|
|
@@ -200,9 +205,6 @@ export const ingest = internalMutation({
|
|
|
200
205
|
})
|
|
201
206
|
}
|
|
202
207
|
|
|
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
208
|
const spam = read.columns.spam !== 'clean'
|
|
207
209
|
const deliveryState: DeliveryState = spam ? 'skipped' : 'pending'
|
|
208
210
|
await saveRecord(
|
|
@@ -0,0 +1,247 @@
|
|
|
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) => ({ storageId: record.recordId, owner: (record.data as { owner?: string }).owner })),
|
|
141
|
+
)
|
|
142
|
+
const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
|
|
143
|
+
|
|
144
|
+
/** An asset an editor makes over stored bytes (`assets:upsert`, the real write path). */
|
|
145
|
+
async function promote(t: Backend, assetId: string, storageKey: string) {
|
|
146
|
+
await asAdmin(t).mutation(makeFunctionReference<'mutation'>('assets:upsert'), {
|
|
147
|
+
assetId,
|
|
148
|
+
filename: `${assetId}.pdf`,
|
|
149
|
+
contentType: 'application/pdf',
|
|
150
|
+
byteSize: 800,
|
|
151
|
+
storageKey,
|
|
152
|
+
createdAt: T0,
|
|
153
|
+
updatedAt: T0,
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
describe('EC-750 probe — removing a submission deletes only the files THAT submission claimed', () => {
|
|
158
|
+
it('a claim binds each file to the submission that claimed it (the owner, not the form)', async () => {
|
|
159
|
+
const t = await backend()
|
|
160
|
+
const b = await genuineSubmission(t)
|
|
161
|
+
expect(await bindings(t)).toEqual([{ storageId: b.file, owner: b.id }]) // base: owner undefined (the form key only)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
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 () => {
|
|
165
|
+
const t = await backend()
|
|
166
|
+
const b = await genuineSubmission(t)
|
|
167
|
+
const bFiles = (await row(t, b.id))!.files!
|
|
168
|
+
expect(bFiles.map((file) => file.storageId)).toEqual([b.file])
|
|
169
|
+
await forgeSibling(t, 'submission_forged', bFiles)
|
|
170
|
+
await editor(t).remove(PROJECT_ID, 'submission_forged')
|
|
171
|
+
expect(await row(t, 'submission_forged')).toBeUndefined()
|
|
172
|
+
// Measured on the base (0b7bb049), counterexample form: both lines below read false / [].
|
|
173
|
+
expect(await survives(t, b.file)).toBe(true) // base: false — A's removal deleted B's bytes
|
|
174
|
+
expect(await bindings(t)).toEqual([{ storageId: b.file, owner: b.id }]) // base: [] — B's binding stripped
|
|
175
|
+
// B still holds its file: its row is intact, and its own removal deletes it (below).
|
|
176
|
+
expect((await row(t, b.id))!.files!.map((file) => file.storageId)).toEqual([b.file])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('control: removing B itself deletes B’s file and its binding (the instrument sees a deletion)', async () => {
|
|
180
|
+
const t = await backend()
|
|
181
|
+
const b = await genuineSubmission(t)
|
|
182
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
183
|
+
expect(await survives(t, b.file)).toBe(false)
|
|
184
|
+
expect(await bindings(t)).toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it('…and after the forged A is removed, removing B still deletes its file (no residue)', async () => {
|
|
188
|
+
const t = await backend()
|
|
189
|
+
const b = await genuineSubmission(t)
|
|
190
|
+
await forgeSibling(t, 'submission_forged', (await row(t, b.id))!.files!)
|
|
191
|
+
await editor(t).remove(PROJECT_ID, 'submission_forged')
|
|
192
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
193
|
+
expect(await survives(t, b.file)).toBe(false)
|
|
194
|
+
expect(await bindings(t)).toEqual([])
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
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 () => {
|
|
198
|
+
const t = await backend()
|
|
199
|
+
const b = await genuineSubmission(t)
|
|
200
|
+
await editor(t).save(PROJECT_ID, { id: b.id, files: null })
|
|
201
|
+
expect((await row(t, b.id))!.files).toBeUndefined()
|
|
202
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
203
|
+
expect(await survives(t, b.file)).toBe(false) // base: true — release got [] and B's bytes stayed forever
|
|
204
|
+
expect(await bindings(t)).toEqual([])
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('a LEGACY binding (bound before bindings recorded their owner) is never deleted — residue, never loss', async () => {
|
|
208
|
+
const t = await backend()
|
|
209
|
+
const legacyFile = await storeFile(t, 300, 'application/pdf')
|
|
210
|
+
// The binding a claim wrote before EC-750: no owner column, no key slot.
|
|
211
|
+
await t.run(async (ctx) => {
|
|
212
|
+
await ctx.db.insert('records', {
|
|
213
|
+
kind: 'ingestBinding',
|
|
214
|
+
recordId: legacyFile,
|
|
215
|
+
data: { uploadId: 'upload_legacy', target: 'contact', boundAt: T0 },
|
|
216
|
+
at: T0,
|
|
217
|
+
createdAt: T0,
|
|
218
|
+
updatedAt: T0,
|
|
219
|
+
})
|
|
220
|
+
})
|
|
221
|
+
await forgeSibling(t, 'submission_legacy', [{ field: 'cv', storageId: legacyFile, contentType: 'application/pdf', size: 300 }])
|
|
222
|
+
await editor(t).remove(PROJECT_ID, 'submission_legacy')
|
|
223
|
+
expect(await survives(t, legacyFile)).toBe(true) // base: false — any bound id the row named was deleted
|
|
224
|
+
expect(await bindings(t)).toEqual([{ storageId: legacyFile, owner: undefined }])
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('assets:deleteBlob still keeps bytes a binding holds — after the forged A’s removal, B’s binding still protects f', async () => {
|
|
228
|
+
const t = await backend()
|
|
229
|
+
const b = await genuineSubmission(t)
|
|
230
|
+
await forgeSibling(t, 'submission_forged', (await row(t, b.id))!.files!)
|
|
231
|
+
await editor(t).remove(PROJECT_ID, 'submission_forged')
|
|
232
|
+
// An editor then makes an asset over f and deletes that asset's bytes: B's binding keeps them.
|
|
233
|
+
await promote(t, 'cv-asset', b.file)
|
|
234
|
+
expect(await asAdmin(t).mutation(deleteBlob, { storageKey: b.file })).toEqual({ deleted: false, heldBy: 'ingest' }) // base: the bytes were already gone
|
|
235
|
+
expect(await survives(t, b.file)).toBe(true)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it('control: release keeps an owner’s file an asset references now (EC-685) — the binding is spent, the bytes stay', async () => {
|
|
239
|
+
const t = await backend()
|
|
240
|
+
const b = await genuineSubmission(t)
|
|
241
|
+
await promote(t, 'cv-asset', b.file)
|
|
242
|
+
await editor(t).remove(PROJECT_ID, b.id)
|
|
243
|
+
expect(await survives(t, b.file)).toBe(true)
|
|
244
|
+
expect(await bindings(t)).toEqual([])
|
|
245
|
+
expect(await asAdmin(t).mutation(deleteBlob, { storageKey: b.file })).toEqual({ deleted: true })
|
|
246
|
+
})
|
|
247
|
+
})
|