Skip to main content

max / makenotwork

sando: typed-body envelope seal + confirm-modal coverage (UX/API A+, ultra-fuzz Run 2 deep Phase 5) Drive the TUI/UX & API axis from A- to A+: - TypedBody<T> extractor (D10): self_update took a bare Json<SelfUpdateBody>, whose deserialize failure returned axum's raw 422 — outside the daemon's 400/404/409/500 contract. TypedBody maps any body rejection to Error::BadRequest, so every required-body handler speaks one error shape. self_update now uses it. - confirm-modal coverage seal (D11): `b` (backup/fetch) dispatched on a single keystroke while p/R/c staged a y/N confirm. `b` now stages too, so the actual dispatch (action_tx.send) lives in exactly one place — the confirm branch — and no mutating action can fire on a single keystroke. Version-parse / no-parent stay 500 by decision: they signal corrupt server state, not a client error, and already go through the Error envelope — mapping them to 4xx would mislabel them. Tests: malformed self-update body returns 400 not 422. clippy -D warnings clean; native fw13. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 03:42 UTC
Commit: 5a51b7a7c7b70ecc3042cbfbf339c80e1336f886
Parent: d42fe39
3 files changed, +54 insertions, -2 deletions
@@ -1,3 +1,4 @@
1 + use axum::extract::FromRequest;
1 2 use axum::http::StatusCode;
2 3 use axum::response::{IntoResponse, Response};
3 4
@@ -28,3 +29,30 @@ impl IntoResponse for Error {
28 29 }
29 30
30 31 pub type Result<T> = std::result::Result<T, Error>;
32 +
33 + /// A JSON request body that funnels deserialization failures through the daemon
34 + /// `Error` envelope instead of axum's raw `422 Unprocessable Entity`. A bare
35 + /// `Json<T>` extractor rejects with axum's own plain-text 422, which is outside
36 + /// the 400/404/409/500 contract every other endpoint speaks; `TypedBody<T>` maps
37 + /// any rejection to `Error::BadRequest` (400) so a non-TUI driver gets one
38 + /// consistent error shape. Use this for every handler that takes a required JSON
39 + /// body.
40 + pub struct TypedBody<T>(pub T);
41 +
42 + impl<T, S> FromRequest<S> for TypedBody<T>
43 + where
44 + T: serde::de::DeserializeOwned,
45 + S: Send + Sync,
46 + {
47 + type Rejection = Error;
48 +
49 + async fn from_request(
50 + req: axum::extract::Request,
51 + state: &S,
52 + ) -> std::result::Result<Self, Self::Rejection> {
53 + let axum::Json(value) = axum::Json::<T>::from_request(req, state)
54 + .await
55 + .map_err(|rej| Error::BadRequest(format!("invalid request body: {}", rej.body_text())))?;
56 + Ok(TypedBody(value))
57 + }
58 + }
@@ -953,7 +953,7 @@ fn self_update_unit(sha: &crate::domain::GitSha) -> String {
953 953 /// version shows up in `/state`'s `sandod_version` once the restart lands.
954 954 async fn self_update(
955 955 State(s): State<AppState>,
956 - Json(body): Json<SelfUpdateBody>,
956 + crate::error::TypedBody(body): crate::error::TypedBody<SelfUpdateBody>,
957 957 ) -> Result<Json<serde_json::Value>> {
958 958 let sha = crate::domain::GitSha::parse(&body.sha)
959 959 .map_err(|e| crate::error::Error::BadRequest(format!("invalid sha: {e}")))?;
@@ -2046,6 +2046,26 @@ mod tests {
2046 2046 }
2047 2047
2048 2048 #[tokio::test]
2049 + async fn self_update_malformed_body_is_400_not_422() {
2050 + // TypedBody funnels a JSON deserialize failure through the Error envelope
2051 + // (400), not axum's raw 422 — keeping every mutator on one error contract.
2052 + let state = test_state().await; // no token -> auth passes, body is the gate
2053 + let app = router(state);
2054 + let resp = app
2055 + .oneshot(
2056 + Request::builder()
2057 + .method("POST")
2058 + .uri("/self-update")
2059 + .header("content-type", "application/json")
2060 + .body(Body::from("{ not valid json"))
2061 + .unwrap(),
2062 + )
2063 + .await
2064 + .unwrap();
2065 + assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2066 + }
2067 +
2068 + #[tokio::test]
2049 2069 async fn gate_log_rejects_unknown_gate_kind() {
2050 2070 // The gate segment is an allowlisted GateKind, not a free-form filename:
2051 2071 // an unknown kind is a 404, so `*.log` basenames can't be probed.
@@ -693,7 +693,11 @@ fn ui_loop<B: Backend>(
693 693 shared.lock().unwrap().notice = Some("refresh on next tick".into());
694 694 }
695 695 KeyCode::Char('b') => {
696 - let _ = action_tx.try_send(Action::BackupFetch);
696 + // Every mutating action stages a confirmation; the actual
697 + // dispatch (action_tx.send) lives ONLY in the pending/confirm
698 + // branch above, so no mutator fires on a single keystroke —
699 + // backup/fetch included (it was the one that still did).
700 + shared.lock().unwrap().pending = Some(Action::BackupFetch);
697 701 }
698 702 KeyCode::Char('p') => {
699 703 if let Some(t) = selected_tier {