Skip to main content

max / makenotwork

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