//! Media library integration tests, image/video upload, folder listing, delete, tier gating. use crate::harness::TestHarness; use makenotwork::storage::StorageBackend; use serde_json::{Value, json}; /// Minimal valid PNG (1x1 transparent pixel). const TINY_PNG: &[u8] = &[ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, // RGBA, CRC 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, // IDAT chunk 0x78, 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, // compressed data 0x27, 0xDE, 0xFC, // CRC 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND 0xAE, 0x42, 0x60, 0x82, // CRC ]; const TEST_MP4: &[u8] = include_bytes!("../fixtures/test.mp4"); /// Helper: set up a trusted creator with basic tier (no file uploads allowed). async fn setup_basic_creator(h: &mut TestHarness, username: &str) -> String { let user_id = h.create_creator(username).await; h.trust_user(user_id).await; h.grant_tier(user_id, "basic").await; user_id.to_string() } /// Helper: set up a trusted creator with big_files tier. async fn setup_bigfiles_creator(h: &mut TestHarness, username: &str) -> String { let user_id = h.create_creator(username).await; h.trust_user(user_id).await; h.grant_tier(user_id, "big_files").await; user_id.to_string() } /// Helper: presign + put bytes + confirm a media file. Returns (s3_key, file_id). async fn upload_media( h: &mut TestHarness, file_name: &str, content_type: &str, folder: &str, data: &[u8], ) -> (String, String) { // Presign let body = json!({ "file_name": file_name, "content_type": content_type, "folder": folder, }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!(resp.status, 200, "Media presign failed: {}", resp.text); let presign: Value = resp.json(); let s3_key = presign["s3_key"].as_str().unwrap().to_string(); // Put bytes into in-memory storage h.storage.as_ref().unwrap().put(&s3_key, data.to_vec()); // Confirm let body = json!({ "s3_key": s3_key, "file_name": file_name, "content_type": content_type, "folder": folder, }); let resp = h .client .post_json("/api/media/confirm", &body.to_string()) .await; assert_eq!(resp.status, 200, "Media confirm failed: {}", resp.text); // Get the file ID from the list let list_resp = h.client.get(&format!("/api/media?folder={folder}")).await; let list: Value = list_resp.json(); let files = list["files"].as_array().unwrap(); let file_id = files .iter() .find(|f| f["filename"].as_str().unwrap() == file_name) .expect("Uploaded file not in list")["id"] .as_str() .unwrap() .to_string(); (s3_key, file_id) } /// Minimal MP4 header, `ftyp` box with the `isom` brand. `infer` classifies /// this as a video, regardless of the declared content type. const TINY_MP4: &[u8] = &[ 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, // size + "ftyp" 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00, // "isom" + minor version 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32, // compatible brands ]; // A video declared as an image is rejected at confirm (tier-gate evasion) #[tokio::test] async fn video_declared_as_image_is_rejected_at_confirm() { // Run #22 Storage MED: the declared content_type is client-controlled. A // video uploaded under an image/png declaration would dodge the BigFiles+ // video-tier gate. The confirm-time byte sniff must catch and reject it. let mut h = TestHarness::with_storage().await; setup_basic_creator(&mut h, "ctevasion").await; let body = json!({ "file_name": "sneaky.png", "content_type": "image/png", "folder": "clips", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 200, "image presign should succeed: {}", resp.text ); let presign: Value = resp.json(); let s3_key = presign["s3_key"].as_str().unwrap().to_string(); // Upload actual MP4 bytes under the image key. h.storage.as_ref().unwrap().put(&s3_key, TINY_MP4.to_vec()); let body = json!({ "s3_key": s3_key, "file_name": "sneaky.png", "content_type": "image/png", "folder": "clips", }); let resp = h .client .post_json("/api/media/confirm", &body.to_string()) .await; assert_eq!( resp.status, 400, "a video declared as an image must be rejected at confirm, got: {} {}", resp.status, resp.text ); // Nothing was recorded in the library. let list: Value = h.client.get("/api/media?folder=clips").await.json(); assert!( list["files"].as_array().is_none_or(std::vec::Vec::is_empty), "rejected upload must not appear in the media library" ); } // Bytes `infer` cannot classify, declared as an image, are rejected at confirm #[tokio::test] async fn unrecognized_bytes_declared_as_image_is_rejected_at_confirm() { // Run #5 Storage S1: the old sniff only rejected a *positively detected* // video, so a video whose container `infer` cannot name slipped through an // `image/png` declaration. Declared images must now sniff positively as an // image; unrecognized bytes are rejected, closing the bypass. let mut h = TestHarness::with_storage().await; setup_basic_creator(&mut h, "unrecimg").await; let body = json!({ "file_name": "blob.png", "content_type": "image/png", "folder": "clips", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 200, "image presign should succeed: {}", resp.text ); let presign: Value = resp.json(); let s3_key = presign["s3_key"].as_str().unwrap().to_string(); // Bytes `infer` does not recognize as any image or video format. h.storage .as_ref() .unwrap() .put(&s3_key, b"not a real image at all".to_vec()); let body = json!({ "s3_key": s3_key, "file_name": "blob.png", "content_type": "image/png", "folder": "clips", }); let resp = h .client .post_json("/api/media/confirm", &body.to_string()) .await; assert_eq!( resp.status, 400, "unrecognized bytes declared as an image must be rejected, got: {} {}", resp.status, resp.text ); let list: Value = h.client.get("/api/media?folder=clips").await.json(); assert!( list["files"].as_array().is_none_or(std::vec::Vec::is_empty), "rejected upload must not appear in the media library" ); } // Image upload on Basic tier succeeds (images bypass tier check like covers) #[tokio::test] async fn image_upload_basic_tier_succeeds() { let mut h = TestHarness::with_storage().await; setup_basic_creator(&mut h, "imgbasic").await; let body = json!({ "file_name": "photo.png", "content_type": "image/png", "folder": "screenshots", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 200, "Image presign should succeed on basic tier: {}", resp.text ); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); h.storage.as_ref().unwrap().put(&s3_key, TINY_PNG.to_vec()); let body = json!({ "s3_key": s3_key, "file_name": "photo.png", "content_type": "image/png", "folder": "screenshots", }); let resp = h .client .post_json("/api/media/confirm", &body.to_string()) .await; assert_eq!( resp.status, 200, "Image confirm should succeed on basic tier: {}", resp.text ); } // Video upload on Basic tier rejected (requires BigFiles+) #[tokio::test] async fn video_upload_basic_tier_rejected() { let mut h = TestHarness::with_storage().await; setup_basic_creator(&mut h, "vidbasic").await; let body = json!({ "file_name": "demo.mp4", "content_type": "video/mp4", "folder": "", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 400, "Video presign should fail on basic tier: {} {}", resp.status, resp.text ); } // Video upload on BigFiles tier succeeds #[tokio::test] async fn video_upload_bigfiles_tier_succeeds() { let mut h = TestHarness::with_storage().await; setup_bigfiles_creator(&mut h, "vidbig").await; let body = json!({ "file_name": "demo.mp4", "content_type": "video/mp4", "folder": "clips", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 200, "Video presign should succeed on big_files tier: {}", resp.text ); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); h.storage.as_ref().unwrap().put(&s3_key, TEST_MP4.to_vec()); let body = json!({ "s3_key": s3_key, "file_name": "demo.mp4", "content_type": "video/mp4", "folder": "clips", }); let resp = h .client .post_json("/api/media/confirm", &body.to_string()) .await; assert_eq!( resp.status, 200, "Video confirm should succeed on big_files tier: {}", resp.text ); } // List files by folder #[tokio::test] async fn list_files_by_folder() { let mut h = TestHarness::with_storage().await; setup_bigfiles_creator(&mut h, "listuser").await; // Upload to two different folders upload_media(&mut h, "img1.png", "image/png", "art", TINY_PNG).await; upload_media(&mut h, "img2.png", "image/png", "photos", TINY_PNG).await; // List all let resp = h.client.get("/api/media").await; assert_eq!(resp.status, 200, "{}", resp.text); let data: Value = resp.json(); assert_eq!( data["files"].as_array().unwrap().len(), 2, "Should list all files" ); let folders = data["folders"].as_array().unwrap(); assert!(folders.iter().any(|f| f.as_str() == Some("art"))); assert!(folders.iter().any(|f| f.as_str() == Some("photos"))); // List filtered by folder let resp = h.client.get("/api/media?folder=art").await; assert_eq!(resp.status, 200, "{}", resp.text); let data: Value = resp.json(); assert_eq!( data["files"].as_array().unwrap().len(), 1, "Should list only art folder files" ); assert_eq!(data["files"][0]["filename"].as_str().unwrap(), "img1.png"); // List folders let resp = h.client.get("/api/media/folders").await; assert_eq!(resp.status, 200, "{}", resp.text); let data: Value = resp.json(); let folders = data["folders"].as_array().unwrap(); assert_eq!(folders.len(), 2); } // Delete media file (storage decremented) #[tokio::test] async fn delete_media_file_decrements_storage() { let mut h = TestHarness::with_storage().await; let user_id_str = setup_bigfiles_creator(&mut h, "deluser").await; let (s3_key, file_id) = upload_media(&mut h, "todel.png", "image/png", "", TINY_PNG).await; // Verify storage was incremented let storage_before: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid") .bind(&user_id_str) .fetch_one(&h.db) .await .unwrap(); assert!( storage_before > 0, "Storage should be non-zero after upload" ); let resp = h.client.delete(&format!("/api/media/{file_id}")).await; assert_eq!(resp.status, 200, "Delete should succeed: {}", resp.text); // Verify storage was decremented let storage_after: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid") .bind(&user_id_str) .fetch_one(&h.db) .await .unwrap(); assert_eq!(storage_after, 0, "Storage should be zero after deletion"); // Verify file is gone from DB let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media_files WHERE id = $1::uuid") .bind(&file_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Media file should be deleted from DB"); // The handler enqueues the S3 delete to the orphan queue; run it (the // scheduler's job in production) before asserting the object is gone. h.drain_s3_deletions().await; // Verify file is gone from S3 (via trait method) let s3_exists = makenotwork::storage::StorageBackend::object_exists( h.storage.as_ref().unwrap().as_ref(), &s3_key, ) .await .unwrap(); assert!(!s3_exists, "File should be deleted from storage"); } // Filename collision rejected #[tokio::test] async fn filename_collision_rejected() { let mut h = TestHarness::with_storage().await; setup_bigfiles_creator(&mut h, "colluser").await; // Upload first file upload_media(&mut h, "same.png", "image/png", "art", TINY_PNG).await; // Filename uniqueness is enforced at CONFIRM time now, not presign, the // pre-check was removed because it raced against concurrent presigns. // Presign succeeds; the confirm catches the duplicate via the // `idx_media_files_user_folder_name` unique index. let body = json!({ "file_name": "same.png", "content_type": "image/png", "folder": "art", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 200, "Presign no longer pre-checks; should succeed: {}", resp.text ); let presign: Value = resp.json(); let dup_s3_key = presign["s3_key"].as_str().unwrap().to_string(); h.storage .as_ref() .unwrap() .put(&dup_s3_key, TINY_PNG.to_vec()); let body = json!({ "s3_key": dup_s3_key, "file_name": "same.png", "content_type": "image/png", "folder": "art", }); let resp = h .client .post_json("/api/media/confirm", &body.to_string()) .await; assert_eq!( resp.status, 400, "Duplicate filename should be rejected at confirm time: {} {}", resp.status, resp.text ); assert!( resp.text.contains("already exists"), "Error should mention collision: {}", resp.text ); } // Same filename in different folders is allowed #[tokio::test] async fn same_filename_different_folder_allowed() { let mut h = TestHarness::with_storage().await; setup_bigfiles_creator(&mut h, "diffuser").await; upload_media(&mut h, "logo.png", "image/png", "art", TINY_PNG).await; upload_media(&mut h, "logo.png", "image/png", "photos", TINY_PNG).await; let resp = h.client.get("/api/media").await; let data: Value = resp.json(); assert_eq!( data["files"].as_array().unwrap().len(), 2, "Same name in different folders should both exist" ); } // Path traversal in folder name rejected #[tokio::test] async fn path_traversal_in_folder_rejected() { let mut h = TestHarness::with_storage().await; setup_bigfiles_creator(&mut h, "travuser").await; let body = json!({ "file_name": "exploit.png", "content_type": "image/png", "folder": "../../../etc", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 400, "Path traversal folder should be rejected: {} {}", resp.status, resp.text ); } // Storage cap enforcement (video respects tier cap) #[tokio::test] async fn video_storage_cap_enforcement() { let mut h = TestHarness::with_storage().await; let user_id_str = setup_bigfiles_creator(&mut h, "capuser").await; // Set storage_used_bytes near the big_files tier cap (500 GB) let near_cap = 500_i64 * 1024 * 1024 * 1024 - 1; sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1::uuid") .bind(&user_id_str) .bind(near_cap) .execute(&h.db) .await .expect("set storage near cap"); // Video upload should fail because storage cap would be exceeded let body = json!({ "file_name": "big.mp4", "content_type": "video/mp4", "folder": "", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; // Presign may succeed (it's a pre-check on tier, not storage), try confirm // Not an assertion: the test covers both outcomes deliberately. if resp.status.is_success() { let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); // Put video bytes h.storage.as_ref().unwrap().put(&s3_key, TEST_MP4.to_vec()); let body = json!({ "s3_key": s3_key, "file_name": "big.mp4", "content_type": "video/mp4", "folder": "", }); let resp = h .client .post_json("/api/media/confirm", &body.to_string()) .await; assert_eq!( resp.status, 400, "Confirm should fail when storage cap exceeded: {} {}", resp.status, resp.text ); } // If presign itself rejected it, that's also correct } // Unsupported content type rejected #[tokio::test] async fn unsupported_content_type_rejected() { let mut h = TestHarness::with_storage().await; setup_bigfiles_creator(&mut h, "badtype").await; let body = json!({ "file_name": "script.js", "content_type": "application/javascript", "folder": "", }); let resp = h .client .post_json("/api/media/presign", &body.to_string()) .await; assert_eq!( resp.status, 400, "Non-image/video content type should be rejected: {} {}", resp.status, resp.text ); } // A duplicate confirm is rejected, but must NOT delete the live object // (Run #11 HIGH regression). Media keys are deterministic by (user, folder, // filename), so a retried/duplicate confirm resolves to the same key the // committed row already points at. The product rejects the duplicate // ("already exists"), but the previous code's error arm deleted that key, // torpedoing the existing file. The fix keeps the rejection and preserves the // object + the storage charge. #[tokio::test] async fn media_duplicate_confirm_rejects_without_deleting_live_object() { let mut h = TestHarness::with_storage().await; let user_id = setup_bigfiles_creator(&mut h, "mediareconfirm").await; // Presign + upload + first confirm. let presign_body = json!({"file_name": "pic.png", "content_type": "image/png", "folder": ""}); let resp = h .client .post_json("/api/media/presign", &presign_body.to_string()) .await; assert_eq!(resp.status, 200, "presign failed: {}", resp.text); let s3_key = resp.json::()["s3_key"].as_str().unwrap().to_string(); h.storage.as_ref().unwrap().put(&s3_key, TINY_PNG.to_vec()); let confirm_body = json!({"s3_key": s3_key, "file_name": "pic.png", "content_type": "image/png", "folder": ""}); let resp = h .client .post_json("/api/media/confirm", &confirm_body.to_string()) .await; assert_eq!(resp.status, 200, "first confirm failed: {}", resp.text); let used_after_first: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid") .bind(&user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(used_after_first, TINY_PNG.len() as i64); // A genuine duplicate under scan-then-promote: presign a SECOND upload of the // same folder+filename (a fresh staging key, so it passes the ownership gate), // then confirm. It collides on the (user, folder, filename) unique index and // is rejected as "already exists". let presign2 = json!({"file_name": "pic.png", "content_type": "image/png", "folder": ""}); let resp = h .client .post_json("/api/media/presign", &presign2.to_string()) .await; assert_eq!(resp.status, 200, "second presign failed: {}", resp.text); let s3_key2 = resp.json::()["s3_key"].as_str().unwrap().to_string(); h.storage.as_ref().unwrap().put(&s3_key2, TINY_PNG.to_vec()); let confirm2 = json!({"s3_key": s3_key2, "file_name": "pic.png", "content_type": "image/png", "folder": ""}); let resp = h .client .post_json("/api/media/confirm", &confirm2.to_string()) .await; assert_eq!( resp.status, 400, "duplicate confirm should be rejected: {} {}", resp.status, resp.text ); assert!( resp.text.contains("already exists"), "rejection should name the collision: {}", resp.text ); // ... but the FIRST upload's live object must SURVIVE (the HIGH). assert!( h.storage .as_ref() .unwrap() .object_exists(&s3_key) .await .unwrap(), "duplicate confirm must NOT delete the first upload's live object" ); // ... and the rolled-back tx must not have double-charged storage. let used_after_second: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid") .bind(&user_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!( used_after_second, TINY_PNG.len() as i64, "duplicate confirm must not double-charge storage" ); let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media_files WHERE s3_key = $1") .bind(&s3_key) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 1, "exactly one media_files row remains"); }