Skip to main content

max / makenotwork

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