Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesUploads R2Uploads & R2 — QA Plan

Uploads & R2 — QA Plan

Presign — auth + policy

StepExpected
Presign without auth401 from the Clerk guard.
Presign as a Clerk user with no active membership for orgId403 Not an active member of this organization.
Presign ownerType: progress_photo, mimeType: image/jpeg, fileSize: 5_000_000200 with { uploadUrl, uploadId, expiresIn: 600 }.
Presign ownerType: progress_photo, mimeType: video/mp4400 Unsupported file type (videos not allowed for photos).
Presign ownerType: progress_photo, fileSize: 20 * 1024 * 1024400 File too large (max 15MB).
Presign ownerType: exercise_comment, mimeType: video/mp4, fileSize: 40 * 1024 * 1024200.
Presign with fileSize > 100MBDTO validation 400.
Presign with an unknown ownerTypeDTO IsIn 400.

Direct PUT to R2

StepExpected
PUT bytes with the exact Content-Type declared at presign200 from R2.
PUT bytes with a different Content-Type403 from R2 (signature mismatch).
PUT to the URL > 10 min after presign403 from R2 (signature expired).
PUT a body that exceeds fileSizeR2 accepts but the parent write may still find the file; size enforcement is presign-only. (Acceptable: oversized uploads are economic, not security.)

Parent write — staging consumption

StepExpected
Parent write with valid uploadId after successful PUTParent row created with r2_key; Redis upload:<id> deleted.
Parent write with someone else’s uploadId403 Upload does not belong to caller.
Parent write with an expired uploadId400 Upload not found or expired.
Parent write referencing a never-PUT uploadId400 File was not uploaded (HEAD fails).
Parent write with the same uploadId twiceSecond call returns 400 — Redis key already consumed.

Token-gated public presign (forms)

StepExpected
POST /forms/by-token/<valid>/signature-upload200 with a presigned PUT URL constrained to image/png.
Same endpoint with <invalid> token404.
Same endpoint with an expired token410 / 404 (per forms.service policy).
Token reuse after signature already submitted409.
Presigned URL allows PUT of arbitrary MIMER2 rejects non-PNG bodies (Content-Type enforced by signature).

Presigned GET

StepExpected
First getPresignedUrlCached(key, 3600)R2 SDK signs a new URL; Redis cache populated with 3300s TTL.
Second call within TTLReturns cached URL — no SDK call.
invalidatePresignedUrl(key, expiresIn) then call againFresh URL minted.
Cached URL hit immediately before its R2 expiry (< 5 min remaining)Should never occur — cache TTL leaves a 5-min safety margin.
getPresignedUrl(key, 60) (short-lived)Always fresh — used for parent-write previews.

Compliance bucket

StepExpected
Read with opts: { bucket: r2.getComplianceBucket() }Returns objects from R2_COMPLIANCE_BUCKET_NAME when set, otherwise the default bucket.
Cached GET URL for a compliance objectCache key includes the bucket name, so it does not collide with same-key reads from the default bucket.
Delete an immutable compliance objectR2 returns 403 (bucket policy).

Unauthorized access attempts

StepExpected
Guess another user’s r2Key and try to GET directlyR2 returns 403 — bucket is private; only presigned reads work.
Try to access a presigned URL from a different IP after it was issuedWorks — R2 presigned URLs are not IP-bound (by design).
Try to enumerate progress-photos/<otherUserId>/... via the APIParent endpoints check ownership; presigned GETs only mint URLs for keys the caller can read.

Negative / robustness

  • Redis down → presign returns 500; existing presigned URLs continue to work; cached reads miss and re-sign.
  • R2 down → presign succeeds (SDK signs offline); PUTs fail at the network layer; cached GETs remain valid.
  • Clock skew between API and R2 > a few minutes → presigned URLs may be rejected by R2. Sync NTP.

Observability

  • Each presign emits a Pino info log: presigned upload <ownerType> <userId>.
  • R2 errors propagate to the request handler; Sentry captures.
  • No PostHog event for uploads today (gap).