//! File scanning workflow tests, clean files pass, malicious magic bytes quarantined. use crate::harness::TestHarness; use serde_json::{Value, json}; use makenotwork::db::UserId; use makenotwork::storage::StorageBackend; /// Helper: set up a trusted creator with a project and audio item. async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String) { let setup = h.create_creator_with_item("scancreator", "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) } #[tokio::test] async fn confirm_upload_clean_file_passes() { let mut h = TestHarness::with_storage_and_scanner().await; let (_project_id, item_id) = setup_creator_with_item(&mut h).await; // Presign let body = json!({ "item_id": item_id, "file_type": "audio", "file_name": "clean.mp3", "content_type": "audio/mpeg", }); let resp = h .client .post_json("/api/upload/presign", &body.to_string()) .await; assert!(resp.status.is_success(), "Presign failed: {}", resp.text); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); // Simulate upload: ID3v2 header (valid MP3 magic bytes) let mut mp3_data = b"ID3".to_vec(); mp3_data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // ID3v2.4 header mp3_data.extend_from_slice(&[0u8; 100]); // padding h.storage.as_ref().unwrap().put(&s3_key, mp3_data); // Confirm, scanner should see MP3/ID3 magic and pass let body = json!({ "item_id": item_id, "file_type": "audio", "s3_key": s3_key, }); let resp = h .client .post_json("/api/upload/confirm", &body.to_string()) .await; assert!( resp.status.is_success(), "Confirm should pass for clean file: {}", resp.text ); // Scanning is async (Phase 1 worker pipeline). Drive the worker to // completion before asserting final state. h.drain_scan_jobs().await; // C1 scan-then-promote: a Clean scan copies the object from its staging key // to a content-addressed served key and repoints the row. audio_s3_key must // NO LONGER be the staging key, it is now `{user}/c/{sha256}.mp3`. let db_key: Option = sqlx::query_scalar("SELECT audio_s3_key FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); let db_key = db_key.expect("audio_s3_key set"); assert_ne!( db_key, s3_key, "clean scan must promote off the staging key" ); assert!( !db_key.starts_with("staging/"), "promoted key must not be a staging key: {db_key}" ); assert!( db_key.contains("/c/"), "promoted key must be content-addressed: {db_key}" ); assert!( std::path::Path::new(&db_key) .extension() .is_some_and(|e| e == "mp3"), "content key keeps the extension: {db_key}" ); // The served object exists at the content key; the staging object is gone // (enqueued for durable deletion after the promote copy). let store = h.storage.as_ref().unwrap(); assert!( store.object_exists(&db_key).await.unwrap(), "content object must exist after promote" ); // Verify scan_status is clean let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "clean"); } /// The C1 invariant end-to-end: after a Clean scan promotes an upload to its /// content key, a creator re-PUT to the (still-known) staging URL cannot change /// the bytes a buyer is served. The served key is the content key; the staging /// object is a dead end. #[tokio::test] async fn repost_to_staging_after_clean_cannot_change_served_bytes() { let mut h = TestHarness::with_storage_and_scanner().await; let (_project_id, item_id) = setup_creator_with_item(&mut h).await; // Presign → the client only ever holds a presign to the staging key. let body = json!({"item_id": item_id, "file_type": "audio", "file_name": "song.mp3", "content_type": "audio/mpeg"}); let resp = h .client .post_json("/api/upload/presign", &body.to_string()) .await; assert!(resp.status.is_success(), "presign: {}", resp.text); let staging_key = resp.json::()["s3_key"].as_str().unwrap().to_string(); assert!(staging_key.starts_with("staging/")); // Upload clean bytes and confirm. let mut clean = b"ID3".to_vec(); clean.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); clean.extend_from_slice(&[0xAAu8; 200]); h.storage.as_ref().unwrap().put(&staging_key, clean.clone()); let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": staging_key}); let resp = h .client .post_json("/api/upload/confirm", &body.to_string()) .await; assert!(resp.status.is_success(), "confirm: {}", resp.text); h.drain_scan_jobs().await; // The row now serves a content key, NOT the staging key. let served_key: String = sqlx::query_scalar::<_, Option>( "SELECT audio_s3_key FROM items WHERE id = $1::uuid", ) .bind(&item_id) .fetch_one(&h.db) .await .unwrap() .expect("audio promoted"); assert!( served_key.contains("/c/"), "served key must be content-addressed: {served_key}" ); assert_ne!(served_key, staging_key); let store = h.storage.as_ref().unwrap(); let served_before = store.download_object(&served_key).await.unwrap(); assert_eq!( served_before, clean, "the content object holds the scanned bytes" ); // The attack: re-PUT malware to the staging key the creator still holds a // presign for. This is the exact move the old mutable-served-key design let // a creator use to swap post-scan bytes. let malware = vec![0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00]; store.put(&staging_key, malware.clone()); // The served key is untouched: a buyer still gets the scanned bytes. The // staging object is irrelevant, it is not what the row serves. let served_after = store.download_object(&served_key).await.unwrap(); assert_eq!( served_after, clean, "re-PUT to the staging key must NOT change the served bytes" ); assert_ne!(served_after, malware); } #[tokio::test] async fn confirm_upload_bad_magic_quarantined() { let mut h = TestHarness::with_storage_and_scanner().await; let (_project_id, item_id) = setup_creator_with_item(&mut h).await; // Presign let body = json!({ "item_id": item_id, "file_type": "audio", "file_name": "sneaky.mp3", "content_type": "audio/mpeg", }); let resp = h .client .post_json("/api/upload/presign", &body.to_string()) .await; assert!(resp.status.is_success()); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); // Simulate upload: ELF binary magic disguised as audio let mut elf_data = vec![0x7f, b'E', b'L', b'F']; elf_data.extend_from_slice(&[0x02, 0x01, 0x01, 0x00]); // 64-bit, LE, current elf_data.extend_from_slice(&[0u8; 100]); // padding h.storage.as_ref().unwrap().put(&s3_key, elf_data); // Confirm, scanner enqueues async; the worker decides quarantine. let body = json!({ "item_id": item_id, "file_type": "audio", "s3_key": s3_key, }); let resp = h .client .post_json("/api/upload/confirm", &body.to_string()) .await; assert!( resp.status.is_success(), "Confirm enqueues async; the worker decides scan verdict. Got {}: {}", resp.status, resp.text ); h.drain_scan_jobs().await; // Verify scan_status is quarantined let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "quarantined"); } #[tokio::test] async fn quarantined_cover_nulls_columns_but_keeps_published_track() { // Run #20 Storage SERIOUS (flip side): a cover is CDN-served with no // per-request gate, so enforcing a quarantine verdict NULLs the cover // columns (stopping the URL from rendering) rather than flipping the // shared `items.scan_status`. The legitimate audio track and its Clean // gate status must survive, a malicious thumbnail can't delist a track. let mut h = TestHarness::with_storage_and_scanner().await; let (_project_id, item_id) = setup_creator_with_item(&mut h).await; // Publish a clean audio track first. let body = json!({"item_id": item_id, "file_type": "audio", "file_name": "t.mp3", "content_type": "audio/mpeg"}); let resp = h .client .post_json("/api/upload/presign", &body.to_string()) .await; assert!(resp.status.is_success(), "audio presign: {}", resp.text); let audio_key = resp.json::()["s3_key"].as_str().unwrap().to_string(); let mut mp3 = b"ID3".to_vec(); mp3.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); mp3.extend_from_slice(&[0u8; 100]); h.storage.as_ref().unwrap().put(&audio_key, mp3); let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": audio_key}); let resp = h .client .post_json("/api/upload/confirm", &body.to_string()) .await; assert!(resp.status.is_success(), "audio confirm: {}", resp.text); // Upload a malicious cover (ELF magic disguised as a png), the worker // quarantines it the same way it does the bad-magic audio above. let body = json!({"item_id": item_id, "file_name": "art.png", "content_type": "image/png"}); let resp = h .client .post_json("/api/items/image/presign", &body.to_string()) .await; assert!(resp.status.is_success(), "cover presign: {}", resp.text); let cover_key = resp.json::()["s3_key"].as_str().unwrap().to_string(); let mut elf = vec![0x7f, b'E', b'L', b'F']; elf.extend_from_slice(&[0x02, 0x01, 0x01, 0x00]); elf.extend_from_slice(&[0u8; 100]); h.storage.as_ref().unwrap().put(&cover_key, elf); let body = json!({"item_id": item_id, "s3_key": cover_key}); let resp = h .client .post_json("/api/items/image/confirm", &body.to_string()) .await; assert!(resp.status.is_success(), "cover confirm: {}", resp.text); h.drain_scan_jobs().await; let (status, audio, ck, cu): (String, Option, Option, Option) = sqlx::query_as( "SELECT scan_status, audio_s3_key, cover_s3_key, cover_image_url \ FROM items WHERE id = $1::uuid", ) .bind(&item_id) .fetch_one(&h.db) .await .expect("the item row must still exist, quarantining a cover must not delete the track"); assert_eq!( status, "clean", "a quarantined cover must not touch the track's gate status" ); // The audio track was clean-scanned, so it was promoted off its staging key to // a content-addressed key, the point is it survives the cover quarantine // (non-null, promoted, still gated Clean), not that it keeps the staging name. let audio = audio.expect("the audio track must be preserved"); assert_ne!( audio, audio_key, "the audio track must have been promoted, not delisted" ); assert!( audio.contains("/c/"), "the surviving track must be content-addressed: {audio}" ); assert_eq!(ck, None, "the quarantined cover key must be NULLed"); assert_eq!( cu, None, "the quarantined cover URL must be NULLed so it stops rendering" ); } // Upload Trust Tier Tests /// Helper: set up an untrusted creator with a project and audio item. /// Returns (user_id, project_id, item_id). async fn setup_untrusted_creator_with_item( h: &mut TestHarness, username: &str, email: &str, ) -> (UserId, String, String) { let user_id = h.signup(username, email, "password123").await; h.grant_creator(user_id).await; h.grant_tier(user_id, "small_files").await; // NOTE: deliberately NOT calling h.trust_user(user_id) h.client.post_form("/logout", "").await; h.login(username, "password123").await; let resp = h .client .post_form( "/api/projects", &format!("slug={username}proj&title={username}+Project"), ) .await; assert!(resp.status.is_success(), "Create project: {}", resp.text); let project: Value = resp.json(); let project_id = project["id"].as_str().unwrap().to_string(); let resp = h .client .post_form( &format!("/api/projects/{project_id}/items"), "title=Trust+Track&price_cents=0&item_type=audio", ) .await; assert!(resp.status.is_success(), "Create item: {}", resp.text); let item: Value = resp.json(); let item_id = item["id"].as_str().unwrap().to_string(); (user_id, project_id, item_id) } /// Helper: presign, simulate upload, and confirm for clean MP3 data. /// Returns the s3_key. async fn upload_clean_mp3(h: &mut TestHarness, item_id: &str) -> String { let body = json!({ "item_id": item_id, "file_type": "audio", "file_name": "trust_test.mp3", "content_type": "audio/mpeg", }); let resp = h .client .post_json("/api/upload/presign", &body.to_string()) .await; assert!(resp.status.is_success(), "Presign failed: {}", resp.text); let data: Value = resp.json(); let s3_key = data["s3_key"].as_str().unwrap().to_string(); // Valid MP3 magic bytes let mut mp3_data = b"ID3".to_vec(); mp3_data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); mp3_data.extend_from_slice(&[0u8; 100]); h.storage.as_ref().unwrap().put(&s3_key, mp3_data); let body = json!({ "item_id": item_id, "file_type": "audio", "s3_key": s3_key, }); let resp = h .client .post_json("/api/upload/confirm", &body.to_string()) .await; assert!(resp.status.is_success(), "Confirm failed: {}", resp.text); // Drive the async worker so callers can immediately assert final state. h.drain_scan_jobs().await; s3_key } #[tokio::test] async fn untrusted_creator_upload_held_for_review() { let mut h = TestHarness::with_storage_and_scanner().await; let (_user_id, _project_id, item_id) = setup_untrusted_creator_with_item(&mut h, "untrusted", "untrusted@test.com").await; let _s3_key = upload_clean_mp3(&mut h, &item_id).await; // Verify scan_status is held_for_review (not clean) let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "held_for_review"); // Creator can preview their own held content (200) let resp = h.client.get(&format!("/api/stream/{item_id}")).await; assert_eq!( resp.status.as_u16(), 200, "Creators should be able to preview held uploads" ); // But a different user should not be able to stream it (log in as buyer) h.client.post_form("/logout", "").await; h.signup("buyer", "buyer@test.com", "password123").await; let resp = h.client.get(&format!("/api/stream/{item_id}")).await; assert_eq!( resp.status.as_u16(), 404, "Non-creators should not stream held uploads" ); } #[tokio::test] async fn trusted_creator_upload_auto_publishes() { let mut h = TestHarness::with_storage_and_scanner().await; let (user_id, _project_id, item_id) = setup_untrusted_creator_with_item(&mut h, "trusted", "trusted@test.com").await; // Trust the user, then upload h.trust_user(user_id).await; let _s3_key = upload_clean_mp3(&mut h, &item_id).await; // Verify scan_status is clean (auto-published) let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "clean"); } #[tokio::test] async fn admin_approve_held_upload() { let (mut h, _admin_id) = TestHarness::with_admin_storage_and_scanner().await; let (_user_id, _project_id, item_id) = setup_untrusted_creator_with_item(&mut h, "heldcreator", "held@test.com").await; let _s3_key = upload_clean_mp3(&mut h, &item_id).await; // Verify it's held let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "held_for_review"); // Log in as admin and approve h.client.post_form("/logout", "").await; h.login("admin", "password123").await; let resp = h .client .post_form(&format!("/api/admin/uploads/items/{item_id}/promote"), "") .await; assert!( resp.status.is_success(), "Admin approve failed: {} {}", resp.status, resp.text ); // Verify scan_status is now clean let scan_status: String = sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid") .bind(&item_id) .fetch_one(&h.db) .await .unwrap(); assert_eq!(scan_status, "clean"); // Verify streaming now works (item is public and free by default) let resp = h.client.get(&format!("/api/stream/{item_id}")).await; assert!( resp.status.is_success(), "Stream should work after approval: {}", resp.status ); }