276f40ee59
Use a real enhanceable Settings URL, remove response-simulated navigation, and replace the one-shot public SSE endpoint with an initial canonical event plus ongoing server-owned updates. Keep a bounded one-event probe for production-reference tests. req: examples/014 req: nav/001 req: nav/002 req: nav/004 req: push/003
312 lines
12 KiB
Rust
312 lines
12 KiB
Rust
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: security/006
|
|
// test req: security/009 req: operations/001 req: operations/006 req: v1_release/003
|
|
let address = available_address();
|
|
let origin = format!("http://{address}");
|
|
let store = test_path("durable");
|
|
|
|
{
|
|
let _app = start(&address, &store);
|
|
let home = request(&address, "GET", "/", &[], "");
|
|
let csp = response_header(&home, "content-security-policy");
|
|
assert!(csp.contains("default-src 'self'"), "{csp}");
|
|
assert!(csp.contains("script-src 'self'"), "{csp}");
|
|
assert!(csp.contains("object-src 'none'"), "{csp}");
|
|
assert!(csp.contains("form-action 'self'"), "{csp}");
|
|
assert!(!csp.contains("unsafe-inline"), "{csp}");
|
|
assert!(!csp.contains("unsafe-eval"), "{csp}");
|
|
assert_eq!(response_header(&home, "x-content-type-options"), "nosniff");
|
|
assert_eq!(
|
|
response_header(&home, "referrer-policy"),
|
|
"strict-origin-when-cross-origin"
|
|
);
|
|
assert!(!home.contains("<script>"));
|
|
assert!(!home.contains("javascript:"));
|
|
assert!(
|
|
home.contains("href=\"/settings\" data-hemx-nav"),
|
|
"settings must remain a real, enhanceable link"
|
|
);
|
|
let settings = request(&address, "GET", "/settings", &[], "");
|
|
assert!(settings.starts_with("HTTP/1.1 200"), "{settings}");
|
|
assert!(settings.contains("<h2>Settings</h2>"), "{settings}");
|
|
assert!(settings.contains("explicit app integrations"), "{settings}");
|
|
|
|
let events = request(&address, "GET", "/events?once=1", &[], "");
|
|
assert!(events.starts_with("HTTP/1.1 200"), "{events}");
|
|
assert_eq!(
|
|
response_header(&events, "content-type"),
|
|
"text/event-stream"
|
|
);
|
|
assert!(events.contains("event: hemx"), "{events}");
|
|
assert!(events.contains("data:"), "{events}");
|
|
// test req: nav/001 req: nav/002 req: push/003 req: examples/014
|
|
|
|
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: operations/007 req: operations/008 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);
|
|
}
|