Skip to main content

max / makenotwork

8.9 KB · 278 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(&db_item, &[], true, true)
60 }
61
62 async fn project_view(h: &TestHarness, id: &str) -> Project {
63 let db_project = makenotwork::db::projects::get_project_by_id(&h.db, project_id(id))
64 .await
65 .unwrap()
66 .expect("project exists");
67 Project::from_db(&db_project, 0)
68 }
69
70 // Covers (items + projects)
71
72 #[tokio::test]
73 async fn pending_cover_hidden_from_item_view() {
74 let mut h = TestHarness::with_storage().await;
75 let (_, _, iid) = creator_with_item(&mut h).await;
76 set_item_cover(&h, &iid, "pending").await;
77 assert_eq!(
78 item_view(&h, &iid).await.cover_image_url,
79 None,
80 "a pending (unscanned) cover must not render"
81 );
82 }
83
84 #[tokio::test]
85 async fn held_cover_hidden_from_item_view() {
86 let mut h = TestHarness::with_storage().await;
87 let (_, _, iid) = creator_with_item(&mut h).await;
88 set_item_cover(&h, &iid, "held_for_review").await;
89 assert_eq!(
90 item_view(&h, &iid).await.cover_image_url,
91 None,
92 "a held cover must not render"
93 );
94 }
95
96 #[tokio::test]
97 async fn clean_cover_renders_in_item_view() {
98 let mut h = TestHarness::with_storage().await;
99 let (_, _, iid) = creator_with_item(&mut h).await;
100 set_item_cover(&h, &iid, "clean").await;
101 assert_eq!(
102 item_view(&h, &iid).await.cover_image_url.as_deref(),
103 Some("https://cdn.makenot.work/cover.png"),
104 "a clean cover must render"
105 );
106 }
107
108 #[tokio::test]
109 async fn held_project_cover_image_hidden_clean_renders() {
110 let mut h = TestHarness::with_storage().await;
111 let (_, pid, _) = creator_with_item(&mut h).await;
112
113 set_project_cover(&h, &pid, "held_for_review").await;
114 assert_eq!(
115 project_view(&h, &pid).await.cover_image_url,
116 None,
117 "a held project cover must not render"
118 );
119
120 set_project_cover(&h, &pid, "clean").await;
121 assert_eq!(
122 project_view(&h, &pid).await.cover_image_url.as_deref(),
123 Some("https://cdn.makenot.work/pcover.png"),
124 "a clean project cover must render"
125 );
126 }
127
128 // Gallery carousel
129
130 /// Insert a gallery image row directly with a chosen scan_status.
131 async fn insert_gallery_image(h: &TestHarness, item: &str, key: &str, pos: i32, status: &str) {
132 sqlx::query(
133 "INSERT INTO item_images (item_id, s3_key, image_url, alt, position, file_size_bytes, scan_status) \
134 VALUES ($1::uuid, $2, $3, 'caption', $4, 10, $5)",
135 )
136 .bind(item)
137 .bind(key)
138 .bind(format!("https://cdn.makenot.work/{key}"))
139 .bind(pos)
140 .bind(status)
141 .execute(&h.db)
142 .await
143 .unwrap();
144 }
145
146 #[tokio::test]
147 async fn held_gallery_image_excluded_from_list_for_item() {
148 let mut h = TestHarness::with_storage().await;
149 let (_, _, iid) = creator_with_item(&mut h).await;
150
151 insert_gallery_image(&h, &iid, "clean.png", 0, "clean").await;
152 insert_gallery_image(&h, &iid, "held.png", 1, "held_for_review").await;
153 insert_gallery_image(&h, &iid, "pending.png", 2, "pending").await;
154
155 let listed = makenotwork::db::gallery_images::list_for_item(&h.db, item_id(&iid))
156 .await
157 .unwrap();
158 let keys: Vec<&str> = listed.iter().map(|g| g.s3_key.as_str()).collect();
159 assert_eq!(
160 keys,
161 vec!["clean.png"],
162 "only the clean gallery image renders"
163 );
164
165 // The per-entity cap still counts every row (held/pending included), so a
166 // held image cannot be re-uploaded around the limit.
167 let cap_count = makenotwork::db::gallery_images::count_for_item(&h.db, item_id(&iid))
168 .await
169 .unwrap();
170 assert_eq!(cap_count, 3, "the cap count includes non-clean rows");
171 }
172
173 // Content insertions (fan playback vs creator management)
174
175 /// Presign + upload + confirm one insertion clip for the logged-in creator, then
176 /// place it as a pre-roll on `item_id`. Returns the insertion's id + storage key.
177 async fn add_placed_insertion(h: &mut TestHarness, item: &str, title: &str) -> (String, String) {
178 let resp = h
179 .client
180 .post_json(
181 "/api/users/me/insertions/presign",
182 &json!({ "file_name": "clip.mp3", "content_type": "audio/mpeg" }).to_string(),
183 )
184 .await;
185 assert!(
186 resp.status.is_success(),
187 "insertion presign failed: {}",
188 resp.text
189 );
190 let s3_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
191
192 h.storage
193 .as_ref()
194 .unwrap()
195 .put(&s3_key, b"fake clip".to_vec());
196
197 let resp = h
198 .client
199 .post_json(
200 "/api/users/me/insertions/confirm",
201 &json!({
202 "s3_key": s3_key,
203 "title": title,
204 "duration_ms": 5000,
205 "file_size": 9,
206 "mime_type": "audio/mpeg",
207 })
208 .to_string(),
209 )
210 .await;
211 assert!(
212 resp.status.is_success(),
213 "insertion confirm failed: {}",
214 resp.text
215 );
216 let insertion_id = resp.json::<Value>()["id"].as_str().unwrap().to_string();
217
218 let resp = h
219 .client
220 .post_json(
221 &format!("/api/items/{item}/insertions"),
222 &json!({ "insertion_id": insertion_id, "position": "pre_roll" }).to_string(),
223 )
224 .await;
225 assert!(resp.status.is_success(), "placement failed: {}", resp.text);
226
227 (insertion_id, s3_key)
228 }
229
230 async fn set_insertion_scan_status(h: &TestHarness, id: &str, status: &str) {
231 sqlx::query("UPDATE content_insertions SET scan_status = $2 WHERE id = $1::uuid")
232 .bind(id)
233 .bind(status)
234 .execute(&h.db)
235 .await
236 .unwrap();
237 }
238
239 #[tokio::test]
240 async fn held_insertion_not_served_to_fans_but_visible_to_creator() {
241 let mut h = TestHarness::with_storage().await;
242 let (_, pid, iid) = creator_with_item(&mut h).await;
243 h.publish_project_and_item(&pid, &iid).await;
244
245 let title = "SponsorClipXYZ";
246 let (ins_id, _key) = add_placed_insertion(&mut h, &iid, title).await;
247
248 // Held: hidden from the fan playback path (library page segments)...
249 set_insertion_scan_status(&h, &ins_id, "held_for_review").await;
250 let fan = h.client.get(&format!("/l/{iid}")).await;
251 assert!(fan.status.is_success(), "library page failed: {}", fan.text);
252 assert!(
253 !fan.text.contains(title),
254 "a held insertion must not be spliced into fan playback"
255 );
256
257 // ...but still present in the creator's management library (ungated).
258 let manage = h.client.get("/api/users/me/insertions").await;
259 assert!(
260 manage.status.is_success(),
261 "manage list failed: {}",
262 manage.text
263 );
264 assert!(
265 manage.text.contains(title),
266 "the creator must still see their held clip to manage it"
267 );
268
269 // Clean: now served to fans.
270 set_insertion_scan_status(&h, &ins_id, "clean").await;
271 let fan = h.client.get(&format!("/l/{iid}")).await;
272 assert!(fan.status.is_success(), "library page failed: {}", fan.text);
273 assert!(
274 fan.text.contains(title),
275 "a clean insertion must be served to fans"
276 );
277 }
278