Uploads & R2 — QA Plan
Presign — auth + policy
| Step | Expected |
|---|---|
| Presign without auth | 401 from the Clerk guard. |
Presign as a Clerk user with no active membership for orgId | 403 Not an active member of this organization. |
Presign ownerType: progress_photo, mimeType: image/jpeg, fileSize: 5_000_000 | 200 with { uploadUrl, uploadId, expiresIn: 600 }. |
Presign ownerType: progress_photo, mimeType: video/mp4 | 400 Unsupported file type (videos not allowed for photos). |
Presign ownerType: progress_photo, fileSize: 20 * 1024 * 1024 | 400 File too large (max 15MB). |
Presign ownerType: exercise_comment, mimeType: video/mp4, fileSize: 40 * 1024 * 1024 | 200. |
Presign with fileSize > 100MB | DTO validation 400. |
Presign with an unknown ownerType | DTO IsIn 400. |
Direct PUT to R2
| Step | Expected |
|---|---|
PUT bytes with the exact Content-Type declared at presign | 200 from R2. |
PUT bytes with a different Content-Type | 403 from R2 (signature mismatch). |
| PUT to the URL > 10 min after presign | 403 from R2 (signature expired). |
PUT a body that exceeds fileSize | R2 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
| Step | Expected |
|---|---|
Parent write with valid uploadId after successful PUT | Parent row created with r2_key; Redis upload:<id> deleted. |
Parent write with someone else’s uploadId | 403 Upload does not belong to caller. |
Parent write with an expired uploadId | 400 Upload not found or expired. |
Parent write referencing a never-PUT uploadId | 400 File was not uploaded (HEAD fails). |
Parent write with the same uploadId twice | Second call returns 400 — Redis key already consumed. |
Token-gated public presign (forms)
| Step | Expected |
|---|---|
POST /forms/by-token/<valid>/signature-upload | 200 with a presigned PUT URL constrained to image/png. |
Same endpoint with <invalid> token | 404. |
| Same endpoint with an expired token | 410 / 404 (per forms.service policy). |
| Token reuse after signature already submitted | 409. |
| Presigned URL allows PUT of arbitrary MIME | R2 rejects non-PNG bodies (Content-Type enforced by signature). |
Presigned GET
| Step | Expected |
|---|---|
First getPresignedUrlCached(key, 3600) | R2 SDK signs a new URL; Redis cache populated with 3300s TTL. |
| Second call within TTL | Returns cached URL — no SDK call. |
invalidatePresignedUrl(key, expiresIn) then call again | Fresh 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
| Step | Expected |
|---|---|
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 object | Cache key includes the bucket name, so it does not collide with same-key reads from the default bucket. |
| Delete an immutable compliance object | R2 returns 403 (bucket policy). |
Unauthorized access attempts
| Step | Expected |
|---|---|
Guess another user’s r2Key and try to GET directly | R2 returns 403 — bucket is private; only presigned reads work. |
| Try to access a presigned URL from a different IP after it was issued | Works — R2 presigned URLs are not IP-bound (by design). |
Try to enumerate progress-photos/<otherUserId>/... via the API | Parent 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).