Skip to main content

max / makenotwork

16.5 KB · 432 lines History Blame Raw
1 //! Integration tests for creator tier storage enforcement (Phase 11C).
2 //!
3 //! Tests use raw SQL for setup/verification and HTTP endpoints for upload flows
4 //! since `db::creator_tiers` is crate-private.
5
6 use crate::harness::TestHarness;
7 use makenotwork::db::UserId;
8 use serde_json::{json, Value};
9
10 // =============================================================================
11 // Helpers
12 // =============================================================================
13
14 /// Create a creator with no subscription. Returns user_id.
15 async fn setup_creator_no_tier(h: &mut TestHarness, username: &str) -> UserId {
16 h.create_creator(username).await
17 }
18
19 /// Give a user an active creator subscription at the given tier.
20 async fn give_subscription(h: &TestHarness, user_id: UserId, tier: &str) {
21 sqlx::query(
22 r#"INSERT INTO creator_subscriptions (user_id, stripe_subscription_id, stripe_customer_id, tier, status)
23 VALUES ($1, 'sub_fake_' || $1::text, 'cus_fake_' || $1::text, $2, 'active')
24 ON CONFLICT (user_id) DO UPDATE SET tier = $2, status = 'active'"#,
25 )
26 .bind(user_id)
27 .bind(tier)
28 .execute(&h.db)
29 .await
30 .expect("give_subscription");
31
32 // Sync denormalized column
33 sqlx::query("UPDATE users SET creator_tier = $2 WHERE id = $1")
34 .bind(user_id)
35 .bind(tier)
36 .execute(&h.db)
37 .await
38 .expect("sync creator_tier");
39 }
40
41 /// Set grandfathered_until for a user.
42 async fn set_grandfathered(h: &TestHarness, user_id: UserId, until: &str) {
43 sqlx::query("UPDATE users SET grandfathered_until = $2::timestamptz WHERE id = $1")
44 .bind(user_id)
45 .bind(until)
46 .execute(&h.db)
47 .await
48 .expect("set_grandfathered");
49 }
50
51 /// Set storage_used_bytes for a user.
52 async fn set_storage_used(h: &TestHarness, user_id: UserId, bytes: i64) {
53 sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1")
54 .bind(user_id)
55 .bind(bytes)
56 .execute(&h.db)
57 .await
58 .expect("set_storage_used");
59 }
60
61 /// Get storage_used_bytes for a user.
62 async fn get_storage_used(h: &TestHarness, user_id: UserId) -> i64 {
63 sqlx::query_scalar::<_, i64>("SELECT storage_used_bytes FROM users WHERE id = $1")
64 .bind(user_id)
65 .fetch_one(&h.db)
66 .await
67 .expect("get_storage_used")
68 }
69
70 /// Cancel a user's subscription (set status + canceled_at).
71 async fn cancel_subscription(h: &TestHarness, user_id: UserId, days_ago: i32) {
72 sqlx::query(
73 r#"UPDATE creator_subscriptions
74 SET status = 'canceled', canceled_at = NOW() - ($2 || ' days')::interval
75 WHERE user_id = $1"#,
76 )
77 .bind(user_id)
78 .bind(days_ago)
79 .execute(&h.db)
80 .await
81 .expect("cancel_subscription");
82
83 // Clear denormalized tier
84 sqlx::query("UPDATE users SET creator_tier = NULL WHERE id = $1")
85 .bind(user_id)
86 .execute(&h.db)
87 .await
88 .expect("clear creator_tier");
89 }
90
91 /// Set max_file_override_bytes for a user.
92 async fn set_file_override(h: &TestHarness, user_id: UserId, bytes: Option<i64>) {
93 sqlx::query("UPDATE users SET max_file_override_bytes = $2 WHERE id = $1")
94 .bind(user_id)
95 .bind(bytes)
96 .execute(&h.db)
97 .await
98 .expect("set_file_override");
99 }
100
101 /// Create a creator with a project and item, trusted for uploads.
102 async fn setup_creator_with_item(
103 h: &mut TestHarness,
104 username: &str,
105 ) -> (UserId, String, String) {
106 let setup = h.create_creator_with_item(username, "audio", 0).await;
107 h.trust_user(setup.user_id).await;
108 (setup.user_id, setup.project_id, setup.item_id)
109 }
110
111 /// Presign + upload + confirm an audio file. Returns the confirm response status.
112 async fn presign_upload_confirm(
113 h: &mut TestHarness,
114 item_id: &str,
115 file_bytes: &[u8],
116 ) -> (u16, String) {
117 let body = json!({
118 "item_id": item_id,
119 "file_type": "audio",
120 "file_name": "test.mp3",
121 "content_type": "audio/mpeg",
122 });
123 let resp = h.client.post_json("/api/upload/presign", &body.to_string()).await;
124 if !resp.status.is_success() {
125 return (resp.status.as_u16(), resp.text);
126 }
127 let data: Value = resp.json();
128 let s3_key = data["s3_key"].as_str().unwrap().to_string();
129
130 // Simulate client upload to S3
131 h.storage.as_ref().unwrap().put(&s3_key, file_bytes.to_vec());
132
133 // Confirm
134 let body = json!({
135 "item_id": item_id,
136 "file_type": "audio",
137 "s3_key": s3_key,
138 });
139 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
140 (resp.status.as_u16(), resp.text)
141 }
142
143 // =============================================================================
144 // Tests
145 // =============================================================================
146
147 /// No tier → file upload rejected.
148 #[tokio::test]
149 async fn upload_without_subscription_rejected() {
150 let mut h = TestHarness::with_storage().await;
151 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "notier").await;
152 let _ = user_id;
153
154 let (status, body) = presign_upload_confirm(&mut h, &item_id, b"fake audio").await;
155 assert!(status == 400 || status == 403, "Expected rejection, got {status}: {body}");
156 assert!(
157 body.contains("subscription is required") || body.contains("tier"),
158 "Expected tier error, got: {body}"
159 );
160 }
161
162 /// Basic tier → audio upload rejected ("text-only").
163 #[tokio::test]
164 async fn upload_with_basic_tier_file_rejected() {
165 let mut h = TestHarness::with_storage().await;
166 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "basicup").await;
167 give_subscription(&h, user_id, "basic").await;
168
169 let (status, body) = presign_upload_confirm(&mut h, &item_id, b"fake audio").await;
170 assert!(status == 400 || status == 403, "Expected rejection, got {status}: {body}");
171 assert!(body.contains("text-only"), "Expected text-only error, got: {body}");
172 }
173
174 /// Basic tier → cover upload succeeds (covers bypass tier).
175 #[tokio::test]
176 async fn upload_with_basic_tier_cover_succeeds() {
177 let mut h = TestHarness::with_storage().await;
178 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "basiccov").await;
179 give_subscription(&h, user_id, "basic").await;
180
181 // Presign a cover via the dedicated item-image route (covers no longer go
182 // through the generic /api/upload/confirm — see storage::confirm_upload_rejects_cover).
183 let body = json!({
184 "item_id": item_id,
185 "file_name": "cover.jpg",
186 "content_type": "image/jpeg",
187 });
188 let resp = h.client.post_json("/api/items/image/presign", &body.to_string()).await;
189 assert!(resp.status.is_success(), "Cover presign failed: {}", resp.text);
190 let data: Value = resp.json();
191 let s3_key = data["s3_key"].as_str().unwrap().to_string();
192
193 // Upload and confirm
194 h.storage.as_ref().unwrap().put(&s3_key, b"fake jpeg bytes".to_vec());
195 let body = json!({
196 "item_id": item_id,
197 "s3_key": s3_key,
198 });
199 let resp = h.client.post_json("/api/items/image/confirm", &body.to_string()).await;
200 assert!(resp.status.is_success(), "Cover confirm should succeed: {}", resp.text);
201 }
202
203 /// SmallFiles → upload succeeds + storage_used_bytes incremented.
204 #[tokio::test]
205 async fn upload_with_small_files_succeeds() {
206 let mut h = TestHarness::with_storage().await;
207 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "smfile").await;
208 give_subscription(&h, user_id, "small_files").await;
209
210 let before = get_storage_used(&h, user_id).await;
211 let file_bytes = vec![0u8; 1024]; // 1 KB
212 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
213 assert!(status == 200, "SmallFiles upload should succeed: {body}");
214
215 let after = get_storage_used(&h, user_id).await;
216 assert!(after > before, "Storage should be incremented (before={before}, after={after})");
217 }
218
219 /// File exceeding tier per-file max rejected.
220 #[tokio::test]
221 async fn per_file_limit_enforced() {
222 let mut h = TestHarness::with_storage().await;
223 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "bigfile").await;
224 give_subscription(&h, user_id, "small_files").await;
225
226 // SmallFiles max is 500 MB per-file. We can't fake S3 object_size to
227 // be >500MB in memory, so we test the storage cap enforcement path instead.
228 // SmallFiles storage cap is 250 GB.
229 let near_cap = 250 * 1024 * 1024 * 1024_i64 - 100; // 250GB - 100 bytes (SmallFiles cap)
230 set_storage_used(&h, user_id, near_cap).await;
231
232 let file_bytes = vec![0u8; 1024]; // 1 KB — within per-file limit but exceeds cap
233 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
234 assert!(status == 400 || status == 413, "Expected rejection, got {status}: {body}");
235 assert!(body.contains("storage"), "Expected storage cap error, got: {body}");
236 }
237
238 /// storage_used near cap → upload rejected.
239 #[tokio::test]
240 async fn storage_cap_enforced() {
241 let mut h = TestHarness::with_storage().await;
242 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "capenf").await;
243 give_subscription(&h, user_id, "small_files").await;
244
245 // SmallFiles cap is 250 GB. Set usage to just under cap.
246 let near_cap = 250 * 1024 * 1024 * 1024_i64 - 1;
247 set_storage_used(&h, user_id, near_cap).await;
248
249 let file_bytes = vec![0u8; 2048]; // 2 KB — pushes over the cap
250 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
251 assert!(status == 400, "Expected rejection for storage cap, got {status}: {body}");
252 assert!(body.contains("storage"), "Expected storage cap error, got: {body}");
253 }
254
255 /// Upload then delete → storage decremented after purge.
256 ///
257 /// Soft-delete does not immediately reclaim storage — the scheduler purges
258 /// items after a 7-day grace window. This test fast-forwards deleted_at to
259 /// simulate the grace period expiring, then runs the purge.
260 #[tokio::test]
261 async fn delete_decrements_storage() {
262 let mut h = TestHarness::with_storage().await;
263 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "deldec").await;
264 give_subscription(&h, user_id, "small_files").await;
265
266 // Upload a file
267 let file_bytes = vec![0u8; 1024];
268 let (status, _) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
269 assert_eq!(status, 200, "Upload should succeed");
270
271 let after_upload = get_storage_used(&h, user_id).await;
272 assert!(after_upload > 0, "Storage should be > 0 after upload");
273
274 // Soft-delete the item
275 let resp = h.client.delete(&format!("/api/items/{item_id}")).await;
276 assert!(resp.status.is_success(), "Delete failed: {}", resp.text);
277
278 // Storage unchanged immediately after soft-delete
279 let after_soft_delete = get_storage_used(&h, user_id).await;
280 assert_eq!(after_soft_delete, after_upload, "Storage unchanged after soft-delete");
281
282 // Fast-forward: backdate deleted_at past the 7-day grace window
283 let item_uuid: uuid::Uuid = item_id.parse().unwrap();
284 sqlx::query("UPDATE items SET deleted_at = NOW() - INTERVAL '8 days' WHERE id = $1")
285 .bind(item_uuid)
286 .execute(&h.db)
287 .await
288 .unwrap();
289
290 // Run the purge (same function the scheduler calls)
291 let purged = makenotwork::db::items::purge_expired_deleted_items(&h.db).await.unwrap();
292 assert_eq!(purged, 1, "Should purge 1 item");
293
294 // Recalculate storage (purge deletes the row but doesn't adjust the counter;
295 // the weekly drift correction handles that). Simulate via direct SQL.
296 sqlx::query(
297 r#"
298 UPDATE users SET storage_used_bytes = COALESCE((
299 SELECT SUM(i.audio_file_size_bytes)::BIGINT
300 FROM items i JOIN projects p ON i.project_id = p.id
301 WHERE p.user_id = users.id AND i.audio_file_size_bytes IS NOT NULL
302 ), 0) WHERE id = $1
303 "#,
304 )
305 .bind(user_id)
306 .execute(&h.db)
307 .await
308 .unwrap();
309
310 let after_purge = get_storage_used(&h, user_id).await;
311 assert!(after_purge < after_upload, "Storage should decrease after purge (before={after_upload}, after={after_purge})");
312 }
313
314 /// grandfathered_until in future → upload succeeds as SmallFiles-equivalent.
315 #[tokio::test]
316 async fn grandfathered_creator_can_upload() {
317 let mut h = TestHarness::with_storage().await;
318 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "grandok").await;
319
320 // No subscription, but grandfathered until next year
321 set_grandfathered(&h, user_id, "2027-01-01T00:00:00Z").await;
322
323 let file_bytes = vec![0u8; 1024];
324 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
325 assert_eq!(status, 200, "Grandfathered creator should upload: {body}");
326 }
327
328 /// grandfathered_until in past → rejected.
329 #[tokio::test]
330 async fn expired_grandfathering_rejected() {
331 let mut h = TestHarness::with_storage().await;
332 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "grandexp").await;
333
334 // Grandfathering expired
335 set_grandfathered(&h, user_id, "2020-01-01T00:00:00Z").await;
336
337 let file_bytes = vec![0u8; 1024];
338 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
339 assert!(status == 400 || status == 403, "Expected rejection, got {status}: {body}");
340 }
341
342 /// Admin file override allows larger files than the tier normally permits.
343 #[tokio::test]
344 async fn admin_file_override_allows_larger() {
345 let mut h = TestHarness::with_storage().await;
346 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "oversize").await;
347 give_subscription(&h, user_id, "small_files").await;
348
349 // Set override to 2 GB
350 let two_gb = 2 * 1024 * 1024 * 1024_i64;
351 set_file_override(&h, user_id, Some(two_gb)).await;
352
353 // Normal upload should still work
354 let file_bytes = vec![0u8; 2048];
355 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
356 assert_eq!(status, 200, "Upload with override should succeed: {body}");
357 }
358
359 /// Canceled subscription → upload rejected.
360 #[tokio::test]
361 async fn canceled_subscription_blocks_upload() {
362 let mut h = TestHarness::with_storage().await;
363 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "cancld").await;
364 give_subscription(&h, user_id, "small_files").await;
365
366 // Cancel 5 days ago (within grace period)
367 cancel_subscription(&h, user_id, 5).await;
368
369 let file_bytes = vec![0u8; 1024];
370 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
371 assert!(status == 400 || status == 403, "Expected rejection, got {status}: {body}");
372 }
373
374 /// Manually set wrong storage_used → recalculate (via SQL) fixes it.
375 #[tokio::test]
376 async fn recalculate_storage_corrects_drift() {
377 let mut h = TestHarness::new().await;
378 let user_id = setup_creator_no_tier(&mut h, "drift").await;
379
380 // Set storage to wrong value
381 set_storage_used(&h, user_id, 999_999_999).await;
382 assert_eq!(get_storage_used(&h, user_id).await, 999_999_999);
383
384 // Run the recalculation query directly (same as db::creator_tiers::recalculate_storage_used)
385 let total: i64 = sqlx::query_scalar(
386 r#"
387 WITH version_bytes AS (
388 SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total
389 FROM versions v
390 JOIN items i ON v.item_id = i.id
391 JOIN projects p ON i.project_id = p.id
392 WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
393 ),
394 insertion_bytes AS (
395 SELECT COALESCE(SUM(ci.file_size)::BIGINT, 0) AS total
396 FROM content_insertions ci
397 WHERE ci.user_id = $1
398 ),
399 audio_bytes AS (
400 SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
401 FROM items i
402 JOIN projects p ON i.project_id = p.id
403 WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
404 ),
405 cover_bytes AS (
406 SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total
407 FROM items i
408 JOIN projects p ON i.project_id = p.id
409 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
410 )
411 SELECT ((SELECT total FROM version_bytes)
412 + (SELECT total FROM insertion_bytes)
413 + (SELECT total FROM audio_bytes)
414 + (SELECT total FROM cover_bytes))::BIGINT AS total
415 "#,
416 )
417 .bind(user_id)
418 .fetch_one(&h.db)
419 .await
420 .expect("recalculate query");
421
422 sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1")
423 .bind(user_id)
424 .bind(total)
425 .execute(&h.db)
426 .await
427 .expect("update storage");
428
429 assert_eq!(total, 0, "User has no files, should be 0");
430 assert_eq!(get_storage_used(&h, user_id).await, 0);
431 }
432