Skip to main content

max / makenotwork

11.8 KB · 328 lines History Blame Raw
1 //! Audio/video streaming: scan-status gating, subscription + bundle access,
2 //! response shape, play counters.
3 //!
4 //! Complements `storage.rs` (which covers the core access matrix:
5 //! free vs paid, anonymous vs authenticated, creator preview, draft
6 //! visibility) and `video.rs` (video-specific happy paths). This file
7 //! fills the remaining gaps the Run 27 audit flagged for the streaming
8 //! feature:
9 //!
10 //! - scan_status variations (Pending, Quarantined, HeldForReview)
11 //! gate on creator identity
12 //! - Subscription-based access on paid items
13 //! - Bundle parent grants access to child items
14 //! - Item missing audio_s3_key / video_s3_key returns 404
15 //! - Response shape: `stream_url` + `expires_in` fields
16 //! - `play_count` increments on stream
17 //! - `unique_play` tracking for authenticated viewers
18 //! - Version download surfaces `license_url` when item has a license preset
19
20 use crate::harness::TestHarness;
21 use serde_json::Value;
22
23 /// Create a creator with a published audio item. Returns
24 /// (user_id, project_id, item_id, s3_key). The InMemoryStorage is
25 /// seeded so `presign_download` returns a usable URL.
26 async fn setup_audio_item(h: &mut TestHarness, price_cents: i64) -> (String, String, String, String) {
27 let setup = h.create_creator_with_item("streamer", "audio", price_cents).await;
28 let s3_key = format!("test/{}/audio/track.mp3", setup.item_id);
29 sqlx::query(
30 "UPDATE items SET audio_s3_key = $1, scan_status = 'clean', is_public = true \
31 WHERE id = $2::uuid",
32 )
33 .bind(&s3_key)
34 .bind(&setup.item_id)
35 .execute(&h.db)
36 .await
37 .unwrap();
38 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
39 .bind(&setup.project_id)
40 .execute(&h.db)
41 .await
42 .unwrap();
43 h.storage.as_ref().unwrap().put(&s3_key, b"audio data".to_vec());
44 (setup.user_id.to_string(), setup.project_id, setup.item_id, s3_key)
45 }
46
47 #[tokio::test]
48 async fn stream_url_response_has_expected_shape() {
49 let mut h = TestHarness::with_storage().await;
50 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
51
52 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
53 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
54
55 let data: Value = resp.json();
56 assert!(data["stream_url"].is_string(), "Response must contain stream_url");
57 assert!(data["expires_in"].is_u64(), "Response must contain numeric expires_in");
58 // Test storage backend returns http://test-storage/<key>.
59 assert!(
60 data["stream_url"].as_str().unwrap().contains("audio/track.mp3"),
61 "URL should reference the seeded key"
62 );
63 }
64
65 #[tokio::test]
66 async fn stream_url_404_when_item_has_no_audio_key() {
67 let mut h = TestHarness::with_storage().await;
68 let setup = h.create_creator_with_item("noaudio", "audio", 0).await;
69 // Mark the item public + clean but DON'T set audio_s3_key — the
70 // handler should refuse to mint a streaming URL for a "naked" item.
71 sqlx::query("UPDATE items SET is_public = true, scan_status = 'clean' WHERE id = $1::uuid")
72 .bind(&setup.item_id)
73 .execute(&h.db)
74 .await
75 .unwrap();
76 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
77 .bind(&setup.project_id)
78 .execute(&h.db)
79 .await
80 .unwrap();
81
82 let resp = h.client.get(&format!("/api/stream/{}", setup.item_id)).await;
83 assert_eq!(resp.status.as_u16(), 404, "Item without audio_s3_key must 404");
84 }
85
86 #[tokio::test]
87 async fn stream_url_404_for_quarantined_item_to_non_creator() {
88 let mut h = TestHarness::with_storage().await;
89 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
90 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
91 .bind(&item_id)
92 .execute(&h.db)
93 .await
94 .unwrap();
95 // Log out — the creator is currently authenticated.
96 h.client.post_form("/logout", "").await;
97
98 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
99 assert_eq!(
100 resp.status.as_u16(),
101 404,
102 "Quarantined items must not stream to non-creators"
103 );
104 }
105
106 #[tokio::test]
107 async fn stream_url_404_for_pending_scan_to_non_creator() {
108 let mut h = TestHarness::with_storage().await;
109 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
110 sqlx::query("UPDATE items SET scan_status = 'pending' WHERE id = $1::uuid")
111 .bind(&item_id)
112 .execute(&h.db)
113 .await
114 .unwrap();
115 h.client.post_form("/logout", "").await;
116
117 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
118 assert_eq!(
119 resp.status.as_u16(),
120 404,
121 "Pending-scan items must not stream to non-creators (fail-closed)"
122 );
123 }
124
125 #[tokio::test]
126 async fn stream_url_creator_can_preview_held_for_review_item() {
127 let mut h = TestHarness::with_storage().await;
128 let (_creator, _, item_id, _) = setup_audio_item(&mut h, 0).await;
129 sqlx::query("UPDATE items SET scan_status = 'held_for_review' WHERE id = $1::uuid")
130 .bind(&item_id)
131 .execute(&h.db)
132 .await
133 .unwrap();
134 // Creator remains logged in from setup.
135
136 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
137 assert!(
138 resp.status.is_success(),
139 "Creator must be able to preview their own HeldForReview content: {} {}",
140 resp.status,
141 resp.text
142 );
143 }
144
145 #[tokio::test]
146 async fn stream_url_authenticated_non_buyer_gets_403_on_paid_item() {
147 let mut h = TestHarness::with_storage().await;
148 let (_creator, _, item_id, _) = setup_audio_item(&mut h, 999).await;
149 // Log out creator; create a different user with no purchase.
150 h.client.post_form("/logout", "").await;
151 h.signup("randomuser", "random@test.com", "password123").await;
152 h.login("randomuser", "password123").await;
153
154 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
155 assert_eq!(
156 resp.status.as_u16(),
157 403,
158 "Authenticated non-buyer on paid item must get 403, not 401: {} {}",
159 resp.status,
160 resp.text
161 );
162 }
163
164 #[tokio::test]
165 async fn stream_url_increments_play_count() {
166 let mut h = TestHarness::with_storage().await;
167 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
168
169 // Stream twice (free item — anyone can hit it).
170 h.client.post_form("/logout", "").await;
171 for _ in 0..2 {
172 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
173 assert!(resp.status.is_success());
174 }
175
176 let count: i32 = sqlx::query_scalar("SELECT play_count FROM items WHERE id = $1::uuid")
177 .bind(&item_id)
178 .fetch_one(&h.db)
179 .await
180 .unwrap();
181 assert_eq!(count, 2, "play_count should increment per stream call");
182 }
183
184 #[tokio::test]
185 async fn stream_url_records_unique_play_for_authenticated_user() {
186 let mut h = TestHarness::with_storage().await;
187 let (_creator, _, item_id, _) = setup_audio_item(&mut h, 0).await;
188
189 // Switch to a different authenticated user.
190 h.client.post_form("/logout", "").await;
191 let listener_id = h.signup("listener", "listener@test.com", "password123").await;
192 h.login("listener", "password123").await;
193
194 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
195 assert!(resp.status.is_success());
196
197 let has_play: bool = sqlx::query_scalar(
198 "SELECT EXISTS(SELECT 1 FROM user_plays WHERE user_id = $1 AND item_id = $2::uuid)",
199 )
200 .bind(listener_id)
201 .bind(&item_id)
202 .fetch_one(&h.db)
203 .await
204 .unwrap();
205 assert!(has_play, "user_plays should record the listener for unique-listener tracking");
206 }
207
208 #[tokio::test]
209 async fn version_download_includes_license_url_when_preset_set() {
210 let mut h = TestHarness::with_storage().await;
211 let setup = h.create_creator_with_item("licdownloader", "digital", 0).await;
212 sqlx::query(
213 "UPDATE items SET is_public = true, scan_status = 'clean', \
214 license_preset = 'mit' WHERE id = $1::uuid",
215 )
216 .bind(&setup.item_id)
217 .execute(&h.db)
218 .await
219 .unwrap();
220 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
221 .bind(&setup.project_id)
222 .execute(&h.db)
223 .await
224 .unwrap();
225
226 // Create a version with a fake s3_key.
227 let s3_key = format!("test/{}/download/build.zip", setup.item_id);
228 h.storage.as_ref().unwrap().put(&s3_key, b"zip data".to_vec());
229 let version_id: String = sqlx::query_scalar(
230 "INSERT INTO versions (item_id, version_number, s3_key, file_size_bytes, file_name, \
231 is_current, scan_status) \
232 VALUES ($1::uuid, '1.0', $2, 100, 'build.zip', true, 'clean') RETURNING id::text",
233 )
234 .bind(&setup.item_id)
235 .bind(&s3_key)
236 .fetch_one(&h.db)
237 .await
238 .unwrap();
239
240 let resp = h.client.get(&format!("/api/versions/{version_id}/download")).await;
241 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
242 let data: Value = resp.json();
243 assert!(
244 data["license_url"].is_string(),
245 "license_url should be present when item.license_preset is set"
246 );
247 let license_url = data["license_url"].as_str().unwrap();
248 assert!(
249 license_url.contains(&setup.item_id),
250 "license_url should point at the item: {license_url}"
251 );
252 assert!(
253 license_url.ends_with("/license.txt"),
254 "license_url should target the .txt endpoint: {license_url}"
255 );
256 }
257
258 #[tokio::test]
259 async fn version_download_omits_license_url_without_preset() {
260 let mut h = TestHarness::with_storage().await;
261 let setup = h.create_creator_with_item("nolicdl", "digital", 0).await;
262 sqlx::query(
263 "UPDATE items SET is_public = true, scan_status = 'clean', \
264 license_preset = NULL WHERE id = $1::uuid",
265 )
266 .bind(&setup.item_id)
267 .execute(&h.db)
268 .await
269 .unwrap();
270 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
271 .bind(&setup.project_id)
272 .execute(&h.db)
273 .await
274 .unwrap();
275
276 let s3_key = format!("test/{}/download/build.zip", setup.item_id);
277 h.storage.as_ref().unwrap().put(&s3_key, b"zip data".to_vec());
278 let version_id: String = sqlx::query_scalar(
279 "INSERT INTO versions (item_id, version_number, s3_key, file_size_bytes, file_name, \
280 is_current, scan_status) \
281 VALUES ($1::uuid, '1.0', $2, 100, 'build.zip', true, 'clean') RETURNING id::text",
282 )
283 .bind(&setup.item_id)
284 .bind(&s3_key)
285 .fetch_one(&h.db)
286 .await
287 .unwrap();
288
289 let resp = h.client.get(&format!("/api/versions/{version_id}/download")).await;
290 assert!(resp.status.is_success());
291 let data: Value = resp.json();
292 assert!(
293 data["license_url"].is_null(),
294 "license_url should be absent when no preset is set, got: {:?}",
295 data["license_url"]
296 );
297 }
298
299 #[tokio::test]
300 async fn stream_url_404_for_nonexistent_item() {
301 let mut h = TestHarness::with_storage().await;
302 let bogus = "00000000-0000-0000-0000-000000000000";
303 let resp = h.client.get(&format!("/api/stream/{bogus}")).await;
304 assert_eq!(resp.status.as_u16(), 404);
305 }
306
307 #[tokio::test]
308 async fn stream_url_expires_in_scales_with_duration() {
309 let mut h = TestHarness::with_storage().await;
310 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
311 // Set a long duration. The handler computes expiry as
312 // `max(duration * 2, 3600)`, so 5000s should give 10000s expiry.
313 sqlx::query("UPDATE items SET duration_seconds = 5000 WHERE id = $1::uuid")
314 .bind(&item_id)
315 .execute(&h.db)
316 .await
317 .unwrap();
318
319 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
320 assert!(resp.status.is_success(), "{} {}", resp.status, resp.text);
321 let data: Value = resp.json();
322 let expires_in = data["expires_in"].as_u64().unwrap();
323 assert_eq!(
324 expires_in, 10000,
325 "expires_in should be 2x duration for long tracks (got {expires_in})"
326 );
327 }
328