Skip to main content

max / makenotwork

17.8 KB · 503 lines History Blame Raw
1 //! File scanning workflow tests, clean files pass, malicious magic bytes quarantined.
2
3 use crate::harness::TestHarness;
4 use serde_json::{Value, json};
5
6 use makenotwork::db::UserId;
7 use makenotwork::storage::StorageBackend;
8
9 /// Helper: set up a trusted creator with a project and audio item.
10 async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String) {
11 let setup = h.create_creator_with_item("scancreator", "audio", 0).await;
12 h.trust_user(setup.user_id).await;
13 h.grant_tier(setup.user_id, "small_files").await;
14 (setup.project_id, setup.item_id)
15 }
16
17 #[tokio::test]
18 async fn confirm_upload_clean_file_passes() {
19 let mut h = TestHarness::with_storage_and_scanner().await;
20 let (_project_id, item_id) = setup_creator_with_item(&mut h).await;
21
22 // Presign
23 let body = json!({
24 "item_id": item_id,
25 "file_type": "audio",
26 "file_name": "clean.mp3",
27 "content_type": "audio/mpeg",
28 });
29 let resp = h
30 .client
31 .post_json("/api/upload/presign", &body.to_string())
32 .await;
33 assert!(resp.status.is_success(), "Presign failed: {}", resp.text);
34 let data: Value = resp.json();
35 let s3_key = data["s3_key"].as_str().unwrap().to_string();
36
37 // Simulate upload: ID3v2 header (valid MP3 magic bytes)
38 let mut mp3_data = b"ID3".to_vec();
39 mp3_data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); // ID3v2.4 header
40 mp3_data.extend_from_slice(&[0u8; 100]); // padding
41 h.storage.as_ref().unwrap().put(&s3_key, mp3_data);
42
43 // Confirm, scanner should see MP3/ID3 magic and pass
44 let body = json!({
45 "item_id": item_id,
46 "file_type": "audio",
47 "s3_key": s3_key,
48 });
49 let resp = h
50 .client
51 .post_json("/api/upload/confirm", &body.to_string())
52 .await;
53 assert!(
54 resp.status.is_success(),
55 "Confirm should pass for clean file: {}",
56 resp.text
57 );
58
59 // Scanning is async (Phase 1 worker pipeline). Drive the worker to
60 // completion before asserting final state.
61 h.drain_scan_jobs().await;
62
63 // C1 scan-then-promote: a Clean scan copies the object from its staging key
64 // to a content-addressed served key and repoints the row. audio_s3_key must
65 // NO LONGER be the staging key, it is now `{user}/c/{sha256}.mp3`.
66 let db_key: Option<String> =
67 sqlx::query_scalar("SELECT audio_s3_key FROM items WHERE id = $1::uuid")
68 .bind(&item_id)
69 .fetch_one(&h.db)
70 .await
71 .unwrap();
72 let db_key = db_key.expect("audio_s3_key set");
73 assert_ne!(
74 db_key, s3_key,
75 "clean scan must promote off the staging key"
76 );
77 assert!(
78 !db_key.starts_with("staging/"),
79 "promoted key must not be a staging key: {db_key}"
80 );
81 assert!(
82 db_key.contains("/c/"),
83 "promoted key must be content-addressed: {db_key}"
84 );
85 assert!(
86 std::path::Path::new(&db_key)
87 .extension()
88 .is_some_and(|e| e == "mp3"),
89 "content key keeps the extension: {db_key}"
90 );
91
92 // The served object exists at the content key; the staging object is gone
93 // (enqueued for durable deletion after the promote copy).
94 let store = h.storage.as_ref().unwrap();
95 assert!(
96 store.object_exists(&db_key).await.unwrap(),
97 "content object must exist after promote"
98 );
99
100 // Verify scan_status is clean
101 let scan_status: String =
102 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
103 .bind(&item_id)
104 .fetch_one(&h.db)
105 .await
106 .unwrap();
107 assert_eq!(scan_status, "clean");
108 }
109
110 /// The C1 invariant end-to-end: after a Clean scan promotes an upload to its
111 /// content key, a creator re-PUT to the (still-known) staging URL cannot change
112 /// the bytes a buyer is served. The served key is the content key; the staging
113 /// object is a dead end.
114 #[tokio::test]
115 async fn repost_to_staging_after_clean_cannot_change_served_bytes() {
116 let mut h = TestHarness::with_storage_and_scanner().await;
117 let (_project_id, item_id) = setup_creator_with_item(&mut h).await;
118
119 // Presign → the client only ever holds a presign to the staging key.
120 let body = json!({"item_id": item_id, "file_type": "audio", "file_name": "song.mp3", "content_type": "audio/mpeg"});
121 let resp = h
122 .client
123 .post_json("/api/upload/presign", &body.to_string())
124 .await;
125 assert!(resp.status.is_success(), "presign: {}", resp.text);
126 let staging_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
127 assert!(staging_key.starts_with("staging/"));
128
129 // Upload clean bytes and confirm.
130 let mut clean = b"ID3".to_vec();
131 clean.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
132 clean.extend_from_slice(&[0xAAu8; 200]);
133 h.storage.as_ref().unwrap().put(&staging_key, clean.clone());
134 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": staging_key});
135 let resp = h
136 .client
137 .post_json("/api/upload/confirm", &body.to_string())
138 .await;
139 assert!(resp.status.is_success(), "confirm: {}", resp.text);
140 h.drain_scan_jobs().await;
141
142 // The row now serves a content key, NOT the staging key.
143 let served_key: String = sqlx::query_scalar::<_, Option<String>>(
144 "SELECT audio_s3_key FROM items WHERE id = $1::uuid",
145 )
146 .bind(&item_id)
147 .fetch_one(&h.db)
148 .await
149 .unwrap()
150 .expect("audio promoted");
151 assert!(
152 served_key.contains("/c/"),
153 "served key must be content-addressed: {served_key}"
154 );
155 assert_ne!(served_key, staging_key);
156
157 let store = h.storage.as_ref().unwrap();
158 let served_before = store.download_object(&served_key).await.unwrap();
159 assert_eq!(
160 served_before, clean,
161 "the content object holds the scanned bytes"
162 );
163
164 // The attack: re-PUT malware to the staging key the creator still holds a
165 // presign for. This is the exact move the old mutable-served-key design let
166 // a creator use to swap post-scan bytes.
167 let malware = vec![0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00];
168 store.put(&staging_key, malware.clone());
169
170 // The served key is untouched: a buyer still gets the scanned bytes. The
171 // staging object is irrelevant, it is not what the row serves.
172 let served_after = store.download_object(&served_key).await.unwrap();
173 assert_eq!(
174 served_after, clean,
175 "re-PUT to the staging key must NOT change the served bytes"
176 );
177 assert_ne!(served_after, malware);
178 }
179
180 #[tokio::test]
181 async fn confirm_upload_bad_magic_quarantined() {
182 let mut h = TestHarness::with_storage_and_scanner().await;
183 let (_project_id, item_id) = setup_creator_with_item(&mut h).await;
184
185 // Presign
186 let body = json!({
187 "item_id": item_id,
188 "file_type": "audio",
189 "file_name": "sneaky.mp3",
190 "content_type": "audio/mpeg",
191 });
192 let resp = h
193 .client
194 .post_json("/api/upload/presign", &body.to_string())
195 .await;
196 assert!(resp.status.is_success());
197 let data: Value = resp.json();
198 let s3_key = data["s3_key"].as_str().unwrap().to_string();
199
200 // Simulate upload: ELF binary magic disguised as audio
201 let mut elf_data = vec![0x7f, b'E', b'L', b'F'];
202 elf_data.extend_from_slice(&[0x02, 0x01, 0x01, 0x00]); // 64-bit, LE, current
203 elf_data.extend_from_slice(&[0u8; 100]); // padding
204 h.storage.as_ref().unwrap().put(&s3_key, elf_data);
205
206 // Confirm, scanner enqueues async; the worker decides quarantine.
207 let body = json!({
208 "item_id": item_id,
209 "file_type": "audio",
210 "s3_key": s3_key,
211 });
212 let resp = h
213 .client
214 .post_json("/api/upload/confirm", &body.to_string())
215 .await;
216 assert!(
217 resp.status.is_success(),
218 "Confirm enqueues async; the worker decides scan verdict. Got {}: {}",
219 resp.status,
220 resp.text
221 );
222 h.drain_scan_jobs().await;
223
224 // Verify scan_status is quarantined
225 let scan_status: String =
226 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
227 .bind(&item_id)
228 .fetch_one(&h.db)
229 .await
230 .unwrap();
231 assert_eq!(scan_status, "quarantined");
232 }
233
234 #[tokio::test]
235 async fn quarantined_cover_nulls_columns_but_keeps_published_track() {
236 // Run #20 Storage SERIOUS (flip side): a cover is CDN-served with no
237 // per-request gate, so enforcing a quarantine verdict NULLs the cover
238 // columns (stopping the URL from rendering) rather than flipping the
239 // shared `items.scan_status`. The legitimate audio track and its Clean
240 // gate status must survive, a malicious thumbnail can't delist a track.
241 let mut h = TestHarness::with_storage_and_scanner().await;
242 let (_project_id, item_id) = setup_creator_with_item(&mut h).await;
243
244 // Publish a clean audio track first.
245 let body = json!({"item_id": item_id, "file_type": "audio", "file_name": "t.mp3", "content_type": "audio/mpeg"});
246 let resp = h
247 .client
248 .post_json("/api/upload/presign", &body.to_string())
249 .await;
250 assert!(resp.status.is_success(), "audio presign: {}", resp.text);
251 let audio_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
252 let mut mp3 = b"ID3".to_vec();
253 mp3.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
254 mp3.extend_from_slice(&[0u8; 100]);
255 h.storage.as_ref().unwrap().put(&audio_key, mp3);
256 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": audio_key});
257 let resp = h
258 .client
259 .post_json("/api/upload/confirm", &body.to_string())
260 .await;
261 assert!(resp.status.is_success(), "audio confirm: {}", resp.text);
262
263 // Upload a malicious cover (ELF magic disguised as a png), the worker
264 // quarantines it the same way it does the bad-magic audio above.
265 let body = json!({"item_id": item_id, "file_name": "art.png", "content_type": "image/png"});
266 let resp = h
267 .client
268 .post_json("/api/items/image/presign", &body.to_string())
269 .await;
270 assert!(resp.status.is_success(), "cover presign: {}", resp.text);
271 let cover_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
272 let mut elf = vec![0x7f, b'E', b'L', b'F'];
273 elf.extend_from_slice(&[0x02, 0x01, 0x01, 0x00]);
274 elf.extend_from_slice(&[0u8; 100]);
275 h.storage.as_ref().unwrap().put(&cover_key, elf);
276 let body = json!({"item_id": item_id, "s3_key": cover_key});
277 let resp = h
278 .client
279 .post_json("/api/items/image/confirm", &body.to_string())
280 .await;
281 assert!(resp.status.is_success(), "cover confirm: {}", resp.text);
282
283 h.drain_scan_jobs().await;
284
285 let (status, audio, ck, cu): (String, Option<String>, Option<String>, Option<String>) =
286 sqlx::query_as(
287 "SELECT scan_status, audio_s3_key, cover_s3_key, cover_image_url \
288 FROM items WHERE id = $1::uuid",
289 )
290 .bind(&item_id)
291 .fetch_one(&h.db)
292 .await
293 .expect("the item row must still exist, quarantining a cover must not delete the track");
294 assert_eq!(
295 status, "clean",
296 "a quarantined cover must not touch the track's gate status"
297 );
298 // The audio track was clean-scanned, so it was promoted off its staging key to
299 // a content-addressed key, the point is it survives the cover quarantine
300 // (non-null, promoted, still gated Clean), not that it keeps the staging name.
301 let audio = audio.expect("the audio track must be preserved");
302 assert_ne!(
303 audio, audio_key,
304 "the audio track must have been promoted, not delisted"
305 );
306 assert!(
307 audio.contains("/c/"),
308 "the surviving track must be content-addressed: {audio}"
309 );
310 assert_eq!(ck, None, "the quarantined cover key must be NULLed");
311 assert_eq!(
312 cu, None,
313 "the quarantined cover URL must be NULLed so it stops rendering"
314 );
315 }
316
317 // Upload Trust Tier Tests
318
319 /// Helper: set up an untrusted creator with a project and audio item.
320 /// Returns (user_id, project_id, item_id).
321 async fn setup_untrusted_creator_with_item(
322 h: &mut TestHarness,
323 username: &str,
324 email: &str,
325 ) -> (UserId, String, String) {
326 let user_id = h.signup(username, email, "password123").await;
327 h.grant_creator(user_id).await;
328 h.grant_tier(user_id, "small_files").await;
329 // NOTE: deliberately NOT calling h.trust_user(user_id)
330 h.client.post_form("/logout", "").await;
331 h.login(username, "password123").await;
332
333 let resp = h
334 .client
335 .post_form(
336 "/api/projects",
337 &format!("slug={username}proj&title={username}+Project"),
338 )
339 .await;
340 assert!(resp.status.is_success(), "Create project: {}", resp.text);
341 let project: Value = resp.json();
342 let project_id = project["id"].as_str().unwrap().to_string();
343
344 let resp = h
345 .client
346 .post_form(
347 &format!("/api/projects/{project_id}/items"),
348 "title=Trust+Track&price_cents=0&item_type=audio",
349 )
350 .await;
351 assert!(resp.status.is_success(), "Create item: {}", resp.text);
352 let item: Value = resp.json();
353 let item_id = item["id"].as_str().unwrap().to_string();
354
355 (user_id, project_id, item_id)
356 }
357
358 /// Helper: presign, simulate upload, and confirm for clean MP3 data.
359 /// Returns the s3_key.
360 async fn upload_clean_mp3(h: &mut TestHarness, item_id: &str) -> String {
361 let body = json!({
362 "item_id": item_id,
363 "file_type": "audio",
364 "file_name": "trust_test.mp3",
365 "content_type": "audio/mpeg",
366 });
367 let resp = h
368 .client
369 .post_json("/api/upload/presign", &body.to_string())
370 .await;
371 assert!(resp.status.is_success(), "Presign failed: {}", resp.text);
372 let data: Value = resp.json();
373 let s3_key = data["s3_key"].as_str().unwrap().to_string();
374
375 // Valid MP3 magic bytes
376 let mut mp3_data = b"ID3".to_vec();
377 mp3_data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
378 mp3_data.extend_from_slice(&[0u8; 100]);
379 h.storage.as_ref().unwrap().put(&s3_key, mp3_data);
380
381 let body = json!({
382 "item_id": item_id,
383 "file_type": "audio",
384 "s3_key": s3_key,
385 });
386 let resp = h
387 .client
388 .post_json("/api/upload/confirm", &body.to_string())
389 .await;
390 assert!(resp.status.is_success(), "Confirm failed: {}", resp.text);
391
392 // Drive the async worker so callers can immediately assert final state.
393 h.drain_scan_jobs().await;
394
395 s3_key
396 }
397
398 #[tokio::test]
399 async fn untrusted_creator_upload_held_for_review() {
400 let mut h = TestHarness::with_storage_and_scanner().await;
401 let (_user_id, _project_id, item_id) =
402 setup_untrusted_creator_with_item(&mut h, "untrusted", "untrusted@test.com").await;
403
404 let _s3_key = upload_clean_mp3(&mut h, &item_id).await;
405
406 // Verify scan_status is held_for_review (not clean)
407 let scan_status: String =
408 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
409 .bind(&item_id)
410 .fetch_one(&h.db)
411 .await
412 .unwrap();
413 assert_eq!(scan_status, "held_for_review");
414
415 // Creator can preview their own held content (200)
416 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
417 assert_eq!(
418 resp.status.as_u16(),
419 200,
420 "Creators should be able to preview held uploads"
421 );
422
423 // But a different user should not be able to stream it (log in as buyer)
424 h.client.post_form("/logout", "").await;
425 h.signup("buyer", "buyer@test.com", "password123").await;
426 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
427 assert_eq!(
428 resp.status.as_u16(),
429 404,
430 "Non-creators should not stream held uploads"
431 );
432 }
433
434 #[tokio::test]
435 async fn trusted_creator_upload_auto_publishes() {
436 let mut h = TestHarness::with_storage_and_scanner().await;
437 let (user_id, _project_id, item_id) =
438 setup_untrusted_creator_with_item(&mut h, "trusted", "trusted@test.com").await;
439
440 // Trust the user, then upload
441 h.trust_user(user_id).await;
442 let _s3_key = upload_clean_mp3(&mut h, &item_id).await;
443
444 // Verify scan_status is clean (auto-published)
445 let scan_status: String =
446 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
447 .bind(&item_id)
448 .fetch_one(&h.db)
449 .await
450 .unwrap();
451 assert_eq!(scan_status, "clean");
452 }
453
454 #[tokio::test]
455 async fn admin_approve_held_upload() {
456 let (mut h, _admin_id) = TestHarness::with_admin_storage_and_scanner().await;
457 let (_user_id, _project_id, item_id) =
458 setup_untrusted_creator_with_item(&mut h, "heldcreator", "held@test.com").await;
459
460 let _s3_key = upload_clean_mp3(&mut h, &item_id).await;
461
462 // Verify it's held
463 let scan_status: String =
464 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
465 .bind(&item_id)
466 .fetch_one(&h.db)
467 .await
468 .unwrap();
469 assert_eq!(scan_status, "held_for_review");
470
471 // Log in as admin and approve
472 h.client.post_form("/logout", "").await;
473 h.login("admin", "password123").await;
474
475 let resp = h
476 .client
477 .post_form(&format!("/api/admin/uploads/items/{item_id}/promote"), "")
478 .await;
479 assert!(
480 resp.status.is_success(),
481 "Admin approve failed: {} {}",
482 resp.status,
483 resp.text
484 );
485
486 // Verify scan_status is now clean
487 let scan_status: String =
488 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
489 .bind(&item_id)
490 .fetch_one(&h.db)
491 .await
492 .unwrap();
493 assert_eq!(scan_status, "clean");
494
495 // Verify streaming now works (item is public and free by default)
496 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
497 assert!(
498 resp.status.is_success(),
499 "Stream should work after approval: {}",
500 resp.status
501 );
502 }
503