Skip to main content

max / makenotwork

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