mod tests { use super::*; use crate::{ config::{self, Config, ValidationPolicy}, db::Store, }; use axum::body::Body; use http::Request; use tower::ServiceExt; async fn fixture() -> (Router, Store, tempfile::TempDir, String, String) { fixture_with_config(Config::default()).await } async fn fixture_with_config( config: Config, ) -> (Router, Store, tempfile::TempDir, String, String) { let dir = tempfile::tempdir().unwrap(); let policy = ValidationPolicy { tls_addr: None, cert_dir: None, data_dir: None, proxy_addr: "127.0.0.1:8080".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(), }; let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); store .initialize_admin("owner", "unused-test-hash", "test") .await .unwrap(); let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) .await .unwrap(); let issued = store .create_session(1, "browser", None) .await .unwrap() .unwrap(); let snapshot = config::compile(config, 0, &policy).unwrap(); let state = App::new( store.clone(), snapshot, policy, auth, 10, Duration::from_secs(30), Duration::from_secs(30), Duration::from_secs(300), ); ( router(state), store, dir, issued.secret, issued.session.csrf, ) } async fn fixture_with_certificate_storage( config: Config, ) -> (Router, Store, tempfile::TempDir, String, String) { let dir = tempfile::tempdir().unwrap(); let certificate_directory = dir.path().join("certs"); crate::tls::initialize_default(&certificate_directory).unwrap(); let policy = ValidationPolicy { tls_addr: None, cert_dir: Some(certificate_directory), data_dir: None, proxy_addr: "127.0.0.1:8080".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(), }; let store = Store::open(&dir.path().join("db"), &config).await.unwrap(); store .initialize_admin("owner", "unused-test-hash", "test") .await .unwrap(); let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) .await .unwrap(); let issued = store .create_session(1, "browser", None) .await .unwrap() .unwrap(); let snapshot = config::compile(config, 0, &policy).unwrap(); let state = App::new( store.clone(), snapshot, policy, auth, 10, Duration::from_secs(30), Duration::from_secs(30), Duration::from_secs(300), ); ( router(state), store, dir, issued.secret, issued.session.csrf, ) } fn request(path: &str) -> http::request::Builder { Request::builder() .uri(path) .header("host", "127.0.0.1:9090") } #[test] fn administration_authority_uses_http2_pseudo_authority_without_legacy_host() { let request = Request::builder() .version(Version::HTTP_2) .uri("https://192.0.2.10:9090/healthz") .body(Body::empty()) .unwrap(); assert_eq!( admin_request_authority(&request, true).unwrap(), "192.0.2.10:9090" ); let conflicting = Request::builder() .version(Version::HTTP_2) .uri("https://192.0.2.10:9090/healthz") .header(HOST, "panel.example.test:9090") .body(Body::empty()) .unwrap(); assert!(admin_request_authority(&conflicting, true).is_err()); let h1_absolute = Request::builder() .uri("https://192.0.2.10:9090/healthz") .header(HOST, "192.0.2.10:9090") .body(Body::empty()) .unwrap(); assert!(admin_request_authority(&h1_absolute, true).is_err()); } #[tokio::test] async fn administration_https_health_accepts_http2_authority_without_host_header() { let (router, _store, _dir, _secret, _csrf) = fixture().await; let request = Request::builder() .version(Version::HTTP_2) .uri("https://127.0.0.1:9090/healthz") .extension(Transport { tls: true }) .body(Body::empty()) .unwrap(); assert_eq!( router.oneshot(request).await.unwrap().status(), StatusCode::OK ); } #[test] fn tls_admin_accepts_dns_without_allowlist_but_keeps_explicit_lists_strict() { let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); assert!(admin_host_allowed("127.0.0.1:9090", wildcard, true, &[])); assert!(admin_host_allowed("192.0.2.10:9090", wildcard, true, &[])); assert!(admin_host_allowed( "example.test:9090", wildcard, true, &["example.test".into()] )); assert!(admin_host_allowed("example.test:9090", wildcard, true, &[])); assert!(!admin_host_allowed( "example.test:9090", wildcard, false, &[] )); assert!(!admin_host_allowed("0.0.0.0:9091", wildcard, true, &[])); assert!(!admin_host_allowed("0.0.0.0:9090", wildcard, true, &[])); assert!(admin_host_allowed("localhost:9090", wildcard, true, &[])); let concrete: std::net::SocketAddr = "192.0.2.10:9090".parse().unwrap(); assert!(admin_host_allowed("localhost:9090", concrete, true, &[])); assert!(admin_host_allowed( "localhost:9090", concrete, true, &["localhost".into()] )); assert!(!admin_host_allowed( "other.test:9090", concrete, true, &["localhost".into()] )); } #[test] fn reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available() { let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap(); let allowed = vec!["panel.example.test".to_owned()]; assert!(admin_host_allowed( "panel.example.test", wildcard, false, &allowed )); assert!(admin_host_allowed( "panel.example.test:443", wildcard, true, &allowed )); assert!(admin_host_allowed( "panel.example.test:8443", wildcard, true, &allowed )); assert!(admin_host_allowed( "192.0.2.10:9090", wildcard, false, &allowed )); assert!(admin_host_allowed( "localhost:9090", wildcard, false, &allowed )); assert!(!admin_host_allowed( "192.0.2.10:443", wildcard, true, &allowed )); assert!(!admin_host_allowed( "other.example.test:443", wildcard, true, &allowed )); } #[test] fn origin_matching_normalizes_default_ports_without_weakening_host_checks() { assert!(admin_origin_matches( "https://panel.example.test", "https", "panel.example.test:443" )); assert!(admin_origin_matches( "https://PANEL.EXAMPLE.TEST:443/", "https", "panel.example.test" )); assert!(!admin_origin_matches( "https://panel.example.test:8443", "https", "panel.example.test" )); assert!(!admin_origin_matches( "http://panel.example.test", "https", "panel.example.test" )); } #[test] fn admin_origin_diagnostics_redact_non_origin_values() { let mut headers = HeaderMap::new(); assert_eq!(diagnostic_admin_origin(&headers), "(absent)"); headers.insert( ORIGIN, HeaderValue::from_static("https://panel.example.test"), ); assert_eq!( diagnostic_admin_origin(&headers), "https://panel.example.test" ); headers.insert( ORIGIN, HeaderValue::from_static("https://user:password@panel.example.test"), ); assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); headers.insert( ORIGIN, HeaderValue::from_static("https://panel.example.test/?token=secret"), ); assert_eq!(diagnostic_admin_origin(&headers), "(invalid)"); headers.insert( "x-forwarded-host", HeaderValue::from_static("other.example.test"), ); assert_eq!( diagnostic_forwarded_header(&headers, "x-forwarded-host", false), "(untrusted)" ); assert_eq!( diagnostic_forwarded_header(&headers, "x-forwarded-host", true), "other.example.test" ); } #[tokio::test] async fn unauthorized_browser_origins_remain_rejected_for_session_checks() { let (router, store, _dir, secret, _) = fixture().await; for (origin, fetch_site) in [ (Some("https://attacker.example"), None), (Some("null"), None), (None, Some("cross-site")), ] { let mut request = Request::builder() .uri("/api/auth/session") .header("host", "127.0.0.1:9090") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); if let Some(origin) = origin { request = request.header(ORIGIN, origin); } if let Some(site) = fetch_site { request = request.header("sec-fetch-site", site); } let response = router .clone() .oneshot(request.body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } for origin in [None, Some("http://127.0.0.1:9090")] { let mut request = Request::builder() .uri("/api/auth/session") .header("host", "127.0.0.1:9090") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)); if let Some(origin) = origin { request = request.header(ORIGIN, origin); } assert_eq!( router .clone() .oneshot(request.body(Body::empty()).unwrap()) .await .unwrap() .status(), StatusCode::OK ); } store.close().await; } #[tokio::test] async fn invalid_origin_on_document_is_visible_but_still_forbidden() { let (router, store, _dir, _secret, _) = fixture().await; let response = router .oneshot( Request::builder() .uri("/overview") .header("host", "127.0.0.1:9090") .header("origin", "null") .header("accept", "text/html,application/xhtml+xml") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert!(response .headers() .get(http::header::CONTENT_TYPE) .unwrap() .to_str() .unwrap() .starts_with("text/html")); let body = axum::body::to_bytes(response.into_body(), 4096) .await .unwrap(); assert!(String::from_utf8_lossy(&body).contains("Could not open the panel")); store.close().await; } #[tokio::test] async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() { let mut config = Config::default(); config.administration.allowed_hosts = vec!["panel.example.test".into()]; let (router, store, _dir, secret, _) = fixture_with_config(config).await; let response = router .oneshot( Request::builder() .uri("/api/status") .header("host", "127.0.0.1:9090") .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https") .header("origin", "https://panel.example.test") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let refreshed = response .headers() .get_all(SET_COOKIE) .iter() .filter_map(|value| value.to_str().ok()) .find(|value| value.starts_with(auth::COOKIE_NAME)) .expect("session cookie refreshed"); assert!(refreshed.contains("; Secure")); store.close().await; } #[tokio::test] async fn https_redirect_router_preserves_authority_path_and_query() { let request = Request::builder() .uri("/api/status?view=compact") .header("host", "panel.example.test:9090") .body(Body::empty()) .unwrap(); let response = https_redirect_router().oneshot(request).await.unwrap(); assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); assert_eq!( response.headers().get(http::header::LOCATION).unwrap(), "https://panel.example.test:9090/api/status?view=compact" ); assert_eq!( response.headers().get(http::header::CACHE_CONTROL).unwrap(), "no-store" ); } #[tokio::test] async fn status_exposes_runtime_memory_accounting_and_cold_limits() { let (router, store, _dir, secret, _) = fixture().await; let response = router .oneshot( request("/api/status") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let body = json_body(response).await; assert_eq!( body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024 ); assert_eq!(body["transform_resources"]["max_inflight"], 32); assert_eq!( body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024 ); assert_eq!( body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024 ); assert_eq!( body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024 ); assert_eq!(body["cache"]["cold_limit_entries"], 50_000); assert_eq!( body["memory_accounting"]["non_cache_rss_includes_transform"], true ); store.close().await; } #[tokio::test] async fn rejects_legacy_token_rebinding_and_cross_origin() { let (router, store, _dir, secret, _) = fixture().await; let old = request("/api/status") .header("authorization", format!("Bearer {}", "a".repeat(64))) .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(old).await.unwrap().status(), StatusCode::UNAUTHORIZED ); let evil = Request::builder() .uri("/healthz") .header("host", "evil.test:9090") .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(evil).await.unwrap().status(), StatusCode::FORBIDDEN ); let req = request("/api/status") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .header("origin", "http://evil.test") .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(req).await.unwrap().status(), StatusCode::FORBIDDEN ); let req = request("/api/status") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .body(Body::empty()) .unwrap(); assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::OK); store.close().await; } #[tokio::test] async fn login_bundle_is_public_but_cache_mutations_require_session_and_csrf() { let (router, store, _dir, secret, csrf) = fixture().await; let public = request("/assets/login-langs.json") .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(public).await.unwrap().status(), StatusCode::OK ); let anonymous = request("/api/cache").body(Body::empty()).unwrap(); assert_eq!( router.clone().oneshot(anonymous).await.unwrap().status(), StatusCode::UNAUTHORIZED ); let anonymous = request("/api/traffic").body(Body::empty()).unwrap(); assert_eq!( router.clone().oneshot(anonymous).await.unwrap().status(), StatusCode::UNAUTHORIZED ); let anonymous = request("/api/transform").body(Body::empty()).unwrap(); assert_eq!( router.clone().oneshot(anonymous).await.unwrap().status(), StatusCode::UNAUTHORIZED ); let cookie = format!("{}={secret}", auth::COOKIE_NAME); for valid in [false, true] { let req = request("/api/cache/purge") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header( "x-csrf-token", if valid { csrf.as_str() } else { "invalid" }, ) .body(Body::empty()) .unwrap(); let expected = if valid { StatusCode::OK } else { StatusCode::FORBIDDEN }; assert_eq!( router.clone().oneshot(req).await.unwrap().status(), expected ); } let cleanup = request("/api/cache/cold/cleanup") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", "invalid") .header("content-type", "application/json") .body(Body::from(r#"{"oldest":1,"hits_zero":false}"#)) .unwrap(); assert_eq!( router.clone().oneshot(cleanup).await.unwrap().status(), StatusCode::FORBIDDEN ); let cleanup_status = request("/api/cache/cold/cleanup/1") .header("cookie", &cookie) .body(Body::empty()) .unwrap(); assert_eq!( router .clone() .oneshot(cleanup_status) .await .unwrap() .status(), StatusCode::NOT_FOUND ); store.close().await; } #[tokio::test] async fn selective_cache_invalidation_api_manages_bans_and_rejects_wide_rules_without_confirmation( ) { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let mutate = |path: &str, method: &str, body: Body| { request(path) .method(method) .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(body) .unwrap() }; let create = router .clone() .oneshot(mutate( "/api/cache/invalidate", "POST", Body::from( r#"{"operation":"ban","selector":"exact","value":"/asset.js?v=1","host":"example.test","ttl_seconds":60}"#, ), )) .await .unwrap(); assert_eq!(create.status(), StatusCode::OK); let created = json_body(create).await; let ban_id = created["result"]["ban_id"].as_u64().expect("ban id"); assert_eq!(created["result"]["status"], "active"); assert_eq!(created["bans"].as_array().unwrap().len(), 1); let list = router .clone() .oneshot( request("/api/cache/bans") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(list.status(), StatusCode::OK); assert_eq!(json_body(list).await["bans"].as_array().unwrap().len(), 1); let wide = router .clone() .oneshot(mutate( "/api/cache/invalidate", "POST", Body::from(r#"{"operation":"purge","selector":"regex","value":".*"}"#), )) .await .unwrap(); assert_eq!(wide.status(), StatusCode::UNPROCESSABLE_ENTITY); let remove = router .clone() .oneshot(mutate( &format!("/api/cache/bans/{ban_id}"), "DELETE", Body::empty(), )) .await .unwrap(); assert_eq!(remove.status(), StatusCode::OK); assert!(json_body(remove).await["bans"] .as_array() .unwrap() .is_empty()); let missing = router .clone() .oneshot(mutate("/api/cache/bans/999999", "DELETE", Body::empty())) .await .unwrap(); assert_eq!(missing.status(), StatusCode::NOT_FOUND); store.close().await; } #[tokio::test] async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let logging = Config::default().logging; let body = serde_json::to_vec(&json!({ "expected_revision": 0, "value": logging, })) .unwrap(); let response = router .clone() .oneshot( request("/api/config/logging") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from(body)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(json_body(response).await["revision"], 1); let http2 = crate::settings::Http2Connections { idle_timeout_seconds: 120, max_connection_lifetime_seconds: 7_200, drain_timeout_seconds: 15, }; let response = router .clone() .oneshot( request("/api/config/http2") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( serde_json::to_vec(&json!({"expected_revision":1,"value":http2})).unwrap(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let updated = json_body(response).await; assert_eq!(updated["revision"], 2); assert_eq!( updated["config"]["http2"]["max_connection_lifetime_seconds"], 7_200 ); let stale = router .clone() .oneshot( request("/api/config/logging") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( serde_json::to_vec( &json!({"expected_revision":0,"value":Config::default().logging}), ) .unwrap(), )) .unwrap(), ) .await .unwrap(); assert_eq!(stale.status(), StatusCode::CONFLICT); let unknown = router .clone() .oneshot( request("/api/config/not-a-section") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from(r#"{"expected_revision":2,"value":{}}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(unknown.status(), StatusCode::NOT_FOUND); store.close().await; } #[tokio::test] async fn logging_section_persists_error_log_settings_atomically() { let (router, store, dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let logging = Config::default().logging; let mut error_log = Config::default().error_log; error_log.enabled = true; error_log.path = "/tmp/rustproxy-error.log".into(); let response = router .clone() .oneshot( request("/api/config/logging") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( serde_json::to_vec(&json!({ "expected_revision": 0, "value": { "logging": logging, "error_log": error_log, }, })) .unwrap(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let updated = json_body(response).await; assert_eq!(updated["revision"], 1); assert_eq!(updated["config"]["error_log"]["enabled"], true); assert_eq!( updated["config"]["error_log"]["path"], "/tmp/rustproxy-error.log" ); let configuration = router .clone() .oneshot( request("/api/config") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let persisted = json_body(configuration).await; assert_eq!(persisted["config"]["error_log"]["enabled"], true); assert_eq!( persisted["config"]["error_log"]["path"], "/tmp/rustproxy-error.log" ); drop(router); store.close().await; let reopened = Store::open(&dir.path().join("db"), &Config::default()) .await .unwrap(); let persisted = reopened.load().await.unwrap(); assert_eq!(persisted.revision, 1); assert!(persisted.config.error_log.enabled); assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log"); reopened.close().await; } #[tokio::test] async fn error_log_is_not_a_standalone_configuration_section() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let response = router .oneshot( request("/api/config/error_log") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from(r#"{"expected_revision":0,"value":{}}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); store.close().await; } #[tokio::test] async fn default_pages_section_accepts_the_standard_value_payload() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let pages = Config::default().pages; let response = router .clone() .oneshot( request("/api/config/pages") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( serde_json::to_vec(&json!({ "expected_revision": 0, "value": pages, })) .unwrap(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(json_body(response).await["revision"], 1); store.close().await; } #[tokio::test] async fn panel_subpage_routes_survive_browser_refresh() { let (router, store, _dir, secret, _) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); for path in [ "/overview", "/vhosts", "/web-server", "/docker-vhosts", "/redirects", "/404-hosts", "/streams", "/acl-profiles", "/configuration", "/tls", "/traffic", "/cache", "/transform", "/logging", "/logs", "/settings", ] { let public = router .clone() .oneshot(request(path).body(Body::empty()).unwrap()) .await .unwrap(); if path == "/logs" { assert_eq!(public.status(), StatusCode::UNAUTHORIZED); assert_eq!(json_body(public).await, json!({"error":"auth.required"})); } else { assert_eq!(public.status(), StatusCode::OK, "anonymous {path}"); let public_body = axum::body::to_bytes(public.into_body(), 100_000) .await .unwrap(); assert!(std::str::from_utf8(&public_body) .unwrap() .contains(&format!("/assets/login_{}.js", super::asset_build_id()))); } let private = router .clone() .oneshot( request(path) .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(private.status(), StatusCode::OK, "authenticated {path}"); let private_body = axum::body::to_bytes(private.into_body(), 2 * 1024 * 1024) .await .unwrap(); assert!(std::str::from_utf8(&private_body) .unwrap() .contains(&format!("/assets/app_{}.js", super::asset_build_id()))); } store.close().await; } #[tokio::test] async fn csrf_required_and_logout_revokes_cookie() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let req = request("/api/auth/logout") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(req).await.unwrap().status(), StatusCode::FORBIDDEN ); let req = request("/api/auth/logout") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", csrf) .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(req).await.unwrap().status(), StatusCode::OK ); let req = request("/api/status") .header("cookie", &cookie) .body(Body::empty()) .unwrap(); assert_eq!( router.oneshot(req).await.unwrap().status(), StatusCode::UNAUTHORIZED ); store.close().await; } #[tokio::test] async fn upload_and_scoped_queries_keep_control_plane_guards() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); for (path, body) in [ ( "/api/certificates/upload", r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#, ), ( "/api/certificates/deploy", r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#, ), ] { for (authenticated, valid_csrf, expected) in [(false, false, 401), (true, false, 403), (true, true, 409)] { let mut req = request(path) .method("POST") .header("content-type", "application/json") .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1"); if authenticated { req = req.header("cookie", &cookie); } if valid_csrf { req = req.header("x-csrf-token", &csrf); } assert_eq!( router .clone() .oneshot(req.body(Body::from(body)).unwrap()) .await .unwrap() .status() .as_u16(), expected ); } } for (path, expected) in [ ("/api/logs?vhost=demo&limit=8", 200), ("/api/logs?cache=BAD", 400), ("/api/file-logs", 200), ("/api/file-logs/query?source=all&limit=8", 200), ("/api/file-logs/query?limit=0", 400), ("/api/traffic", 200), ("/api/cache", 200), ("/api/transform", 200), ] { let req = request(path) .header("cookie", &cookie) .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(req).await.unwrap().status().as_u16(), expected ); } let req = request("/api/cache/purge?vhost=missing") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::empty()) .unwrap(); assert_eq!( router.oneshot(req).await.unwrap().status(), StatusCode::NOT_FOUND ); store.close().await; } #[tokio::test] async fn certificate_reload_keeps_configuration_revision_stable() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let response = router .clone() .oneshot( request("/api/certificates/reload") .method("POST") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from("{}")) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let payload = json_body(response).await; assert_eq!(payload["reloaded"], true); assert_eq!(payload["revision"], 0); let configuration = router .oneshot( request("/api/config") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(json_body(configuration).await["revision"], 0); store.close().await; } #[tokio::test] async fn manual_certificate_can_be_reloaded_by_id_without_revision_change() { let mut config = Config::default(); config.tls.certificates.push(crate::tls::Certificate { issuer: None, id: "manual-file".into(), covered_hosts: Vec::new(), hosts: Vec::new(), cert_file: "default.pem".into(), key_file: "default.key".into(), }); let (router, store, _dir, secret, csrf) = fixture_with_certificate_storage(config).await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let response = router .clone() .oneshot( request("/api/certificates/manual-file/reload") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from("{}")) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let payload = json_body(response).await; assert_eq!(payload["certificate_id"], "manual-file"); assert_eq!(payload["revision"], 0); assert_eq!(payload["reloaded"], true); let api_token = "c".repeat(64); store .upsert_certificate_api_credential( "file-renewer", &crate::auth::secret_hash(&api_token), "external file renewal hook", false, &["manual-file".to_owned()], "owner", ) .await .unwrap(); let external = router .clone() .oneshot( request("/api/certificates/manual-file/reload") .method("POST") .header("authorization", format!("Bearer {api_token}")) .header("x-rustproxy-certificate-key", "file-renewer") .header("content-type", "application/json") .body(Body::from("{}")) .unwrap(), ) .await .unwrap(); assert_eq!(external.status(), StatusCode::OK); assert_eq!(json_body(external).await["certificate_id"], "manual-file"); store.close().await; } #[tokio::test] async fn certificate_api_tokens_are_scoped_commented_and_individually_revocable() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let token = "a".repeat(64); let token_two = "b".repeat(64); let invalid_key = router .clone() .oneshot( request("/api/certificate-api") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from( json!({"key_id":"x","token":token,"certificate_ids":["shared-cert"]}) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(invalid_key.status(), StatusCode::UNPROCESSABLE_ENTITY); for (key_id, value, comment) in [ ("certbot-main", token.as_str(), "production certbot"), ("deploy-hook", token_two.as_str(), "secondary deploy hook"), ] { let rotated = router .clone() .oneshot( request("/api/certificate-api") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from( json!({ "key_id":key_id, "token":value, "comment":comment, "certificate_ids":[if key_id == "certbot-main" { "shared-cert" } else { "deploy-cert" }], "allow_deploy": key_id == "deploy-hook" }).to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(rotated.status(), StatusCode::OK); let rotated = json_body(rotated).await; assert_eq!(rotated["configured"], true); assert_eq!(rotated["credential"]["key_id"], key_id); assert_eq!(rotated["credential"]["comment"], comment); assert_eq!( rotated["credential"]["allow_deploy"], key_id == "deploy-hook" ); assert_eq!( rotated["credential"]["certificate_ids"] .as_array() .unwrap() .len(), 1 ); assert_eq!(rotated["token"], value); } let status = router .clone() .oneshot( request("/api/certificate-api") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let status = json_body(status).await; assert_eq!(status["configured"], true); assert_eq!(status["credentials"].as_array().unwrap().len(), 2); assert!(status.get("token").is_none()); let external = |path: &'static str, key_id: &'static str, value: String| { request(path) .method("POST") .header("authorization", format!("Bearer {value}")) .header("x-rustproxy-certificate-key", key_id) .header("content-type", "application/json") .body(Body::from("{}")) .unwrap() }; let response = router .clone() .oneshot(external( "/api/certificates/reload", "certbot-main", token.clone(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let response = router .clone() .oneshot(external( "/api/certificates/shared-cert/reload", "certbot-main", token.clone(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let response = router .clone() .oneshot( request("/api/certificates/deploy") .method("POST") .header("authorization", format!("Bearer {token}")) .header("x-rustproxy-certificate-key", "certbot-main") .header("content-type", "application/json") .body(Body::from("{}")) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let response = router .clone() .oneshot(external( "/api/certificates/deploy-cert/reload", "certbot-main", token.clone(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let response = router .clone() .oneshot(external( "/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let response = router .clone() .oneshot( request("/api/certificates/deploy") .method("POST") .header("authorization", format!("Bearer {token_two}")) .header("x-rustproxy-certificate-key", "deploy-hook") .header("content-type", "application/json") .body(Body::from( json!({"certificate_id":"shared-cert","cert_pem":"bad","key_pem":"bad"}) .to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!( json_body(response).await, json!({"error":"certificate_api.certificate_forbidden"}) ); let missing_key = router .clone() .oneshot( request("/api/certificates/reload") .method("POST") .header("authorization", format!("Bearer {token}")) .header("content-type", "application/json") .body(Body::from("{}")) .unwrap(), ) .await .unwrap(); assert_eq!(missing_key.status(), StatusCode::UNAUTHORIZED); let forbidden_scope = router .clone() .oneshot( request("/api/config") .header("authorization", format!("Bearer {token}")) .header("x-rustproxy-certificate-key", "certbot-main") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED); let revoked = router .clone() .oneshot( request("/api/certificate-api/certbot-main") .method("DELETE") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(revoked.status(), StatusCode::OK); let response = router .clone() .oneshot(external( "/api/certificates/shared-cert/reload", "certbot-main", token.clone(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); let response = router .clone() .oneshot(external( "/api/certificates/deploy-cert/reload", "deploy-hook", token_two.clone(), )) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND); let revoked_all = router .clone() .oneshot( request("/api/certificate-api") .method("DELETE") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(revoked_all.status(), StatusCode::OK); let response = router .oneshot(external( "/api/certificates/deploy-cert/reload", "deploy-hook", token_two, )) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); store.close().await; } #[tokio::test] async fn certificate_api_settings_read_edit_and_regenerate_without_exposing_existing_token() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let existing_token = "C".repeat(64); let admin = |method: Method, path: &str, body: serde_json::Value| { request(path) .method(method) .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from(body.to_string())) .unwrap() }; let external = |path: &str, token: &str| { request(path) .method("POST") .header("authorization", format!("Bearer {token}")) .header("x-rustproxy-certificate-key", "certbot-prod") .header("content-type", "application/json") .body(Body::from("{}")) .unwrap() }; let created = router.clone().oneshot(admin( Method::PUT, "/api/certificate-api", json!({ "key_id": "certbot-prod", "token": existing_token, "comment": "first comment", "certificate_ids": ["first-cert"], "allow_deploy": false }), )).await.unwrap(); assert_eq!(created.status(), StatusCode::OK); let created = json_body(created).await; assert_eq!(created["token"], existing_token); let before_hash: String = sqlx::query_scalar( "SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'", ) .fetch_one(&store.pool) .await .unwrap(); assert_eq!(before_hash, auth::secret_hash(&existing_token)); let detail = router .clone() .oneshot(admin( Method::GET, "/api/certificate-api/certbot-prod", json!({}), )) .await .unwrap(); assert_eq!(detail.status(), StatusCode::OK); let detail = json_body(detail).await; assert_eq!(detail["credential"]["comment"], "first comment"); assert_eq!( detail["credential"]["certificate_ids"], json!(["first-cert"]) ); assert!(detail.get("token").is_none()); assert!(detail["credential"].get("token").is_none()); assert!(detail["credential"].get("token_hash").is_none()); let created_at = detail["credential"]["created_at"].clone(); let denied = router .clone() .oneshot( request("/api/certificate-api/certbot-prod") .header("authorization", format!("Bearer {existing_token}")) .header("x-rustproxy-certificate-key", "certbot-prod") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(denied.status(), StatusCode::UNAUTHORIZED); let no_csrf = router .clone() .oneshot( request("/api/certificate-api/certbot-prod/regenerate") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(no_csrf.status(), StatusCode::FORBIDDEN); let bad_edit = router.clone().oneshot(admin( Method::PATCH, "/api/certificate-api/certbot-prod", json!({"comment":"changed", "certificate_ids":["other-cert"], "allow_deploy":true, "token":"should-not-be-accepted"}), )).await.unwrap(); assert!(bad_edit.status().is_client_error()); let wildcard = router .clone() .oneshot(admin( Method::PATCH, "/api/certificate-api/certbot-prod", json!({"comment":"changed", "certificate_ids":["*"], "allow_deploy":true}), )) .await .unwrap(); assert_eq!(wildcard.status(), StatusCode::UNPROCESSABLE_ENTITY); let edited = router.clone().oneshot(admin( Method::PATCH, "/api/certificate-api/certbot-prod", json!({"comment":"updated owner", "certificate_ids":["new-cert", "new-cert"], "allow_deploy":true}), )).await.unwrap(); assert_eq!(edited.status(), StatusCode::OK); let edited = json_body(edited).await; assert_eq!(edited["credential"]["comment"], "updated owner"); assert_eq!(edited["credential"]["certificate_ids"], json!(["new-cert"])); assert_eq!(edited["credential"]["allow_deploy"], true); assert_eq!(edited["credential"]["created_at"], created_at); assert!(edited.get("token").is_none()); let hash_after_edit: String = sqlx::query_scalar( "SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'", ) .fetch_one(&store.pool) .await .unwrap(); assert_eq!(hash_after_edit, before_hash); let allowed = router .clone() .oneshot(external( "/api/certificates/new-cert/reload", &existing_token, )) .await .unwrap(); assert_eq!(allowed.status(), StatusCode::NOT_FOUND); let old_id = router .clone() .oneshot(external( "/api/certificates/first-cert/reload", &existing_token, )) .await .unwrap(); assert_eq!(old_id.status(), StatusCode::FORBIDDEN); let regenerated = router .clone() .oneshot(admin( Method::POST, "/api/certificate-api/certbot-prod/regenerate", json!({}), )) .await .unwrap(); assert_eq!(regenerated.status(), StatusCode::OK); let regenerated = json_body(regenerated).await; let new_token = regenerated["token"].as_str().unwrap().to_owned(); assert!(new_token.len() >= 32); assert_ne!(new_token, existing_token); assert_eq!(regenerated["credential"]["comment"], "updated owner"); assert_eq!(regenerated["credential"]["allow_deploy"], true); assert_eq!( regenerated["credential"]["certificate_ids"], json!(["new-cert"]) ); assert!(regenerated["credential"]["last_used_at"].is_null()); let raw: String = sqlx::query_scalar( "SELECT token_hash FROM certificate_api_credentials WHERE key_id = 'certbot-prod'", ) .fetch_one(&store.pool) .await .unwrap(); assert_eq!(raw, auth::secret_hash(&new_token)); assert_ne!(raw, new_token); assert_ne!(raw, before_hash); assert_eq!( router .clone() .oneshot(external( "/api/certificates/new-cert/reload", &existing_token, )) .await .unwrap() .status(), StatusCode::UNAUTHORIZED ); assert_eq!( router .clone() .oneshot(external("/api/certificates/new-cert/reload", &new_token,)) .await .unwrap() .status(), StatusCode::NOT_FOUND ); let after = router .clone() .oneshot(admin( Method::GET, "/api/certificate-api/certbot-prod", json!({}), )) .await .unwrap(); let after = json_body(after).await; assert!(after.get("token").is_none()); assert!(after["credential"].get("token").is_none()); assert!(after["credential"].get("token_hash").is_none()); let missing = router .clone() .oneshot(admin( Method::POST, "/api/certificate-api/missing-key/regenerate", json!({}), )) .await .unwrap(); assert_eq!(missing.status(), StatusCode::NOT_FOUND); let audit: Vec = sqlx::query_scalar( "SELECT action FROM admin_audit WHERE action LIKE 'certificate_api.%' ORDER BY id", ) .fetch_all(&store.pool) .await .unwrap(); assert!(audit.iter().any(|entry| entry == "certificate_api.edit")); assert!(audit .iter() .any(|entry| entry == "certificate_api.regenerate")); store.close().await; } #[tokio::test] async fn named_dashboard_presets_are_private_validated_and_persistent() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let preset = DashboardPresetLayout { dashboard_widgets: AdminPreferences::default().dashboard_widgets, dashboard_hidden_widgets: default_hidden_dashboard_widgets(), dashboard_widget_sizes: None, dashboard_density: "compact".to_owned(), overview_samples: 600, }; let body = serde_json::to_string(&preset).unwrap(); let anon = router .clone() .oneshot( request("/api/dashboard-presets") .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(anon.status(), StatusCode::UNAUTHORIZED); let forbidden = router .clone() .oneshot( request("/api/dashboard-presets/Daily") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("content-type", "application/json") .body(Body::from(body.clone())) .unwrap(), ) .await .unwrap(); assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); let saved = router .clone() .oneshot( request("/api/dashboard-presets/Daily") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from(body.clone())) .unwrap(), ) .await .unwrap(); assert_eq!(saved.status(), StatusCode::NO_CONTENT); let listing = router .clone() .oneshot( request("/api/dashboard-presets") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(listing.status(), StatusCode::OK); let entries = json_body(listing).await; assert_eq!(entries[0]["name"], "Daily"); assert_eq!(entries[0]["overview_samples"], 600); assert_eq!(entries[0]["dashboard_widget_sizes"], Value::Null); assert_eq!( store .list_admin_dashboard_presets("owner") .await .unwrap() .len(), 1 ); assert!(store .list_admin_dashboard_presets("someone-else") .await .unwrap() .is_empty()); let rejected = router .clone() .oneshot( request("/api/dashboard-presets/Invalid") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from(body.replace( "\"overview_samples\":600", "\"overview_samples\":999", ))) .unwrap(), ) .await .unwrap(); assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); let renamed = router .clone() .oneshot( request("/api/dashboard-presets/Daily") .method("PATCH") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from(r#"{"name":"Nightly"}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(renamed.status(), StatusCode::NO_CONTENT); assert_eq!( store.list_admin_dashboard_presets("owner").await.unwrap()[0].0, "Nightly" ); let deleted = router .oneshot( request("/api/dashboard-presets/Nightly") .method("DELETE") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(deleted.status(), StatusCode::NO_CONTENT); assert!(store .list_admin_dashboard_presets("owner") .await .unwrap() .is_empty()); store.close().await; } #[tokio::test] async fn admin_preferences_are_account_scoped_and_csrf_guarded() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let response = router .clone() .oneshot( request("/api/preferences") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let initial = json_body(response).await; assert_eq!( initial["live_log_columns"].as_array().unwrap().len(), LIVE_LOG_COLUMNS.len() ); assert_eq!( initial["refresh"], json!({ "live_enabled":true, "overview_seconds":3, "traffic_seconds":5, "cache_seconds":5, "system_info_seconds":60 }) ); assert_eq!(initial["theme"], "dark"); assert_eq!(initial["language"], "auto"); assert_eq!(initial["ui_scale"], 100); assert_eq!(initial["time_format"], "24h"); assert_eq!(initial["chart_style"], "default"); assert_eq!(initial["button_style"], "normal"); assert_eq!(initial["corner_style"], "default"); assert_eq!( initial["file_log_columns"].as_array().unwrap().len(), default_file_log_columns().len() ); assert_eq!( initial["backend_table_columns"].as_array().unwrap().len(), BACKEND_TABLE_COLUMNS.len() ); assert_eq!(initial["backend_table_sort"], "vhost"); assert_eq!(initial["backend_table_descending"], false); assert!(store .save_admin_preferences("owner", r#"{"live_log_columns":["time","status"]}"#) .await .unwrap()); let response = router .clone() .oneshot( request("/api/preferences") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let legacy = json_body(response).await; assert_eq!(legacy["live_log_columns"], json!(["time", "status"])); assert_eq!(legacy["refresh"], initial["refresh"]); assert_eq!(legacy["theme"], "dark"); assert_eq!(legacy["language"], "auto"); assert_eq!(legacy["ui_scale"], 100); assert_eq!(legacy["time_format"], "24h"); assert_eq!(legacy["chart_style"], "default"); assert_eq!(legacy["button_style"], "normal"); assert_eq!(legacy["corner_style"], "default"); assert_eq!( legacy["file_log_columns"], json!(default_file_log_columns()) ); assert_eq!( legacy["backend_table_columns"].as_array().unwrap().len(), BACKEND_TABLE_COLUMNS.len() ); assert_eq!(legacy["backend_table_sort"], "vhost"); assert_eq!(legacy["backend_table_descending"], false); let selected = json!({ "refresh":{"live_enabled":false,"overview_seconds":10,"traffic_seconds":0,"cache_seconds":3,"system_info_seconds":300}, "live_log_columns":["time","request","status","upstream_error"], "theme":"light", "language":"pl_pl", "ui_scale":70, "time_format":"12h", "chart_style":"neon", "button_style":"compact", "corner_style":"rounded", "table_density":"ultra", "dashboard_density":"ultra", "dashboard_widgets":["errors","requests","active","bandwidth","cache","memory","websocket","http2","http3","tls","http_protocol","websocket_protocol"], "dashboard_hidden_widgets":["http3","tls"], "file_log_columns":["time","event","client_ip","record"], "backend_table_columns":["vhost","backend","status","requests","latency"], "backend_table_sort":"requests", "backend_table_descending":true }); let normalized = serde_json::to_value( serde_json::from_value::(selected.clone()).unwrap(), ) .unwrap(); for (field, value) in selected.as_object().unwrap() { assert_eq!(&normalized[field], value, "selected field {field}"); } assert_eq!(normalized["dashboard_widget_sizes"], Value::Null); assert_eq!(normalized["file_log_sort"], "time"); assert_eq!(normalized["file_log_descending"], true); assert_eq!(normalized["vhost_list_sort"], "id"); assert_eq!(normalized["docker_vhost_sort"], "service"); assert_eq!(normalized["stream_list_sort"], "name"); assert_eq!(normalized["redirect_list_sort"], "id"); assert_eq!(normalized["notfound_list_sort"], "id"); for field in [ "vhost_list_descending", "docker_vhost_descending", "stream_list_descending", "redirect_list_descending", "notfound_list_descending", ] { assert_eq!(normalized[field], false, "default field {field}"); } let response = router .clone() .oneshot( request("/api/preferences") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from(selected.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(json_body(response).await, normalized); let saved: Value = serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap()) .unwrap(); assert_eq!(saved, normalized); let persisted = router .clone() .oneshot( request("/api/preferences") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let mut upgraded = normalized.clone(); for widget in DASHBOARD_WIDGETS.iter() { let id = json!(widget.id); if selected["dashboard_widgets"] .as_array() .unwrap() .contains(&id) { continue; } upgraded["dashboard_widgets"] .as_array_mut() .unwrap() .push(id.clone()); if widget.default_hidden { upgraded["dashboard_hidden_widgets"] .as_array_mut() .unwrap() .push(id); } } assert_eq!(json_body(persisted).await, upgraded); let mut invalid = selected.clone(); invalid["refresh"]["traffic_seconds"] = json!(4); let rejected = router .clone() .oneshot( request("/api/preferences") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from(invalid.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY); let response = router .clone() .oneshot( request("/api/preferences") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("content-type", "application/json") .body(Body::from(selected.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let response = router .clone() .oneshot( request("/api/preferences") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from(r#"{"live_log_columns":[]}"#)) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let response = router .clone() .oneshot( request("/api/preferences") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from( r#"{"live_log_columns":["time"],"theme":"sepia"}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let response = router .clone() .oneshot( request("/api/preferences") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from( r#"{"live_log_columns":["time"],"ui_scale":150}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); let response = router .oneshot( request("/api/preferences") .method("PUT") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header("content-type", "application/json") .body(Body::from( r#"{"live_log_columns":["time"],"backend_table_columns":["unknown"]}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); store.close().await; } #[tokio::test] async fn acme_and_cache_inspection_require_existing_admin_guards() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); for path in ["/api/acme", "/api/cache?offset=0&limit=2"] { let req = request(path).body(Body::empty()).unwrap(); assert_eq!( router.clone().oneshot(req).await.unwrap().status(), StatusCode::UNAUTHORIZED ); let req = request(path) .header("cookie", &cookie) .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(req).await.unwrap().status(), StatusCode::OK ); } for (authenticated, valid_csrf, expected) in [(false, false, 401), (true, false, 403), (true, true, 409)] { let mut req = request("/api/acme/issue") .method("POST") .header("content-type", "application/json") .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1"); if authenticated { req = req.header("cookie", &cookie); } if valid_csrf { req = req.header("x-csrf-token", &csrf); } let response = router .clone() .oneshot(req.body(Body::from("{}")).unwrap()) .await .unwrap(); assert_eq!(response.status().as_u16(), expected); } let req = request("/api/cache?vhost=missing") .header("cookie", &cookie) .body(Body::empty()) .unwrap(); assert_eq!( router.oneshot(req).await.unwrap().status(), StatusCode::NOT_FOUND ); store.close().await; } async fn json_body(response: Response) -> Value { let bytes = axum::body::to_bytes(response.into_body(), 2 * 1024 * 1024) .await .unwrap(); serde_json::from_slice(&bytes).unwrap() } #[tokio::test] async fn external_certificate_deploy_creates_updates_and_noops() { let (router, store, dir, secret, csrf) = fixture_with_certificate_storage(Config::default()).await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let first = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap(); let first_body = json!({ "certificate_id": "opnsense-cert", "cert_pem": first.cert.pem(), "key_pem": first.signing_key.serialize_pem(), }) .to_string(); let deploy = |body: String| { request("/api/certificates/deploy") .method("POST") .header("content-type", "application/json") .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("cookie", &cookie) .header("x-csrf-token", &csrf) .body(Body::from(body)) .unwrap() }; let response = router .clone() .oneshot(deploy(first_body.clone())) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let created = json_body(response).await; assert_eq!(created["operation"], "created"); assert_eq!(created["created"], true); assert_eq!(created["changed"], true); let first_revision = created["revision"].as_u64().unwrap(); let first_cert_file = created["cert_file"].as_str().unwrap().to_owned(); let first_key_file = created["key_file"].as_str().unwrap().to_owned(); assert!(dir.path().join("certs").join(&first_cert_file).is_file()); assert!(dir.path().join("certs").join(&first_key_file).is_file()); let response = router.clone().oneshot(deploy(first_body)).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let unchanged = json_body(response).await; assert_eq!(unchanged["operation"], "unchanged"); assert_eq!(unchanged["created"], false); assert_eq!(unchanged["changed"], false); assert_eq!(unchanged["revision"].as_u64(), Some(first_revision)); assert_eq!( unchanged["cert_file"].as_str(), Some(first_cert_file.as_str()) ); assert_eq!( unchanged["key_file"].as_str(), Some(first_key_file.as_str()) ); let second = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap(); let second_body = json!({ "certificate_id": "opnsense-cert", "cert_pem": second.cert.pem(), "key_pem": second.signing_key.serialize_pem(), }) .to_string(); let response = router.oneshot(deploy(second_body)).await.unwrap(); assert_eq!(response.status(), StatusCode::OK); let updated = json_body(response).await; assert_eq!(updated["operation"], "updated"); assert_eq!(updated["created"], false); assert_eq!(updated["changed"], true); assert!(updated["revision"].as_u64().unwrap() > first_revision); assert_ne!( updated["cert_file"].as_str(), Some(first_cert_file.as_str()) ); assert_ne!(updated["key_file"].as_str(), Some(first_key_file.as_str())); assert!(!dir.path().join("certs").join(&first_cert_file).exists()); assert!(!dir.path().join("certs").join(&first_key_file).exists()); store.close().await; } #[tokio::test] async fn anonymous_operational_paths_fail_closed_before_extractors() { let (router, store, _dir, _, _) = fixture().await; for path in [ "/api/status", "/api/system-info", "/api/config", "/api/config/history", "/api/backends", "/api/docker", "/api/traffic", "/api/cache?limit=invalid", "/api/logs?limit=invalid", "/api/live-logs", "/api/file-logs", "/api/file-logs/query?limit=invalid", "/api/certificate-api", "/api/preferences", "/api/audit", "/api/events", "/api/certificates", "/api/acme", "/api/auth/session", "/api/metrics", "/api/metrics/json", "/metrics", "/docs/openapi.json", "/docs/content.json", "/docs/init.js", "/docs/style.css", "/docs/swagger-ui-bundle.js", "/docs/swagger-ui.css", "/docs/licenses.txt", "/assets/langs.json", "/assets/app.js", "/assets/management.js", "/assets/drafts.js", "/assets/integrations.js", "/assets/traffic-panel.js", "/assets/cache-panel.js", "/assets/transform-panel.js", "/assets/vhosts.js", "/assets/docker.js", "/assets/streams.js", "/assets/redirects.js", "/assets/host404.js", "/assets/settings.js", "/assets/file-logs.js", "/assets/acme.js", "/api/not-a-route", "/not-a-route", ] { let response = router .clone() .oneshot(request(path).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}"); assert_eq!( response.headers()["cache-control"], "private, no-store, no-cache, no-transform" ); assert!(!response .headers() .contains_key("access-control-allow-origin")); assert!(!response.headers().contains_key("server")); assert_eq!( json_body(response).await, json!({"error":"auth.required"}), "{path}" ); } for (path, method) in [ ("/api/config", "PUT"), ("/api/config/cache", "PUT"), ("/api/config/history/restore", "POST"), ("/api/acme/issue", "POST"), ("/api/acme/renew", "POST"), ("/api/docker/refresh", "POST"), ("/api/geoip/update", "POST"), ("/api/cache/purge", "POST"), ("/api/cache/invalidate", "POST"), ("/api/cache/bans", "GET"), ("/api/cache/bans/1", "DELETE"), ("/api/tunnels", "GET"), ("/api/tunnels/1/kill", "POST"), ("/api/cache/cold/cleanup", "POST"), ("/api/cache/cold/cleanup/1", "GET"), ("/api/certificates/upload", "POST"), ("/api/certificates/deploy", "POST"), ("/api/certificates/reload", "POST"), ("/api/certificates/manual-file/reload", "POST"), ("/api/certificate-api", "PUT"), ("/api/certificate-api/certbot-main", "DELETE"), ("/api/certificate-api", "DELETE"), ("/api/basic-auth/hash", "POST"), ("/api/auth/password", "POST"), ("/api/auth/logout", "POST"), ("/api/auth/logout-all", "POST"), ("/api/config", "OPTIONS"), ("/api/config", "DELETE"), ("/api/auth/login", "GET"), ("/api/auth/setup", "GET"), ("/healthz", "POST"), ("/docs/openapi.json", "HEAD"), ("/docs/content.json", "HEAD"), ] { let response = router .clone() .oneshot( request(path) .method(method) .header("content-type", "application/json") .body(Body::from("malformed secret payload")) .unwrap(), ) .await .unwrap(); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "{method} {path}" ); if method != "HEAD" { assert_eq!(json_body(response).await, json!({"error":"auth.required"})); } } store.close().await; } #[tokio::test] async fn docs_require_the_same_session_and_do_not_redirect_schema_fetches() { let (router, store, _dir, secret, _) = fixture().await; for path in ["/docs", "/docs/"] { let response = router .clone() .oneshot(request(path).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::SEE_OTHER); assert_eq!(response.headers()["location"], "/?next=docs"); } let cookie = format!("{}={secret}", auth::COOKIE_NAME); for path in [ "/", "/docs", "/docs/", "/docs/licenses.txt", "/docs/content.json", &format!("/assets/app_{}.js", super::asset_build_id()), ] { let response = router .clone() .oneshot( request(path) .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK, "{path}"); assert_eq!(response.headers()["x-frame-options"], "DENY"); assert_eq!( response.headers()["cache-control"], "private, no-store, no-cache, no-transform" ); } let response = router .clone() .oneshot( request("/docs/openapi.json") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let spec = json_body(response).await; assert_eq!(spec["openapi"], "3.0.3"); assert_eq!(spec["info"]["version"], env!("CARGO_PKG_VERSION")); assert_eq!( spec["components"]["securitySchemes"]["AdminSession"]["in"], "cookie" ); assert!(spec["paths"].get("/api/config").is_some()); assert!(spec["paths"].get("/api/config/storage").is_none()); assert_eq!( spec["paths"]["/api/config"]["get"]["responses"]["200"]["content"]["application/json"] ["schema"]["$ref"], "#/components/schemas/ConfigurationRead" ); assert!(spec["paths"].get("/api/config/history/restore").is_some()); let response = router .clone() .oneshot( request("/docs/content.json") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); let docs = json_body(response).await; let documents = docs["documents"].as_array().expect("documentation bundle"); assert!(documents .iter() .any(|document| document["path"] == "docs/README.md")); assert!(documents .iter() .any(|document| document["path"] == "docs/WEB_SERVER.md")); assert!(documents .iter() .any(|document| document["path"] == "docs/PHP_FPM.md")); assert!(documents .iter() .any(|document| document["path"] == "docs/SECURITY.md")); assert!(documents .iter() .any(|document| document["path"] == "docs/TROUBLESHOOTING.md")); store.close().await; } #[tokio::test] async fn credentials_in_query_or_duplicate_cookies_cannot_authorize() { let (router, store, _dir, secret, _) = fixture().await; for cookie in [ String::new(), format!("{}=invalid", auth::COOKIE_NAME), format!( "{}={secret}; {}={secret}", auth::COOKIE_NAME, auth::COOKIE_NAME ), ] { let path = format!("/api/config?token={secret}&session={secret}"); let response = router .clone() .oneshot( request(&path) .header("cookie", cookie) .header("authorization", format!("Bearer {secret}")) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } store.close().await; } #[tokio::test] async fn duplicate_csrf_or_marker_is_rejected_and_session_survives() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); for duplicate in ["x-csrf-token", "x-rustproxy-request"] { let response = router .clone() .oneshot( request("/api/auth/logout") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .header( duplicate, if duplicate == "x-csrf-token" { csrf.as_str() } else { "1" }, ) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); } let response = router .oneshot( request("/api/auth/session") .header("cookie", cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); store.close().await; } #[tokio::test] async fn malformed_anonymous_json_never_reflects_submitted_values() { let (router, store, _dir, _, _) = fixture().await; for path in ["/api/auth/login", "/api/auth/setup"] { let response = router .clone() .oneshot( request(path) .method("POST") .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("content-type", "application/json") .body(Body::from( r#"{"username":{"untrusted":"DO_NOT_REFLECT_ME"}}"#, )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::BAD_REQUEST); assert_eq!( json_body(response).await, json!({"error":"auth.invalid_request"}) ); } store.close().await; } #[tokio::test] async fn retained_configuration_revision_can_be_restored_as_a_new_revision() { let (router, store, _dir, secret, csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let mut first = Config::default(); first.server_header.value = "first".into(); let response = router .clone() .oneshot( request("/api/config") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( json!({"expected_revision":0,"config":first}).to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(json_body(response).await["revision"], 1); let mut second = Config::default(); second.server_header.value = "second".into(); let response = router .clone() .oneshot( request("/api/config") .method("PUT") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( json!({"expected_revision":1,"config":second}).to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(json_body(response).await["revision"], 2); let response = router .clone() .oneshot( request("/api/config/history/restore") .method("POST") .header("content-type", "application/json") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::from( json!({"revision":1,"expected_revision":2}).to_string(), )) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let restored = json_body(response).await; assert_eq!(restored["revision"], 3); assert_eq!(restored["config"]["server_header"]["value"], "first"); let audit = store.recent_audit().await.unwrap(); assert_eq!(audit[0].actor, "owner"); assert_eq!(audit[0].action, "config.restore"); assert_eq!(audit[0].detail, "source_revision=1 revision=3"); let detail = store.configuration_revision(3).await.unwrap().unwrap(); assert_eq!(detail.config.server_header.value, "first"); assert_eq!( detail.previous_config.unwrap().server_header.value, "second" ); store.close().await; } #[test] fn stored_log_inventory_keeps_existing_files_visible_when_writer_is_disabled() { use std::io::Write; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("access.jsonl"); let mut file = std::fs::File::create(&path).unwrap(); writeln!(file, r#"{{"event":"request","status":200}}"#).unwrap(); let mut config = Config::default(); config.logging.file.enabled = false; config.logging.file.per_vhost = false; config.logging.file.path = path.to_string_lossy().into_owned(); let sources = configured_file_log_sources(&config); let access = sources.iter().find(|source| source.id == "access").unwrap(); assert!(!access.enabled); assert!(access.exists); assert!(access.size > 0); assert_eq!(access.path, path); } #[test] fn stored_log_tail_enforces_line_budget() { use std::io::Write; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("access.jsonl"); let mut file = std::fs::File::create(&path).unwrap(); for id in 0..10 { writeln!(file, r#"{{"time_ms":{id},"event":"request"}}"#).unwrap(); } drop(file); let source = source_from_path( "access".into(), "Access log".into(), "access".into(), "access", path, false, "JSONL", ); let (lines, _bytes, truncated) = read_tail(&source, 1024 * 1024, 3).unwrap(); assert_eq!(lines.len(), 3); assert!(lines[0].contains(r#""time_ms":9"#)); assert!(lines[2].contains(r#""time_ms":7"#)); assert!(truncated); } #[test] fn stored_log_query_rejects_oversized_source_identifier() { let query = FileLogQuery { source: Some("x".repeat(193)), ..FileLogQuery::default() }; assert!(validate_file_log_query(&query).is_err()); } #[test] fn stored_log_normalizes_mapped_client_ip_and_searches_diagnostics() { let dir = tempfile::tempdir().unwrap(); let source = source_from_path( "vhost:app".into(), "app.jsonl".into(), "vhost:app".into(), "access", dir.path().join("app.jsonl"), true, "JSONL", ); let mut format = crate::logging::JsonFormat::default(); format.fields.remove("client_ip"); format .fields .insert("remote_addr".into(), "client_ip".into()); let record = normalized_record( &source, r#"{"event":"protocol_error","remote_addr":"192.0.2.10","reason":"tls_handshake_error","error":"HTTP parser error","sni":"app.example.test","matched_by":"sni","vhost":"app"}"#, &format, ); assert_eq!(record["client_ip"], "192.0.2.10"); assert_eq!(record["sni"], "app.example.test"); let query = FileLogQuery::default(); assert!(record_matches( &record, &query, &parse_file_log_search("error").unwrap() )); assert!(record_matches( &record, &query, &parse_file_log_search(r#"error:"HTTP parser""#).unwrap() )); assert!(record_matches( &record, &query, &parse_file_log_search("sni:app.example.test AND !country:Poland").unwrap() )); assert!(!record_matches( &record, &query, &parse_file_log_search("sni:missing").unwrap() )); } #[test] fn stored_log_search_supports_field_aliases_negation_and_numeric_filters() { let record = json!({ "method":"GET", "country":"Germany", "status":503, "path":"/api/users" }); assert!(record_matches_search( &record, &parse_file_log_search("req: GET AND !country: Poland AND status:>=500").unwrap() )); assert!(record_matches_search( &record, &parse_file_log_search("status:5xx path:/api/*").unwrap() )); assert!(!record_matches_search( &record, &parse_file_log_search("country:Poland").unwrap() )); } #[test] fn stored_log_raw_error_parser_extracts_basic_fields() { let record = parse_raw_error_record( "2026-10-05T19:00:00.000Z ERROR rust_proxy::http upstream failed client_ip=192.0.2.10 status=502 error=connection reset", ); assert_eq!(record["timestamp"], "2026-10-05T19:00:00.000Z"); assert_eq!(record["level"], "ERROR"); assert_eq!(record["target"], "rust_proxy::http"); assert_eq!(record["message"], "upstream failed"); assert_eq!(record["client_ip"], "192.0.2.10"); assert_eq!(record["status"], "502"); assert_eq!(record["error"], "connection reset"); } #[cfg(unix)] #[test] fn stored_log_reader_refuses_symlink_targets() { use std::os::unix::fs::symlink; let dir = tempfile::tempdir().unwrap(); let real = dir.path().join("real.jsonl"); let link = dir.path().join("link.jsonl"); std::fs::write(&real, b"{}\n").unwrap(); symlink(&real, &link).unwrap(); assert!(open_file_log(&link).is_err()); } #[test] fn stored_log_tail_reads_rotated_gzip_and_inventory_discovers_it() { use std::io::Write; let dir = tempfile::tempdir().unwrap(); let active = dir.path().join("access.jsonl"); std::fs::write(&active, b"{\"event\":\"request\",\"time_ms\":3}\n").unwrap(); let rotated = dir.path().join("access.jsonl.1.gz"); let file = std::fs::File::create(&rotated).unwrap(); let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); writeln!(encoder, r#"{{"event":"request","time_ms":1}}"#).unwrap(); writeln!(encoder, r#"{{"event":"request","time_ms":2}}"#).unwrap(); encoder.finish().unwrap(); let source = source_from_path( "access:gz:access.jsonl.1.gz".into(), "Access log ยท access.jsonl.1.gz".into(), "access".into(), "access", rotated.clone(), true, "JSONL", ); assert!(source.compressed); let (lines, bytes, truncated) = read_tail(&source, FILE_LOG_SCAN_BYTES_TOTAL, 10).unwrap(); assert_eq!(bytes, std::fs::metadata(&rotated).unwrap().len()); assert!(!truncated); assert_eq!(lines.len(), 2); assert!(lines[0].contains(r#""time_ms":2"#)); let mut config = Config::default(); config.logging.file.per_vhost = false; config.logging.file.path = active.to_string_lossy().into_owned(); let sources = configured_file_log_sources(&config); assert!(sources .iter() .any(|item| item.path == rotated && item.compressed)); } #[test] fn stored_log_per_vhost_inventory_groups_active_and_rotated_files() { use std::io::Write; let dir = tempfile::tempdir().unwrap(); let active = dir.path().join("app.jsonl"); std::fs::write(&active, b"{\"event\":\"request\"}\n").unwrap(); let rotated = dir.path().join("app.jsonl.1.gz"); let file = std::fs::File::create(&rotated).unwrap(); let mut encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); writeln!(encoder, r#"{{"event":"request"}}"#).unwrap(); encoder.finish().unwrap(); let mut config = Config::default(); config.logging.file.per_vhost = true; config.logging.file.directory = dir.path().to_string_lossy().into_owned(); let sources = configured_file_log_sources(&config); let app = sources .iter() .filter(|source| source.group_id == "vhost:app") .collect::>(); assert_eq!(app.len(), 2); assert!(app.iter().any(|source| source.id == "vhost:app")); assert!(app.iter().any(|source| source.compressed)); } #[test] fn stored_log_aggregate_values_support_scalars_and_bounded_arrays() { let value = json!(["BadBot/1", "BadBot/1", 503, true, {"ignored":"object"}]); let mut values = Vec::new(); file_log_aggregate_values(&value, &mut values); assert_eq!( values, vec![ "BadBot/1".to_string(), "BadBot/1".to_string(), "503".to_string(), "true".to_string() ] ); } #[test] fn stored_log_query_validates_aggregation_controls() { assert!(validate_file_log_query(&FileLogQuery { aggregate_field: Some("user_agent".into()), aggregate_min_count: Some(2), aggregate_limit: Some(50), ..FileLogQuery::default() }) .is_ok()); assert!(validate_file_log_query(&FileLogQuery { aggregate_field: Some("bad field".into()), ..FileLogQuery::default() }) .is_err()); } #[test] fn stored_logs_sort_globally_before_pagination_and_keep_missing_last() { let original = vec![ json!({"time_ms": 200, "status": 500, "nested": {"count": 9}, "method": "POST", "path": "/z"}), json!({"time_ms": 300, "status": 200, "nested": {"count": 2}, "method": "GET", "path": "/a"}), json!({"time_ms": 100, "status": 404, "nested": {"count": 5}, "method": "GET", "path": "/b"}), json!({"time_ms": 400, "nested": {}, "message": "missing status"}), ]; let mut records = original.clone(); sort_file_log_records(&mut records, "status", false); assert_eq!( records .iter() .map(|v| v["status"].as_u64()) .collect::>(), vec![Some(200), Some(404), Some(500), None] ); assert_eq!(records[1]["status"], 404); sort_file_log_records(&mut records, "status", true); assert_eq!( records .iter() .map(|v| v["status"].as_u64()) .collect::>(), vec![Some(500), Some(404), Some(200), None] ); sort_file_log_records(&mut records, "nested.count", false); assert_eq!( records .iter() .map(|v| v["nested"]["count"].as_u64()) .collect::>(), vec![Some(2), Some(5), Some(9), None] ); sort_file_log_records(&mut records, "time", true); assert_eq!( records .iter() .map(|v| v["time_ms"].as_u64()) .collect::>(), vec![Some(400), Some(300), Some(200), Some(100)] ); sort_file_log_records(&mut records, "record", false); assert_eq!(records[0]["path"], "/a"); assert_eq!(records[3]["path"], "/z"); assert!(validate_file_log_query(&FileLogQuery { sort_by: Some("bad field".into()), ..FileLogQuery::default() }) .is_err()); assert!(validate_file_log_query(&FileLogQuery { sort_by: Some("nested.count".into()), ..FileLogQuery::default() }) .is_ok()); } #[tokio::test] async fn only_current_build_fingerprinted_javascript_and_css_are_served() { let (router, store, _dir, secret, _) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let id = super::asset_build_id(); for (name, suffix) in [ ("/assets/app", "js"), ("/assets/host404", "js"), ("/assets/style", "css"), ("/assets/login", "js"), ("/docs/init", "js"), ("/docs/style", "css"), ("/docs/swagger-ui-bundle", "js"), ("/docs/swagger-ui", "css"), ] { let legacy = format!("{name}.{suffix}"); let wrong = format!("{name}_0000000000000000.{suffix}"); let current = format!("{name}_{id}.{suffix}"); for path in [legacy, wrong] { let response = router .clone() .oneshot( request(&path) .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::NOT_FOUND, "{path}"); } let response = router .clone() .oneshot( request(¤t) .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK, "{current}"); } store.close().await; } #[tokio::test] async fn public_document_and_discovery_contain_only_login_information() { let (router, store, _dir, _, _) = fixture().await; for (path, expected) in [ ("/healthz", json!({"status":"ok"})), ("/api/auth/status", json!({"setup_required":false})), ] { let response = router .clone() .oneshot(request(path).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); assert_eq!(json_body(response).await, expected); } let response = router .clone() .oneshot(request("/").body(Body::empty()).unwrap()) .await .unwrap(); let bytes = axum::body::to_bytes(response.into_body(), 100_000) .await .unwrap(); let text = std::str::from_utf8(&bytes).unwrap(); assert!(text.contains(&format!("/assets/login_{}.js", super::asset_build_id()))); for absent in [ "/assets/app.js", "id=\"workspace\"", "loginLead", "config-editor", "version", ] { assert!(!text.contains(absent), "unexpected public content {absent}"); } let response = router .clone() .oneshot( request("/assets/login-langs.json") .body(Body::empty()) .unwrap(), ) .await .unwrap(); let bundle = json_body(response).await; let keys: Vec = serde_json::from_str(include_str!("web/login-keys.json")).unwrap(); for language in bundle["languages"].as_array().unwrap() { assert!(language["translations"] .as_object() .unwrap() .keys() .all(|key| keys.contains(key))); assert!(language["translations"].get("cacheTitle").is_none()); } store.close().await; } #[test] fn directory_inventory_retains_newest_with_deterministic_ties() { let candidates = (0..10_000).map(|index| { let name = format!("{index:05}.jsonl.gz"); ( name.clone(), std::path::PathBuf::from(name), (index / 10) as u64, ) }); let files = newest_log_files(candidates, 5); assert_eq!(files.len(), 5); let names: Vec<_> = files.iter().map(|entry| entry.0.as_str()).collect(); assert_eq!( names, [ "09990.jsonl.gz", "09991.jsonl.gz", "09992.jsonl.gz", "09993.jsonl.gz", "09994.jsonl.gz" ] ); assert!(newest_log_files(std::iter::once(("x".into(), "x".into(), 1)), 0).is_empty()); } #[tokio::test] async fn system_info_and_compact_live_status_reuse_existing_admin_session() { let (router, store, _dir, secret, _) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let info = router .clone() .oneshot( request("/api/system-info") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(info.status(), StatusCode::OK); let info = json_body(info).await; assert!(info.get("system").is_some()); assert!(info.get("runtime").is_some()); assert!(info.get("memory_accounting").is_some()); assert!(info.get("config").is_none()); let live = router .clone() .oneshot( request("/api/status?view=live") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(live.status(), StatusCode::OK); let live = json_body(live).await; assert!(live.get("metrics").is_some()); assert!(live["live_system"].get("sampled_at_ms").is_some()); assert!(live["live_system"].get("process_cpu_percent").is_some()); assert!(live["live_system"].get("process_rss_bytes").is_some()); assert!(live["live_system"].get("network_interfaces").is_some()); assert!(live["live_system"].get("open_fds").is_none()); assert!(live.get("backend_pools").is_some()); assert!(live["listeners"].get("active_endpoints").is_some()); assert!(live.get("system").is_none()); assert!(live.get("memory_accounting").is_none()); assert!(live.get("administration").is_none()); let complete = router .clone() .oneshot( request("/api/status") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(complete.status(), StatusCode::OK); assert!(json_body(complete).await.get("system").is_some()); let invalid = router .clone() .oneshot( request("/api/status?view=unknown") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(invalid.status(), StatusCode::BAD_REQUEST); let bad_sse = router .oneshot( request("/api/events?interval=4") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(bad_sse.status(), StatusCode::UNPROCESSABLE_ENTITY); store.close().await; } #[test] fn defaults_and_partial_legacy_refresh_documents() { let previous = serde_json::to_value(AdminPreferences::default()).unwrap(); let mut legacy = previous.as_object().unwrap().clone(); legacy.remove("refresh"); let loaded: AdminPreferences = serde_json::from_value(Value::Object(legacy)).unwrap(); assert_eq!(loaded.refresh, RefreshPreferences::default()); let partial: RefreshPreferences = serde_json::from_str(r#"{"traffic_seconds":0}"#).unwrap(); assert_eq!(partial.traffic_seconds, 0); assert_eq!(partial.system_info_seconds, 60); assert!(validate_admin_preferences(&loaded).is_ok()); assert_eq!(loaded.refresh.overview_seconds, 3); } #[test] fn refresh_values_are_allowlisted() { let mut value = RefreshPreferences::default(); for seconds in [0, 4, 11, 65000] { value.overview_seconds = seconds; assert!(validate_refresh_preferences(&value).is_err()); } value.overview_seconds = 3; assert!(validate_refresh_preferences(&value).is_ok()); value.overview_seconds = 2; assert!(validate_refresh_preferences(&value).is_ok()); value.overview_seconds = 1; for seconds in [1, 2, 4, 31] { value.traffic_seconds = seconds; assert!(validate_refresh_preferences(&value).is_err()); } value.traffic_seconds = 0; value.cache_seconds = 0; value.system_info_seconds = 0; assert!(validate_refresh_preferences(&value).is_ok()); value.system_info_seconds = 16; assert!(validate_refresh_preferences(&value).is_err()); assert!(serde_json::from_str::(r#"{"unexpected":1}"#).is_err()); } #[tokio::test] async fn cache_system_events_and_static_private_assets_reject_anonymous_requests() { let (router, store, _dir, secret, csrf) = fixture().await; for path in [ "/api/status", "/api/status?view=live", "/api/system-info", "/api/config", "/api/preferences", "/api/dashboard-presets", "/api/cache", "/api/cache/bans", "/api/audit", "/api/logs", "/api/traffic", "/api/events", "/api/live-logs", "/api/file-logs", "/api/certificate-api", "/docs/openapi.json", "/assets/app.js", "/assets/cache-panel.js", "/assets/langs.json", ] { let response = router .clone() .oneshot(request(path).body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "anonymous GET {path}" ); assert_eq!(response.headers().get("x-frame-options").unwrap(), "DENY"); assert!(response.headers().get("content-security-policy").is_some()); } let cookie = format!("{}={secret}", auth::COOKIE_NAME); for (method, path) in [ ("POST", "/api/cache/purge"), ("POST", "/api/cache/invalidate"), ("DELETE", "/api/cache/bans/1"), ("PUT", "/api/config/cache"), ("PUT", "/api/preferences"), ("PUT", "/api/dashboard-presets/example"), ("DELETE", "/api/dashboard-presets/example"), ("PATCH", "/api/dashboard-presets/example"), ("POST", "/api/auth/logout-all"), ] { let no_session = request(path).method(method).body(Body::empty()).unwrap(); let response = router.clone().oneshot(no_session).await.unwrap(); assert_eq!( response.status(), StatusCode::UNAUTHORIZED, "anonymous {method} {path}" ); let without_csrf = request(path) .method(method) .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .body(Body::empty()) .unwrap(); let response = router.clone().oneshot(without_csrf).await.unwrap(); assert_eq!( response.status(), StatusCode::FORBIDDEN, "CSRF {method} {path}" ); } let response = router .oneshot( request("/api/auth/logout") .method("POST") .header("cookie", &cookie) .header("origin", "http://127.0.0.1:9090") .header("x-rustproxy-request", "1") .header("x-csrf-token", &csrf) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); store.close().await; } #[tokio::test] async fn admin_origin_host_and_fetch_metadata_are_checked_even_with_valid_cookie() { let (router, store, _dir, secret, _csrf) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let evil_origin = request("/api/status") .header("cookie", &cookie) .header("origin", "https://another.example") .body(Body::empty()) .unwrap(); let response = router.clone().oneshot(evil_origin).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let mut repeated_host = request("/api/status") .header("cookie", &cookie) .body(Body::empty()) .unwrap(); repeated_host .headers_mut() .append("host", "other.example".parse().unwrap()); let response = router.clone().oneshot(repeated_host).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let cross_site = request("/api/status") .header("cookie", &cookie) .header("sec-fetch-site", "cross-site") .body(Body::empty()) .unwrap(); let response = router.clone().oneshot(cross_site).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); store.close().await; } #[test] fn pinned_admin_proxy_proto_is_bounded_and_requires_external_https() { let mut headers = HeaderMap::new(); assert!(pinned_admin_proto_compatible(&headers)); headers.insert("x-forwarded-proto", HeaderValue::from_static("https")); assert!(pinned_admin_proto_compatible(&headers)); headers.insert("x-forwarded-proto", HeaderValue::from_static("https, http")); assert!(pinned_admin_proto_compatible(&headers)); headers.append("x-forwarded-proto", HeaderValue::from_static("http")); assert!(pinned_admin_proto_compatible(&headers)); for bad in [ "http", "http, https", "https, ftp", "https,", ",https", "HTTPS", "https, http, http, http, http, http, http, http, http", ] { headers.remove("x-forwarded-proto"); headers.insert("x-forwarded-proto", HeaderValue::from_str(bad).unwrap()); assert!( !pinned_admin_proto_compatible(&headers), "must reject {bad}" ); } } #[tokio::test] async fn pinned_panel_origin_survives_multi_hop_proxy_without_relaxing_csrf() { let mut config = Config::default(); config.administration.public_origin = Some("https://panel.example.test".into()); let (router, store, _dir, secret, csrf) = fixture_with_config(config).await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let result = router .clone() .oneshot( request("/api/auth/session") .header("cookie", &cookie) .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .header("sec-fetch-site", "same-origin") .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(result.status(), StatusCode::OK); let session_cookie = result .headers() .get_all(SET_COOKIE) .iter() .filter_map(|v| v.to_str().ok()) .find(|v| v.starts_with(auth::COOKIE_NAME)) .unwrap(); assert!(session_cookie.contains("; Secure")); let pinned_host = Request::builder() .uri("/api/auth/session") .header("host", "panel.example.test") .header("cookie", &cookie) .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .extension(PeerAddr("127.0.0.1:45003".parse().unwrap())) .body(Body::empty()) .unwrap(); assert_eq!( router.clone().oneshot(pinned_host).await.unwrap().status(), StatusCode::OK ); let unauthorized = router .clone() .oneshot( request("/api/auth/logout") .method("POST") .header("cookie", &cookie) .header("origin", "https://panel.example.test") .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .extension(PeerAddr("127.0.0.1:45001".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(unauthorized.status(), StatusCode::FORBIDDEN); let authorized = router .oneshot( request("/api/auth/logout") .method("POST") .header("cookie", &cookie) .header("origin", "https://panel.example.test") .header("x-rustproxy-request", "1") .header("x-csrf-token", csrf) .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .extension(PeerAddr("127.0.0.1:45002".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(authorized.status(), StatusCode::OK); store.close().await; } #[tokio::test] async fn pinned_panel_origin_rejects_untrusted_proxy_host_and_origin() { let mut config = Config::default(); config.administration.public_origin = Some("https://panel.example.test".into()); let (router, store, _dir, secret, _) = fixture_with_config(config).await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); for (xfh, xfp, origin, fetch_site) in [ ( "evil.example.test", "https, http", "https://panel.example.test", "same-origin", ), ( "other@panel.example.test", "https, http", "https://panel.example.test", "same-origin", ), ( "panel.example.test", "http", "https://panel.example.test", "same-origin", ), ( "panel.example.test", "http, https", "https://panel.example.test", "same-origin", ), ( "panel.example.test", "https, http", "https://evil.example.test", "same-origin", ), ("panel.example.test", "https, http", "null", "same-origin"), ( "panel.example.test", "https, http", "https://panel.example.test", "cross-site", ), ] { let result = router .clone() .oneshot( request("/api/auth/session") .header("cookie", &cookie) .header("x-forwarded-host", xfh) .header("x-forwarded-proto", xfp) .header("origin", origin) .header("sec-fetch-site", fetch_site) .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(result.status(), StatusCode::FORBIDDEN, "must reject forwarded-host={xfh}, proto={xfp}, origin={origin}, fetch={fetch_site}"); } let automation = router .clone() .oneshot( request("/api/certificates/deploy") .method("POST") .header(CERTIFICATE_API_KEY_HEADER, "svc-key") .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(automation.status(), StatusCode::FORBIDDEN); let untrusted = router .clone() .oneshot( request("/api/auth/session") .header("cookie", &cookie) .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .header("origin", "https://panel.example.test") .extension(PeerAddr("192.0.2.42:44000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(untrusted.status(), StatusCode::FORBIDDEN); let mut duplicated = request("/api/auth/session") .header("cookie", &cookie) .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(); duplicated .headers_mut() .append("x-forwarded-host", "panel.example.test".parse().unwrap()); assert_eq!( router.clone().oneshot(duplicated).await.unwrap().status(), StatusCode::FORBIDDEN ); store.close().await; } #[tokio::test] async fn without_public_origin_ambiguous_forwarded_proto_stays_forbidden() { let mut config = Config::default(); config.administration.allowed_hosts = vec!["panel.example.test".into()]; let (router, store, _dir, secret, _) = fixture_with_config(config).await; let result = router .oneshot( request("/api/auth/session") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .header("x-forwarded-host", "panel.example.test") .header("x-forwarded-proto", "https, http") .extension(PeerAddr("127.0.0.1:45000".parse().unwrap())) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(result.status(), StatusCode::FORBIDDEN); store.close().await; } #[tokio::test] async fn configuration_get_requires_authentication_and_reports_sqlite_storage() { let (router, store, _dir, secret, _) = fixture().await; let unauthenticated = router .clone() .oneshot(request("/api/config").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); let response = router .clone() .oneshot( request("/api/config") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let payload = json_body(response).await; assert!(payload["revision"].is_number()); assert!(payload["config"].is_object()); assert!(payload["config"].get("storage").is_none()); assert_eq!(payload["storage"]["mode"], "sqlite"); assert!(payload["storage"]["config_path"].is_null()); assert!(payload["storage"]["database_path"].is_null()); let old_route = router .oneshot( request("/api/config/storage") .header("cookie", format!("{}={secret}", auth::COOKIE_NAME)) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(old_route.status(), StatusCode::METHOD_NOT_ALLOWED); store.close().await; } #[tokio::test] async fn configuration_get_reports_explicit_json_and_private_sqlite_paths() { let dir = tempfile::tempdir().unwrap(); let config = Config::default(); let policy = ValidationPolicy { tls_addr: None, cert_dir: None, data_dir: Some(dir.path().to_path_buf()), proxy_addr: "127.0.0.1:8080".parse().unwrap(), admin_addr: "127.0.0.1:9090".parse().unwrap(), }; let store = Store::open(&dir.path().join("config.sqlite3"), &config) .await .unwrap(); store .initialize_admin("owner", "unused-test-hash", "test") .await .unwrap(); let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf()) .await .unwrap(); let issued = store .create_session(1, "browser", None) .await .unwrap() .unwrap(); let snapshot = config::compile(config, 0, &policy).unwrap(); let state = App::new( store.clone(), snapshot, policy, auth, 10, Duration::from_secs(30), Duration::from_secs(30), Duration::from_secs(300), ); let file = dir.path().join("config.json"); state.set_config_file(file.clone(), [0u8; 32]).await; let response = router(state) .oneshot( request("/api/config") .header("cookie", format!("{}={}", auth::COOKIE_NAME, issued.secret)) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let payload = json_body(response).await; assert!(payload["revision"].is_number()); assert!(payload["config"].is_object()); assert!(payload["config"].get("storage").is_none()); assert_eq!(payload["storage"]["mode"], "file"); assert_eq!( payload["storage"]["config_path"], file.to_string_lossy().as_ref() ); assert_eq!( payload["storage"]["database_path"], dir.path().join("config.sqlite3").to_string_lossy().as_ref() ); store.close().await; } #[tokio::test] async fn json_metrics_api_is_authenticated_and_matches_prometheus_families() { let (router, store, _dir, secret, _) = fixture().await; let cookie = format!("{}={secret}", auth::COOKIE_NAME); let snapshot = router .clone() .oneshot( request("/api/metrics/json") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(snapshot.status(), StatusCode::OK); assert!(snapshot.headers()["content-type"] .to_str() .unwrap() .starts_with("application/json")); let snapshot = json_body(snapshot).await; for field in [ "summary", "vhosts", "requests", "system", "cache", "backends", "web_server", "streams", "docker", "traffic", "transform", "logging", "capacity", "prometheus", ] { assert!(snapshot.get(field).is_some(), "missing: {field}"); } assert_eq!(snapshot["vhosts"]["total"], 0); assert_eq!(snapshot["vhosts"]["enabled"], 0); assert_eq!(snapshot["vhosts"]["disabled"], 0); assert_eq!(snapshot["summary"]["vhosts"]["enabled"], 0); assert_eq!(snapshot["summary"]["backends"]["total"], 0); assert!(snapshot["summary"]["traffic"]["bytes_total"].is_number()); assert!(snapshot["prometheus"]["samples"]["rust_proxy_requests_total"].is_array()); assert_eq!( snapshot["prometheus"]["types"]["rust_proxy_requests_total"], "counter" ); assert!(general_api_token_allowed( &Method::GET, "/api/metrics/json", "ro" )); assert!(general_api_token_allowed( &Method::GET, "/api/metrics/summary", "ro" )); assert!(!general_api_token_allowed( &Method::POST, "/api/metrics/json", "rw" )); assert!(!general_api_token_allowed( &Method::POST, "/api/metrics/summary", "rw" )); let summary = router .clone() .oneshot( request("/api/metrics/summary") .header("cookie", &cookie) .body(Body::empty()) .unwrap(), ) .await .unwrap(); assert_eq!(summary.status(), StatusCode::OK); assert_eq!(summary.headers()["cache-control"], "no-store"); let summary = json_body(summary).await; assert!(summary.get("prometheus").is_none()); assert_eq!( summary["vhosts"]["enabled"], snapshot["summary"]["vhosts"]["enabled"] ); assert_eq!( summary["backends"]["total"], snapshot["summary"]["backends"]["total"] ); store.close().await; } #[tokio::test] async fn json_metrics_accepts_ro_bearer_only_from_allowed_peer() { let (router, store, _dir, _, _) = fixture().await; let token = "T".repeat(64); let cidrs = vec!["127.0.0.1/32".to_string()]; assert!(store .create_api_token( "api-monitor", &auth::secret_hash(&token), "ro", "monitor test", &cidrs, "owner" ) .await .unwrap()); let build = |peer: Option<&str>| { let mut request = request("/api/metrics/json").header("authorization", format!("Bearer {token}")); if let Some(peer) = peer { request = request.extension(PeerAddr(peer.parse().unwrap())); } request.body(Body::empty()).unwrap() }; let response = router.clone().oneshot(build(None)).await.unwrap(); assert_eq!(response.status(), StatusCode::FORBIDDEN); let response = router .clone() .oneshot(build(Some("127.0.0.1:12345"))) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); let response = json_body(response).await; assert!(response["prometheus"]["samples"].is_object()); assert!(response.get("config").is_none()); let summarize = |peer: Option<&str>| { let mut req = request("/api/metrics/summary").header("authorization", format!("Bearer {token}")); if let Some(peer) = peer { req = req.extension(PeerAddr(peer.parse().unwrap())); } req.body(Body::empty()).unwrap() }; assert_eq!( router .clone() .oneshot(summarize(None)) .await .unwrap() .status(), StatusCode::FORBIDDEN ); let summary = router .oneshot(summarize(Some("127.0.0.1:12345"))) .await .unwrap(); assert_eq!(summary.status(), StatusCode::OK); let summary = json_body(summary).await; assert!(summary["traffic"]["requests_total"].is_number()); assert!(summary.get("prometheus").is_none()); store.close().await; } #[test] fn storage_metadata_is_exposed_only_through_configuration_get_to_api_tokens() { assert!(general_api_token_allowed(&Method::GET, "/api/config", "ro")); assert!(general_api_token_allowed(&Method::GET, "/api/config", "rw")); assert!(!general_api_token_allowed( &Method::GET, "/api/config/storage", "ro" )); assert!(!general_api_token_allowed( &Method::PUT, "/api/config/storage", "rw" )); assert!(!general_api_token_allowed( &Method::POST, "/api/config/storage", "rw" )); } include!("preferences_tests.rs"); }