From b161b4c49b096fc77cfab7d0da11d56cceb68581 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:13:53 +0000 Subject: [PATCH 1/3] fix(api): reject unknown or lossy octad create/update payloads --- rust-core/verisim-api/src/lib.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/rust-core/verisim-api/src/lib.rs b/rust-core/verisim-api/src/lib.rs index a807765..f2b1404 100644 --- a/rust-core/verisim-api/src/lib.rs +++ b/rust-core/verisim-api/src/lib.rs @@ -270,6 +270,7 @@ pub struct HealthResponse { /// Octad create/update request #[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct OctadRequest { /// Document title pub title: Option, @@ -322,6 +323,16 @@ pub struct SpatialRequest { } impl OctadRequest { + /// Refuse payloads that would silently lose a supplied field. + fn validate(&self) -> Result<(), ApiError> { + if self.body.is_some() && self.title.is_none() { + return Err(ApiError::BadRequest( + "body requires title; omitting it would discard document content".to_string(), + )); + } + Ok(()) + } + /// Convert to OctadInput fn to_octad_input(&self) -> OctadInput { let mut input = OctadInput::default(); @@ -1051,6 +1062,7 @@ async fn create_octad_handler( State(state): State, Json(request): Json, ) -> Result<(StatusCode, Json), ApiError> { + request.validate()?; let input = request.to_octad_input(); let octad = state @@ -1095,6 +1107,7 @@ async fn update_octad_handler( ) -> Result, ApiError> { validate_octad_id(&id)?; let octad_id = OctadId::new(&id); + request.validate()?; let input = request.to_octad_input(); // The pre-update modality set is the schema baseline: an update that From 19b42b6488d638bca8987e0ba26edd27470f8cae Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:14:21 +0000 Subject: [PATCH 2/3] test(api): fail explicitly on nested and body-only requests --- rust-core/verisim-api/src/lib.rs | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/rust-core/verisim-api/src/lib.rs b/rust-core/verisim-api/src/lib.rs index f2b1404..68dae9c 100644 --- a/rust-core/verisim-api/src/lib.rs +++ b/rust-core/verisim-api/src/lib.rs @@ -2695,6 +2695,42 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } + #[tokio::test] + async fn test_reject_nested_schema_payload_instead_of_silently_dropping_it() { + let state = create_test_state().await; + let app = build_router(state); + let nested = serde_json::json!({ + "document": {"title": "Keep", "body": "Must not be discarded"} + }); + + let response = app.oneshot( + Request::builder() + .method("POST") + .uri("/octads") + .header("content-type", "application/json") + .body(Body::from(nested.to_string())) + .unwrap(), + ).await.unwrap(); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + } + + #[tokio::test] + async fn test_reject_body_without_title_instead_of_silently_dropping_it() { + let state = create_test_state().await; + let app = build_router(state); + let response = app.oneshot( + Request::builder() + .method("POST") + .uri("/octads") + .header("content-type", "application/json") + .body(Body::from(r#"{"body":"A document without a title"}"#)) + .unwrap(), + ).await.unwrap(); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + } + #[tokio::test] async fn test_create_and_get_octad() { let state = create_test_state().await; From 2e126ca2ffc89ff189c834a0cb9cc479b57c0056 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:16:26 +0000 Subject: [PATCH 3/3] style(api): apply CI rustfmt corrections --- rust-core/verisim-api/src/lib.rs | 38 ++++++++++++++++++-------------- 1 file changed, 22 insertions(+), 16 deletions(-) diff --git a/rust-core/verisim-api/src/lib.rs b/rust-core/verisim-api/src/lib.rs index 68dae9c..6686021 100644 --- a/rust-core/verisim-api/src/lib.rs +++ b/rust-core/verisim-api/src/lib.rs @@ -2703,14 +2703,17 @@ mod tests { "document": {"title": "Keep", "body": "Must not be discarded"} }); - let response = app.oneshot( - Request::builder() - .method("POST") - .uri("/octads") - .header("content-type", "application/json") - .body(Body::from(nested.to_string())) - .unwrap(), - ).await.unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/octads") + .header("content-type", "application/json") + .body(Body::from(nested.to_string())) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); } @@ -2719,14 +2722,17 @@ mod tests { async fn test_reject_body_without_title_instead_of_silently_dropping_it() { let state = create_test_state().await; let app = build_router(state); - let response = app.oneshot( - Request::builder() - .method("POST") - .uri("/octads") - .header("content-type", "application/json") - .body(Body::from(r#"{"body":"A document without a title"}"#)) - .unwrap(), - ).await.unwrap(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/octads") + .header("content-type", "application/json") + .body(Body::from(r#"{"body":"A document without a title"}"#)) + .unwrap(), + ) + .await + .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); }