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 { request( address, "POST", "/projects", &[ ("Authorization", bearer), ("Origin", origin), ("Content-Type", "application/x-www-form-urlencoded"), ], &format!("name={name}&csrf={csrf}"), ) } #[test] fn authenticated_project_mutation_is_atomic_and_survives_restart() { // test req: auth/001 req: auth/002 req: auth/004 req: security/004 req: v1_release/003 let address = available_address(); let origin = format!("http://{address}"); let store = test_path("durable"); { let _app = start(&address, &store); for denied in [ create(&address, "DeniedAuth", "Bearer wrong", "demo-csrf", &origin), create( &address, "DeniedCsrf", "Bearer demo-session", "stale", &origin, ), create( &address, "DeniedOrigin", "Bearer demo-session", "demo-csrf", "https://attacker.invalid", ), ] { assert!(denied.starts_with("HTTP/1.1 403"), "{denied}"); } 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 allowed = create( &address, "Durable%20Project", "Bearer demo-session", "demo-csrf", &origin, ); assert!(allowed.starts_with("HTTP/1.1 303"), "{allowed}"); assert!(request(&address, "GET", "/", &[], "").contains("Durable Project")); } { 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 rejected = create( &address, "Must%20Rollback", "Bearer demo-session", "demo-csrf", &origin, ); assert!(rejected.starts_with("HTTP/1.1 503"), "{rejected}"); assert!(!request(&address, "GET", "/", &[], "").contains("Must Rollback")); assert!(!store.with_extension("tmp").exists()); let _ = fs::remove_dir(store); }