Skip to main content

max / makenotwork

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