Skip to main content

max / makenotwork

16.6 KB · 489 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 if !resp.status.is_success() {
123 return (resp.status.as_u16(), resp.text);
124 }
125 let data: Value = resp.json();
126 let s3_key = data["s3_key"].as_str().unwrap().to_string();
127
128 // Simulate client upload to S3
129 h.storage
130 .as_ref()
131 .unwrap()
132 .put(&s3_key, file_bytes.to_vec());
133
134 // Confirm
135 let body = json!({
136 "item_id": item_id,
137 "file_type": "audio",
138 "s3_key": s3_key,
139 });
140 let resp = h
141 .client
142 .post_json("/api/upload/confirm", &body.to_string())
143 .await;
144 (resp.status.as_u16(), resp.text)
145 }
146
147 // Tests
148
149 /// No tier → file upload rejected.
150 #[tokio::test]
151 async fn upload_without_subscription_rejected() {
152 let mut h = TestHarness::with_storage().await;
153 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "notier").await;
154 let _ = user_id;
155
156 let (status, body) = presign_upload_confirm(&mut h, &item_id, b"fake audio").await;
157 assert!(
158 status == 400 || status == 403,
159 "Expected rejection, got {status}: {body}"
160 );
161 assert!(
162 body.contains("subscription is required") || body.contains("tier"),
163 "Expected tier error, got: {body}"
164 );
165 }
166
167 /// Basic tier → audio upload rejected ("text-only").
168 #[tokio::test]
169 async fn upload_with_basic_tier_file_rejected() {
170 let mut h = TestHarness::with_storage().await;
171 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "basicup").await;
172 give_subscription(&h, user_id, "basic").await;
173
174 let (status, body) = presign_upload_confirm(&mut h, &item_id, b"fake audio").await;
175 assert!(
176 status == 400 || status == 403,
177 "Expected rejection, got {status}: {body}"
178 );
179 assert!(
180 body.contains("text-only"),
181 "Expected text-only error, got: {body}"
182 );
183 }
184
185 /// Basic tier → cover upload succeeds (covers bypass tier).
186 #[tokio::test]
187 async fn upload_with_basic_tier_cover_succeeds() {
188 let mut h = TestHarness::with_storage().await;
189 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "basiccov").await;
190 give_subscription(&h, user_id, "basic").await;
191
192 // Presign a cover via the dedicated item-image route (covers no longer go
193 // through the generic /api/upload/confirm, see storage::confirm_upload_rejects_cover).
194 let body = json!({
195 "item_id": item_id,
196 "file_name": "cover.jpg",
197 "content_type": "image/jpeg",
198 });
199 let resp = h
200 .client
201 .post_json("/api/items/image/presign", &body.to_string())
202 .await;
203 assert!(
204 resp.status.is_success(),
205 "Cover presign failed: {}",
206 resp.text
207 );
208 let data: Value = resp.json();
209 let s3_key = data["s3_key"].as_str().unwrap().to_string();
210
211 // Upload and confirm
212 h.storage
213 .as_ref()
214 .unwrap()
215 .put(&s3_key, b"fake jpeg bytes".to_vec());
216 let body = json!({
217 "item_id": item_id,
218 "s3_key": s3_key,
219 });
220 let resp = h
221 .client
222 .post_json("/api/items/image/confirm", &body.to_string())
223 .await;
224 assert!(
225 resp.status.is_success(),
226 "Cover confirm should succeed: {}",
227 resp.text
228 );
229 }
230
231 /// SmallFiles → upload succeeds + storage_used_bytes incremented.
232 #[tokio::test]
233 async fn upload_with_small_files_succeeds() {
234 let mut h = TestHarness::with_storage().await;
235 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "smfile").await;
236 give_subscription(&h, user_id, "small_files").await;
237
238 let before = get_storage_used(&h, user_id).await;
239 let file_bytes = vec![0u8; 1024]; // 1 KB
240 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
241 assert!(status == 200, "SmallFiles upload should succeed: {body}");
242
243 let after = get_storage_used(&h, user_id).await;
244 assert!(
245 after > before,
246 "Storage should be incremented (before={before}, after={after})"
247 );
248 }
249
250 /// File exceeding tier per-file max rejected.
251 #[tokio::test]
252 async fn per_file_limit_enforced() {
253 let mut h = TestHarness::with_storage().await;
254 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "bigfile").await;
255 give_subscription(&h, user_id, "small_files").await;
256
257 // SmallFiles max is 500 MB per-file. We can't fake S3 object_size to
258 // be >500MB in memory, so we test the storage cap enforcement path instead.
259 // SmallFiles storage cap is 250 GB.
260 let near_cap = 250 * 1024 * 1024 * 1024_i64 - 100; // 250GB - 100 bytes (SmallFiles cap)
261 set_storage_used(&h, user_id, near_cap).await;
262
263 let file_bytes = vec![0u8; 1024]; // 1 KB, within per-file limit but exceeds cap
264 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
265 assert!(
266 status == 400 || status == 413,
267 "Expected rejection, got {status}: {body}"
268 );
269 assert!(
270 body.contains("storage"),
271 "Expected storage cap error, got: {body}"
272 );
273 }
274
275 /// storage_used near cap → upload rejected.
276 #[tokio::test]
277 async fn storage_cap_enforced() {
278 let mut h = TestHarness::with_storage().await;
279 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "capenf").await;
280 give_subscription(&h, user_id, "small_files").await;
281
282 // SmallFiles cap is 250 GB. Set usage to just under cap.
283 let near_cap = 250 * 1024 * 1024 * 1024_i64 - 1;
284 set_storage_used(&h, user_id, near_cap).await;
285
286 let file_bytes = vec![0u8; 2048]; // 2 KB, pushes over the cap
287 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
288 assert!(
289 status == 400,
290 "Expected rejection for storage cap, got {status}: {body}"
291 );
292 assert!(
293 body.contains("storage"),
294 "Expected storage cap error, got: {body}"
295 );
296 }
297
298 /// Upload then delete → storage decremented after purge.
299 ///
300 /// Soft-delete does not immediately reclaim storage, the scheduler purges
301 /// items after a 7-day grace window. This test fast-forwards deleted_at to
302 /// simulate the grace period expiring, then runs the purge.
303 #[tokio::test]
304 async fn delete_decrements_storage() {
305 let mut h = TestHarness::with_storage().await;
306 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "deldec").await;
307 give_subscription(&h, user_id, "small_files").await;
308
309 // Upload a file
310 let file_bytes = vec![0u8; 1024];
311 let (status, _) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
312 assert_eq!(status, 200, "Upload should succeed");
313
314 let after_upload = get_storage_used(&h, user_id).await;
315 assert!(after_upload > 0, "Storage should be > 0 after upload");
316
317 // Soft-delete the item
318 let resp = h.client.delete(&format!("/api/items/{item_id}")).await;
319 assert!(resp.status.is_success(), "Delete failed: {}", resp.text);
320
321 // Storage unchanged immediately after soft-delete
322 let after_soft_delete = get_storage_used(&h, user_id).await;
323 assert_eq!(
324 after_soft_delete, after_upload,
325 "Storage unchanged after soft-delete"
326 );
327
328 // Fast-forward: backdate deleted_at past the 7-day grace window
329 let item_uuid: uuid::Uuid = item_id.parse().unwrap();
330 sqlx::query("UPDATE items SET deleted_at = NOW() - INTERVAL '8 days' WHERE id = $1")
331 .bind(item_uuid)
332 .execute(&h.db)
333 .await
334 .unwrap();
335
336 // Run the purge (same function the scheduler calls)
337 let purged = makenotwork::db::items::purge_expired_deleted_items(&h.db)
338 .await
339 .unwrap();
340 assert_eq!(purged, 1, "Should purge 1 item");
341
342 // Recalculate storage (purge deletes the row but doesn't adjust the counter;
343 // the weekly drift correction handles that). Simulate via direct SQL.
344 sqlx::query(
345 r"
346 UPDATE users SET storage_used_bytes = COALESCE((
347 SELECT SUM(i.audio_file_size_bytes)::BIGINT
348 FROM items i JOIN projects p ON i.project_id = p.id
349 WHERE p.user_id = users.id AND i.audio_file_size_bytes IS NOT NULL
350 ), 0) WHERE id = $1
351 ",
352 )
353 .bind(user_id)
354 .execute(&h.db)
355 .await
356 .unwrap();
357
358 let after_purge = get_storage_used(&h, user_id).await;
359 assert!(
360 after_purge < after_upload,
361 "Storage should decrease after purge (before={after_upload}, after={after_purge})"
362 );
363 }
364
365 /// grandfathered_until in future → upload succeeds as SmallFiles-equivalent.
366 #[tokio::test]
367 async fn grandfathered_creator_can_upload() {
368 let mut h = TestHarness::with_storage().await;
369 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "grandok").await;
370
371 // No subscription, but grandfathered until next year
372 set_grandfathered(&h, user_id, "2027-01-01T00:00:00Z").await;
373
374 let file_bytes = vec![0u8; 1024];
375 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
376 assert_eq!(status, 200, "Grandfathered creator should upload: {body}");
377 }
378
379 /// grandfathered_until in past → rejected.
380 #[tokio::test]
381 async fn expired_grandfathering_rejected() {
382 let mut h = TestHarness::with_storage().await;
383 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "grandexp").await;
384
385 // Grandfathering expired
386 set_grandfathered(&h, user_id, "2020-01-01T00:00:00Z").await;
387
388 let file_bytes = vec![0u8; 1024];
389 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
390 assert!(
391 status == 400 || status == 403,
392 "Expected rejection, got {status}: {body}"
393 );
394 }
395
396 /// Admin file override allows larger files than the tier normally permits.
397 #[tokio::test]
398 async fn admin_file_override_allows_larger() {
399 let mut h = TestHarness::with_storage().await;
400 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "oversize").await;
401 give_subscription(&h, user_id, "small_files").await;
402
403 // Set override to 2 GB
404 let two_gb = 2 * 1024 * 1024 * 1024_i64;
405 set_file_override(&h, user_id, Some(two_gb)).await;
406
407 // Normal upload should still work
408 let file_bytes = vec![0u8; 2048];
409 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
410 assert_eq!(status, 200, "Upload with override should succeed: {body}");
411 }
412
413 /// Canceled subscription → upload rejected.
414 #[tokio::test]
415 async fn canceled_subscription_blocks_upload() {
416 let mut h = TestHarness::with_storage().await;
417 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "cancld").await;
418 give_subscription(&h, user_id, "small_files").await;
419
420 // Cancel 5 days ago (within grace period)
421 cancel_subscription(&h, user_id, 5).await;
422
423 let file_bytes = vec![0u8; 1024];
424 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
425 assert!(
426 status == 400 || status == 403,
427 "Expected rejection, got {status}: {body}"
428 );
429 }
430
431 /// Manually set wrong storage_used → recalculate (via SQL) fixes it.
432 #[tokio::test]
433 async fn recalculate_storage_corrects_drift() {
434 let mut h = TestHarness::new().await;
435 let user_id = setup_creator_no_tier(&mut h, "drift").await;
436
437 // Set storage to wrong value
438 set_storage_used(&h, user_id, 999_999_999).await;
439 assert_eq!(get_storage_used(&h, user_id).await, 999_999_999);
440
441 // Run the recalculation query directly (same as db::creator_tiers::recalculate_storage_used)
442 let total: i64 = sqlx::query_scalar(
443 r"
444 WITH version_bytes AS (
445 SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total
446 FROM versions v
447 JOIN items i ON v.item_id = i.id
448 JOIN projects p ON i.project_id = p.id
449 WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
450 ),
451 insertion_bytes AS (
452 SELECT COALESCE(SUM(ci.file_size)::BIGINT, 0) AS total
453 FROM content_insertions ci
454 WHERE ci.user_id = $1
455 ),
456 audio_bytes AS (
457 SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
458 FROM items i
459 JOIN projects p ON i.project_id = p.id
460 WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
461 ),
462 cover_bytes AS (
463 SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total
464 FROM items i
465 JOIN projects p ON i.project_id = p.id
466 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
467 )
468 SELECT ((SELECT total FROM version_bytes)
469 + (SELECT total FROM insertion_bytes)
470 + (SELECT total FROM audio_bytes)
471 + (SELECT total FROM cover_bytes))::BIGINT AS total
472 ",
473 )
474 .bind(user_id)
475 .fetch_one(&h.db)
476 .await
477 .expect("recalculate query");
478
479 sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1")
480 .bind(user_id)
481 .bind(total)
482 .execute(&h.db)
483 .await
484 .expect("update storage");
485
486 assert_eq!(total, 0, "User has no files, should be 0");
487 assert_eq!(get_storage_used(&h, user_id).await, 0);
488 }
489