WP-20/21: contract and failing tests for vulnerability management
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:40:48 +02:00
parent e6ef9b21d3
commit 5c6e09ad10
17 changed files with 1530 additions and 1 deletions

View File

@ -6,3 +6,4 @@ mod scheduler_tests;
mod settings_tests;
mod upgrade_tests;
mod user_service_tests;
mod vuln_tests;

View File

@ -0,0 +1,278 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::vuln::{FindingFilter, FindingStatus, Severity, TargetKind};
use domain::DomainError;
use crate::jobs::JobLog;
use crate::test_fakes::{
raw, smtp, FakeCipher, FakeScanner, MemCluster, MemFindings, MemMailer, MemSettings,
ScanResults,
};
use crate::{SettingsService, VulnerabilityService};
#[derive(Default)]
struct VecLog(Mutex<Vec<String>>);
#[async_trait]
impl JobLog for VecLog {
async fn line(&self, text: &str) {
self.0.lock().unwrap().push(text.into());
}
}
struct F {
findings: Arc<MemFindings>,
mailer: Arc<MemMailer>,
settings: Arc<SettingsService>,
results: ScanResults,
}
fn fixture(scanner: FakeScanner) -> (F, VulnerabilityService) {
let findings = Arc::new(MemFindings::default());
let mailer = Arc::new(MemMailer::default());
let results = scanner.results.clone();
let settings = Arc::new(SettingsService::new(
Arc::new(MemSettings::default()),
Arc::new(FakeCipher),
mailer.clone(),
));
let svc = VulnerabilityService::new(
Arc::new(scanner),
findings.clone(),
Arc::new(MemCluster::default()),
settings.clone(),
);
(
F {
findings,
mailer,
settings,
results,
},
svc,
)
}
const GITEA: &str = "gitea/gitea:1.22.3";
const PG: &str = "bitnami/postgresql:16.4.0";
#[tokio::test]
async fn first_scan_inserts_findings_for_os_and_all_cluster_images() {
let scanner = FakeScanner::default()
.with(
"os",
Ok(vec![raw(
"CVE-1",
"openssl",
"3.0.1",
Severity::High,
Some("3.0.2"),
)]),
)
.with(
GITEA,
Ok(vec![
raw("CVE-2", "git", "2.39", Severity::Critical, None),
raw("CVE-3", "curl", "7.88", Severity::Low, None),
]),
);
let (f, svc) = fixture(scanner);
let log = VecLog::default();
let report = svc.scan(&log).await.unwrap();
assert_eq!(report.targets, vec!["os", PG, GITEA]);
assert_eq!(report.new_findings.len(), 3);
assert_eq!(report.total_open, 3);
assert_eq!(report.fixed, 0);
let all = f.findings.0.lock().unwrap();
let os = all.iter().find(|x| x.target == "os").unwrap();
assert_eq!(os.target_kind, TargetKind::Os);
assert_eq!(os.status, FindingStatus::Open);
assert_eq!(os.raw.fixed_version.as_deref(), Some("3.0.2"));
assert!(all
.iter()
.any(|x| x.target == GITEA && x.target_kind == TargetKind::Image));
let lines = log.0.lock().unwrap().join("\n");
assert!(lines.contains("scanning os"), "{lines}");
assert!(lines.contains(GITEA), "{lines}");
}
#[tokio::test]
async fn rescan_keeps_existing_marks_fixed_and_reports_only_new() {
let scanner = FakeScanner::default().with(
"os",
Ok(vec![
raw("CVE-1", "openssl", "3.0.1", Severity::High, None),
raw("CVE-9", "bash", "5.2", Severity::Low, None),
]),
);
let (f, svc) = fixture(scanner);
let first = svc.scan(&VecLog::default()).await.unwrap();
assert_eq!(first.new_findings.len(), 2);
let first_seen = f.findings.0.lock().unwrap()[0].first_seen;
// CVE-9 disappears (fixed), CVE-1 stays, CVE-2 is new
*f.results.lock().unwrap() = std::collections::HashMap::from([(
"os".to_string(),
Ok(vec![
raw("CVE-1", "openssl", "3.0.1", Severity::High, None),
raw("CVE-2", "openssl", "3.0.1", Severity::Critical, None),
]),
)]);
let second = svc.scan(&VecLog::default()).await.unwrap();
assert_eq!(
second
.new_findings
.iter()
.map(|f| f.raw.cve_id.as_str())
.collect::<Vec<_>>(),
vec!["CVE-2"]
);
assert_eq!(second.fixed, 1);
assert_eq!(second.total_open, 2);
let all = f.findings.0.lock().unwrap();
let cve1 = all.iter().find(|x| x.raw.cve_id == "CVE-1").unwrap();
assert_eq!(cve1.first_seen, first_seen, "first_seen is preserved");
assert!(cve1.last_seen > first_seen);
assert_eq!(
all.iter().find(|x| x.raw.cve_id == "CVE-9").unwrap().status,
FindingStatus::Fixed
);
}
#[tokio::test]
async fn failed_target_is_skipped_and_its_findings_are_kept() {
let scanner = FakeScanner::default()
.with("os", Ok(vec![raw("CVE-1", "a", "1", Severity::High, None)]))
.with(GITEA, Err("pull failed"));
let (f, svc) = fixture(scanner);
svc.scan(&VecLog::default()).await.unwrap();
*f.results.lock().unwrap() =
std::collections::HashMap::from([("os".to_string(), Err("trivy crashed".to_string()))]);
let log = VecLog::default();
let report = svc.scan(&log).await.unwrap();
assert_eq!(report.failed_targets, vec!["os"]);
assert_eq!(
f.findings
.0
.lock()
.unwrap()
.iter()
.filter(|x| x.status == FindingStatus::Open)
.count(),
1,
"not marked fixed on failure"
);
assert!(log
.0
.lock()
.unwrap()
.iter()
.any(|l| l.contains("trivy crashed")));
// scanner not installed at all -> hard error
*f.results.lock().unwrap() = std::collections::HashMap::new();
svc.scan(&VecLog::default()).await.unwrap();
}
#[tokio::test]
async fn new_findings_at_or_above_threshold_trigger_one_mail() {
let scanner = FakeScanner::default().with(
"os",
Ok(vec![
raw("CVE-1", "a", "1", Severity::Critical, Some("2")),
raw("CVE-2", "b", "1", Severity::Medium, None),
]),
);
let (f, svc) = fixture(scanner);
// no smtp configured -> no mail, no error
svc.scan(&VecLog::default()).await.unwrap();
assert!(f.mailer.0.lock().unwrap().is_empty());
f.settings.set_smtp(smtp()).await.unwrap();
*f.results.lock().unwrap() = std::collections::HashMap::from([(
"os".to_string(),
Ok(vec![
raw("CVE-3", "c", "1", Severity::High, None),
raw("CVE-4", "d", "1", Severity::Low, None),
]),
)]);
svc.scan(&VecLog::default()).await.unwrap();
let sent = f.mailer.0.lock().unwrap();
assert_eq!(sent.len(), 1);
assert!(sent[0].1.contains("1 new"), "{}", sent[0].1);
assert!(sent[0].2.contains("CVE-3"));
assert!(!sent[0].2.contains("CVE-4"), "below threshold");
drop(sent);
f.settings
.set_notify_min_severity(Severity::Low)
.await
.unwrap();
*f.results.lock().unwrap() = std::collections::HashMap::from([(
"os".to_string(),
Ok(vec![raw("CVE-5", "e", "1", Severity::Low, None)]),
)]);
svc.scan(&VecLog::default()).await.unwrap();
assert_eq!(f.mailer.0.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn list_summary_and_status_changes() {
let scanner = FakeScanner::default()
.with(
"os",
Ok(vec![
raw("CVE-1", "a", "1", Severity::Critical, None),
raw("CVE-2", "b", "1", Severity::Low, None),
]),
)
.with(
GITEA,
Ok(vec![raw("CVE-3", "c", "1", Severity::High, None)]),
);
let (_f, svc) = fixture(scanner);
svc.scan(&VecLog::default()).await.unwrap();
let high = svc
.list(FindingFilter {
min_severity: Some(Severity::High),
..Default::default()
})
.await
.unwrap();
assert_eq!(
high.iter()
.map(|f| f.raw.cve_id.as_str())
.collect::<Vec<_>>(),
vec!["CVE-1", "CVE-3"]
);
let s = svc.summary().await.unwrap();
assert_eq!(s.total.total(), 3);
assert_eq!(s.os.critical, 1);
assert_eq!(s.images.high, 1);
assert!(s.last_scan.is_some());
let id = high[0].id;
let f = svc
.set_status(id, FindingStatus::Acknowledged)
.await
.unwrap();
assert_eq!(f.status, FindingStatus::Acknowledged);
assert!(matches!(
svc.set_status(id, FindingStatus::Fixed).await.unwrap_err(),
DomainError::Validation(_)
));
assert_eq!(
svc.set_status(uuid::Uuid::new_v4(), FindingStatus::Open)
.await
.unwrap_err(),
DomainError::NotFound
);
let ack = svc
.list(FindingFilter {
status: Some(FindingStatus::Acknowledged),
..Default::default()
})
.await
.unwrap();
assert_eq!(ack.len(), 1);
}