Skip to main content

max / makenotwork

13.0 KB · 463 lines History Blame Raw
1 //! Item sections: CRUD lifecycle, reorder, max limit, ownership, validation, public visibility.
2
3 use crate::harness::TestHarness;
4 use serde_json::Value;
5
6 /// Helper: create a creator with a project and a plugin item, return (project_id, item_id).
7 async fn setup_creator_with_item(h: &mut TestHarness, username: &str) -> (String, String) {
8 let setup = h.create_creator_with_item(username, "plugin", 0).await;
9 (setup.project_id, setup.item_id)
10 }
11
12 #[tokio::test]
13 async fn section_create_update_delete() {
14 let mut h = TestHarness::new().await;
15 let (_project_id, item_id) = setup_creator_with_item(&mut h, "seccrud").await;
16
17 // Create section
18 let resp = h
19 .client
20 .post_json(
21 &format!("/api/items/{item_id}/sections"),
22 r#"{"title": "Features", "body": "- Fast\n- Reliable"}"#,
23 )
24 .await;
25 assert!(
26 resp.status.is_success(),
27 "Create section failed: {} {}",
28 resp.status,
29 resp.text
30 );
31 let section: Value = resp.json();
32 let section_id = section["id"].as_str().unwrap().to_string();
33 assert_eq!(section["title"].as_str().unwrap(), "Features");
34 assert_eq!(section["slug"].as_str().unwrap(), "features");
35 assert_eq!(section["sort_order"].as_i64().unwrap(), 0);
36
37 // Update section
38 let resp = h
39 .client
40 .put_json(
41 &format!("/api/sections/{section_id}"),
42 r#"{"title": "Key Features", "body": "- Fast\n- Reliable\n- Secure"}"#,
43 )
44 .await;
45 assert!(
46 resp.status.is_success(),
47 "Update section failed: {} {}",
48 resp.status,
49 resp.text
50 );
51 let updated: Value = resp.json();
52 assert_eq!(updated["title"].as_str().unwrap(), "Key Features");
53 assert_eq!(updated["slug"].as_str().unwrap(), "key-features");
54
55 // Delete section
56 let resp = h
57 .client
58 .delete(&format!("/api/sections/{section_id}"))
59 .await;
60 assert!(
61 resp.status.is_success(),
62 "Delete section failed: {} {}",
63 resp.status,
64 resp.text
65 );
66
67 // Verify gone
68 let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM item_sections WHERE id = $1")
69 .bind(section_id.parse::<uuid::Uuid>().unwrap())
70 .fetch_one(&h.db)
71 .await
72 .unwrap();
73 assert_eq!(count, 0, "Section should be deleted from database");
74 }
75
76 #[tokio::test]
77 async fn section_list_public_only() {
78 let mut h = TestHarness::new().await;
79 let (project_id, item_id) = setup_creator_with_item(&mut h, "seclist").await;
80
81 // Create a section
82 let resp = h
83 .client
84 .post_json(
85 &format!("/api/items/{item_id}/sections"),
86 r#"{"title": "Installation", "body": "Run `npm install`"}"#,
87 )
88 .await;
89 assert!(resp.status.is_success());
90
91 // Make item a draft (items default to is_public=true)
92 h.client
93 .put_form(&format!("/api/items/{item_id}"), "is_public=false")
94 .await;
95
96 // Draft item: unauthenticated list should 404
97 h.client.post_form("/logout", "").await;
98 h.client.fetch_csrf_token().await;
99 let resp = h
100 .client
101 .get(&format!("/api/items/{item_id}/sections"))
102 .await;
103 assert_eq!(resp.status, 404, "Draft item sections should return 404");
104
105 // Publish item
106 h.login("seclist", "password123").await;
107 h.client
108 .put_form(&format!("/api/items/{item_id}"), "is_public=true")
109 .await;
110 h.client
111 .put_json(
112 &format!("/api/projects/{project_id}"),
113 r#"{"is_public": true}"#,
114 )
115 .await;
116
117 // Now list should work
118 h.client.post_form("/logout", "").await;
119 h.client.fetch_csrf_token().await;
120 let resp = h
121 .client
122 .get(&format!("/api/items/{item_id}/sections"))
123 .await;
124 assert!(
125 resp.status.is_success(),
126 "Public list failed: {} {}",
127 resp.status,
128 resp.text
129 );
130 let list: Value = resp.json();
131 let data = list["data"].as_array().unwrap();
132 assert_eq!(data.len(), 1);
133 assert_eq!(data[0]["title"].as_str().unwrap(), "Installation");
134 }
135
136 #[tokio::test]
137 async fn section_reorder() {
138 let mut h = TestHarness::new().await;
139 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secreorder").await;
140
141 // Create 3 sections
142 let mut ids = Vec::new();
143 for title in &["Alpha", "Beta", "Gamma"] {
144 let body = format!(r#"{{"title": "{title}", "body": ""}}"#);
145 let resp = h
146 .client
147 .post_json(&format!("/api/items/{item_id}/sections"), &body)
148 .await;
149 assert!(resp.status.is_success());
150 let sec: Value = resp.json();
151 ids.push(sec["id"].as_str().unwrap().to_string());
152 }
153
154 // Reorder: Gamma, Alpha, Beta
155 let reorder_body = format!(
156 r#"{{"section_ids": ["{}", "{}", "{}"]}}"#,
157 ids[2], ids[0], ids[1]
158 );
159 let resp = h
160 .client
161 .put_json(
162 &format!("/api/items/{item_id}/sections/reorder"),
163 &reorder_body,
164 )
165 .await;
166 assert!(
167 resp.status.is_success(),
168 "Reorder failed: {} {}",
169 resp.status,
170 resp.text
171 );
172
173 // Verify order via DB
174 let rows = sqlx::query_as::<_, (String, i32)>(
175 "SELECT title, sort_order FROM item_sections WHERE item_id = $1 ORDER BY sort_order",
176 )
177 .bind(item_id.parse::<uuid::Uuid>().unwrap())
178 .fetch_all(&h.db)
179 .await
180 .unwrap();
181 assert_eq!(rows[0].0, "Gamma");
182 assert_eq!(rows[1].0, "Alpha");
183 assert_eq!(rows[2].0, "Beta");
184 }
185
186 #[tokio::test]
187 async fn section_max_limit() {
188 let mut h = TestHarness::new().await;
189 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secmax").await;
190
191 // Create 10 sections (the limit)
192 for i in 0..10 {
193 let body = format!(r#"{{"title": "Section {i}", "body": ""}}"#);
194 let resp = h
195 .client
196 .post_json(&format!("/api/items/{item_id}/sections"), &body)
197 .await;
198 assert!(
199 resp.status.is_success(),
200 "Section {} create failed: {} {}",
201 i,
202 resp.status,
203 resp.text
204 );
205 }
206
207 // 11th should fail
208 let resp = h
209 .client
210 .post_json(
211 &format!("/api/items/{item_id}/sections"),
212 r#"{"title": "Too Many", "body": ""}"#,
213 )
214 .await;
215 assert!(
216 resp.status == 400 || resp.status == 422,
217 "11th section should be rejected, got {} {}",
218 resp.status,
219 resp.text
220 );
221 }
222
223 #[tokio::test]
224 async fn section_ownership_enforced() {
225 let mut h = TestHarness::new().await;
226 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secowner").await;
227
228 // Creator A creates a section
229 let resp = h
230 .client
231 .post_json(
232 &format!("/api/items/{item_id}/sections"),
233 r#"{"title": "Private", "body": "secret"}"#,
234 )
235 .await;
236 assert!(resp.status.is_success());
237 let section: Value = resp.json();
238 let section_id = section["id"].as_str().unwrap().to_string();
239
240 // Switch to creator B
241 h.client.post_form("/logout", "").await;
242 let b_id = h
243 .signup("secintruder", "secintruder@test.com", "password123")
244 .await;
245 h.grant_creator(b_id).await;
246 h.client.post_form("/logout", "").await;
247 h.login("secintruder", "password123").await;
248
249 // Creator B tries to update A's section
250 let resp = h
251 .client
252 .put_json(
253 &format!("/api/sections/{section_id}"),
254 r#"{"title": "Hacked", "body": "pwned"}"#,
255 )
256 .await;
257 assert_eq!(
258 resp.status, 403,
259 "Non-owner PUT should get 403, got {} {}",
260 resp.status, resp.text
261 );
262
263 // Creator B tries to delete A's section
264 let resp = h
265 .client
266 .delete(&format!("/api/sections/{section_id}"))
267 .await;
268 assert_eq!(
269 resp.status, 403,
270 "Non-owner DELETE should get 403, got {} {}",
271 resp.status, resp.text
272 );
273
274 // Creator B tries to create section on A's item
275 let resp = h
276 .client
277 .post_json(
278 &format!("/api/items/{item_id}/sections"),
279 r#"{"title": "Inject", "body": ""}"#,
280 )
281 .await;
282 assert_eq!(
283 resp.status, 403,
284 "Non-owner POST should get 403, got {} {}",
285 resp.status, resp.text
286 );
287 }
288
289 #[tokio::test]
290 async fn section_title_validation() {
291 let mut h = TestHarness::new().await;
292 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secvalid").await;
293
294 // Empty title
295 let resp = h
296 .client
297 .post_json(
298 &format!("/api/items/{item_id}/sections"),
299 r#"{"title": "", "body": ""}"#,
300 )
301 .await;
302 assert!(
303 resp.status == 400 || resp.status == 422,
304 "Empty title should be rejected, got {} {}",
305 resp.status,
306 resp.text
307 );
308
309 // Whitespace-only title
310 let resp = h
311 .client
312 .post_json(
313 &format!("/api/items/{item_id}/sections"),
314 r#"{"title": " ", "body": ""}"#,
315 )
316 .await;
317 assert!(
318 resp.status == 400 || resp.status == 422,
319 "Whitespace title should be rejected, got {} {}",
320 resp.status,
321 resp.text
322 );
323
324 // 101-char title (exceeds 100 limit)
325 let long_title = "A".repeat(101);
326 let body = format!(r#"{{"title": "{long_title}", "body": ""}}"#);
327 let resp = h
328 .client
329 .post_json(&format!("/api/items/{item_id}/sections"), &body)
330 .await;
331 assert!(
332 resp.status == 400 || resp.status == 422,
333 "101-char title should be rejected, got {} {}",
334 resp.status,
335 resp.text
336 );
337
338 // 100-char title should succeed
339 let title_100 = "A".repeat(100);
340 let body = format!(r#"{{"title": "{title_100}", "body": ""}}"#);
341 let resp = h
342 .client
343 .post_json(&format!("/api/items/{item_id}/sections"), &body)
344 .await;
345 assert!(
346 resp.status.is_success(),
347 "100-char title should be accepted, got {} {}",
348 resp.status,
349 resp.text
350 );
351 }
352
353 #[tokio::test]
354 async fn section_unauthenticated_rejected() {
355 let mut h = TestHarness::new().await;
356 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secunauth").await;
357
358 // Create a section so we have an ID
359 let resp = h
360 .client
361 .post_json(
362 &format!("/api/items/{item_id}/sections"),
363 r#"{"title": "Temp", "body": ""}"#,
364 )
365 .await;
366 assert!(resp.status.is_success());
367 let section: Value = resp.json();
368 let section_id = section["id"].as_str().unwrap().to_string();
369
370 h.client.post_form("/logout", "").await;
371 h.client.fetch_csrf_token().await;
372
373 // POST should be 401
374 let resp = h
375 .client
376 .post_json(
377 &format!("/api/items/{item_id}/sections"),
378 r#"{"title": "No Auth", "body": ""}"#,
379 )
380 .await;
381 assert_eq!(
382 resp.status, 401,
383 "Unauthenticated POST should be 401, got {} {}",
384 resp.status, resp.text
385 );
386
387 // PUT should be 401
388 let resp = h
389 .client
390 .put_json(
391 &format!("/api/sections/{section_id}"),
392 r#"{"title": "No Auth", "body": ""}"#,
393 )
394 .await;
395 assert_eq!(
396 resp.status, 401,
397 "Unauthenticated PUT should be 401, got {} {}",
398 resp.status, resp.text
399 );
400
401 // DELETE should be 401
402 let resp = h
403 .client
404 .delete(&format!("/api/sections/{section_id}"))
405 .await;
406 assert_eq!(
407 resp.status, 401,
408 "Unauthenticated DELETE should be 401, got {} {}",
409 resp.status, resp.text
410 );
411 }
412
413 #[tokio::test]
414 async fn section_nonexistent_item() {
415 let mut h = TestHarness::new().await;
416 let user_id = h
417 .signup("secghost", "secghost@test.com", "password123")
418 .await;
419 h.grant_creator(user_id).await;
420 h.client.post_form("/logout", "").await;
421 h.login("secghost", "password123").await;
422
423 let fake_id = uuid::Uuid::new_v4();
424 let resp = h
425 .client
426 .post_json(
427 &format!("/api/items/{fake_id}/sections"),
428 r#"{"title": "Ghost", "body": ""}"#,
429 )
430 .await;
431 assert_eq!(
432 resp.status, 404,
433 "Section on nonexistent item should be 404, got {} {}",
434 resp.status, resp.text
435 );
436 }
437
438 #[tokio::test]
439 async fn section_slug_generation() {
440 let mut h = TestHarness::new().await;
441 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secslug").await;
442
443 // Title with special characters
444 let resp = h
445 .client
446 .post_json(
447 &format!("/api/items/{item_id}/sections"),
448 r#"{"title": "Getting Started!", "body": ""}"#,
449 )
450 .await;
451 assert!(resp.status.is_success());
452 let section: Value = resp.json();
453 let slug = section["slug"].as_str().unwrap();
454 assert!(
455 slug.contains("getting"),
456 "Slug should contain 'getting', got '{slug}'"
457 );
458 assert!(
459 !slug.contains('!'),
460 "Slug should not contain '!', got '{slug}'"
461 );
462 }
463