//! Cookie-aware in-process HTTP client. //! //! Wraps an Axum `Router` and uses `tower::ServiceExt::oneshot` for each //! request. Manages cookies across requests and auto-injects CSRF tokens. use axum::body::Body; use axum::http::{header, Method, Request, StatusCode}; use axum::Router; use http_body_util::BodyExt; use std::collections::HashMap; use std::sync::atomic::{AtomicU32, Ordering}; use tower::ServiceExt; /// Monotonic counter for unique per-test IPs (10.1.x.y). static IP_COUNTER: AtomicU32 = AtomicU32::new(1); /// A test HTTP client that talks to the app in-process. pub struct TestClient { app: Router, cookies: HashMap, csrf_token: Option, forwarded_ip: String, bearer_token: Option, } impl TestClient { pub fn new(app: Router) -> Self { let n = IP_COUNTER.fetch_add(1, Ordering::Relaxed); let octet3 = (n / 256) % 256; let octet4 = n % 256; TestClient { app, cookies: HashMap::new(), csrf_token: None, forwarded_ip: format!("10.1.{}.{}", octet3, octet4), bearer_token: None, } } /// Set the IP address used in the X-Forwarded-For header. #[allow(dead_code)] pub fn set_forwarded_ip(&mut self, ip: &str) { self.forwarded_ip = ip.to_string(); } /// Set a bearer token for subsequent requests (used by SyncKit JWT auth). #[allow(dead_code)] pub fn set_bearer_token(&mut self, token: &str) { self.bearer_token = Some(token.to_string()); } /// Clear the bearer token. #[allow(dead_code)] pub fn clear_bearer_token(&mut self) { self.bearer_token = None; } /// Access the current CSRF token (if any). #[allow(dead_code)] pub fn csrf_token(&self) -> Option<&str> { self.csrf_token.as_deref() } /// GET request. pub async fn get(&mut self, uri: &str) -> TestResponse { self.request(Method::GET, uri, None, None).await } /// POST with form-encoded body. pub async fn post_form(&mut self, uri: &str, body: &str) -> TestResponse { self.request( Method::POST, uri, Some("application/x-www-form-urlencoded"), Some(body.to_string()), ) .await } /// POST with JSON body. #[allow(dead_code)] pub async fn post_json(&mut self, uri: &str, body: &str) -> TestResponse { self.request(Method::POST, uri, Some("application/json"), Some(body.to_string())) .await } /// PUT with form-encoded body. pub async fn put_form(&mut self, uri: &str, body: &str) -> TestResponse { self.request( Method::PUT, uri, Some("application/x-www-form-urlencoded"), Some(body.to_string()), ) .await } /// PUT with JSON body. #[allow(dead_code)] pub async fn put_json(&mut self, uri: &str, body: &str) -> TestResponse { self.request(Method::PUT, uri, Some("application/json"), Some(body.to_string())) .await } /// DELETE request. #[allow(dead_code)] pub async fn delete(&mut self, uri: &str) -> TestResponse { self.request(Method::DELETE, uri, None, None).await } /// PATCH with JSON body. #[allow(dead_code)] pub async fn patch_json(&mut self, uri: &str, body: &str) -> TestResponse { self.request(Method::PATCH, uri, Some("application/json"), Some(body.to_string())) .await } /// DELETE with form-encoded body. #[allow(dead_code)] pub async fn delete_form(&mut self, uri: &str, body: &str) -> TestResponse { self.request( Method::DELETE, uri, Some("application/x-www-form-urlencoded"), Some(body.to_string()), ) .await } /// POST with multipart/form-data body. Fields are (name, value) pairs. /// Supports repeated field names for Vec deserialization. #[allow(dead_code)] pub async fn post_multipart(&mut self, uri: &str, fields: &[(&str, &str)]) -> TestResponse { let boundary = "----TestBoundary7MA4YWxkTrZu0gW"; let mut body = String::new(); for (name, value) in fields { body.push_str(&format!("--{}\r\n", boundary)); body.push_str(&format!( "Content-Disposition: form-data; name=\"{}\"\r\n\r\n{}\r\n", name, value )); } body.push_str(&format!("--{}--\r\n", boundary)); let content_type = format!("multipart/form-data; boundary={}", boundary); self.request(Method::POST, uri, Some(&content_type), Some(body)) .await } /// HTMX GET request (includes `HX-Request: true` header). #[allow(dead_code)] pub async fn htmx_get(&mut self, uri: &str) -> TestResponse { self.request_htmx(Method::GET, uri, None, None).await } /// HTMX POST with form-encoded body. #[allow(dead_code)] pub async fn htmx_post_form(&mut self, uri: &str, body: &str) -> TestResponse { self.request_htmx( Method::POST, uri, Some("application/x-www-form-urlencoded"), Some(body.to_string()), ) .await } /// HTMX PUT with form-encoded body. #[allow(dead_code)] pub async fn htmx_put_form(&mut self, uri: &str, body: &str) -> TestResponse { self.request_htmx( Method::PUT, uri, Some("application/x-www-form-urlencoded"), Some(body.to_string()), ) .await } /// HTMX DELETE request (includes `HX-Request: true` header). #[allow(dead_code)] pub async fn htmx_delete(&mut self, uri: &str) -> TestResponse { self.request_htmx(Method::DELETE, uri, None, None).await } /// Fetch the CSRF token by loading the /login page and extracting it /// from ``. pub async fn fetch_csrf_token(&mut self) { let resp = self.get("/login").await; if let Some(token) = extract_csrf_from_html(&resp.text) { self.csrf_token = Some(token); } } /// Build and send a regular request (no HTMX header). async fn request( &mut self, method: Method, uri: &str, content_type: Option<&str>, body: Option, ) -> TestResponse { self.send(method, uri, content_type, body, false).await } /// Build and send a request with the `HX-Request: true` header. #[allow(dead_code)] async fn request_htmx( &mut self, method: Method, uri: &str, content_type: Option<&str>, body: Option, ) -> TestResponse { self.send(method, uri, content_type, body, true).await } /// Build and send a request through `oneshot`, optionally with the HTMX header. async fn send( &mut self, method: Method, uri: &str, content_type: Option<&str>, body: Option, htmx: bool, ) -> TestResponse { let body_data = body.unwrap_or_default(); let mut builder = Request::builder() .method(&method) .uri(uri) // Required: SmartIpKeyExtractor needs an IP; oneshot has no ConnectInfo .header("X-Forwarded-For", &self.forwarded_ip) // Production reads `CF-Connecting-IP` (the only header origin clients // can't spoof through Caddy). Send both so the harness matches the // production proxy chain and `extract_client_ip` finds a value. .header("CF-Connecting-IP", &self.forwarded_ip); if htmx { builder = builder.header("HX-Request", "true"); } // Inject bearer token if set if let Some(ref token) = self.bearer_token { builder = builder.header(header::AUTHORIZATION, format!("Bearer {}", token)); } // Set content type if let Some(ct) = content_type { builder = builder.header(header::CONTENT_TYPE, ct); } // Inject CSRF token for mutating methods if matches!(method, Method::POST | Method::PUT | Method::PATCH | Method::DELETE) && let Some(ref token) = self.csrf_token { builder = builder.header("X-CSRF-Token", token.as_str()); } // Attach cookies if !self.cookies.is_empty() { let cookie_header: String = self .cookies .iter() .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("; "); builder = builder.header(header::COOKIE, cookie_header); } let request = builder.body(Body::from(body_data)).expect("Failed to build request"); let response = self .app .clone() .oneshot(request) .await .expect("Failed to send request"); // Collect set-cookie headers before consuming response let status = response.status(); let headers = response.headers().clone(); // Store cookies from response for value in headers.get_all(header::SET_COOKIE) { if let Ok(cookie_str) = value.to_str() { // Parse "name=value; ..." — take only the name=value part if let Some(nv) = cookie_str.split(';').next() && let Some((name, val)) = nv.split_once('=') { self.cookies .insert(name.trim().to_string(), val.trim().to_string()); } } } // Read body let body_bytes = response .into_body() .collect() .await .expect("Failed to read response body") .to_bytes(); let text = String::from_utf8_lossy(&body_bytes).to_string(); // Auto-extract CSRF token from HTML responses for convenience if let Some(token) = extract_csrf_from_html(&text) { self.csrf_token = Some(token); } TestResponse { status, text, headers, } } /// Raw request with custom headers. Used for webhook tests where we need /// to set the stripe-signature header and bypass CSRF. #[allow(dead_code)] pub async fn request_with_headers( &mut self, method: &str, uri: &str, body: Option<&str>, extra_headers: &[(&str, &str)], ) -> TestResponse { let body_data = body.unwrap_or_default().to_string(); let mut builder = Request::builder() .method(method) .uri(uri) .header("X-Forwarded-For", &self.forwarded_ip) // Production reads `CF-Connecting-IP` (the only header origin clients // can't spoof through Caddy). Send both so the harness matches the // production proxy chain and `extract_client_ip` finds a value. .header("CF-Connecting-IP", &self.forwarded_ip); for (name, value) in extra_headers { builder = builder.header(*name, *value); } // Attach cookies if !self.cookies.is_empty() { let cookie_header: String = self .cookies .iter() .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("; "); builder = builder.header(header::COOKIE, cookie_header); } let request = builder.body(Body::from(body_data)).expect("Failed to build request"); let response = self .app .clone() .oneshot(request) .await .expect("Failed to send request"); let status = response.status(); let resp_headers = response.headers().clone(); for value in resp_headers.get_all(header::SET_COOKIE) { if let Ok(cookie_str) = value.to_str() && let Some(nv) = cookie_str.split(';').next() && let Some((name, val)) = nv.split_once('=') { self.cookies .insert(name.trim().to_string(), val.trim().to_string()); } } let body_bytes = response .into_body() .collect() .await .expect("Failed to read response body") .to_bytes(); let text = String::from_utf8_lossy(&body_bytes).to_string(); TestResponse { status, text, headers: resp_headers, } } /// Send a GET request and return status + headers WITHOUT reading the body. /// Use for streaming endpoints (SSE) where the body never ends. #[allow(dead_code)] pub async fn get_streaming(&mut self, uri: &str) -> TestResponse { let mut builder = Request::builder() .method(Method::GET) .uri(uri) .header("X-Forwarded-For", &self.forwarded_ip) // Production reads `CF-Connecting-IP` (the only header origin clients // can't spoof through Caddy). Send both so the harness matches the // production proxy chain and `extract_client_ip` finds a value. .header("CF-Connecting-IP", &self.forwarded_ip); if let Some(ref token) = self.bearer_token { builder = builder.header(header::AUTHORIZATION, format!("Bearer {}", token)); } if !self.cookies.is_empty() { let cookie_header: String = self .cookies .iter() .map(|(k, v)| format!("{}={}", k, v)) .collect::>() .join("; "); builder = builder.header(header::COOKIE, cookie_header); } let request = builder.body(Body::empty()).expect("Failed to build request"); let response = self .app .clone() .oneshot(request) .await .expect("Failed to send request"); let status = response.status(); let headers = response.headers().clone(); // Do NOT read the body — return immediately TestResponse { status, text: String::new(), headers, } } } /// Response wrapper with convenience methods. #[allow(dead_code)] pub struct TestResponse { pub status: StatusCode, pub text: String, pub headers: axum::http::HeaderMap, } #[allow(dead_code)] impl TestResponse { /// Parse the body as JSON. pub fn json(&self) -> T { serde_json::from_str(&self.text) .unwrap_or_else(|e| panic!("Failed to parse JSON: {}\nBody: {}", e, &self.text)) } /// Get a header value as a string. pub fn header(&self, name: &str) -> Option<&str> { self.headers.get(name).and_then(|v| v.to_str().ok()) } } /// Extract CSRF token from ``. fn extract_csrf_from_html(html: &str) -> Option { let marker = "csrf-token\" content=\""; let start = html.find(marker)? + marker.len(); let end = html[start..].find('"')? + start; Some(html[start..end].to_string()) }