Skip to main content

max / makenotwork

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