Skip to main content

max / makenotwork

8.9 KB · 272 lines History Blame Raw
1 //! Fail-closed "held gate" for CDN-served images (migration 162).
2 //!
3 //! Item/project covers, gallery carousels, and content-insertion clips render
4 //! straight from the CDN with no per-request gate, so a per-row scan_status now
5 //! decides visibility: `pending` and `held` must not render, only `clean` does.
6 //! Quarantine (row purge) is exercised in `scanning.rs` and is unchanged here.
7
8 use crate::harness::TestHarness;
9 use makenotwork::db::{ItemId, ProjectId};
10 use makenotwork::types::{Item, Project};
11 use serde_json::{Value, json};
12
13 async fn creator_with_item(h: &mut TestHarness) -> (String, String, String) {
14 let setup = h.create_creator_with_item("gatecreator", "audio", 0).await;
15 h.trust_user(setup.user_id).await;
16 h.grant_tier(setup.user_id, "small_files").await;
17 (setup.user_id.to_string(), setup.project_id, setup.item_id)
18 }
19
20 fn item_id(s: &str) -> ItemId {
21 ItemId::from(uuid::Uuid::parse_str(s).unwrap())
22 }
23
24 fn project_id(s: &str) -> ProjectId {
25 ProjectId::from(uuid::Uuid::parse_str(s).unwrap())
26 }
27
28 /// Point an item at a cover and stamp its cover_scan_status.
29 async fn set_item_cover(h: &TestHarness, id: &str, status: &str) {
30 sqlx::query(
31 "UPDATE items SET cover_image_url = 'https://cdn.makenot.work/cover.png', \
32 cover_s3_key = 'cover.png', cover_scan_status = $2 WHERE id = $1::uuid",
33 )
34 .bind(id)
35 .bind(status)
36 .execute(&h.db)
37 .await
38 .unwrap();
39 }
40
41 /// Point a project at a cover and stamp its cover_scan_status.
42 async fn set_project_cover(h: &TestHarness, id: &str, status: &str) {
43 sqlx::query(
44 "UPDATE projects SET cover_image_url = 'https://cdn.makenot.work/pcover.png', \
45 cover_s3_key = 'pcover.png', cover_scan_status = $2 WHERE id = $1::uuid",
46 )
47 .bind(id)
48 .bind(status)
49 .execute(&h.db)
50 .await
51 .unwrap();
52 }
53
54 async fn item_view(h: &TestHarness, id: &str) -> Item {
55 let db_item = makenotwork::db::items::get_item_by_id(&h.db, item_id(id))
56 .await
57 .unwrap()
58 .expect("item exists");
59 Item::from_db_list(
60 &db_item,
61 &[],
62 true,
63 true,
64 makenotwork::currency::SettlementCurrency::Usd,
65 )
66 }
67
68 async fn project_view(h: &TestHarness, id: &str) -> Project {
69 let db_project = makenotwork::db::projects::get_project_by_id(&h.db, project_id(id))
70 .await
71 .unwrap()
72 .expect("project exists");
73 Project::from_db(&db_project, 0)
74 }
75
76 // Covers (items + projects)
77
78 #[tokio::test]
79 async fn pending_cover_hidden_from_item_view() {
80 let mut h = TestHarness::with_storage().await;
81 let (_, _, iid) = creator_with_item(&mut h).await;
82 set_item_cover(&h, &iid, "pending").await;
83 assert_eq!(
84 item_view(&h, &iid).await.cover_image_url,
85 None,
86 "a pending (unscanned) cover must not render"
87 );
88 }
89
90 #[tokio::test]
91 async fn held_cover_hidden_from_item_view() {
92 let mut h = TestHarness::with_storage().await;
93 let (_, _, iid) = creator_with_item(&mut h).await;
94 set_item_cover(&h, &iid, "held_for_review").await;
95 assert_eq!(
96 item_view(&h, &iid).await.cover_image_url,
97 None,
98 "a held cover must not render"
99 );
100 }
101
102 #[tokio::test]
103 async fn clean_cover_renders_in_item_view() {
104 let mut h = TestHarness::with_storage().await;
105 let (_, _, iid) = creator_with_item(&mut h).await;
106 set_item_cover(&h, &iid, "clean").await;
107 assert_eq!(
108 item_view(&h, &iid).await.cover_image_url.as_deref(),
109 Some("https://cdn.makenot.work/cover.png"),
110 "a clean cover must render"
111 );
112 }
113
114 #[tokio::test]
115 async fn held_project_cover_image_hidden_clean_renders() {
116 let mut h = TestHarness::with_storage().await;
117 let (_, pid, _) = creator_with_item(&mut h).await;
118
119 set_project_cover(&h, &pid, "held_for_review").await;
120 assert_eq!(
121 project_view(&h, &pid).await.cover_image_url,
122 None,
123 "a held project cover must not render"
124 );
125
126 set_project_cover(&h, &pid, "clean").await;
127 assert_eq!(
128 project_view(&h, &pid).await.cover_image_url.as_deref(),
129 Some("https://cdn.makenot.work/pcover.png"),
130 "a clean project cover must render"
131 );
132 }
133
134 // Gallery carousel
135
136 /// Insert a gallery image row directly with a chosen scan_status.
137 async fn insert_gallery_image(h: &TestHarness, item: &str, key: &str, pos: i32, status: &str) {
138 sqlx::query(
139 "INSERT INTO item_images (item_id, s3_key, image_url, alt, position, file_size_bytes, scan_status) \
140 VALUES ($1::uuid, $2, $3, 'caption', $4, 10, $5)",
141 )
142 .bind(item)
143 .bind(key)
144 .bind(format!("https://cdn.makenot.work/{key}"))
145 .bind(pos)
146 .bind(status)
147 .execute(&h.db)
148 .await
149 .unwrap();
150 }
151
152 #[tokio::test]
153 async fn held_gallery_image_excluded_from_list_for_item() {
154 let mut h = TestHarness::with_storage().await;
155 let (_, _, iid) = creator_with_item(&mut h).await;
156
157 insert_gallery_image(&h, &iid, "clean.png", 0, "clean").await;
158 insert_gallery_image(&h, &iid, "held.png", 1, "held_for_review").await;
159 insert_gallery_image(&h, &iid, "pending.png", 2, "pending").await;
160
161 let listed = makenotwork::db::gallery_images::list_for_item(&h.db, item_id(&iid))
162 .await
163 .unwrap();
164 let keys: Vec<&str> = listed.iter().map(|g| g.s3_key.as_str()).collect();
165 assert_eq!(
166 keys,
167 vec!["clean.png"],
168 "only the clean gallery image renders"
169 );
170
171 // The per-entity cap still counts every row (held/pending included), so a
172 // held image cannot be re-uploaded around the limit.
173 let cap_count = makenotwork::db::gallery_images::count_for_item(&h.db, item_id(&iid))
174 .await
175 .unwrap();
176 assert_eq!(cap_count, 3, "the cap count includes non-clean rows");
177 }
178
179 // Content insertions (fan playback vs creator management)
180
181 /// Presign + upload + confirm one insertion clip for the logged-in creator, then
182 /// place it as a pre-roll on `item_id`. Returns the insertion's id + storage key.
183 async fn add_placed_insertion(h: &mut TestHarness, item: &str, title: &str) -> (String, String) {
184 let resp = h
185 .client
186 .post_json(
187 "/api/users/me/insertions/presign",
188 &json!({ "file_name": "clip.mp3", "content_type": "audio/mpeg" }).to_string(),
189 )
190 .await;
191 assert_eq!(resp.status, 200, "insertion presign failed: {}", resp.text);
192 let s3_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
193
194 h.storage
195 .as_ref()
196 .unwrap()
197 .put(&s3_key, b"fake clip".to_vec());
198
199 let resp = h
200 .client
201 .post_json(
202 "/api/users/me/insertions/confirm",
203 &json!({
204 "s3_key": s3_key,
205 "title": title,
206 "duration_ms": 5000,
207 "file_size": 9,
208 "mime_type": "audio/mpeg",
209 })
210 .to_string(),
211 )
212 .await;
213 assert_eq!(resp.status, 200, "insertion confirm failed: {}", resp.text);
214 let insertion_id = resp.json::<Value>()["id"].as_str().unwrap().to_string();
215
216 let resp = h
217 .client
218 .post_json(
219 &format!("/api/items/{item}/insertions"),
220 &json!({ "insertion_id": insertion_id, "position": "pre_roll" }).to_string(),
221 )
222 .await;
223 assert_eq!(resp.status, 200, "placement failed: {}", resp.text);
224
225 (insertion_id, s3_key)
226 }
227
228 async fn set_insertion_scan_status(h: &TestHarness, id: &str, status: &str) {
229 sqlx::query("UPDATE content_insertions SET scan_status = $2 WHERE id = $1::uuid")
230 .bind(id)
231 .bind(status)
232 .execute(&h.db)
233 .await
234 .unwrap();
235 }
236
237 #[tokio::test]
238 async fn held_insertion_not_served_to_fans_but_visible_to_creator() {
239 let mut h = TestHarness::with_storage().await;
240 let (_, pid, iid) = creator_with_item(&mut h).await;
241 h.publish_project_and_item(&pid, &iid).await;
242
243 let title = "SponsorClipXYZ";
244 let (ins_id, _key) = add_placed_insertion(&mut h, &iid, title).await;
245
246 // Held: hidden from the fan playback path (library page segments)...
247 set_insertion_scan_status(&h, &ins_id, "held_for_review").await;
248 let fan = h.client.get(&format!("/l/{iid}")).await;
249 assert_eq!(fan.status, 200, "library page failed: {}", fan.text);
250 assert!(
251 !fan.text.contains(title),
252 "a held insertion must not be spliced into fan playback"
253 );
254
255 // ...but still present in the creator's management library (ungated).
256 let manage = h.client.get("/api/users/me/insertions").await;
257 assert_eq!(manage.status, 200, "manage list failed: {}", manage.text);
258 assert!(
259 manage.text.contains(title),
260 "the creator must still see their held clip to manage it"
261 );
262
263 // Clean: now served to fans.
264 set_insertion_scan_status(&h, &ins_id, "clean").await;
265 let fan = h.client.get(&format!("/l/{iid}")).await;
266 assert_eq!(fan.status, 200, "library page failed: {}", fan.text);
267 assert!(
268 fan.text.contains(title),
269 "a clean insertion must be served to fans"
270 );
271 }
272