Skip to main content

max / makenotwork

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