feat(saas): prove durable authenticated mutation
req: auth/001 req: auth/002 req: auth/004 req: security/004 req: v1_release/003
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user