Skip to main content

max / makenotwork

17.8 KB · 501 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_eq!(resp.status, 200, "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_eq!(
54 resp.status, 200,
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_eq!(resp.status, 200, "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_eq!(resp.status, 200, "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_eq!(resp.status, 200, "{}", resp.text);
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_eq!(
217 resp.status, 200,
218 "Confirm enqueues async; the worker decides scan verdict. Got {}: {}",
219 resp.status, resp.text
220 );
221 h.drain_scan_jobs().await;
222
223 // Verify scan_status is quarantined
224 let scan_status: String =
225 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
226 .bind(&item_id)
227 .fetch_one(&h.db)
228 .await
229 .unwrap();
230 assert_eq!(scan_status, "quarantined");
231 }
232
233 #[tokio::test]
234 async fn quarantined_cover_nulls_columns_but_keeps_published_track() {
235 // Run #20 Storage SERIOUS (flip side): a cover is CDN-served with no
236 // per-request gate, so enforcing a quarantine verdict NULLs the cover
237 // columns (stopping the URL from rendering) rather than flipping the
238 // shared `items.scan_status`. The legitimate audio track and its Clean
239 // gate status must survive, a malicious thumbnail can't delist a track.
240 let mut h = TestHarness::with_storage_and_scanner().await;
241 let (_project_id, item_id) = setup_creator_with_item(&mut h).await;
242
243 // Publish a clean audio track first.
244 let body = json!({"item_id": item_id, "file_type": "audio", "file_name": "t.mp3", "content_type": "audio/mpeg"});
245 let resp = h
246 .client
247 .post_json("/api/upload/presign", &body.to_string())
248 .await;
249 assert_eq!(resp.status, 200, "audio presign: {}", resp.text);
250 let audio_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
251 let mut mp3 = b"ID3".to_vec();
252 mp3.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
253 mp3.extend_from_slice(&[0u8; 100]);
254 h.storage.as_ref().unwrap().put(&audio_key, mp3);
255 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": audio_key});
256 let resp = h
257 .client
258 .post_json("/api/upload/confirm", &body.to_string())
259 .await;
260 assert_eq!(resp.status, 200, "audio confirm: {}", resp.text);
261
262 // Upload a malicious cover (ELF magic disguised as a png), the worker
263 // quarantines it the same way it does the bad-magic audio above.
264 let body = json!({"item_id": item_id, "file_name": "art.png", "content_type": "image/png"});
265 let resp = h
266 .client
267 .post_json("/api/items/image/presign", &body.to_string())
268 .await;
269 assert_eq!(resp.status, 200, "cover presign: {}", resp.text);
270 let cover_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
271 let mut elf = vec![0x7f, b'E', b'L', b'F'];
272 elf.extend_from_slice(&[0x02, 0x01, 0x01, 0x00]);
273 elf.extend_from_slice(&[0u8; 100]);
274 h.storage.as_ref().unwrap().put(&cover_key, elf);
275 let body = json!({"item_id": item_id, "s3_key": cover_key});
276 let resp = h
277 .client
278 .post_json("/api/items/image/confirm", &body.to_string())
279 .await;
280 assert_eq!(resp.status, 200, "cover confirm: {}", resp.text);
281
282 h.drain_scan_jobs().await;
283
284 let (status, audio, ck, cu): (String, Option<String>, Option<String>, Option<String>) =
285 sqlx::query_as(
286 "SELECT scan_status, audio_s3_key, cover_s3_key, cover_image_url \
287 FROM items WHERE id = $1::uuid",
288 )
289 .bind(&item_id)
290 .fetch_one(&h.db)
291 .await
292 .expect("the item row must still exist, quarantining a cover must not delete the track");
293 assert_eq!(
294 status, "clean",
295 "a quarantined cover must not touch the track's gate status"
296 );
297 // The audio track was clean-scanned, so it was promoted off its staging key to
298 // a content-addressed key, the point is it survives the cover quarantine
299 // (non-null, promoted, still gated Clean), not that it keeps the staging name.
300 let audio = audio.expect("the audio track must be preserved");
301 assert_ne!(
302 audio, audio_key,
303 "the audio track must have been promoted, not delisted"
304 );
305 assert!(
306 audio.contains("/c/"),
307 "the surviving track must be content-addressed: {audio}"
308 );
309 assert_eq!(ck, None, "the quarantined cover key must be NULLed");
310 assert_eq!(
311 cu, None,
312 "the quarantined cover URL must be NULLed so it stops rendering"
313 );
314 }
315
316 // Upload Trust Tier Tests
317
318 /// Helper: set up an untrusted creator with a project and audio item.
319 /// Returns (user_id, project_id, item_id).
320 async fn setup_untrusted_creator_with_item(
321 h: &mut TestHarness,
322 username: &str,
323 email: &str,
324 ) -> (UserId, String, String) {
325 let user_id = h.signup(username, email, "password123").await;
326 h.grant_creator(user_id).await;
327 h.grant_tier(user_id, "small_files").await;
328 // NOTE: deliberately NOT calling h.trust_user(user_id)
329 h.client.post_form("/logout", "").await;
330 h.login(username, "password123").await;
331
332 let resp = h
333 .client
334 .post_form(
335 "/api/projects",
336 &format!("slug={username}proj&title={username}+Project"),
337 )
338 .await;
339 assert_eq!(resp.status, 200, "Create project: {}", resp.text);
340 let project: Value = resp.json();
341 let project_id = project["id"].as_str().unwrap().to_string();
342
343 let resp = h
344 .client
345 .post_form(
346 &format!("/api/projects/{project_id}/items"),
347 "title=Trust+Track&price_cents=0&item_type=audio",
348 )
349 .await;
350 assert_eq!(resp.status, 200, "Create item: {}", resp.text);
351 let item: Value = resp.json();
352 let item_id = item["id"].as_str().unwrap().to_string();
353
354 (user_id, project_id, item_id)
355 }
356
357 /// Helper: presign, simulate upload, and confirm for clean MP3 data.
358 /// Returns the s3_key.
359 async fn upload_clean_mp3(h: &mut TestHarness, item_id: &str) -> String {
360 let body = json!({
361 "item_id": item_id,
362 "file_type": "audio",
363 "file_name": "trust_test.mp3",
364 "content_type": "audio/mpeg",
365 });
366 let resp = h
367 .client
368 .post_json("/api/upload/presign", &body.to_string())
369 .await;
370 assert_eq!(resp.status, 200, "Presign failed: {}", resp.text);
371 let data: Value = resp.json();
372 let s3_key = data["s3_key"].as_str().unwrap().to_string();
373
374 // Valid MP3 magic bytes
375 let mut mp3_data = b"ID3".to_vec();
376 mp3_data.extend_from_slice(&[0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
377 mp3_data.extend_from_slice(&[0u8; 100]);
378 h.storage.as_ref().unwrap().put(&s3_key, mp3_data);
379
380 let body = json!({
381 "item_id": item_id,
382 "file_type": "audio",
383 "s3_key": s3_key,
384 });
385 let resp = h
386 .client
387 .post_json("/api/upload/confirm", &body.to_string())
388 .await;
389 assert_eq!(resp.status, 200, "Confirm failed: {}", resp.text);
390
391 // Drive the async worker so callers can immediately assert final state.
392 h.drain_scan_jobs().await;
393
394 s3_key
395 }
396
397 #[tokio::test]
398 async fn untrusted_creator_upload_held_for_review() {
399 let mut h = TestHarness::with_storage_and_scanner().await;
400 let (_user_id, _project_id, item_id) =
401 setup_untrusted_creator_with_item(&mut h, "untrusted", "untrusted@test.com").await;
402
403 let _s3_key = upload_clean_mp3(&mut h, &item_id).await;
404
405 // Verify scan_status is held_for_review (not clean)
406 let scan_status: String =
407 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
408 .bind(&item_id)
409 .fetch_one(&h.db)
410 .await
411 .unwrap();
412 assert_eq!(scan_status, "held_for_review");
413
414 // Creator can preview their own held content (200)
415 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
416 assert_eq!(
417 resp.status.as_u16(),
418 200,
419 "Creators should be able to preview held uploads"
420 );
421
422 // But a different user should not be able to stream it (log in as buyer)
423 h.client.post_form("/logout", "").await;
424 h.signup("buyer", "buyer@test.com", "password123").await;
425 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
426 assert_eq!(
427 resp.status.as_u16(),
428 404,
429 "Non-creators should not stream held uploads"
430 );
431 }
432
433 #[tokio::test]
434 async fn trusted_creator_upload_auto_publishes() {
435 let mut h = TestHarness::with_storage_and_scanner().await;
436 let (user_id, _project_id, item_id) =
437 setup_untrusted_creator_with_item(&mut h, "trusted", "trusted@test.com").await;
438
439 // Trust the user, then upload
440 h.trust_user(user_id).await;
441 let _s3_key = upload_clean_mp3(&mut h, &item_id).await;
442
443 // Verify scan_status is clean (auto-published)
444 let scan_status: String =
445 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
446 .bind(&item_id)
447 .fetch_one(&h.db)
448 .await
449 .unwrap();
450 assert_eq!(scan_status, "clean");
451 }
452
453 #[tokio::test]
454 async fn admin_approve_held_upload() {
455 let (mut h, _admin_id) = TestHarness::with_admin_storage_and_scanner().await;
456 let (_user_id, _project_id, item_id) =
457 setup_untrusted_creator_with_item(&mut h, "heldcreator", "held@test.com").await;
458
459 let _s3_key = upload_clean_mp3(&mut h, &item_id).await;
460
461 // Verify it's held
462 let scan_status: String =
463 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
464 .bind(&item_id)
465 .fetch_one(&h.db)
466 .await
467 .unwrap();
468 assert_eq!(scan_status, "held_for_review");
469
470 // Log in as admin and approve
471 h.client.post_form("/logout", "").await;
472 h.login("admin", "password123").await;
473
474 let resp = h
475 .client
476 .post_form(&format!("/api/admin/uploads/items/{item_id}/promote"), "")
477 .await;
478 assert_eq!(
479 resp.status, 200,
480 "Admin approve failed: {} {}",
481 resp.status, resp.text
482 );
483
484 // Verify scan_status is now clean
485 let scan_status: String =
486 sqlx::query_scalar("SELECT scan_status FROM items WHERE id = $1::uuid")
487 .bind(&item_id)
488 .fetch_one(&h.db)
489 .await
490 .unwrap();
491 assert_eq!(scan_status, "clean");
492
493 // Verify streaming now works (item is public and free by default)
494 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
495 assert_eq!(
496 resp.status, 200,
497 "Stream should work after approval: {}",
498 resp.status
499 );
500 }
501