From d63842996d01cfa2626660d8f7ea02280451ca49 Mon Sep 17 00:00:00 2001 From: Razvan Dimescu Date: Sat, 28 Mar 2026 02:44:57 +0200 Subject: [PATCH] feat: API endpoint tests, coverage target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- Cargo.lock | 2 + Cargo.toml | 2 + Makefile | 5 +- src/api.rs | 249 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index c6161f5..1367cd3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1148,6 +1148,7 @@ dependencies = [ "criterion", "env_logger", "futures", + "http", "http-body-util", "hyper", "hyper-util", @@ -1163,6 +1164,7 @@ dependencies = [ "tokio", "tokio-rustls", "toml", + "tower", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4e988b9..7143098 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,8 @@ ring = "0.17" [dev-dependencies] criterion = { version = "0.5", features = ["html_reports"] } +tower = { version = "0.5", features = ["util"] } +http = "1" [[bench]] name = "hot_path" diff --git a/Makefile b/Makefile index 9e95829..6d73acb 100644 --- a/Makefile +++ b/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 @@ -19,6 +19,9 @@ audit: test: cargo test +coverage: + cargo tarpaulin --skip-clean --out stdout + bench: cargo bench diff --git a/src/api.rs b/src/api.rs index 9d89a7d..e956e48 100644 --- a/src/api.rs +++ b/src/api.rs @@ -909,3 +909,252 @@ async fn check_tcp(addr: std::net::SocketAddr) -> bool { .map(|r| r.is_ok()) .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 { + 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")); + } +}