test(axum): harden form extraction boundary
Prove repeated and required form semantics, stable rejection diagnostics, media-type and body limits, multipart files, unnamed-part skipping, invalid UTF-8, and streaming failures through the public extractor. req: form/002 req: form/004 req: failure/003 req: multipart/001 req: multipart/002 req: multipart/003
This commit is contained in:
@@ -21,7 +21,7 @@
|
|||||||
- [ ] **State:** In progress — the package-native capped xtask entry point is reachable, rejects unknown packages, propagates mutest failure, and mutation-tests `hemx-core`, `hemx-js`, and the full `hemx-test` package cleanly; full package closure remains.
|
- [ ] **State:** In progress — the package-native capped xtask entry point is reachable, rejects unknown packages, propagates mutest failure, and mutation-tests `hemx-core`, `hemx-js`, and the full `hemx-test` package cleanly; full package closure remains.
|
||||||
- **User value:** maintainers can run one bounded repository command and trust that meaningful Rust logic across every mutation-applicable library is either killed or explicitly justified.
|
- **User value:** maintainers can run one bounded repository command and trust that meaningful Rust logic across every mutation-applicable library is either killed or explicitly justified.
|
||||||
- **Build:** add a capped `hemx-xtask` mutation command that invokes `/opt/repositories/mutest`/`mutest` through package-native test targets rather than the broken workspace-wide example path; enumerate only current mutation-applicable library/proc-macro packages; finish adversarial tests or simplify code until every survivor is classified; keep equivalent, invariant-only, and infrastructure-inapplicable classifications inspectable and minimal; document the exact local release command in the existing readiness surface.
|
- **Build:** add a capped `hemx-xtask` mutation command that invokes `/opt/repositories/mutest`/`mutest` through package-native test targets rather than the broken workspace-wide example path; enumerate only current mutation-applicable library/proc-macro packages; finish adversarial tests or simplify code until every survivor is classified; keep equivalent, invariant-only, and infrastructure-inapplicable classifications inspectable and minimal; document the exact local release command in the existing readiness surface.
|
||||||
- **Blocked by:** none; broad survivors currently remain in `hemx-axum`, `hemx-build`, `hemx-derive`, and `hemx-lsp` outside already-clean focused contracts; the next `hemx-axum` slice now proves page-mode and response-constructor semantics, while form/registry/rejection paths remain.
|
- **Blocked by:** none; broad survivors currently remain in `hemx-axum`, `hemx-build`, `hemx-derive`, and `hemx-lsp` outside already-clean focused contracts; the current `hemx-axum` frontier now proves page-mode, response constructors, form accessors/rejections, media-type limits, and multipart success/error semantics mutation-clean; registry and remaining response paths remain.
|
||||||
- **Proof:** the new xtask mutation command exits zero within its documented bound, covers each applicable package, emits no unexplained missed mutant, and a deliberate adjacent mutation makes it fail. `cargo run -p hemx-xtask -- test` remains green. req: test/020 req: test/021
|
- **Proof:** the new xtask mutation command exits zero within its documented bound, covers each applicable package, emits no unexplained missed mutant, and a deliberate adjacent mutation makes it fail. `cargo run -p hemx-xtask -- test` remains green. req: test/020 req: test/021
|
||||||
|
|
||||||
## 3. Elect and enforce the release license policy
|
## 3. Elect and enforce the release license policy
|
||||||
|
|||||||
+192
-1
@@ -1,7 +1,11 @@
|
|||||||
use axum::extract::{DefaultBodyLimit, State};
|
use axum::extract::{DefaultBodyLimit, State};
|
||||||
use axum::http::{header, HeaderMap, Request, StatusCode};
|
use axum::http::{header, HeaderMap, Request, StatusCode};
|
||||||
use axum::response::IntoResponse;
|
use axum::response::IntoResponse;
|
||||||
use axum::{body::Body, routing::post, Router};
|
use axum::{
|
||||||
|
body::{to_bytes, Body, Bytes},
|
||||||
|
routing::post,
|
||||||
|
Router,
|
||||||
|
};
|
||||||
use hemx_axum::{
|
use hemx_axum::{
|
||||||
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
|
interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path,
|
||||||
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
|
runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form,
|
||||||
@@ -21,6 +25,18 @@ async fn bounded_mutation(_: InteractionRequest) -> StatusCode {
|
|||||||
StatusCode::NO_CONTENT
|
StatusCode::NO_CONTENT
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn multipart_mutation(request: InteractionRequest) -> StatusCode {
|
||||||
|
let form = request.form();
|
||||||
|
assert_eq!(form.handle_id, 7);
|
||||||
|
assert_eq!(form.value("title"), Some("report"));
|
||||||
|
assert_eq!(form.files().len(), 1);
|
||||||
|
let upload = form.file("upload").expect("uploaded file");
|
||||||
|
assert_eq!(upload.file_name.as_deref(), Some("report.txt"));
|
||||||
|
assert_eq!(upload.content_type.as_deref(), Some("text/plain"));
|
||||||
|
assert_eq!(upload.bytes, b"hello");
|
||||||
|
StatusCode::NO_CONTENT
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
||||||
// test req: security/003
|
// test req: security/003
|
||||||
@@ -29,6 +45,17 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
|||||||
.route("/mutate", post(bounded_mutation))
|
.route("/mutate", post(bounded_mutation))
|
||||||
.layer(DefaultBodyLimit::max(32));
|
.layer(DefaultBodyLimit::max(32));
|
||||||
|
|
||||||
|
let missing_content_type = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::post("/mutate").body(Body::from("__h=1")).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
missing_content_type.status(),
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE
|
||||||
|
);
|
||||||
|
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0);
|
||||||
|
|
||||||
let unsupported = app
|
let unsupported = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
@@ -71,6 +98,97 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() {
|
|||||||
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1);
|
assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interaction_boundary_extracts_multipart_fields_and_files() {
|
||||||
|
let boundary = "hemx-boundary";
|
||||||
|
let body = concat!(
|
||||||
|
"--hemx-boundary\r\n",
|
||||||
|
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
|
||||||
|
"7\r\n",
|
||||||
|
"--hemx-boundary\r\n",
|
||||||
|
"Content-Disposition: form-data; name=\"title\"\r\n\r\n",
|
||||||
|
"report\r\n",
|
||||||
|
"--hemx-boundary\r\n",
|
||||||
|
"Content-Disposition: form-data; name=\"upload\"; filename=\"report.txt\"\r\n",
|
||||||
|
"Content-Type: text/plain\r\n\r\n",
|
||||||
|
"hello\r\n",
|
||||||
|
"--hemx-boundary--\r\n"
|
||||||
|
);
|
||||||
|
let response = Router::new()
|
||||||
|
.route("/upload", post(multipart_mutation))
|
||||||
|
.oneshot(
|
||||||
|
Request::post("/upload")
|
||||||
|
.header(
|
||||||
|
header::CONTENT_TYPE,
|
||||||
|
format!("multipart/form-data; boundary={boundary}"),
|
||||||
|
)
|
||||||
|
.body(Body::from(body))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
// test req: multipart/001 req: multipart/002 req: multipart/003
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interaction_boundary_skips_unnamed_parts_and_rejects_invalid_multipart() {
|
||||||
|
let accepted = concat!(
|
||||||
|
"--b\r\n",
|
||||||
|
"Content-Disposition: form-data; filename=\"ignored.txt\"\r\n\r\n",
|
||||||
|
"ignored\r\n",
|
||||||
|
"--b\r\n",
|
||||||
|
"Content-Disposition: form-data; name=\"__h\"\r\n\r\n",
|
||||||
|
"1\r\n",
|
||||||
|
"--b--\r\n"
|
||||||
|
);
|
||||||
|
let app = Router::new().route("/mutate", post(bounded_mutation));
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::post("/mutate")
|
||||||
|
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
|
||||||
|
.body(Body::from(accepted))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::NO_CONTENT);
|
||||||
|
|
||||||
|
let invalid_utf8 =
|
||||||
|
b"--b\r\nContent-Disposition: form-data; name=\"__h\"\r\n\r\n\xff\r\n--b--\r\n";
|
||||||
|
let response = app
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::post("/mutate")
|
||||||
|
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
|
||||||
|
.body(Body::from(invalid_utf8.as_slice()))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
|
||||||
|
let stream = futures_util::stream::iter([
|
||||||
|
Ok::<_, std::io::Error>(Bytes::from_static(
|
||||||
|
b"--b\r\nContent-Disposition: form-data; name=\"__h\"\r\n\r\n",
|
||||||
|
)),
|
||||||
|
Err(std::io::Error::other("stream failed")),
|
||||||
|
]);
|
||||||
|
let response = app
|
||||||
|
.oneshot(
|
||||||
|
Request::post("/mutate")
|
||||||
|
.header(header::CONTENT_TYPE, "multipart/form-data; boundary=b")
|
||||||
|
.body(Body::from_stream(stream))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
// test req: multipart/001 req: multipart/003
|
||||||
|
}
|
||||||
|
|
||||||
fn selector(value: &str) -> Selector {
|
fn selector(value: &str) -> Selector {
|
||||||
Selector::parse(value).expect("test selector parses")
|
Selector::parse(value).expect("test selector parses")
|
||||||
}
|
}
|
||||||
@@ -361,6 +479,79 @@ fn state_interactions_starts_stateful_wiring_without_nested_closures() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn interaction_form_accessors_preserve_repeated_values_and_decode_diagnostics() {
|
||||||
|
let form =
|
||||||
|
InteractionForm::parse_urlencoded(b"__h=42&count=7&tag=alpha&tag=beta&empty=").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(form.handle_id, 42);
|
||||||
|
assert_eq!(form.value("tag"), Some("alpha"));
|
||||||
|
assert_eq!(form.values("tag").collect::<Vec<_>>(), ["alpha", "beta"]);
|
||||||
|
assert_eq!(form.parse::<u32>("count"), Some(7));
|
||||||
|
assert_eq!(form.parse::<u32>("tag"), None);
|
||||||
|
assert_eq!(form.parse::<u32>("missing"), None);
|
||||||
|
assert_eq!(form.required("empty"), Ok(""));
|
||||||
|
assert_eq!(
|
||||||
|
form.required("missing").unwrap_err().message(),
|
||||||
|
"missing form field `missing`"
|
||||||
|
);
|
||||||
|
assert_eq!(form.parse_required::<u32>("count"), Ok(7));
|
||||||
|
assert_eq!(
|
||||||
|
form.parse_required::<u32>("missing").unwrap_err().message(),
|
||||||
|
"missing form field `missing`"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
form.parse_required::<u32>("tag").unwrap_err().message(),
|
||||||
|
"invalid form field `tag`"
|
||||||
|
);
|
||||||
|
assert!(form.files().is_empty());
|
||||||
|
assert!(form.file("upload").is_none());
|
||||||
|
assert!(form
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.any(|pair| pair == &("count".into(), "7".into())));
|
||||||
|
// test req: form/002 req: form/004 req: multipart/001
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn interaction_form_rejections_return_stable_status_and_diagnostic() {
|
||||||
|
for (rejection, status, message) in [
|
||||||
|
(
|
||||||
|
InteractionFormRejection::UnsupportedMediaType,
|
||||||
|
StatusCode::UNSUPPORTED_MEDIA_TYPE,
|
||||||
|
"hemx interactions require application/x-www-form-urlencoded or multipart/form-data",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
InteractionFormRejection::BodyTooLarge,
|
||||||
|
StatusCode::PAYLOAD_TOO_LARGE,
|
||||||
|
"hemx interaction body exceeds the host limit",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
InteractionFormRejection::InvalidBody,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid hemx form body",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
InteractionFormRejection::MissingHandle,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"missing __h hemx handle field",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
InteractionFormRejection::InvalidHandle,
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"invalid __h hemx handle field",
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let response = rejection.into_response();
|
||||||
|
assert_eq!(response.status(), status);
|
||||||
|
assert_eq!(
|
||||||
|
to_bytes(response.into_body(), 1024).await.unwrap().as_ref(),
|
||||||
|
message.as_bytes()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// test req: failure/003 req: multipart/003
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn interaction_request_dispatches_typed_form_inputs() {
|
fn interaction_request_dispatches_typed_form_inputs() {
|
||||||
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
// req: axum_integration/003 req: form/004 req: canonical_authoring/003
|
||||||
|
|||||||
Reference in New Issue
Block a user