//! Media library integration tests — image/video upload, folder listing, delete, tier gating. use crate::harness::TestHarness; use makenotwork::storage::StorageBackend; use serde_json::{json, Value}; /// 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!(resp.status.is_success(), "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!(resp.status.is_success(), "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) } // --------------------------------------------------------------------------- // 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!(resp.status.is_success(), "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!(resp.status.is_success(), "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!( resp.status.is_client_error(), "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!(resp.status.is_success(), "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!(resp.status.is_success(), "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!(resp.status.is_success()); 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!(resp.status.is_success()); 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!(resp.status.is_success()); 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"); // Delete the file let resp = h.client.delete(&format!("/api/media/{}", file_id)).await; assert!(resp.status.is_success(), "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"); // 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!(resp.status.is_success(), "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!( resp.status.is_client_error(), "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!( resp.status.is_client_error(), "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 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!( resp.status.is_client_error(), "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!( resp.status.is_client_error(), "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!(resp.status.is_success(), "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!(resp.status.is_success(), "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); // Re-confirm the SAME key. Rejected as a duplicate ... let resp = h.client.post_json("/api/media/confirm", &confirm_body.to_string()).await; assert!(resp.status.is_client_error(), "duplicate confirm should be rejected: {} {}", resp.status, resp.text); assert!(resp.text.contains("already exists"), "rejection should name the collision: {}", resp.text); // ... but the live object the committed row points at must SURVIVE (the HIGH). assert!( h.storage.as_ref().unwrap().object_exists(&s3_key).await.unwrap(), "duplicate confirm must NOT delete the 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"); }