Skip to main content

max / makenotwork

30.1 KB · 798 lines History Blame Raw
1 //! Storage workflow tests — presign, confirm, stream, download, access control.
2
3 use crate::harness::TestHarness;
4 use makenotwork::storage::StorageBackend;
5 use serde_json::{json, Value};
6
7 /// Helper: create a trusted creator with a project and audio item. Returns (user_id, project_id, item_id).
8 async fn setup_creator_with_item(
9 h: &mut TestHarness,
10 price_cents: i64,
11 ) -> (String, String, String) {
12 let setup = h.create_creator_with_item("creator", "audio", price_cents).await;
13 h.trust_user(setup.user_id).await;
14 h.grant_tier(setup.user_id, "small_files").await;
15 (setup.user_id.to_string(), setup.project_id, setup.item_id)
16 }
17
18 // ---------------------------------------------------------------------------
19 // Presign
20 // ---------------------------------------------------------------------------
21
22 #[tokio::test]
23 async fn presign_upload_audio() {
24 let mut h = TestHarness::with_storage().await;
25 let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
26
27 let body = json!({
28 "item_id": item_id,
29 "file_type": "audio",
30 "file_name": "episode.mp3",
31 "content_type": "audio/mpeg",
32 });
33 let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await;
34 assert!(resp.status.is_success(), "Presign failed: {}", resp.text);
35
36 let data: Value = resp.json();
37 assert!(data["upload_url"].as_str().unwrap().starts_with("http://test-storage/"));
38 assert!(data["s3_key"].as_str().unwrap().contains("/audio/episode.mp3"));
39 assert_eq!(data["expires_in"], 3600);
40 }
41
42 // ---------------------------------------------------------------------------
43 // Confirm
44 // ---------------------------------------------------------------------------
45
46 #[tokio::test]
47 async fn confirm_upload_audio_updates_db() {
48 let mut h = TestHarness::with_storage().await;
49 let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
50
51 // Presign
52 let body = json!({
53 "item_id": item_id,
54 "file_type": "audio",
55 "file_name": "song.mp3",
56 "content_type": "audio/mpeg",
57 });
58 let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await;
59 assert!(resp.status.is_success());
60 let data: Value = resp.json();
61 let s3_key = data["s3_key"].as_str().unwrap().to_string();
62
63 // Simulate the client uploading to S3
64 h.storage.as_ref().unwrap().put(&s3_key, b"fake mp3 bytes".to_vec());
65
66 // Confirm
67 let body = json!({
68 "item_id": item_id,
69 "file_type": "audio",
70 "s3_key": s3_key,
71 });
72 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
73 assert!(resp.status.is_success(), "Confirm failed: {}", resp.text);
74 let data: Value = resp.json();
75 assert_eq!(data["success"], true);
76
77 // Verify database
78 let db_key: Option<String> = sqlx::query_scalar(
79 "SELECT audio_s3_key FROM items WHERE id = $1::uuid",
80 )
81 .bind(&item_id)
82 .fetch_one(&h.db)
83 .await
84 .unwrap();
85 assert_eq!(db_key.as_deref(), Some(s3_key.as_str()));
86 }
87
88 #[tokio::test]
89 async fn confirm_item_cover_via_dedicated_route_writes_key_and_url() {
90 // Covers go through /api/items/image/{presign,confirm}, which writes
91 // cover_s3_key, cover_file_size_bytes AND cover_image_url together. The
92 // generic /api/upload/confirm used to accept cover and write the first two
93 // but NOT the URL, leaving an invisible cover (Run #13 SERIOUS); it now
94 // rejects cover (see confirm_upload_rejects_cover below).
95 let mut h = TestHarness::with_storage().await;
96 let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
97
98 let body = json!({
99 "item_id": item_id,
100 "file_name": "art.png",
101 "content_type": "image/png",
102 });
103 let resp = h.client.post_json("/api/items/image/presign", &body.to_string()).await;
104 assert!(resp.status.is_success(), "presign failed: {}", resp.text);
105 let data: Value = resp.json();
106 let s3_key = data["s3_key"].as_str().unwrap().to_string();
107
108 h.storage.as_ref().unwrap().put(&s3_key, b"fake png bytes".to_vec());
109
110 let body = json!({ "item_id": item_id, "s3_key": s3_key });
111 let resp = h.client.post_json("/api/items/image/confirm", &body.to_string()).await;
112 assert!(resp.status.is_success(), "Confirm failed: {}", resp.text);
113
114 // Both the key AND the render URL must be set — the URL is what the bug missed.
115 let (db_key, db_url): (Option<String>, Option<String>) = sqlx::query_as(
116 "SELECT cover_s3_key, cover_image_url FROM items WHERE id = $1::uuid",
117 )
118 .bind(&item_id)
119 .fetch_one(&h.db)
120 .await
121 .unwrap();
122 assert_eq!(db_key.as_deref(), Some(s3_key.as_str()));
123 assert!(db_url.is_some_and(|u| u.contains(&s3_key)), "cover_image_url must be set so the cover renders");
124 }
125
126 #[tokio::test]
127 async fn confirm_upload_rejects_cover() {
128 // The generic confirm route must refuse cover and point at the dedicated
129 // route, rather than half-writing the row (no cover_image_url). Run #13.
130 let mut h = TestHarness::with_storage().await;
131 let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
132
133 let body = json!({
134 "item_id": item_id,
135 "file_type": "cover",
136 "file_name": "art.png",
137 "content_type": "image/png",
138 });
139 let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await;
140 assert!(resp.status.is_success());
141 let data: Value = resp.json();
142 let s3_key = data["s3_key"].as_str().unwrap().to_string();
143 h.storage.as_ref().unwrap().put(&s3_key, b"fake png bytes".to_vec());
144
145 let body = json!({ "item_id": item_id, "file_type": "cover", "s3_key": s3_key });
146 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
147 assert_eq!(resp.status.as_u16(), 400, "generic confirm must reject cover: {}", resp.text);
148 assert!(resp.text.contains("/api/items/image/confirm"), "rejection should name the dedicated route: {}", resp.text);
149
150 // The row must be untouched — no half-written cover key.
151 let db_key: Option<String> = sqlx::query_scalar(
152 "SELECT cover_s3_key FROM items WHERE id = $1::uuid",
153 )
154 .bind(&item_id)
155 .fetch_one(&h.db)
156 .await
157 .unwrap();
158 assert_eq!(db_key, None, "rejected cover confirm must not write cover_s3_key");
159 }
160
161 // ---------------------------------------------------------------------------
162 // Versions
163 // ---------------------------------------------------------------------------
164
165 #[tokio::test]
166 async fn version_upload_and_download() {
167 let mut h = TestHarness::with_storage().await;
168 let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
169
170 // Create a version (digital item needs a version for downloads)
171 let resp = h
172 .client
173 .post_json(
174 &format!("/api/items/{}/versions", item_id),
175 &json!({"version_number": "1.0.0"}).to_string(),
176 )
177 .await;
178 assert!(resp.status.is_success(), "Create version failed: {}", resp.text);
179 let version: Value = resp.json();
180 let version_id = version["id"].as_str().unwrap().to_string();
181
182 // Presign version upload
183 let resp = h
184 .client
185 .post_json(
186 &format!("/api/versions/{}/upload/presign", version_id),
187 &json!({
188 "file_name": "plugin.zip",
189 "content_type": "application/zip",
190 })
191 .to_string(),
192 )
193 .await;
194 assert!(resp.status.is_success(), "Version presign failed: {}", resp.text);
195 let data: Value = resp.json();
196 let s3_key = data["s3_key"].as_str().unwrap().to_string();
197
198 // Simulate upload
199 h.storage.as_ref().unwrap().put(&s3_key, b"fake zip data".to_vec());
200
201 // Confirm version upload
202 let resp = h
203 .client
204 .post_json(
205 &format!("/api/versions/{}/upload/confirm", version_id),
206 &json!({"s3_key": s3_key}).to_string(),
207 )
208 .await;
209 assert!(resp.status.is_success(), "Version confirm failed: {}", resp.text);
210
211 // Publish item + project so download works
212 h.client
213 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
214 .await;
215 let project_id: String = sqlx::query_scalar(
216 "SELECT project_id::text FROM items WHERE id = $1::uuid",
217 )
218 .bind(&item_id)
219 .fetch_one(&h.db)
220 .await
221 .unwrap();
222 h.client
223 .put_json(
224 &format!("/api/projects/{}", project_id),
225 r#"{"is_public": true}"#,
226 )
227 .await;
228
229 // Download version
230 let resp = h
231 .client
232 .get(&format!("/api/versions/{}/download", version_id))
233 .await;
234 assert!(resp.status.is_success(), "Version download failed: {}", resp.text);
235 let data: Value = resp.json();
236 assert!(data["download_url"].as_str().unwrap().starts_with("http://test-storage/"));
237 }
238
239 // ---------------------------------------------------------------------------
240 // Audio Streaming
241 // ---------------------------------------------------------------------------
242
243 #[tokio::test]
244 async fn stream_url_free_item() {
245 let mut h = TestHarness::with_storage().await;
246 let (_, project_id, item_id) = setup_creator_with_item(&mut h, 0).await;
247
248 // Set up audio key directly in DB (simulates a completed upload)
249 let s3_key = format!("test/{}/audio/track.mp3", item_id);
250 sqlx::query("UPDATE items SET audio_s3_key = $1, scan_status = 'clean' WHERE id = $2::uuid")
251 .bind(&s3_key)
252 .bind(&item_id)
253 .execute(&h.db)
254 .await
255 .unwrap();
256
257 // Pre-populate storage
258 h.storage.as_ref().unwrap().put(&s3_key, b"audio data".to_vec());
259
260 // Publish
261 h.client
262 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
263 .await;
264 h.client
265 .put_json(
266 &format!("/api/projects/{}", project_id),
267 r#"{"is_public": true}"#,
268 )
269 .await;
270
271 // Stream — free item, any user can access
272 let resp = h.client.get(&format!("/api/stream/{}", item_id)).await;
273 assert!(resp.status.is_success(), "Stream failed: {}", resp.text);
274 let data: Value = resp.json();
275 assert!(data["stream_url"].as_str().unwrap().starts_with("http://test-storage/"));
276 }
277
278 #[tokio::test]
279 async fn stream_url_paid_requires_purchase() {
280 let mut h = TestHarness::with_storage().await;
281 let (_, project_id, item_id) = setup_creator_with_item(&mut h, 500).await;
282
283 // Set up audio key
284 let s3_key = format!("test/{}/audio/track.mp3", item_id);
285 sqlx::query("UPDATE items SET audio_s3_key = $1, scan_status = 'clean' WHERE id = $2::uuid")
286 .bind(&s3_key)
287 .bind(&item_id)
288 .execute(&h.db)
289 .await
290 .unwrap();
291
292 h.storage.as_ref().unwrap().put(&s3_key, b"audio data".to_vec());
293
294 // Publish
295 h.client
296 .put_form(&format!("/api/items/{}", item_id), "is_public=true")
297 .await;
298 h.client
299 .put_json(
300 &format!("/api/projects/{}", project_id),
301 r#"{"is_public": true}"#,
302 )
303 .await;
304
305 // Log out the creator and sign up a buyer with no purchase
306 h.client.post_form("/logout", "").await;
307 h.signup("buyer", "buyer@test.com", "password123").await;
308 h.login("buyer", "password123").await;
309
310 // Stream should be forbidden (paid, no purchase)
311 let resp = h.client.get(&format!("/api/stream/{}", item_id)).await;
312 assert_eq!(resp.status.as_u16(), 403, "Expected 403, got: {}", resp.text);
313 }
314
315 /// Helper: set up a paid item with audio, publish it, return (creator_user_id, project_id, item_id, s3_key).
316 /// Leaves the creator logged in.
317 async fn setup_published_paid_audio(
318 h: &mut TestHarness,
319 username: &str,
320 price_cents: i64,
321 ) -> (String, String, String, String) {
322 let setup = h.create_creator_with_item(username, "audio", price_cents).await;
323 h.trust_user(setup.user_id).await;
324 h.grant_tier(setup.user_id, "small_files").await;
325 let user_id = setup.user_id.to_string();
326
327 let s3_key = format!("test/{}/audio/track.mp3", setup.item_id);
328 sqlx::query("UPDATE items SET audio_s3_key = $1, scan_status = 'clean' WHERE id = $2::uuid")
329 .bind(&s3_key)
330 .bind(&setup.item_id)
331 .execute(&h.db)
332 .await
333 .unwrap();
334 h.storage.as_ref().unwrap().put(&s3_key, b"audio data".to_vec());
335
336 h.publish_project_and_item(&setup.project_id, &setup.item_id)
337 .await;
338
339 (user_id, setup.project_id, setup.item_id, s3_key)
340 }
341
342 // ---------------------------------------------------------------------------
343 // Stream access control (test-fuzz)
344 // ---------------------------------------------------------------------------
345
346 /// Unauthenticated user gets 401 on paid item stream.
347 #[tokio::test]
348 async fn stream_url_paid_unauthenticated_returns_401() {
349 let mut h = TestHarness::with_storage().await;
350 let (_, _, item_id, _) = setup_published_paid_audio(&mut h, "seller401", 500).await;
351
352 // Log out — no session
353 h.client.post_form("/logout", "").await;
354
355 let resp = h.client.get(&format!("/api/stream/{}", item_id)).await;
356 assert_eq!(
357 resp.status.as_u16(),
358 401,
359 "Unauthenticated stream of paid item should be 401, got: {}",
360 resp.status
361 );
362 }
363
364 /// After a direct DB purchase record, buyer can stream paid content.
365 #[tokio::test]
366 async fn stream_url_paid_purchaser_gets_access() {
367 let mut h = TestHarness::with_storage().await;
368 let (creator_id, _, item_id, _) = setup_published_paid_audio(&mut h, "sellaccess", 999).await;
369
370 // Create buyer and insert a completed transaction (simulates webhook completion)
371 h.client.post_form("/logout", "").await;
372 let buyer_id = h.signup("buyaccess", "buyaccess@test.com", "password123").await;
373
374 sqlx::query(
375 r#"INSERT INTO transactions
376 (buyer_id, seller_id, item_id, amount_cents, status,
377 stripe_checkout_session_id, item_title, seller_username,
378 completed_at)
379 VALUES ($1, $2::uuid, $3::uuid, 999, 'completed',
380 'cs_access_test', 'Track', 'sellaccess', NOW())"#,
381 )
382 .bind(buyer_id)
383 .bind(&creator_id)
384 .bind(&item_id)
385 .execute(&h.db)
386 .await
387 .unwrap();
388
389 h.login("buyaccess", "password123").await;
390
391 let resp = h.client.get(&format!("/api/stream/{}", item_id)).await;
392 assert!(
393 resp.status.is_success(),
394 "Purchaser should be able to stream paid item, got: {} {}",
395 resp.status, resp.text
396 );
397 let data: Value = resp.json();
398 assert!(
399 data["stream_url"].as_str().is_some(),
400 "Response should contain stream_url"
401 );
402 }
403
404 /// Creator can always stream their own paid content.
405 #[tokio::test]
406 async fn stream_url_creator_always_has_access() {
407 let mut h = TestHarness::with_storage().await;
408 let (_, _, item_id, _) = setup_published_paid_audio(&mut h, "selfstream", 999).await;
409
410 // Creator is still logged in
411 let resp = h.client.get(&format!("/api/stream/{}", item_id)).await;
412 assert!(
413 resp.status.is_success(),
414 "Creator should stream their own paid item, got: {} {}",
415 resp.status, resp.text
416 );
417 }
418
419 /// Unpublished (draft) item returns 404 for non-owner.
420 #[tokio::test]
421 async fn stream_url_draft_item_404_for_non_owner() {
422 let mut h = TestHarness::with_storage().await;
423 let setup = h.create_creator_with_item("draftowner", "audio", 0).await;
424 h.trust_user(setup.user_id).await;
425 h.grant_tier(setup.user_id, "small_files").await;
426
427 let s3_key = format!("test/{}/audio/draft.mp3", setup.item_id);
428 sqlx::query("UPDATE items SET audio_s3_key = $1, scan_status = 'clean' WHERE id = $2::uuid")
429 .bind(&s3_key)
430 .bind(&setup.item_id)
431 .execute(&h.db)
432 .await
433 .unwrap();
434 h.storage.as_ref().unwrap().put(&s3_key, b"audio data".to_vec());
435
436 // Explicitly unpublish the item (items default to is_public=true)
437 h.client
438 .put_form(&format!("/api/items/{}", setup.item_id), "is_public=false")
439 .await;
440
441 h.client.post_form("/logout", "").await;
442 h.signup("snooper", "snooper@test.com", "password123").await;
443 h.login("snooper", "password123").await;
444
445 let resp = h
446 .client
447 .get(&format!("/api/stream/{}", setup.item_id))
448 .await;
449 assert_eq!(
450 resp.status.as_u16(),
451 404,
452 "Draft item should be 404 for non-owner, got: {}",
453 resp.status
454 );
455 }
456
457 /// Version download for paid item: non-purchaser gets 403.
458 #[tokio::test]
459 async fn version_download_paid_non_purchaser_forbidden() {
460 let mut h = TestHarness::with_storage().await;
461 let setup = h
462 .create_creator_with_item("verseller", "digital", 500)
463 .await;
464 h.trust_user(setup.user_id).await;
465 h.grant_tier(setup.user_id, "small_files").await;
466
467 // Create version with file
468 let resp = h
469 .client
470 .post_json(
471 &format!("/api/items/{}/versions", setup.item_id),
472 &json!({"version_number": "1.0.0"}).to_string(),
473 )
474 .await;
475 assert!(resp.status.is_success(), "Create version failed: {}", resp.text);
476 let version: Value = resp.json();
477 let version_id = version["id"].as_str().unwrap().to_string();
478
479 let resp = h
480 .client
481 .post_json(
482 &format!("/api/versions/{}/upload/presign", version_id),
483 &json!({"file_name": "app.zip", "content_type": "application/zip"}).to_string(),
484 )
485 .await;
486 assert!(resp.status.is_success());
487 let data: Value = resp.json();
488 let s3_key = data["s3_key"].as_str().unwrap().to_string();
489 h.storage
490 .as_ref()
491 .unwrap()
492 .put(&s3_key, b"zip data".to_vec());
493
494 h.client
495 .post_json(
496 &format!("/api/versions/{}/upload/confirm", version_id),
497 &json!({"s3_key": s3_key}).to_string(),
498 )
499 .await;
500
501 // Publish
502 h.publish_project_and_item(&setup.project_id, &setup.item_id)
503 .await;
504
505 // Non-purchaser tries to download
506 h.client.post_form("/logout", "").await;
507 h.signup("verbuyer", "verbuyer@test.com", "password123").await;
508 h.login("verbuyer", "password123").await;
509
510 let resp = h
511 .client
512 .get(&format!("/api/versions/{}/download", version_id))
513 .await;
514 assert_eq!(
515 resp.status.as_u16(),
516 403,
517 "Non-purchaser version download should be 403, got: {}",
518 resp.status
519 );
520 }
521
522 // ---------------------------------------------------------------------------
523 // Access control
524 // ---------------------------------------------------------------------------
525
526 #[tokio::test]
527 async fn upload_non_owner_forbidden() {
528 let mut h = TestHarness::with_storage().await;
529 let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
530
531 // Log out creator, sign up a different user
532 h.client.post_form("/logout", "").await;
533 h.signup("intruder", "intruder@test.com", "password123").await;
534 h.login("intruder", "password123").await;
535
536 // Attempt to presign to creator's item
537 let body = json!({
538 "item_id": item_id,
539 "file_type": "audio",
540 "file_name": "evil.mp3",
541 "content_type": "audio/mpeg",
542 });
543 let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await;
544 assert_eq!(resp.status.as_u16(), 403, "Expected 403, got: {}", resp.text);
545 }
546
547 // ---------------------------------------------------------------------------
548 // Confirm-handler failure & rollback contract (test-fuzz Phase 2.2)
549 //
550 // The Run #9 tx port made the confirm handlers charge storage inside a
551 // transaction and route orphaned keys through the deletion queue. These pin the
552 // observable contract that work protects: a FAILED confirm must never inflate
553 // the storage counter and never leak the S3 object, and the deterministic
554 // reachable tx paths (replace storage-math + old-key orphan enqueue) must hold.
555 //
556 // NOTE on the pure lost-race rollback branches (uploads `Ok(0)`, versions
557 // `Ok(false)`, and the in-tx `Err`): these fire only when a second confirm — or
558 // an item/version delete — interleaves inside the handler's read-then-write
559 // window. `try_apply_storage_on` takes the users-row lock, which serializes
560 // concurrent confirms, so the branch is genuinely a TOCTOU guard. It is not
561 // reachable from a single sequential request (the test client is cookie-bound
562 // and `&mut`, with no exposed app handle for a concurrent same-user pair), so it
563 // is left to the lower-level race coverage; the tests below exercise the same
564 // transaction body and the same orphan-queue helper on their reachable paths.
565 // ---------------------------------------------------------------------------
566
567 /// SMALL_FILES tier storage cap, in bytes (250 GiB). Mirrors
568 /// `CreatorTier::SmallFiles.max_storage_bytes()`.
569 const SMALL_FILES_CAP: i64 = 250 * 1024 * 1024 * 1024;
570
571 async fn presign_audio(h: &mut TestHarness, item_id: &str, file_name: &str) -> String {
572 let body = json!({
573 "item_id": item_id,
574 "file_type": "audio",
575 "file_name": file_name,
576 "content_type": "audio/mpeg",
577 });
578 let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await;
579 assert!(resp.status.is_success(), "presign failed: {}", resp.text);
580 let data: Value = resp.json();
581 data["s3_key"].as_str().unwrap().to_string()
582 }
583
584 async fn presign_version(h: &mut TestHarness, version_id: &str, file_name: &str) -> String {
585 let resp = h
586 .client
587 .post_json(
588 &format!("/api/versions/{}/upload/presign", version_id),
589 &json!({"file_name": file_name, "content_type": "application/zip"}).to_string(),
590 )
591 .await;
592 assert!(resp.status.is_success(), "version presign failed: {}", resp.text);
593 let data: Value = resp.json();
594 data["s3_key"].as_str().unwrap().to_string()
595 }
596
597 async fn storage_used(h: &TestHarness, user_id: &str) -> i64 {
598 sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
599 .bind(user_id)
600 .fetch_one(&h.db)
601 .await
602 .unwrap()
603 }
604
605 #[tokio::test]
606 async fn confirm_over_storage_cap_does_not_charge_or_leak() {
607 let mut h = TestHarness::with_storage().await;
608 let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h, 0).await;
609
610 let s3_key = presign_audio(&mut h, &item_id, "big.mp3").await;
611 h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 100]);
612
613 // Park the counter one increment shy of the cap so this 100-byte file pushes over.
614 let parked = SMALL_FILES_CAP - 50;
615 sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1::uuid")
616 .bind(&user_id)
617 .bind(parked)
618 .execute(&h.db)
619 .await
620 .unwrap();
621
622 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": s3_key});
623 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
624 assert!(!resp.status.is_success(), "over-cap confirm must fail, got: {} {}", resp.status, resp.text);
625
626 // Counter unchanged — a failed confirm never inflates storage.
627 assert_eq!(storage_used(&h, &user_id).await, parked, "failed confirm must not charge storage");
628 // And the object was cleaned up, not leaked.
629 assert!(
630 !h.storage.as_ref().unwrap().object_exists(&s3_key).await.unwrap(),
631 "over-cap confirm must delete the orphaned object"
632 );
633 // The item never picked up the key.
634 let db_key: Option<String> = sqlx::query_scalar("SELECT audio_s3_key FROM items WHERE id = $1::uuid")
635 .bind(&item_id)
636 .fetch_one(&h.db)
637 .await
638 .unwrap();
639 assert_eq!(db_key, None, "item must not reference a key from a failed confirm");
640 }
641
642 #[tokio::test]
643 async fn confirm_wrong_route_file_type_deletes_object_and_does_not_charge() {
644 let mut h = TestHarness::with_storage().await;
645 let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h, 0).await;
646
647 // Presign as audio (a key under user/item/), but confirm it as a "download" —
648 // download has its own /api/versions route, so the item-upload confirm must
649 // reject it, delete the object, and charge nothing.
650 let s3_key = presign_audio(&mut h, &item_id, "song.mp3").await;
651 h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 500]);
652
653 let body = json!({"item_id": item_id, "file_type": "download", "s3_key": s3_key});
654 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
655 assert_eq!(resp.status.as_u16(), 400, "misrouted file type must 400, got: {} {}", resp.status, resp.text);
656
657 assert_eq!(storage_used(&h, &user_id).await, 0, "misrouted confirm must charge nothing");
658 assert!(
659 !h.storage.as_ref().unwrap().object_exists(&s3_key).await.unwrap(),
660 "misrouted confirm must delete the object (it would otherwise leak — the scan_jobs/scan_status footgun the guard prevents)"
661 );
662 }
663
664 #[tokio::test]
665 async fn confirm_replace_charges_delta_and_orphans_old_key() {
666 let mut h = TestHarness::with_storage().await;
667 let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h, 0).await;
668
669 // First upload: 1000 bytes.
670 let key1 = presign_audio(&mut h, &item_id, "v1.mp3").await;
671 h.storage.as_ref().unwrap().put(&key1, vec![0u8; 1000]);
672 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": key1});
673 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
674 assert!(resp.status.is_success(), "first confirm failed: {}", resp.text);
675 assert_eq!(storage_used(&h, &user_id).await, 1000, "first upload charges its full size");
676
677 // Replace with a 300-byte upload.
678 let key2 = presign_audio(&mut h, &item_id, "v2.mp3").await;
679 h.storage.as_ref().unwrap().put(&key2, vec![0u8; 300]);
680 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": key2});
681 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
682 assert!(resp.status.is_success(), "replace confirm failed: {}", resp.text);
683
684 // The item now points at the new key.
685 let db_key: Option<String> = sqlx::query_scalar("SELECT audio_s3_key FROM items WHERE id = $1::uuid")
686 .bind(&item_id)
687 .fetch_one(&h.db)
688 .await
689 .unwrap();
690 assert_eq!(db_key.as_deref(), Some(key2.as_str()));
691
692 // Storage reflects the DELTA, not a double-charge: 1000 - 1000 + 300 = 300.
693 // This is the in-tx try_replace_storage_on path executing for real.
694 assert_eq!(storage_used(&h, &user_id).await, 300, "replace must apply the size delta, not stack");
695
696 // The OLD key is routed through the deletion queue (not deleted inline) so a
697 // transient S3 failure can't leak it — the same orphan-queue the lost-race
698 // path uses.
699 let queued: i64 = sqlx::query_scalar(
700 "SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1 AND source = 'item_upload_replace'",
701 )
702 .bind(&key1)
703 .fetch_one(&h.db)
704 .await
705 .unwrap();
706 assert_eq!(queued, 1, "old key must be enqueued for deletion on replace");
707 // It's still present in S3 right now — the worker deletes it later.
708 assert!(
709 h.storage.as_ref().unwrap().object_exists(&key1).await.unwrap(),
710 "old key is queued, not deleted inline"
711 );
712 }
713
714 #[tokio::test]
715 async fn version_confirm_replace_enqueues_old_key_and_charges_delta() {
716 let mut h = TestHarness::with_storage().await;
717 let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h, 0).await;
718
719 let resp = h
720 .client
721 .post_json(
722 &format!("/api/items/{}/versions", item_id),
723 &json!({"version_number": "1.0.0"}).to_string(),
724 )
725 .await;
726 assert!(resp.status.is_success(), "create version failed: {}", resp.text);
727 let version: Value = resp.json();
728 let version_id = version["id"].as_str().unwrap().to_string();
729
730 // First version file: 2000 bytes.
731 let key1 = presign_version(&mut h, &version_id, "v1.zip").await;
732 h.storage.as_ref().unwrap().put(&key1, vec![0u8; 2000]);
733 let resp = h
734 .client
735 .post_json(
736 &format!("/api/versions/{}/upload/confirm", version_id),
737 &json!({"s3_key": key1}).to_string(),
738 )
739 .await;
740 assert!(resp.status.is_success(), "first version confirm failed: {}", resp.text);
741 assert_eq!(storage_used(&h, &user_id).await, 2000);
742
743 // Replace with 600 bytes.
744 let key2 = presign_version(&mut h, &version_id, "v2.zip").await;
745 h.storage.as_ref().unwrap().put(&key2, vec![0u8; 600]);
746 let resp = h
747 .client
748 .post_json(
749 &format!("/api/versions/{}/upload/confirm", version_id),
750 &json!({"s3_key": key2}).to_string(),
751 )
752 .await;
753 assert!(resp.status.is_success(), "version replace confirm failed: {}", resp.text);
754
755 assert_eq!(storage_used(&h, &user_id).await, 600, "version replace must apply the delta");
756 let queued: i64 = sqlx::query_scalar(
757 "SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1 AND source = 'version_replace'",
758 )
759 .bind(&key1)
760 .fetch_one(&h.db)
761 .await
762 .unwrap();
763 assert_eq!(queued, 1, "old version key must be enqueued for deletion on replace");
764 }
765
766 #[tokio::test]
767 async fn confirm_idempotent_reconfirm_does_not_double_charge() {
768 let mut h = TestHarness::with_storage().await;
769 let (user_id, _project_id, item_id) = setup_creator_with_item(&mut h, 0).await;
770
771 let s3_key = presign_audio(&mut h, &item_id, "track.mp3").await;
772 h.storage.as_ref().unwrap().put(&s3_key, vec![0u8; 500]);
773 let body = json!({"item_id": item_id, "file_type": "audio", "s3_key": s3_key});
774
775 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
776 assert!(resp.status.is_success(), "first confirm failed: {}", resp.text);
777 assert_eq!(storage_used(&h, &user_id).await, 500);
778 // pending_uploads cleared on confirm (Run #7 HIGH-1: otherwise the reaper
779 // deletes the live object 24h later).
780 let pending_after_first: i64 = sqlx::query_scalar(
781 "SELECT COUNT(*) FROM pending_uploads WHERE s3_key = $1",
782 )
783 .bind(&s3_key)
784 .fetch_one(&h.db)
785 .await
786 .unwrap();
787 assert_eq!(pending_after_first, 0, "confirm must clear the pending_uploads row");
788
789 // Re-confirm the SAME key: idempotent success, no second charge.
790 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
791 assert!(resp.status.is_success(), "idempotent re-confirm should succeed: {}", resp.text);
792 assert_eq!(storage_used(&h, &user_id).await, 500, "idempotent re-confirm must NOT double-charge storage");
793 assert!(
794 h.storage.as_ref().unwrap().object_exists(&s3_key).await.unwrap(),
795 "idempotent re-confirm must not delete the live object"
796 );
797 }
798