//! WP-20/21: /api/vulnerabilities and notification settings. mod common; use axum::http::StatusCode; use common::{get, post, send_json, test_app_with_admin}; use serde_json::json; const ADMIN: &str = "admin@example.com"; const PW: &str = "admin-password-123"; async fn run_scan(app: &axum::Router, token: &str) -> serde_json::Value { let run = post( app, "/api/jobs/run", json!({"kind": "vulnerability_scan"}), Some(token), ) .await; assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json); let id = run.json["id"].as_str().unwrap().to_string(); for _ in 0..100 { let r = get(app, &format!("/api/jobs/{id}"), Some(token)).await; if r.json["status"] != "running" { return r.json; } tokio::time::sleep(std::time::Duration::from_millis(30)).await; } panic!("scan did not finish"); } #[tokio::test] async fn scan_populates_findings_summary_and_filters() { let app = test_app_with_admin().await; let token = common::login(&app, ADMIN, PW).await.access; let empty = get(&app, "/api/vulnerabilities/summary", Some(&token)).await; assert_eq!(empty.status, StatusCode::OK); assert_eq!(empty.json["total"]["critical"], 0); assert!(empty.json["last_scan"].is_null()); assert!(empty.json["scanner"].as_str().unwrap().contains("fake")); let run = run_scan(&app, &token).await; assert_eq!(run["status"], "success", "{}", run["log"]); assert!(run["log"].as_str().unwrap().contains("new")); let s = get(&app, "/api/vulnerabilities/summary", Some(&token)).await; assert!(s.json["total"]["critical"].as_u64().unwrap() >= 2); assert!(s.json["os"]["high"].as_u64().unwrap() >= 1); assert!(s.json["last_scan"].is_string()); let all = get(&app, "/api/vulnerabilities", Some(&token)).await; let list = all.json.as_array().unwrap(); assert!(list.len() >= 5); assert_eq!(list[0]["severity"], "critical", "sorted by severity"); let crit = get( &app, "/api/vulnerabilities?min_severity=critical", Some(&token), ) .await; assert!(crit .json .as_array() .unwrap() .iter() .all(|f| f["severity"] == "critical")); let os = get(&app, "/api/vulnerabilities?target=os", Some(&token)).await; assert!(os .json .as_array() .unwrap() .iter() .all(|f| f["target"] == "os")); let targets = get(&app, "/api/vulnerabilities/targets", Some(&token)).await; assert!(targets .json .as_array() .unwrap() .iter() .any(|t| t["target"] == "os" && t["kind"] == "os")); // acknowledge one let id = list[0]["id"].as_str().unwrap(); let res = post( &app, &format!("/api/vulnerabilities/{id}/status"), json!({"status": "acknowledged"}), Some(&token), ) .await; assert_eq!(res.status, StatusCode::OK, "{}", res.json); assert_eq!(res.json["status"], "acknowledged"); assert_eq!( post( &app, &format!("/api/vulnerabilities/{id}/status"), json!({"status": "fixed"}), Some(&token) ) .await .status, StatusCode::UNPROCESSABLE_ENTITY ); // second scan reports nothing new let run = run_scan(&app, &token).await; assert!( run["log"].as_str().unwrap().contains("0 new"), "{}", run["log"] ); } #[tokio::test] async fn notification_threshold_setting_and_permissions() { let app = test_app_with_admin().await; let admin = common::login(&app, ADMIN, PW).await.access; let res = get(&app, "/api/settings/notifications", Some(&admin)).await; assert_eq!(res.json["min_severity"], "high"); assert_eq!( send_json( &app, "PUT", "/api/settings/notifications", json!({"min_severity": "medium"}), Some(&admin) ) .await .status, StatusCode::NO_CONTENT ); assert_eq!( get(&app, "/api/settings/notifications", Some(&admin)) .await .json["min_severity"], "medium" ); post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await; let user = common::login(&app, "u@x.de", "user-password-123") .await .access; assert_eq!( get(&app, "/api/vulnerabilities", Some(&user)).await.status, StatusCode::OK ); assert_eq!( send_json( &app, "PUT", "/api/settings/notifications", json!({"min_severity": "low"}), Some(&user) ) .await .status, StatusCode::FORBIDDEN ); assert_eq!( post( &app, "/api/vulnerabilities/00000000-0000-0000-0000-000000000000/status", json!({"status": "acknowledged"}), Some(&user) ) .await .status, StatusCode::FORBIDDEN ); assert_eq!( get(&app, "/api/vulnerabilities", None).await.status, StatusCode::UNAUTHORIZED ); } #[tokio::test] async fn findings_are_scoped_into_host_and_containers() { let app = test_app_with_admin().await; let token = common::login(&app, ADMIN, PW).await.access; run_scan(&app, &token).await; let host = get( &app, "/api/vulnerabilities?scope=host&min_severity=unknown", Some(&token), ) .await; assert_eq!(host.status, StatusCode::OK, "{}", host.json); let host_list = host.json.as_array().unwrap(); assert!(!host_list.is_empty()); assert!(host_list .iter() .all(|f| f["target_kind"] == "os" && f["target"] == "os")); assert!( host_list.iter().any(|f| f["source"] == "debian"), "host findings name their package source" ); let containers = get( &app, "/api/vulnerabilities?scope=container&min_severity=unknown", Some(&token), ) .await; let container_list = containers.json.as_array().unwrap(); assert!(!container_list.is_empty()); assert!(container_list.iter().all(|f| f["target_kind"] == "image")); assert!(container_list .iter() .any(|f| f["target"].as_str().unwrap().contains("gitea"))); let all = get( &app, "/api/vulnerabilities?min_severity=unknown", Some(&token), ) .await; assert_eq!( all.json.as_array().unwrap().len(), host_list.len() + container_list.len() ); assert_eq!( get(&app, "/api/vulnerabilities?scope=nope", Some(&token)) .await .status, StatusCode::UNPROCESSABLE_ENTITY ); // the summary splits the same way let s = get(&app, "/api/vulnerabilities/summary", Some(&token)).await; let sum = |v: &serde_json::Value| { v["critical"].as_u64().unwrap() + v["high"].as_u64().unwrap() + v["medium"].as_u64().unwrap() + v["low"].as_u64().unwrap() + v["unknown"].as_u64().unwrap() }; assert_eq!(sum(&s.json["os"]) as usize, host_list.len()); assert_eq!(sum(&s.json["images"]) as usize, container_list.len()); } #[tokio::test] async fn targets_carry_their_scope_and_open_count() { let app = test_app_with_admin().await; let token = common::login(&app, ADMIN, PW).await.access; run_scan(&app, &token).await; let res = get(&app, "/api/vulnerabilities/targets", Some(&token)).await; let targets = res.json.as_array().unwrap(); assert_eq!(targets[0]["kind"], "os", "the host comes first"); assert_eq!(targets[0]["target"], "os"); assert!(targets[0]["open"].as_u64().unwrap() >= 3); let image = targets .iter() .find(|t| t["target"].as_str().unwrap().contains("gitea")) .unwrap(); assert_eq!(image["kind"], "image"); assert!(image["open"].as_u64().unwrap() >= 1); } #[tokio::test] async fn findings_are_rolled_up_per_package_and_image() { let app = test_app_with_admin().await; let token = common::login(&app, ADMIN, PW).await.access; run_scan(&app, &token).await; let host = get( &app, "/api/vulnerabilities/groups?scope=host&min_severity=unknown", Some(&token), ) .await; assert_eq!(host.status, StatusCode::OK, "{}", host.json); let groups = host.json.as_array().unwrap(); let flat = get( &app, "/api/vulnerabilities?scope=host&min_severity=unknown", Some(&token), ) .await; let flat_len = flat.json.as_array().unwrap().len(); assert!(groups.len() < flat_len, "fewer rows than findings"); assert_eq!( groups .iter() .map(|g| g["total"].as_u64().unwrap()) .sum::() as usize, flat_len ); let zlib = groups.iter().find(|g| g["key"] == "zlib1g").unwrap(); assert_eq!(zlib["kind"], "os"); assert_eq!(zlib["source"], "debian"); assert_eq!(zlib["counts"]["critical"], 1); assert!(zlib["installed"].as_str().unwrap().starts_with("1:1.2.13")); let images = get( &app, "/api/vulnerabilities/groups?scope=container&min_severity=unknown", Some(&token), ) .await; let images = images.json.as_array().unwrap(); let gitea = images .iter() .find(|g| g["key"].as_str().unwrap().contains("gitea")) .unwrap(); assert_eq!(gitea["kind"], "image"); assert!(gitea["total"].as_u64().unwrap() >= 1); assert!(gitea["packages"].as_u64().unwrap() >= 1); // a group is expanded by filtering the flat list let pkg = get( &app, "/api/vulnerabilities?scope=host&package=zlib1g&min_severity=unknown", Some(&token), ) .await; let pkg = pkg.json.as_array().unwrap(); assert_eq!(pkg.len(), zlib["total"].as_u64().unwrap() as usize); assert!(pkg.iter().all(|f| f["package"] == "zlib1g")); assert_eq!( get(&app, "/api/vulnerabilities/groups?scope=nope", Some(&token)) .await .status, StatusCode::UNPROCESSABLE_ENTITY ); assert_eq!( get(&app, "/api/vulnerabilities/groups", None).await.status, StatusCode::UNAUTHORIZED ); }