Skip to main content

max / makenotwork

17.9 KB · 458 lines History Blame Raw
1 //! Media library integration tests — image/video upload, folder listing, delete, tier gating.
2
3 use crate::harness::TestHarness;
4 use makenotwork::storage::StorageBackend;
5 use serde_json::{json, Value};
6
7 /// Minimal valid PNG (1x1 transparent pixel).
8 const TINY_PNG: &[u8] = &[
9 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
10 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
11 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
12 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, 0x89, // RGBA, CRC
13 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, 0x54, // IDAT chunk
14 0x78, 0x9C, 0x62, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE5, // compressed data
15 0x27, 0xDE, 0xFC, // CRC
16 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, // IEND
17 0xAE, 0x42, 0x60, 0x82, // CRC
18 ];
19
20 const TEST_MP4: &[u8] = include_bytes!("../fixtures/test.mp4");
21
22 /// Helper: set up a trusted creator with basic tier (no file uploads allowed).
23 async fn setup_basic_creator(h: &mut TestHarness, username: &str) -> String {
24 let user_id = h.create_creator(username).await;
25 h.trust_user(user_id).await;
26 h.grant_tier(user_id, "basic").await;
27 user_id.to_string()
28 }
29
30 /// Helper: set up a trusted creator with big_files tier.
31 async fn setup_bigfiles_creator(h: &mut TestHarness, username: &str) -> String {
32 let user_id = h.create_creator(username).await;
33 h.trust_user(user_id).await;
34 h.grant_tier(user_id, "big_files").await;
35 user_id.to_string()
36 }
37
38 /// Helper: presign + put bytes + confirm a media file. Returns (s3_key, file_id).
39 async fn upload_media(
40 h: &mut TestHarness,
41 file_name: &str,
42 content_type: &str,
43 folder: &str,
44 data: &[u8],
45 ) -> (String, String) {
46 // Presign
47 let body = json!({
48 "file_name": file_name,
49 "content_type": content_type,
50 "folder": folder,
51 });
52 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
53 assert!(resp.status.is_success(), "Media presign failed: {}", resp.text);
54 let presign: Value = resp.json();
55 let s3_key = presign["s3_key"].as_str().unwrap().to_string();
56
57 // Put bytes into in-memory storage
58 h.storage.as_ref().unwrap().put(&s3_key, data.to_vec());
59
60 // Confirm
61 let body = json!({
62 "s3_key": s3_key,
63 "file_name": file_name,
64 "content_type": content_type,
65 "folder": folder,
66 });
67 let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await;
68 assert!(resp.status.is_success(), "Media confirm failed: {}", resp.text);
69
70 // Get the file ID from the list
71 let list_resp = h.client.get(&format!("/api/media?folder={}", folder)).await;
72 let list: Value = list_resp.json();
73 let files = list["files"].as_array().unwrap();
74 let file_id = files
75 .iter()
76 .find(|f| f["filename"].as_str().unwrap() == file_name)
77 .expect("Uploaded file not in list")["id"]
78 .as_str()
79 .unwrap()
80 .to_string();
81
82 (s3_key, file_id)
83 }
84
85 // ---------------------------------------------------------------------------
86 // Image upload on Basic tier succeeds (images bypass tier check like covers)
87 // ---------------------------------------------------------------------------
88
89 #[tokio::test]
90 async fn image_upload_basic_tier_succeeds() {
91 let mut h = TestHarness::with_storage().await;
92 setup_basic_creator(&mut h, "imgbasic").await;
93
94 let body = json!({
95 "file_name": "photo.png",
96 "content_type": "image/png",
97 "folder": "screenshots",
98 });
99 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
100 assert!(resp.status.is_success(), "Image presign should succeed on basic tier: {}", resp.text);
101
102 let data: Value = resp.json();
103 let s3_key = data["s3_key"].as_str().unwrap().to_string();
104
105 h.storage.as_ref().unwrap().put(&s3_key, TINY_PNG.to_vec());
106
107 let body = json!({
108 "s3_key": s3_key,
109 "file_name": "photo.png",
110 "content_type": "image/png",
111 "folder": "screenshots",
112 });
113 let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await;
114 assert!(resp.status.is_success(), "Image confirm should succeed on basic tier: {}", resp.text);
115 }
116
117 // ---------------------------------------------------------------------------
118 // Video upload on Basic tier rejected (requires BigFiles+)
119 // ---------------------------------------------------------------------------
120
121 #[tokio::test]
122 async fn video_upload_basic_tier_rejected() {
123 let mut h = TestHarness::with_storage().await;
124 setup_basic_creator(&mut h, "vidbasic").await;
125
126 let body = json!({
127 "file_name": "demo.mp4",
128 "content_type": "video/mp4",
129 "folder": "",
130 });
131 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
132 assert!(
133 resp.status.is_client_error(),
134 "Video presign should fail on basic tier: {} {}",
135 resp.status, resp.text
136 );
137 }
138
139 // ---------------------------------------------------------------------------
140 // Video upload on BigFiles tier succeeds
141 // ---------------------------------------------------------------------------
142
143 #[tokio::test]
144 async fn video_upload_bigfiles_tier_succeeds() {
145 let mut h = TestHarness::with_storage().await;
146 setup_bigfiles_creator(&mut h, "vidbig").await;
147
148 let body = json!({
149 "file_name": "demo.mp4",
150 "content_type": "video/mp4",
151 "folder": "clips",
152 });
153 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
154 assert!(resp.status.is_success(), "Video presign should succeed on big_files tier: {}", resp.text);
155
156 let data: Value = resp.json();
157 let s3_key = data["s3_key"].as_str().unwrap().to_string();
158
159 h.storage.as_ref().unwrap().put(&s3_key, TEST_MP4.to_vec());
160
161 let body = json!({
162 "s3_key": s3_key,
163 "file_name": "demo.mp4",
164 "content_type": "video/mp4",
165 "folder": "clips",
166 });
167 let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await;
168 assert!(resp.status.is_success(), "Video confirm should succeed on big_files tier: {}", resp.text);
169 }
170
171 // ---------------------------------------------------------------------------
172 // List files by folder
173 // ---------------------------------------------------------------------------
174
175 #[tokio::test]
176 async fn list_files_by_folder() {
177 let mut h = TestHarness::with_storage().await;
178 setup_bigfiles_creator(&mut h, "listuser").await;
179
180 // Upload to two different folders
181 upload_media(&mut h, "img1.png", "image/png", "art", TINY_PNG).await;
182 upload_media(&mut h, "img2.png", "image/png", "photos", TINY_PNG).await;
183
184 // List all
185 let resp = h.client.get("/api/media").await;
186 assert!(resp.status.is_success());
187 let data: Value = resp.json();
188 assert_eq!(data["files"].as_array().unwrap().len(), 2, "Should list all files");
189 let folders = data["folders"].as_array().unwrap();
190 assert!(folders.iter().any(|f| f.as_str() == Some("art")));
191 assert!(folders.iter().any(|f| f.as_str() == Some("photos")));
192
193 // List filtered by folder
194 let resp = h.client.get("/api/media?folder=art").await;
195 assert!(resp.status.is_success());
196 let data: Value = resp.json();
197 assert_eq!(data["files"].as_array().unwrap().len(), 1, "Should list only art folder files");
198 assert_eq!(data["files"][0]["filename"].as_str().unwrap(), "img1.png");
199
200 // List folders
201 let resp = h.client.get("/api/media/folders").await;
202 assert!(resp.status.is_success());
203 let data: Value = resp.json();
204 let folders = data["folders"].as_array().unwrap();
205 assert_eq!(folders.len(), 2);
206 }
207
208 // ---------------------------------------------------------------------------
209 // Delete media file (storage decremented)
210 // ---------------------------------------------------------------------------
211
212 #[tokio::test]
213 async fn delete_media_file_decrements_storage() {
214 let mut h = TestHarness::with_storage().await;
215 let user_id_str = setup_bigfiles_creator(&mut h, "deluser").await;
216
217 let (s3_key, file_id) = upload_media(&mut h, "todel.png", "image/png", "", TINY_PNG).await;
218
219 // Verify storage was incremented
220 let storage_before: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
221 .bind(&user_id_str)
222 .fetch_one(&h.db)
223 .await
224 .unwrap();
225 assert!(storage_before > 0, "Storage should be non-zero after upload");
226
227 // Delete the file
228 let resp = h.client.delete(&format!("/api/media/{}", file_id)).await;
229 assert!(resp.status.is_success(), "Delete should succeed: {}", resp.text);
230
231 // Verify storage was decremented
232 let storage_after: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
233 .bind(&user_id_str)
234 .fetch_one(&h.db)
235 .await
236 .unwrap();
237 assert_eq!(storage_after, 0, "Storage should be zero after deletion");
238
239 // Verify file is gone from DB
240 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media_files WHERE id = $1::uuid")
241 .bind(&file_id)
242 .fetch_one(&h.db)
243 .await
244 .unwrap();
245 assert_eq!(count, 0, "Media file should be deleted from DB");
246
247 // Verify file is gone from S3 (via trait method)
248 let s3_exists = makenotwork::storage::StorageBackend::object_exists(
249 h.storage.as_ref().unwrap().as_ref(),
250 &s3_key,
251 )
252 .await
253 .unwrap();
254 assert!(!s3_exists, "File should be deleted from storage");
255 }
256
257 // ---------------------------------------------------------------------------
258 // Filename collision rejected
259 // ---------------------------------------------------------------------------
260
261 #[tokio::test]
262 async fn filename_collision_rejected() {
263 let mut h = TestHarness::with_storage().await;
264 setup_bigfiles_creator(&mut h, "colluser").await;
265
266 // Upload first file
267 upload_media(&mut h, "same.png", "image/png", "art", TINY_PNG).await;
268
269 // Filename uniqueness is enforced at CONFIRM time now, not presign — the
270 // pre-check was removed because it raced against concurrent presigns.
271 // Presign succeeds; the confirm catches the duplicate via the
272 // `idx_media_files_user_folder_name` unique index.
273 let body = json!({
274 "file_name": "same.png",
275 "content_type": "image/png",
276 "folder": "art",
277 });
278 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
279 assert!(resp.status.is_success(), "Presign no longer pre-checks; should succeed: {}", resp.text);
280 let presign: Value = resp.json();
281 let dup_s3_key = presign["s3_key"].as_str().unwrap().to_string();
282 h.storage.as_ref().unwrap().put(&dup_s3_key, TINY_PNG.to_vec());
283
284 let body = json!({
285 "s3_key": dup_s3_key,
286 "file_name": "same.png",
287 "content_type": "image/png",
288 "folder": "art",
289 });
290 let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await;
291 assert!(
292 resp.status.is_client_error(),
293 "Duplicate filename should be rejected at confirm time: {} {}",
294 resp.status, resp.text
295 );
296 assert!(resp.text.contains("already exists"), "Error should mention collision: {}", resp.text);
297 }
298
299 // ---------------------------------------------------------------------------
300 // Same filename in different folders is allowed
301 // ---------------------------------------------------------------------------
302
303 #[tokio::test]
304 async fn same_filename_different_folder_allowed() {
305 let mut h = TestHarness::with_storage().await;
306 setup_bigfiles_creator(&mut h, "diffuser").await;
307
308 upload_media(&mut h, "logo.png", "image/png", "art", TINY_PNG).await;
309 upload_media(&mut h, "logo.png", "image/png", "photos", TINY_PNG).await;
310
311 let resp = h.client.get("/api/media").await;
312 let data: Value = resp.json();
313 assert_eq!(data["files"].as_array().unwrap().len(), 2, "Same name in different folders should both exist");
314 }
315
316 // ---------------------------------------------------------------------------
317 // Path traversal in folder name rejected
318 // ---------------------------------------------------------------------------
319
320 #[tokio::test]
321 async fn path_traversal_in_folder_rejected() {
322 let mut h = TestHarness::with_storage().await;
323 setup_bigfiles_creator(&mut h, "travuser").await;
324
325 let body = json!({
326 "file_name": "exploit.png",
327 "content_type": "image/png",
328 "folder": "../../../etc",
329 });
330 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
331 assert!(
332 resp.status.is_client_error(),
333 "Path traversal folder should be rejected: {} {}",
334 resp.status, resp.text
335 );
336 }
337
338 // ---------------------------------------------------------------------------
339 // Storage cap enforcement (video respects tier cap)
340 // ---------------------------------------------------------------------------
341
342 #[tokio::test]
343 async fn video_storage_cap_enforcement() {
344 let mut h = TestHarness::with_storage().await;
345 let user_id_str = setup_bigfiles_creator(&mut h, "capuser").await;
346
347 // Set storage_used_bytes near the big_files tier cap (500 GB)
348 let near_cap = 500_i64 * 1024 * 1024 * 1024 - 1;
349 sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1::uuid")
350 .bind(&user_id_str)
351 .bind(near_cap)
352 .execute(&h.db)
353 .await
354 .expect("set storage near cap");
355
356 // Video upload should fail because storage cap would be exceeded
357 let body = json!({
358 "file_name": "big.mp4",
359 "content_type": "video/mp4",
360 "folder": "",
361 });
362 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
363
364 // Presign may succeed (it's a pre-check on tier, not storage), try confirm
365 if resp.status.is_success() {
366 let data: Value = resp.json();
367 let s3_key = data["s3_key"].as_str().unwrap().to_string();
368
369 // Put video bytes
370 h.storage.as_ref().unwrap().put(&s3_key, TEST_MP4.to_vec());
371
372 let body = json!({
373 "s3_key": s3_key,
374 "file_name": "big.mp4",
375 "content_type": "video/mp4",
376 "folder": "",
377 });
378 let resp = h.client.post_json("/api/media/confirm", &body.to_string()).await;
379 assert!(
380 resp.status.is_client_error(),
381 "Confirm should fail when storage cap exceeded: {} {}",
382 resp.status, resp.text
383 );
384 }
385 // If presign itself rejected it, that's also correct
386 }
387
388 // ---------------------------------------------------------------------------
389 // Unsupported content type rejected
390 // ---------------------------------------------------------------------------
391
392 #[tokio::test]
393 async fn unsupported_content_type_rejected() {
394 let mut h = TestHarness::with_storage().await;
395 setup_bigfiles_creator(&mut h, "badtype").await;
396
397 let body = json!({
398 "file_name": "script.js",
399 "content_type": "application/javascript",
400 "folder": "",
401 });
402 let resp = h.client.post_json("/api/media/presign", &body.to_string()).await;
403 assert!(
404 resp.status.is_client_error(),
405 "Non-image/video content type should be rejected: {} {}",
406 resp.status, resp.text
407 );
408 }
409
410 // ---------------------------------------------------------------------------
411 // A duplicate confirm is rejected, but must NOT delete the live object
412 // (Run #11 HIGH regression). Media keys are deterministic by (user, folder,
413 // filename), so a retried/duplicate confirm resolves to the same key the
414 // committed row already points at. The product rejects the duplicate
415 // ("already exists"), but the previous code's error arm deleted that key,
416 // torpedoing the existing file. The fix keeps the rejection and preserves the
417 // object + the storage charge.
418 // ---------------------------------------------------------------------------
419
420 #[tokio::test]
421 async fn media_duplicate_confirm_rejects_without_deleting_live_object() {
422 let mut h = TestHarness::with_storage().await;
423 let user_id = setup_bigfiles_creator(&mut h, "mediareconfirm").await;
424
425 // Presign + upload + first confirm.
426 let presign_body = json!({"file_name": "pic.png", "content_type": "image/png", "folder": ""});
427 let resp = h.client.post_json("/api/media/presign", &presign_body.to_string()).await;
428 assert!(resp.status.is_success(), "presign failed: {}", resp.text);
429 let s3_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
430 h.storage.as_ref().unwrap().put(&s3_key, TINY_PNG.to_vec());
431
432 let confirm_body = json!({"s3_key": s3_key, "file_name": "pic.png", "content_type": "image/png", "folder": ""});
433 let resp = h.client.post_json("/api/media/confirm", &confirm_body.to_string()).await;
434 assert!(resp.status.is_success(), "first confirm failed: {}", resp.text);
435
436 let used_after_first: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
437 .bind(&user_id).fetch_one(&h.db).await.unwrap();
438 assert_eq!(used_after_first, TINY_PNG.len() as i64);
439
440 // Re-confirm the SAME key. Rejected as a duplicate ...
441 let resp = h.client.post_json("/api/media/confirm", &confirm_body.to_string()).await;
442 assert!(resp.status.is_client_error(), "duplicate confirm should be rejected: {} {}", resp.status, resp.text);
443 assert!(resp.text.contains("already exists"), "rejection should name the collision: {}", resp.text);
444
445 // ... but the live object the committed row points at must SURVIVE (the HIGH).
446 assert!(
447 h.storage.as_ref().unwrap().object_exists(&s3_key).await.unwrap(),
448 "duplicate confirm must NOT delete the live object"
449 );
450 // ... and the rolled-back tx must not have double-charged storage.
451 let used_after_second: i64 = sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
452 .bind(&user_id).fetch_one(&h.db).await.unwrap();
453 assert_eq!(used_after_second, TINY_PNG.len() as i64, "duplicate confirm must not double-charge storage");
454 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media_files WHERE s3_key = $1")
455 .bind(&s3_key).fetch_one(&h.db).await.unwrap();
456 assert_eq!(count, 1, "exactly one media_files row remains");
457 }
458