feat: API endpoint tests, coverage target
- 8 new axum handler tests: health, stats, query-log, overrides CRUD, cache, blocking stats, services CRUD, dashboard HTML - Tests use tower::oneshot — no network, no server startup - test_ctx() builds minimal ServerCtx for isolated testing - `make coverage` target (cargo-tarpaulin), separate from `make all` - 82 total tests (was 74) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1148,6 +1148,7 @@ dependencies = [
|
|||||||
"criterion",
|
"criterion",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
"futures",
|
"futures",
|
||||||
|
"http",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
@@ -1163,6 +1164,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"toml",
|
"toml",
|
||||||
|
"tower",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ ring = "0.17"
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
criterion = { version = "0.5", features = ["html_reports"] }
|
criterion = { version = "0.5", features = ["html_reports"] }
|
||||||
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
http = "1"
|
||||||
|
|
||||||
[[bench]]
|
[[bench]]
|
||||||
name = "hot_path"
|
name = "hot_path"
|
||||||
|
|||||||
5
Makefile
5
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all build lint fmt check audit test bench clean deploy blog
|
.PHONY: all build lint fmt check audit test coverage bench clean deploy blog
|
||||||
|
|
||||||
all: lint build test
|
all: lint build test
|
||||||
|
|
||||||
@@ -19,6 +19,9 @@ audit:
|
|||||||
test:
|
test:
|
||||||
cargo test
|
cargo test
|
||||||
|
|
||||||
|
coverage:
|
||||||
|
cargo tarpaulin --skip-clean --out stdout
|
||||||
|
|
||||||
bench:
|
bench:
|
||||||
cargo bench
|
cargo bench
|
||||||
|
|
||||||
|
|||||||
249
src/api.rs
249
src/api.rs
@@ -909,3 +909,252 @@ async fn check_tcp(addr: std::net::SocketAddr) -> bool {
|
|||||||
.map(|r| r.is_ok())
|
.map(|r| r.is_ok())
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::body::Body;
|
||||||
|
use http::Request;
|
||||||
|
use std::sync::{Mutex, RwLock};
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
async fn test_ctx() -> Arc<ServerCtx> {
|
||||||
|
let socket = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||||
|
Arc::new(ServerCtx {
|
||||||
|
socket,
|
||||||
|
zone_map: std::collections::HashMap::new(),
|
||||||
|
cache: RwLock::new(crate::cache::DnsCache::new(100, 60, 86400)),
|
||||||
|
stats: Mutex::new(crate::stats::ServerStats::new()),
|
||||||
|
overrides: RwLock::new(crate::override_store::OverrideStore::new()),
|
||||||
|
blocklist: RwLock::new(crate::blocklist::BlocklistStore::new()),
|
||||||
|
query_log: Mutex::new(crate::query_log::QueryLog::new(100)),
|
||||||
|
services: Mutex::new(crate::service_store::ServiceStore::new()),
|
||||||
|
lan_peers: Mutex::new(crate::lan::PeerStore::new(90)),
|
||||||
|
forwarding_rules: Vec::new(),
|
||||||
|
upstream: Mutex::new(crate::forward::Upstream::Udp(
|
||||||
|
"127.0.0.1:53".parse().unwrap(),
|
||||||
|
)),
|
||||||
|
upstream_auto: false,
|
||||||
|
upstream_port: 53,
|
||||||
|
lan_ip: Mutex::new(std::net::Ipv4Addr::LOCALHOST),
|
||||||
|
timeout: std::time::Duration::from_secs(3),
|
||||||
|
proxy_tld: "numa".to_string(),
|
||||||
|
proxy_tld_suffix: ".numa".to_string(),
|
||||||
|
lan_enabled: false,
|
||||||
|
config_path: "/tmp/test-numa.toml".to_string(),
|
||||||
|
config_found: false,
|
||||||
|
config_dir: std::path::PathBuf::from("/tmp"),
|
||||||
|
data_dir: std::path::PathBuf::from("/tmp"),
|
||||||
|
tls_config: None,
|
||||||
|
upstream_mode: crate::config::UpstreamMode::Forward,
|
||||||
|
root_hints: Vec::new(),
|
||||||
|
dnssec_enabled: false,
|
||||||
|
dnssec_strict: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn health_returns_ok() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let resp = router(ctx)
|
||||||
|
.oneshot(Request::get("/health").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 1000).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(json["status"], "ok");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn stats_returns_json() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let resp = router(ctx)
|
||||||
|
.oneshot(Request::get("/stats").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["uptime_secs"].is_number());
|
||||||
|
assert!(json["queries"]["total"].is_number());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn query_log_empty() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let resp = router(ctx)
|
||||||
|
.oneshot(
|
||||||
|
Request::get("/query-log?limit=10")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json.as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn overrides_crud() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let a = router(ctx.clone());
|
||||||
|
|
||||||
|
// Create
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::post("/overrides")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"domain":"test.dev","target":"1.2.3.4","duration_secs":60}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_success());
|
||||||
|
|
||||||
|
// List
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::get("/overrides").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&body).contains("test.dev"));
|
||||||
|
|
||||||
|
// Get
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::get("/overrides/test.dev")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::delete("/overrides/test.dev")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_success());
|
||||||
|
|
||||||
|
// Verify deleted
|
||||||
|
let resp = a
|
||||||
|
.oneshot(
|
||||||
|
Request::get("/overrides/test.dev")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cache_list_and_flush() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let a = router(ctx.clone());
|
||||||
|
|
||||||
|
// List (empty)
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::get("/cache").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
|
||||||
|
// Flush
|
||||||
|
let resp = a
|
||||||
|
.oneshot(Request::delete("/cache").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_success());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn blocking_stats_returns_json() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let resp = router(ctx)
|
||||||
|
.oneshot(Request::get("/blocking/stats").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["enabled"].is_boolean());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn services_crud() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let a = router(ctx);
|
||||||
|
|
||||||
|
// Add service
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::post("/services")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(r#"{"name":"testapp","target_port":3000}"#))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_success());
|
||||||
|
|
||||||
|
// List
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(Request::get("/services").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&body).contains("testapp"));
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
let resp = a
|
||||||
|
.clone()
|
||||||
|
.oneshot(
|
||||||
|
Request::delete("/services/testapp")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(resp.status().is_success());
|
||||||
|
|
||||||
|
// Verify deleted
|
||||||
|
let resp = a
|
||||||
|
.oneshot(Request::get("/services").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 10000).await.unwrap();
|
||||||
|
assert!(!String::from_utf8_lossy(&body).contains("testapp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn dashboard_returns_html() {
|
||||||
|
let ctx = test_ctx().await;
|
||||||
|
let resp = router(ctx)
|
||||||
|
.oneshot(Request::get("/").body(Body::empty()).unwrap())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let body = axum::body::to_bytes(resp.into_body(), 100000)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(String::from_utf8_lossy(&body).contains("Numa"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user