Skip to main content

max / makenotwork

12.4 KB · 366 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/{}/sections", project_id),
22 r#"{"title": "Privacy Policy", "body": "We don't collect data."}"#,
23 )
24 .await;
25 assert!(resp.status.is_success(), "Create failed: {} {}", resp.status, resp.text);
26 let section: Value = resp.json();
27 let section_id = section["id"].as_str().unwrap().to_string();
28 assert_eq!(section["title"].as_str().unwrap(), "Privacy Policy");
29 assert_eq!(section["slug"].as_str().unwrap(), "privacy-policy");
30 assert_eq!(section["sort_order"].as_i64().unwrap(), 0);
31 assert_eq!(section["project_id"].as_str().unwrap(), project_id);
32
33 let resp = h
34 .client
35 .put_json(
36 &format!("/api/project-sections/{}", section_id),
37 r#"{"title": "Privacy & Terms", "body": "We still don't collect data."}"#,
38 )
39 .await;
40 assert!(resp.status.is_success(), "Update failed: {} {}", resp.status, resp.text);
41 let updated: Value = resp.json();
42 assert_eq!(updated["title"].as_str().unwrap(), "Privacy & Terms");
43 assert_eq!(updated["slug"].as_str().unwrap(), "privacy-terms");
44
45 let resp = h
46 .client
47 .delete(&format!("/api/project-sections/{}", section_id))
48 .await;
49 assert!(resp.status.is_success(), "Delete failed: {} {}", resp.status, resp.text);
50
51 let count = sqlx::query_scalar::<_, i64>(
52 "SELECT COUNT(*) FROM project_sections WHERE id = $1",
53 )
54 .bind(section_id.parse::<uuid::Uuid>().unwrap())
55 .fetch_one(&h.db)
56 .await
57 .unwrap();
58 assert_eq!(count, 0, "Section should be deleted from database");
59 }
60
61 #[tokio::test]
62 async fn project_section_list_public_only() {
63 let mut h = TestHarness::new().await;
64 let project_id = setup_creator_with_project(&mut h, "pseclist").await;
65
66 let resp = h
67 .client
68 .post_json(
69 &format!("/api/projects/{}/sections", project_id),
70 r#"{"title": "FAQ", "body": "Q: ...?"}"#,
71 )
72 .await;
73 assert!(resp.status.is_success());
74
75 // Make project private (projects default to is_public=true).
76 h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": false}"#).await;
77 h.client.post_form("/logout", "").await;
78 h.client.fetch_csrf_token().await;
79 let resp = h.client.get(&format!("/api/projects/{}/sections", project_id)).await;
80 assert_eq!(resp.status, 404, "Private project sections should return 404");
81
82 // Publish project
83 h.login("pseclist", "password123").await;
84 h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await;
85
86 h.client.post_form("/logout", "").await;
87 h.client.fetch_csrf_token().await;
88 let resp = h.client.get(&format!("/api/projects/{}/sections", project_id)).await;
89 assert!(resp.status.is_success(), "Public list failed: {} {}", resp.status, resp.text);
90 let list: Value = resp.json();
91 let data = list["data"].as_array().unwrap();
92 assert_eq!(data.len(), 1);
93 assert_eq!(data[0]["title"].as_str().unwrap(), "FAQ");
94 }
95
96 #[tokio::test]
97 async fn project_section_reorder() {
98 let mut h = TestHarness::new().await;
99 let project_id = setup_creator_with_project(&mut h, "psecreorder").await;
100
101 let mut ids = Vec::new();
102 for title in &["Alpha", "Beta", "Gamma"] {
103 let body = format!(r#"{{"title": "{}", "body": ""}}"#, title);
104 let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await;
105 assert!(resp.status.is_success());
106 let sec: Value = resp.json();
107 ids.push(sec["id"].as_str().unwrap().to_string());
108 }
109
110 let reorder_body = format!(
111 r#"{{"section_ids": ["{}", "{}", "{}"]}}"#,
112 ids[2], ids[0], ids[1]
113 );
114 let resp = h
115 .client
116 .put_json(
117 &format!("/api/projects/{}/sections/reorder", project_id),
118 &reorder_body,
119 )
120 .await;
121 assert!(resp.status.is_success(), "Reorder failed: {} {}", resp.status, resp.text);
122
123 let rows = sqlx::query_as::<_, (String, i32)>(
124 "SELECT title, sort_order FROM project_sections WHERE project_id = $1 ORDER BY sort_order",
125 )
126 .bind(project_id.parse::<uuid::Uuid>().unwrap())
127 .fetch_all(&h.db)
128 .await
129 .unwrap();
130 assert_eq!(rows[0].0, "Gamma");
131 assert_eq!(rows[1].0, "Alpha");
132 assert_eq!(rows[2].0, "Beta");
133 }
134
135 #[tokio::test]
136 async fn project_section_max_limit() {
137 let mut h = TestHarness::new().await;
138 let project_id = setup_creator_with_project(&mut h, "psecmax").await;
139
140 for i in 0..10 {
141 let body = format!(r#"{{"title": "Page {}", "body": ""}}"#, i);
142 let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await;
143 assert!(
144 resp.status.is_success(),
145 "Page {} create failed: {} {}",
146 i, resp.status, resp.text
147 );
148 }
149
150 let resp = h
151 .client
152 .post_json(
153 &format!("/api/projects/{}/sections", project_id),
154 r#"{"title": "Too Many", "body": ""}"#,
155 )
156 .await;
157 assert!(
158 resp.status == 400 || resp.status == 422,
159 "11th section should be rejected, got {} {}",
160 resp.status, resp.text
161 );
162 }
163
164 #[tokio::test]
165 async fn project_section_ownership_enforced() {
166 let mut h = TestHarness::new().await;
167 let project_id = setup_creator_with_project(&mut h, "psecowner").await;
168
169 let resp = h
170 .client
171 .post_json(
172 &format!("/api/projects/{}/sections", project_id),
173 r#"{"title": "Private", "body": "secret"}"#,
174 )
175 .await;
176 assert!(resp.status.is_success());
177 let section: Value = resp.json();
178 let section_id = section["id"].as_str().unwrap().to_string();
179
180 h.client.post_form("/logout", "").await;
181 let b_id = h.signup("psecintruder", "psecintruder@test.com", "password123").await;
182 h.grant_creator(b_id).await;
183 h.client.post_form("/logout", "").await;
184 h.login("psecintruder", "password123").await;
185
186 let resp = h
187 .client
188 .put_json(
189 &format!("/api/project-sections/{}", section_id),
190 r#"{"title": "Hacked", "body": "pwned"}"#,
191 )
192 .await;
193 assert_eq!(resp.status, 403, "Non-owner PUT should be 403, got {} {}", resp.status, resp.text);
194
195 let resp = h.client.delete(&format!("/api/project-sections/{}", section_id)).await;
196 assert_eq!(resp.status, 403, "Non-owner DELETE should be 403, got {} {}", resp.status, resp.text);
197
198 let resp = h
199 .client
200 .post_json(
201 &format!("/api/projects/{}/sections", project_id),
202 r#"{"title": "Inject", "body": ""}"#,
203 )
204 .await;
205 assert_eq!(resp.status, 403, "Non-owner POST should be 403, got {} {}", resp.status, resp.text);
206 }
207
208 #[tokio::test]
209 async fn project_section_title_validation() {
210 let mut h = TestHarness::new().await;
211 let project_id = setup_creator_with_project(&mut h, "psecvalid").await;
212
213 let resp = h
214 .client
215 .post_json(
216 &format!("/api/projects/{}/sections", project_id),
217 r#"{"title": "", "body": ""}"#,
218 )
219 .await;
220 assert!(
221 resp.status == 400 || resp.status == 422,
222 "Empty title rejected, got {} {}",
223 resp.status, resp.text
224 );
225
226 let resp = h
227 .client
228 .post_json(
229 &format!("/api/projects/{}/sections", project_id),
230 r#"{"title": " ", "body": ""}"#,
231 )
232 .await;
233 assert!(
234 resp.status == 400 || resp.status == 422,
235 "Whitespace title rejected, got {} {}",
236 resp.status, resp.text
237 );
238
239 let long_title = "A".repeat(101);
240 let body = format!(r#"{{"title": "{}", "body": ""}}"#, long_title);
241 let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await;
242 assert!(
243 resp.status == 400 || resp.status == 422,
244 "101-char title rejected, got {} {}",
245 resp.status, resp.text
246 );
247
248 let title_100 = "A".repeat(100);
249 let body = format!(r#"{{"title": "{}", "body": ""}}"#, title_100);
250 let resp = h.client.post_json(&format!("/api/projects/{}/sections", project_id), &body).await;
251 assert!(
252 resp.status.is_success(),
253 "100-char title accepted, got {} {}",
254 resp.status, resp.text
255 );
256 }
257
258 #[tokio::test]
259 async fn project_section_unauthenticated_rejected() {
260 let mut h = TestHarness::new().await;
261 let project_id = setup_creator_with_project(&mut h, "psecunauth").await;
262
263 let resp = h
264 .client
265 .post_json(
266 &format!("/api/projects/{}/sections", project_id),
267 r#"{"title": "Temp", "body": ""}"#,
268 )
269 .await;
270 assert!(resp.status.is_success());
271 let section: Value = resp.json();
272 let section_id = section["id"].as_str().unwrap().to_string();
273
274 h.client.post_form("/logout", "").await;
275 h.client.fetch_csrf_token().await;
276
277 let resp = h
278 .client
279 .post_json(
280 &format!("/api/projects/{}/sections", project_id),
281 r#"{"title": "No Auth", "body": ""}"#,
282 )
283 .await;
284 assert_eq!(resp.status, 401, "Unauth POST: 401, got {} {}", resp.status, resp.text);
285
286 let resp = h
287 .client
288 .put_json(
289 &format!("/api/project-sections/{}", section_id),
290 r#"{"title": "No Auth", "body": ""}"#,
291 )
292 .await;
293 assert_eq!(resp.status, 401, "Unauth PUT: 401, got {} {}", resp.status, resp.text);
294
295 let resp = h.client.delete(&format!("/api/project-sections/{}", section_id)).await;
296 assert_eq!(resp.status, 401, "Unauth DELETE: 401, got {} {}", resp.status, resp.text);
297 }
298
299 #[tokio::test]
300 async fn project_section_nonexistent_project() {
301 let mut h = TestHarness::new().await;
302 let user_id = h.signup("psecghost", "psecghost@test.com", "password123").await;
303 h.grant_creator(user_id).await;
304 h.client.post_form("/logout", "").await;
305 h.login("psecghost", "password123").await;
306
307 let fake_id = uuid::Uuid::new_v4();
308 let resp = h
309 .client
310 .post_json(
311 &format!("/api/projects/{}/sections", fake_id),
312 r#"{"title": "Ghost", "body": ""}"#,
313 )
314 .await;
315 assert_eq!(resp.status, 404, "Nonexistent project: 404, got {} {}", resp.status, resp.text);
316 }
317
318 #[tokio::test]
319 async fn project_section_slug_generation() {
320 let mut h = TestHarness::new().await;
321 let project_id = setup_creator_with_project(&mut h, "psecslug").await;
322
323 let resp = h
324 .client
325 .post_json(
326 &format!("/api/projects/{}/sections", project_id),
327 r#"{"title": "Terms of Service!", "body": ""}"#,
328 )
329 .await;
330 assert!(resp.status.is_success());
331 let section: Value = resp.json();
332 let slug = section["slug"].as_str().unwrap();
333 assert!(slug.contains("terms"), "Slug should contain 'terms', got '{}'", slug);
334 assert!(!slug.contains('!'), "Slug should not contain '!', got '{}'", slug);
335 }
336
337 #[tokio::test]
338 async fn project_section_unique_slug_per_project() {
339 // Two different projects can have sections with the same slug;
340 // within one project, duplicates fail at the DB unique index.
341 let mut h = TestHarness::new().await;
342 let project_id = setup_creator_with_project(&mut h, "psecuniq").await;
343
344 let resp = h
345 .client
346 .post_json(
347 &format!("/api/projects/{}/sections", project_id),
348 r#"{"title": "Privacy Policy", "body": ""}"#,
349 )
350 .await;
351 assert!(resp.status.is_success());
352
353 let resp = h
354 .client
355 .post_json(
356 &format!("/api/projects/{}/sections", project_id),
357 r#"{"title": "Privacy Policy", "body": ""}"#,
358 )
359 .await;
360 assert!(
361 !resp.status.is_success(),
362 "Duplicate slug within a project should fail, got {} {}",
363 resp.status, resp.text
364 );
365 }
366