Skip to main content

max / makenotwork

22.1 KB · 691 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::{Value, json};
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
53 .client
54 .post_json("/api/media/presign", &body.to_string())
55 .await;
56 assert_eq!(resp.status, 200, "Media presign failed: {}", resp.text);
57 let presign: Value = resp.json();
58 let s3_key = presign["s3_key"].as_str().unwrap().to_string();
59
60 // Put bytes into in-memory storage
61 h.storage.as_ref().unwrap().put(&s3_key, data.to_vec());
62
63 // Confirm
64 let body = json!({
65 "s3_key": s3_key,
66 "file_name": file_name,
67 "content_type": content_type,
68 "folder": folder,
69 });
70 let resp = h
71 .client
72 .post_json("/api/media/confirm", &body.to_string())
73 .await;
74 assert_eq!(resp.status, 200, "Media confirm failed: {}", resp.text);
75
76 // Get the file ID from the list
77 let list_resp = h.client.get(&format!("/api/media?folder={folder}")).await;
78 let list: Value = list_resp.json();
79 let files = list["files"].as_array().unwrap();
80 let file_id = files
81 .iter()
82 .find(|f| f["filename"].as_str().unwrap() == file_name)
83 .expect("Uploaded file not in list")["id"]
84 .as_str()
85 .unwrap()
86 .to_string();
87
88 (s3_key, file_id)
89 }
90
91 /// Minimal MP4 header, `ftyp` box with the `isom` brand. `infer` classifies
92 /// this as a video, regardless of the declared content type.
93 const TINY_MP4: &[u8] = &[
94 0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70, // size + "ftyp"
95 0x69, 0x73, 0x6F, 0x6D, 0x00, 0x00, 0x02, 0x00, // "isom" + minor version
96 0x69, 0x73, 0x6F, 0x6D, 0x69, 0x73, 0x6F, 0x32, // compatible brands
97 ];
98
99 // A video declared as an image is rejected at confirm (tier-gate evasion)
100
101 #[tokio::test]
102 async fn video_declared_as_image_is_rejected_at_confirm() {
103 // Run #22 Storage MED: the declared content_type is client-controlled. A
104 // video uploaded under an image/png declaration would dodge the BigFiles+
105 // video-tier gate. The confirm-time byte sniff must catch and reject it.
106 let mut h = TestHarness::with_storage().await;
107 setup_basic_creator(&mut h, "ctevasion").await;
108
109 let body = json!({
110 "file_name": "sneaky.png",
111 "content_type": "image/png",
112 "folder": "clips",
113 });
114 let resp = h
115 .client
116 .post_json("/api/media/presign", &body.to_string())
117 .await;
118 assert_eq!(
119 resp.status, 200,
120 "image presign should succeed: {}",
121 resp.text
122 );
123 let presign: Value = resp.json();
124 let s3_key = presign["s3_key"].as_str().unwrap().to_string();
125
126 // Upload actual MP4 bytes under the image key.
127 h.storage.as_ref().unwrap().put(&s3_key, TINY_MP4.to_vec());
128
129 let body = json!({
130 "s3_key": s3_key,
131 "file_name": "sneaky.png",
132 "content_type": "image/png",
133 "folder": "clips",
134 });
135 let resp = h
136 .client
137 .post_json("/api/media/confirm", &body.to_string())
138 .await;
139 assert_eq!(
140 resp.status, 400,
141 "a video declared as an image must be rejected at confirm, got: {} {}",
142 resp.status, resp.text
143 );
144
145 // Nothing was recorded in the library.
146 let list: Value = h.client.get("/api/media?folder=clips").await.json();
147 assert!(
148 list["files"].as_array().is_none_or(std::vec::Vec::is_empty),
149 "rejected upload must not appear in the media library"
150 );
151 }
152
153 // Bytes `infer` cannot classify, declared as an image, are rejected at confirm
154
155 #[tokio::test]
156 async fn unrecognized_bytes_declared_as_image_is_rejected_at_confirm() {
157 // Run #5 Storage S1: the old sniff only rejected a *positively detected*
158 // video, so a video whose container `infer` cannot name slipped through an
159 // `image/png` declaration. Declared images must now sniff positively as an
160 // image; unrecognized bytes are rejected, closing the bypass.
161 let mut h = TestHarness::with_storage().await;
162 setup_basic_creator(&mut h, "unrecimg").await;
163
164 let body = json!({
165 "file_name": "blob.png",
166 "content_type": "image/png",
167 "folder": "clips",
168 });
169 let resp = h
170 .client
171 .post_json("/api/media/presign", &body.to_string())
172 .await;
173 assert_eq!(
174 resp.status, 200,
175 "image presign should succeed: {}",
176 resp.text
177 );
178 let presign: Value = resp.json();
179 let s3_key = presign["s3_key"].as_str().unwrap().to_string();
180
181 // Bytes `infer` does not recognize as any image or video format.
182 h.storage
183 .as_ref()
184 .unwrap()
185 .put(&s3_key, b"not a real image at all".to_vec());
186
187 let body = json!({
188 "s3_key": s3_key,
189 "file_name": "blob.png",
190 "content_type": "image/png",
191 "folder": "clips",
192 });
193 let resp = h
194 .client
195 .post_json("/api/media/confirm", &body.to_string())
196 .await;
197 assert_eq!(
198 resp.status, 400,
199 "unrecognized bytes declared as an image must be rejected, got: {} {}",
200 resp.status, resp.text
201 );
202
203 let list: Value = h.client.get("/api/media?folder=clips").await.json();
204 assert!(
205 list["files"].as_array().is_none_or(std::vec::Vec::is_empty),
206 "rejected upload must not appear in the media library"
207 );
208 }
209
210 // Image upload on Basic tier succeeds (images bypass tier check like covers)
211
212 #[tokio::test]
213 async fn image_upload_basic_tier_succeeds() {
214 let mut h = TestHarness::with_storage().await;
215 setup_basic_creator(&mut h, "imgbasic").await;
216
217 let body = json!({
218 "file_name": "photo.png",
219 "content_type": "image/png",
220 "folder": "screenshots",
221 });
222 let resp = h
223 .client
224 .post_json("/api/media/presign", &body.to_string())
225 .await;
226 assert_eq!(
227 resp.status, 200,
228 "Image presign should succeed on basic tier: {}",
229 resp.text
230 );
231
232 let data: Value = resp.json();
233 let s3_key = data["s3_key"].as_str().unwrap().to_string();
234
235 h.storage.as_ref().unwrap().put(&s3_key, TINY_PNG.to_vec());
236
237 let body = json!({
238 "s3_key": s3_key,
239 "file_name": "photo.png",
240 "content_type": "image/png",
241 "folder": "screenshots",
242 });
243 let resp = h
244 .client
245 .post_json("/api/media/confirm", &body.to_string())
246 .await;
247 assert_eq!(
248 resp.status, 200,
249 "Image confirm should succeed on basic tier: {}",
250 resp.text
251 );
252 }
253
254 // Video upload on Basic tier rejected (requires BigFiles+)
255
256 #[tokio::test]
257 async fn video_upload_basic_tier_rejected() {
258 let mut h = TestHarness::with_storage().await;
259 setup_basic_creator(&mut h, "vidbasic").await;
260
261 let body = json!({
262 "file_name": "demo.mp4",
263 "content_type": "video/mp4",
264 "folder": "",
265 });
266 let resp = h
267 .client
268 .post_json("/api/media/presign", &body.to_string())
269 .await;
270 assert_eq!(
271 resp.status, 400,
272 "Video presign should fail on basic tier: {} {}",
273 resp.status, resp.text
274 );
275 }
276
277 // Video upload on BigFiles tier succeeds
278
279 #[tokio::test]
280 async fn video_upload_bigfiles_tier_succeeds() {
281 let mut h = TestHarness::with_storage().await;
282 setup_bigfiles_creator(&mut h, "vidbig").await;
283
284 let body = json!({
285 "file_name": "demo.mp4",
286 "content_type": "video/mp4",
287 "folder": "clips",
288 });
289 let resp = h
290 .client
291 .post_json("/api/media/presign", &body.to_string())
292 .await;
293 assert_eq!(
294 resp.status, 200,
295 "Video presign should succeed on big_files tier: {}",
296 resp.text
297 );
298
299 let data: Value = resp.json();
300 let s3_key = data["s3_key"].as_str().unwrap().to_string();
301
302 h.storage.as_ref().unwrap().put(&s3_key, TEST_MP4.to_vec());
303
304 let body = json!({
305 "s3_key": s3_key,
306 "file_name": "demo.mp4",
307 "content_type": "video/mp4",
308 "folder": "clips",
309 });
310 let resp = h
311 .client
312 .post_json("/api/media/confirm", &body.to_string())
313 .await;
314 assert_eq!(
315 resp.status, 200,
316 "Video confirm should succeed on big_files tier: {}",
317 resp.text
318 );
319 }
320
321 // List files by folder
322
323 #[tokio::test]
324 async fn list_files_by_folder() {
325 let mut h = TestHarness::with_storage().await;
326 setup_bigfiles_creator(&mut h, "listuser").await;
327
328 // Upload to two different folders
329 upload_media(&mut h, "img1.png", "image/png", "art", TINY_PNG).await;
330 upload_media(&mut h, "img2.png", "image/png", "photos", TINY_PNG).await;
331
332 // List all
333 let resp = h.client.get("/api/media").await;
334 assert_eq!(resp.status, 200, "{}", resp.text);
335 let data: Value = resp.json();
336 assert_eq!(
337 data["files"].as_array().unwrap().len(),
338 2,
339 "Should list all files"
340 );
341 let folders = data["folders"].as_array().unwrap();
342 assert!(folders.iter().any(|f| f.as_str() == Some("art")));
343 assert!(folders.iter().any(|f| f.as_str() == Some("photos")));
344
345 // List filtered by folder
346 let resp = h.client.get("/api/media?folder=art").await;
347 assert_eq!(resp.status, 200, "{}", resp.text);
348 let data: Value = resp.json();
349 assert_eq!(
350 data["files"].as_array().unwrap().len(),
351 1,
352 "Should list only art folder files"
353 );
354 assert_eq!(data["files"][0]["filename"].as_str().unwrap(), "img1.png");
355
356 // List folders
357 let resp = h.client.get("/api/media/folders").await;
358 assert_eq!(resp.status, 200, "{}", resp.text);
359 let data: Value = resp.json();
360 let folders = data["folders"].as_array().unwrap();
361 assert_eq!(folders.len(), 2);
362 }
363
364 // Delete media file (storage decremented)
365
366 #[tokio::test]
367 async fn delete_media_file_decrements_storage() {
368 let mut h = TestHarness::with_storage().await;
369 let user_id_str = setup_bigfiles_creator(&mut h, "deluser").await;
370
371 let (s3_key, file_id) = upload_media(&mut h, "todel.png", "image/png", "", TINY_PNG).await;
372
373 // Verify storage was incremented
374 let storage_before: i64 =
375 sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
376 .bind(&user_id_str)
377 .fetch_one(&h.db)
378 .await
379 .unwrap();
380 assert!(
381 storage_before > 0,
382 "Storage should be non-zero after upload"
383 );
384
385 let resp = h.client.delete(&format!("/api/media/{file_id}")).await;
386 assert_eq!(resp.status, 200, "Delete should succeed: {}", resp.text);
387
388 // Verify storage was decremented
389 let storage_after: i64 =
390 sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
391 .bind(&user_id_str)
392 .fetch_one(&h.db)
393 .await
394 .unwrap();
395 assert_eq!(storage_after, 0, "Storage should be zero after deletion");
396
397 // Verify file is gone from DB
398 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media_files WHERE id = $1::uuid")
399 .bind(&file_id)
400 .fetch_one(&h.db)
401 .await
402 .unwrap();
403 assert_eq!(count, 0, "Media file should be deleted from DB");
404
405 // The handler enqueues the S3 delete to the orphan queue; run it (the
406 // scheduler's job in production) before asserting the object is gone.
407 h.drain_s3_deletions().await;
408
409 // Verify file is gone from S3 (via trait method)
410 let s3_exists = makenotwork::storage::StorageBackend::object_exists(
411 h.storage.as_ref().unwrap().as_ref(),
412 &s3_key,
413 )
414 .await
415 .unwrap();
416 assert!(!s3_exists, "File should be deleted from storage");
417 }
418
419 // Filename collision rejected
420
421 #[tokio::test]
422 async fn filename_collision_rejected() {
423 let mut h = TestHarness::with_storage().await;
424 setup_bigfiles_creator(&mut h, "colluser").await;
425
426 // Upload first file
427 upload_media(&mut h, "same.png", "image/png", "art", TINY_PNG).await;
428
429 // Filename uniqueness is enforced at CONFIRM time now, not presign, the
430 // pre-check was removed because it raced against concurrent presigns.
431 // Presign succeeds; the confirm catches the duplicate via the
432 // `idx_media_files_user_folder_name` unique index.
433 let body = json!({
434 "file_name": "same.png",
435 "content_type": "image/png",
436 "folder": "art",
437 });
438 let resp = h
439 .client
440 .post_json("/api/media/presign", &body.to_string())
441 .await;
442 assert_eq!(
443 resp.status, 200,
444 "Presign no longer pre-checks; should succeed: {}",
445 resp.text
446 );
447 let presign: Value = resp.json();
448 let dup_s3_key = presign["s3_key"].as_str().unwrap().to_string();
449 h.storage
450 .as_ref()
451 .unwrap()
452 .put(&dup_s3_key, TINY_PNG.to_vec());
453
454 let body = json!({
455 "s3_key": dup_s3_key,
456 "file_name": "same.png",
457 "content_type": "image/png",
458 "folder": "art",
459 });
460 let resp = h
461 .client
462 .post_json("/api/media/confirm", &body.to_string())
463 .await;
464 assert_eq!(
465 resp.status, 400,
466 "Duplicate filename should be rejected at confirm time: {} {}",
467 resp.status, resp.text
468 );
469 assert!(
470 resp.text.contains("already exists"),
471 "Error should mention collision: {}",
472 resp.text
473 );
474 }
475
476 // Same filename in different folders is allowed
477
478 #[tokio::test]
479 async fn same_filename_different_folder_allowed() {
480 let mut h = TestHarness::with_storage().await;
481 setup_bigfiles_creator(&mut h, "diffuser").await;
482
483 upload_media(&mut h, "logo.png", "image/png", "art", TINY_PNG).await;
484 upload_media(&mut h, "logo.png", "image/png", "photos", TINY_PNG).await;
485
486 let resp = h.client.get("/api/media").await;
487 let data: Value = resp.json();
488 assert_eq!(
489 data["files"].as_array().unwrap().len(),
490 2,
491 "Same name in different folders should both exist"
492 );
493 }
494
495 // Path traversal in folder name rejected
496
497 #[tokio::test]
498 async fn path_traversal_in_folder_rejected() {
499 let mut h = TestHarness::with_storage().await;
500 setup_bigfiles_creator(&mut h, "travuser").await;
501
502 let body = json!({
503 "file_name": "exploit.png",
504 "content_type": "image/png",
505 "folder": "../../../etc",
506 });
507 let resp = h
508 .client
509 .post_json("/api/media/presign", &body.to_string())
510 .await;
511 assert_eq!(
512 resp.status, 400,
513 "Path traversal folder should be rejected: {} {}",
514 resp.status, resp.text
515 );
516 }
517
518 // Storage cap enforcement (video respects tier cap)
519
520 #[tokio::test]
521 async fn video_storage_cap_enforcement() {
522 let mut h = TestHarness::with_storage().await;
523 let user_id_str = setup_bigfiles_creator(&mut h, "capuser").await;
524
525 // Set storage_used_bytes near the big_files tier cap (500 GB)
526 let near_cap = 500_i64 * 1024 * 1024 * 1024 - 1;
527 sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1::uuid")
528 .bind(&user_id_str)
529 .bind(near_cap)
530 .execute(&h.db)
531 .await
532 .expect("set storage near cap");
533
534 // Video upload should fail because storage cap would be exceeded
535 let body = json!({
536 "file_name": "big.mp4",
537 "content_type": "video/mp4",
538 "folder": "",
539 });
540 let resp = h
541 .client
542 .post_json("/api/media/presign", &body.to_string())
543 .await;
544
545 // Presign may succeed (it's a pre-check on tier, not storage), try confirm
546 // Not an assertion: the test covers both outcomes deliberately.
547 if resp.status.is_success() {
548 let data: Value = resp.json();
549 let s3_key = data["s3_key"].as_str().unwrap().to_string();
550
551 // Put video bytes
552 h.storage.as_ref().unwrap().put(&s3_key, TEST_MP4.to_vec());
553
554 let body = json!({
555 "s3_key": s3_key,
556 "file_name": "big.mp4",
557 "content_type": "video/mp4",
558 "folder": "",
559 });
560 let resp = h
561 .client
562 .post_json("/api/media/confirm", &body.to_string())
563 .await;
564 assert_eq!(
565 resp.status, 400,
566 "Confirm should fail when storage cap exceeded: {} {}",
567 resp.status, resp.text
568 );
569 }
570 // If presign itself rejected it, that's also correct
571 }
572
573 // Unsupported content type rejected
574
575 #[tokio::test]
576 async fn unsupported_content_type_rejected() {
577 let mut h = TestHarness::with_storage().await;
578 setup_bigfiles_creator(&mut h, "badtype").await;
579
580 let body = json!({
581 "file_name": "script.js",
582 "content_type": "application/javascript",
583 "folder": "",
584 });
585 let resp = h
586 .client
587 .post_json("/api/media/presign", &body.to_string())
588 .await;
589 assert_eq!(
590 resp.status, 400,
591 "Non-image/video content type should be rejected: {} {}",
592 resp.status, resp.text
593 );
594 }
595
596 // A duplicate confirm is rejected, but must NOT delete the live object
597 // (Run #11 HIGH regression). Media keys are deterministic by (user, folder,
598 // filename), so a retried/duplicate confirm resolves to the same key the
599 // committed row already points at. The product rejects the duplicate
600 // ("already exists"), but the previous code's error arm deleted that key,
601 // torpedoing the existing file. The fix keeps the rejection and preserves the
602 // object + the storage charge.
603
604 #[tokio::test]
605 async fn media_duplicate_confirm_rejects_without_deleting_live_object() {
606 let mut h = TestHarness::with_storage().await;
607 let user_id = setup_bigfiles_creator(&mut h, "mediareconfirm").await;
608
609 // Presign + upload + first confirm.
610 let presign_body = json!({"file_name": "pic.png", "content_type": "image/png", "folder": ""});
611 let resp = h
612 .client
613 .post_json("/api/media/presign", &presign_body.to_string())
614 .await;
615 assert_eq!(resp.status, 200, "presign failed: {}", resp.text);
616 let s3_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
617 h.storage.as_ref().unwrap().put(&s3_key, TINY_PNG.to_vec());
618
619 let confirm_body = json!({"s3_key": s3_key, "file_name": "pic.png", "content_type": "image/png", "folder": ""});
620 let resp = h
621 .client
622 .post_json("/api/media/confirm", &confirm_body.to_string())
623 .await;
624 assert_eq!(resp.status, 200, "first confirm failed: {}", resp.text);
625
626 let used_after_first: i64 =
627 sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
628 .bind(&user_id)
629 .fetch_one(&h.db)
630 .await
631 .unwrap();
632 assert_eq!(used_after_first, TINY_PNG.len() as i64);
633
634 // A genuine duplicate under scan-then-promote: presign a SECOND upload of the
635 // same folder+filename (a fresh staging key, so it passes the ownership gate),
636 // then confirm. It collides on the (user, folder, filename) unique index and
637 // is rejected as "already exists".
638 let presign2 = json!({"file_name": "pic.png", "content_type": "image/png", "folder": ""});
639 let resp = h
640 .client
641 .post_json("/api/media/presign", &presign2.to_string())
642 .await;
643 assert_eq!(resp.status, 200, "second presign failed: {}", resp.text);
644 let s3_key2 = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
645 h.storage.as_ref().unwrap().put(&s3_key2, TINY_PNG.to_vec());
646 let confirm2 = json!({"s3_key": s3_key2, "file_name": "pic.png", "content_type": "image/png", "folder": ""});
647 let resp = h
648 .client
649 .post_json("/api/media/confirm", &confirm2.to_string())
650 .await;
651 assert_eq!(
652 resp.status, 400,
653 "duplicate confirm should be rejected: {} {}",
654 resp.status, resp.text
655 );
656 assert!(
657 resp.text.contains("already exists"),
658 "rejection should name the collision: {}",
659 resp.text
660 );
661
662 // ... but the FIRST upload's live object must SURVIVE (the HIGH).
663 assert!(
664 h.storage
665 .as_ref()
666 .unwrap()
667 .object_exists(&s3_key)
668 .await
669 .unwrap(),
670 "duplicate confirm must NOT delete the first upload's live object"
671 );
672 // ... and the rolled-back tx must not have double-charged storage.
673 let used_after_second: i64 =
674 sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
675 .bind(&user_id)
676 .fetch_one(&h.db)
677 .await
678 .unwrap();
679 assert_eq!(
680 used_after_second,
681 TINY_PNG.len() as i64,
682 "duplicate confirm must not double-charge storage"
683 );
684 let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM media_files WHERE s3_key = $1")
685 .bind(&s3_key)
686 .fetch_one(&h.db)
687 .await
688 .unwrap();
689 assert_eq!(count, 1, "exactly one media_files row remains");
690 }
691