//! Item sections: CRUD lifecycle, reorder, max limit, ownership, validation, public visibility. use crate::harness::TestHarness; use serde_json::Value; /// Helper: create a creator with a project and a plugin item, return (project_id, item_id). async fn setup_creator_with_item( h: &mut TestHarness, username: &str, ) -> (String, String) { let setup = h.create_creator_with_item(username, "plugin", 0).await; (setup.project_id, setup.item_id) } #[tokio::test] async fn section_create_update_delete() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h, "seccrud").await; // Create section let resp = h .client .post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "Features", "body": "- Fast\n- Reliable"}"#, ) .await; assert!(resp.status.is_success(), "Create section failed: {} {}", resp.status, resp.text); let section: Value = resp.json(); let section_id = section["id"].as_str().unwrap().to_string(); assert_eq!(section["title"].as_str().unwrap(), "Features"); assert_eq!(section["slug"].as_str().unwrap(), "features"); assert_eq!(section["sort_order"].as_i64().unwrap(), 0); // Update section let resp = h .client .put_json( &format!("/api/sections/{}", section_id), r#"{"title": "Key Features", "body": "- Fast\n- Reliable\n- Secure"}"#, ) .await; assert!(resp.status.is_success(), "Update section failed: {} {}", resp.status, resp.text); let updated: Value = resp.json(); assert_eq!(updated["title"].as_str().unwrap(), "Key Features"); assert_eq!(updated["slug"].as_str().unwrap(), "key-features"); // Delete section let resp = h .client .delete(&format!("/api/sections/{}", section_id)) .await; assert!(resp.status.is_success(), "Delete section failed: {} {}", resp.status, resp.text); // Verify gone let count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM item_sections WHERE id = $1") .bind(section_id.parse::().unwrap()) .fetch_one(&h.db) .await .unwrap(); assert_eq!(count, 0, "Section should be deleted from database"); } #[tokio::test] async fn section_list_public_only() { let mut h = TestHarness::new().await; let (project_id, item_id) = setup_creator_with_item(&mut h, "seclist").await; // Create a section let resp = h .client .post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "Installation", "body": "Run `npm install`"}"#, ) .await; assert!(resp.status.is_success()); // Make item a draft (items default to is_public=true) h.client.put_form(&format!("/api/items/{}", item_id), "is_public=false").await; // Draft item: unauthenticated list should 404 h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h.client.get(&format!("/api/items/{}/sections", item_id)).await; assert_eq!(resp.status, 404, "Draft item sections should return 404"); // Publish item h.login("seclist", "password123").await; h.client.put_form(&format!("/api/items/{}", item_id), "is_public=true").await; h.client.put_json(&format!("/api/projects/{}", project_id), r#"{"is_public": true}"#).await; // Now list should work h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; let resp = h.client.get(&format!("/api/items/{}/sections", item_id)).await; assert!(resp.status.is_success(), "Public list failed: {} {}", resp.status, resp.text); let list: Value = resp.json(); let data = list["data"].as_array().unwrap(); assert_eq!(data.len(), 1); assert_eq!(data[0]["title"].as_str().unwrap(), "Installation"); } #[tokio::test] async fn section_reorder() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h, "secreorder").await; // Create 3 sections let mut ids = Vec::new(); for title in &["Alpha", "Beta", "Gamma"] { let body = format!(r#"{{"title": "{}", "body": ""}}"#, title); let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await; assert!(resp.status.is_success()); let sec: Value = resp.json(); ids.push(sec["id"].as_str().unwrap().to_string()); } // Reorder: Gamma, Alpha, Beta let reorder_body = format!( r#"{{"section_ids": ["{}", "{}", "{}"]}}"#, ids[2], ids[0], ids[1] ); let resp = h.client.put_json( &format!("/api/items/{}/sections/reorder", item_id), &reorder_body, ).await; assert!(resp.status.is_success(), "Reorder failed: {} {}", resp.status, resp.text); // Verify order via DB let rows = sqlx::query_as::<_, (String, i32)>( "SELECT title, sort_order FROM item_sections WHERE item_id = $1 ORDER BY sort_order" ) .bind(item_id.parse::().unwrap()) .fetch_all(&h.db) .await .unwrap(); assert_eq!(rows[0].0, "Gamma"); assert_eq!(rows[1].0, "Alpha"); assert_eq!(rows[2].0, "Beta"); } #[tokio::test] async fn section_max_limit() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h, "secmax").await; // Create 10 sections (the limit) for i in 0..10 { let body = format!(r#"{{"title": "Section {}", "body": ""}}"#, i); let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await; assert!(resp.status.is_success(), "Section {} create failed: {} {}", i, resp.status, resp.text); } // 11th should fail let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "Too Many", "body": ""}"#, ).await; assert!( resp.status == 400 || resp.status == 422, "11th section should be rejected, got {} {}", resp.status, resp.text ); } #[tokio::test] async fn section_ownership_enforced() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h, "secowner").await; // Creator A creates a section let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "Private", "body": "secret"}"#, ).await; assert!(resp.status.is_success()); let section: Value = resp.json(); let section_id = section["id"].as_str().unwrap().to_string(); // Switch to creator B h.client.post_form("/logout", "").await; let b_id = h.signup("secintruder", "secintruder@test.com", "password123").await; h.grant_creator(b_id).await; h.client.post_form("/logout", "").await; h.login("secintruder", "password123").await; // Creator B tries to update A's section let resp = h.client.put_json( &format!("/api/sections/{}", section_id), r#"{"title": "Hacked", "body": "pwned"}"#, ).await; assert_eq!(resp.status, 403, "Non-owner PUT should get 403, got {} {}", resp.status, resp.text); // Creator B tries to delete A's section let resp = h.client.delete(&format!("/api/sections/{}", section_id)).await; assert_eq!(resp.status, 403, "Non-owner DELETE should get 403, got {} {}", resp.status, resp.text); // Creator B tries to create section on A's item let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "Inject", "body": ""}"#, ).await; assert_eq!(resp.status, 403, "Non-owner POST should get 403, got {} {}", resp.status, resp.text); } #[tokio::test] async fn section_title_validation() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h, "secvalid").await; // Empty title let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "", "body": ""}"#, ).await; assert!( resp.status == 400 || resp.status == 422, "Empty title should be rejected, got {} {}", resp.status, resp.text ); // Whitespace-only title let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": " ", "body": ""}"#, ).await; assert!( resp.status == 400 || resp.status == 422, "Whitespace title should be rejected, got {} {}", resp.status, resp.text ); // 101-char title (exceeds 100 limit) let long_title = "A".repeat(101); let body = format!(r#"{{"title": "{}", "body": ""}}"#, long_title); let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await; assert!( resp.status == 400 || resp.status == 422, "101-char title should be rejected, got {} {}", resp.status, resp.text ); // 100-char title should succeed let title_100 = "A".repeat(100); let body = format!(r#"{{"title": "{}", "body": ""}}"#, title_100); let resp = h.client.post_json(&format!("/api/items/{}/sections", item_id), &body).await; assert!(resp.status.is_success(), "100-char title should be accepted, got {} {}", resp.status, resp.text); } #[tokio::test] async fn section_unauthenticated_rejected() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h, "secunauth").await; // Create a section so we have an ID let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "Temp", "body": ""}"#, ).await; assert!(resp.status.is_success()); let section: Value = resp.json(); let section_id = section["id"].as_str().unwrap().to_string(); // Logout h.client.post_form("/logout", "").await; h.client.fetch_csrf_token().await; // POST should be 401 let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "No Auth", "body": ""}"#, ).await; assert_eq!(resp.status, 401, "Unauthenticated POST should be 401, got {} {}", resp.status, resp.text); // PUT should be 401 let resp = h.client.put_json( &format!("/api/sections/{}", section_id), r#"{"title": "No Auth", "body": ""}"#, ).await; assert_eq!(resp.status, 401, "Unauthenticated PUT should be 401, got {} {}", resp.status, resp.text); // DELETE should be 401 let resp = h.client.delete(&format!("/api/sections/{}", section_id)).await; assert_eq!(resp.status, 401, "Unauthenticated DELETE should be 401, got {} {}", resp.status, resp.text); } #[tokio::test] async fn section_nonexistent_item() { let mut h = TestHarness::new().await; let user_id = h.signup("secghost", "secghost@test.com", "password123").await; h.grant_creator(user_id).await; h.client.post_form("/logout", "").await; h.login("secghost", "password123").await; let fake_id = uuid::Uuid::new_v4(); let resp = h.client.post_json( &format!("/api/items/{}/sections", fake_id), r#"{"title": "Ghost", "body": ""}"#, ).await; assert_eq!(resp.status, 404, "Section on nonexistent item should be 404, got {} {}", resp.status, resp.text); } #[tokio::test] async fn section_slug_generation() { let mut h = TestHarness::new().await; let (_project_id, item_id) = setup_creator_with_item(&mut h, "secslug").await; // Title with special characters let resp = h.client.post_json( &format!("/api/items/{}/sections", item_id), r#"{"title": "Getting Started!", "body": ""}"#, ).await; assert!(resp.status.is_success()); let section: Value = resp.json(); let slug = section["slug"].as_str().unwrap(); assert!(slug.contains("getting"), "Slug should contain 'getting', got '{}'", slug); assert!(!slug.contains('!'), "Slug should not contain '!', got '{}'", slug); }