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}")))?;
|
| 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 |
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 {
|