use hemx_test::TestProcess; use std::fs; use std::io::{Read, Write}; use std::net::{TcpListener, TcpStream}; use std::path::{Path, PathBuf}; use std::process::Command; use std::time::{Duration, SystemTime, UNIX_EPOCH}; const STARTUP_TIMEOUT: Duration = Duration::from_secs(12); fn available_address() -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("reserve test port"); let address = listener.local_addr().expect("test address"); drop(listener); address.to_string() } fn test_path(label: &str) -> PathBuf { let nonce = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock") .as_nanos(); std::env::temp_dir().join(format!("hemx-saas-{label}-{}-{nonce}", std::process::id())) } fn start(address: &str, store: &Path) -> TestProcess { let mut command = Command::new(env!("CARGO_BIN_EXE_hemx-saas-example")); command .env("HEMX_SAAS_ADDR", address) .env("HEMX_SAAS_STORE", store); TestProcess::start(command, "hemx-saas", address, STARTUP_TIMEOUT).expect("start SaaS app") } fn request( address: &str, method: &str, path: &str, headers: &[(&str, &str)], body: &str, ) -> String { let mut stream = TcpStream::connect(address).expect("connect to SaaS app"); write!( stream, "{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nContent-Length: {}\r\n", body.len() ) .expect("write request line"); for (name, value) in headers { write!(stream, "{name}: {value}\r\n").expect("write request header"); } write!(stream, "\r\n{body}").expect("finish request"); let mut response = String::new(); stream.read_to_string(&mut response).expect("read response"); response } fn create(address: &str, name: &str, bearer: &str, csrf: &str, origin: &str) -> String { create_at_version(address, name, bearer, csrf, origin, None) } fn create_at_version( address: &str, name: &str, bearer: &str, csrf: &str, origin: &str, fingerprint: Option<&str>, ) -> String { let mut headers = vec![ ("Authorization", bearer), ("Origin", origin), ("Content-Type", "application/x-www-form-urlencoded"), ]; if let Some(fingerprint) = fingerprint { headers.push(("x-hemx-fingerprint", fingerprint)); } request( address, "POST", "/projects", &headers, &format!("name={name}&csrf={csrf}"), ) } fn response_header<'a>(response: &'a str, name: &str) -> &'a str { response .lines() .find_map(|line| { let (header_name, value) = line.split_once(':')?; header_name.eq_ignore_ascii_case(name).then(|| value.trim()) }) .unwrap_or_else(|| panic!("missing {name} response header")) } fn ready_fingerprint(response: &str) -> &str { let marker = "\"fingerprint\":\""; let start = response.find(marker).expect("readiness fingerprint") + marker.len(); let end = response[start..].find('"').expect("fingerprint end") + start; &response[start..end] } #[test] fn authenticated_project_mutation_is_atomic_and_survives_restart() { // test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: operations/001 req: v1_release/003 let address = available_address(); let origin = format!("http://{address}"); let store = test_path("durable"); { let _app = start(&address, &store); let live = request(&address, "GET", "/health/live", &[], ""); assert!(live.starts_with("HTTP/1.1 200"), "{live}"); assert!(live.contains("{\"status\":\"live\"}"), "{live}"); let ready = request(&address, "GET", "/health/ready", &[], ""); assert!(ready.starts_with("HTTP/1.1 200"), "{ready}"); assert!(ready.contains("{\"status\":\"ready\","), "{ready}"); let denied_responses = [ create( &address, "DeniedAuth", "Bearer secret-auth-material", "demo-csrf", &origin, ), create( &address, "DeniedCsrf", "Bearer demo-session", "stale", &origin, ), create( &address, "DeniedOrigin", "Bearer demo-session", "demo-csrf", "https://attacker.invalid", ), ]; for denied in &denied_responses { assert!(denied.starts_with("HTTP/1.1 403"), "{denied}"); assert!(response_header(denied, "x-request-id").starts_with("req-")); assert!(denied.contains("{\"code\":\"authorization-denied\"}")); assert!(!denied.contains("Denied")); assert!(!denied.contains("demo-csrf")); assert!(!denied.contains("secret-auth-material")); assert!(!denied.contains("attacker.invalid")); } let wrong_content_type = request( &address, "POST", "/projects", &[ ("Authorization", "Bearer demo-session"), ("Origin", origin.as_str()), ("Content-Type", "text/plain"), ], "name=WrongType&csrf=demo-csrf", ); assert!( wrong_content_type.starts_with("HTTP/1.1 415"), "{wrong_content_type}" ); let oversized = request( &address, "POST", "/projects", &[ ("Authorization", "Bearer demo-session"), ("Origin", origin.as_str()), ("Content-Type", "application/x-www-form-urlencoded"), ], &format!("name={}&csrf=demo-csrf", "x".repeat(9 * 1024)), ); assert!(oversized.starts_with("HTTP/1.1 413"), "{oversized}"); let before = request(&address, "GET", "/", &[], ""); assert!(!before.contains("DeniedAuth")); assert!(!before.contains("DeniedCsrf")); assert!(!before.contains("DeniedOrigin")); assert!(!before.contains("WrongType")); let denied_metrics = request(&address, "GET", "/metrics", &[], ""); assert!( denied_metrics.starts_with("HTTP/1.1 200"), "{denied_metrics}" ); assert!( denied_metrics.contains("\"attempts\":3"), "{denied_metrics}" ); assert!(denied_metrics.contains("\"denied\":3"), "{denied_metrics}"); assert!(!denied_metrics.contains("Denied")); assert!(!denied_metrics.contains("demo-csrf")); assert!(!denied_metrics.contains("secret-auth-material")); let stale = create_at_version( &address, "Stale%20Project", "Bearer demo-session", "demo-csrf", &origin, Some("0"), ); assert!(stale.starts_with("HTTP/1.1 409"), "{stale}"); assert!(response_header(&stale, "x-request-id").starts_with("req-")); assert!(stale.contains("{\"code\":\"deployment-mismatch\"}")); assert!(stale .to_ascii_lowercase() .contains("x-hemx-recovery: reload")); assert!(!request(&address, "GET", "/", &[], "").contains("Stale Project")); let fingerprint = ready_fingerprint(&ready); let allowed = create_at_version( &address, "Durable%20Project", "Bearer demo-session", "demo-csrf", &origin, Some(fingerprint), ); assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}"); let allowed_request_id = response_header(&allowed, "x-request-id"); assert!(allowed_request_id.starts_with("req-")); assert_ne!(allowed_request_id, response_header(&stale, "x-request-id")); assert!(request(&address, "GET", "/", &[], "").contains("Durable Project")); let metrics = request(&address, "GET", "/metrics", &[], ""); assert!(metrics.contains("\"attempts\":5"), "{metrics}"); assert!(metrics.contains("\"succeeded\":1"), "{metrics}"); assert!(metrics.contains("\"mismatch\":1"), "{metrics}"); } { let _restarted = start(&address, &store); let restored = request(&address, "GET", "/", &[], ""); assert!(restored.contains("Durable Project"), "{restored}"); assert!(restored.contains("1 project"), "{restored}"); } let _ = fs::remove_file(store); } #[test] fn failed_durable_commit_rolls_back_visible_state() { // test req: failure/004 req: operations/002 req: v1_release/003 let address = available_address(); let origin = format!("http://{address}"); let store = test_path("rollback"); let _app = start(&address, &store); fs::create_dir(&store).expect("block atomic rename destination"); let not_ready = request(&address, "GET", "/health/ready", &[], ""); assert!(not_ready.starts_with("HTTP/1.1 503"), "{not_ready}"); assert!(not_ready.contains("\"code\":\"storage-unavailable\"")); assert!(request(&address, "GET", "/health/live", &[], "").starts_with("HTTP/1.1 200")); let rejected = create( &address, "Must%20Rollback", "Bearer demo-session", "demo-csrf", &origin, ); assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}"); assert!(response_header(&rejected, "x-request-id").starts_with("req-")); assert!(rejected.contains("{\"code\":\"storage-unavailable\"}")); assert!(!rejected.contains("Must Rollback")); assert!(!rejected.contains("demo-csrf")); assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback")); assert!(!store.with_extension("tmp").exists()); let metrics = request(&address, "GET", "/metrics", &[], ""); assert!(metrics.contains("\"attempts\":1"), "{metrics}"); assert!(metrics.contains("\"failed\":1"), "{metrics}"); assert!(!metrics.contains("Must Rollback")); let _ = fs::remove_dir(store); }