use std::io::{Read, Write};
use std::net::{SocketAddr, TcpStream};
use std::sync::{
atomic::{AtomicBool, Ordering},
Arc, Mutex,
};
use std::thread;
use std::time::Duration;
use lex_api::handlers::{handle_with_auth, State};
use tempfile::TempDir;
#[test]
fn new_with_tenant_opens_under_prefix() {
let tmp = TempDir::new().unwrap();
let _state =
State::new_with_tenant("acme", tmp.path().to_path_buf()).expect("valid tenant id");
assert!(
tmp.path().join("acme/stages").is_dir(),
"tenant store should be initialised at <store_root>/<tenant_id>/"
);
assert!(
!tmp.path().join("stages").exists(),
"store root should not be polluted at the parent level"
);
}
#[test]
fn new_with_tenant_isolates_two_tenants() {
let tmp = TempDir::new().unwrap();
let _a = State::new_with_tenant("alpha", tmp.path().to_path_buf()).unwrap();
let _b = State::new_with_tenant("beta", tmp.path().to_path_buf()).unwrap();
assert!(tmp.path().join("alpha/stages").is_dir());
assert!(tmp.path().join("beta/stages").is_dir());
}
#[test]
fn new_with_tenant_rejects_unsafe_ids() {
let tmp = TempDir::new().unwrap();
let bad = [
"",
"..",
"../foo",
"/etc",
"foo/bar",
"foo\\bar",
".",
".hidden",
"a\0b",
];
for id in &bad {
let r = State::new_with_tenant(id, tmp.path().to_path_buf());
assert!(r.is_err(), "tenant_id {id:?} should be rejected");
}
let too_long = "a".repeat(65);
assert!(State::new_with_tenant(&too_long, tmp.path().to_path_buf()).is_err());
let at_limit = "a".repeat(64);
assert!(State::new_with_tenant(&at_limit, tmp.path().to_path_buf()).is_ok());
}
fn http(addr: &SocketAddr, path: &str, header: Option<(&str, &str)>) -> (u16, String) {
let mut s = TcpStream::connect(addr).unwrap();
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
let extra = match header {
Some((k, v)) => format!("{k}: {v}\r\n"),
None => String::new(),
};
let req = format!(
"GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\n{extra}Connection: close\r\n\r\n"
);
s.write_all(req.as_bytes()).unwrap();
let mut buf = String::new();
s.read_to_string(&mut buf).unwrap();
let (head, body) = buf.split_once("\r\n\r\n").unwrap_or((&buf, ""));
let status = head
.split_whitespace()
.nth(1)
.unwrap_or("0")
.parse()
.unwrap_or(0);
(status, body.to_string())
}
struct AuthServer {
addr: SocketAddr,
stop: Arc<AtomicBool>,
seen: Arc<Mutex<Vec<(String, bool)>>>,
}
fn start_auth_server(expected_token: &'static str) -> AuthServer {
let tmp = TempDir::new().unwrap();
let server = tiny_http::Server::http(("127.0.0.1", 0)).unwrap();
let addr = match server.server_addr() {
tiny_http::ListenAddr::IP(a) => a,
_ => panic!("expected IP listener"),
};
let state = Arc::new(State::open(tmp.path().to_path_buf()).unwrap());
let stop = Arc::new(AtomicBool::new(false));
let seen: Arc<Mutex<Vec<(String, bool)>>> = Arc::new(Mutex::new(Vec::new()));
let stop_for_thread = Arc::clone(&stop);
let seen_for_thread = Arc::clone(&seen);
thread::spawn(move || {
let _hold = tmp;
for request in server.incoming_requests() {
if stop_for_thread.load(Ordering::Relaxed) {
break;
}
let state = Arc::clone(&state);
let seen = Arc::clone(&seen_for_thread);
let _ = handle_with_auth(state, request, move |path, headers| {
let ok = headers
.iter()
.any(|h| h.field.as_str().as_str() == "X-Auth"
&& h.value.as_str() == expected_token);
seen.lock().unwrap().push((path.to_string(), ok));
ok
});
}
});
thread::sleep(Duration::from_millis(20));
AuthServer { addr, stop, seen }
}
#[test]
fn handle_with_auth_returns_401_without_token() {
let srv = start_auth_server("secret");
let (status, body) = http(&srv.addr, "/v1/health", None);
assert_eq!(status, 401, "missing token should 401");
assert!(
body.contains("\"error\":\"unauthorized\""),
"401 body shape: {body:?}"
);
let seen = srv.seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert_eq!(seen[0].0, "/v1/health");
assert!(!seen[0].1, "auth closure should have returned false");
srv.stop.store(true, Ordering::Relaxed);
}
#[test]
fn handle_with_auth_passes_through_with_token() {
let srv = start_auth_server("secret");
let (status, body) = http(&srv.addr, "/v1/health", Some(("X-Auth", "secret")));
assert_eq!(status, 200, "valid token should pass through, got: {body}");
assert!(body.contains("\"ok\":true"), "health body: {body:?}");
let seen = srv.seen.lock().unwrap();
assert_eq!(seen.len(), 1);
assert!(seen[0].1);
srv.stop.store(true, Ordering::Relaxed);
}
#[test]
fn handle_with_auth_strips_query_string_before_auth() {
let srv = start_auth_server("secret");
let (_status, _body) = http(&srv.addr, "/v1/health?token=oops", None);
let seen = srv.seen.lock().unwrap();
assert_eq!(seen[0].0, "/v1/health", "auth path should not include query string");
srv.stop.store(true, Ordering::Relaxed);
}