From 581ee43b5c580ceefaf5bef19d8086f6dad2632e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 20 Jul 2026 11:11:47 -0400 Subject: [PATCH] test: cover finding rules, model predicates, cost buckets, and region logic Coverage went from 140 to 205 tests across the four areas the issue names. All 25 finding rules are now exercised, up from 11. The added ones cover the pairs that must not double-report: production versus general secret rotation, stopped instances that look important versus merely unused, and idle load balancers versus failing ones. Model predicates gained tests on EC2, RDS, ELB, target groups, and SQS. These are the thresholds and boundaries the findings are built on: inclusive backlog thresholds, a low-CPU threshold that is exclusive at the boundary, unreadable tags never counting as a coverage gap, and saturating arithmetic that cannot underflow when a service reports more unhealthy targets than registered ones. Cost time-bucket logic had no tests at all. Covering it required making the window helpers take the date rather than reading the clock, which is also what let the December and year-boundary cases be tested at all. That surfaced a real bug, fixed here. Cost Explorer rejects a time period whose start and end are the same day, and the month-to-date window collapsed to exactly that on the first of every month, so all cost views reported the service unavailable that day. The window now covers the current day. Region selection gained tests for the synthetic global slot, exact and case-sensitive region matching, and the index clamp that keeps a stale index from reading past a shorter region list. --- .changeset/core-logic-test-coverage.md | 5 + src/app/findings.rs | 297 +++++++++++++++++++++++++ src/app/mod.rs | 113 ++++++++++ src/aws/cost.rs | 220 +++++++++++++++++- src/models/ec2.rs | 169 ++++++++++++++ src/models/elb.rs | 54 +++++ src/models/rds.rs | 68 ++++++ src/models/sqs.rs | 69 ++++++ src/models/target_group.rs | 67 ++++++ 9 files changed, 1053 insertions(+), 9 deletions(-) create mode 100644 .changeset/core-logic-test-coverage.md diff --git a/.changeset/core-logic-test-coverage.md b/.changeset/core-logic-test-coverage.md new file mode 100644 index 0000000..5be22c2 --- /dev/null +++ b/.changeset/core-logic-test-coverage.md @@ -0,0 +1,5 @@ +--- +"seamless-glance": patch +--- + +Fix cost data failing to load on the first of each month. Cost Explorer rejects a time period whose start and end are the same day, which is exactly what the month-to-date window collapsed to on the first, so every cost view reported the service as unavailable for that day. The window now covers the current day instead of collapsing. Found by adding test coverage for the cost time-bucket logic, which previously had none. diff --git a/src/app/findings.rs b/src/app/findings.rs index 6a820db..7f11e15 100644 --- a/src/app/findings.rs +++ b/src/app/findings.rs @@ -1471,6 +1471,303 @@ mod tests { } /// One finding per offending resource, each independently identifiable. + fn secret(name: &str, rotation_enabled: bool) -> SecretInfo { + SecretInfo { + name: name.into(), + rotation_enabled, + last_rotated: None, + tags: Tags::loaded([("Owner", "platform")]), + } + } + + fn api(name: &str, created_at: &str) -> ApiGatewayInfo { + ApiGatewayInfo { + id: format!("id-{name}"), + name: name.into(), + api_type: "REST".into(), + created_at: created_at.into(), + tags: Tags::loaded([("Owner", "platform")]), + } + } + + fn rds(identifier: &str, status: &str, multi_az: bool) -> RdsInstanceInfo { + RdsInstanceInfo { + identifier: identifier.into(), + region: "us-east-1".into(), + engine: "postgres".into(), + instance_class: "db.t3.medium".into(), + status: status.into(), + az: "us-east-1a".into(), + multi_az, + tags: Tags::loaded([("Owner", "platform")]), + } + } + + fn balancer(name: &str, groups: usize, total: usize, healthy: usize) -> LoadBalancerInfo { + LoadBalancerInfo { + arn: format!("arn:aws:elasticloadbalancing:::loadbalancer/{name}"), + name: name.into(), + lb_type: "Application".into(), + scheme: "internet-facing".into(), + state: "active".into(), + az_count: 2, + attached_target_groups: groups, + total_targets: total, + healthy_targets: healthy, + tags: Tags::loaded([("Owner", "platform")]), + } + } + + /// Production-like secrets are a separate, higher-severity rule, so the + /// general rotation rule must not report them a second time. + #[test] + fn secrets_rotation_rules_do_not_double_report() { + let ov = overview(); + let secrets = vec![ + secret("prod-signing-key", false), + secret("dev-token", false), + ]; + let mut c = ctx(Some(&ov)); + c.secrets = &secrets; + + let production = secrets_production_rotation_disabled(&c); + assert_eq!(production.len(), 1); + assert_eq!(production[0].severity, FindingSeverity::High); + assert!(production[0].summary.contains("prod-signing-key")); + + let general = secrets_rotation_disabled(&c); + assert_eq!(general.len(), 1); + assert!(general[0].summary.contains("dev-token")); + } + + #[test] + fn a_secret_that_has_not_rotated_in_too_long_is_reported() { + let ov = overview(); + let long_ago = (chrono::Utc::now() + - chrono::Duration::days(SecretInfo::STALE_ROTATION_DAYS + 1)) + .to_rfc3339(); + let recently = chrono::Utc::now().to_rfc3339(); + + let secrets = vec![ + SecretInfo { + last_rotated: Some(long_ago), + ..secret("stale-key", true) + }, + SecretInfo { + last_rotated: Some(recently), + ..secret("fresh-key", true) + }, + ]; + let mut c = ctx(Some(&ov)); + c.secrets = &secrets; + + let found = secrets_stale_rotation(&c); + assert_eq!(found.len(), 1); + assert!(found[0].summary.contains("stale-key")); + } + + /// Rotation age only means something once rotation is on. A secret that + /// never rotates is the other rule's case, not this one's. + #[test] + fn a_secret_with_rotation_disabled_is_not_also_reported_as_stale() { + let ov = overview(); + let long_ago = (chrono::Utc::now() + - chrono::Duration::days(SecretInfo::STALE_ROTATION_DAYS + 1)) + .to_rfc3339(); + + let secrets = vec![SecretInfo { + last_rotated: Some(long_ago), + ..secret("never-rotates", false) + }]; + let mut c = ctx(Some(&ov)); + c.secrets = &secrets; + + assert!(secrets_stale_rotation(&c).is_empty()); + } + + #[test] + fn a_rotating_secret_is_not_reported() { + let ov = overview(); + let secrets = vec![secret("prod-signing-key", true)]; + let mut c = ctx(Some(&ov)); + c.secrets = &secrets; + + assert!(secrets_production_rotation_disabled(&c).is_empty()); + assert!(secrets_rotation_disabled(&c).is_empty()); + } + + #[test] + fn orphan_target_groups_are_reported_per_group() { + let ov = overview(); + let groups = vec![ + target_group("orphan", 0, 0, false), + target_group("attached", 0, 0, true), + ]; + let mut c = ctx(Some(&ov)); + c.target_groups = &groups; + + let found = target_groups_orphaned(&c); + assert_eq!(found.len(), 1); + assert!(found[0].summary.contains("orphan")); + assert_eq!(found[0].category, FindingCategory::Waste); + } + + #[test] + fn stopped_instances_split_by_whether_they_look_important() { + let ov = overview(); + let mut important = low_cpu_instance("t3.medium", "us-east-1"); + important.state = "stopped".into(); + important.tags = Tags::loaded([("Name", "prod-api"), ("Owner", "p"), ("Environment", "e")]); + let mut plain = low_cpu_instance("t3.medium", "us-east-1"); + plain.id = "i-2".into(); + plain.state = "stopped".into(); + plain.tags = Tags::loaded([("Name", "dev-box"), ("Owner", "p"), ("Environment", "e")]); + + let instances = vec![important, plain]; + let mut c = ctx(Some(&ov)); + c.ec2_instances = &instances; + + let needing = ec2_stopped_instances_needing_review(&c); + assert_eq!(needing.len(), 1); + assert!(needing[0].summary.contains("prod-api")); + + let unused = ec2_stopped_instances_unused(&c); + assert_eq!(unused.len(), 1); + assert!(unused[0].summary.contains("dev-box")); + } + + #[test] + fn ec2_tag_gaps_name_the_tags_that_are_missing() { + let ov = overview(); + let mut untagged = low_cpu_instance("t3.medium", "us-east-1"); + untagged.tags = Tags::loaded([("Name", "web")]); + let instances = vec![untagged]; + let mut c = ctx(Some(&ov)); + c.ec2_instances = &instances; + + let found = ec2_tag_coverage_gaps(&c); + assert_eq!(found.len(), 1); + assert!(found[0].summary.contains("Owner")); + assert!(found[0].summary.contains("Environment")); + assert_eq!(found[0].severity, FindingSeverity::Low); + } + + #[test] + fn an_instance_with_unreadable_tags_is_not_reported_as_untagged() { + let ov = overview(); + let mut unknown = low_cpu_instance("t3.medium", "us-east-1"); + unknown.tags = Tags::Unavailable; + let instances = vec![unknown]; + let mut c = ctx(Some(&ov)); + c.ec2_instances = &instances; + + assert!(ec2_tag_coverage_gaps(&c).is_empty()); + } + + #[test] + fn generic_api_names_are_reported_and_specific_ones_are_not() { + let apis = vec![ + api("test", "2026-07-01T00:00:00Z"), + api("orders-public-api", "2026-07-01T00:00:00Z"), + ]; + let mut c = ctx(None); + c.apigateway_apis = &apis; + + let found = apigateway_generic_or_stale_apis(&c); + assert_eq!(found.len(), 1); + assert!(found[0].summary.contains("test")); + } + + #[test] + fn a_backlogged_queue_is_an_incident_and_a_quiet_one_is_not() { + let queues = vec![ + SqsQueueInfo { + messages_available: 5_000, + ..queue_fixture("busy") + }, + queue_fixture("quiet"), + ]; + let mut c = ctx(None); + c.sqs_queues_data = &queues; + + let found = sqs_queue_backlog(&c); + assert_eq!(found.len(), 1); + assert!(found[0].summary.contains("busy")); + assert_eq!(found[0].severity, FindingSeverity::High); + } + + fn queue_fixture(name: &str) -> SqsQueueInfo { + SqsQueueInfo { + name: name.into(), + queue_url: format!("https://sqs.us-east-1.amazonaws.com/1/{name}"), + is_fifo: false, + messages_available: 0, + messages_in_flight: 0, + has_dlq: true, + dead_letter_target_arn: None, + tags: Tags::loaded([("Owner", "platform")]), + } + } + + #[test] + fn rds_reports_unavailable_instances_and_single_az_separately() { + let instances = vec![ + rds("prod-writer", "available", false), + rds("dev-reader", "creating", true), + ]; + let mut c = ctx(None); + c.rds_instances = &instances; + + let unavailable = rds_instances_not_available(&c); + assert_eq!(unavailable.len(), 1); + assert!(unavailable[0].summary.contains("dev-reader")); + assert_eq!(unavailable[0].category, FindingCategory::Incident); + + let single_az = rds_single_az_production_like(&c); + assert_eq!(single_az.len(), 1); + assert!(single_az[0].summary.contains("prod-writer")); + } + + /// A balancer serving nothing is waste; one whose targets are all failing + /// is an incident. Neither rule may claim the other's case. + #[test] + fn load_balancer_rules_split_idle_from_failing() { + let balancers = vec![balancer("idle", 0, 0, 0), balancer("failing", 2, 4, 0)]; + let mut c = ctx(None); + c.load_balancers = &balancers; + + let zero_healthy = load_balancers_zero_healthy_targets(&c); + assert_eq!(zero_healthy.len(), 1); + assert!(zero_healthy[0].summary.contains("failing")); + assert_eq!(zero_healthy[0].category, FindingCategory::Incident); + + let idle = load_balancers_no_active_targets(&c); + assert_eq!(idle.len(), 1); + assert!(idle[0].summary.contains("idle")); + assert_eq!(idle[0].category, FindingCategory::Waste); + } + + #[test] + fn stale_lambda_functions_are_reported_once_each() { + let functions = vec![ + LambdaFunctionInfo { + name: "old".into(), + last_modified: "2020-01-01T00:00:00.000+0000".into(), + ..lambda(128) + }, + LambdaFunctionInfo { + name: "fresh".into(), + ..lambda(128) + }, + ]; + let mut c = ctx(None); + c.lambda_functions = &functions; + + let found = lambda_stale_functions(&c); + assert_eq!(found.len(), 1); + assert!(found[0].summary.contains("old")); + } + #[test] fn a_rule_emits_one_finding_per_offending_resource() { let ov = overview(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 9c4b354..6901919 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1273,3 +1273,116 @@ impl App { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::aws::clients::AwsClients; + + fn app_with_regions(names: &[&str]) -> App { + let config = aws_config::SdkConfig::builder() + .region(Region::new("us-east-1")) + .behavior_version(aws_config::BehaviorVersion::latest()) + .build(); + let mut app = App::new(AwsClients::new(&config)); + app.regions = names.iter().map(|n| Region::new(n.to_string())).collect(); + app.current_region_index = 0; + app + } + + /// Global is a synthetic slot one past the real regions, not a region in + /// the list, so the slot count is always one more than what AWS returned. + #[test] + fn global_is_a_slot_past_the_real_regions() { + let mut app = app_with_regions(&["us-east-1", "eu-west-1"]); + + assert_eq!(app.region_slot_count(), 3); + assert!(!app.is_global_region_selected()); + + app.set_global_region(); + + assert!(app.is_global_region_selected()); + assert_eq!(app.current_region_index, 2); + assert_eq!(app.current_region_label(), "global"); + } + + /// With no regions discovered there is nothing to aggregate, so global must + /// not become selectable and leave the index out of bounds. + #[test] + fn global_is_not_selectable_without_any_regions() { + let mut app = app_with_regions(&[]); + + assert_eq!(app.region_slot_count(), 0); + + app.set_global_region(); + + assert!(!app.is_global_region_selected()); + } + + #[tokio::test] + async fn a_region_is_selected_by_its_exact_name() { + let mut app = app_with_regions(&["us-east-1", "eu-west-1"]); + + assert!(app.set_region_by_name("eu-west-1").await); + assert_eq!(app.current_region_label(), "eu-west-1"); + assert!(!app.is_global_region_selected()); + } + + #[tokio::test] + async fn global_is_accepted_in_any_casing() { + for name in ["global", "GLOBAL", "Global"] { + let mut app = app_with_regions(&["us-east-1"]); + + assert!( + app.set_region_by_name(name).await, + "{name} should select global" + ); + assert!(app.is_global_region_selected()); + } + } + + /// An unknown region must be refused rather than silently leaving the + /// selection where it was, so the caller can report it. + #[test] + fn an_unknown_region_is_refused_and_changes_nothing() { + let app = app_with_regions(&["us-east-1", "eu-west-1"]); + + // Region names are matched exactly; a near miss is still a miss. + assert!(!app.regions.iter().any(|r| r.as_ref() == "us-east-2")); + assert_eq!(app.current_region_label(), "us-east-1"); + } + + /// Region names are case-sensitive in AWS, and matching loosely would + /// select a region the operator did not name. + #[tokio::test] + async fn region_matching_is_exact_and_case_sensitive() { + let mut app = app_with_regions(&["us-east-1"]); + + assert!(!app.set_region_by_name("US-EAST-1").await); + assert!(!app.set_region_by_name("us-east").await); + assert!(!app.set_region_by_name("").await); + assert_eq!(app.current_region_label(), "us-east-1"); + } + + /// The index is clamped when read, so a stale index cannot index past the + /// end of a shorter region list. + #[test] + fn an_index_past_the_end_still_reads_a_real_region() { + let mut app = app_with_regions(&["us-east-1", "eu-west-1"]); + app.current_region_index = 99; + + assert_eq!(app.current_region().as_ref(), "eu-west-1"); + } + + #[test] + fn the_data_context_pairs_the_profile_with_the_region() { + let mut app = app_with_regions(&["us-east-1"]); + app.current_profile = Some("prod".into()); + + assert_eq!(app.current_region_label(), "us-east-1"); + assert_eq!(app.current_profile.as_deref(), Some("prod")); + + app.set_global_region(); + assert_eq!(app.current_region_label(), "global"); + } +} diff --git a/src/aws/cost.rs b/src/aws/cost.rs index 8cd5e72..4d71e36 100644 --- a/src/aws/cost.rs +++ b/src/aws/cost.rs @@ -42,18 +42,39 @@ fn interval(start: NaiveDate, end_exclusive: NaiveDate) -> DateInterval { .expect("valid Cost Explorer interval") } +/// Month to date: the first of `today`'s month up to, but not including, today. +/// +/// Cost Explorer treats the end of an interval as exclusive and rejects an +/// interval that starts and ends on the same day. On the first of the month +/// those coincide, so the window is widened to cover today, which is the only +/// spend there is to report at that point. +fn month_to_date_from(today: NaiveDate) -> (NaiveDate, NaiveDate) { + let start = first_day_of_month(today); + + if start == today { + return (start, start.succ_opt().unwrap_or(today)); + } + + (start, today) +} + fn current_month_dates() -> (NaiveDate, NaiveDate) { - let today = today_exclusive(); - (first_day_of_month(today), today) + month_to_date_from(today_exclusive()) } -fn forecast_month_dates() -> (NaiveDate, NaiveDate) { - let today = today_exclusive(); +/// The rest of `today`'s month: today up to the first of next month. +fn remainder_of_month_from(today: NaiveDate) -> (NaiveDate, NaiveDate) { (today, first_day_of_next_month(today)) } -fn trailing_six_month_dates() -> (NaiveDate, NaiveDate) { - let today = today_exclusive(); +fn forecast_month_dates() -> (NaiveDate, NaiveDate) { + remainder_of_month_from(today_exclusive()) +} + +/// Six calendar months ending today: the first of the month five months back, +/// up to today. The window starts at a month boundary so the first bucket is a +/// whole month rather than a partial one. +fn trailing_six_months_from(today: NaiveDate) -> (NaiveDate, NaiveDate) { let start = first_day_of_month( today .checked_sub_months(Months::new(5)) @@ -63,12 +84,17 @@ fn trailing_six_month_dates() -> (NaiveDate, NaiveDate) { (start, today) } -pub fn last_6_month_labels() -> Vec { - let now = today_exclusive(); +fn trailing_six_month_dates() -> (NaiveDate, NaiveDate) { + trailing_six_months_from(today_exclusive()) +} +/// Short month names for the trailing six months, oldest first, so the labels +/// line up with the buckets `trailing_six_months_from` asks for. +fn six_month_labels_from(today: NaiveDate) -> Vec { (0..6) .map(|i| { - now.checked_sub_months(Months::new((5 - i) as u32)) + today + .checked_sub_months(Months::new((5 - i) as u32)) .expect("valid month") .format("%b") .to_string() @@ -76,6 +102,10 @@ pub fn last_6_month_labels() -> Vec { .collect() } +pub fn last_6_month_labels() -> Vec { + six_month_labels_from(today_exclusive()) +} + fn metric_amount(metric_value: Option<&aws_sdk_costexplorer::types::MetricValue>) -> f64 { metric_value .and_then(|metric| metric.amount()) @@ -293,3 +323,175 @@ pub async fn fetch_budget(app: &App) -> (BudgetInfo, ServiceStatus) { ServiceStatus::Ok, ) } + +#[cfg(test)] +mod tests { + use super::*; + use aws_sdk_costexplorer::types::MetricValue; + + fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).expect("valid test date") + } + + #[test] + fn month_to_date_runs_from_the_first_up_to_today() { + let (start, end) = month_to_date_from(date(2026, 7, 20)); + + assert_eq!(start, date(2026, 7, 1)); + // Exclusive end, so today's partial spend is not requested. + assert_eq!(end, date(2026, 7, 20)); + } + + /// Cost Explorer rejects an interval whose start and end are the same day + /// ("Start date (and hour) should be before end date (and hour)"), so on + /// the first of the month the window has to cover today instead of + /// collapsing. Left as-is this made every cost view report unavailable on + /// the first of each month. + #[test] + fn the_first_of_the_month_is_never_an_empty_window() { + let (start, end) = month_to_date_from(date(2026, 7, 1)); + + assert_eq!(start, date(2026, 7, 1)); + assert_eq!(end, date(2026, 7, 2)); + assert!(start < end, "Cost Explorer requires a non-empty interval"); + } + + /// The same collapse would happen on the first of January, where the widened + /// end must stay inside the new year. + #[test] + fn the_first_of_january_widens_within_the_new_year() { + let (start, end) = month_to_date_from(date(2026, 1, 1)); + + assert_eq!(start, date(2026, 1, 1)); + assert_eq!(end, date(2026, 1, 2)); + } + + #[test] + fn every_day_of_a_month_yields_a_usable_interval() { + for day in 1..=31 { + let today = date(2026, 7, day); + let (start, end) = month_to_date_from(today); + + assert!(start < end, "empty interval for {today}"); + } + } + + #[test] + fn the_forecast_window_runs_to_the_start_of_next_month() { + let (start, end) = remainder_of_month_from(date(2026, 7, 20)); + + assert_eq!(start, date(2026, 7, 20)); + assert_eq!(end, date(2026, 8, 1)); + } + + /// December has to roll the year, which plain month arithmetic gets wrong. + #[test] + fn december_rolls_into_the_next_year() { + assert_eq!(first_day_of_next_month(date(2026, 12, 9)), date(2027, 1, 1)); + assert_eq!( + remainder_of_month_from(date(2026, 12, 31)).1, + date(2027, 1, 1) + ); + } + + #[test] + fn the_first_of_a_month_is_found_from_any_day_in_it() { + assert_eq!(first_day_of_month(date(2026, 7, 20)), date(2026, 7, 1)); + assert_eq!(first_day_of_month(date(2026, 7, 1)), date(2026, 7, 1)); + // A leap day is still just a day in February. + assert_eq!(first_day_of_month(date(2024, 2, 29)), date(2024, 2, 1)); + } + + #[test] + fn the_six_month_window_starts_on_a_month_boundary() { + let (start, end) = trailing_six_months_from(date(2026, 7, 20)); + + // Five months back from July is February, snapped to the 1st so the + // oldest bucket is a whole month. + assert_eq!(start, date(2026, 2, 1)); + assert_eq!(end, date(2026, 7, 20)); + } + + #[test] + fn the_six_month_window_crosses_a_year_boundary() { + let (start, end) = trailing_six_months_from(date(2026, 3, 15)); + + assert_eq!(start, date(2025, 10, 1)); + assert_eq!(end, date(2026, 3, 15)); + } + + /// Subtracting months from a 31-day date lands on a shorter month. The + /// window is snapped to the 1st afterwards, so the clamp cannot shift which + /// month the window starts in. + #[test] + fn a_month_end_date_still_starts_the_window_on_the_first() { + let (start, _) = trailing_six_months_from(date(2026, 7, 31)); + + assert_eq!(start, date(2026, 2, 1)); + } + + #[test] + fn labels_run_oldest_first_and_match_the_window() { + let labels = six_month_labels_from(date(2026, 7, 20)); + + assert_eq!(labels, vec!["Feb", "Mar", "Apr", "May", "Jun", "Jul"]); + assert_eq!(labels.len(), 6); + } + + #[test] + fn labels_cross_a_year_boundary_in_order() { + let labels = six_month_labels_from(date(2026, 2, 5)); + + assert_eq!(labels, vec!["Sep", "Oct", "Nov", "Dec", "Jan", "Feb"]); + } + + /// The label list and the requested window have to agree, or the chart + /// axis is offset from the data it plots. + #[test] + fn the_first_label_is_the_month_the_window_starts_in() { + for today in [date(2026, 7, 20), date(2026, 1, 3), date(2026, 12, 31)] { + let (start, _) = trailing_six_months_from(today); + let labels = six_month_labels_from(today); + + assert_eq!( + labels[0], + start.format("%b").to_string(), + "window and labels disagree for {today}" + ); + } + } + + #[test] + fn an_interval_is_formatted_as_cost_explorer_expects() { + let (start, end) = interval_strings(date(2026, 2, 1), date(2026, 7, 20)); + + assert_eq!(start, "2026-02-01"); + assert_eq!(end, "2026-07-20"); + } + + #[test] + fn a_metric_amount_is_parsed_from_its_string() { + let value = MetricValue::builder().amount("123.45").unit("USD").build(); + + assert_eq!(metric_amount(Some(&value)), 123.45); + assert_eq!(metric_unit(Some(&value)), "USD"); + } + + /// Cost Explorer omits metrics for a period with no spend. That is zero, + /// not an error, and must not poison the total. + #[test] + fn a_missing_metric_reads_as_zero() { + assert_eq!(metric_amount(None), 0.0); + assert_eq!(metric_unit(None), ""); + + let empty = MetricValue::builder().build(); + assert_eq!(metric_amount(Some(&empty)), 0.0); + } + + #[test] + fn an_unparseable_amount_reads_as_zero_rather_than_panicking() { + let broken = MetricValue::builder().amount("not-a-number").build(); + + assert_eq!(metric_amount(Some(&broken)), 0.0); + } +} diff --git a/src/models/ec2.rs b/src/models/ec2.rs index 857ef1f..eea036b 100644 --- a/src/models/ec2.rs +++ b/src/models/ec2.rs @@ -167,3 +167,172 @@ impl DescribableResource for Ec2InstanceInfo { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn instance(state: &str, tags: Tags) -> Ec2InstanceInfo { + Ec2InstanceInfo { + id: "i-0abc".into(), + tags, + avg_cpu_utilization: None, + instance_type: "t3.medium".into(), + state: state.into(), + region: "us-east-1".into(), + az: "us-east-1a".into(), + private_ip: Some("10.0.0.5".into()), + public_ip: None, + key_name: None, + } + } + + fn tagged(name: &str) -> Tags { + Tags::loaded([ + ("Name", name), + ("Owner", "platform"), + ("Environment", "dev"), + ]) + } + + #[test] + fn an_instance_falls_back_to_its_id_when_it_has_no_name() { + assert_eq!(instance("running", Tags::empty()).label(), "i-0abc"); + assert_eq!(instance("running", tagged("web-1")).label(), "web-1"); + } + + #[test] + fn a_production_like_name_is_matched_case_insensitively_anywhere_in_the_name() { + for name in ["prod-api", "API-PROD", "customer-db", "Main-Gateway"] { + assert!( + instance("running", tagged(name)).has_production_like_name(), + "{name} should read as production-like" + ); + } + + for name in ["dev-api", "staging-web", "sandbox"] { + assert!( + !instance("running", tagged(name)).has_production_like_name(), + "{name} should not read as production-like" + ); + } + } + + /// An untagged instance has no name to judge, which is not the same as + /// having a name that looks non-production. + #[test] + fn an_unnamed_instance_is_not_production_like() { + assert!(!instance("running", Tags::empty()).has_production_like_name()); + assert!(!instance("running", Tags::Unavailable).has_production_like_name()); + } + + #[test] + fn a_stopped_instance_needs_review_only_with_a_reason() { + let plain = instance("stopped", tagged("dev-box")); + assert!(!plain.needs_stopped_review()); + + let mut public = instance("stopped", tagged("dev-box")); + public.public_ip = Some("54.1.2.3".into()); + assert!(public.needs_stopped_review()); + + assert!(instance("stopped", tagged("prod-api")).needs_stopped_review()); + } + + /// The reasons only matter while the instance is stopped: a running + /// instance with a public IP is normal. + #[test] + fn a_running_instance_never_needs_stopped_review() { + let mut running = instance("running", tagged("prod-api")); + running.public_ip = Some("54.1.2.3".into()); + + assert!(!running.needs_stopped_review()); + } + + #[test] + fn low_cpu_is_judged_only_on_running_instances_with_a_reading() { + let below = Ec2InstanceInfo { + avg_cpu_utilization: Some(Ec2InstanceInfo::LOW_CPU_THRESHOLD_PERCENT - 0.1), + ..instance("running", tagged("web")) + }; + assert!(below.has_sustained_low_cpu()); + + let at_threshold = Ec2InstanceInfo { + avg_cpu_utilization: Some(Ec2InstanceInfo::LOW_CPU_THRESHOLD_PERCENT), + ..instance("running", tagged("web")) + }; + assert!( + !at_threshold.has_sustained_low_cpu(), + "the threshold itself is not below it" + ); + + let stopped = Ec2InstanceInfo { + avg_cpu_utilization: Some(0.0), + ..instance("stopped", tagged("web")) + }; + assert!( + !stopped.has_sustained_low_cpu(), + "a stopped instance idles by definition" + ); + + let no_reading = instance("running", tagged("web")); + assert!(!no_reading.has_sustained_low_cpu(), "no metric is not zero"); + } + + #[test] + fn a_missing_cpu_reading_renders_as_a_dash_not_zero() { + assert_eq!(instance("running", tagged("web")).formatted_avg_cpu(), "-"); + + let measured = Ec2InstanceInfo { + avg_cpu_utilization: Some(2.345), + ..instance("running", tagged("web")) + }; + assert_eq!(measured.formatted_avg_cpu(), "2.3%"); + } + + #[test] + fn tag_coverage_names_only_the_missing_tags() { + let partial = instance("running", Tags::loaded([("Name", "web")])); + + assert_eq!( + partial.missing_required_tags(), + Some(vec!["Owner", "Environment"]) + ); + assert!(partial.has_tag_coverage_gap()); + + let complete = instance("running", tagged("web")); + assert_eq!(complete.missing_required_tags(), Some(Vec::new())); + assert!(!complete.has_tag_coverage_gap()); + } + + /// Unreadable tags are not evidence of a gap. Reporting one would blame an + /// instance for a failed lookup. + #[test] + fn unreadable_tags_are_not_a_coverage_gap() { + let unknown = instance("running", Tags::Unavailable); + + assert_eq!(unknown.missing_required_tags(), None); + assert!(!unknown.has_tag_coverage_gap()); + assert!(!unknown.review_signals().contains(&"missing-tags")); + } + + #[test] + fn review_signals_report_every_reason_at_once() { + let mut bad = Ec2InstanceInfo { + avg_cpu_utilization: Some(0.5), + ..instance("running", Tags::loaded([("Name", "prod-api")])) + }; + bad.public_ip = Some("54.1.2.3".into()); + + assert_eq!( + bad.review_signals(), + vec!["public-ip", "prod-name", "missing-tags", "low-cpu"] + ); + } + + #[test] + fn a_clean_instance_has_no_signals() { + assert!(instance("running", tagged("dev-box")) + .review_signals() + .is_empty()); + } +} diff --git a/src/models/elb.rs b/src/models/elb.rs index 8f893fb..e90c146 100644 --- a/src/models/elb.rs +++ b/src/models/elb.rs @@ -86,3 +86,57 @@ impl DescribableResource for LoadBalancerInfo { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn balancer(groups: usize, total: usize, healthy: usize) -> LoadBalancerInfo { + LoadBalancerInfo { + arn: "arn:aws:elasticloadbalancing:::loadbalancer/app".into(), + name: "app".into(), + lb_type: "Application".into(), + scheme: "internet-facing".into(), + state: "active".into(), + az_count: 2, + attached_target_groups: groups, + total_targets: total, + healthy_targets: healthy, + tags: Tags::empty(), + } + } + + #[test] + fn a_balancer_with_no_path_to_a_target_is_inactive() { + assert!(balancer(0, 0, 0).has_no_active_targets()); + assert!( + balancer(2, 0, 0).has_no_active_targets(), + "groups attached but nothing registered is still no path" + ); + assert!(!balancer(2, 4, 4).has_no_active_targets()); + } + + /// A balancer serving nothing is waste; one whose targets are all failing + /// is an incident. Keeping them apart is what stops one rule reporting both. + #[test] + fn serving_nothing_is_distinct_from_serving_unhealthy_targets() { + let empty = balancer(0, 0, 0); + assert!(empty.has_no_active_targets()); + assert!( + !empty.has_zero_healthy_targets(), + "nothing registered is not an outage" + ); + + let failing = balancer(2, 4, 0); + assert!(failing.has_zero_healthy_targets()); + assert!(!failing.has_no_active_targets()); + } + + #[test] + fn signals_distinguish_no_groups_from_no_targets() { + assert_eq!(balancer(0, 0, 0).review_signals(), vec!["no-target-groups"]); + assert_eq!(balancer(2, 0, 0).review_signals(), vec!["no-targets"]); + assert_eq!(balancer(2, 4, 0).review_signals(), vec!["zero-healthy"]); + assert!(balancer(2, 4, 4).review_signals().is_empty()); + } +} diff --git a/src/models/rds.rs b/src/models/rds.rs index 5788fd0..35c615a 100644 --- a/src/models/rds.rs +++ b/src/models/rds.rs @@ -105,3 +105,71 @@ impl DescribableResource for RdsInstanceInfo { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn db(identifier: &str, status: &str, multi_az: bool) -> RdsInstanceInfo { + RdsInstanceInfo { + identifier: identifier.into(), + region: "us-east-1".into(), + engine: "postgres".into(), + instance_class: "db.t3.medium".into(), + status: status.into(), + az: "us-east-1a".into(), + multi_az, + tags: Tags::empty(), + } + } + + #[test] + fn single_az_review_needs_all_three_conditions() { + assert!(db("prod-orders", "available", false).needs_single_az_review()); + + assert!( + !db("prod-orders", "available", true).needs_single_az_review(), + "multi-AZ is already covered" + ); + assert!( + !db("dev-orders", "available", false).needs_single_az_review(), + "a non-production name is not worth flagging" + ); + assert!( + !db("prod-orders", "creating", false).needs_single_az_review(), + "an instance that is not available yet is not a coverage gap" + ); + } + + #[test] + fn a_production_like_identifier_matches_a_hint_anywhere() { + for identifier in ["prod-db", "DB-PRODUCTION", "customer-data", "main-writer"] { + assert!( + db(identifier, "available", false).has_production_like_identifier(), + "{identifier} should read as production-like" + ); + } + + for identifier in ["dev-db", "test-writer", "sandbox"] { + assert!( + !db(identifier, "available", false).has_production_like_identifier(), + "{identifier} should not read as production-like" + ); + } + } + + #[test] + fn review_signals_report_each_reason() { + assert_eq!( + db("prod-orders", "available", false).review_signals(), + vec!["single-az", "prod-name"] + ); + assert_eq!( + db("dev-orders", "available", false).review_signals(), + vec!["single-az"] + ); + assert!(db("dev-orders", "available", true) + .review_signals() + .is_empty()); + } +} diff --git a/src/models/sqs.rs b/src/models/sqs.rs index bf5986c..58f3dc1 100644 --- a/src/models/sqs.rs +++ b/src/models/sqs.rs @@ -106,3 +106,72 @@ impl DescribableResource for SqsQueueInfo { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn queue(name: &str, visible: i64, in_flight: i64) -> SqsQueueInfo { + SqsQueueInfo { + name: name.into(), + queue_url: format!("https://sqs.us-east-1.amazonaws.com/1/{name}"), + is_fifo: false, + messages_available: visible, + messages_in_flight: in_flight, + has_dlq: false, + dead_letter_target_arn: None, + tags: Tags::empty(), + } + } + + #[test] + fn backlog_thresholds_are_inclusive() { + assert!(queue("a", SqsQueueInfo::HIGH_VISIBLE_THRESHOLD, 0).has_high_visible_messages()); + assert!( + !queue("a", SqsQueueInfo::HIGH_VISIBLE_THRESHOLD - 1, 0).has_high_visible_messages() + ); + + assert!(queue("a", 0, SqsQueueInfo::HIGH_IN_FLIGHT_THRESHOLD).has_high_in_flight_messages()); + assert!(!queue("a", 0, SqsQueueInfo::HIGH_IN_FLIGHT_THRESHOLD - 1) + .has_high_in_flight_messages()); + } + + #[test] + fn either_kind_of_backlog_is_an_incident() { + assert!(queue("a", 500, 0).has_backlog_incident()); + assert!(queue("a", 0, 500).has_backlog_incident()); + assert!(!queue("a", 0, 0).has_backlog_incident()); + } + + #[test] + fn signals_name_every_kind_of_backlog_present() { + assert_eq!( + queue("a", 500, 500).backlog_signals(), + vec!["visible", "in-flight"] + ); + assert_eq!(queue("a", 500, 0).backlog_signals(), vec!["visible"]); + assert!(queue("a", 0, 0).backlog_signals().is_empty()); + } + + /// A dead-letter queue is identified by another queue redriving to it, not + /// by its name, so a queue merely called "-dlq" is not exempt. + #[test] + fn dead_letter_queues_are_found_through_redrive_targets() { + let mut source = queue("orders", 0, 0); + source.dead_letter_target_arn = Some("arn:aws:sqs:us-east-1:1:orders-dlq".into()); + let queues = vec![source, queue("orders-dlq", 0, 0), queue("billing", 0, 0)]; + + let names = dead_letter_queue_names(&queues); + + assert!(names.contains("orders-dlq")); + assert!(!names.contains("billing")); + assert_eq!(names.len(), 1); + } + + #[test] + fn a_region_with_no_redrive_policies_has_no_dead_letter_queues() { + let queues = vec![queue("orders", 0, 0), queue("billing", 0, 0)]; + + assert!(dead_letter_queue_names(&queues).is_empty()); + } +} diff --git a/src/models/target_group.rs b/src/models/target_group.rs index 8dd9903..6661b84 100644 --- a/src/models/target_group.rs +++ b/src/models/target_group.rs @@ -97,3 +97,70 @@ impl DescribableResource for TargetGroupInfo { )) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn group(total: usize, unhealthy: usize, attached: bool) -> TargetGroupInfo { + TargetGroupInfo { + arn: "arn:aws:elasticloadbalancing:::targetgroup/tg".into(), + name: "tg".into(), + protocol: "HTTP".into(), + port: 80, + target_type: "instance".into(), + attached_load_balancer_arns: if attached { + vec!["arn:aws:elasticloadbalancing:::loadbalancer/app".into()] + } else { + Vec::new() + }, + total_targets: total, + unhealthy_targets: unhealthy, + tags: Tags::empty(), + } + } + + #[test] + fn healthy_targets_are_the_remainder() { + assert_eq!(group(4, 1, true).healthy_targets(), 3); + assert_eq!(group(4, 4, true).healthy_targets(), 0); + } + + /// More unhealthy than registered should not underflow into a huge count. + #[test] + fn more_unhealthy_than_registered_cannot_underflow() { + assert_eq!(group(2, 5, true).healthy_targets(), 0); + assert!(group(2, 5, true).has_zero_healthy_targets()); + } + + /// An empty group is not an outage. Nothing is failing, there is just + /// nothing registered, which the orphan rule covers instead. + #[test] + fn an_empty_group_is_not_zero_healthy() { + assert!(!group(0, 0, true).has_zero_healthy_targets()); + assert!(group(1, 1, true).has_zero_healthy_targets()); + } + + #[test] + fn an_orphan_has_neither_a_balancer_nor_targets() { + assert!(group(0, 0, false).is_orphan_candidate()); + + assert!( + !group(0, 0, true).is_orphan_candidate(), + "attached but empty is a deployment in progress, not an orphan" + ); + assert!( + !group(2, 0, false).is_orphan_candidate(), + "unattached but serving targets is not an orphan" + ); + } + + /// Zero-healthy and partially-unhealthy are mutually exclusive, so a group + /// is never reported as both. + #[test] + fn health_signals_do_not_double_report() { + assert_eq!(group(2, 2, true).review_signals(), vec!["zero-healthy"]); + assert_eq!(group(4, 1, true).review_signals(), vec!["unhealthy"]); + assert!(group(4, 0, true).review_signals().is_empty()); + } +}