Skip to main content

max / makenotwork

12.0 KB · 320 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(
8 h: &mut TestHarness,
9 username: &str,
10 ) -> (String, String) {
11 let setup = h.create_creator_with_item(username, "plugin", 0).await;
12 (setup.project_id, setup.item_id)
13 }
14
15 #[tokio::test]
16 async fn section_create_update_delete() {
17 let mut h = TestHarness::new().await;
18 let (_project_id, item_id) = setup_creator_with_item(&mut h, "seccrud").await;
19
20 // Create section
21 let resp = h
22 .client
23 .post_json(
24 &format!("/api/items/{}/sections", item_id),
25 r#"{"title": "Features", "body": "- Fast\n- Reliable"}"#,
26 )
27 .await;
28 assert!(resp.status.is_success(), "Create section failed: {} {}", resp.status, resp.text);
29 let section: Value = resp.json();
30 let section_id = section["id"].as_str().unwrap().to_string();
31 assert_eq!(section["title"].as_str().unwrap(), "Features");
32 assert_eq!(section["slug"].as_str().unwrap(), "features");
33 assert_eq!(section["sort_order"].as_i64().unwrap(), 0);
34
35 // Update section
36 let resp = h
37 .client
38 .put_json(
39 &format!("/api/sections/{}", section_id),
40 r#"{"title": "Key Features", "body": "- Fast\n- Reliable\n- Secure"}"#,
41 )
42 .await;
43 assert!(resp.status.is_success(), "Update section failed: {} {}", resp.status, resp.text);
44 let updated: Value = resp.json();
45 assert_eq!(updated["title"].as_str().unwrap(), "Key Features");
46 assert_eq!(updated["slug"].as_str().unwrap(), "key-features");
47
48 // Delete section
49 let resp = h
50 .client
51 .delete(&format!("/api/sections/{}", section_id))
52 .await;
53 assert!(resp.status.is_success(), "Delete section failed: {} {}", resp.status, resp.text);
54
55 // Verify gone
56 let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM item_sections WHERE id = $1")
57 .bind(section_id.parse::<uuid::Uuid>().unwrap())
58 .fetch_one(&h.db)
59 .await
60 .unwrap();
61 assert_eq!(count, 0, "Section should be deleted from database");
62 }
63
64 #[tokio::test]
65 async fn section_list_public_only() {
66 let mut h = TestHarness::new().await;
67 let (project_id, item_id) = setup_creator_with_item(&mut h, "seclist").await;
68
69 // Create a section
70 let resp = h
71 .client
72 .post_json(
73 &format!("/api/items/{}/sections", item_id),
74 r#"{"title": "Installation", "body": "Run `npm install`"}"#,
75 )
76 .await;
77 assert!(resp.status.is_success());
78
79 // Make item a draft (items default to is_public=true)
80 h.client.put_form(&format!("/api/items/{}", item_id), "is_public=false").await;
81
82 // Draft item: unauthenticated list should 404
83 h.client.post_form("/logout", "").await;
84 h.client.fetch_csrf_token().await;
85 let resp = h.client.get(&format!("/api/items/{}/sections", item_id)).await;
86 assert_eq!(resp.status, 404, "Draft item sections should return 404");
87
88 // Publish item
89 h.login("seclist", "password123").await;
90 h.client.put_form(&format!("/api/items/{}", item_id), "is_public=true").await;
91 h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await;
92
93 // Now list should work
94 h.client.post_form("/logout", "").await;
95 h.client.fetch_csrf_token().await;
96 let resp = h.client.get(&format!("/api/items/{}/sections", item_id)).await;
97 assert!(resp.status.is_success(), "Public list failed: {} {}", resp.status, resp.text);
98 let list: Value = resp.json();
99 let data = list["data"].as_array().unwrap();
100 assert_eq!(data.len(), 1);
101 assert_eq!(data[0]["title"].as_str().unwrap(), "Installation");
102 }
103
104 #[tokio::test]
105 async fn section_reorder() {
106 let mut h = TestHarness::new().await;
107 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secreorder").await;
108
109 // Create 3 sections
110 let mut ids = Vec::new();
111 for title in &["Alpha", "Beta", "Gamma"] {
112 let body = format!(r#"{{"title": "{}", "body": ""}}"#, title);
113 let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await;
114 assert!(resp.status.is_success());
115 let sec: Value = resp.json();
116 ids.push(sec["id"].as_str().unwrap().to_string());
117 }
118
119 // Reorder: Gamma, Alpha, Beta
120 let reorder_body = format!(
121 r#"{{"section_ids": ["{}", "{}", "{}"]}}"#,
122 ids[2], ids[0], ids[1]
123 );
124 let resp = h.client.put_json(
125 &format!("/api/items/{}/sections/reorder", item_id),
126 &reorder_body,
127 ).await;
128 assert!(resp.status.is_success(), "Reorder failed: {} {}", resp.status, resp.text);
129
130 // Verify order via DB
131 let rows = sqlx::query_as::<_, (String, i32)>(
132 "SELECT title, sort_order FROM item_sections WHERE item_id = $1 ORDER BY sort_order"
133 )
134 .bind(item_id.parse::<uuid::Uuid>().unwrap())
135 .fetch_all(&h.db)
136 .await
137 .unwrap();
138 assert_eq!(rows[0].0, "Gamma");
139 assert_eq!(rows[1].0, "Alpha");
140 assert_eq!(rows[2].0, "Beta");
141 }
142
143 #[tokio::test]
144 async fn section_max_limit() {
145 let mut h = TestHarness::new().await;
146 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secmax").await;
147
148 // Create 10 sections (the limit)
149 for i in 0..10 {
150 let body = format!(r#"{{"title": "Section {}", "body": ""}}"#, i);
151 let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await;
152 assert!(resp.status.is_success(), "Section {} create failed: {} {}", i, resp.status, resp.text);
153 }
154
155 // 11th should fail
156 let resp = h.client.post_json(
157 &format!("/api/items/{}/sections", item_id),
158 r#"{"title": "Too Many", "body": ""}"#,
159 ).await;
160 assert!(
161 resp.status == 400 || resp.status == 422,
162 "11th section should be rejected, got {} {}",
163 resp.status, resp.text
164 );
165 }
166
167 #[tokio::test]
168 async fn section_ownership_enforced() {
169 let mut h = TestHarness::new().await;
170 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secowner").await;
171
172 // Creator A creates a section
173 let resp = h.client.post_json(
174 &format!("/api/items/{}/sections", item_id),
175 r#"{"title": "Private", "body": "secret"}"#,
176 ).await;
177 assert!(resp.status.is_success());
178 let section: Value = resp.json();
179 let section_id = section["id"].as_str().unwrap().to_string();
180
181 // Switch to creator B
182 h.client.post_form("/logout", "").await;
183 let b_id = h.signup("secintruder", "secintruder@test.com", "password123").await;
184 h.grant_creator(b_id).await;
185 h.client.post_form("/logout", "").await;
186 h.login("secintruder", "password123").await;
187
188 // Creator B tries to update A's section
189 let resp = h.client.put_json(
190 &format!("/api/sections/{}", section_id),
191 r#"{"title": "Hacked", "body": "pwned"}"#,
192 ).await;
193 assert_eq!(resp.status, 403, "Non-owner PUT should get 403, got {} {}", resp.status, resp.text);
194
195 // Creator B tries to delete A's section
196 let resp = h.client.delete(&format!("/api/sections/{}", section_id)).await;
197 assert_eq!(resp.status, 403, "Non-owner DELETE should get 403, got {} {}", resp.status, resp.text);
198
199 // Creator B tries to create section on A's item
200 let resp = h.client.post_json(
201 &format!("/api/items/{}/sections", item_id),
202 r#"{"title": "Inject", "body": ""}"#,
203 ).await;
204 assert_eq!(resp.status, 403, "Non-owner POST should get 403, got {} {}", resp.status, resp.text);
205 }
206
207 #[tokio::test]
208 async fn section_title_validation() {
209 let mut h = TestHarness::new().await;
210 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secvalid").await;
211
212 // Empty title
213 let resp = h.client.post_json(
214 &format!("/api/items/{}/sections", item_id),
215 r#"{"title": "", "body": ""}"#,
216 ).await;
217 assert!(
218 resp.status == 400 || resp.status == 422,
219 "Empty title should be rejected, got {} {}",
220 resp.status, resp.text
221 );
222
223 // Whitespace-only title
224 let resp = h.client.post_json(
225 &format!("/api/items/{}/sections", item_id),
226 r#"{"title": " ", "body": ""}"#,
227 ).await;
228 assert!(
229 resp.status == 400 || resp.status == 422,
230 "Whitespace title should be rejected, got {} {}",
231 resp.status, resp.text
232 );
233
234 // 101-char title (exceeds 100 limit)
235 let long_title = "A".repeat(101);
236 let body = format!(r#"{{"title": "{}", "body": ""}}"#, long_title);
237 let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await;
238 assert!(
239 resp.status == 400 || resp.status == 422,
240 "101-char title should be rejected, got {} {}",
241 resp.status, resp.text
242 );
243
244 // 100-char title should succeed
245 let title_100 = "A".repeat(100);
246 let body = format!(r#"{{"title": "{}", "body": ""}}"#, title_100);
247 let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await;
248 assert!(resp.status.is_success(), "100-char title should be accepted, got {} {}", resp.status, resp.text);
249 }
250
251 #[tokio::test]
252 async fn section_unauthenticated_rejected() {
253 let mut h = TestHarness::new().await;
254 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secunauth").await;
255
256 // Create a section so we have an ID
257 let resp = h.client.post_json(
258 &format!("/api/items/{}/sections", item_id),
259 r#"{"title": "Temp", "body": ""}"#,
260 ).await;
261 assert!(resp.status.is_success());
262 let section: Value = resp.json();
263 let section_id = section["id"].as_str().unwrap().to_string();
264
265 // Logout
266 h.client.post_form("/logout", "").await;
267 h.client.fetch_csrf_token().await;
268
269 // POST should be 401
270 let resp = h.client.post_json(
271 &format!("/api/items/{}/sections", item_id),
272 r#"{"title": "No Auth", "body": ""}"#,
273 ).await;
274 assert_eq!(resp.status, 401, "Unauthenticated POST should be 401, got {} {}", resp.status, resp.text);
275
276 // PUT should be 401
277 let resp = h.client.put_json(
278 &format!("/api/sections/{}", section_id),
279 r#"{"title": "No Auth", "body": ""}"#,
280 ).await;
281 assert_eq!(resp.status, 401, "Unauthenticated PUT should be 401, got {} {}", resp.status, resp.text);
282
283 // DELETE should be 401
284 let resp = h.client.delete(&format!("/api/sections/{}", section_id)).await;
285 assert_eq!(resp.status, 401, "Unauthenticated DELETE should be 401, got {} {}", resp.status, resp.text);
286 }
287
288 #[tokio::test]
289 async fn section_nonexistent_item() {
290 let mut h = TestHarness::new().await;
291 let user_id = h.signup("secghost", "secghost@test.com", "password123").await;
292 h.grant_creator(user_id).await;
293 h.client.post_form("/logout", "").await;
294 h.login("secghost", "password123").await;
295
296 let fake_id = uuid::Uuid::new_v4();
297 let resp = h.client.post_json(
298 &format!("/api/items/{}/sections", fake_id),
299 r#"{"title": "Ghost", "body": ""}"#,
300 ).await;
301 assert_eq!(resp.status, 404, "Section on nonexistent item should be 404, got {} {}", resp.status, resp.text);
302 }
303
304 #[tokio::test]
305 async fn section_slug_generation() {
306 let mut h = TestHarness::new().await;
307 let (_project_id, item_id) = setup_creator_with_item(&mut h, "secslug").await;
308
309 // Title with special characters
310 let resp = h.client.post_json(
311 &format!("/api/items/{}/sections", item_id),
312 r#"{"title": "Getting Started!", "body": ""}"#,
313 ).await;
314 assert!(resp.status.is_success());
315 let section: Value = resp.json();
316 let slug = section["slug"].as_str().unwrap();
317 assert!(slug.contains("getting"), "Slug should contain 'getting', got '{}'", slug);
318 assert!(!slug.contains('!'), "Slug should not contain '!', got '{}'", slug);
319 }
320