use axum::extract::{DefaultBodyLimit, State}; use axum::http::{header, HeaderMap, Request, StatusCode}; use axum::response::IntoResponse; use axum::{body::Body, 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, HandlerErrorContext, HandlerFailure, InteractionForm, InteractionFormRejection, InteractionRequest, IntoHandlerFailure, PageMode, PageRequest, PageResponse, HEMX_CONTENT_TYPE, HEMX_FINGERPRINT_HEADER, HEMX_PARTIAL_HEADER, HEMX_RUNTIME_CONTENT_TYPE, HEMX_TITLE_HEADER, }; use hemx_core::{push, BuildFingerprint, Handle, IntoEffect, SafeHtml, Slot}; use scraper::{Html, Selector}; use std::sync::atomic::{AtomicUsize, Ordering}; use tower::ServiceExt; static MUTATION_CALLS: AtomicUsize = AtomicUsize::new(0); async fn bounded_mutation(_: InteractionRequest) -> StatusCode { MUTATION_CALLS.fetch_add(1, Ordering::SeqCst); StatusCode::NO_CONTENT } #[tokio::test] async fn interaction_boundary_honors_media_type_and_host_body_limit() { // test req: security/003 MUTATION_CALLS.store(0, Ordering::SeqCst); let app = Router::new() .route("/mutate", post(bounded_mutation)) .layer(DefaultBodyLimit::max(32)); let unsupported = app .clone() .oneshot( Request::post("/mutate") .header(header::CONTENT_TYPE, "application/json") .body(Body::from(r#"{"__h":"1"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(unsupported.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0); let oversized = app .clone() .oneshot( Request::post("/mutate") .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") .body(Body::from(format!("__h=1&value={}", "x".repeat(64)))) .unwrap(), ) .await .unwrap(); assert_eq!(oversized.status(), StatusCode::PAYLOAD_TOO_LARGE); assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 0); let accepted = app .oneshot( Request::post("/mutate") .header( header::CONTENT_TYPE, "application/x-www-form-urlencoded; charset=utf-8", ) .body(Body::from("__h=1")) .unwrap(), ) .await .unwrap(); assert_eq!(accepted.status(), StatusCode::NO_CONTENT); assert_eq!(MUTATION_CALLS.load(Ordering::SeqCst), 1); } fn selector(value: &str) -> Selector { Selector::parse(value).expect("test selector parses") } #[derive(Clone, Copy, Debug, Eq, PartialEq)] struct ProjectId(u32); impl std::str::FromStr for ProjectId { type Err = std::num::ParseIntError; fn from_str(value: &str) -> Result { value.parse().map(ProjectId) } } struct OpenProject { project_id: ProjectId, } impl hemx_core::FromForm for OpenProject { fn from_form_fields(fields: &[(String, String)]) -> Result { let Some(value) = fields .iter() .find_map(|(name, value)| (name == "project_id").then_some(value.as_str())) else { return Err(hemx_core::FormError::new("missing form field `project_id`")); }; Ok(Self { project_id: value .parse() .map_err(|_| hemx_core::FormError::new("invalid form field `project_id`"))?, }) } } #[test] fn page_mode_detects_partial_header() { let mut headers = HeaderMap::new(); assert_eq!(PageMode::from_headers(&headers), PageMode::Full); headers.insert(HEMX_PARTIAL_HEADER, "true".parse().unwrap()); assert_eq!(PageMode::from_headers(&headers), PageMode::Partial); } #[test] fn page_request_wraps_full_pages_and_leaves_partials_unwrapped() { // req: test/005 assert!(!PageRequest { mode: PageMode::Full } .is_partial()); assert!(PageRequest { mode: PageMode::Partial } .is_partial()); let full = PageRequest { mode: PageMode::Full, } .page("
Docs
", |content| { format!("{content}") }); assert_eq!(full.mode, PageMode::Full); let full_document = Html::parse_document(&full.html); assert_eq!( full_document .select(&selector( "body[data-shell=\"docs\"] main[data-page=\"docs\"]" )) .count(), 1 ); let partial = PageRequest { mode: PageMode::Partial, } .page("
Docs
", |content| { format!("{content}") }); assert_eq!(partial.mode, PageMode::Partial); let partial_fragment = Html::parse_fragment(&partial.html); assert_eq!( partial_fragment .select(&selector("main[data-page=\"docs\"]")) .count(), 1 ); assert_eq!( partial_fragment .select(&selector("body[data-shell=\"docs\"]")) .count(), 0 ); } #[test] fn page_request_wraps_safe_html_full_pages_and_leaves_partials_unwrapped() { // req: axum_integration/001 req: html_safety/001 req: html_safety/002 req: test/005 let full = PageRequest { mode: PageMode::Full, } .page_html( SafeHtml::trusted("
Docs
"), |content| { SafeHtml::trusted(format!( "{content}" )) }, ); assert_eq!(full.mode, PageMode::Full); let full_document = Html::parse_document(&full.html); assert_eq!( full_document .select(&selector( "body[data-shell=\"docs\"] main[data-page=\"docs\"]" )) .count(), 1 ); let partial = PageRequest { mode: PageMode::Partial, } .page_html( SafeHtml::trusted("
Docs
"), |content| { SafeHtml::trusted(format!( "{content}" )) }, ); assert_eq!(partial.mode, PageMode::Partial); let partial_fragment = Html::parse_fragment(&partial.html); assert_eq!( partial_fragment .select(&selector("main[data-page=\"docs\"]")) .count(), 1 ); assert_eq!( partial_fragment .select(&selector("body[data-shell=\"docs\"]")) .count(), 0 ); } #[test] fn page_response_constructors_preserve_mode_and_optional_fingerprint() { let full = PageResponse::full("Full"); assert_eq!(full.mode, PageMode::Full); assert_eq!(full.html, "Full"); assert_eq!(full.fingerprint, None); let partial = PageResponse::partial("
Partial
"); assert_eq!(partial.mode, PageMode::Partial); assert_eq!(partial.html, "
Partial
"); assert_eq!(partial.fingerprint, None); let fingerprint = BuildFingerprint(42); assert_eq!( partial.fingerprint(fingerprint).fingerprint, Some(fingerprint) ); // test req: page_swap/001 req: abi/005 } #[test] fn partial_page_response_sets_partial_and_title_headers() { let response = PageResponse::partial("
Docs
") .title("Docs") .into_response(); assert_eq!( response.headers()[header::CONTENT_TYPE], "text/html; charset=utf-8" ); assert_eq!(response.headers()[HEMX_PARTIAL_HEADER], "true"); assert_eq!(response.headers()[HEMX_TITLE_HEADER], "Docs"); } #[test] fn effect_response_is_wire_batch_with_fingerprint_header() { let response = EffectResponse::new(push("/docs"), BuildFingerprint(99)).into_response(); assert_eq!(response.headers()[header::CONTENT_TYPE], HEMX_CONTENT_TYPE); assert_eq!(response.headers()[HEMX_FINGERPRINT_HEADER], "99"); } #[test] fn interaction_form_parses_handle_and_fields() { let form = InteractionForm::parse_urlencoded(b"__h=42&title=Hello+World&tag=a&tag=b%2Fc").unwrap(); assert_eq!(form.handle_id, 42); assert_eq!(form.value("title"), Some("Hello World")); assert_eq!(form.values("tag").collect::>(), ["a", "b/c"]); } #[test] fn interaction_form_parses_typed_values() { // req: form/004 req: dx/003 let form = InteractionForm::parse_urlencoded(b"__h=42&count=7&bad=nope").expect("form should parse"); assert_eq!(form.parse::("count"), Some(7)); assert_eq!(form.parse::("bad"), None); assert_eq!(form.parse::("missing"), None); } #[test] fn interaction_form_requires_numeric_handle() { assert_eq!( InteractionForm::parse_urlencoded(b"title=Hello").unwrap_err(), InteractionFormRejection::MissingHandle ); assert_eq!( InteractionForm::parse_urlencoded(b"__h=nope").unwrap_err(), InteractionFormRejection::InvalidHandle ); } #[test] fn interaction_form_preserves_hidden_csrf_fields_for_extractors() { // req: auth/004 let form = InteractionForm::parse_urlencoded(b"__h=42&csrf_token=abc123&title=Hello").unwrap(); assert_eq!(form.handle_id, 42); assert_eq!(form.value("csrf_token"), Some("abc123")); assert!(form.fields().iter().any(|(name, _)| name == "csrf_token")); } #[test] fn interaction_request_dispatches_with_concise_handlers_helper() { // req: axum_integration/003 req: ceremony/001 req: dx/003 let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(7), Vec::new(), )); let response = request .dispatch( interactions(BuildFingerprint(4)) .on(Handle::<()>::new(7), |_| Slot::::new(3).text("ok")), ) .unwrap(); assert_eq!(response.batch.fingerprint, BuildFingerprint(4)); assert_eq!(response.batch.ops, vec![Slot::::new(3).text("ok")]); } #[test] fn state_interactions_starts_stateful_wiring_without_nested_closures() { // req: axum_integration/003 req: ceremony/001 req: dx/003 fn ping(prefix: String) -> impl IntoEffect { Slot::::new(3).text(format!("{prefix}: ping")) } fn open(prefix: String, input: OpenProject) -> impl IntoEffect { Slot::::new(3).text(format!("{prefix}: {}", input.project_id.0)) } let registry = || { hemx_axum::state_interactions(BuildFingerprint(4), "project".to_owned()) .on_state(Handle::<()>::new(7), ping) .on(Handle::<()>::new(8), open) .into_registry() }; let ping_response = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(7), Vec::new(), )) .dispatch(registry()) .unwrap(); assert_eq!( ping_response.batch.ops, vec![Slot::::new(3).text("project: ping")] ); let open_response = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(8), vec![("project_id".to_owned(), "42".to_owned())], )) .dispatch(registry()) .unwrap(); assert_eq!( open_response.batch.ops, vec![Slot::::new(3).text("project: 42")] ); } #[test] fn interaction_request_dispatches_typed_form_inputs() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003 let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(8), vec![("project_id".to_owned(), "42".to_owned())], )); let response = request .dispatch( interactions(BuildFingerprint(4)) .on_form(Handle::<()>::new(8), |input: OpenProject| { Slot::::new(3).text(input.project_id.0) }), ) .unwrap(); assert_eq!(response.batch.fingerprint, BuildFingerprint(4)); assert_eq!(response.batch.ops, vec![Slot::::new(3).text(42)]); } #[test] fn interaction_request_rejects_invalid_typed_form_inputs() { // req: axum_integration/003 req: form/004 req: canonical_authoring/004 let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(8), vec![("project_id".to_owned(), "nope".to_owned())], )); let rejection = request .dispatch( interactions(BuildFingerprint(4)) .on_form(Handle::<()>::new(8), |input: OpenProject| { Slot::::new(3).text(input.project_id.0) }), ) .unwrap_err(); assert!(matches!( rejection, DispatchRejection::InvalidForm { handle_id: 8, .. } )); } #[test] fn interaction_request_dispatches_typed_state_handlers() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003 fn open_project(multiplier: u32, input: OpenProject) -> impl IntoEffect { Slot::::new(3).text(input.project_id.0 * multiplier) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(9), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch( interactions(BuildFingerprint(4)) .with_state(2_u32) .on(Handle::<()>::new(9), open_project), ) .unwrap(); assert_eq!(response.batch.ops, vec![Slot::::new(3).text(42)]); } #[derive(Debug)] struct HandlerBoom; impl std::fmt::Display for HandlerBoom { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("database unavailable") } } impl std::error::Error for HandlerBoom {} impl IntoHandlerFailure for HandlerBoom { fn into_handler_failure(self, _context: HandlerErrorContext) -> HandlerFailure { HandlerFailure::internal(self.to_string()) } } #[derive(Debug)] struct UiFailure; impl IntoHandlerFailure for UiFailure { fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure { HandlerFailure::effects(Slot::::new(3).text("try again"), context) } } #[tokio::test] async fn interaction_request_dispatches_async_typed_state_extractors() { // req: axum_integration/003 req: form/004 req: canonical_authoring/003 async fn open_project( State(multiplier): State, Form(input): Form, ) -> impl IntoEffect { Slot::::new(3).text(input.project_id.0 * multiplier) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(10), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch_async( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_async(Handle::<()>::new(10), open_project) .into_registry(), ) .await .unwrap(); assert_eq!(response.batch.ops, vec![Slot::::new(3).text(42)]); } #[tokio::test] async fn interaction_request_reports_async_result_handler_errors() { // req: axum_integration/003 req: form/004 req: failure/003 async fn open_project( State(_multiplier): State, Form(_input): Form, ) -> Result { Err::<(), _>(HandlerBoom) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(11), vec![("project_id".to_owned(), "21".to_owned())], )); let error = request .dispatch_async( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_async_result(Handle::<()>::new(11), open_project) .into_registry(), ) .await .unwrap_err(); assert_eq!( error, DispatchRejection::HandlerError(HandlerFailure::Response { status: axum::http::StatusCode::INTERNAL_SERVER_ERROR, message: "database unavailable".to_owned(), }) ); } #[tokio::test] async fn interaction_request_maps_async_result_errors_to_effects() { // req: axum_integration/003 req: failure/004 async fn open_project( State(_multiplier): State, Form(_input): Form, ) -> Result { Err::<(), _>(UiFailure) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(12), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch_async( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_async_result(Handle::<()>::new(12), open_project) .into_registry(), ) .await .unwrap(); assert_eq!( response.batch.ops, vec![Slot::::new(3).text("try again")] ); } #[test] fn interaction_request_maps_sync_result_errors_to_effects() { // req: axum_integration/003 req: failure/004 fn open_project( State(_multiplier): State, Form(_input): Form, ) -> Result { Err::<(), _>(UiFailure) } let request = InteractionRequest::from(InteractionForm::for_handle( Handle::<()>::new(13), vec![("project_id".to_owned(), "21".to_owned())], )); let response = request .dispatch( interactions(BuildFingerprint(4)) .with_state(2_u32) .on_result(Handle::<()>::new(13), open_project) .into_registry(), ) .unwrap(); assert_eq!( response.batch.ops, vec![Slot::::new(3).text("try again")] ); } #[test] fn interactions_dispatch_by_checked_handle() { // req: ceremony/004 req: public_api/001 let title = Slot::::new(7); let create = Handle::<()>::new(42); let request = InteractionRequest::from(InteractionForm::for_handle( create, [("title".into(), "Hello".into())], )); let response = request .dispatch(interactions(BuildFingerprint(123)).on(create, move |form| { title.text(form.value("title").unwrap_or("")) })) .unwrap(); assert_eq!(response.batch.fingerprint, BuildFingerprint(123)); assert_eq!(response.batch.ops, vec![title.text("Hello")]); } #[test] fn interactions_reject_unknown_handle_ids() { let request = InteractionRequest::from(InteractionForm::new(9, [])); assert_eq!( request .dispatch(interactions(BuildFingerprint(123))) .unwrap_err(), DispatchRejection::UnknownHandle(9) ); } #[test] fn runtime_js_response_serves_embedded_runtime() { let response = runtime_js().into_response(); assert_eq!( response.headers()[header::CONTENT_TYPE], HEMX_RUNTIME_CONTENT_TYPE ); assert_eq!( response.headers()[header::CACHE_CONTROL], "public, max-age=31536000, immutable" ); assert_eq!( response.headers()[header::ETAG], format!("\"{}\"", runtime_js_hash()) ); assert_eq!( response.headers()[header::CONTENT_LENGTH], runtime_js_source().len().to_string() ); } // req: axum_integration/005 #[test] fn runtime_js_path_is_content_hashed() { let path = runtime_js_path(); assert!(path.starts_with("/hemx.")); assert!(path.ends_with(".js")); assert!(path.contains(runtime_js_hash())); assert_eq!(runtime_js_script_src(), path); assert_eq!(runtime_js_route_path(), path); assert_eq!(runtime_js_hash().len(), 64); }