diff --git a/src/api/handlers/alerts.rs b/src/api/handlers/alerts.rs index d14d3e5..aa561c5 100644 --- a/src/api/handlers/alerts.rs +++ b/src/api/handlers/alerts.rs @@ -23,7 +23,11 @@ pub async fn list_alerts( ) -> AppResult>>> { let alerts = state .alert_repo - .list(params.severity.as_deref(), params.state.as_deref()) + .list( + params.severity.as_deref(), + params.state.as_deref(), + state.seed_mock_data(), + ) .await; let page = params.page.unwrap_or(1).max(1); @@ -50,7 +54,7 @@ pub async fn list_alerts( pub async fn get_summary( State(state): State, ) -> AppResult>> { - let summary = state.alert_repo.summary().await; + let summary = state.alert_repo.summary(state.seed_mock_data()).await; Ok(Json(ApiResponse::ok(summary))) } diff --git a/src/models/alert.rs b/src/models/alert.rs index a0e8817..c9a107c 100644 --- a/src/models/alert.rs +++ b/src/models/alert.rs @@ -63,6 +63,8 @@ pub struct Alert { pub source: String, #[serde(skip_serializing_if = "Option::is_none")] pub external_ticket_id: Option, + #[serde(skip)] + pub mock: bool, } /// Summary statistics for the dashboard. @@ -100,6 +102,7 @@ pub fn seed_alerts() -> Vec { sla_window: Some("15m".into()), source: "verifier".into(), external_ticket_id: None, + mock: true, }, Alert { id: Uuid::parse_str("a0000001-0000-4000-8000-000000000002").unwrap(), @@ -121,6 +124,7 @@ pub fn seed_alerts() -> Vec { sla_window: Some("30m".into()), source: "verifier".into(), external_ticket_id: None, + mock: true, }, Alert { id: Uuid::parse_str("a0000001-0000-4000-8000-000000000003").unwrap(), @@ -140,6 +144,7 @@ pub fn seed_alerts() -> Vec { sla_window: None, source: "certificate-monitor".into(), external_ticket_id: None, + mock: true, }, Alert { id: Uuid::parse_str("a0000001-0000-4000-8000-000000000004").unwrap(), @@ -163,6 +168,7 @@ pub fn seed_alerts() -> Vec { sla_window: None, source: "verifier".into(), external_ticket_id: None, + mock: true, }, Alert { id: Uuid::parse_str("a0000001-0000-4000-8000-000000000005").unwrap(), @@ -186,6 +192,7 @@ pub fn seed_alerts() -> Vec { sla_window: Some("15m".into()), source: "verifier".into(), external_ticket_id: Some("SEC-2024-0042".into()), + mock: true, }, Alert { id: Uuid::parse_str("a0000001-0000-4000-8000-000000000006").unwrap(), @@ -205,6 +212,7 @@ pub fn seed_alerts() -> Vec { sla_window: None, source: "verifier".into(), external_ticket_id: None, + mock: true, }, ] } @@ -289,10 +297,12 @@ mod tests { sla_window: None, source: "test".into(), external_ticket_id: None, + mock: false, }; let json = serde_json::to_value(&alert).unwrap(); assert!(json.get("type").is_some()); assert!(json.get("alert_type").is_none()); + assert!(json.get("mock").is_none()); assert_eq!(json["type"], "cert_expiry"); } diff --git a/src/models/alert_store.rs b/src/models/alert_store.rs index 6fb55ac..637f800 100644 --- a/src/models/alert_store.rs +++ b/src/models/alert_store.rs @@ -39,6 +39,7 @@ impl AlertStore { sla_window: Some("15m".into()), source: "verifier".into(), external_ticket_id: None, + mock: true, }, // WARNING: failed push-mode agent consecutive failures (Acknowledged) Alert { @@ -61,6 +62,7 @@ impl AlertStore { sla_window: Some("30m".into()), source: "verifier".into(), external_ticket_id: None, + mock: true, }, // WARNING: certificate approaching expiry (New) Alert { @@ -81,6 +83,7 @@ impl AlertStore { sla_window: None, source: "certificate-monitor".into(), external_ticket_id: None, + mock: true, }, // INFO: PCR change detected on healthy push agent (Resolved) Alert { @@ -105,6 +108,7 @@ impl AlertStore { sla_window: None, source: "verifier".into(), external_ticket_id: None, + mock: true, }, // CRITICAL: policy violation on failed pull agent (Under Investigation) Alert { @@ -130,6 +134,7 @@ impl AlertStore { sla_window: Some("15m".into()), source: "verifier".into(), external_ticket_id: Some("SEC-2024-0042".into()), + mock: true, }, // INFO: clock skew detected (Dismissed) Alert { @@ -150,6 +155,7 @@ impl AlertStore { sla_window: None, source: "verifier".into(), external_ticket_id: None, + mock: true, }, ]; diff --git a/src/repository/alert.rs b/src/repository/alert.rs index e0a8c94..e69e7a4 100644 --- a/src/repository/alert.rs +++ b/src/repository/alert.rs @@ -8,9 +8,14 @@ use crate::models::alert::{seed_alerts, Alert, AlertSeverity, AlertState, AlertS #[async_trait] pub trait AlertRepository: Send + Sync + 'static { - async fn list(&self, severity: Option<&str>, state: Option<&str>) -> Vec; + async fn list( + &self, + severity: Option<&str>, + state: Option<&str>, + include_mock: bool, + ) -> Vec; async fn get(&self, id: Uuid) -> Option; - async fn summary(&self) -> AlertSummary; + async fn summary(&self, include_mock: bool) -> AlertSummary; async fn acknowledge(&self, id: Uuid) -> Result<(), String>; async fn investigate(&self, id: Uuid, assigned_to: Option) -> Result<(), String>; async fn resolve(&self, id: Uuid, resolution: Option) -> Result<(), String>; @@ -33,11 +38,19 @@ impl InMemoryAlertRepository { #[async_trait] impl AlertRepository for InMemoryAlertRepository { - async fn list(&self, severity: Option<&str>, state: Option<&str>) -> Vec { + async fn list( + &self, + severity: Option<&str>, + state: Option<&str>, + include_mock: bool, + ) -> Vec { let alerts = self.alerts.read().unwrap(); alerts .iter() .filter(|a| { + if !include_mock && a.mock { + return false; + } if let Some(sev) = severity { let a_sev = serde_json::to_string(&a.severity).unwrap_or_default(); let a_sev = a_sev.trim_matches('"'); @@ -63,21 +76,25 @@ impl AlertRepository for InMemoryAlertRepository { alerts.iter().find(|a| a.id == id).cloned() } - async fn summary(&self) -> AlertSummary { + async fn summary(&self, include_mock: bool) -> AlertSummary { let alerts = self.alerts.read().unwrap(); + let visible = |a: &&Alert| include_mock || !a.mock; let critical = alerts .iter() + .filter(visible) .filter(|a| a.severity == AlertSeverity::Critical) .count() as u64; let warnings = alerts .iter() + .filter(visible) .filter(|a| a.severity == AlertSeverity::Warning) .count() as u64; let info = alerts .iter() + .filter(visible) .filter(|a| a.severity == AlertSeverity::Info) .count() as u64; @@ -86,11 +103,13 @@ impl AlertRepository for InMemoryAlertRepository { let active_critical = alerts .iter() + .filter(visible) .filter(|a| a.severity == AlertSeverity::Critical && is_active(a)) .count() as u64; let active_warnings = alerts .iter() + .filter(visible) .filter(|a| a.severity == AlertSeverity::Warning && is_active(a)) .count() as u64; @@ -213,30 +232,30 @@ mod tests { #[tokio::test] async fn seed_data_has_expected_alerts() { let repo = make_repo(); - let all = repo.list(None, None).await; + let all = repo.list(None, None, true).await; assert_eq!(all.len(), 6); } #[tokio::test] async fn filter_by_severity() { let repo = make_repo(); - let critical = repo.list(Some("critical"), None).await; + let critical = repo.list(Some("critical"), None, true).await; assert_eq!(critical.len(), 2); - let info = repo.list(Some("info"), None).await; + let info = repo.list(Some("info"), None, true).await; assert_eq!(info.len(), 2); } #[tokio::test] async fn filter_by_state() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; assert_eq!(new_alerts.len(), 2); } #[tokio::test] async fn acknowledge_transitions_new_to_acknowledged() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; let id = new_alerts[0].id; repo.acknowledge(id).await.unwrap(); @@ -249,7 +268,7 @@ mod tests { #[tokio::test] async fn acknowledge_rejects_non_new_state() { let repo = make_repo(); - let acked = repo.list(None, Some("acknowledged")).await; + let acked = repo.list(None, Some("acknowledged"), true).await; let id = acked[0].id; let result = repo.acknowledge(id).await; @@ -259,7 +278,7 @@ mod tests { #[tokio::test] async fn investigate_sets_assignee() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; let id = new_alerts[0].id; repo.investigate(id, Some("analyst@example.com".into())) @@ -274,7 +293,7 @@ mod tests { #[tokio::test] async fn resolve_sets_resolution_reason() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; let id = new_alerts[0].id; repo.resolve(id, Some("fixed the issue".into())) @@ -289,7 +308,7 @@ mod tests { #[tokio::test] async fn dismiss_transitions_to_dismissed() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; let id = new_alerts[0].id; repo.dismiss(id).await.unwrap(); @@ -301,7 +320,7 @@ mod tests { #[tokio::test] async fn escalate_increments_count() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; let id = new_alerts[0].id; let before = repo.get(id).await.unwrap().escalation_count; @@ -314,7 +333,7 @@ mod tests { #[tokio::test] async fn cannot_resolve_already_resolved() { let repo = make_repo(); - let resolved = repo.list(None, Some("resolved")).await; + let resolved = repo.list(None, Some("resolved"), true).await; let id = resolved[0].id; let result = repo.resolve(id, None).await; @@ -324,7 +343,7 @@ mod tests { #[tokio::test] async fn summary_counts_active_alerts() { let repo = make_repo(); - let summary = repo.summary().await; + let summary = repo.summary(true).await; assert_eq!(summary.critical, 2); assert_eq!(summary.warnings, 2); assert_eq!(summary.info, 2); @@ -336,7 +355,7 @@ mod tests { #[tokio::test] async fn concurrent_escalations_are_serialized() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; let id = new_alerts[0].id; let before = repo.get(id).await.unwrap().escalation_count; @@ -359,7 +378,7 @@ mod tests { #[tokio::test] async fn concurrent_reads_during_writes() { let repo = make_repo(); - let new_alerts = repo.list(None, Some("new")).await; + let new_alerts = repo.list(None, Some("new"), true).await; let id = new_alerts[0].id; let mut handles = Vec::new(); @@ -368,9 +387,9 @@ mod tests { for _ in 0..20 { let repo = repo.clone(); handles.push(tokio::spawn(async move { - let _ = repo.list(None, None).await; + let _ = repo.list(None, None, true).await; let _ = repo.get(id).await; - let _ = repo.summary().await; + let _ = repo.summary(true).await; })); } diff --git a/src/repository/repository_tests.rs b/src/repository/repository_tests.rs index 9459abe..40c7f93 100644 --- a/src/repository/repository_tests.rs +++ b/src/repository/repository_tests.rs @@ -55,13 +55,13 @@ mod tests { async fn in_memory_factory_creates_working_repos() { let repos = Repositories::in_memory(); - let alerts = repos.alert.list(None, None).await; + let alerts = repos.alert.list(None, None, true).await; assert!( !alerts.is_empty(), "in-memory alert repo should have seed data" ); - let summary = repos.alert.summary().await; + let summary = repos.alert.summary(true).await; assert!(summary.critical > 0); let policies = repos.policy.list().await.unwrap(); @@ -85,10 +85,10 @@ mod tests { let db = test_db().await; let repos = db.repositories(); - let alerts = repos.alert.list(None, None).await; + let alerts = repos.alert.list(None, None, true).await; assert!(alerts.is_empty(), "sqlite alert repo starts empty"); - let summary = repos.alert.summary().await; + let summary = repos.alert.summary(true).await; assert_eq!(summary.critical, 0); assert_eq!(summary.active_alerts, 0); @@ -405,6 +405,7 @@ mod tests { sla_window: None, source: "test".into(), external_ticket_id: None, + mock: false, } } @@ -424,17 +425,17 @@ mod tests { expected_new: usize, label: &str, ) { - let all = alert.list(None, None).await; + let all = alert.list(None, None, true).await; assert_eq!(all.len(), expected_total, "[{label}] total alert count"); - let critical = alert.list(Some("critical"), None).await; + let critical = alert.list(Some("critical"), None, true).await; assert_eq!( critical.len(), expected_critical, "[{label}] critical count" ); - let new = alert.list(None, Some("new")).await; + let new = alert.list(None, Some("new"), true).await; assert_eq!(new.len(), expected_new, "[{label}] new count"); let known_id = Uuid::parse_str(SEED_IDS[0]).unwrap(); @@ -447,7 +448,7 @@ mod tests { "[{label}] get Uuid::nil should return None" ); - let summary = alert.summary().await; + let summary = alert.summary(true).await; assert!( summary.critical > 0 || expected_critical == 0, "[{label}] summary critical count" diff --git a/src/repository/sqlite/alert.rs b/src/repository/sqlite/alert.rs index 9f0219c..e017e22 100644 --- a/src/repository/sqlite/alert.rs +++ b/src/repository/sqlite/alert.rs @@ -55,13 +55,22 @@ fn row_to_alert(row: &sqlx::sqlite::SqliteRow) -> Alert { sla_window: row.get("sla_window"), source: row.get("source"), external_ticket_id: row.get("external_ticket_id"), + mock: row.get::("mock") != 0, } } #[async_trait] impl AlertRepository for SqliteAlertRepository { - async fn list(&self, severity: Option<&str>, state: Option<&str>) -> Vec { + async fn list( + &self, + severity: Option<&str>, + state: Option<&str>, + include_mock: bool, + ) -> Vec { let mut sql = "SELECT * FROM alerts WHERE 1=1".to_string(); + if !include_mock { + sql.push_str(" AND mock = 0"); + } if severity.is_some() { sql.push_str(" AND severity = ?"); } @@ -96,8 +105,9 @@ impl AlertRepository for SqliteAlertRepository { .map(|row| row_to_alert(&row)) } - async fn summary(&self) -> AlertSummary { - let row = sqlx::query( + async fn summary(&self, include_mock: bool) -> AlertSummary { + let mock_filter = if include_mock { "" } else { "WHERE mock = 0" }; + let sql = format!( "SELECT COUNT(*) FILTER (WHERE severity = 'critical') AS critical, COUNT(*) FILTER (WHERE severity = 'warning') AS warnings, @@ -106,10 +116,9 @@ impl AlertRepository for SqliteAlertRepository { AND state NOT IN ('resolved', 'dismissed')) AS active_critical, COUNT(*) FILTER (WHERE severity = 'warning' AND state NOT IN ('resolved', 'dismissed')) AS active_warnings - FROM alerts", - ) - .fetch_one(&self.pool) - .await; + FROM alerts {mock_filter}", + ); + let row = sqlx::query(&sql).fetch_one(&self.pool).await; match row { Ok(r) => { @@ -310,7 +319,7 @@ pub(crate) async fn insert_alert(pool: &SqlitePool, alert: &Alert) { "INSERT INTO alerts (id, alert_type, severity, description, affected_agents, state, \ created_timestamp, acknowledged_timestamp, assigned_to, investigation_notes, \ root_cause, resolution, auto_resolved, escalation_count, sla_window, source, \ - external_ticket_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + external_ticket_id, mock) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", ) .bind(alert.id.to_string()) .bind(serialize_enum(&alert.alert_type)) @@ -329,6 +338,7 @@ pub(crate) async fn insert_alert(pool: &SqlitePool, alert: &Alert) { .bind(&alert.sla_window) .bind(&alert.source) .bind(&alert.external_ticket_id) + .bind(alert.mock as i32) .execute(pool) .await .expect("insert test alert"); @@ -360,6 +370,7 @@ mod tests { sla_window: None, source: "test".into(), external_ticket_id: None, + mock: false, } } @@ -405,21 +416,21 @@ mod tests { #[tokio::test] async fn sqlite_list_all() { let (repo, _, _, _) = seeded_repo().await; - let all = repo.list(None, None).await; + let all = repo.list(None, None, true).await; assert_eq!(all.len(), 3); } #[tokio::test] async fn sqlite_filter_by_severity() { let (repo, _, _, _) = seeded_repo().await; - let critical = repo.list(Some("critical"), None).await; + let critical = repo.list(Some("critical"), None, true).await; assert_eq!(critical.len(), 1); } #[tokio::test] async fn sqlite_filter_by_state() { let (repo, _, _, _) = seeded_repo().await; - let new = repo.list(None, Some("new")).await; + let new = repo.list(None, Some("new"), true).await; assert_eq!(new.len(), 1); } @@ -500,7 +511,7 @@ mod tests { #[tokio::test] async fn sqlite_summary() { let (repo, _, _, _) = seeded_repo().await; - let summary = repo.summary().await; + let summary = repo.summary(true).await; assert_eq!(summary.critical, 1); assert_eq!(summary.warnings, 1); assert_eq!(summary.info, 1); diff --git a/src/repository/sqlite/mod.rs b/src/repository/sqlite/mod.rs index 49353a2..3e99b53 100644 --- a/src/repository/sqlite/mod.rs +++ b/src/repository/sqlite/mod.rs @@ -38,7 +38,8 @@ CREATE TABLE IF NOT EXISTS alerts ( escalation_count INTEGER NOT NULL DEFAULT 0, sla_window TEXT, source TEXT NOT NULL, - external_ticket_id TEXT + external_ticket_id TEXT, + mock INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS policies ( diff --git a/src/settings_store.rs b/src/settings_store.rs index a1b4c8a..9a5949e 100644 --- a/src/settings_store.rs +++ b/src/settings_store.rs @@ -134,6 +134,9 @@ fn dirs_path() -> Option { mod tests { use super::*; use std::path::PathBuf; + use std::sync::Mutex; + + static ENV_MUTEX: Mutex<()> = Mutex::new(()); #[test] fn round_trip_keylime_only() { @@ -208,19 +211,20 @@ mod tests { #[test] fn resolve_config_path_from_env() { + let _lock = ENV_MUTEX.lock().unwrap(); std::env::set_var("KEYLIME_WEBTOOL_CONFIG", "/tmp/test-config.toml"); let path = resolve_config_path(); - assert_eq!(path, Some(PathBuf::from("/tmp/test-config.toml"))); std::env::remove_var("KEYLIME_WEBTOOL_CONFIG"); + assert_eq!(path, Some(PathBuf::from("/tmp/test-config.toml"))); } #[test] fn resolve_config_path_empty_env() { + let _lock = ENV_MUTEX.lock().unwrap(); std::env::set_var("KEYLIME_WEBTOOL_CONFIG", ""); let path = resolve_config_path(); - // falls through to dirs_path - assert!(path.is_some() || path.is_none()); std::env::remove_var("KEYLIME_WEBTOOL_CONFIG"); + assert!(path.is_some() || path.is_none()); } #[test]