Skip to main content

max / makenotwork

12.8 KB · 366 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
19 use crate::harness::TestHarness;
20 use serde_json::Value;
21
22 /// Create a creator with a published audio item. Returns
23 /// (user_id, project_id, item_id, s3_key). The InMemoryStorage is
24 /// seeded so `presign_download` returns a usable URL.
25 async fn setup_audio_item(
26 h: &mut TestHarness,
27 price_cents: i64,
28 ) -> (String, String, String, String) {
29 let setup = h
30 .create_creator_with_item("streamer", "audio", price_cents)
31 .await;
32 let s3_key = format!("test/{}/audio/track.mp3", setup.item_id);
33 sqlx::query(
34 "UPDATE items SET audio_s3_key = $1, scan_status = 'clean', is_public = true \
35 WHERE id = $2::uuid",
36 )
37 .bind(&s3_key)
38 .bind(&setup.item_id)
39 .execute(&h.db)
40 .await
41 .unwrap();
42 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
43 .bind(&setup.project_id)
44 .execute(&h.db)
45 .await
46 .unwrap();
47 h.storage
48 .as_ref()
49 .unwrap()
50 .put(&s3_key, b"audio data".to_vec());
51 (
52 setup.user_id.to_string(),
53 setup.project_id,
54 setup.item_id,
55 s3_key,
56 )
57 }
58
59 /// Regression pin for Payments S1 / CHRONIC 2: the download/stream access gate
60 /// MUST honor `current_period_end`. An item subscription still flagged
61 /// `status = 'active'` (its cancel/expiry webhook lapsed or never arrived) but
62 /// whose paid period has EXPIRED must not grant access. Before the seal,
63 /// `check_item_access` hand-wrote `status = 'active' AND paused_at IS NULL` and
64 /// dropped the period clause, so a lapsed subscriber could still pull the file
65 /// on the very route that protects content. The gate now routes through
66 /// `subscriptions::has_access`, which enforces the clause in one sealed place.
67 /// If anyone re-inlines the predicate without the period clause, the first
68 /// assertion (lapsed → 403) flips to 200 and this test fails.
69 #[tokio::test]
70 async fn stream_denies_item_subscription_with_lapsed_period() {
71 let mut h = TestHarness::with_storage().await;
72 // Paid audio item owned by the creator (price > 0 ⇒ access is gated).
73 let (_creator, _project, item_id, _s3) = setup_audio_item(&mut h, 500).await;
74 let item_uuid: uuid::Uuid = item_id.parse().unwrap();
75
76 // Item-level subscription tier (project_id NULL, item_id set).
77 let tier_id: uuid::Uuid = sqlx::query_scalar(
78 "INSERT INTO subscription_tiers (project_id, item_id, name, price_cents) \
79 VALUES (NULL, $1, 'Item Tier', 500) RETURNING id",
80 )
81 .bind(item_uuid)
82 .fetch_one(&h.db)
83 .await
84 .unwrap();
85
86 // A fan with a known password (seeded directly; we only need to log in).
87 let hash = makenotwork::auth::hash_password("password123").unwrap();
88 let fan_id: makenotwork::db::UserId = sqlx::query_scalar(
89 "INSERT INTO users (username, email, password_hash, email_verified) \
90 VALUES ('lapsedfan', 'lapsedfan@example.com', $1, true) RETURNING id",
91 )
92 .bind(&hash)
93 .fetch_one(&h.db)
94 .await
95 .unwrap();
96
97 // Active-status item subscription whose paid period ended a day ago.
98 sqlx::query(
99 "INSERT INTO subscriptions \
100 (subscriber_id, tier_id, item_id, project_id, stripe_subscription_id, \
101 stripe_customer_id, status, paused_at, current_period_end) \
102 VALUES ($1, $2, $3, NULL, 'sub_lapsed', 'cus_lapsed', 'active', NULL, NOW() - INTERVAL '1 day')",
103 )
104 .bind(fan_id)
105 .bind(tier_id)
106 .bind(item_uuid)
107 .execute(&h.db)
108 .await
109 .unwrap();
110
111 // Act as the fan (the creator is logged in from setup_audio_item).
112 h.client.post_form("/logout", "").await;
113 h.login("lapsedfan", "password123").await;
114
115 // Lapsed period → denied on the download gate.
116 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
117 assert_eq!(
118 resp.status.as_u16(),
119 403,
120 "lapsed-period subscriber must be denied the download gate, got {} {}",
121 resp.status,
122 resp.text
123 );
124
125 // Same subscriber, period pushed into the future → now passes.
126 sqlx::query(
127 "UPDATE subscriptions SET current_period_end = NOW() + INTERVAL '30 days' \
128 WHERE subscriber_id = $1",
129 )
130 .bind(fan_id)
131 .execute(&h.db)
132 .await
133 .unwrap();
134
135 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
136 assert_eq!(
137 resp.status, 200,
138 "subscriber within the paid period must pass the gate, got {} {}",
139 resp.status, resp.text
140 );
141 }
142
143 #[tokio::test]
144 async fn stream_url_response_has_expected_shape() {
145 let mut h = TestHarness::with_storage().await;
146 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
147
148 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
149 assert_eq!(resp.status, 200, "{} {}", resp.status, resp.text);
150
151 let data: Value = resp.json();
152 assert!(
153 data["stream_url"].is_string(),
154 "Response must contain stream_url"
155 );
156 assert!(
157 data["expires_in"].is_u64(),
158 "Response must contain numeric expires_in"
159 );
160 // Test storage backend returns http://test-storage/<key>.
161 assert!(
162 data["stream_url"]
163 .as_str()
164 .unwrap()
165 .contains("audio/track.mp3"),
166 "URL should reference the seeded key"
167 );
168 }
169
170 #[tokio::test]
171 async fn stream_url_404_when_item_has_no_audio_key() {
172 let mut h = TestHarness::with_storage().await;
173 let setup = h.create_creator_with_item("noaudio", "audio", 0).await;
174 // Mark the item public + clean but DON'T set audio_s3_key, the
175 // handler should refuse to mint a streaming URL for a "naked" item.
176 sqlx::query("UPDATE items SET is_public = true, scan_status = 'clean' WHERE id = $1::uuid")
177 .bind(&setup.item_id)
178 .execute(&h.db)
179 .await
180 .unwrap();
181 sqlx::query("UPDATE projects SET is_public = true WHERE id = $1::uuid")
182 .bind(&setup.project_id)
183 .execute(&h.db)
184 .await
185 .unwrap();
186
187 let resp = h
188 .client
189 .get(&format!("/api/stream/{}", setup.item_id))
190 .await;
191 assert_eq!(
192 resp.status.as_u16(),
193 404,
194 "Item without audio_s3_key must 404"
195 );
196 }
197
198 #[tokio::test]
199 async fn stream_url_404_for_quarantined_item_to_non_creator() {
200 let mut h = TestHarness::with_storage().await;
201 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
202 sqlx::query("UPDATE items SET scan_status = 'quarantined' WHERE id = $1::uuid")
203 .bind(&item_id)
204 .execute(&h.db)
205 .await
206 .unwrap();
207 // Log out, the creator is currently authenticated.
208 h.client.post_form("/logout", "").await;
209
210 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
211 assert_eq!(
212 resp.status.as_u16(),
213 404,
214 "Quarantined items must not stream to non-creators"
215 );
216 }
217
218 #[tokio::test]
219 async fn stream_url_404_for_pending_scan_to_non_creator() {
220 let mut h = TestHarness::with_storage().await;
221 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
222 sqlx::query("UPDATE items SET scan_status = 'pending' WHERE id = $1::uuid")
223 .bind(&item_id)
224 .execute(&h.db)
225 .await
226 .unwrap();
227 h.client.post_form("/logout", "").await;
228
229 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
230 assert_eq!(
231 resp.status.as_u16(),
232 404,
233 "Pending-scan items must not stream to non-creators (fail-closed)"
234 );
235 }
236
237 #[tokio::test]
238 async fn stream_url_creator_can_preview_held_for_review_item() {
239 let mut h = TestHarness::with_storage().await;
240 let (_creator, _, item_id, _) = setup_audio_item(&mut h, 0).await;
241 sqlx::query("UPDATE items SET scan_status = 'held_for_review' WHERE id = $1::uuid")
242 .bind(&item_id)
243 .execute(&h.db)
244 .await
245 .unwrap();
246 // Creator remains logged in from setup.
247
248 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
249 assert_eq!(
250 resp.status, 200,
251 "Creator must be able to preview their own HeldForReview content: {} {}",
252 resp.status, resp.text
253 );
254 }
255
256 #[tokio::test]
257 async fn stream_url_authenticated_non_buyer_gets_403_on_paid_item() {
258 let mut h = TestHarness::with_storage().await;
259 let (_creator, _, item_id, _) = setup_audio_item(&mut h, 999).await;
260 // Log out creator; create a different user with no purchase.
261 h.client.post_form("/logout", "").await;
262 h.signup("randomuser", "random@test.com", "password123")
263 .await;
264 h.login("randomuser", "password123").await;
265
266 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
267 assert_eq!(
268 resp.status.as_u16(),
269 403,
270 "Authenticated non-buyer on paid item must get 403, not 401: {} {}",
271 resp.status,
272 resp.text
273 );
274 }
275
276 #[tokio::test]
277 async fn stream_url_increments_play_count() {
278 let mut h = TestHarness::with_storage().await;
279 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
280
281 // Stream twice (free item, anyone can hit it).
282 h.client.post_form("/logout", "").await;
283 for _ in 0..2 {
284 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
285 assert_eq!(resp.status, 200, "{}", resp.text);
286 }
287
288 let count: i32 = sqlx::query_scalar("SELECT play_count FROM items WHERE id = $1::uuid")
289 .bind(&item_id)
290 .fetch_one(&h.db)
291 .await
292 .unwrap();
293 assert_eq!(count, 2, "play_count should increment per stream call");
294 }
295
296 #[tokio::test]
297 async fn stream_url_records_unique_play_for_authenticated_user() {
298 let mut h = TestHarness::with_storage().await;
299 let (_creator, _, item_id, _) = setup_audio_item(&mut h, 0).await;
300
301 // Switch to a different authenticated user.
302 h.client.post_form("/logout", "").await;
303 let listener_id = h
304 .signup("listener", "listener@test.com", "password123")
305 .await;
306 h.login("listener", "password123").await;
307
308 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
309 assert_eq!(resp.status, 200, "{}", resp.text);
310
311 let has_play: bool = sqlx::query_scalar(
312 "SELECT EXISTS(SELECT 1 FROM user_plays WHERE user_id = $1 AND item_id = $2::uuid)",
313 )
314 .bind(listener_id)
315 .bind(&item_id)
316 .fetch_one(&h.db)
317 .await
318 .unwrap();
319 assert!(
320 has_play,
321 "user_plays should record the listener for unique-listener tracking"
322 );
323 }
324
325 // `version_download_includes_license_url_when_preset_set` and
326 // `version_download_omits_license_url_without_preset` were here until
327 // 2026-08-26. `GET /api/versions/{id}/download` answered JSON carrying a
328 // `license_url`; it now answers 303 to the presigned URL (`8fc6b1af`, option
329 // (a)), so a redirect has nowhere to put that field and these two assert a
330 // shape that no longer exists.
331 //
332 // The licence itself is not lost and was never reached through this field:
333 // `/api/items/{id}/license.txt` is linked directly from `pages/item.html` and
334 // `pages/library_downloads.html`, fetched by `static/page-item-1.js`, and
335 // published as a v1 OpenAPI path. Nothing consumed the copy in this response.
336
337 #[tokio::test]
338 async fn stream_url_404_for_nonexistent_item() {
339 let mut h = TestHarness::with_storage().await;
340 let bogus = "00000000-0000-0000-0000-000000000000";
341 let resp = h.client.get(&format!("/api/stream/{bogus}")).await;
342 assert_eq!(resp.status.as_u16(), 404);
343 }
344
345 #[tokio::test]
346 async fn stream_url_expires_in_scales_with_duration() {
347 let mut h = TestHarness::with_storage().await;
348 let (_, _, item_id, _) = setup_audio_item(&mut h, 0).await;
349 // Set a long duration. The handler computes expiry as
350 // `max(duration * 2, 3600)`, so 5000s should give 10000s expiry.
351 sqlx::query("UPDATE items SET duration_seconds = 5000 WHERE id = $1::uuid")
352 .bind(&item_id)
353 .execute(&h.db)
354 .await
355 .unwrap();
356
357 let resp = h.client.get(&format!("/api/stream/{item_id}")).await;
358 assert_eq!(resp.status, 200, "{} {}", resp.status, resp.text);
359 let data: Value = resp.json();
360 let expires_in = data["expires_in"].as_u64().unwrap();
361 assert_eq!(
362 expires_in, 10000,
363 "expires_in should be 2x duration for long tracks (got {expires_in})"
364 );
365 }
366