//! Fail-closed "held gate" for CDN-served images (migration 162). //! //! Item/project covers, gallery carousels, and content-insertion clips render //! straight from the CDN with no per-request gate, so a per-row scan_status now //! decides visibility: `pending` and `held` must not render, only `clean` does. //! Quarantine (row purge) is exercised in `scanning.rs` and is unchanged here. use crate::harness::TestHarness; use makenotwork::db::{ItemId, ProjectId}; use makenotwork::types::{Item, Project}; use serde_json::{Value, json}; async fn creator_with_item(h: &mut TestHarness) -> (String, String, String) { let setup = h.create_creator_with_item("gatecreator", "audio", 0).await; h.trust_user(setup.user_id).await; h.grant_tier(setup.user_id, "small_files").await; (setup.user_id.to_string(), setup.project_id, setup.item_id) } fn item_id(s: &str) -> ItemId { ItemId::from(uuid::Uuid::parse_str(s).unwrap()) } fn project_id(s: &str) -> ProjectId { ProjectId::from(uuid::Uuid::parse_str(s).unwrap()) } /// Point an item at a cover and stamp its cover_scan_status. async fn set_item_cover(h: &TestHarness, id: &str, status: &str) { sqlx::query( "UPDATE items SET cover_image_url = 'https://cdn.makenot.work/cover.png', \ cover_s3_key = 'cover.png', cover_scan_status = $2 WHERE id = $1::uuid", ) .bind(id) .bind(status) .execute(&h.db) .await .unwrap(); } /// Point a project at a cover and stamp its cover_scan_status. async fn set_project_cover(h: &TestHarness, id: &str, status: &str) { sqlx::query( "UPDATE projects SET cover_image_url = 'https://cdn.makenot.work/pcover.png', \ cover_s3_key = 'pcover.png', cover_scan_status = $2 WHERE id = $1::uuid", ) .bind(id) .bind(status) .execute(&h.db) .await .unwrap(); } async fn item_view(h: &TestHarness, id: &str) -> Item { let db_item = makenotwork::db::items::get_item_by_id(&h.db, item_id(id)) .await .unwrap() .expect("item exists"); Item::from_db_list(&db_item, &[], true, true) } async fn project_view(h: &TestHarness, id: &str) -> Project { let db_project = makenotwork::db::projects::get_project_by_id(&h.db, project_id(id)) .await .unwrap() .expect("project exists"); Project::from_db(&db_project, 0) } // Covers (items + projects) #[tokio::test] async fn pending_cover_hidden_from_item_view() { let mut h = TestHarness::with_storage().await; let (_, _, iid) = creator_with_item(&mut h).await; set_item_cover(&h, &iid, "pending").await; assert_eq!( item_view(&h, &iid).await.cover_image_url, None, "a pending (unscanned) cover must not render" ); } #[tokio::test] async fn held_cover_hidden_from_item_view() { let mut h = TestHarness::with_storage().await; let (_, _, iid) = creator_with_item(&mut h).await; set_item_cover(&h, &iid, "held_for_review").await; assert_eq!( item_view(&h, &iid).await.cover_image_url, None, "a held cover must not render" ); } #[tokio::test] async fn clean_cover_renders_in_item_view() { let mut h = TestHarness::with_storage().await; let (_, _, iid) = creator_with_item(&mut h).await; set_item_cover(&h, &iid, "clean").await; assert_eq!( item_view(&h, &iid).await.cover_image_url.as_deref(), Some("https://cdn.makenot.work/cover.png"), "a clean cover must render" ); } #[tokio::test] async fn held_project_cover_image_hidden_clean_renders() { let mut h = TestHarness::with_storage().await; let (_, pid, _) = creator_with_item(&mut h).await; set_project_cover(&h, &pid, "held_for_review").await; assert_eq!( project_view(&h, &pid).await.cover_image_url, None, "a held project cover must not render" ); set_project_cover(&h, &pid, "clean").await; assert_eq!( project_view(&h, &pid).await.cover_image_url.as_deref(), Some("https://cdn.makenot.work/pcover.png"), "a clean project cover must render" ); } // Gallery carousel /// Insert a gallery image row directly with a chosen scan_status. async fn insert_gallery_image(h: &TestHarness, item: &str, key: &str, pos: i32, status: &str) { sqlx::query( "INSERT INTO item_images (item_id, s3_key, image_url, alt, position, file_size_bytes, scan_status) \ VALUES ($1::uuid, $2, $3, 'caption', $4, 10, $5)", ) .bind(item) .bind(key) .bind(format!("https://cdn.makenot.work/{key}")) .bind(pos) .bind(status) .execute(&h.db) .await .unwrap(); } #[tokio::test] async fn held_gallery_image_excluded_from_list_for_item() { let mut h = TestHarness::with_storage().await; let (_, _, iid) = creator_with_item(&mut h).await; insert_gallery_image(&h, &iid, "clean.png", 0, "clean").await; insert_gallery_image(&h, &iid, "held.png", 1, "held_for_review").await; insert_gallery_image(&h, &iid, "pending.png", 2, "pending").await; let listed = makenotwork::db::gallery_images::list_for_item(&h.db, item_id(&iid)) .await .unwrap(); let keys: Vec<&str> = listed.iter().map(|g| g.s3_key.as_str()).collect(); assert_eq!( keys, vec!["clean.png"], "only the clean gallery image renders" ); // The per-entity cap still counts every row (held/pending included), so a // held image cannot be re-uploaded around the limit. let cap_count = makenotwork::db::gallery_images::count_for_item(&h.db, item_id(&iid)) .await .unwrap(); assert_eq!(cap_count, 3, "the cap count includes non-clean rows"); } // Content insertions (fan playback vs creator management) /// Presign + upload + confirm one insertion clip for the logged-in creator, then /// place it as a pre-roll on `item_id`. Returns the insertion's id + storage key. async fn add_placed_insertion(h: &mut TestHarness, item: &str, title: &str) -> (String, String) { let resp = h .client .post_json( "/api/users/me/insertions/presign", &json!({ "file_name": "clip.mp3", "content_type": "audio/mpeg" }).to_string(), ) .await; assert!( resp.status.is_success(), "insertion presign failed: {}", resp.text ); let s3_key = resp.json::()["s3_key"].as_str().unwrap().to_string(); h.storage .as_ref() .unwrap() .put(&s3_key, b"fake clip".to_vec()); let resp = h .client .post_json( "/api/users/me/insertions/confirm", &json!({ "s3_key": s3_key, "title": title, "duration_ms": 5000, "file_size": 9, "mime_type": "audio/mpeg", }) .to_string(), ) .await; assert!( resp.status.is_success(), "insertion confirm failed: {}", resp.text ); let insertion_id = resp.json::()["id"].as_str().unwrap().to_string(); let resp = h .client .post_json( &format!("/api/items/{item}/insertions"), &json!({ "insertion_id": insertion_id, "position": "pre_roll" }).to_string(), ) .await; assert!(resp.status.is_success(), "placement failed: {}", resp.text); (insertion_id, s3_key) } async fn set_insertion_scan_status(h: &TestHarness, id: &str, status: &str) { sqlx::query("UPDATE content_insertions SET scan_status = $2 WHERE id = $1::uuid") .bind(id) .bind(status) .execute(&h.db) .await .unwrap(); } #[tokio::test] async fn held_insertion_not_served_to_fans_but_visible_to_creator() { let mut h = TestHarness::with_storage().await; let (_, pid, iid) = creator_with_item(&mut h).await; h.publish_project_and_item(&pid, &iid).await; let title = "SponsorClipXYZ"; let (ins_id, _key) = add_placed_insertion(&mut h, &iid, title).await; // Held: hidden from the fan playback path (library page segments)... set_insertion_scan_status(&h, &ins_id, "held_for_review").await; let fan = h.client.get(&format!("/l/{iid}")).await; assert!(fan.status.is_success(), "library page failed: {}", fan.text); assert!( !fan.text.contains(title), "a held insertion must not be spliced into fan playback" ); // ...but still present in the creator's management library (ungated). let manage = h.client.get("/api/users/me/insertions").await; assert!( manage.status.is_success(), "manage list failed: {}", manage.text ); assert!( manage.text.contains(title), "the creator must still see their held clip to manage it" ); // Clean: now served to fans. set_insertion_scan_status(&h, &ins_id, "clean").await; let fan = h.client.get(&format!("/l/{iid}")).await; assert!(fan.status.is_success(), "library page failed: {}", fan.text); assert!( fan.text.contains(title), "a clean insertion must be served to fans" ); }