use std::time::Duration;
use openlatch_client::{config::Config, daemon};
use tokio::net::TcpListener;
struct TestDaemon {
port: u16,
token: String,
client: reqwest::Client,
_temp_dir: tempfile::TempDir,
log_dir: std::path::PathBuf,
}
impl TestDaemon {
fn base_url(&self) -> String {
format!("http://127.0.0.1:{}", self.port)
}
fn url(&self, path: &str) -> String {
format!("{}{}", self.base_url(), path)
}
fn auth_header(&self) -> String {
format!("Bearer {}", self.token)
}
}
async fn start_test_daemon() -> TestDaemon {
let temp_dir = tempfile::tempdir().expect("tempdir must be created");
let log_dir = temp_dir.path().join("logs");
std::fs::create_dir_all(&log_dir).expect("log dir must be created");
let token = "test-integration-token-abcdef1234567890".to_string();
let cfg = Config {
port: 0, log_dir: log_dir.clone(),
log_level: "info".into(),
retention_days: 30,
extra_patterns: vec![],
foreground: true,
update: openlatch_client::config::UpdateConfig::default(),
};
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("bind to port 0 must succeed");
let port = listener
.local_addr()
.expect("local_addr must return address")
.port();
let token_clone = token.clone();
tokio::spawn(async move {
daemon::start_server_with_listener(listener, cfg, token_clone)
.await
.ok(); });
tokio::time::sleep(Duration::from_millis(50)).await;
TestDaemon {
port,
token,
client: reqwest::Client::new(),
_temp_dir: temp_dir,
log_dir,
}
}
fn test_event() -> serde_json::Value {
serde_json::json!({
"session_id": "test-session-abc",
"tool_name": "bash",
"tool_input": {
"command": "ls -la"
}
})
}
#[tokio::test]
async fn test_health_returns_200_with_status_ok() {
let daemon = start_test_daemon().await;
let resp = daemon
.client
.get(daemon.url("/health"))
.send()
.await
.expect("GET /health must succeed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("health body must be JSON");
assert_eq!(body["status"], "ok", "status field must be 'ok'");
assert!(body["version"].is_string(), "version field must be present");
assert!(
body["uptime_secs"].is_number(),
"uptime_secs field must be a number"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_hook_without_token_returns_401() {
let daemon = start_test_daemon().await;
let resp = daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.json(&test_event())
.send()
.await
.expect("POST without token must get a response");
assert_eq!(
resp.status(),
401,
"missing auth header must return 401 Unauthorized"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_hook_with_invalid_token_returns_401() {
let daemon = start_test_daemon().await;
let resp = daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", "Bearer wrong-token-xyz")
.json(&test_event())
.send()
.await
.expect("POST with wrong token must get a response");
assert_eq!(
resp.status(),
401,
"invalid bearer token must return 401 Unauthorized"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_pre_tool_use_with_valid_token_returns_allow() {
let daemon = start_test_daemon().await;
let resp = daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", daemon.auth_header())
.json(&test_event())
.send()
.await
.expect("POST pre-tool-use must succeed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("body must be JSON");
assert_eq!(
body["verdict"], "allow",
"pre-tool-use verdict must be 'allow'"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_user_prompt_submit_returns_allow() {
let daemon = start_test_daemon().await;
let event = serde_json::json!({
"session_id": "test-session-prompt",
"user_prompt": "What is the capital of France?"
});
let resp = daemon
.client
.post(daemon.url("/hooks/user-prompt-submit"))
.header("Authorization", daemon.auth_header())
.json(&event)
.send()
.await
.expect("POST user-prompt-submit must succeed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("body must be JSON");
assert_eq!(
body["verdict"], "allow",
"user-prompt-submit verdict must be 'allow'"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_stop_hook_returns_approve() {
let daemon = start_test_daemon().await;
let event = serde_json::json!({
"session_id": "test-session-stop"
});
let resp = daemon
.client
.post(daemon.url("/hooks/stop"))
.header("Authorization", daemon.auth_header())
.json(&event)
.send()
.await
.expect("POST /hooks/stop must succeed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("body must be JSON");
assert_eq!(
body["verdict"], "approve",
"stop hook verdict must be 'approve'"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_duplicate_event_returns_dedup_header() {
let daemon = start_test_daemon().await;
let event = serde_json::json!({
"session_id": "test-session-dedup",
"tool_name": "bash",
"tool_input": {"command": "echo hello"}
});
let resp1 = daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", daemon.auth_header())
.json(&event)
.send()
.await
.expect("first POST must succeed");
assert_eq!(resp1.status(), 200);
assert!(
resp1.headers().get("x-openlatch-dedup").is_none(),
"first request must NOT have dedup header"
);
let resp2 = daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", daemon.auth_header())
.json(&event)
.send()
.await
.expect("second POST must succeed");
assert_eq!(resp2.status(), 200);
assert_eq!(
resp2
.headers()
.get("x-openlatch-dedup")
.and_then(|v| v.to_str().ok()),
Some("true"),
"second identical request must have X-OpenLatch-Dedup: true header"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_event_written_to_jsonl_log() {
let daemon = start_test_daemon().await;
let event = serde_json::json!({
"session_id": "test-session-log",
"tool_name": "file_read",
"tool_input": {"path": "/etc/hosts"}
});
daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", daemon.auth_header())
.json(&event)
.send()
.await
.expect("POST must succeed");
tokio::time::sleep(Duration::from_millis(100)).await;
let log_files: Vec<_> = std::fs::read_dir(&daemon.log_dir)
.expect("log dir must be readable")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with("events-") && n.ends_with(".jsonl"))
.unwrap_or(false)
})
.collect();
assert!(
!log_files.is_empty(),
"at least one events-*.jsonl file must exist after sending an event"
);
let log_path = log_files[0].path();
let contents = std::fs::read_to_string(&log_path).expect("log file must be readable");
assert!(
!contents.is_empty(),
"log file must contain at least one entry"
);
let found = contents.lines().any(|line| {
if let Ok(entry) = serde_json::from_str::<serde_json::Value>(line) {
entry["session_id"] == "test-session-log"
} else {
false
}
});
assert!(
found,
"log must contain entry with session_id 'test-session-log'"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_log_entry_has_masked_aws_key() {
let daemon = start_test_daemon().await;
let raw_key = "AKIAIOSFODNN7EXAMPLE";
let event = serde_json::json!({
"session_id": "test-session-mask",
"tool_name": "bash",
"tool_input": {
"command": "export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE"
}
});
daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", daemon.auth_header())
.json(&event)
.send()
.await
.expect("POST must succeed");
tokio::time::sleep(Duration::from_millis(100)).await;
let log_files: Vec<_> = std::fs::read_dir(&daemon.log_dir)
.expect("log dir must be readable")
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_str()
.map(|n| n.starts_with("events-") && n.ends_with(".jsonl"))
.unwrap_or(false)
})
.collect();
assert!(
!log_files.is_empty(),
"log file must exist after sending event with AWS key"
);
let contents = std::fs::read_to_string(log_files[0].path()).expect("log file readable");
assert!(
!contents.contains(raw_key),
"log must NOT contain raw AWS key '{}' — should be masked",
raw_key
);
assert!(
contents.contains("AKIA"),
"log must contain masked AWS key with AKIA prefix preserved"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_metrics_returns_event_count() {
let daemon = start_test_daemon().await;
for _ in 0..2 {
let event = serde_json::json!({
"session_id": "test-session-metrics",
"tool_name": "tool_a",
"tool_input": {}
});
daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", daemon.auth_header())
.json(&event)
.send()
.await
.expect("POST must succeed");
}
let resp = daemon
.client
.get(daemon.url("/metrics"))
.send()
.await
.expect("GET /metrics must succeed");
assert_eq!(resp.status(), 200);
let body: serde_json::Value = resp.json().await.expect("metrics body must be JSON");
assert!(
body["events_processed"].is_number(),
"events_processed must be a number"
);
assert_eq!(
body["events_processed"].as_u64().unwrap_or(0),
1,
"second identical event must be deduped — only 1 should be processed"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}
#[tokio::test]
async fn test_shutdown_endpoint_stops_daemon() {
let daemon = start_test_daemon().await;
let resp = daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.expect("POST /shutdown must get a response");
assert_eq!(resp.status(), 200, "shutdown endpoint must return 200 OK");
tokio::time::sleep(Duration::from_millis(200)).await;
let post_shutdown = daemon.client.get(daemon.url("/health")).send().await;
assert!(
post_shutdown.is_err(),
"GET /health after shutdown must fail — daemon is no longer listening"
);
}
#[tokio::test]
async fn test_oversized_body_returns_413() {
let daemon = start_test_daemon().await;
let oversized = "x".repeat(1_100_000);
let body = format!("{{\"data\": \"{}\"}}", oversized);
let resp = daemon
.client
.post(daemon.url("/hooks/pre-tool-use"))
.header("Authorization", daemon.auth_header())
.header("Content-Type", "application/json")
.body(body)
.send()
.await
.expect("oversized POST must get a response");
assert_eq!(
resp.status(),
413,
"body over 1MB must return 413 Payload Too Large"
);
daemon
.client
.post(daemon.url("/shutdown"))
.header("Authorization", daemon.auth_header())
.send()
.await
.ok();
}