main
tag: launch-2026-06-01
tag: magicmirror-v0.1.1
tag: magicmirror-v0.3.0
tag: mnw-cli-v0.1.2
tag: mnw-cli-v0.1.3
tag: mnw-cli-v0.1.4
tag: pom-v0.4.1
tag: pom-v0.4.2
tag: pom-v0.4.3
tag: pom-v0.4.4
tag: pom-v0.4.5
tag: wam-v0.3.0
tag: wam-v0.3.1
Files
Commits
Tags
Notes
Issues
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>
3 files changed,
+54 insertions,
-2 deletions
1
+
use axum::extract::FromRequest;
1
2
use axum::http::StatusCode;
2
3
use axum::response::{IntoResponse, Response};
3
4
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
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}")))?;
2045
2045
assert_eq!(resp.status(), StatusCode::OK);
2046
2046
}
2047
2047
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
+
2048
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:
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 {