//! Route-layer contract tests for `routes::storage::images`, the four handlers //! behind a project cover and an item cover. //! //! Five suites touch these routes in passing (`storage`, `creator_media`, //! `scanning`, `video`, `tier_enforcement`) and none of them is their contract //! test: between them they check that a cover upload works, that it does not //! flip the shared `items.scan_status`, and that the item route writes //! `cover_image_url` as well as the key. What nobody checks is the part of these //! handlers that exists to stop them destroying a file. //! //! A cover is presigned to a `staging/{uuid}` key, and a staging key carries no //! user, project or item in its path. There is nothing to do a prefix check //! against, so ownership of the key is proved entirely by the `pending_uploads` //! row written at presign. That single lookup is the only thing standing between //! "confirm this key" and one creator handing another creator's in-flight upload //! to the deletion queue, and it is deliberately placed before the size-reject //! path for exactly that reason. //! //! The other two guards here are both scar tissue with a run number on them. //! Re-confirming the key a project already displays must never queue the live //! object for deletion (Run #6), and the path that returns early must still //! clear the pending row or the orphan reaper deletes the live object a day //! later instead (Run #7). //! //! Measuring that turned up something worth writing down: the `pending_uploads` //! gate runs BEFORE the idempotency check, and a successful confirm consumes the //! row. So an ordinary retry (a dropped response, a double-click) is refused at //! the gate with a 400 and never reaches the idempotency branch at all. The //! branch is not dead code, it covers the narrower window where the row update //! committed and the pending-row cleanup did not, and it is reachable exactly //! then. Both are tested below, because they are two different guards arriving //! at the same requirement: whatever happens, the image on display survives. //! //! Replacement is the third: the old key is enqueued for deletion inside the same //! transaction as the row update, and the storage counter moves by the delta //! rather than by the sum, so a creator who replaces a cover ten times is charged //! for one. //! //! Delete this file and a stranger could aim the deletion queue at somebody //! else's upload, a retried confirm could delete the cover it was confirming, and //! replacing an image could bill for both copies. use crate::harness::TestHarness; use serde_json::{Value, json}; /// A trusted creator with a project and an item, on a tier with room to upload. async fn creator_with_project(h: &mut TestHarness, username: &str) -> (String, String) { let setup = h.create_creator_with_item(username, "audio", 0).await; h.trust_user(setup.user_id).await; h.grant_tier(setup.user_id, "small_files").await; (setup.project_id, setup.item_id) } /// Presign a project cover and return the staging key. async fn presign_project_cover(h: &mut TestHarness, project_id: &str, file_name: &str) -> String { let body = json!({ "project_id": project_id, "file_name": file_name, "content_type": "image/png", }); let resp = h .client .post_json("/api/projects/image/presign", &body.to_string()) .await; assert_eq!(resp.status.as_u16(), 200, "presign failed: {}", resp.text); let data: Value = resp.json(); data["s3_key"] .as_str() .expect("presign returns an s3_key") .to_string() } /// Put bytes at the key, as the browser's PUT to the presigned URL would. fn upload_bytes(h: &TestHarness, key: &str, size: usize) { h.storage .as_ref() .expect("with_storage provides an in-memory bucket") .put(key, vec![b'x'; size]); } async fn confirm_project_cover( h: &mut TestHarness, project_id: &str, key: &str, ) -> crate::harness::client::TestResponse { let body = json!({ "project_id": project_id, "s3_key": key }); h.client .post_json("/api/projects/image/confirm", &body.to_string()) .await } async fn deletions_for(h: &TestHarness, key: &str) -> i64 { sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1") .bind(key) .fetch_one(&h.db) .await .expect("count queued deletions") } async fn pending_upload_rows(h: &TestHarness, key: &str) -> i64 { sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_uploads WHERE s3_key = $1") .bind(key) .fetch_one(&h.db) .await .expect("count pending uploads") } async fn storage_used(h: &TestHarness, username: &str) -> i64 { sqlx::query_scalar::<_, i64>("SELECT storage_used_bytes FROM users WHERE username = $1") .bind(username) .fetch_one(&h.db) .await .expect("read storage counter") } async fn cover_url(h: &TestHarness, project_id: &str) -> Option { sqlx::query_scalar::<_, Option>( "SELECT cover_image_url FROM projects WHERE id = $1::uuid", ) .bind(project_id) .fetch_one(&h.db) .await .expect("read project cover url") } /// The staging key is unguessable in practice but not secret, and it names /// nobody, so the `pending_uploads` lookup is the entire authorization. A /// creator confirming a key minted for someone else's upload must be refused, /// and, because the gate sits before the size-reject path, the object they /// pointed at must not be queued for deletion on the way out. /// /// This is the difference between a rejected request and a creator losing an /// upload because a stranger typed its key. #[tokio::test] async fn confirming_a_key_minted_for_another_creator_is_refused_and_deletes_nothing() { let mut h = TestHarness::with_storage().await; let (victim_project, _) = creator_with_project(&mut h, "coverowner").await; let victim_key = presign_project_cover(&mut h, &victim_project, "mine.png").await; upload_bytes(&h, &victim_key, 2048); h.client.post_form("/logout", "").await; let (thief_project, _) = creator_with_project(&mut h, "coverthief").await; let resp = confirm_project_cover(&mut h, &thief_project, &victim_key).await; assert_eq!( resp.status.as_u16(), 400, "a key with no pending_uploads row for this user is not confirmable: {}", resp.text ); assert_eq!( deletions_for(&h, &victim_key).await, 0, "the refusal must happen before the size-reject path, or a stranger can \ aim the deletion queue at an in-flight upload" ); assert_eq!( pending_upload_rows(&h, &victim_key).await, 1, "and the owner's claim on the key is untouched" ); assert_eq!( cover_url(&h, &thief_project).await, None, "nothing was written to the caller's own project either" ); } /// The ordinary retry: a dropped response, a double-click, a client that resends. /// The key has already been consumed, so the `pending_uploads` gate refuses it /// before the idempotency branch is consulted. /// /// The status is the least interesting assertion here. What Run #6 was about is /// the second one: the object the request names is the one the project is /// currently displaying, and a refusal that queued it for deletion would delete /// the live cover on a retry that changed nothing. #[tokio::test] async fn a_retried_confirm_is_refused_without_touching_the_live_cover() { let mut h = TestHarness::with_storage().await; let (project_id, _) = creator_with_project(&mut h, "coverretry").await; let key = presign_project_cover(&mut h, &project_id, "cover.png").await; upload_bytes(&h, &key, 4096); let first = confirm_project_cover(&mut h, &project_id, &key).await; assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text); let url = cover_url(&h, &project_id) .await .expect("the cover url is written"); let charged = storage_used(&h, "coverretry").await; assert_eq!( pending_upload_rows(&h, &key).await, 0, "a successful confirm consumes the key's claim" ); let again = confirm_project_cover(&mut h, &project_id, &key).await; assert_eq!( again.status.as_u16(), 400, "the key is spent, so the gate refuses before anything else runs: {}", again.text ); assert_eq!( deletions_for(&h, &key).await, 0, "and the object the project is displaying is not queued for deletion (Run #6)" ); assert_eq!( cover_url(&h, &project_id).await.as_deref(), Some(url.as_str()), "the project still shows the same image" ); assert_eq!( storage_used(&h, "coverretry").await, charged, "and is still charged once" ); } /// The window the idempotency branch actually serves: the transaction committed /// the new cover, and the pending-row cleanup after it did not run. The row and /// the live URL both name the same key, which is a state no ordinary request can /// produce, so it is set up directly. /// /// A confirm arriving then must recognise the key as the one already on display /// and return it, rather than treating it as a replacement, which would charge /// storage a second time and queue the live object for deletion as the "old" /// one. It must also clear the pending row on the way out, or the orphan reaper /// deletes that object 24 hours later (Run #7). #[tokio::test] async fn a_confirm_that_lands_on_the_cover_already_shown_returns_it_and_deletes_nothing() { let mut h = TestHarness::with_storage().await; let (project_id, _) = creator_with_project(&mut h, "covercrash").await; let key = presign_project_cover(&mut h, &project_id, "cover.png").await; upload_bytes(&h, &key, 4096); let first = confirm_project_cover(&mut h, &project_id, &key).await; assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text); let url = cover_url(&h, &project_id) .await .expect("the cover url is written"); let charged = storage_used(&h, "covercrash").await; // Re-create the state a crash between the commit and the cleanup leaves. let user_id: makenotwork::db::UserId = sqlx::query_scalar("SELECT id FROM users WHERE username = 'covercrash'") .fetch_one(&h.db) .await .expect("read the creator id"); sqlx::query("INSERT INTO pending_uploads (user_id, s3_key, bucket) VALUES ($1, $2, 'main')") .bind(user_id) .bind(&key) .execute(&h.db) .await .expect("restore the pending row a crash would have left"); let again = confirm_project_cover(&mut h, &project_id, &key).await; assert_eq!( again.status.as_u16(), 200, "the key already on display is confirmed, not re-applied: {}", again.text ); let body: Value = again.json(); assert_eq!( body["image_url"].as_str(), Some(url.as_str()), "and the answer is the URL the project already has" ); assert_eq!( deletions_for(&h, &key).await, 0, "the live object must not be queued as the replaced one (Run #6)" ); assert_eq!( pending_upload_rows(&h, &key).await, 0, "and the stale row is cleared, or the reaper deletes the live cover (Run #7)" ); assert_eq!( storage_used(&h, "covercrash").await, charged, "one image on display, charged once" ); } /// Replacing a cover charges the difference between the two files, not their /// sum, and hands the old key to the deletion queue. A creator iterating on /// artwork does it many times; billing the sum would exhaust their quota with /// one image on display. #[tokio::test] async fn replacing_a_cover_charges_the_delta_and_queues_the_old_key() { let mut h = TestHarness::with_storage().await; let (project_id, _) = creator_with_project(&mut h, "coverswap").await; let first_key = presign_project_cover(&mut h, &project_id, "first.png").await; upload_bytes(&h, &first_key, 4000); let resp = confirm_project_cover(&mut h, &project_id, &first_key).await; assert_eq!(resp.status.as_u16(), 200, "first confirm: {}", resp.text); let after_first = storage_used(&h, "coverswap").await; assert_eq!(after_first, 4000, "the first image is charged in full"); let second_key = presign_project_cover(&mut h, &project_id, "second.png").await; upload_bytes(&h, &second_key, 6000); let resp = confirm_project_cover(&mut h, &project_id, &second_key).await; assert_eq!(resp.status.as_u16(), 200, "replace confirm: {}", resp.text); assert_eq!( storage_used(&h, "coverswap").await, 6000, "a replacement charges the delta: one image on display, one image billed" ); assert!( deletions_for(&h, &first_key).await > 0, "the replaced object is queued for deletion rather than left to the reaper" ); assert!( cover_url(&h, &project_id) .await .is_some_and(|u| u.contains(&second_key)), "and the project displays the new image" ); } /// Ownership of the target, checked on both halves of the flow. Presign is the /// half that matters most: it is what mints the `pending_uploads` row the /// confirm gate reads, so a stranger who could presign against someone else's /// project would hold a key the confirm gate then accepts. #[tokio::test] async fn a_strangers_project_is_refused_at_presign_and_at_confirm() { let mut h = TestHarness::with_storage().await; let (owned_project, _) = creator_with_project(&mut h, "coverwall").await; h.client.post_form("/logout", "").await; let (own_project, _) = creator_with_project(&mut h, "coverintruder").await; let body = json!({ "project_id": owned_project, "file_name": "theirs.png", "content_type": "image/png", }); let resp = h .client .post_json("/api/projects/image/presign", &body.to_string()) .await; assert_eq!( resp.status.as_u16(), 403, "a stranger cannot mint an upload slot against another creator's project: {}", resp.text ); // A key of the intruder's own, aimed at the project they do not own. let own_key = presign_project_cover(&mut h, &own_project, "ok.png").await; upload_bytes(&h, &own_key, 1024); let resp = confirm_project_cover(&mut h, &owned_project, &own_key).await; assert_eq!( resp.status.as_u16(), 403, "nor confirm into it with a key they legitimately own: {}", resp.text ); assert_eq!( cover_url(&h, &owned_project).await, None, "the target project is untouched" ); } /// A project id that resolves to nothing is a 404, distinct from the 403 a real /// project belonging to someone else gets. Collapsing the two would turn the /// endpoint into an oracle for which project ids exist. #[tokio::test] async fn an_unknown_project_is_not_found_rather_than_forbidden() { let mut h = TestHarness::with_storage().await; creator_with_project(&mut h, "covermissing").await; let body = json!({ "project_id": "ce9f7087-d503-4cf1-8f80-b2080508e5fe", "file_name": "ghost.png", "content_type": "image/png", }); let resp = h .client .post_json("/api/projects/image/presign", &body.to_string()) .await; assert_eq!( resp.status.as_u16(), 404, "an id that matches no project is not found: {}", resp.text ); } /// Both image routes accept only cover-shaped uploads, and the content type is /// signed into the presigned URL. A type outside that set must be refused before /// a URL exists, on the item route as well as the project one. #[tokio::test] async fn the_image_routes_refuse_a_non_image_upload() { let mut h = TestHarness::with_storage().await; let (project_id, item_id) = creator_with_project(&mut h, "coverwrongtype").await; let body = json!({ "project_id": project_id, "file_name": "cover.png", "content_type": "audio/mpeg", }); let resp = h .client .post_json("/api/projects/image/presign", &body.to_string()) .await; assert_eq!( resp.status.as_u16(), 400, "audio is not a project cover: {}", resp.text ); let body = json!({ "item_id": item_id, "file_name": "cover.mp3", "content_type": "image/png", }); let resp = h .client .post_json("/api/items/image/presign", &body.to_string()) .await; assert_eq!( resp.status.as_u16(), 400, "the extension has to agree with the declared image type: {}", resp.text ); } /// The item half carries the same staging-key gate as the project half, and it /// is a separate code path with its own copy of the lookup. A regression that /// removed one would leave the other passing. #[tokio::test] async fn the_item_route_also_refuses_a_key_it_did_not_mint() { let mut h = TestHarness::with_storage().await; let (project_id, _) = creator_with_project(&mut h, "itemcoverowner").await; let victim_key = presign_project_cover(&mut h, &project_id, "mine.png").await; upload_bytes(&h, &victim_key, 2048); h.client.post_form("/logout", "").await; let (_, thief_item) = creator_with_project(&mut h, "itemcoverthief").await; let body = json!({ "item_id": thief_item, "s3_key": victim_key }); let resp = h .client .post_json("/api/items/image/confirm", &body.to_string()) .await; assert_eq!( resp.status.as_u16(), 400, "the item route proves key ownership the same way: {}", resp.text ); assert_eq!( deletions_for(&h, &victim_key).await, 0, "and refuses before anything can be queued for deletion" ); }