Skip to main content

max / makenotwork

12.1 KB · 390 lines History Blame Raw
1 //! Gallery workflow tests, item/project image galleries (launchplan S.1).
2 //!
3 //! Covers the add-only confirm (storage increment + row insert), list ordering,
4 //! delete (storage decrement + row removal), the per-entity cap, reorder, the
5 //! project-target path, and cross-user access control. Mirrors the presign→PUT
6 //! →confirm shape of `storage.rs`.
7
8 use crate::harness::TestHarness;
9 use serde_json::{Value, json};
10
11 async fn setup_creator_with_item(h: &mut TestHarness) -> (String, String, String) {
12 let setup = h.create_creator_with_item("creator", "audio", 0).await;
13 h.trust_user(setup.user_id).await;
14 h.grant_tier(setup.user_id, "small_files").await;
15 (setup.user_id.to_string(), setup.project_id, setup.item_id)
16 }
17
18 async fn storage_used(h: &TestHarness, user_id: &str) -> i64 {
19 sqlx::query_scalar("SELECT storage_used_bytes FROM users WHERE id = $1::uuid")
20 .bind(user_id)
21 .fetch_one(&h.db)
22 .await
23 .unwrap()
24 }
25
26 /// Run the full presign → PUT → confirm for one gallery image. Returns (id, s3_key).
27 async fn add_gallery_image(
28 h: &mut TestHarness,
29 target_type: &str,
30 target_id: &str,
31 file_name: &str,
32 bytes: Vec<u8>,
33 ) -> (String, String) {
34 let resp = h
35 .client
36 .post_json(
37 "/api/gallery/presign",
38 &json!({
39 "target_type": target_type,
40 "target_id": target_id,
41 "file_name": file_name,
42 "content_type": "image/png",
43 })
44 .to_string(),
45 )
46 .await;
47 assert!(
48 resp.status.is_success(),
49 "gallery presign failed: {}",
50 resp.text
51 );
52 let s3_key = resp.json::<Value>()["s3_key"].as_str().unwrap().to_string();
53
54 h.storage.as_ref().unwrap().put(&s3_key, bytes);
55
56 let resp = h
57 .client
58 .post_json(
59 "/api/gallery/confirm",
60 &json!({
61 "target_type": target_type,
62 "target_id": target_id,
63 "s3_key": s3_key,
64 "alt": "a descriptive caption",
65 })
66 .to_string(),
67 )
68 .await;
69 assert!(
70 resp.status.is_success(),
71 "gallery confirm failed: {}",
72 resp.text
73 );
74 let data: Value = resp.json();
75 assert_eq!(data["success"], true);
76
77 // A confirmed gallery image starts scan_status='pending' (fail-closed gate,
78 // migration 162) and only renders once the scan worker flips it to 'clean'.
79 // These tests run without a scan worker, so mark the row clean by key to
80 // simulate a completed scan, the render reads (`list_for_item`) are gated to
81 // clean, and these tests assert ordering of *visible* images.
82 for table in ["item_images", "project_images"] {
83 sqlx::query(&format!(
84 "UPDATE {table} SET scan_status = 'clean' WHERE s3_key = $1"
85 ))
86 .bind(&s3_key)
87 .execute(&h.db)
88 .await
89 .unwrap();
90 }
91
92 (data["id"].as_str().unwrap().to_string(), s3_key)
93 }
94
95 #[tokio::test]
96 async fn gallery_confirm_inserts_row_and_charges_storage() {
97 let mut h = TestHarness::with_storage().await;
98 let (user_id, _, item_id) = setup_creator_with_item(&mut h).await;
99
100 let (id, s3_key) =
101 add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1234]).await;
102 assert!(!id.is_empty());
103
104 let (count, db_key, db_size): (i64, String, i64) = sqlx::query_as(
105 "SELECT COUNT(*)::bigint, MAX(s3_key), MAX(file_size_bytes) FROM item_images WHERE item_id = $1::uuid",
106 )
107 .bind(&item_id)
108 .fetch_one(&h.db)
109 .await
110 .unwrap();
111 assert_eq!(count, 1, "one gallery row inserted");
112 assert_eq!(db_key, s3_key);
113 assert_eq!(db_size, 1234);
114 assert_eq!(
115 storage_used(&h, &user_id).await,
116 1234,
117 "confirm charges the full size"
118 );
119 }
120
121 #[tokio::test]
122 async fn gallery_confirm_replay_is_idempotent() {
123 // STOR-S1 (Run #23): a replayed confirm for an already-recorded s3_key (the
124 // classic lost-response retry) must NOT insert a second row or charge storage
125 // again, it returns the existing row.
126 let mut h = TestHarness::with_storage().await;
127 let (user_id, _, item_id) = setup_creator_with_item(&mut h).await;
128
129 let (id, s3_key) =
130 add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1234]).await;
131 assert_eq!(storage_used(&h, &user_id).await, 1234);
132
133 // Replay the exact same confirm body.
134 let resp = h
135 .client
136 .post_json(
137 "/api/gallery/confirm",
138 &json!({
139 "target_type": "item",
140 "target_id": item_id,
141 "s3_key": s3_key,
142 "alt": "a descriptive caption",
143 })
144 .to_string(),
145 )
146 .await;
147 assert!(
148 resp.status.is_success(),
149 "replayed confirm should succeed idempotently: {}",
150 resp.text
151 );
152 let data: Value = resp.json();
153 assert_eq!(data["success"], true);
154 assert_eq!(
155 data["id"].as_str().unwrap(),
156 id,
157 "replay returns the same row id"
158 );
159
160 let count: i64 =
161 sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1::uuid")
162 .bind(&item_id)
163 .fetch_one(&h.db)
164 .await
165 .unwrap();
166 assert_eq!(count, 1, "replay does not insert a second row");
167 assert_eq!(
168 storage_used(&h, &user_id).await,
169 1234,
170 "replay does not double-charge storage"
171 );
172 }
173
174 #[tokio::test]
175 async fn gallery_list_returns_in_insertion_order() {
176 let mut h = TestHarness::with_storage().await;
177 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
178
179 let (id_a, _) = add_gallery_image(&mut h, "item", &item_id, "a.png", vec![1u8; 100]).await;
180 let (id_b, _) = add_gallery_image(&mut h, "item", &item_id, "b.png", vec![2u8; 100]).await;
181
182 let resp = h
183 .client
184 .get(&format!("/api/gallery/list/item/{item_id}"))
185 .await;
186 assert!(resp.status.is_success(), "list failed: {}", resp.text);
187 let data: Value = resp.json();
188 let arr = data.as_array().unwrap();
189 assert_eq!(arr.len(), 2);
190 assert_eq!(arr[0]["id"], id_a);
191 assert_eq!(arr[1]["id"], id_b);
192 assert_eq!(arr[0]["alt"], "a descriptive caption");
193 }
194
195 #[tokio::test]
196 async fn gallery_delete_removes_row_and_decrements_storage() {
197 let mut h = TestHarness::with_storage().await;
198 let (user_id, _, item_id) = setup_creator_with_item(&mut h).await;
199
200 let (id, _) = add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1000]).await;
201 assert_eq!(storage_used(&h, &user_id).await, 1000);
202
203 let resp = h
204 .client
205 .delete(&format!("/api/gallery/image/item/{id}"))
206 .await;
207 assert!(resp.status.is_success(), "delete failed: {}", resp.text);
208
209 let count: i64 =
210 sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1::uuid")
211 .bind(&item_id)
212 .fetch_one(&h.db)
213 .await
214 .unwrap();
215 assert_eq!(count, 0, "row removed");
216 assert_eq!(
217 storage_used(&h, &user_id).await,
218 0,
219 "delete decrements storage"
220 );
221 }
222
223 #[tokio::test]
224 async fn gallery_cap_enforced_at_presign() {
225 let mut h = TestHarness::with_storage().await;
226 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
227
228 // Seed the gallery to its cap (8) directly, then a 9th presign must 400.
229 for i in 0..8 {
230 sqlx::query(
231 "INSERT INTO item_images (item_id, s3_key, image_url, position) VALUES ($1::uuid, $2, $3, $4)",
232 )
233 .bind(&item_id)
234 .bind(format!("key-{i}"))
235 .bind(format!("http://test-storage/key-{i}"))
236 .bind(i)
237 .execute(&h.db)
238 .await
239 .unwrap();
240 }
241
242 let resp = h
243 .client
244 .post_json(
245 "/api/gallery/presign",
246 &json!({
247 "target_type": "item",
248 "target_id": item_id,
249 "file_name": "ninth.png",
250 "content_type": "image/png",
251 })
252 .to_string(),
253 )
254 .await;
255 assert_eq!(
256 resp.status.as_u16(),
257 400,
258 "9th presign over cap must be rejected: {}",
259 resp.text
260 );
261 }
262
263 #[tokio::test]
264 async fn gallery_reorder_updates_positions() {
265 let mut h = TestHarness::with_storage().await;
266 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
267
268 let (id_a, _) = add_gallery_image(&mut h, "item", &item_id, "a.png", vec![1u8; 100]).await;
269 let (id_b, _) = add_gallery_image(&mut h, "item", &item_id, "b.png", vec![2u8; 100]).await;
270
271 let resp = h
272 .client
273 .post_json(
274 "/api/gallery/reorder",
275 &json!({
276 "target_type": "item",
277 "target_id": item_id,
278 "ordered_ids": [id_b, id_a],
279 })
280 .to_string(),
281 )
282 .await;
283 assert!(resp.status.is_success(), "reorder failed: {}", resp.text);
284
285 let resp = h
286 .client
287 .get(&format!("/api/gallery/list/item/{item_id}"))
288 .await;
289 let data: Value = resp.json();
290 let arr = data.as_array().unwrap();
291 assert_eq!(arr[0]["id"], id_b, "b is now first");
292 assert_eq!(arr[1]["id"], id_a, "a is now second");
293 }
294
295 #[tokio::test]
296 async fn gallery_project_target_inserts_row() {
297 let mut h = TestHarness::with_storage().await;
298 let (user_id, project_id, _) = setup_creator_with_item(&mut h).await;
299
300 let (_, s3_key) =
301 add_gallery_image(&mut h, "project", &project_id, "banner.png", vec![0u8; 500]).await;
302
303 let (count, db_key): (i64, String) = sqlx::query_as(
304 "SELECT COUNT(*)::bigint, MAX(s3_key) FROM project_images WHERE project_id = $1::uuid",
305 )
306 .bind(&project_id)
307 .fetch_one(&h.db)
308 .await
309 .unwrap();
310 assert_eq!(count, 1);
311 assert_eq!(db_key, s3_key);
312 // No scanner in this harness, so the row still holds the unserved staging key
313 // (a Clean scan would later promote it to a content key). Presign hands out
314 // `staging/{uuid}/{filename}`, never the old `projects/{id}/gallery/...` key.
315 assert!(
316 s3_key.starts_with("staging/"),
317 "gallery presign must return a staging key: {s3_key}"
318 );
319 assert_eq!(storage_used(&h, &user_id).await, 500);
320 }
321
322 #[tokio::test]
323 async fn gallery_presign_non_owner_forbidden() {
324 let mut h = TestHarness::with_storage().await;
325 let (_, _, item_id) = setup_creator_with_item(&mut h).await;
326
327 h.client.post_form("/logout", "").await;
328 h.signup("intruder", "intruder@test.com", "password123")
329 .await;
330 h.login("intruder", "password123").await;
331
332 let resp = h
333 .client
334 .post_json(
335 "/api/gallery/presign",
336 &json!({
337 "target_type": "item",
338 "target_id": item_id,
339 "file_name": "evil.png",
340 "content_type": "image/png",
341 })
342 .to_string(),
343 )
344 .await;
345 assert_eq!(
346 resp.status.as_u16(),
347 403,
348 "non-owner presign must be 403: {}",
349 resp.text
350 );
351 }
352
353 #[tokio::test]
354 async fn gallery_delete_non_owner_does_not_remove() {
355 let mut h = TestHarness::with_storage().await;
356 let (user_id, _, item_id) = setup_creator_with_item(&mut h).await;
357 let (img_id, _) =
358 add_gallery_image(&mut h, "item", &item_id, "shot.png", vec![0u8; 1000]).await;
359
360 h.client.post_form("/logout", "").await;
361 h.signup("intruder", "intruder@test.com", "password123")
362 .await;
363 h.login("intruder", "password123").await;
364
365 let resp = h
366 .client
367 .delete(&format!("/api/gallery/image/item/{img_id}"))
368 .await;
369 assert_eq!(
370 resp.status.as_u16(),
371 404,
372 "non-owner delete must be 404: {}",
373 resp.text
374 );
375
376 // Row + the owner's storage are untouched.
377 let count: i64 =
378 sqlx::query_scalar("SELECT COUNT(*) FROM item_images WHERE item_id = $1::uuid")
379 .bind(&item_id)
380 .fetch_one(&h.db)
381 .await
382 .unwrap();
383 assert_eq!(count, 1, "image survives a non-owner delete");
384 assert_eq!(
385 storage_used(&h, &user_id).await,
386 1000,
387 "owner storage unchanged"
388 );
389 }
390