Skip to main content

max / makenotwork

16.4 KB · 433 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
182 let body = json!({
183 "item_id": item_id,
184 "file_type": "cover",
185 "file_name": "cover.jpg",
186 "content_type": "image/jpeg",
187 });
188 let resp = h.client.post_json("/api/upload/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 "file_type": "cover",
198 "s3_key": s3_key,
199 });
200 let resp = h.client.post_json("/api/upload/confirm", &body.to_string()).await;
201 assert!(resp.status.is_success(), "Cover confirm should succeed: {}", resp.text);
202 }
203
204 /// SmallFiles → upload succeeds + storage_used_bytes incremented.
205 #[tokio::test]
206 async fn upload_with_small_files_succeeds() {
207 let mut h = TestHarness::with_storage().await;
208 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "smfile").await;
209 give_subscription(&h, user_id, "small_files").await;
210
211 let before = get_storage_used(&h, user_id).await;
212 let file_bytes = vec![0u8; 1024]; // 1 KB
213 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
214 assert!(status == 200, "SmallFiles upload should succeed: {body}");
215
216 let after = get_storage_used(&h, user_id).await;
217 assert!(after > before, "Storage should be incremented (before={before}, after={after})");
218 }
219
220 /// File exceeding tier per-file max rejected.
221 #[tokio::test]
222 async fn per_file_limit_enforced() {
223 let mut h = TestHarness::with_storage().await;
224 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "bigfile").await;
225 give_subscription(&h, user_id, "small_files").await;
226
227 // SmallFiles max is 500 MB per-file. We can't fake S3 object_size to
228 // be >500MB in memory, so we test the storage cap enforcement path instead.
229 // SmallFiles storage cap is 250 GB.
230 let near_cap = 250 * 1024 * 1024 * 1024_i64 - 100; // 250GB - 100 bytes (SmallFiles cap)
231 set_storage_used(&h, user_id, near_cap).await;
232
233 let file_bytes = vec![0u8; 1024]; // 1 KB — within per-file limit but exceeds cap
234 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
235 assert!(status == 400 || status == 413, "Expected rejection, got {status}: {body}");
236 assert!(body.contains("storage"), "Expected storage cap error, got: {body}");
237 }
238
239 /// storage_used near cap → upload rejected.
240 #[tokio::test]
241 async fn storage_cap_enforced() {
242 let mut h = TestHarness::with_storage().await;
243 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "capenf").await;
244 give_subscription(&h, user_id, "small_files").await;
245
246 // SmallFiles cap is 250 GB. Set usage to just under cap.
247 let near_cap = 250 * 1024 * 1024 * 1024_i64 - 1;
248 set_storage_used(&h, user_id, near_cap).await;
249
250 let file_bytes = vec![0u8; 2048]; // 2 KB — pushes over the cap
251 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
252 assert!(status == 400, "Expected rejection for storage cap, got {status}: {body}");
253 assert!(body.contains("storage"), "Expected storage cap error, got: {body}");
254 }
255
256 /// Upload then delete → storage decremented after purge.
257 ///
258 /// Soft-delete does not immediately reclaim storage — the scheduler purges
259 /// items after a 7-day grace window. This test fast-forwards deleted_at to
260 /// simulate the grace period expiring, then runs the purge.
261 #[tokio::test]
262 async fn delete_decrements_storage() {
263 let mut h = TestHarness::with_storage().await;
264 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "deldec").await;
265 give_subscription(&h, user_id, "small_files").await;
266
267 // Upload a file
268 let file_bytes = vec![0u8; 1024];
269 let (status, _) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
270 assert_eq!(status, 200, "Upload should succeed");
271
272 let after_upload = get_storage_used(&h, user_id).await;
273 assert!(after_upload > 0, "Storage should be > 0 after upload");
274
275 // Soft-delete the item
276 let resp = h.client.delete(&format!("/api/items/{item_id}")).await;
277 assert!(resp.status.is_success(), "Delete failed: {}", resp.text);
278
279 // Storage unchanged immediately after soft-delete
280 let after_soft_delete = get_storage_used(&h, user_id).await;
281 assert_eq!(after_soft_delete, after_upload, "Storage unchanged after soft-delete");
282
283 // Fast-forward: backdate deleted_at past the 7-day grace window
284 let item_uuid: uuid::Uuid = item_id.parse().unwrap();
285 sqlx::query("UPDATE items SET deleted_at = NOW() - INTERVAL '8 days' WHERE id = $1")
286 .bind(item_uuid)
287 .execute(&h.db)
288 .await
289 .unwrap();
290
291 // Run the purge (same function the scheduler calls)
292 let purged = makenotwork::db::items::purge_expired_deleted_items(&h.db).await.unwrap();
293 assert_eq!(purged, 1, "Should purge 1 item");
294
295 // Recalculate storage (purge deletes the row but doesn't adjust the counter;
296 // the weekly drift correction handles that). Simulate via direct SQL.
297 sqlx::query(
298 r#"
299 UPDATE users SET storage_used_bytes = COALESCE((
300 SELECT SUM(i.audio_file_size_bytes)::BIGINT
301 FROM items i JOIN projects p ON i.project_id = p.id
302 WHERE p.user_id = users.id AND i.audio_file_size_bytes IS NOT NULL
303 ), 0) WHERE id = $1
304 "#,
305 )
306 .bind(user_id)
307 .execute(&h.db)
308 .await
309 .unwrap();
310
311 let after_purge = get_storage_used(&h, user_id).await;
312 assert!(after_purge < after_upload, "Storage should decrease after purge (before={after_upload}, after={after_purge})");
313 }
314
315 /// grandfathered_until in future → upload succeeds as SmallFiles-equivalent.
316 #[tokio::test]
317 async fn grandfathered_creator_can_upload() {
318 let mut h = TestHarness::with_storage().await;
319 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "grandok").await;
320
321 // No subscription, but grandfathered until next year
322 set_grandfathered(&h, user_id, "2027-01-01T00:00:00Z").await;
323
324 let file_bytes = vec![0u8; 1024];
325 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
326 assert_eq!(status, 200, "Grandfathered creator should upload: {body}");
327 }
328
329 /// grandfathered_until in past → rejected.
330 #[tokio::test]
331 async fn expired_grandfathering_rejected() {
332 let mut h = TestHarness::with_storage().await;
333 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "grandexp").await;
334
335 // Grandfathering expired
336 set_grandfathered(&h, user_id, "2020-01-01T00:00:00Z").await;
337
338 let file_bytes = vec![0u8; 1024];
339 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
340 assert!(status == 400 || status == 403, "Expected rejection, got {status}: {body}");
341 }
342
343 /// Admin file override allows larger files than the tier normally permits.
344 #[tokio::test]
345 async fn admin_file_override_allows_larger() {
346 let mut h = TestHarness::with_storage().await;
347 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "oversize").await;
348 give_subscription(&h, user_id, "small_files").await;
349
350 // Set override to 2 GB
351 let two_gb = 2 * 1024 * 1024 * 1024_i64;
352 set_file_override(&h, user_id, Some(two_gb)).await;
353
354 // Normal upload should still work
355 let file_bytes = vec![0u8; 2048];
356 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
357 assert_eq!(status, 200, "Upload with override should succeed: {body}");
358 }
359
360 /// Canceled subscription → upload rejected.
361 #[tokio::test]
362 async fn canceled_subscription_blocks_upload() {
363 let mut h = TestHarness::with_storage().await;
364 let (user_id, _, item_id) = setup_creator_with_item(&mut h, "cancld").await;
365 give_subscription(&h, user_id, "small_files").await;
366
367 // Cancel 5 days ago (within grace period)
368 cancel_subscription(&h, user_id, 5).await;
369
370 let file_bytes = vec![0u8; 1024];
371 let (status, body) = presign_upload_confirm(&mut h, &item_id, &file_bytes).await;
372 assert!(status == 400 || status == 403, "Expected rejection, got {status}: {body}");
373 }
374
375 /// Manually set wrong storage_used → recalculate (via SQL) fixes it.
376 #[tokio::test]
377 async fn recalculate_storage_corrects_drift() {
378 let mut h = TestHarness::new().await;
379 let user_id = setup_creator_no_tier(&mut h, "drift").await;
380
381 // Set storage to wrong value
382 set_storage_used(&h, user_id, 999_999_999).await;
383 assert_eq!(get_storage_used(&h, user_id).await, 999_999_999);
384
385 // Run the recalculation query directly (same as db::creator_tiers::recalculate_storage_used)
386 let total: i64 = sqlx::query_scalar(
387 r#"
388 WITH version_bytes AS (
389 SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total
390 FROM versions v
391 JOIN items i ON v.item_id = i.id
392 JOIN projects p ON i.project_id = p.id
393 WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
394 ),
395 insertion_bytes AS (
396 SELECT COALESCE(SUM(ci.file_size)::BIGINT, 0) AS total
397 FROM content_insertions ci
398 WHERE ci.user_id = $1
399 ),
400 audio_bytes AS (
401 SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
402 FROM items i
403 JOIN projects p ON i.project_id = p.id
404 WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
405 ),
406 cover_bytes AS (
407 SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total
408 FROM items i
409 JOIN projects p ON i.project_id = p.id
410 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
411 )
412 SELECT ((SELECT total FROM version_bytes)
413 + (SELECT total FROM insertion_bytes)
414 + (SELECT total FROM audio_bytes)
415 + (SELECT total FROM cover_bytes))::BIGINT AS total
416 "#,
417 )
418 .bind(user_id)
419 .fetch_one(&h.db)
420 .await
421 .expect("recalculate query");
422
423 sqlx::query("UPDATE users SET storage_used_bytes = $2 WHERE id = $1")
424 .bind(user_id)
425 .bind(total)
426 .execute(&h.db)
427 .await
428 .expect("update storage");
429
430 assert_eq!(total, 0, "User has no files, should be 0");
431 assert_eq!(get_storage_used(&h, user_id).await, 0);
432 }
433