Skip to main content

max / makenotwork

17.7 KB · 471 lines History Blame Raw
1 //! Route-layer contract tests for `routes::storage::images`, the four handlers
2 //! behind a project cover and an item cover.
3 //!
4 //! Five suites touch these routes in passing (`storage`, `creator_media`,
5 //! `scanning`, `video`, `tier_enforcement`) and none of them is their contract
6 //! test: between them they check that a cover upload works, that it does not
7 //! flip the shared `items.scan_status`, and that the item route writes
8 //! `cover_image_url` as well as the key. What nobody checks is the part of these
9 //! handlers that exists to stop them destroying a file.
10 //!
11 //! A cover is presigned to a `staging/{uuid}` key, and a staging key carries no
12 //! user, project or item in its path. There is nothing to do a prefix check
13 //! against, so ownership of the key is proved entirely by the `pending_uploads`
14 //! row written at presign. That single lookup is the only thing standing between
15 //! "confirm this key" and one creator handing another creator's in-flight upload
16 //! to the deletion queue, and it is deliberately placed before the size-reject
17 //! path for exactly that reason.
18 //!
19 //! The other two guards here are both scar tissue with a run number on them.
20 //! Re-confirming the key a project already displays must never queue the live
21 //! object for deletion (Run #6), and the path that returns early must still
22 //! clear the pending row or the orphan reaper deletes the live object a day
23 //! later instead (Run #7).
24 //!
25 //! Measuring that turned up something worth writing down: the `pending_uploads`
26 //! gate runs BEFORE the idempotency check, and a successful confirm consumes the
27 //! row. So an ordinary retry (a dropped response, a double-click) is refused at
28 //! the gate with a 400 and never reaches the idempotency branch at all. The
29 //! branch is not dead code, it covers the narrower window where the row update
30 //! committed and the pending-row cleanup did not, and it is reachable exactly
31 //! then. Both are tested below, because they are two different guards arriving
32 //! at the same requirement: whatever happens, the image on display survives.
33 //!
34 //! Replacement is the third: the old key is enqueued for deletion inside the same
35 //! transaction as the row update, and the storage counter moves by the delta
36 //! rather than by the sum, so a creator who replaces a cover ten times is charged
37 //! for one.
38 //!
39 //! Delete this file and a stranger could aim the deletion queue at somebody
40 //! else's upload, a retried confirm could delete the cover it was confirming, and
41 //! replacing an image could bill for both copies.
42
43 use crate::harness::TestHarness;
44 use serde_json::{Value, json};
45
46 /// A trusted creator with a project and an item, on a tier with room to upload.
47 async fn creator_with_project(h: &mut TestHarness, username: &str) -> (String, String) {
48 let setup = h.create_creator_with_item(username, "audio", 0).await;
49 h.trust_user(setup.user_id).await;
50 h.grant_tier(setup.user_id, "small_files").await;
51 (setup.project_id, setup.item_id)
52 }
53
54 /// Presign a project cover and return the staging key.
55 async fn presign_project_cover(h: &mut TestHarness, project_id: &str, file_name: &str) -> String {
56 let body = json!({
57 "project_id": project_id,
58 "file_name": file_name,
59 "content_type": "image/png",
60 });
61 let resp = h
62 .client
63 .post_json("/api/projects/image/presign", &body.to_string())
64 .await;
65 assert_eq!(resp.status.as_u16(), 200, "presign failed: {}", resp.text);
66 let data: Value = resp.json();
67 data["s3_key"]
68 .as_str()
69 .expect("presign returns an s3_key")
70 .to_string()
71 }
72
73 /// Put bytes at the key, as the browser's PUT to the presigned URL would.
74 fn upload_bytes(h: &TestHarness, key: &str, size: usize) {
75 h.storage
76 .as_ref()
77 .expect("with_storage provides an in-memory bucket")
78 .put(key, vec![b'x'; size]);
79 }
80
81 async fn confirm_project_cover(
82 h: &mut TestHarness,
83 project_id: &str,
84 key: &str,
85 ) -> crate::harness::client::TestResponse {
86 let body = json!({ "project_id": project_id, "s3_key": key });
87 h.client
88 .post_json("/api/projects/image/confirm", &body.to_string())
89 .await
90 }
91
92 async fn deletions_for(h: &TestHarness, key: &str) -> i64 {
93 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1")
94 .bind(key)
95 .fetch_one(&h.db)
96 .await
97 .expect("count queued deletions")
98 }
99
100 async fn pending_upload_rows(h: &TestHarness, key: &str) -> i64 {
101 sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_uploads WHERE s3_key = $1")
102 .bind(key)
103 .fetch_one(&h.db)
104 .await
105 .expect("count pending uploads")
106 }
107
108 async fn storage_used(h: &TestHarness, username: &str) -> i64 {
109 sqlx::query_scalar::<_, i64>("SELECT storage_used_bytes FROM users WHERE username = $1")
110 .bind(username)
111 .fetch_one(&h.db)
112 .await
113 .expect("read storage counter")
114 }
115
116 async fn cover_url(h: &TestHarness, project_id: &str) -> Option<String> {
117 sqlx::query_scalar::<_, Option<String>>(
118 "SELECT cover_image_url FROM projects WHERE id = $1::uuid",
119 )
120 .bind(project_id)
121 .fetch_one(&h.db)
122 .await
123 .expect("read project cover url")
124 }
125
126 /// The staging key is unguessable in practice but not secret, and it names
127 /// nobody, so the `pending_uploads` lookup is the entire authorization. A
128 /// creator confirming a key minted for someone else's upload must be refused,
129 /// and, because the gate sits before the size-reject path, the object they
130 /// pointed at must not be queued for deletion on the way out.
131 ///
132 /// This is the difference between a rejected request and a creator losing an
133 /// upload because a stranger typed its key.
134 #[tokio::test]
135 async fn confirming_a_key_minted_for_another_creator_is_refused_and_deletes_nothing() {
136 let mut h = TestHarness::with_storage().await;
137
138 let (victim_project, _) = creator_with_project(&mut h, "coverowner").await;
139 let victim_key = presign_project_cover(&mut h, &victim_project, "mine.png").await;
140 upload_bytes(&h, &victim_key, 2048);
141 h.client.post_form("/logout", "").await;
142
143 let (thief_project, _) = creator_with_project(&mut h, "coverthief").await;
144 let resp = confirm_project_cover(&mut h, &thief_project, &victim_key).await;
145
146 assert_eq!(
147 resp.status.as_u16(),
148 400,
149 "a key with no pending_uploads row for this user is not confirmable: {}",
150 resp.text
151 );
152 assert_eq!(
153 deletions_for(&h, &victim_key).await,
154 0,
155 "the refusal must happen before the size-reject path, or a stranger can \
156 aim the deletion queue at an in-flight upload"
157 );
158 assert_eq!(
159 pending_upload_rows(&h, &victim_key).await,
160 1,
161 "and the owner's claim on the key is untouched"
162 );
163 assert_eq!(
164 cover_url(&h, &thief_project).await,
165 None,
166 "nothing was written to the caller's own project either"
167 );
168 }
169
170 /// The ordinary retry: a dropped response, a double-click, a client that resends.
171 /// The key has already been consumed, so the `pending_uploads` gate refuses it
172 /// before the idempotency branch is consulted.
173 ///
174 /// The status is the least interesting assertion here. What Run #6 was about is
175 /// the second one: the object the request names is the one the project is
176 /// currently displaying, and a refusal that queued it for deletion would delete
177 /// the live cover on a retry that changed nothing.
178 #[tokio::test]
179 async fn a_retried_confirm_is_refused_without_touching_the_live_cover() {
180 let mut h = TestHarness::with_storage().await;
181 let (project_id, _) = creator_with_project(&mut h, "coverretry").await;
182
183 let key = presign_project_cover(&mut h, &project_id, "cover.png").await;
184 upload_bytes(&h, &key, 4096);
185 let first = confirm_project_cover(&mut h, &project_id, &key).await;
186 assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text);
187 let url = cover_url(&h, &project_id)
188 .await
189 .expect("the cover url is written");
190 let charged = storage_used(&h, "coverretry").await;
191
192 assert_eq!(
193 pending_upload_rows(&h, &key).await,
194 0,
195 "a successful confirm consumes the key's claim"
196 );
197
198 let again = confirm_project_cover(&mut h, &project_id, &key).await;
199 assert_eq!(
200 again.status.as_u16(),
201 400,
202 "the key is spent, so the gate refuses before anything else runs: {}",
203 again.text
204 );
205 assert_eq!(
206 deletions_for(&h, &key).await,
207 0,
208 "and the object the project is displaying is not queued for deletion (Run #6)"
209 );
210 assert_eq!(
211 cover_url(&h, &project_id).await.as_deref(),
212 Some(url.as_str()),
213 "the project still shows the same image"
214 );
215 assert_eq!(
216 storage_used(&h, "coverretry").await,
217 charged,
218 "and is still charged once"
219 );
220 }
221
222 /// The window the idempotency branch actually serves: the transaction committed
223 /// the new cover, and the pending-row cleanup after it did not run. The row and
224 /// the live URL both name the same key, which is a state no ordinary request can
225 /// produce, so it is set up directly.
226 ///
227 /// A confirm arriving then must recognise the key as the one already on display
228 /// and return it, rather than treating it as a replacement, which would charge
229 /// storage a second time and queue the live object for deletion as the "old"
230 /// one. It must also clear the pending row on the way out, or the orphan reaper
231 /// deletes that object 24 hours later (Run #7).
232 #[tokio::test]
233 async fn a_confirm_that_lands_on_the_cover_already_shown_returns_it_and_deletes_nothing() {
234 let mut h = TestHarness::with_storage().await;
235 let (project_id, _) = creator_with_project(&mut h, "covercrash").await;
236
237 let key = presign_project_cover(&mut h, &project_id, "cover.png").await;
238 upload_bytes(&h, &key, 4096);
239 let first = confirm_project_cover(&mut h, &project_id, &key).await;
240 assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text);
241 let url = cover_url(&h, &project_id)
242 .await
243 .expect("the cover url is written");
244 let charged = storage_used(&h, "covercrash").await;
245
246 // Re-create the state a crash between the commit and the cleanup leaves.
247 let user_id: makenotwork::db::UserId =
248 sqlx::query_scalar("SELECT id FROM users WHERE username = 'covercrash'")
249 .fetch_one(&h.db)
250 .await
251 .expect("read the creator id");
252 sqlx::query("INSERT INTO pending_uploads (user_id, s3_key, bucket) VALUES ($1, $2, 'main')")
253 .bind(user_id)
254 .bind(&key)
255 .execute(&h.db)
256 .await
257 .expect("restore the pending row a crash would have left");
258
259 let again = confirm_project_cover(&mut h, &project_id, &key).await;
260 assert_eq!(
261 again.status.as_u16(),
262 200,
263 "the key already on display is confirmed, not re-applied: {}",
264 again.text
265 );
266 let body: Value = again.json();
267 assert_eq!(
268 body["image_url"].as_str(),
269 Some(url.as_str()),
270 "and the answer is the URL the project already has"
271 );
272
273 assert_eq!(
274 deletions_for(&h, &key).await,
275 0,
276 "the live object must not be queued as the replaced one (Run #6)"
277 );
278 assert_eq!(
279 pending_upload_rows(&h, &key).await,
280 0,
281 "and the stale row is cleared, or the reaper deletes the live cover (Run #7)"
282 );
283 assert_eq!(
284 storage_used(&h, "covercrash").await,
285 charged,
286 "one image on display, charged once"
287 );
288 }
289
290 /// Replacing a cover charges the difference between the two files, not their
291 /// sum, and hands the old key to the deletion queue. A creator iterating on
292 /// artwork does it many times; billing the sum would exhaust their quota with
293 /// one image on display.
294 #[tokio::test]
295 async fn replacing_a_cover_charges_the_delta_and_queues_the_old_key() {
296 let mut h = TestHarness::with_storage().await;
297 let (project_id, _) = creator_with_project(&mut h, "coverswap").await;
298
299 let first_key = presign_project_cover(&mut h, &project_id, "first.png").await;
300 upload_bytes(&h, &first_key, 4000);
301 let resp = confirm_project_cover(&mut h, &project_id, &first_key).await;
302 assert_eq!(resp.status.as_u16(), 200, "first confirm: {}", resp.text);
303 let after_first = storage_used(&h, "coverswap").await;
304 assert_eq!(after_first, 4000, "the first image is charged in full");
305
306 let second_key = presign_project_cover(&mut h, &project_id, "second.png").await;
307 upload_bytes(&h, &second_key, 6000);
308 let resp = confirm_project_cover(&mut h, &project_id, &second_key).await;
309 assert_eq!(resp.status.as_u16(), 200, "replace confirm: {}", resp.text);
310
311 assert_eq!(
312 storage_used(&h, "coverswap").await,
313 6000,
314 "a replacement charges the delta: one image on display, one image billed"
315 );
316 assert!(
317 deletions_for(&h, &first_key).await > 0,
318 "the replaced object is queued for deletion rather than left to the reaper"
319 );
320 assert!(
321 cover_url(&h, &project_id)
322 .await
323 .is_some_and(|u| u.contains(&second_key)),
324 "and the project displays the new image"
325 );
326 }
327
328 /// Ownership of the target, checked on both halves of the flow. Presign is the
329 /// half that matters most: it is what mints the `pending_uploads` row the
330 /// confirm gate reads, so a stranger who could presign against someone else's
331 /// project would hold a key the confirm gate then accepts.
332 #[tokio::test]
333 async fn a_strangers_project_is_refused_at_presign_and_at_confirm() {
334 let mut h = TestHarness::with_storage().await;
335
336 let (owned_project, _) = creator_with_project(&mut h, "coverwall").await;
337 h.client.post_form("/logout", "").await;
338 let (own_project, _) = creator_with_project(&mut h, "coverintruder").await;
339
340 let body = json!({
341 "project_id": owned_project,
342 "file_name": "theirs.png",
343 "content_type": "image/png",
344 });
345 let resp = h
346 .client
347 .post_json("/api/projects/image/presign", &body.to_string())
348 .await;
349 assert_eq!(
350 resp.status.as_u16(),
351 403,
352 "a stranger cannot mint an upload slot against another creator's project: {}",
353 resp.text
354 );
355
356 // A key of the intruder's own, aimed at the project they do not own.
357 let own_key = presign_project_cover(&mut h, &own_project, "ok.png").await;
358 upload_bytes(&h, &own_key, 1024);
359 let resp = confirm_project_cover(&mut h, &owned_project, &own_key).await;
360 assert_eq!(
361 resp.status.as_u16(),
362 403,
363 "nor confirm into it with a key they legitimately own: {}",
364 resp.text
365 );
366 assert_eq!(
367 cover_url(&h, &owned_project).await,
368 None,
369 "the target project is untouched"
370 );
371 }
372
373 /// A project id that resolves to nothing is a 404, distinct from the 403 a real
374 /// project belonging to someone else gets. Collapsing the two would turn the
375 /// endpoint into an oracle for which project ids exist.
376 #[tokio::test]
377 async fn an_unknown_project_is_not_found_rather_than_forbidden() {
378 let mut h = TestHarness::with_storage().await;
379 creator_with_project(&mut h, "covermissing").await;
380
381 let body = json!({
382 "project_id": "ce9f7087-d503-4cf1-8f80-b2080508e5fe",
383 "file_name": "ghost.png",
384 "content_type": "image/png",
385 });
386 let resp = h
387 .client
388 .post_json("/api/projects/image/presign", &body.to_string())
389 .await;
390
391 assert_eq!(
392 resp.status.as_u16(),
393 404,
394 "an id that matches no project is not found: {}",
395 resp.text
396 );
397 }
398
399 /// Both image routes accept only cover-shaped uploads, and the content type is
400 /// signed into the presigned URL. A type outside that set must be refused before
401 /// a URL exists, on the item route as well as the project one.
402 #[tokio::test]
403 async fn the_image_routes_refuse_a_non_image_upload() {
404 let mut h = TestHarness::with_storage().await;
405 let (project_id, item_id) = creator_with_project(&mut h, "coverwrongtype").await;
406
407 let body = json!({
408 "project_id": project_id,
409 "file_name": "cover.png",
410 "content_type": "audio/mpeg",
411 });
412 let resp = h
413 .client
414 .post_json("/api/projects/image/presign", &body.to_string())
415 .await;
416 assert_eq!(
417 resp.status.as_u16(),
418 400,
419 "audio is not a project cover: {}",
420 resp.text
421 );
422
423 let body = json!({
424 "item_id": item_id,
425 "file_name": "cover.mp3",
426 "content_type": "image/png",
427 });
428 let resp = h
429 .client
430 .post_json("/api/items/image/presign", &body.to_string())
431 .await;
432 assert_eq!(
433 resp.status.as_u16(),
434 400,
435 "the extension has to agree with the declared image type: {}",
436 resp.text
437 );
438 }
439
440 /// The item half carries the same staging-key gate as the project half, and it
441 /// is a separate code path with its own copy of the lookup. A regression that
442 /// removed one would leave the other passing.
443 #[tokio::test]
444 async fn the_item_route_also_refuses_a_key_it_did_not_mint() {
445 let mut h = TestHarness::with_storage().await;
446
447 let (project_id, _) = creator_with_project(&mut h, "itemcoverowner").await;
448 let victim_key = presign_project_cover(&mut h, &project_id, "mine.png").await;
449 upload_bytes(&h, &victim_key, 2048);
450 h.client.post_form("/logout", "").await;
451
452 let (_, thief_item) = creator_with_project(&mut h, "itemcoverthief").await;
453 let body = json!({ "item_id": thief_item, "s3_key": victim_key });
454 let resp = h
455 .client
456 .post_json("/api/items/image/confirm", &body.to_string())
457 .await;
458
459 assert_eq!(
460 resp.status.as_u16(),
461 400,
462 "the item route proves key ownership the same way: {}",
463 resp.text
464 );
465 assert_eq!(
466 deletions_for(&h, &victim_key).await,
467 0,
468 "and refuses before anything can be queued for deletion"
469 );
470 }
471