Skip to main content

max / makenotwork

13.7 KB · 475 lines History Blame Raw
1 //! Project sections: CRUD lifecycle, reorder, max limit, ownership, validation, public visibility.
2 //! Mirrors item_sections tests but scoped to project-level markdown pages.
3
4 use crate::harness::TestHarness;
5 use serde_json::Value;
6
7 /// Helper: create a creator with a project (no item needed), return project_id.
8 async fn setup_creator_with_project(h: &mut TestHarness, username: &str) -> String {
9 let setup = h.create_creator_with_item(username, "plugin", 0).await;
10 setup.project_id
11 }
12
13 #[tokio::test]
14 async fn project_section_create_update_delete() {
15 let mut h = TestHarness::new().await;
16 let project_id = setup_creator_with_project(&mut h, "psecrud").await;
17
18 let resp = h
19 .client
20 .post_json(
21 &format!("/api/projects/{project_id}/sections"),
22 r#"{"title": "Privacy Policy", "body": "We don't collect data."}"#,
23 )
24 .await;
25 assert_eq!(
26 resp.status, 200,
27 "Create 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(), "Privacy Policy");
33 assert_eq!(section["slug"].as_str().unwrap(), "privacy-policy");
34 assert_eq!(section["sort_order"].as_i64().unwrap(), 0);
35 assert_eq!(section["project_id"].as_str().unwrap(), project_id);
36
37 let resp = h
38 .client
39 .put_json(
40 &format!("/api/project-sections/{section_id}"),
41 r#"{"title": "Privacy & Terms", "body": "We still don't collect data."}"#,
42 )
43 .await;
44 assert_eq!(
45 resp.status, 200,
46 "Update failed: {} {}",
47 resp.status, resp.text
48 );
49 let updated: Value = resp.json();
50 assert_eq!(updated["title"].as_str().unwrap(), "Privacy & Terms");
51 assert_eq!(updated["slug"].as_str().unwrap(), "privacy-terms");
52
53 let resp = h
54 .client
55 .delete(&format!("/api/project-sections/{section_id}"))
56 .await;
57 assert_eq!(
58 resp.status, 204,
59 "Delete failed: {} {}",
60 resp.status, resp.text
61 );
62
63 let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM project_sections WHERE id = $1")
64 .bind(section_id.parse::<uuid::Uuid>().unwrap())
65 .fetch_one(&h.db)
66 .await
67 .unwrap();
68 assert_eq!(count, 0, "Section should be deleted from database");
69 }
70
71 #[tokio::test]
72 async fn project_section_list_public_only() {
73 let mut h = TestHarness::new().await;
74 let project_id = setup_creator_with_project(&mut h, "pseclist").await;
75
76 let resp = h
77 .client
78 .post_json(
79 &format!("/api/projects/{project_id}/sections"),
80 r#"{"title": "FAQ", "body": "Q: ...?"}"#,
81 )
82 .await;
83 assert_eq!(resp.status, 200, "{}", resp.text);
84
85 // Make project private (projects default to is_public=true).
86 h.client
87 .put_json(
88 &format!("/api/projects/{project_id}"),
89 r#"{"is_public": false}"#,
90 )
91 .await;
92 h.client.post_form("/logout", "").await;
93 h.client.fetch_csrf_token().await;
94 let resp = h
95 .client
96 .get(&format!("/api/projects/{project_id}/sections"))
97 .await;
98 assert_eq!(
99 resp.status, 404,
100 "Private project sections should return 404"
101 );
102
103 // Publish project
104 h.login("pseclist", "password123").await;
105 h.client
106 .put_json(
107 &format!("/api/projects/{project_id}"),
108 r#"{"is_public": true}"#,
109 )
110 .await;
111
112 h.client.post_form("/logout", "").await;
113 h.client.fetch_csrf_token().await;
114 let resp = h
115 .client
116 .get(&format!("/api/projects/{project_id}/sections"))
117 .await;
118 assert_eq!(
119 resp.status, 200,
120 "Public list failed: {} {}",
121 resp.status, resp.text
122 );
123 let list: Value = resp.json();
124 let data = list["data"].as_array().unwrap();
125 assert_eq!(data.len(), 1);
126 assert_eq!(data[0]["title"].as_str().unwrap(), "FAQ");
127 }
128
129 #[tokio::test]
130 async fn project_section_reorder() {
131 let mut h = TestHarness::new().await;
132 let project_id = setup_creator_with_project(&mut h, "psecreorder").await;
133
134 let mut ids = Vec::new();
135 for title in &["Alpha", "Beta", "Gamma"] {
136 let body = format!(r#"{{"title": "{title}", "body": ""}}"#);
137 let resp = h
138 .client
139 .post_json(&format!("/api/projects/{project_id}/sections"), &body)
140 .await;
141 assert_eq!(resp.status, 200, "{}", resp.text);
142 let sec: Value = resp.json();
143 ids.push(sec["id"].as_str().unwrap().to_string());
144 }
145
146 let reorder_body = format!(
147 r#"{{"section_ids": ["{}", "{}", "{}"]}}"#,
148 ids[2], ids[0], ids[1]
149 );
150 let resp = h
151 .client
152 .put_json(
153 &format!("/api/projects/{project_id}/sections/reorder"),
154 &reorder_body,
155 )
156 .await;
157 assert_eq!(
158 resp.status, 204,
159 "Reorder failed: {} {}",
160 resp.status, resp.text
161 );
162
163 let rows = sqlx::query_as::<_, (String, i32)>(
164 "SELECT title, sort_order FROM project_sections WHERE project_id = $1 ORDER BY sort_order",
165 )
166 .bind(project_id.parse::<uuid::Uuid>().unwrap())
167 .fetch_all(&h.db)
168 .await
169 .unwrap();
170 assert_eq!(rows[0].0, "Gamma");
171 assert_eq!(rows[1].0, "Alpha");
172 assert_eq!(rows[2].0, "Beta");
173 }
174
175 #[tokio::test]
176 async fn project_section_max_limit() {
177 let mut h = TestHarness::new().await;
178 let project_id = setup_creator_with_project(&mut h, "psecmax").await;
179
180 for i in 0..10 {
181 let body = format!(r#"{{"title": "Page {i}", "body": ""}}"#);
182 let resp = h
183 .client
184 .post_json(&format!("/api/projects/{project_id}/sections"), &body)
185 .await;
186 assert_eq!(
187 resp.status, 200,
188 "Page {} create failed: {} {}",
189 i, resp.status, resp.text
190 );
191 }
192
193 let resp = h
194 .client
195 .post_json(
196 &format!("/api/projects/{project_id}/sections"),
197 r#"{"title": "Too Many", "body": ""}"#,
198 )
199 .await;
200 assert!(
201 resp.status == 400 || resp.status == 422,
202 "11th section should be rejected, got {} {}",
203 resp.status,
204 resp.text
205 );
206 }
207
208 #[tokio::test]
209 async fn project_section_ownership_enforced() {
210 let mut h = TestHarness::new().await;
211 let project_id = setup_creator_with_project(&mut h, "psecowner").await;
212
213 let resp = h
214 .client
215 .post_json(
216 &format!("/api/projects/{project_id}/sections"),
217 r#"{"title": "Private", "body": "secret"}"#,
218 )
219 .await;
220 assert_eq!(resp.status, 200, "{}", resp.text);
221 let section: Value = resp.json();
222 let section_id = section["id"].as_str().unwrap().to_string();
223
224 h.client.post_form("/logout", "").await;
225 let b_id = h
226 .signup("psecintruder", "psecintruder@test.com", "password123")
227 .await;
228 h.grant_creator(b_id).await;
229 h.client.post_form("/logout", "").await;
230 h.login("psecintruder", "password123").await;
231
232 let resp = h
233 .client
234 .put_json(
235 &format!("/api/project-sections/{section_id}"),
236 r#"{"title": "Hacked", "body": "pwned"}"#,
237 )
238 .await;
239 assert_eq!(
240 resp.status, 403,
241 "Non-owner PUT should be 403, got {} {}",
242 resp.status, resp.text
243 );
244
245 let resp = h
246 .client
247 .delete(&format!("/api/project-sections/{section_id}"))
248 .await;
249 assert_eq!(
250 resp.status, 403,
251 "Non-owner DELETE should be 403, got {} {}",
252 resp.status, resp.text
253 );
254
255 let resp = h
256 .client
257 .post_json(
258 &format!("/api/projects/{project_id}/sections"),
259 r#"{"title": "Inject", "body": ""}"#,
260 )
261 .await;
262 assert_eq!(
263 resp.status, 403,
264 "Non-owner POST should be 403, got {} {}",
265 resp.status, resp.text
266 );
267 }
268
269 #[tokio::test]
270 async fn project_section_title_validation() {
271 let mut h = TestHarness::new().await;
272 let project_id = setup_creator_with_project(&mut h, "psecvalid").await;
273
274 let resp = h
275 .client
276 .post_json(
277 &format!("/api/projects/{project_id}/sections"),
278 r#"{"title": "", "body": ""}"#,
279 )
280 .await;
281 assert!(
282 resp.status == 400 || resp.status == 422,
283 "Empty title rejected, got {} {}",
284 resp.status,
285 resp.text
286 );
287
288 let resp = h
289 .client
290 .post_json(
291 &format!("/api/projects/{project_id}/sections"),
292 r#"{"title": " ", "body": ""}"#,
293 )
294 .await;
295 assert!(
296 resp.status == 400 || resp.status == 422,
297 "Whitespace title rejected, got {} {}",
298 resp.status,
299 resp.text
300 );
301
302 let long_title = "A".repeat(101);
303 let body = format!(r#"{{"title": "{long_title}", "body": ""}}"#);
304 let resp = h
305 .client
306 .post_json(&format!("/api/projects/{project_id}/sections"), &body)
307 .await;
308 assert!(
309 resp.status == 400 || resp.status == 422,
310 "101-char title rejected, got {} {}",
311 resp.status,
312 resp.text
313 );
314
315 let title_100 = "A".repeat(100);
316 let body = format!(r#"{{"title": "{title_100}", "body": ""}}"#);
317 let resp = h
318 .client
319 .post_json(&format!("/api/projects/{project_id}/sections"), &body)
320 .await;
321 assert_eq!(
322 resp.status, 200,
323 "100-char title accepted, got {} {}",
324 resp.status, resp.text
325 );
326 }
327
328 #[tokio::test]
329 async fn project_section_unauthenticated_rejected() {
330 let mut h = TestHarness::new().await;
331 let project_id = setup_creator_with_project(&mut h, "psecunauth").await;
332
333 let resp = h
334 .client
335 .post_json(
336 &format!("/api/projects/{project_id}/sections"),
337 r#"{"title": "Temp", "body": ""}"#,
338 )
339 .await;
340 assert_eq!(resp.status, 200, "{}", resp.text);
341 let section: Value = resp.json();
342 let section_id = section["id"].as_str().unwrap().to_string();
343
344 h.client.post_form("/logout", "").await;
345 h.client.fetch_csrf_token().await;
346
347 let resp = h
348 .client
349 .post_json(
350 &format!("/api/projects/{project_id}/sections"),
351 r#"{"title": "No Auth", "body": ""}"#,
352 )
353 .await;
354 assert_eq!(
355 resp.status, 401,
356 "Unauth POST: 401, got {} {}",
357 resp.status, resp.text
358 );
359
360 let resp = h
361 .client
362 .put_json(
363 &format!("/api/project-sections/{section_id}"),
364 r#"{"title": "No Auth", "body": ""}"#,
365 )
366 .await;
367 assert_eq!(
368 resp.status, 401,
369 "Unauth PUT: 401, got {} {}",
370 resp.status, resp.text
371 );
372
373 let resp = h
374 .client
375 .delete(&format!("/api/project-sections/{section_id}"))
376 .await;
377 assert_eq!(
378 resp.status, 401,
379 "Unauth DELETE: 401, got {} {}",
380 resp.status, resp.text
381 );
382 }
383
384 #[tokio::test]
385 async fn project_section_nonexistent_project() {
386 let mut h = TestHarness::new().await;
387 let user_id = h
388 .signup("psecghost", "psecghost@test.com", "password123")
389 .await;
390 h.grant_creator(user_id).await;
391 h.client.post_form("/logout", "").await;
392 h.login("psecghost", "password123").await;
393
394 let fake_id = uuid::Uuid::new_v4();
395 let resp = h
396 .client
397 .post_json(
398 &format!("/api/projects/{fake_id}/sections"),
399 r#"{"title": "Ghost", "body": ""}"#,
400 )
401 .await;
402 assert_eq!(
403 resp.status, 404,
404 "Nonexistent project: 404, got {} {}",
405 resp.status, resp.text
406 );
407 }
408
409 #[tokio::test]
410 async fn project_section_slug_generation() {
411 let mut h = TestHarness::new().await;
412 let project_id = setup_creator_with_project(&mut h, "psecslug").await;
413
414 let resp = h
415 .client
416 .post_json(
417 &format!("/api/projects/{project_id}/sections"),
418 r#"{"title": "Terms of Service!", "body": ""}"#,
419 )
420 .await;
421 assert_eq!(resp.status, 200, "{}", resp.text);
422 let section: Value = resp.json();
423 let slug = section["slug"].as_str().unwrap();
424 assert!(
425 slug.contains("terms"),
426 "Slug should contain 'terms', got '{slug}'"
427 );
428 assert!(
429 !slug.contains('!'),
430 "Slug should not contain '!', got '{slug}'"
431 );
432 }
433
434 #[tokio::test]
435 async fn project_section_unique_slug_per_project() {
436 // Within one project, two sections whose titles slugify to the same value
437 // must both succeed: the second is auto-suffixed (`-2`) rather than
438 // surfacing the UNIQUE(project_id, slug) violation as an error. The DB index
439 // stays the race-safe source of truth via insert_with_unique_slug.
440 let mut h = TestHarness::new().await;
441 let project_id = setup_creator_with_project(&mut h, "psecuniq").await;
442
443 let resp = h
444 .client
445 .post_json(
446 &format!("/api/projects/{project_id}/sections"),
447 r#"{"title": "Privacy Policy", "body": ""}"#,
448 )
449 .await;
450 assert_eq!(resp.status, 200, "{}", resp.text);
451 assert!(
452 resp.text.contains(r#""slug":"privacy-policy""#),
453 "First section should take the bare slug, got {}",
454 resp.text
455 );
456
457 let resp = h
458 .client
459 .post_json(
460 &format!("/api/projects/{project_id}/sections"),
461 r#"{"title": "Privacy Policy", "body": ""}"#,
462 )
463 .await;
464 assert_eq!(
465 resp.status, 200,
466 "Duplicate title should auto-suffix, not fail, got {} {}",
467 resp.status, resp.text
468 );
469 assert!(
470 resp.text.contains(r#""slug":"privacy-policy-2""#),
471 "Second section should be auto-suffixed to privacy-policy-2, got {}",
472 resp.text
473 );
474 }
475