diff --git a/PLAN.md b/PLAN.md index 9179151..0dde1ad 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. - **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. -- **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 ## 3. Elect and enforce the release license policy diff --git a/hemx-axum/tests/response.rs b/hemx-axum/tests/response.rs index 709b089..fbb0238 100644 --- a/hemx-axum/tests/response.rs +++ b/hemx-axum/tests/response.rs @@ -1,7 +1,11 @@ use axum::extract::{DefaultBodyLimit, State}; use axum::http::{header, HeaderMap, Request, StatusCode}; use axum::response::IntoResponse; -use axum::{body::Body, routing::post, Router}; +use axum::{ + body::{to_bytes, Body, Bytes}, + routing::post, + Router, +}; use hemx_axum::{ interactions, runtime_js, runtime_js_hash, runtime_js_path, runtime_js_route_path, runtime_js_script_src, runtime_js_source, DispatchRejection, EffectResponse, Form, @@ -21,6 +25,18 @@ async fn bounded_mutation(_: InteractionRequest) -> StatusCode { 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] async fn interaction_boundary_honors_media_type_and_host_body_limit() { // test req: security/003 @@ -29,6 +45,17 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() { .route("/mutate", post(bounded_mutation)) .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 .clone() .oneshot( @@ -71,6 +98,97 @@ async fn interaction_boundary_honors_media_type_and_host_body_limit() { 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 { 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::>(), ["alpha", "beta"]); + assert_eq!(form.parse::("count"), Some(7)); + assert_eq!(form.parse::("tag"), None); + assert_eq!(form.parse::("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::("count"), Ok(7)); + assert_eq!( + form.parse_required::("missing").unwrap_err().message(), + "missing form field `missing`" + ); + assert_eq!( + form.parse_required::("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] fn interaction_request_dispatches_typed_form_inputs() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003