//! Tests for [`super`]. use super::*; use axum::body::Body; use axum::http::Request as HttpRequest; use tower::ServiceExt; fn test_config(api_token: Option<&str>) -> Config { let mut config = Config { serve: crate::config::ServeConfig::default(), instance: crate::config::InstanceConfig::default(), targets: HashMap::new(), peers: HashMap::new(), storage: crate::config::StorageConfig::default(), alerts: None, }; config.serve.api_token = api_token.map(std::string::ToString::to_string); config } #[tokio::test] async fn no_token_configured_allows_all_requests() { let pool = crate::db::connect_in_memory().await.unwrap(); let app = router(pool, test_config(None), None); let resp = app .oneshot(with_connect_info("/api/status", None)) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn valid_token_allows_request() { let pool = crate::db::connect_in_memory().await.unwrap(); let app = router(pool, test_config(Some("secret123")), None); let resp = app .oneshot(with_connect_info("/api/status", Some("Bearer secret123"))) .await .unwrap(); assert_eq!(resp.status(), StatusCode::OK); } #[tokio::test] async fn dashboard_uses_cookie_not_embedded_token() { // SERIOUS #4: GET / must NOT ship the api_token in the page, and must set // an httpOnly session cookie that authenticates the dashboard's /api/* calls. let pool = crate::db::connect_in_memory().await.unwrap(); let mut config = test_config(Some("supersecret-token")); config.serve.dashboard = true; let app = router(pool, config, None); let req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap(); let resp = app.clone().oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::OK); let cookie_hdr = resp .headers() .get(axum::http::header::SET_COOKIE) .expect("dashboard must set a session cookie") .to_str() .unwrap() .to_string(); assert!(cookie_hdr.contains("pom_dash=")); assert!(cookie_hdr.contains("HttpOnly")); assert!( !cookie_hdr.contains("supersecret-token"), "cookie must not be the api_token" ); let body = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); let html = String::from_utf8_lossy(&body); assert!( !html.contains("supersecret-token"), "the api_token must never appear in served HTML" ); // The issued cookie authenticates an /api/* call without any bearer token. let dash = cookie_hdr.split(';').next().unwrap().trim().to_string(); // "pom_dash=" let mut api_req = HttpRequest::builder() .uri("/api/status") .header(axum::http::header::COOKIE, dash) .body(Body::empty()) .unwrap(); api_req .extensions_mut() .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( [127, 0, 0, 1], 40001, )))); let api_resp = app.oneshot(api_req).await.unwrap(); assert_eq!( api_resp.status(), StatusCode::OK, "dashboard cookie must authenticate /api/*" ); } #[tokio::test] async fn wrong_token_returns_401() { let pool = crate::db::connect_in_memory().await.unwrap(); let app = router(pool, test_config(Some("secret123")), None); let req = HttpRequest::builder() .uri("/api/status") .header("authorization", "Bearer wrong-token") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn missing_header_returns_401() { let pool = crate::db::connect_in_memory().await.unwrap(); let app = router(pool, test_config(Some("secret123")), None); let req = HttpRequest::builder() .uri("/api/status") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } #[tokio::test] async fn malformed_header_returns_401() { let pool = crate::db::connect_in_memory().await.unwrap(); let app = router(pool, test_config(Some("secret123")), None); let req = HttpRequest::builder() .uri("/api/status") .header("authorization", "Basic dXNlcjpwYXNz") .body(Body::empty()) .unwrap(); let resp = app.oneshot(req).await.unwrap(); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } fn ip(n: u8) -> std::net::IpAddr { std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, n)) } #[test] fn reproject_drops_injected_structure() { // #6: a compromised peer stuffs extra structure into its status blob. let hostile = serde_json::json!({ "instance": { "id": "abc", "version": "9.9", "evil_field": {"x": 1} }, "targets": { "mnw": { "status": "operational", "response_time_ms": 5, "evil": "inject" } }, "peers": { "p2": { "status": "up", "latency_ms": 3 } }, "top_level_injection": [1, 2, 3] }); let clean = reproject_peer_status(&hostile); // Only the fixed top-level keys survive. let obj = clean.as_object().unwrap(); let mut keys: Vec<&String> = obj.keys().collect(); keys.sort(); assert_eq!(keys, vec!["instance", "peers", "targets"]); assert!(clean.get("top_level_injection").is_none()); // Known values are preserved; injected sibling keys are gone. assert_eq!(clean["instance"]["id"], "abc"); assert_eq!(clean["instance"]["version"], "9.9"); assert!(clean["instance"].get("evil_field").is_none()); assert_eq!(clean["targets"]["mnw"]["status"], "operational"); assert!(clean["targets"]["mnw"].get("evil").is_none()); assert_eq!(clean["peers"]["p2"]["latency_ms"], 3); } /// Build a GET request carrying a `ConnectInfo` extension, which /// the real server injects via `into_make_service_with_connect_info` but /// `oneshot` does not, the rate-limit layer extracts it. fn with_connect_info(uri: &str, bearer: Option<&str>) -> HttpRequest { let mut b = HttpRequest::builder().uri(uri); if let Some(h) = bearer { b = b.header("authorization", h); } let mut req = b.body(Body::empty()).unwrap(); req.extensions_mut() .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( [127, 0, 0, 1], 40000, )))); req } #[test] fn rate_limiter_allows_within_limit() { let limiter = PerIpRateLimiter::new(3, std::time::Duration::from_mins(1)); assert!(limiter.try_acquire(ip(1))); assert!(limiter.try_acquire(ip(1))); assert!(limiter.try_acquire(ip(1))); } #[test] fn rate_limiter_blocks_over_limit() { let limiter = PerIpRateLimiter::new(2, std::time::Duration::from_mins(1)); assert!(limiter.try_acquire(ip(1))); assert!(limiter.try_acquire(ip(1))); assert!(!limiter.try_acquire(ip(1))); } #[test] fn rate_limiter_isolates_clients_by_ip() { // SERIOUS #5: one client exhausting its bucket must not affect another. let limiter = PerIpRateLimiter::new(1, std::time::Duration::from_mins(1)); assert!(limiter.try_acquire(ip(1))); assert!( !limiter.try_acquire(ip(1)), "ip(1) is now over its own limit" ); assert!(limiter.try_acquire(ip(2)), "ip(2) has its own fresh bucket"); } #[tokio::test] async fn rate_limiter_resets_after_window() { let limiter = PerIpRateLimiter::new(1, std::time::Duration::from_millis(10)); assert!(limiter.try_acquire(ip(1))); assert!(!limiter.try_acquire(ip(1))); tokio::time::sleep(std::time::Duration::from_millis(15)).await; assert!(limiter.try_acquire(ip(1))); }