#[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":2, "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["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["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", "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 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, selected); let saved: Value = serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap()) .unwrap(); assert_eq!(saved, selected); // Preferences must also survive a subsequent authenticated read. let persisted = router.clone().oneshot(request("/api/preferences") .header("cookie", &cookie).body(Body::empty()).unwrap()).await.unwrap(); assert_eq!(json_body(persisted).await, selected); // Wrong allowlist value is rejected even if the rest is valid. 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); } // This fixture has no certificate directory: valid auth reaches the // 409 precondition, never a child process, DNS update or CA request. 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", "/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"], "no-store"); 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; }