Skip to main content

max / makenotwork

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