@elytracms/studio 0.0.97 → 0.0.99
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/convex/assets.ts +16 -2
- package/convex/ingest-attempt.test.ts +161 -0
- package/convex/ingest.test.ts +269 -16
- package/convex/ingest.ts +238 -47
- package/convex/path-segment-refusal.test.ts +201 -0
- package/convex/prototype-key.test.ts +92 -0
- package/convex/records-column-transform.convex.test.ts +147 -0
- package/convex/records-zod-refinements.convex.test.ts +94 -0
- package/convex/schema.ts +1 -1
- package/dist/addon.d.ts +5 -3
- package/dist/addon.js +4 -3
- package/dist/addon.js.map +1 -1
- package/dist/components/app-views/default-modules.d.ts +10 -3
- package/dist/components/app-views/default-modules.js +5 -1
- package/dist/components/app-views/default-modules.js.map +1 -1
- package/dist/components/app-views/module.d.ts +15 -8
- package/dist/components/app-views/module.js.map +1 -1
- package/dist/components/builder/reuse-context.d.ts +29 -1
- package/dist/components/builder/reuse-context.js +58 -12
- package/dist/components/builder/reuse-context.js.map +1 -1
- package/dist/components/cms/cms-store.d.ts +5 -6
- package/dist/components/cms/cms-store.js +12 -24
- package/dist/components/cms/cms-store.js.map +1 -1
- package/dist/components/cms/field-control.d.ts +12 -1
- package/dist/components/cms/field-control.js +31 -6
- package/dist/components/cms/field-control.js.map +1 -1
- package/dist/components/cms/import-batch.d.ts +23 -0
- package/dist/components/cms/import-batch.js +43 -0
- package/dist/components/cms/import-batch.js.map +1 -0
- package/dist/components/cms/rich-text/editor.js +13 -10
- package/dist/components/cms/rich-text/editor.js.map +1 -1
- package/dist/components/cms/rich-text/value.d.ts +9 -0
- package/dist/components/cms/rich-text/value.js +13 -0
- package/dist/components/cms/rich-text/value.js.map +1 -1
- package/dist/components/cms/use-document-draft.d.ts +39 -3
- package/dist/components/cms/use-document-draft.js +46 -16
- package/dist/components/cms/use-document-draft.js.map +1 -1
- package/dist/components/cms/use-publish-controller.d.ts +7 -0
- package/dist/components/cms/use-publish-controller.js +17 -3
- package/dist/components/cms/use-publish-controller.js.map +1 -1
- package/dist/components/cms/versions-pane.js +8 -5
- package/dist/components/cms/versions-pane.js.map +1 -1
- package/dist/components/delivery-health-banner.d.ts +34 -16
- package/dist/components/delivery-health-banner.js +59 -27
- package/dist/components/delivery-health-banner.js.map +1 -1
- package/dist/components/issue-severity.d.ts +3 -2
- package/dist/components/issue-severity.js.map +1 -1
- package/dist/components/nav-issues-registry.d.ts +39 -24
- package/dist/components/nav-issues-registry.js +50 -21
- package/dist/components/nav-issues-registry.js.map +1 -1
- package/dist/components/nav-issues.d.ts +23 -50
- package/dist/components/nav-issues.js +31 -59
- package/dist/components/nav-issues.js.map +1 -1
- package/dist/components/notifications/notice-center.d.ts +61 -0
- package/dist/components/notifications/notice-center.js +119 -0
- package/dist/components/notifications/notice-center.js.map +1 -0
- package/dist/components/notifications/notice-model.d.ts +137 -0
- package/dist/components/notifications/notice-model.js +107 -0
- package/dist/components/notifications/notice-model.js.map +1 -0
- package/dist/components/notifications/outcome-notice.d.ts +16 -0
- package/dist/components/notifications/outcome-notice.js +34 -0
- package/dist/components/notifications/outcome-notice.js.map +1 -0
- package/dist/components/notifications/shell-findings.d.ts +36 -0
- package/dist/components/notifications/shell-findings.js +140 -0
- package/dist/components/notifications/shell-findings.js.map +1 -0
- package/dist/components/rail-view-source.js +5 -0
- package/dist/components/rail-view-source.js.map +1 -1
- package/dist/components/records/record-lens.js.map +1 -1
- package/dist/components/records/record-rail.d.ts +10 -2
- package/dist/components/records/record-rail.js +74 -4
- package/dist/components/records/record-rail.js.map +1 -1
- package/dist/components/section-nav.d.ts +15 -3
- package/dist/components/section-nav.js +39 -147
- package/dist/components/section-nav.js.map +1 -1
- package/dist/components/studio-project-screen.js.map +1 -1
- package/dist/components/studio-shell.js +8 -3
- package/dist/components/studio-shell.js.map +1 -1
- package/dist/components/use-narrow-viewport.d.ts +11 -0
- package/dist/components/use-narrow-viewport.js +25 -0
- package/dist/components/use-narrow-viewport.js.map +1 -0
- package/dist/components/users/role-policy-drift-issues.d.ts +14 -7
- package/dist/components/users/role-policy-drift-issues.js +17 -8
- package/dist/components/users/role-policy-drift-issues.js.map +1 -1
- package/dist/lib/collection-view/collection-view-source.d.ts +4 -2
- package/dist/lib/collection-view/collection-view-source.js.map +1 -1
- package/dist/lib/engine-version.d.ts +1 -1
- package/dist/lib/engine-version.js +1 -1
- package/dist/lib/engine-version.js.map +1 -1
- package/dist/lib/i18n/messages.de.js +24 -3
- package/dist/lib/i18n/messages.de.js.map +1 -1
- package/dist/lib/i18n/messages.en.d.ts +22 -2
- package/dist/lib/i18n/messages.en.js +27 -4
- package/dist/lib/i18n/messages.en.js.map +1 -1
- package/dist/lib/notices/toast-store.d.ts +61 -0
- package/dist/lib/notices/toast-store.js +116 -0
- package/dist/lib/notices/toast-store.js.map +1 -0
- package/dist/lib/operations/cms-persistence.d.ts +32 -7
- package/dist/lib/operations/cms-persistence.js +511 -71
- package/dist/lib/operations/cms-persistence.js.map +1 -1
- package/dist/lib/persistence/convex-adapter.js +61 -30
- package/dist/lib/persistence/convex-adapter.js.map +1 -1
- package/dist/lib/persona/labels.js +1 -0
- package/dist/lib/persona/labels.js.map +1 -1
- package/dist/ui/toast.d.ts +24 -16
- package/dist/ui/toast.js +23 -32
- package/dist/ui/toast.js.map +1 -1
- package/package.json +4 -3
package/convex/assets.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { liftLegacyAlt as legacyAltLift, rejectedWriteMessage, resolveAssetWrite
|
|
|
5
5
|
import type { DataModel } from './functions'
|
|
6
6
|
import { internalMutation, mutation, query } from './functions'
|
|
7
7
|
import { requireCapability } from './guard'
|
|
8
|
+
import { ingestBindingHolds } from './ingest'
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Asset metadata persistence (EC-044) + byte storage (EC-151). Bytes live in
|
|
@@ -210,15 +211,28 @@ export const getServeUrl = query({
|
|
|
210
211
|
},
|
|
211
212
|
})
|
|
212
213
|
|
|
213
|
-
/**
|
|
214
|
+
/**
|
|
215
|
+
* Delete stored bytes. Metadata removal is a separate `assets:remove` call.
|
|
216
|
+
*
|
|
217
|
+
* EC-685 — unless an ingest binding holds them: an editor may create an asset over a form
|
|
218
|
+
* submission's uploaded file (`upsert` allows it — promoting an upload is a legitimate edit),
|
|
219
|
+
* and deleting that asset must not take the submission's file with it. Such bytes are KEPT,
|
|
220
|
+
* read in this transaction, and the call returns normally (`{ deleted: false, heldBy:
|
|
221
|
+
* 'ingest' }`): the studio deletes an asset's bytes BEFORE its row (`deleteAsset`), so a
|
|
222
|
+
* refusal would leave the asset undeletable, while keeping them is the correct ownership —
|
|
223
|
+
* the asset row goes, the submission keeps its file, and removing the submission deletes it
|
|
224
|
+
* (`releaseIngestUploads`, the mirror check).
|
|
225
|
+
*/
|
|
214
226
|
export const deleteBlob = mutation({
|
|
215
227
|
args: { storageKey: v.string() },
|
|
216
|
-
handler: async (ctx, { storageKey }) => {
|
|
228
|
+
handler: async (ctx, { storageKey }): Promise<{ deleted: true } | { deleted: false; heldBy: 'ingest' }> => {
|
|
217
229
|
// EC-150 / EC-304: destructive — gate at `editor`, matching `assets:remove`.
|
|
218
230
|
// Under the old `requireUserId` an authenticated non-member could delete the
|
|
219
231
|
// bytes of any published asset (storageKeys are harvestable from the public
|
|
220
232
|
// `delivery:snapshot`), knocking every image off the live site.
|
|
221
233
|
await requireCapability(ctx, 'content.write')
|
|
234
|
+
if (await ingestBindingHolds(ctx, storageKey)) return { deleted: false, heldBy: 'ingest' }
|
|
222
235
|
await ctx.storage.delete(asStorageId(storageKey))
|
|
236
|
+
return { deleted: true }
|
|
223
237
|
},
|
|
224
238
|
})
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { convexTest } from 'convex-test'
|
|
3
|
+
import { makeFunctionReference } from 'convex/server'
|
|
4
|
+
import { v } from 'convex/values'
|
|
5
|
+
import { z } from 'zod'
|
|
6
|
+
import { INGEST_RECEIVER_PATH, signedIngestRequest } from '@elytracms/core/ingest'
|
|
7
|
+
import type { IngestHandlerResult } from '@elytracms/core/ingest'
|
|
8
|
+
import { defineRecordKind } from '@elytracms/core/persistence'
|
|
9
|
+
import schema from './schema'
|
|
10
|
+
import { internalMutation } from './functions'
|
|
11
|
+
import { defineStudioHttp } from './http'
|
|
12
|
+
import { INGEST_HANDLER_ATTEMPTS, ingestHandlerArgs, parseIngestPayload } from './ingest'
|
|
13
|
+
import type { IngestHandlerRef } from './ingest'
|
|
14
|
+
import { saveRecord } from './record-functions'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* EC-618 (the ratified low-volume lever, the critic's correction 1) — the receiver RETRIES a
|
|
18
|
+
* handler whose mutation THREW, with an additive, optional `attempt` argument. A Convex mutation
|
|
19
|
+
* cannot observe its own optimistic-concurrency conflict: Convex re-executes it, and exhaustion
|
|
20
|
+
* surfaces only to the caller — the receiver's `ctx.runMutation` (measured on the dev deployment
|
|
21
|
+
* in wave-0095: every receiver 500 of the burst was an `analytics:ingest` execution that failed
|
|
22
|
+
* after its retries). So the receiver is where a handler gets its next attempt; analytics uses
|
|
23
|
+
* it to open an overflow shard, every other handler ignores it.
|
|
24
|
+
*
|
|
25
|
+
* convex-test SERIALISES mutations, so no write conflict can happen here: the failure is
|
|
26
|
+
* INJECTED (a handler that writes, then throws on its early attempts). What this proves is the
|
|
27
|
+
* receiver's contract — attempt 1 carries no `attempt` key, a throw is retried with `attempt: n`,
|
|
28
|
+
* a thrown attempt committed nothing, a refusal is final, the attempts are bounded — never
|
|
29
|
+
* Convex's OCC (that is the dev-deployment burst, `pnpm dev:ingest rosy-armadillo-302 ec618-burst`).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const SECRET = 'ec618-attempt-secret'
|
|
33
|
+
const env = () => (globalThis as { process: { env: Record<string, string | undefined> } }).process.env
|
|
34
|
+
|
|
35
|
+
const hitKind = defineRecordKind({
|
|
36
|
+
kind: 'testHit',
|
|
37
|
+
module: 'testAttempt',
|
|
38
|
+
keys: ['label'],
|
|
39
|
+
fields: { label: z.string().min(1), attempt: z.number().int().positive() },
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** Every execution the handlers saw, with the `attempt` they were given (`undefined` = the key was absent). */
|
|
43
|
+
let seen: { handler: string; attempt: number | undefined; keys: string[] }[] = []
|
|
44
|
+
/** The flaky handler throws on every attempt below this one (after writing — the write must not commit). */
|
|
45
|
+
let succeedFrom = 1
|
|
46
|
+
|
|
47
|
+
async function writeHit(ctx: Parameters<typeof saveRecord>[0], label: string, attempt: number, receivedAt: string) {
|
|
48
|
+
const id = `hit_${(await ctx.db.query('records').collect()).length + 1}`
|
|
49
|
+
await saveRecord(ctx, hitKind, id, { label, attempt }, { createdAt: receivedAt, updatedAt: receivedAt })
|
|
50
|
+
return id
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const testAttemptModule = {
|
|
54
|
+
/** Writes its hit, then throws while `attempt < succeedFrom` — an exhausted write conflict, injected. */
|
|
55
|
+
flaky: internalMutation({
|
|
56
|
+
args: ingestHandlerArgs,
|
|
57
|
+
handler: async (ctx, args): Promise<IngestHandlerResult> => {
|
|
58
|
+
seen.push({ handler: 'flaky', attempt: args.attempt, keys: Object.keys(args).sort() })
|
|
59
|
+
const payload = parseIngestPayload(args.payload)
|
|
60
|
+
const attempt = args.attempt ?? 1
|
|
61
|
+
const id = await writeHit(ctx, payload.kind === 'event' ? String(payload.data['name']) : payload.kind, attempt, args.receivedAt)
|
|
62
|
+
if (attempt < succeedFrom) throw new Error(`injected: the write conflicted on every retry (attempt ${attempt})`)
|
|
63
|
+
return { ok: true, id }
|
|
64
|
+
},
|
|
65
|
+
}),
|
|
66
|
+
/** A handler declared with the PRE-attempt arguments (strict: an extra key would be refused). */
|
|
67
|
+
legacy: internalMutation({
|
|
68
|
+
args: { payload: v.any(), receivedAt: v.string() },
|
|
69
|
+
handler: async (ctx, args): Promise<IngestHandlerResult> => {
|
|
70
|
+
seen.push({ handler: 'legacy', attempt: undefined, keys: Object.keys(args).sort() })
|
|
71
|
+
return { ok: true, id: await writeHit(ctx, 'legacy', 1, args.receivedAt) }
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
/** A refusal is an ANSWER, not a failure: never retried. */
|
|
75
|
+
refusing: internalMutation({
|
|
76
|
+
args: ingestHandlerArgs,
|
|
77
|
+
handler: async (_ctx, args): Promise<IngestHandlerResult> => {
|
|
78
|
+
seen.push({ handler: 'refusing', attempt: args.attempt, keys: Object.keys(args).sort() })
|
|
79
|
+
return { ok: false, status: 409, message: 'Refused on purpose.' }
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ref = (name: string) => makeFunctionReference<'mutation'>(`testAttempt:${name}`) as unknown as IngestHandlerRef
|
|
85
|
+
const silentAuth = { addHttpRoutes: () => {} }
|
|
86
|
+
|
|
87
|
+
function backend(event: IngestHandlerRef) {
|
|
88
|
+
return convexTest(schema, {
|
|
89
|
+
...import.meta.glob(['./**/*.ts', '!./**/*.test.ts', '!./_generated/**']),
|
|
90
|
+
'./_generated/api.ts': async () => ({}),
|
|
91
|
+
'./testAttempt.ts': async () => testAttemptModule,
|
|
92
|
+
'./http.ts': async () => ({ default: defineStudioHttp({ auth: silentAuth, ingest: { event } }) }),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function post(t: ReturnType<typeof backend>, name = 'pageview') {
|
|
97
|
+
const signed = await signedIngestRequest({ kind: 'event', data: { name } }, SECRET)
|
|
98
|
+
return t.fetch(INGEST_RECEIVER_PATH, { method: 'POST', headers: signed.headers, body: signed.body })
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function hits(t: ReturnType<typeof backend>) {
|
|
102
|
+
return t.run(async (ctx) =>
|
|
103
|
+
(await ctx.db.query('records').collect()).filter((row) => row.kind === hitKind.kind).map((row) => row.data as { label: string; attempt: number }),
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
beforeEach(() => {
|
|
108
|
+
vi.useFakeTimers()
|
|
109
|
+
vi.setSystemTime(new Date('2026-10-28T12:00:00.000Z'))
|
|
110
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
111
|
+
seen = []
|
|
112
|
+
succeedFrom = 1
|
|
113
|
+
})
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
vi.useRealTimers()
|
|
116
|
+
delete env()['ELYTRA_INGEST_SECRET']
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe('the receiver retries a handler that THREW, with an additive `attempt` (EC-618)', () => {
|
|
120
|
+
it('attempt 1 carries no `attempt` key — a handler declared with the pre-attempt arguments runs unchanged', async () => {
|
|
121
|
+
const t = backend(ref('legacy'))
|
|
122
|
+
expect((await post(t)).status).toBe(200)
|
|
123
|
+
expect(seen).toEqual([{ handler: 'legacy', attempt: undefined, keys: ['payload', 'receivedAt'] }])
|
|
124
|
+
expect(await hits(t)).toEqual([{ label: 'legacy', attempt: 1 }])
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('a throw is retried with `attempt: 2`; the thrown attempt committed nothing (no double count)', async () => {
|
|
128
|
+
succeedFrom = 2
|
|
129
|
+
const t = backend(ref('flaky'))
|
|
130
|
+
const response = await post(t)
|
|
131
|
+
expect(response.status).toBe(200)
|
|
132
|
+
expect(seen.map((entry) => entry.attempt)).toEqual([undefined, 2])
|
|
133
|
+
expect(seen[1]!.keys).toEqual(['attempt', 'payload', 'receivedAt'])
|
|
134
|
+
// Attempt 1 wrote its hit and threw: rolled back. Only attempt 2's write is stored.
|
|
135
|
+
expect(await hits(t)).toEqual([{ label: 'pageview', attempt: 2 }])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('the attempts are bounded: a handler that throws every time is run INGEST_HANDLER_ATTEMPTS times, then the failure surfaces', async () => {
|
|
139
|
+
succeedFrom = Number.POSITIVE_INFINITY
|
|
140
|
+
const t = backend(ref('flaky'))
|
|
141
|
+
// convex-test rethrows a throwing httpAction to the caller; a deployment answers 500.
|
|
142
|
+
await expect(post(t)).rejects.toThrow(/injected: the write conflicted on every retry \(attempt 3\)/)
|
|
143
|
+
expect(INGEST_HANDLER_ATTEMPTS).toBe(3)
|
|
144
|
+
expect(seen.map((entry) => entry.attempt)).toEqual([undefined, 2, 3])
|
|
145
|
+
expect(await hits(t)).toEqual([]) // nothing committed by any of them
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('a refusal is final — relayed as it was, never retried', async () => {
|
|
149
|
+
const t = backend(ref('refusing'))
|
|
150
|
+
const response = await post(t)
|
|
151
|
+
expect(response.status).toBe(409)
|
|
152
|
+
expect(seen).toHaveLength(1)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('control — without a failure every request runs its handler exactly once (the instrument can count one)', async () => {
|
|
156
|
+
const t = backend(ref('flaky'))
|
|
157
|
+
for (let i = 0; i < 5; i += 1) expect((await post(t, `e${i}`)).status).toBe(200)
|
|
158
|
+
expect(seen.map((entry) => entry.attempt)).toEqual([undefined, undefined, undefined, undefined, undefined])
|
|
159
|
+
expect((await hits(t)).map((hit) => hit.attempt)).toEqual([1, 1, 1, 1, 1])
|
|
160
|
+
})
|
|
161
|
+
})
|
package/convex/ingest.test.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
INGEST_MAX_BODY_BYTES,
|
|
7
7
|
INGEST_RECEIVER_PATH,
|
|
8
8
|
INGEST_SIGNATURE_HEADER,
|
|
9
|
+
ingestUploadSha256,
|
|
9
10
|
signedIngestRequest,
|
|
10
11
|
} from '@elytracms/core/ingest'
|
|
11
12
|
import type { IngestHandlerResult, IngestPayload } from '@elytracms/core/ingest'
|
|
@@ -15,7 +16,7 @@ import { internalMutation } from './functions'
|
|
|
15
16
|
import { defineStudioHttp } from './http'
|
|
16
17
|
import { CONVEX_MAX_NESTING_DEPTH, measureMaxDepth } from '@elytracms/core/cms-core'
|
|
17
18
|
import {
|
|
18
|
-
|
|
19
|
+
claimIngestUploads,
|
|
19
20
|
ingestHandlerArgs,
|
|
20
21
|
parseIngestPayload,
|
|
21
22
|
recordIngestUpload,
|
|
@@ -76,12 +77,10 @@ const testIngestModule = {
|
|
|
76
77
|
}
|
|
77
78
|
if (payload.kind !== 'submission') return { ok: false, status: 422, message: 'Not a submission.' }
|
|
78
79
|
if (payload.target !== 'contact') return { ok: false, status: 404, message: `No form “${payload.target}”.` }
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
files.push(claimed.storageId)
|
|
84
|
-
}
|
|
80
|
+
// EC-695: one call for every upload, after every check that can refuse — all bound or none.
|
|
81
|
+
const claim = await claimIngestUploads(ctx, payload.uploads ?? [], payload.target, args.receivedAt)
|
|
82
|
+
if (!claim.ok) return claim
|
|
83
|
+
const files = claim.files.map((file) => file.storageId)
|
|
85
84
|
const id = `sub_${(await ctx.db.query('records').collect()).length + 1}`
|
|
86
85
|
await saveRecord(
|
|
87
86
|
ctx,
|
|
@@ -186,6 +185,13 @@ async function storeFile(t: ReturnType<typeof backend>, bytes: number, contentTy
|
|
|
186
185
|
})
|
|
187
186
|
}
|
|
188
187
|
|
|
188
|
+
/** EC-685: the SHA-256 an upload declares for the zero-filled `bytes` bytes {@link storeFile} stores. */
|
|
189
|
+
async function sha256Of(bytes: number): Promise<string> {
|
|
190
|
+
const digest = await ingestUploadSha256(new Uint8Array(bytes))
|
|
191
|
+
if (!digest.ok) throw new Error(digest.message)
|
|
192
|
+
return digest.sha256
|
|
193
|
+
}
|
|
194
|
+
|
|
189
195
|
/** Whether stored bytes exist (`storage.get` yields a Blob, which is not a Convex value: answer a boolean). */
|
|
190
196
|
function stored(t: ReturnType<typeof backend>, storageId: string): Promise<boolean> {
|
|
191
197
|
return t.run(async (ctx) => (await ctx.storage.get(storageId as never)) !== null)
|
|
@@ -265,7 +271,7 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
265
271
|
it('an upload the handler accepts gets ONE storage URL and an upload id; one it refuses gets none', async () => {
|
|
266
272
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
267
273
|
const t = backend()
|
|
268
|
-
const upload = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
274
|
+
const upload = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500, sha256: await sha256Of(500) } as const
|
|
269
275
|
const issued = await signedPost(t, upload)
|
|
270
276
|
expect(issued.status).toBe(200)
|
|
271
277
|
const body = (await issued.json()) as { uploadId: string; uploadUrl: string; expiresInSeconds: number }
|
|
@@ -274,23 +280,45 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
274
280
|
expect(body.expiresInSeconds).toBe(3600)
|
|
275
281
|
expect((await signedPost(t, { ...upload, size: 5000 })).status).toBe(413) // the handler's policy
|
|
276
282
|
expect((await signedPost(t, { ...upload, contentType: 'application/x-msdownload' })).status).toBe(415)
|
|
283
|
+
// EC-685: the intent records the declared digest (what the claim compares).
|
|
284
|
+
const intent = await t.run(async (ctx) => (await ctx.db.query('records').collect()).find((row) => row.recordId === body.uploadId))
|
|
285
|
+
expect((intent!.data as { sha256: string }).sha256).toBe(upload.sha256)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
it('EC-685 wire: an upload that declares no sha256, or one in another encoding, is a 400 — no intent, no URL', async () => {
|
|
289
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
290
|
+
const t = backend()
|
|
291
|
+
const base = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
292
|
+
const hex = await sha256Of(500)
|
|
293
|
+
for (const payload of [base, { ...base, sha256: hex.toUpperCase() }, { ...base, sha256: hex.slice(1) }]) {
|
|
294
|
+
const raw = JSON.stringify(payload)
|
|
295
|
+
const answer = await t.fetch(INGEST_RECEIVER_PATH, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: { 'content-type': 'application/json', [INGEST_SIGNATURE_HEADER]: await signedIngestRequestRaw(raw) },
|
|
298
|
+
body: raw,
|
|
299
|
+
})
|
|
300
|
+
expect(answer.status, raw).toBe(400) // the receiver's union (payload | probe) reports it at the root; the route's read names `sha256` (core test)
|
|
301
|
+
}
|
|
302
|
+
expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).length)).toBe(0)
|
|
303
|
+
// Control: the declared lowercase hex is accepted.
|
|
304
|
+
expect((await signedPost(t, { ...base, sha256: hex })).status).toBe(200)
|
|
277
305
|
})
|
|
278
306
|
|
|
279
307
|
it('a submission claims its upload: the stored file must match the declaration; a mismatch is refused, never deleted', async () => {
|
|
280
308
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
281
309
|
const t = backend()
|
|
282
|
-
const declare = async (contentType: string, size: number) =>
|
|
283
|
-
((await (await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType, size })).json()) as {
|
|
310
|
+
const declare = async (contentType: string, size: number, bytesOf: number) =>
|
|
311
|
+
((await (await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType, size, sha256: await sha256Of(bytesOf) })).json()) as {
|
|
284
312
|
uploadId: string
|
|
285
313
|
}).uploadId
|
|
286
|
-
const good = await declare('image/png', 500)
|
|
314
|
+
const good = await declare('image/png', 500, 400)
|
|
287
315
|
const goodFile = await storeFile(t, 400, 'image/png')
|
|
288
316
|
const ok = await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: good, storageId: goodFile }] })
|
|
289
317
|
expect(ok.status).toBe(200)
|
|
290
318
|
// A second claim of the same upload is a conflict.
|
|
291
319
|
expect((await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: good, storageId: goodFile }] })).status).toBe(409)
|
|
292
320
|
|
|
293
|
-
const liar = await declare('image/png', 500)
|
|
321
|
+
const liar = await declare('image/png', 500, 10) // its own bytes: the mismatch is the type
|
|
294
322
|
const wrongType = await storeFile(t, 10, 'text/html')
|
|
295
323
|
expect((await signedPost(t, { ...SUBMISSION, uploads: [{ uploadId: liar, storageId: wrongType }] })).status).toBe(415)
|
|
296
324
|
// EC-671: refused and KEPT — a claim never deletes the id it names (it is the visitor's word).
|
|
@@ -301,7 +329,7 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
301
329
|
it('an upload no submission claims is listed once its life is over (the visible orphan finding)', async () => {
|
|
302
330
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
303
331
|
const t = backend()
|
|
304
|
-
await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 10 })
|
|
332
|
+
await signedPost(t, { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 10, sha256: await sha256Of(10) })
|
|
305
333
|
const later = (offsetMs: number) => new Date(Date.parse('2026-10-26T12:00:00.000Z') + offsetMs).toISOString()
|
|
306
334
|
expect(await t.run((ctx) => unclaimedIngestUploads(ctx, 'contact', later(30 * 60 * 1000)))).toEqual([])
|
|
307
335
|
const orphans = await t.run((ctx) => unclaimedIngestUploads(ctx, 'contact', later(2 * 60 * 60 * 1000)))
|
|
@@ -318,8 +346,9 @@ describe('the ingest receiver — dispatch (EC-617)', () => {
|
|
|
318
346
|
*/
|
|
319
347
|
describe('the claim binds only this intent’s bytes; release deletes only bound bytes (EC-671)', () => {
|
|
320
348
|
const PNG = { kind: 'upload', target: 'contact', field: 'cv', contentType: 'image/png', size: 500 } as const
|
|
321
|
-
|
|
322
|
-
|
|
349
|
+
/** An intent declaring a png of up to 500 bytes whose content is the `bytesOf` zero bytes the test then stores (EC-685). */
|
|
350
|
+
async function declare(t: ReturnType<typeof backend>, bytesOf = 400): Promise<string> {
|
|
351
|
+
return ((await (await signedPost(t, { ...PNG, sha256: await sha256Of(bytesOf) })).json()) as { uploadId: string }).uploadId
|
|
323
352
|
}
|
|
324
353
|
const claimWith = (t: ReturnType<typeof backend>, uploadId: string, storageId: string) =>
|
|
325
354
|
signedPost(t, { ...SUBMISSION, uploads: [{ uploadId, storageId }] })
|
|
@@ -388,7 +417,7 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
388
417
|
it('(3) a file an asset row references — as its file or as its preview — is refused; control: an unreferenced one is claimed', async () => {
|
|
389
418
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
390
419
|
const t = backend()
|
|
391
|
-
const [asFile, asThumb, plain] = [await declare(t), await declare(t), await declare(t)]
|
|
420
|
+
const [asFile, asThumb, plain] = [await declare(t), await declare(t, 40), await declare(t)]
|
|
392
421
|
const original = await storeFile(t, 400, 'image/png') // all stored AFTER the three intents: check (1) passes
|
|
393
422
|
const preview = await storeFile(t, 40, 'image/png')
|
|
394
423
|
const unreferenced = await storeFile(t, 400, 'image/png')
|
|
@@ -414,6 +443,151 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
414
443
|
expect(await bindings(t)).toEqual([])
|
|
415
444
|
})
|
|
416
445
|
|
|
446
|
+
// EC-685 (Codex r4 F20, ordering 2): a fact checked at claim time is not permanent — an
|
|
447
|
+
// asset may acquire a bound file afterwards. Each delete path re-checks the OTHER owner.
|
|
448
|
+
async function asEditor(t: ReturnType<typeof backend>) {
|
|
449
|
+
await t.run(async (ctx) => {
|
|
450
|
+
await ctx.db.insert('members', { userId: 'user_editor', role: 'admin', createdAt: '2026-10-26T12:00:00.000Z', updatedAt: '2026-10-26T12:00:00.000Z' })
|
|
451
|
+
})
|
|
452
|
+
return t.withIdentity({ subject: 'user_editor', issuer: 'https://convex.test' })
|
|
453
|
+
}
|
|
454
|
+
const deleteBlob = makeFunctionReference<'mutation', { storageKey: string }, unknown>('assets:deleteBlob')
|
|
455
|
+
|
|
456
|
+
it('EC-685: release KEEPS a bound file an asset references now — the binding is spent, the bytes go with the asset', async () => {
|
|
457
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
458
|
+
const t = backend()
|
|
459
|
+
const [asFile, asThumb, plain] = [await declare(t), await declare(t, 40), await declare(t)]
|
|
460
|
+
const promoted = await storeFile(t, 400, 'image/png')
|
|
461
|
+
const preview = await storeFile(t, 40, 'image/png')
|
|
462
|
+
const unreferenced = await storeFile(t, 400, 'image/png')
|
|
463
|
+
for (const [uploadId, file] of [[asFile, promoted], [asThumb, preview], [plain, unreferenced]] as const) {
|
|
464
|
+
expect((await claimWith(t, uploadId, file)).status).toBe(200)
|
|
465
|
+
}
|
|
466
|
+
// An editor promotes two of the uploads to an asset (its file and its preview) — after the claims.
|
|
467
|
+
await insertAsset(t, { storageKey: promoted, thumb: preview })
|
|
468
|
+
const released = await t.run((ctx) => releaseIngestUploads(ctx, [promoted, preview, unreferenced]))
|
|
469
|
+
expect(released).toEqual({ deleted: [unreferenced], kept: [promoted, preview] })
|
|
470
|
+
expect([await stored(t, promoted), await stored(t, preview), await stored(t, unreferenced)]).toEqual([true, true, false])
|
|
471
|
+
expect(await bindings(t)).toEqual([]) // ownership passed: no binding is left to hold the asset's bytes
|
|
472
|
+
// …so the asset's own delete now deletes them (control: deleteBlob deletes unbound bytes).
|
|
473
|
+
const editor = await asEditor(t)
|
|
474
|
+
expect(await editor.mutation(deleteBlob, { storageKey: promoted })).toEqual({ deleted: true })
|
|
475
|
+
expect(await stored(t, promoted)).toBe(false)
|
|
476
|
+
})
|
|
477
|
+
|
|
478
|
+
it('EC-685 mirror: assets:deleteBlob KEEPS bytes an ingest binding holds and returns normally; release then deletes them', async () => {
|
|
479
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
480
|
+
const t = backend()
|
|
481
|
+
const uploadId = await declare(t)
|
|
482
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
483
|
+
expect((await claimWith(t, uploadId, own)).status).toBe(200)
|
|
484
|
+
await insertAsset(t, { storageKey: own }) // promoted to an asset while the submission still holds it
|
|
485
|
+
const editor = await asEditor(t)
|
|
486
|
+
expect(await editor.mutation(deleteBlob, { storageKey: own })).toEqual({ deleted: false, heldBy: 'ingest' })
|
|
487
|
+
expect(await stored(t, own)).toBe(true)
|
|
488
|
+
expect(await bindings(t)).toEqual([{ storageId: own, uploadId }])
|
|
489
|
+
// The asset row goes (the studio's `assets:remove`); the submission's release deletes the bytes.
|
|
490
|
+
await t.run(async (ctx) => {
|
|
491
|
+
for (const row of await ctx.db.query('assets').collect()) await ctx.db.delete(row._id)
|
|
492
|
+
})
|
|
493
|
+
expect(await t.run((ctx) => releaseIngestUploads(ctx, [own]))).toEqual({ deleted: [own], kept: [] })
|
|
494
|
+
expect(await stored(t, own)).toBe(false)
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
// EC-685 (Codex r4 F20, ordering 1): checks (1)–(3) are eligibility; (4) is causation.
|
|
498
|
+
async function storeBytes(t: ReturnType<typeof backend>, bytes: Uint8Array<ArrayBuffer>, contentType: string) {
|
|
499
|
+
return t.run(async (ctx) => {
|
|
500
|
+
const id = await ctx.storage.store(new Blob([bytes], { type: contentType }))
|
|
501
|
+
await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(id, { contentType })
|
|
502
|
+
return id
|
|
503
|
+
})
|
|
504
|
+
}
|
|
505
|
+
const setStoredDigest = (t: ReturnType<typeof backend>, storageId: string, sha256: string) =>
|
|
506
|
+
t.run(async (ctx) => {
|
|
507
|
+
await (ctx.db as unknown as { patch(id: unknown, value: unknown): Promise<void> }).patch(storageId, { sha256 })
|
|
508
|
+
})
|
|
509
|
+
|
|
510
|
+
it('(4) EC-685: a file stored after the intent, unbound, no asset’s — but not the DECLARED bytes — is refused, rejected and kept', async () => {
|
|
511
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
512
|
+
const t = backend()
|
|
513
|
+
const uploadId = await declare(t) // declares 400 zero bytes
|
|
514
|
+
const other = await storeBytes(t, new Uint8Array(400).fill(7), 'image/png') // same type and size, other bytes
|
|
515
|
+
const refused = await claimWith(t, uploadId, other)
|
|
516
|
+
expect(refused.status).toBe(422)
|
|
517
|
+
// The ONE answer of checks (1)–(4): which one failed is not the visitor's to learn.
|
|
518
|
+
expect(await refused.json()).toEqual({ error: `Upload “${uploadId}” did not store the file “${other}”.` })
|
|
519
|
+
expect(await intentState(t, uploadId)).toEqual({ state: 'rejected', storageId: other })
|
|
520
|
+
expect(await stored(t, other)).toBe(true)
|
|
521
|
+
expect(await bindings(t)).toEqual([])
|
|
522
|
+
// Control: the declared bytes, stored after their intent, are claimed.
|
|
523
|
+
const fresh = await declare(t)
|
|
524
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
525
|
+
expect((await claimWith(t, fresh, own)).status).toBe(200)
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it('EC-685: the comparator reads the stored digest by its FORM — convex-test’s base64 (measured) and hex both match', async () => {
|
|
529
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
530
|
+
const t = backend()
|
|
531
|
+
const first = await declare(t)
|
|
532
|
+
const asStored = await storeFile(t, 400, 'image/png')
|
|
533
|
+
const stamped = await t.run(async (ctx) => ((await ctx.db.system.get(asStored as never)) as { sha256: string }).sha256)
|
|
534
|
+
expect(stamped).toMatch(/^[A-Za-z0-9+/]{43}=$/) // convex-test 0.0.54 fills `_storage.sha256` with base64 (`blobSha`)
|
|
535
|
+
expect((await claimWith(t, first, asStored)).status).toBe(200)
|
|
536
|
+
// The same digest in hex (`storage.getMetadata`'s documented form) reads the same.
|
|
537
|
+
const second = await declare(t)
|
|
538
|
+
const asHex = await storeFile(t, 400, 'image/png')
|
|
539
|
+
await setStoredDigest(t, asHex, (await sha256Of(400)).toUpperCase())
|
|
540
|
+
expect((await claimWith(t, second, asHex)).status).toBe(200)
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
it('EC-685: a stored digest in NEITHER form is refused visibly with its own reason, never read as a match', async () => {
|
|
544
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
545
|
+
const t = backend()
|
|
546
|
+
const uploadId = await declare(t)
|
|
547
|
+
const own = await storeFile(t, 400, 'image/png')
|
|
548
|
+
// A base64url spelling of the right digest is still neither form: refused, not guessed at.
|
|
549
|
+
const url = btoa(String.fromCharCode(...Array.from((await sha256Of(400)).match(/../g)!, (pair) => parseInt(pair, 16))))
|
|
550
|
+
.replace(/\+/g, '-')
|
|
551
|
+
.replace(/\//g, '_')
|
|
552
|
+
.replace(/=+$/, '')
|
|
553
|
+
await setStoredDigest(t, own, url)
|
|
554
|
+
const refused = await claimWith(t, uploadId, own)
|
|
555
|
+
expect(refused.status).toBe(422)
|
|
556
|
+
expect(await refused.json()).toEqual({
|
|
557
|
+
error: `The deployment's stored digest of “${own}” is in neither form the claim reads (64 hex or 44 base64 characters), so the upload cannot be verified.`,
|
|
558
|
+
})
|
|
559
|
+
expect(await intentState(t, uploadId)).toEqual({ state: 'rejected', storageId: own })
|
|
560
|
+
expect(await bindings(t)).toEqual([])
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('LEGACY (EC-685): an intent issued before uploads declared a digest is refused before any write — no crash, the row untouched', async () => {
|
|
564
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
565
|
+
const t = backend()
|
|
566
|
+
const issuedAt = '2026-10-26T12:00:00.000Z'
|
|
567
|
+
await t.run(async (ctx) => {
|
|
568
|
+
// A row as the previous release wrote it: no `sha256` column (the kind now requires one).
|
|
569
|
+
await ctx.db.insert('records', {
|
|
570
|
+
kind: 'ingestUpload',
|
|
571
|
+
recordId: 'upload_legacy',
|
|
572
|
+
data: { target: 'contact', field: 'cv', contentType: 'image/png', size: 500, state: 'issued', issuedAt },
|
|
573
|
+
key1: 'contact',
|
|
574
|
+
key2: 'issued',
|
|
575
|
+
at: issuedAt,
|
|
576
|
+
createdAt: issuedAt,
|
|
577
|
+
updatedAt: issuedAt,
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
const file = await storeFile(t, 400, 'image/png')
|
|
581
|
+
const refused = await claimWith(t, 'upload_legacy', file)
|
|
582
|
+
expect(refused.status).toBe(422)
|
|
583
|
+
expect(await refused.json()).toEqual({
|
|
584
|
+
error: 'Upload “upload_legacy” was issued before uploads declared their SHA-256 — upload the file again.',
|
|
585
|
+
})
|
|
586
|
+
expect(await intentState(t, 'upload_legacy')).toEqual({ state: 'issued', storageId: undefined })
|
|
587
|
+
expect(await bindings(t)).toEqual([])
|
|
588
|
+
expect(await stored(t, file)).toBe(true)
|
|
589
|
+
})
|
|
590
|
+
|
|
417
591
|
it('the binding row is flat — its stored nesting is far inside Convex’s limit', async () => {
|
|
418
592
|
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
419
593
|
const t = backend()
|
|
@@ -425,6 +599,85 @@ describe('the claim binds only this intent’s bytes; release deletes only bound
|
|
|
425
599
|
expect(measureMaxDepth(row).depth).toBe(2) // the row object → `data` → its string columns
|
|
426
600
|
expect(measureMaxDepth(row).depth).toBeLessThanOrEqual(CONVEX_MAX_NESTING_DEPTH)
|
|
427
601
|
})
|
|
602
|
+
|
|
603
|
+
// EC-695 — the claim is ALL-OR-NOTHING, in the engine: every claim judged, then all bound.
|
|
604
|
+
const intents = (t: ReturnType<typeof backend>) =>
|
|
605
|
+
t.run(async (ctx) =>
|
|
606
|
+
Object.fromEntries(
|
|
607
|
+
(await ctx.db.query('records').collect())
|
|
608
|
+
.filter((row) => row.kind === 'ingestUpload')
|
|
609
|
+
.map((row) => [row.recordId, (row.data as { state: string }).state]),
|
|
610
|
+
),
|
|
611
|
+
)
|
|
612
|
+
const claimDirect = (t: ReturnType<typeof backend>, claims: { uploadId: string; storageId: string }[]) =>
|
|
613
|
+
t.run((ctx) => claimIngestUploads(ctx, claims, 'contact', '2026-10-26T12:00:00.000Z'))
|
|
614
|
+
|
|
615
|
+
it('EC-695: a submission with one refused claim binds NOTHING — the claims before it stay issued; nothing is deleted', async () => {
|
|
616
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
617
|
+
const t = backend()
|
|
618
|
+
const [first, second, third] = [await declare(t), await declare(t), await declare(t)]
|
|
619
|
+
const [one, two] = [await storeFile(t, 400, 'image/png'), await storeFile(t, 400, 'image/png')]
|
|
620
|
+
const wrongType = await storeFile(t, 400, 'text/html') // the third's: the declared bytes, another type
|
|
621
|
+
const answer = await signedPost(t, {
|
|
622
|
+
...SUBMISSION,
|
|
623
|
+
uploads: [
|
|
624
|
+
{ uploadId: first, storageId: one },
|
|
625
|
+
{ uploadId: second, storageId: two },
|
|
626
|
+
{ uploadId: third, storageId: wrongType },
|
|
627
|
+
],
|
|
628
|
+
})
|
|
629
|
+
expect(answer.status).toBe(415)
|
|
630
|
+
expect(await bindings(t)).toEqual([]) // per-claim binding would leave the first two bound, with no row
|
|
631
|
+
expect(await intents(t)).toEqual({ [first]: 'issued', [second]: 'issued', [third]: 'rejected' })
|
|
632
|
+
expect([await stored(t, one), await stored(t, two), await stored(t, wrongType)]).toEqual([true, true, true])
|
|
633
|
+
expect((await t.run(async (ctx) => ctx.db.query('records').collect())).some((row) => row.kind === 'testSubmission')).toBe(false)
|
|
634
|
+
// Control: the corrected submission (the same two uploads) binds both, in claim order.
|
|
635
|
+
const retry = await signedPost(t, {
|
|
636
|
+
...SUBMISSION,
|
|
637
|
+
uploads: [
|
|
638
|
+
{ uploadId: first, storageId: one },
|
|
639
|
+
{ uploadId: second, storageId: two },
|
|
640
|
+
],
|
|
641
|
+
})
|
|
642
|
+
expect(retry.status).toBe(200)
|
|
643
|
+
expect((await bindings(t)).map((binding) => binding.uploadId).sort()).toEqual([first, second].sort())
|
|
644
|
+
expect(await intents(t)).toEqual({ [first]: 'claimed', [second]: 'claimed', [third]: 'rejected' })
|
|
645
|
+
})
|
|
646
|
+
|
|
647
|
+
it('EC-695: called past the wire, a repeated upload or a repeated stored file is refused (400) with NOTHING written', async () => {
|
|
648
|
+
env()['ELYTRA_INGEST_SECRET'] = SECRET
|
|
649
|
+
const t = backend()
|
|
650
|
+
const [first, second] = [await declare(t), await declare(t)]
|
|
651
|
+
const file = await storeFile(t, 400, 'image/png')
|
|
652
|
+
const another = await storeFile(t, 400, 'image/png') // the same declared bytes, a second stored file
|
|
653
|
+
for (const repeat of [another, file]) {
|
|
654
|
+
const sameUpload = await claimDirect(t, [
|
|
655
|
+
{ uploadId: first, storageId: file },
|
|
656
|
+
{ uploadId: first, storageId: repeat }, // the one intent, the same file or ANOTHER file it would also pass for
|
|
657
|
+
])
|
|
658
|
+
expect(sameUpload).toEqual({ ok: false, status: 400, message: 'Claim 2 names an upload an earlier claim of this submission names.' })
|
|
659
|
+
}
|
|
660
|
+
const sameFile = await claimDirect(t, [
|
|
661
|
+
{ uploadId: first, storageId: file },
|
|
662
|
+
{ uploadId: second, storageId: file },
|
|
663
|
+
])
|
|
664
|
+
expect(sameFile).toEqual({ ok: false, status: 400, message: 'Claim 2 names a stored file an earlier claim of this submission names.' })
|
|
665
|
+
expect(await bindings(t)).toEqual([]) // deferred binds without the repeat check: two bindings, or one overwritten
|
|
666
|
+
expect(await intents(t)).toEqual({ [first]: 'issued', [second]: 'issued' })
|
|
667
|
+
expect([await stored(t, file), await stored(t, another)]).toEqual([true, true])
|
|
668
|
+
// Control: each claim once binds.
|
|
669
|
+
expect(await claimDirect(t, [{ uploadId: first, storageId: file }])).toEqual({
|
|
670
|
+
ok: true,
|
|
671
|
+
files: [{ storageId: file, field: 'cv', contentType: 'image/png', size: 400 }],
|
|
672
|
+
})
|
|
673
|
+
expect(await bindings(t)).toEqual([{ storageId: file, uploadId: first }])
|
|
674
|
+
})
|
|
675
|
+
|
|
676
|
+
it('EC-695: no claims is an empty success that writes nothing', async () => {
|
|
677
|
+
const t = backend()
|
|
678
|
+
expect(await claimDirect(t, [])).toEqual({ ok: true, files: [] })
|
|
679
|
+
expect(await t.run(async (ctx) => (await ctx.db.query('records').collect()).length)).toBe(0)
|
|
680
|
+
})
|
|
428
681
|
})
|
|
429
682
|
|
|
430
683
|
describe('the site’s route → the receiver, end to end (EC-617)', () => {
|