Skip to main content

max / makenotwork

Move fourteen test modules to sibling files across the sub-projects pom, sando, bento, magicmirror and shared each carried files that were mostly test code under a little production. runner.rs was 4156 lines holding 1083 of production, routes/mod.rs 4240 holding 1234, display.rs 1708 holding 526. Each test block becomes a tests.rs sibling behind a `#[cfg(test)] mod tests;` declaration. No production line changes. The invariant checked on every file is the `#[test]` count across the parent plus the sibling against HEAD, not the line count, which cargo fmt legitimately changes by rejoining wrapped expressions: 14 files, 468 tests, every pair equal. runner.rs had two column-0 test modules, `tests` and `live_recipe_smoke`, contiguous with nothing production between them. Both moved; the second is now a submodule of the first. custom-pages keeps its proptests separate from its unit tests, so that file has two siblings rather than one. Nothing under server/ is touched. Another session holds four files there.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
Author: Max Johnson <me@maxj.phd> · 2026-09-04 15:37 UTC
Signed with PGP, not checked
Commit: 75d5fcdb4a814abeba76b64ef15fd2df018e34ad
Parent: 6edfa38
29 files changed, +16323 insertions, -14525 deletions
@@ -725,666 +725,4 @@
725 725 }
726 726
727 727 #[cfg(test)]
728 - mod tests {
729 - use super::*;
730 - use ops_status::{Action, Condition, Method, Node};
731 - use std::collections::BTreeMap;
732 -
733 - fn now() -> DateTime<Utc> {
734 - "2026-07-21T18:00:00Z".parse().unwrap()
735 - }
736 -
737 - fn node(id: &str, status: Status, children: Vec<&str>) -> Node {
738 - Node {
739 - id: id.into(),
740 - kind: "tier".into(),
741 - label: id.into(),
742 - status,
743 - fields: Vec::new(),
744 - conditions: Vec::new(),
745 - children: children.into_iter().map(Into::into).collect(),
746 - actions: Vec::new(),
747 - }
748 - }
749 -
750 - fn act(label: &str, confirm: bool, danger: bool) -> Action {
751 - Action {
752 - label: label.into(),
753 - method: Method::Post,
754 - url: "/x".into(),
755 - confirm,
756 - danger,
757 - body: None,
758 - }
759 - }
760 -
761 - /// A source on a single node that declares the given actions, with actions
762 - /// allowed, sitting on its own tab ready to prompt.
763 - fn actionable(node_actions: &[&str], declared: Vec<(&str, Action)>) -> Model {
764 - let mut n = node("tier:b", Status::Ok, vec![]);
765 - n.actions = node_actions.iter().map(ToString::to_string).collect();
766 - let mut p = payload(now(), vec![n]);
767 - p.actions = declared
768 - .into_iter()
769 - .map(|(k, a)| (k.to_string(), a))
770 - .collect::<BTreeMap<_, _>>();
771 - let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
772 - s.observe(p, now());
773 - let mut m = Model::new(vec![s]);
774 - // Row 0 is the source line, row 1 its only node. Actions hang off the
775 - // node, so that is where the cursor has to be.
776 - m.selected = 1;
777 - m
778 - }
779 -
780 - fn event(at: DateTime<Utc>, label: &str) -> Event {
781 - Event {
782 - at,
783 - label: label.into(),
784 - status: None,
785 - detail: None,
786 - node_id: None,
787 - }
788 - }
789 -
790 - fn payload(at: DateTime<Utc>, nodes: Vec<Node>) -> Payload {
791 - let mut p = Payload::new("sando", at);
792 - p.nodes = nodes;
793 - p
794 - }
795 -
796 - fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState {
797 - let mut s = SourceState::new(name, TimeDelta::seconds(60));
798 - s.observe(payload(at, nodes), at);
799 - s
800 - }
801 -
802 - #[test]
803 - fn a_source_never_polled_is_unknown_not_ok() {
804 - let s = SourceState::new("sando", TimeDelta::seconds(60));
805 - assert_eq!(s.status(now()), Status::Unknown);
806 - assert_eq!(s.summary(now()), "waiting for first poll");
807 - }
808 -
809 - #[test]
810 - fn a_fresh_healthy_source_is_ok() {
811 - let s = source("sando", now(), vec![node("tier:b", Status::Ok, vec![])]);
812 - assert_eq!(s.status(now()), Status::Ok);
813 - assert_eq!(s.summary(now()), "1 node ok");
814 - }
815 -
816 - #[test]
817 - fn a_stale_but_green_source_is_degraded() {
818 - // The forty-day-old backup that every check called healthy.
819 - let s = source(
820 - "pom",
821 - now() - TimeDelta::hours(4),
822 - vec![node("backup", Status::Ok, vec![])],
823 - );
824 - assert_eq!(s.status(now()), Status::Degraded);
825 - assert!(
826 - s.summary(now()).starts_with("stale:"),
827 - "{}",
828 - s.summary(now())
829 - );
830 - }
831 -
832 - #[test]
833 - fn staleness_never_downgrades_a_worse_status() {
834 - let mut s = source(
835 - "sando",
836 - now() - TimeDelta::hours(4),
837 - vec![node("tier:b", Status::Failed, vec![])],
838 - );
839 - assert_eq!(s.status(now()), Status::Failed);
840 - s.observe_error("connection refused");
841 - assert_eq!(s.status(now()), Status::Failed);
842 - }
843 -
844 - #[test]
845 - fn an_unreachable_source_keeps_its_last_payload_and_says_when() {
846 - let mut s = source("bento", now(), vec![node("app:x", Status::Ok, vec![])]);
847 - s.observe_error("connection refused");
848 - // Degraded, not Unknown: we still have a recent answer, we just could
849 - // not refresh it.
850 - assert_eq!(s.status(now()), Status::Degraded);
851 - assert!(
852 - s.payload.is_some(),
853 - "the last known state must not go blank"
854 - );
855 - let summary = s.summary(now());
856 - assert!(summary.contains("connection refused"), "{summary}");
857 - assert!(summary.contains("last ok"), "{summary}");
858 - }
859 -
860 - #[test]
861 - fn a_source_that_never_answered_and_then_failed_is_unknown() {
862 - let mut s = SourceState::new("bento", TimeDelta::seconds(60));
863 - s.observe_error("connection refused");
864 - assert_eq!(s.status(now()), Status::Unknown);
865 - assert!(s.summary(now()).contains("last ok never"));
866 - }
867 -
868 - #[test]
869 - fn rows_put_children_under_their_parent() {
870 - let s = source(
871 - "sando",
872 - now(),
873 - vec![
874 - node("tier:b", Status::Ok, vec!["node:prod-1"]),
875 - node("node:prod-1", Status::Ok, vec![]),
876 - ],
877 - );
878 - let rows = s.rows();
879 - assert_eq!(rows.len(), 2);
880 - assert_eq!(rows[0].node.id, "tier:b");
881 - assert_eq!(rows[0].depth, 0);
882 - assert_eq!(rows[1].node.id, "node:prod-1");
883 - assert_eq!(rows[1].depth, 1);
884 - }
885 -
886 - #[test]
887 - fn a_dangling_child_reference_is_skipped_not_fatal() {
888 - let s = source(
889 - "sando",
890 - now(),
891 - vec![node("tier:b", Status::Ok, vec!["node:ghost"])],
892 - );
893 - assert_eq!(s.rows().len(), 1);
894 - }
895 -
896 - #[test]
897 - fn the_rollup_puts_the_worst_source_first() {
898 - let m = Model::new(vec![
899 - source("aaa", now(), vec![node("n", Status::Ok, vec![])]),
900 - source("bbb", now(), vec![node("n", Status::Failed, vec![])]),
901 - source("ccc", now(), vec![node("n", Status::Degraded, vec![])]),
902 - ]);
903 - let order = m.rollup_order(now());
904 - let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect();
905 - assert_eq!(names, vec!["bbb", "ccc", "aaa"]);
906 - assert_eq!(m.worst(now()), Status::Failed);
907 - }
908 -
909 - #[test]
910 - fn an_unreachable_source_outranks_a_merely_degraded_one() {
911 - let m = Model::new(vec![
912 - source("degraded", now(), vec![node("n", Status::Degraded, vec![])]),
913 - SourceState::new("silent", TimeDelta::seconds(60)),
914 - ]);
915 - let order = m.rollup_order(now());
916 - assert_eq!(m.sources[order[0]].name, "silent");
917 - }
918 -
919 - #[test]
920 - fn equal_statuses_sort_by_name_so_the_order_does_not_jitter() {
921 - let m = Model::new(vec![
922 - source("zebra", now(), vec![node("n", Status::Ok, vec![])]),
923 - source("alpha", now(), vec![node("n", Status::Ok, vec![])]),
924 - ]);
925 - let order = m.rollup_order(now());
926 - let names: Vec<&str> = order.iter().map(|&i| m.sources[i].name.as_str()).collect();
927 - assert_eq!(names, vec!["alpha", "zebra"]);
928 - }
929 -
930 - #[test]
931 - fn the_tabs_are_fixed_and_wrap_in_both_directions() {
932 - // Two sources, three tabs: the count no longer follows the config,
933 - // which is the whole of what this restructure changed.
934 - let mut m = Model::new(vec![source("a", now(), vec![]), source("b", now(), vec![])]);
935 - assert_eq!(Tab::titles(), vec!["live", "logs", "store"]);
936 - assert_eq!(m.tab, Tab::Live);
937 - m.next_tab();
938 - assert_eq!(m.tab, Tab::Logs);
939 - m.next_tab();
940 - assert_eq!(m.tab, Tab::Store);
941 - m.next_tab();
942 - assert_eq!(m.tab, Tab::Live);
943 - m.prev_tab();
944 - assert_eq!(m.tab, Tab::Store);
945 - }
946 -
947 - #[test]
948 - fn a_tab_out_of_range_is_ignored_rather_than_clamped() {
949 - let mut m = Model::new(vec![source("a", now(), vec![])]);
950 - m.select_tab(1);
951 - assert_eq!(m.tab, Tab::Logs);
952 - m.select_tab(9);
953 - assert_eq!(m.tab, Tab::Logs, "a mistyped digit must not move the tab");
954 - }
955 -
956 - #[test]
957 - fn the_live_rows_put_each_sources_nodes_under_it_worst_first() {
958 - let mut parent = node("tier:b", Status::Failed, vec!["node:prod-1"]);
959 - parent.children = vec!["node:prod-1".into()];
960 - let m = Model::new(vec![
961 - source("healthy", now(), vec![node("n", Status::Ok, vec![])]),
962 - source(
963 - "broken",
964 - now(),
965 - vec![parent, node("node:prod-1", Status::Ok, vec![])],
966 - ),
967 - ]);
968 - let rows = m.live_rows(now());
969 - // The failing source leads, then its node, then its child, then the
970 - // healthy source and its node.
971 - assert_eq!(rows.len(), 5);
972 - assert!(matches!(rows[0], LiveRow::Source { index: 1 }));
973 - assert!(matches!(rows[1], LiveRow::Node { depth: 1, .. }));
974 - assert!(matches!(rows[2], LiveRow::Node { depth: 2, .. }));
975 - assert!(matches!(rows[3], LiveRow::Source { index: 0 }));
976 - assert_eq!(rows[1].source_index(), 1, "a node knows its own source");
977 - assert!(rows[0].node().is_none(), "a source line is not a node");
978 - }
979 -
980 - #[test]
981 - fn selection_cannot_run_off_either_end() {
982 - let mut m = Model::new(vec![source(
983 - "sando",
984 - now(),
985 - vec![node("a", Status::Ok, vec![]), node("b", Status::Ok, vec![])],
986 - )]);
987 - m.move_selection(-5, now());
988 - assert_eq!(m.selected, 0);
989 - m.move_selection(99, now());
990 - // One source line plus two nodes.
991 - assert_eq!(m.selected, 2);
992 - }
993 -
994 - #[test]
995 - fn a_shrinking_payload_pulls_the_cursor_back_in_bounds() {
996 - // A poll that returns fewer nodes must not leave the cursor dangling.
997 - let mut m = Model::new(vec![source(
998 - "sando",
999 - now(),
1000 - vec![
1001 - node("a", Status::Ok, vec![]),
1002 - node("b", Status::Ok, vec![]),
1003 - node("c", Status::Ok, vec![]),
1004 - ],
1005 - )]);
1006 - m.move_selection(3, now());
1007 - assert_eq!(m.selected, 3);
1008 - m.sources[0].observe(payload(now(), vec![node("a", Status::Ok, vec![])]), now());
1009 - m.clamp_selection(now());
1010 - assert_eq!(m.selected, 1);
1011 - assert!(m.live_rows(now()).get(m.selected).is_some());
1012 - }
1013 -
1014 - #[test]
1015 - fn selection_survives_an_empty_payload() {
1016 - let mut m = Model::new(vec![SourceState::new("sando", TimeDelta::seconds(60))]);
1017 - m.sources[0].observe(payload(now(), vec![]), now());
1018 - m.move_selection(1, now());
1019 - // The source line is still a row; it just has nothing under it.
1020 - assert_eq!(m.selected, 0);
1021 - assert!(m.live_rows(now())[0].node().is_none());
1022 - }
1023 -
1024 - #[test]
1025 - fn the_logs_group_by_source_by_name_and_run_newest_first() {
1026 - let mut a = SourceState::new("zebra", TimeDelta::seconds(60));
1027 - let mut pz = payload(now(), vec![]);
1028 - pz.events = vec![event(now(), "z-old"), event(now(), "z-new")];
1029 - pz.events[0].at = now() - TimeDelta::minutes(5);
1030 - a.observe(pz, now());
1031 -
1032 - let mut b = SourceState::new("alpha", TimeDelta::seconds(60));
1033 - let mut pa = payload(now(), vec![]);
1034 - pa.events = vec![event(now(), "a-only")];
1035 - b.observe(pa, now());
1036 -
1037 - let m = Model::new(vec![a, b]);
1038 - let rows = m.log_rows();
1039 - let seen: Vec<(&str, &str)> = rows
1040 - .iter()
1041 - .map(|r| (r.source, r.event.label.as_str()))
1042 - .collect();
1043 - assert_eq!(
1044 - seen,
1045 - vec![("alpha", "a-only"), ("zebra", "z-new"), ("zebra", "z-old"),],
1046 - "sources in name order, events newest first within each"
1047 - );
1048 - }
1049 -
1050 - fn spec(series: &str, label: &str) -> Series {
1051 - Series {
1052 - name: series.into(),
1053 - label: label.into(),
1054 - unit: Some("edges".into()),
1055 - }
1056 - }
1057 -
1058 - fn reading(series: &str, labels: &str, value: f64) -> Reading {
1059 - Reading {
1060 - series: series.into(),
1061 - labels: labels.into(),
1062 - value,
1063 - at: now(),
1064 - }
1065 - }
1066 -
1067 - #[test]
1068 - fn the_store_rows_follow_the_config_and_split_by_label_set() {
1069 - let mut store = StoreState::new(
1070 - "witchbroom",
1071 - vec![
1072 - spec("soak.coverage_edges", "Coverage reached"),
1073 - spec("cache.size_bytes", "Cache size"),
1074 - ],
1075 - );
1076 - store.observe(
1077 - vec![
1078 - reading("soak.coverage_edges", r#"{"repo":"a"}"#, 100.0),
1079 - reading("soak.coverage_edges", r#"{"repo":"b"}"#, 200.0),
1080 - // In the store, never named in config: must not appear.
1081 - reading("cache.hit_rate_pct", "{}", 90.0),
1082 - ],
1083 - now(),
1084 - );
1085 - let m = Model::new(vec![]).with_stores(vec![store]);
1086 - let rows = m.store_rows();
1087 -
1088 - assert_eq!(rows.len(), 3, "two label sets plus the unrecorded series");
1089 - assert!(matches!(
1090 - rows[0],
1091 - StoreRow::Value { spec, .. } if spec.label == "Coverage reached"
1092 - ));
1093 - assert!(matches!(rows[1], StoreRow::Value { .. }));
1094 - // A configured series the store has nothing for is shown, not skipped.
1095 - assert!(matches!(
1096 - rows[2],
1097 - StoreRow::Missing { spec, .. } if spec.label == "Cache size"
1098 - ));
1099 - }
1100 -
1101 - #[test]
1102 - fn a_series_the_config_never_named_is_not_a_row() {
1103 - // The ruling's accepted cost. A fallback that rendered this "just in
1104 - // case" is the table browser arriving by the back door.
1105 - let mut store = StoreState::new("witchbroom", vec![spec("named", "Named")]);
1106 - store.observe(vec![reading("unnamed", "{}", 1.0)], now());
1107 - let m = Model::new(vec![]).with_stores(vec![store]);
1108 - let rows = m.store_rows();
1109 - assert_eq!(rows.len(), 1);
1110 - assert!(matches!(rows[0], StoreRow::Missing { .. }));
1111 - }
1112 -
1113 - #[test]
1114 - fn an_unreadable_store_says_so_above_whatever_it_last_said() {
1115 - let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]);
1116 - store.observe(vec![reading("s", "{}", 41.0)], now());
1117 - store.observe_error("unable to open database file");
1118 - let m = Model::new(vec![]).with_stores(vec![store]);
1119 - let rows = m.store_rows();
1120 -
1121 - assert!(
1122 - matches!(rows[0], StoreRow::Unavailable { reason, .. } if reason.contains("open")),
1123 - "the failure leads, so old numbers are not read as current"
1124 - );
1125 - assert!(
1126 - matches!(rows[1], StoreRow::Value { reading, .. } if (reading.value - 41.0).abs() < f64::EPSILON),
1127 - "the last known values are still there"
1128 - );
1129 - }
1130 -
1131 - #[test]
1132 - fn no_configured_store_is_no_rows_rather_than_an_empty_one() {
1133 - assert!(Model::new(vec![]).store_rows().is_empty());
1134 - }
1135 -
1136 - #[test]
1137 - fn the_store_cursor_cannot_run_off_either_end() {
1138 - let mut store = StoreState::new("witchbroom", vec![spec("a", "A"), spec("b", "B")]);
1139 - store.observe(vec![], now());
1140 - let mut m = Model::new(vec![]).with_stores(vec![store]);
1141 - m.tab = Tab::Store;
1142 - m.move_selection(99, now());
1143 - assert_eq!(m.store_scroll, 1);
1144 - m.move_selection(-99, now());
1145 - assert_eq!(m.store_scroll, 0);
1146 - }
1147 -
1148 - #[test]
1149 - fn a_shrinking_store_pulls_its_cursor_back_in_bounds() {
1150 - let mut store = StoreState::new("witchbroom", vec![spec("s", "S")]);
1151 - store.observe(
1152 - vec![
1153 - reading("s", r#"{"repo":"a"}"#, 1.0),
1154 - reading("s", r#"{"repo":"b"}"#, 2.0),
1155 - reading("s", r#"{"repo":"c"}"#, 3.0),
1156 - ],
1157 - now(),
1158 - );
1159 - let mut m = Model::new(vec![]).with_stores(vec![store]);
1160 - m.tab = Tab::Store;
1161 - m.move_selection(2, now());
1162 - assert_eq!(m.store_scroll, 2);
1163 - m.stores[0].observe(vec![reading("s", r#"{"repo":"a"}"#, 1.0)], now());
1164 - m.clamp_selection(now());
1165 - assert_eq!(m.store_scroll, 0);
1166 - }
1167 -
1168 - #[test]
1169 - fn a_source_that_never_answered_contributes_no_log_rows() {
1170 - let m = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]);
1171 - assert!(m.log_rows().is_empty());
1172 - }
1173 -
1174 - #[test]
1175 - fn a_disabled_source_refuses_to_open_the_picker_and_says_why() {
1176 - let mut n = node("tier:b", Status::Ok, vec![]);
1177 - n.actions = vec!["rollback-b".into()];
1178 - let mut p = payload(now(), vec![n]);
1179 - p.actions
1180 - .insert("rollback-b".into(), act("Roll back", true, true));
1181 - // allow_actions defaults off.
1182 - let mut s = SourceState::new("sando", TimeDelta::seconds(60));
1183 - s.observe(p, now());
1184 - let mut m = Model::new(vec![s]);
1185 - m.selected = 1;
1186 -
1187 - m.open_actions(now());
1188 - assert!(
1189 - m.prompt.is_none(),
1190 - "a read-only source must not open a prompt"
1191 - );
1192 - assert!(m.message.as_deref().unwrap().contains("read-only"));
1193 - }
1194 -
1195 - #[test]
1196 - fn a_node_with_no_actions_does_nothing_on_enter() {
1197 - let mut m = actionable(&[], vec![]);
1198 - m.open_actions(now());
1199 - assert!(m.prompt.is_none());
1200 - assert!(m.message.is_none());
1201 - }
1202 -
1203 - #[test]
1204 - fn a_source_line_never_opens_an_action_prompt() {
1205 - let mut m = actionable(
1206 - &["rollback-b"],
1207 - vec![("rollback-b", act("Roll back", true, true))],
1208 - );
1209 - m.selected = 0; // the source line, not its node
1210 - m.open_actions(now());
1211 - assert!(
1212 - m.prompt.is_none(),
1213 - "a source declares no actions; its nodes do"
1214 - );
1215 - }
1216 -
1217 - #[test]
1218 - fn the_other_tabs_never_open_an_action_prompt() {
1219 - for tab in [Tab::Logs, Tab::Store] {
1220 - let mut m = actionable(
1221 - &["rollback-b"],
1222 - vec![("rollback-b", act("Roll back", true, true))],
1223 - );
1224 - m.tab = tab;
Lines truncated
@@ -860,587 +860,4 @@
860 860 }
861 861
862 862 #[cfg(test)]
863 - mod tests {
864 - use super::*;
865 - use crate::model::SourceState;
866 - use chrono::TimeDelta;
867 - use ops_status::{Condition, Field, Payload, Status, Value};
868 - use ratatui::Terminal;
869 - use ratatui::backend::TestBackend;
870 -
871 - fn now() -> DateTime<Utc> {
872 - "2026-07-21T18:00:00Z".parse().unwrap()
873 - }
874 -
875 - /// Render a model into a fixed-size buffer and return it as text lines.
876 - ///
877 - /// This is the whole payoff of keeping render pure: the entire surface is
878 - /// verifiable with no daemon running and no terminal attached.
879 - fn draw(model: &Model, now: DateTime<Utc>, width: u16, height: u16) -> Vec<String> {
880 - let theme = crate::theme::tests::fixed();
881 - let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
882 - terminal
883 - .draw(|frame| render(model, &theme, now, frame))
884 - .unwrap();
885 - let buffer = terminal.backend().buffer().clone();
886 - (0..buffer.area.height)
887 - .map(|y| {
888 - (0..buffer.area.width)
889 - .map(|x| buffer[(x, y)].symbol().to_string())
890 - .collect::<String>()
891 - .trim_end()
892 - .to_string()
893 - })
894 - .collect()
895 - }
896 -
897 - fn node(id: &str, label: &str, status: Status) -> Node {
898 - Node {
899 - id: id.into(),
900 - kind: "tier".into(),
901 - label: label.into(),
902 - status,
903 - fields: Vec::new(),
904 - conditions: Vec::new(),
905 - children: Vec::new(),
906 - actions: Vec::new(),
907 - }
908 - }
909 -
910 - fn source(name: &str, at: DateTime<Utc>, nodes: Vec<Node>) -> SourceState {
911 - let mut s = SourceState::new(name, TimeDelta::seconds(60));
912 - let mut p = Payload::new(name, at);
913 - p.nodes = nodes;
914 - s.observe(p, at);
915 - s
916 - }
917 -
918 - fn joined(lines: &[String]) -> String {
919 - lines.join("\n")
920 - }
921 -
922 - #[test]
923 - fn the_live_tab_leads_with_the_worst_source() {
924 - let model = Model::new(vec![
925 - source("sando", now(), vec![node("a", "tier a", Status::Ok)]),
926 - source("bento", now(), vec![node("b", "goingson", Status::Failed)]),
927 - ]);
928 - let lines = draw(&model, now(), 80, 12);
929 - let text = joined(&lines);
930 -
931 - assert!(
932 - text.contains("live"),
933 - "the tab bar names the fixed tabs:\n{text}"
934 - );
935 - assert!(text.contains("logs"), "{text}");
936 - assert!(text.contains("store"), "{text}");
937 - // Skip the tab bar, which names every tab regardless of order.
938 - let body = &lines[1..];
939 - let bento = body.iter().position(|l| l.contains("bento")).unwrap();
940 - let sando = body.iter().position(|l| l.contains("sando")).unwrap();
941 - assert!(bento < sando, "the failing source must be on top:\n{text}");
942 - assert!(text.contains("FAIL"), "{text}");
943 - }
944 -
945 - #[test]
946 - fn no_source_gets_a_tab_of_its_own() {
947 - // The restructure, asserted directly: two sources, three tabs, and the
948 - // tab bar names none of them.
949 - let model = Model::new(vec![
950 - source("sando", now(), vec![node("a", "tier a", Status::Ok)]),
951 - source("bento", now(), vec![node("b", "goingson", Status::Ok)]),
952 - ]);
953 - let bar = draw(&model, now(), 80, 12)[0].clone();
954 - assert!(
955 - bar.contains("live") && bar.contains("logs") && bar.contains("store"),
956 - "{bar}"
957 - );
958 - assert!(
959 - !bar.contains("sando"),
960 - "a source must not own a tab:\n{bar}"
961 - );
962 - assert!(!bar.contains("bento"), "{bar}");
963 - }
964 -
965 - #[test]
966 - fn a_source_that_has_never_answered_says_so_rather_than_showing_nothing() {
967 - let model = Model::new(vec![SourceState::new("bento", TimeDelta::seconds(60))]);
968 - let text = joined(&draw(&model, now(), 80, 12));
969 - assert!(
970 - text.contains("????"),
971 - "an unreachable source must be loud:\n{text}"
972 - );
973 - assert!(text.contains("waiting for first poll"), "{text}");
974 - }
975 -
976 - #[test]
977 - fn a_stale_source_shows_its_age_on_the_live_tab() {
978 - let model = Model::new(vec![source(
979 - "pom",
980 - now() - TimeDelta::hours(4),
981 - vec![node("backup", "backup", Status::Ok)],
982 - )]);
983 - let text = joined(&draw(&model, now(), 80, 12));
984 - assert!(text.contains("4h"), "the age must be visible:\n{text}");
985 - assert!(text.contains("degr"), "stale-but-green is not ok:\n{text}");
986 - }
987 -
988 - #[test]
989 - fn a_narrow_live_tab_drops_the_age_before_it_drops_the_detail() {
990 - // What the hand-written `Constraint`s could not do: at 80 columns every
991 - // column is drawn, and at a width where they no longer all fit the
992 - // priority decides which one goes rather than the order they were
993 - // written in. Age is the only Secondary column, so it is the only one
994 - // that can go.
995 - let model = Model::new(vec![source(
996 - "pom",
997 - now() - TimeDelta::hours(4),
998 - vec![node("backup", "backup", Status::Ok)],
999 - )]);
1000 -
1001 - let wide = joined(&draw(&model, now(), 80, 12));
1002 - assert!(wide.contains("age"), "the age column at 80 wide:\n{wide}");
1003 -
1004 - let narrow = joined(&draw(&model, now(), 24, 12));
1005 - assert!(!narrow.contains("age"), "age must drop first:\n{narrow}");
1006 - assert!(narrow.contains("pom"), "the source stays:\n{narrow}");
1007 - assert!(narrow.contains("detail"), "the detail stays:\n{narrow}");
1008 - }
1009 -
1010 - #[test]
1011 - fn the_live_tab_nests_nodes_under_their_source_and_children_under_those() {
1012 - let mut parent = node("tier:b", "b (prod-1)", Status::Ok);
1013 - parent.children = vec!["node:prod-1".into()];
1014 - let child = node("node:prod-1", "prod-1", Status::Ok);
1015 -
1016 - let mut model = Model::new(vec![source("sando", now(), vec![parent, child])]);
1017 - model.selected = 1;
1018 - let lines = draw(&model, now(), 80, 20);
1019 - let text = joined(&lines);
1020 -
1021 - let source_row = lines
1022 - .iter()
1023 - .position(|l| l.contains("sando") && !l.contains("live"))
1024 - .unwrap();
1025 - let parent_row = lines.iter().position(|l| l.contains("b (prod-1)")).unwrap();
1026 - let child_row = lines
1027 - .iter()
1028 - .rposition(|l| l.contains("prod-1") && !l.contains("b (prod-1)"))
1029 - .unwrap();
1030 - assert!(
1031 - source_row < parent_row,
1032 - "the source leads its nodes:\n{text}"
1033 - );
1034 - assert!(parent_row < child_row, "{text}");
1035 -
1036 - // Each level is indented relative to the one above it.
1037 - let source_col = lines[source_row].find("sando").unwrap();
1038 - let parent_col = lines[parent_row].find("b (prod-1)").unwrap();
1039 - let child_col = lines[child_row].find("prod-1").unwrap();
1040 - assert!(
1041 - source_col < parent_col,
1042 - "a node is indented under its source:\n{text}"
1043 - );
1044 - assert!(parent_col < child_col, "child must be indented:\n{text}");
1045 - }
1046 -
1047 - #[test]
1048 - fn the_detail_pane_shows_conditions_with_their_why() {
1049 - let mut n = node("tier:b", "b", Status::Ok);
1050 - n.conditions = vec![Condition {
1051 - condition_type: "burn_in".into(),
1052 - status: Status::Pending,
1053 - since: None,
1054 - detail: Some("17 hours remaining of 48".into()),
1055 - }];
1056 - let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1057 - model.selected = 1;
1058 - let text = joined(&draw(&model, now(), 80, 20));
1059 -
1060 - assert!(text.contains("burn_in"), "{text}");
1061 - assert!(
1062 - text.contains("17 hours remaining"),
1063 - "a condition without its why is useless:\n{text}"
1064 - );
1065 - }
1066 -
1067 - #[test]
1068 - fn a_progress_field_renders_as_a_bar() {
1069 - let mut n = node("tier:b", "b", Status::Ok);
1070 - n.fields = vec![Field::new(
1071 - "burn-in",
1072 - Value::Progress {
1073 - value: 31.0,
1074 - max: 48.0,
1075 - unit: Some("hour".into()),
1076 - },
1077 - )];
1078 - let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1079 - model.selected = 1;
1080 - let text = joined(&draw(&model, now(), 80, 20));
1081 -
1082 - assert!(text.contains("31/48 hour"), "{text}");
1083 - assert!(
1084 - text.contains('#'),
1085 - "a progress value must draw a bar:\n{text}"
1086 - );
1087 - }
1088 -
1089 - #[test]
1090 - fn an_instant_renders_relative_to_the_passed_in_clock() {
1091 - let mut n = node("tier:b", "b", Status::Ok);
1092 - n.fields = vec![Field::new(
1093 - "built",
1094 - Value::Instant {
1095 - value: now() - TimeDelta::minutes(3),
1096 - },
1097 - )];
1098 - let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1099 - model.selected = 1;
1100 - let text = joined(&draw(&model, now(), 80, 20));
1101 - assert!(text.contains("3m 0s ago"), "{text}");
1102 - }
1103 -
1104 - #[test]
1105 - fn render_is_deterministic_for_a_fixed_clock() {
1106 - // The property every snapshot test rests on.
1107 - let mut n = node("tier:b", "b", Status::Ok);
1108 - n.fields = vec![Field::new(
1109 - "built",
1110 - Value::Instant {
1111 - value: now() - TimeDelta::minutes(3),
1112 - },
1113 - )];
1114 - let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1115 - model.selected = 1;
1116 - assert_eq!(draw(&model, now(), 80, 20), draw(&model, now(), 80, 20));
1117 - }
1118 -
1119 - #[test]
1120 - fn an_unknown_value_kind_still_renders_as_text() {
1121 - // Version skew: a producer one release ahead must not blank the pane.
1122 - let field: Field =
1123 - serde_json::from_str(r#"{"label":"temp","kind":"celsius","value":"41"}"#).unwrap();
1124 - let mut n = node("tier:b", "b", Status::Ok);
1125 - n.fields = vec![field];
1126 - let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1127 - model.selected = 1;
1128 - let text = joined(&draw(&model, now(), 80, 20));
1129 - assert!(text.contains("temp"), "{text}");
1130 - assert!(text.contains("41"), "{text}");
1131 - }
1132 -
1133 - #[test]
1134 - fn a_narrow_terminal_does_not_panic() {
1135 - // Every widget here has to survive a width no layout was designed for.
1136 - let mut n = node("tier:b", "a rather long tier label", Status::Failed);
1137 - n.fields = vec![Field::new(
1138 - "path",
1139 - Value::Path {
1140 - value: "/srv/sando/releases/a3f9c21b7e4d8056/bin/makenotwork".into(),
1141 - },
1142 - )];
1143 - n.conditions = vec![Condition {
1144 - condition_type: "node_health".into(),
1145 - status: Status::Failed,
1146 - since: None,
1147 - detail: Some("prod-1 unhealthy: connection refused after 30s".into()),
1148 - }];
1149 - let mut model = Model::new(vec![source("sando", now(), vec![n])]);
1150 - model.selected = 1;
1151 - for width in [8_u16, 12, 20, 40] {
1152 - for height in [4_u16, 8, 20] {
1153 - let _ = draw(&model, now(), width, height);
1154 - }
1155 - }
1156 - }
1157 -
1158 - #[test]
1159 - fn a_multiline_detail_is_flattened_not_sprawled() {
1160 - assert_eq!(truncate("a\nb", 40), "a b");
1161 - assert!(truncate(&"x".repeat(100), 10).ends_with('…'));
1162 - assert_eq!(truncate(&"x".repeat(100), 10).chars().count(), 10);
1163 - }
1164 -
1165 - fn action(label: &str, danger: bool) -> ops_status::Action {
1166 - ops_status::Action {
1167 - label: label.into(),
1168 - method: ops_status::Method::Post,
1169 - url: "/rollback/b".into(),
1170 - confirm: true,
1171 - danger,
1172 - body: None,
1173 - }
1174 - }
1175 -
1176 - /// A source with one node declaring `keys`, actions allowed, on its tab.
1177 - fn actionable(keys: &[(&str, bool)]) -> Model {
1178 - let mut n = node("tier:b", "b (prod-1)", Status::Ok);
1179 - n.actions = keys.iter().map(|(k, _)| k.to_string()).collect();
1180 - let mut p = Payload::new("sando", now());
1181 - p.nodes = vec![n];
1182 - p.actions = keys
1183 - .iter()
1184 - .map(|(k, d)| (k.to_string(), action(k, *d)))
1185 - .collect();
1186 - let mut s = SourceState::new("sando", TimeDelta::seconds(60)).with_actions(true);
1187 - s.observe(p, now());
1188 - let mut m = Model::new(vec![s]);
1189 - // Row 0 is the source line, row 1 its only node.
1190 - m.selected = 1;
1191 - m
1192 - }
1193 -
1194 - #[test]
1195 - fn the_detail_hint_says_whether_actions_can_run() {
1196 - let mut m = actionable(&[("rollback-b", true)]);
1197 - let text = joined(&draw(&m, now(), 80, 20));
1198 - assert!(text.contains("rollback-b"), "{text}");
1199 - assert!(text.contains("enter to run"), "{text}");
1200 -
1201 - m.sources[0].allow_actions = false;
1202 - let text = joined(&draw(&m, now(), 80, 20));
1203 - assert!(
1204 - text.contains("read-only"),
1205 - "a disabled source must say so:\n{text}"
1206 - );
1207 - }
1208 -
1209 - #[test]
1210 - fn the_picker_lists_a_nodes_actions() {
1211 - let mut m = actionable(&[("promote-b", false), ("rollback-b", true)]);
1212 - m.open_actions(now());
1213 - let text = joined(&draw(&m, now(), 80, 20));
1214 - assert!(text.contains("run action"), "{text}");
1215 - assert!(text.contains("[promote-b]"), "{text}");
1216 - assert!(text.contains("[rollback-b]"), "{text}");
1217 - assert!(text.contains("enter run"), "{text}");
1218 - }
1219 -
1220 - #[test]
1221 - fn a_danger_prompt_shows_the_key_to_type() {
1222 - let mut m = actionable(&[("rollback-b", true)]);
1223 - m.open_actions(now());
1224 - m.prompt_enter(); // Pick -> Type (danger)
1225 - let text = joined(&draw(&m, now(), 80, 20));
1226 - assert!(
1227 - text.contains("DANGER"),
1228 - "a danger action must be loud:\n{text}"
1229 - );
1230 - assert!(
1231 - text.contains("type 'rollback-b'"),
1232 - "the exact key to type must be shown:\n{text}"
1233 - );
1234 - }
1235 -
1236 - #[test]
1237 - fn a_retracted_action_is_named_in_the_confirmation_not_left_blank() {
1238 - let mut m = actionable(&[("promote-b", false)]);
1239 - m.open_actions(now());
1240 - m.prompt_enter(); // Pick -> Confirm (confirm, not danger)
1241 - // A poll drops the action while the confirm box is up.
1242 - let mut p = Payload::new("sando", now());
1243 - p.nodes = vec![node("tier:b", "b", Status::Ok)];
1244 - m.sources[0].observe(p, now());
1245 - let text = joined(&draw(&m, now(), 80, 20));
1246 - assert!(text.contains("no longer offered"), "{text}");
1247 - }
1248 -
1249 - fn with_events(name: &str, events: Vec<ops_status::Event>) -> SourceState {
1250 - let mut s = SourceState::new(name, TimeDelta::seconds(60));
1251 - let mut p = Payload::new(name, now());
1252 - p.events = events;
1253 - s.observe(p, now());
1254 - s
1255 - }
1256 -
1257 - fn ev(minutes_ago: i64, label: &str, status: Option<Status>) -> ops_status::Event {
1258 - ops_status::Event {
1259 - at: now() - TimeDelta::minutes(minutes_ago),
1260 - label: label.into(),
1261 - status,
1262 - detail: None,
1263 - node_id: None,
1264 - }
1265 - }
1266 -
1267 - #[test]
1268 - fn the_logs_tab_shows_every_sources_events_with_who_said_it() {
1269 - let mut model = Model::new(vec![
1270 - with_events("zebra", vec![ev(5, "sweep finished", Some(Status::Ok))]),
1271 - with_events(
1272 - "alpha",
1273 - vec![ev(1, "promote refused", Some(Status::Failed))],
1274 - ),
1275 - ]);
1276 - model.tab = crate::model::Tab::Logs;
1277 - let lines = draw(&model, now(), 80, 14);
1278 - let text = joined(&lines);
1279 -
1280 - assert!(text.contains("promote refused"), "{text}");
1281 - assert!(text.contains("sweep finished"), "{text}");
1282 - // Every line says who said it, so a line read alone is still readable.
1283 - assert!(text.contains("alpha"), "{text}");
1284 - assert!(text.contains("zebra"), "{text}");
1285 - // Grouped by source, in name order.
1286 - let alpha = lines.iter().position(|l| l.contains("alpha")).unwrap();
1287 - let zebra = lines.iter().position(|l| l.contains("zebra")).unwrap();
1288 - assert!(alpha < zebra, "sources group in name order:\n{text}");
1289 - // An event's own status colours it through the same marks as a node's.
1290 - assert!(text.contains("FAIL"), "{text}");
1291 - }
1292 -
1293 - #[test]
1294 - fn a_logs_tab_with_nothing_in_it_says_so_rather_than_showing_an_empty_box() {
1295 - let mut model = Model::new(vec![source("sando", now(), vec![])]);
1296 - model.tab = crate::model::Tab::Logs;
1297 - let text = joined(&draw(&model, now(), 80, 14));
1298 - assert!(text.contains("no source has reported an event"), "{text}");
1299 - }
1300 -
1301 - #[test]
1302 - fn an_event_with_no_status_is_a_note_and_gets_no_mark() {
1303 - let mut model = Model::new(vec![with_events(
1304 - "sando",
1305 - vec![ev(1, "config reloaded", None)],
1306 - )]);
1307 - model.tab = crate::model::Tab::Logs;
1308 - let lines = draw(&model, now(), 80, 14);
1309 - let text = joined(&lines);
1310 - let row = lines
1311 - .iter()
1312 - .find(|l| l.contains("config reloaded"))
1313 - .unwrap_or_else(|| panic!("{text}"));
1314 - // Only the event's own row: the header chip carries the worst status
1315 - // across every source, which is a different claim.
1316 - for mark in ["ok", "FAIL", "degr", "????"] {
1317 - assert!(
1318 - !row.contains(mark),
1319 - "a note must not be given a verdict ({mark}):\n{text}"
1320 - );
1321 - }
1322 - }
1323 -
1324 - fn stored(
1325 - series: &[(&str, &str, Option<&str>)],
1326 - readings: Vec<crate::store::Reading>,
1327 - ) -> Model {
1328 - let mut store = crate::model::StoreState::new(
1329 - "witchbroom",
1330 - series
1331 - .iter()
1332 - .map(|(s, label, unit)| crate::config::Series {
1333 - name: (*s).to_string(),
1334 - label: (*label).to_string(),
1335 - unit: unit.map(ToString::to_string),
1336 - })
1337 - .collect(),
1338 - );
1339 - store.observe(readings, now());
1340 - let mut model = Model::new(vec![]).with_stores(vec![store]);
1341 - model.tab = crate::model::Tab::Store;
1342 - model
1343 - }
1344 -
1345 - fn stored_at(
1346 - series: &str,
1347 - labels: &str,
1348 - value: f64,
1349 - at: DateTime<Utc>,
1350 - ) -> crate::store::Reading {
1351 - crate::store::Reading {
1352 - series: series.into(),
1353 - labels: labels.into(),
1354 - value,
1355 - at,
1356 - }
1357 - }
1358 -
1359 - #[test]
Lines truncated
M pom/src/api.rs +1 -226
@@ -1098,229 +1098,4 @@
1098 1098 }
1099 1099
1100 1100 #[cfg(test)]
1101 - mod tests {
1102 - use super::*;
1103 - use axum::body::Body;
1104 - use axum::http::Request as HttpRequest;
1105 - use tower::ServiceExt;
1106 -
1107 - fn test_config(api_token: Option<&str>) -> Config {
1108 - let mut config = Config {
1109 - serve: crate::config::ServeConfig::default(),
1110 - instance: crate::config::InstanceConfig::default(),
1111 - targets: HashMap::new(),
1112 - peers: HashMap::new(),
1113 - storage: crate::config::StorageConfig::default(),
1114 - alerts: None,
1115 - };
1116 - config.serve.api_token = api_token.map(std::string::ToString::to_string);
1117 - config
1118 - }
1119 -
1120 - #[tokio::test]
1121 - async fn no_token_configured_allows_all_requests() {
1122 - let pool = crate::db::connect_in_memory().await.unwrap();
1123 - let app = router(pool, test_config(None), None);
1124 -
1125 - let resp = app
1126 - .oneshot(with_connect_info("/api/status", None))
1127 - .await
1128 - .unwrap();
1129 - assert_eq!(resp.status(), StatusCode::OK);
1130 - }
1131 -
1132 - #[tokio::test]
1133 - async fn valid_token_allows_request() {
1134 - let pool = crate::db::connect_in_memory().await.unwrap();
1135 - let app = router(pool, test_config(Some("secret123")), None);
1136 -
1137 - let resp = app
1138 - .oneshot(with_connect_info("/api/status", Some("Bearer secret123")))
1139 - .await
1140 - .unwrap();
1141 - assert_eq!(resp.status(), StatusCode::OK);
1142 - }
1143 -
1144 - #[tokio::test]
1145 - async fn dashboard_uses_cookie_not_embedded_token() {
1146 - // SERIOUS #4: GET / must NOT ship the api_token in the page, and must set
1147 - // an httpOnly session cookie that authenticates the dashboard's /api/* calls.
1148 - let pool = crate::db::connect_in_memory().await.unwrap();
1149 - let mut config = test_config(Some("supersecret-token"));
1150 - config.serve.dashboard = true;
1151 - let app = router(pool, config, None);
1152 -
1153 - let req = HttpRequest::builder().uri("/").body(Body::empty()).unwrap();
1154 - let resp = app.clone().oneshot(req).await.unwrap();
1155 - assert_eq!(resp.status(), StatusCode::OK);
1156 -
1157 - let cookie_hdr = resp
1158 - .headers()
1159 - .get(axum::http::header::SET_COOKIE)
1160 - .expect("dashboard must set a session cookie")
1161 - .to_str()
1162 - .unwrap()
1163 - .to_string();
1164 - assert!(cookie_hdr.contains("pom_dash="));
1165 - assert!(cookie_hdr.contains("HttpOnly"));
1166 - assert!(
1167 - !cookie_hdr.contains("supersecret-token"),
1168 - "cookie must not be the api_token"
1169 - );
1170 -
1171 - let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1172 - .await
1173 - .unwrap();
1174 - let html = String::from_utf8_lossy(&body);
1175 - assert!(
1176 - !html.contains("supersecret-token"),
1177 - "the api_token must never appear in served HTML"
1178 - );
1179 -
1180 - // The issued cookie authenticates an /api/* call without any bearer token.
1181 - let dash = cookie_hdr.split(';').next().unwrap().trim().to_string(); // "pom_dash=<value>"
1182 - let mut api_req = HttpRequest::builder()
1183 - .uri("/api/status")
1184 - .header(axum::http::header::COOKIE, dash)
1185 - .body(Body::empty())
1186 - .unwrap();
1187 - api_req
1188 - .extensions_mut()
1189 - .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
1190 - [127, 0, 0, 1],
1191 - 40001,
1192 - ))));
1193 - let api_resp = app.oneshot(api_req).await.unwrap();
1194 - assert_eq!(
1195 - api_resp.status(),
1196 - StatusCode::OK,
1197 - "dashboard cookie must authenticate /api/*"
1198 - );
1199 - }
1200 -
1201 - #[tokio::test]
1202 - async fn wrong_token_returns_401() {
1203 - let pool = crate::db::connect_in_memory().await.unwrap();
1204 - let app = router(pool, test_config(Some("secret123")), None);
1205 -
1206 - let req = HttpRequest::builder()
1207 - .uri("/api/status")
1208 - .header("authorization", "Bearer wrong-token")
1209 - .body(Body::empty())
1210 - .unwrap();
1211 - let resp = app.oneshot(req).await.unwrap();
1212 - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1213 - }
1214 -
1215 - #[tokio::test]
1216 - async fn missing_header_returns_401() {
1217 - let pool = crate::db::connect_in_memory().await.unwrap();
1218 - let app = router(pool, test_config(Some("secret123")), None);
1219 -
1220 - let req = HttpRequest::builder()
1221 - .uri("/api/status")
1222 - .body(Body::empty())
1223 - .unwrap();
1224 - let resp = app.oneshot(req).await.unwrap();
1225 - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1226 - }
1227 -
1228 - #[tokio::test]
1229 - async fn malformed_header_returns_401() {
1230 - let pool = crate::db::connect_in_memory().await.unwrap();
1231 - let app = router(pool, test_config(Some("secret123")), None);
1232 -
1233 - let req = HttpRequest::builder()
1234 - .uri("/api/status")
1235 - .header("authorization", "Basic dXNlcjpwYXNz")
1236 - .body(Body::empty())
1237 - .unwrap();
1238 - let resp = app.oneshot(req).await.unwrap();
1239 - assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1240 - }
1241 -
1242 - fn ip(n: u8) -> std::net::IpAddr {
1243 - std::net::IpAddr::V4(std::net::Ipv4Addr::new(10, 0, 0, n))
1244 - }
1245 -
1246 - #[test]
1247 - fn reproject_drops_injected_structure() {
1248 - // #6: a compromised peer stuffs extra structure into its status blob.
1249 - let hostile = serde_json::json!({
1250 - "instance": { "id": "abc", "version": "9.9", "evil_field": {"x": 1} },
1251 - "targets": { "mnw": { "status": "operational", "response_time_ms": 5, "evil": "inject" } },
1252 - "peers": { "p2": { "status": "up", "latency_ms": 3 } },
1253 - "top_level_injection": [1, 2, 3]
1254 - });
1255 - let clean = reproject_peer_status(&hostile);
1256 -
1257 - // Only the fixed top-level keys survive.
1258 - let obj = clean.as_object().unwrap();
1259 - let mut keys: Vec<&String> = obj.keys().collect();
1260 - keys.sort();
1261 - assert_eq!(keys, vec!["instance", "peers", "targets"]);
1262 - assert!(clean.get("top_level_injection").is_none());
1263 -
1264 - // Known values are preserved; injected sibling keys are gone.
1265 - assert_eq!(clean["instance"]["id"], "abc");
1266 - assert_eq!(clean["instance"]["version"], "9.9");
1267 - assert!(clean["instance"].get("evil_field").is_none());
1268 - assert_eq!(clean["targets"]["mnw"]["status"], "operational");
1269 - assert!(clean["targets"]["mnw"].get("evil").is_none());
1270 - assert_eq!(clean["peers"]["p2"]["latency_ms"], 3);
1271 - }
1272 -
1273 - /// Build a GET request carrying a `ConnectInfo<SocketAddr>` extension, which
1274 - /// the real server injects via `into_make_service_with_connect_info` but
1275 - /// `oneshot` does not, the rate-limit layer extracts it.
1276 - fn with_connect_info(uri: &str, bearer: Option<&str>) -> HttpRequest<Body> {
1277 - let mut b = HttpRequest::builder().uri(uri);
1278 - if let Some(h) = bearer {
1279 - b = b.header("authorization", h);
1280 - }
1281 - let mut req = b.body(Body::empty()).unwrap();
1282 - req.extensions_mut()
1283 - .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
1284 - [127, 0, 0, 1],
1285 - 40000,
1286 - ))));
1287 - req
1288 - }
1289 -
1290 - #[test]
1291 - fn rate_limiter_allows_within_limit() {
1292 - let limiter = PerIpRateLimiter::new(3, std::time::Duration::from_mins(1));
1293 - assert!(limiter.try_acquire(ip(1)));
1294 - assert!(limiter.try_acquire(ip(1)));
1295 - assert!(limiter.try_acquire(ip(1)));
1296 - }
1297 -
1298 - #[test]
1299 - fn rate_limiter_blocks_over_limit() {
1300 - let limiter = PerIpRateLimiter::new(2, std::time::Duration::from_mins(1));
1301 - assert!(limiter.try_acquire(ip(1)));
1302 - assert!(limiter.try_acquire(ip(1)));
1303 - assert!(!limiter.try_acquire(ip(1)));
1304 - }
1305 -
1306 - #[test]
1307 - fn rate_limiter_isolates_clients_by_ip() {
1308 - // SERIOUS #5: one client exhausting its bucket must not affect another.
1309 - let limiter = PerIpRateLimiter::new(1, std::time::Duration::from_mins(1));
1310 - assert!(limiter.try_acquire(ip(1)));
1311 - assert!(
1312 - !limiter.try_acquire(ip(1)),
1313 - "ip(1) is now over its own limit"
1314 - );
1315 - assert!(limiter.try_acquire(ip(2)), "ip(2) has its own fresh bucket");
1316 - }
1317 -
1318 - #[tokio::test]
1319 - async fn rate_limiter_resets_after_window() {
1320 - let limiter = PerIpRateLimiter::new(1, std::time::Duration::from_millis(10));
1321 - assert!(limiter.try_acquire(ip(1)));
1322 - assert!(!limiter.try_acquire(ip(1)));
1323 - tokio::time::sleep(std::time::Duration::from_millis(15)).await;
1324 - assert!(limiter.try_acquire(ip(1)));
1325 - }
1326 - }
1101 + mod tests;
@@ -874,913 +874,4 @@
874 874 }
875 875
876 876 #[cfg(test)]
877 - mod tests {
878 - use super::*;
879 -
880 - #[test]
881 - fn parse_full_config() {
882 - let toml = r#"
883 - [serve]
884 - interval_secs = 120
885 - listen = "127.0.0.1:9100"
886 - peer_heartbeat_secs = 30
887 -
888 - [instance]
889 - name = "hetzner"
890 -
891 - [targets.mnw]
892 - label = "MakeNotWork"
893 - [targets.mnw.health]
894 - url = "https://makenot.work/health"
895 - timeout_secs = 5
896 - [targets.mnw.tests]
897 - ssh = "hetzner"
898 - command = "cd /srv/mnw && ./ci.sh"
899 -
900 - [peers.astra]
901 - address = "100.0.0.1:9100"
902 - on_missing = "alert"
903 - grace_count = 5
904 - "#;
905 -
906 - let config: Config = toml::from_str(toml).unwrap();
907 - assert_eq!(config.serve.interval_secs, 120);
908 - assert_eq!(config.serve.listen, "127.0.0.1:9100");
909 - assert_eq!(config.serve.peer_heartbeat_secs, 30);
910 - assert_eq!(config.instance.name.as_deref(), Some("hetzner"));
911 - assert_eq!(config.target_names(), vec!["mnw"]);
912 -
913 - let mnw = config.get_target("mnw").unwrap();
914 - assert_eq!(mnw.label, "MakeNotWork");
915 - assert_eq!(mnw.health.as_ref().unwrap().timeout_secs, 5);
916 - assert_eq!(mnw.tests.as_ref().unwrap().ssh.as_deref(), Some("hetzner"));
917 -
918 - let astra = config.peers.get("astra").unwrap();
919 - assert_eq!(astra.address, "100.0.0.1:9100");
920 - assert_eq!(astra.on_missing, OnMissing::Alert);
921 - assert_eq!(astra.grace_count, Some(5));
922 - }
923 -
924 - #[test]
925 - fn empty_config_uses_defaults() {
926 - let config: Config = toml::from_str("").unwrap();
927 - assert_eq!(config.serve.interval_secs, 300);
928 - assert_eq!(config.serve.prune_days, 30);
929 - assert_eq!(config.serve.listen, "127.0.0.1:9100");
930 - assert_eq!(config.serve.peer_heartbeat_secs, 60);
931 - assert!(config.targets.is_empty());
932 - assert!(config.peers.is_empty());
933 - assert!(config.instance.name.is_none());
934 - }
935 -
936 - #[test]
937 - fn peer_on_missing_defaults_to_log() {
938 - let toml = r#"
939 - [peers.test]
940 - address = "10.0.0.1:9100"
941 - "#;
942 - let config: Config = toml::from_str(toml).unwrap();
943 - let peer = config.peers.get("test").unwrap();
944 - assert_eq!(peer.on_missing, OnMissing::Log);
945 - assert_eq!(peer.grace_count, None);
946 - assert!(peer.token.is_none());
947 - }
948 -
949 - #[test]
950 - fn peer_with_token() {
951 - let toml = r#"
952 - [peers.test]
953 - address = "10.0.0.1:9100"
954 - token = "peer-secret-123"
955 - "#;
956 - let config: Config = toml::from_str(toml).unwrap();
957 - let peer = config.peers.get("test").unwrap();
958 - assert_eq!(peer.token.as_deref(), Some("peer-secret-123"));
959 - }
960 -
961 - #[test]
962 - fn serve_api_token_from_config() {
963 - let toml = r#"
964 - [serve]
965 - api_token = "my-api-secret"
966 - "#;
967 - let config: Config = toml::from_str(toml).unwrap();
968 - assert_eq!(config.serve.api_token.as_deref(), Some("my-api-secret"));
969 - }
970 -
971 - #[test]
972 - fn serve_api_token_defaults_to_none() {
973 - let config: Config = toml::from_str("").unwrap();
974 - assert!(config.serve.api_token.is_none());
975 - }
976 -
977 - #[test]
978 - fn instance_name_falls_back_to_hostname() {
979 - let config: Config = toml::from_str("").unwrap();
980 - let name = config.instance_name();
981 - assert!(!name.is_empty());
982 - }
983 -
984 - #[test]
985 - fn config_without_alerts_section() {
986 - let config: Config = toml::from_str("").unwrap();
987 - assert!(config.alerts.is_none());
988 - }
989 -
990 - #[test]
991 - fn config_with_alerts_section() {
992 - let toml = r#"
993 - [alerts]
994 - postmark_token = "test-token"
995 - to = "alerts@example.com"
996 - "#;
997 - let config: Config = toml::from_str(toml).unwrap();
998 - let alerts = config.alerts.unwrap();
999 - assert_eq!(alerts.postmark_token.as_deref(), Some("test-token"));
1000 - assert_eq!(alerts.to, "alerts@example.com");
1001 - assert_eq!(alerts.from, "PoM Alerts <pom-alerts@makenot.work>");
1002 - assert_eq!(alerts.cooldown_secs, 300);
1003 - }
1004 -
1005 - #[test]
1006 - fn config_alerts_wam_token() {
1007 - let toml = r#"
1008 - [alerts]
1009 - to = "alerts@example.com"
1010 - wam_url = "http://wam.tailnet:9000"
1011 - wam_token = "test-wam-token"
1012 - "#;
1013 - let config: Config = toml::from_str(toml).unwrap();
1014 - let alerts = config.alerts.unwrap();
1015 - assert_eq!(alerts.wam_url.as_deref(), Some("http://wam.tailnet:9000"));
1016 - assert_eq!(alerts.wam_token.as_deref(), Some("test-wam-token"));
1017 - }
1018 -
1019 - #[test]
1020 - fn config_alerts_wam_token_defaults_to_none() {
1021 - let toml = r#"
1022 - [alerts]
1023 - to = "alerts@example.com"
1024 - wam_url = "http://wam.tailnet:9000"
1025 - "#;
1026 - let config: Config = toml::from_str(toml).unwrap();
1027 - assert!(config.alerts.unwrap().wam_token.is_none());
1028 - }
1029 -
1030 - #[test]
1031 - fn config_with_tls() {
1032 - let toml = r#"
1033 - [targets.mnw]
1034 - label = "MakeNotWork"
1035 - [targets.mnw.tls]
1036 - host = "makenot.work"
1037 - port = 8443
1038 - warn_days = 30
1039 - "#;
1040 - let config: Config = toml::from_str(toml).unwrap();
1041 - let mnw = config.get_target("mnw").unwrap();
1042 - let tls = mnw.tls.as_ref().unwrap();
1043 - assert_eq!(tls.host, "makenot.work");
1044 - assert_eq!(tls.port, 8443);
1045 - assert_eq!(tls.warn_days, 30);
1046 - }
1047 -
1048 - #[test]
1049 - fn config_tls_defaults() {
1050 - let toml = r#"
1051 - [targets.mnw]
1052 - label = "MakeNotWork"
1053 - [targets.mnw.tls]
1054 - host = "makenot.work"
1055 - "#;
1056 - let config: Config = toml::from_str(toml).unwrap();
1057 - let tls = config.get_target("mnw").unwrap().tls.as_ref().unwrap();
1058 - assert_eq!(tls.port, 443);
1059 - assert_eq!(tls.warn_days, 14);
1060 - }
1061 -
1062 - #[test]
1063 - fn config_without_tls() {
1064 - let toml = r#"
1065 - [targets.mnw]
1066 - label = "MakeNotWork"
1067 - "#;
1068 - let config: Config = toml::from_str(toml).unwrap();
1069 - assert!(config.get_target("mnw").unwrap().tls.is_none());
1070 - }
1071 -
1072 - #[test]
1073 - fn config_tls_check_interval_default() {
1074 - let config: Config = toml::from_str("").unwrap();
1075 - assert_eq!(config.serve.tls_check_interval_secs, 3600);
1076 - }
1077 -
1078 - #[test]
1079 - fn config_tls_check_interval_custom() {
1080 - let toml = r"
1081 - [serve]
1082 - tls_check_interval_secs = 1800
1083 - ";
1084 - let config: Config = toml::from_str(toml).unwrap();
1085 - assert_eq!(config.serve.tls_check_interval_secs, 1800);
1086 - }
1087 -
1088 - #[test]
1089 - fn config_with_health_expect() {
1090 - let toml = r#"
1091 - [targets.mnw]
1092 - label = "MakeNotWork"
1093 - [targets.mnw.health]
1094 - url = "https://makenot.work/health"
1095 - [targets.mnw.health.expect]
1096 - status_code = 200
1097 - body_contains = "operational"
1098 - json_fields = { "status" = "operational", "checks.db" = "ok" }
1099 - "#;
1100 - let config: Config = toml::from_str(toml).unwrap();
1101 - let expect = config
1102 - .get_target("mnw")
1103 - .unwrap()
1104 - .health
1105 - .as_ref()
1106 - .unwrap()
1107 - .expect
1108 - .as_ref()
1109 - .unwrap();
1110 - assert_eq!(expect.status_code, Some(200));
1111 - assert_eq!(expect.body_contains.as_deref(), Some("operational"));
1112 - assert_eq!(expect.json_fields.get("status").unwrap(), "operational");
1113 - assert_eq!(expect.json_fields.get("checks.db").unwrap(), "ok");
1114 - }
1115 -
1116 - #[test]
1117 - fn config_health_without_expect() {
1118 - let toml = r#"
1119 - [targets.mnw]
1120 - label = "MakeNotWork"
1121 - [targets.mnw.health]
1122 - url = "https://makenot.work/health"
1123 - "#;
1124 - let config: Config = toml::from_str(toml).unwrap();
1125 - assert!(
1126 - config
1127 - .get_target("mnw")
1128 - .unwrap()
1129 - .health
1130 - .as_ref()
1131 - .unwrap()
1132 - .expect
1133 - .is_none()
1134 - );
1135 - }
1136 -
1137 - #[test]
1138 - fn config_with_trending() {
1139 - let toml = r#"
1140 - [targets.mnw]
1141 - label = "MakeNotWork"
1142 - [targets.mnw.health]
1143 - url = "https://makenot.work/health"
1144 - [targets.mnw.health.trending]
1145 - baseline_window_hours = 48
1146 - spike_threshold = 1.5
1147 - "#;
1148 - let config: Config = toml::from_str(toml).unwrap();
1149 - let trending = config
1150 - .get_target("mnw")
1151 - .unwrap()
1152 - .health
1153 - .as_ref()
1154 - .unwrap()
1155 - .trending
1156 - .as_ref()
1157 - .unwrap();
1158 - assert_eq!(trending.baseline_window_hours, 48);
1159 - assert!((trending.spike_threshold - 1.5).abs() < f64::EPSILON);
1160 - }
1161 -
1162 - #[test]
1163 - fn config_trending_defaults() {
1164 - let toml = r#"
1165 - [targets.mnw]
1166 - label = "MakeNotWork"
1167 - [targets.mnw.health]
1168 - url = "https://makenot.work/health"
1169 - [targets.mnw.health.trending]
1170 - "#;
1171 - let config: Config = toml::from_str(toml).unwrap();
1172 - let trending = config
1173 - .get_target("mnw")
1174 - .unwrap()
1175 - .health
1176 - .as_ref()
1177 - .unwrap()
1178 - .trending
1179 - .as_ref()
1180 - .unwrap();
1181 - assert_eq!(trending.baseline_window_hours, 168);
1182 - assert!((trending.spike_threshold - 2.0).abs() < f64::EPSILON);
1183 - }
1184 -
1185 - #[test]
1186 - fn config_without_trending() {
1187 - let toml = r#"
1188 - [targets.mnw]
1189 - label = "MakeNotWork"
1190 - [targets.mnw.health]
1191 - url = "https://makenot.work/health"
1192 - "#;
1193 - let config: Config = toml::from_str(toml).unwrap();
1194 - assert!(
1195 - config
1196 - .get_target("mnw")
1197 - .unwrap()
1198 - .health
1199 - .as_ref()
1200 - .unwrap()
1201 - .trending
1202 - .is_none()
1203 - );
1204 - }
1205 -
1206 - #[test]
1207 - fn config_health_expect_empty() {
1208 - let toml = r#"
1209 - [targets.mnw]
1210 - label = "MakeNotWork"
1211 - [targets.mnw.health]
1212 - url = "https://makenot.work/health"
1213 - [targets.mnw.health.expect]
1214 - "#;
1215 - let config: Config = toml::from_str(toml).unwrap();
1216 - let expect = config
1217 - .get_target("mnw")
1218 - .unwrap()
1219 - .health
1220 - .as_ref()
1221 - .unwrap()
1222 - .expect
1223 - .as_ref()
1224 - .unwrap();
1225 - assert_eq!(expect.status_code, None);
1226 - assert!(expect.json_fields.is_empty());
1227 - assert_eq!(expect.body_contains, None);
1228 - }
1229 -
1230 - #[test]
1231 - fn config_staleness_days_default() {
1232 - let toml = r#"
1233 - [targets.mnw]
1234 - label = "MakeNotWork"
1235 - [targets.mnw.tests]
1236 - ssh = "host"
1237 - command = "./ci.sh"
1238 - "#;
1239 - let config: Config = toml::from_str(toml).unwrap();
1240 - assert_eq!(
1241 - config
1242 - .get_target("mnw")
1243 - .unwrap()
1244 - .tests
1245 - .as_ref()
1246 - .unwrap()
1247 - .staleness_days,
1248 - 7
1249 - );
1250 - }
1251 -
1252 - #[test]
1253 - fn config_staleness_days_custom() {
1254 - let toml = r#"
1255 - [targets.mnw]
1256 - label = "MakeNotWork"
1257 - [targets.mnw.tests]
1258 - ssh = "host"
1259 - command = "./ci.sh"
1260 - staleness_days = 14
1261 - "#;
1262 - let config: Config = toml::from_str(toml).unwrap();
1263 - assert_eq!(
1264 - config
1265 - .get_target("mnw")
1266 - .unwrap()
1267 - .tests
1268 - .as_ref()
1269 - .unwrap()
1270 - .staleness_days,
1271 - 14
1272 - );
1273 - }
1274 -
1275 - #[test]
1276 - fn config_with_alerts_custom_defaults() {
1277 - let toml = r#"
1278 - [alerts]
1279 - to = "alerts@example.com"
1280 - from = "Custom <custom@example.com>"
1281 - cooldown_secs = 60
1282 - "#;
1283 - let config: Config = toml::from_str(toml).unwrap();
1284 - let alerts = config.alerts.unwrap();
1285 - assert!(alerts.postmark_token.is_none());
1286 - assert_eq!(alerts.from, "Custom <custom@example.com>");
1287 - assert_eq!(alerts.cooldown_secs, 60);
1288 - }
1289 -
1290 - #[test]
1291 - fn config_expected_routes() {
1292 - let toml = r#"
1293 - [targets.mnw]
1294 - label = "MakeNotWork"
1295 - expected_routes = ["/", "/discover", "/login", "/docs"]
1296 - [targets.mnw.health]
1297 - url = "https://makenot.work/api/health"
1298 - "#;
1299 - let config: Config = toml::from_str(toml).unwrap();
1300 - let mnw = config.get_target("mnw").unwrap();
1301 - assert_eq!(
1302 - mnw.expected_routes,
1303 - vec!["/", "/discover", "/login", "/docs"]
1304 - );
1305 - }
1306 -
1307 - #[test]
1308 - fn config_expected_routes_default_empty() {
1309 - let toml = r#"
1310 - [targets.mnw]
1311 - label = "MakeNotWork"
1312 - "#;
1313 - let config: Config = toml::from_str(toml).unwrap();
1314 - assert!(config.get_target("mnw").unwrap().expected_routes.is_empty());
1315 - }
1316 -
1317 - #[test]
1318 - fn config_route_check_interval_default() {
1319 - let config: Config = toml::from_str("").unwrap();
1320 - assert_eq!(config.serve.route_check_interval_secs, 300);
1321 - }
1322 -
1323 - #[test]
1324 - fn config_route_check_interval_custom() {
1325 - let toml = r"
1326 - [serve]
1327 - route_check_interval_secs = 600
1328 - ";
1329 - let config: Config = toml::from_str(toml).unwrap();
1330 - assert_eq!(config.serve.route_check_interval_secs, 600);
1331 - }
1332 -
1333 - #[test]
1334 - fn config_dns_check_interval_default() {
1335 - let config: Config = toml::from_str("").unwrap();
1336 - assert_eq!(config.serve.dns_check_interval_secs, 3600);
1337 - }
1338 -
1339 - #[test]
1340 - fn config_dns_check_interval_custom() {
1341 - let toml = r"
1342 - [serve]
1343 - dns_check_interval_secs = 1800
1344 - ";
1345 - let config: Config = toml::from_str(toml).unwrap();
1346 - assert_eq!(config.serve.dns_check_interval_secs, 1800);
1347 - }
1348 -
1349 - #[test]
1350 - fn config_with_dns_records() {
1351 - let toml = r#"
1352 - [targets.mnw]
1353 - label = "MakeNotWork"
1354 -
1355 - [[targets.mnw.dns]]
1356 - name = "makenot.work"
1357 - record_type = "A"
1358 - expected = ["5.78.144.244"]
1359 -
1360 - [[targets.mnw.dns]]
1361 - name = "git.makenot.work"
1362 - record_type = "A"
1363 - expected = ["5.78.144.244"]
1364 - "#;
1365 - let config: Config = toml::from_str(toml).unwrap();
1366 - let mnw = config.get_target("mnw").unwrap();
1367 - assert_eq!(mnw.dns.len(), 2);
1368 - assert_eq!(mnw.dns[0].name, "makenot.work");
1369 - assert_eq!(mnw.dns[0].record_type, DnsRecordType::A);
1370 - assert_eq!(mnw.dns[0].expected, vec!["5.78.144.244"]);
1371 - assert_eq!(mnw.dns[1].name, "git.makenot.work");
1372 - }
1373 -
Lines truncated
@@ -524,1185 +524,4 @@
524 524 }
525 525
526 526 #[cfg(test)]
527 - mod tests {
528 - use super::*;
529 - use crate::types::*;
530 -
531 - #[test]
532 - fn scrub_strips_ansi_and_control_chars() {
533 - // A monitored host trying to clear/rewrite the operator's terminal.
534 - let hostile = "1.0\u{1b}[2J\u{1b}[1;1H FAKE ALL-CLEAR\r\n";
535 - let cleaned = scrub(hostile);
536 - assert!(!cleaned.contains('\u{1b}'), "ESC must be stripped");
537 - assert!(!cleaned.contains('\r') && !cleaned.contains('\n'));
538 - assert_eq!(cleaned, "1.0[2J[1;1H FAKE ALL-CLEAR");
539 - // Printable non-ASCII Unicode is preserved.
540 - assert_eq!(scrub("v1.2 \u{2014} ok"), "v1.2 \u{2014} ok");
541 - }
542 -
543 - #[test]
544 - fn health_snapshot_scrubs_hostile_error() {
545 - let s = HealthSnapshot {
546 - id: None,
547 - target: "mnw".to_string(),
548 - status: HealthStatus::Error,
549 - checked_at: "2026-07-07T00:00:00+00:00".to_string(),
550 - response_time_ms: 5,
551 - details: None,
552 - error: Some("boom\u{1b}[2Jcleared".to_string()),
553 - };
554 - let out = format_health_snapshot(&s);
555 - assert!(
556 - !out.contains('\u{1b}'),
557 - "ESC from a remote error must not reach the terminal"
558 - );
559 - }
560 -
561 - // format_health_snapshot
562 -
563 - #[test]
564 - fn health_snapshot_operational_with_details() {
565 - let s = HealthSnapshot {
566 - id: None,
567 - target: "mnw".to_string(),
568 - status: HealthStatus::Operational,
569 - checked_at: "2026-03-10T00:00:00Z".to_string(),
570 - response_time_ms: 95,
571 - details: Some(HealthDetails {
572 - version: Some("1.2.0".to_string()),
573 - git_sha: None,
574 - uptime: Some("5d 3h".to_string()),
575 - checks: None,
576 - monitoring: None,
577 - }),
578 - error: None,
579 - };
580 - let out = format_health_snapshot(&s);
581 - assert!(out.contains("[OK]"));
582 - assert!(out.contains("mnw"));
583 - assert!(out.contains("operational"));
584 - assert!(out.contains("(95ms)"));
585 - assert!(out.contains("v1.2.0"));
586 - assert!(out.contains("up 5d 3h"));
587 - }
588 -
589 - #[test]
590 - fn health_snapshot_unreachable_with_error() {
591 - let s = HealthSnapshot {
592 - id: None,
593 - target: "api".to_string(),
594 - status: HealthStatus::Unreachable,
595 - checked_at: "2026-03-10T00:00:00Z".to_string(),
596 - response_time_ms: 0,
597 - details: None,
598 - error: Some("connection refused".to_string()),
599 - };
600 - let out = format_health_snapshot(&s);
601 - assert!(out.contains("[DOWN]"));
602 - assert!(out.contains("unreachable"));
603 - assert!(out.contains("connection refused"));
604 - }
605 -
606 - #[test]
607 - fn health_snapshot_degraded_no_details() {
608 - let s = HealthSnapshot {
609 - id: None,
610 - target: "svc".to_string(),
611 - status: HealthStatus::Degraded,
612 - checked_at: "2026-03-10T00:00:00Z".to_string(),
613 - response_time_ms: 2500,
614 - details: None,
615 - error: None,
616 - };
617 - let out = format_health_snapshot(&s);
618 - assert!(out.contains("[WARN]"));
619 - assert!(out.contains("degraded"));
620 - assert!(out.contains("(2500ms)"));
621 - assert!(!out.contains("up "));
622 - assert!(!out.contains(" v"));
623 - }
624 -
625 - #[test]
626 - fn health_snapshot_error_status() {
627 - let s = HealthSnapshot {
628 - id: None,
629 - target: "db".to_string(),
630 - status: HealthStatus::Error,
631 - checked_at: "2026-03-10T00:00:00Z".to_string(),
632 - response_time_ms: 500,
633 - details: None,
634 - error: Some("500 internal server error".to_string()),
635 - };
636 - let out = format_health_snapshot(&s);
637 - assert!(out.contains("[ERR]"));
638 - assert!(out.contains("error"));
639 - assert!(out.contains("500 internal server error"));
640 - }
641 -
642 - #[test]
643 - fn health_snapshots_multiple() {
644 - let snapshots = vec![
645 - HealthSnapshot {
646 - id: None,
647 - target: "a".to_string(),
648 - status: HealthStatus::Operational,
649 - checked_at: "2026-03-10T00:00:00Z".to_string(),
650 - response_time_ms: 50,
651 - details: None,
652 - error: None,
653 - },
654 - HealthSnapshot {
655 - id: None,
656 - target: "b".to_string(),
657 - status: HealthStatus::Degraded,
658 - checked_at: "2026-03-10T00:00:00Z".to_string(),
659 - response_time_ms: 3000,
660 - details: None,
661 - error: None,
662 - },
663 - ];
664 - let out = format_health_snapshots(&snapshots);
665 - assert!(out.contains("[OK]"));
666 - assert!(out.contains("[WARN]"));
667 - assert!(out.contains('a'));
668 - assert!(out.contains('b'));
669 - }
670 -
671 - // format_test_result
672 -
673 - #[test]
674 - fn test_result_passed() {
675 - let run = TestRun {
676 - id: None,
677 - target: "mnw".to_string(),
678 - started_at: "2026-03-10T00:00:00Z".to_string(),
679 - finished_at: Some("2026-03-10T00:02:00Z".to_string()),
680 - duration_secs: Some(120),
681 - exit_code: Some(0),
682 - passed: true,
683 - summary: TestSummary {
684 - steps: vec![
685 - StepResult {
686 - name: "cargo check".to_string(),
687 - passed: true,
688 - },
689 - StepResult {
690 - name: "cargo test".to_string(),
691 - passed: true,
692 - },
693 - ],
694 - total_passed: Some(759),
695 - total_failed: Some(0),
696 - details: vec![],
697 - },
698 - raw_output: String::new(),
699 - filter: None,
700 - };
701 - let out = format_test_result("mnw", &run);
702 - assert!(out.contains("mnw: PASSED"));
703 - assert!(out.contains("Duration: 120s"));
704 - assert!(out.contains("Tests: 759 passed, 0 failed"));
705 - assert!(out.contains("PASS cargo check"));
706 - assert!(out.contains("PASS cargo test"));
707 - assert!(!out.contains("Raw output"));
708 - }
709 -
710 - #[test]
711 - fn test_result_failed_shows_raw_output() {
712 - let run = TestRun {
713 - id: None,
714 - target: "mnw".to_string(),
715 - started_at: "2026-03-10T00:00:00Z".to_string(),
716 - finished_at: Some("2026-03-10T00:01:00Z".to_string()),
717 - duration_secs: Some(60),
718 - exit_code: Some(1),
719 - passed: false,
720 - summary: TestSummary {
721 - steps: vec![
722 - StepResult {
723 - name: "cargo check".to_string(),
724 - passed: true,
725 - },
726 - StepResult {
727 - name: "cargo test".to_string(),
728 - passed: false,
729 - },
730 - ],
731 - total_passed: Some(750),
732 - total_failed: Some(9),
733 - details: vec![],
734 - },
735 - raw_output: "thread 'test_foo' panicked at 'assertion failed'".to_string(),
736 - filter: None,
737 - };
738 - let out = format_test_result("mnw", &run);
739 - assert!(out.contains("mnw: FAILED"));
740 - assert!(out.contains("PASS cargo check"));
741 - assert!(out.contains("FAIL cargo test"));
742 - assert!(out.contains("750 passed, 9 failed"));
743 - assert!(out.contains("Raw output:"));
744 - assert!(out.contains("assertion failed"));
745 - }
746 -
747 - #[test]
748 - fn test_result_no_duration_or_counts() {
749 - let run = TestRun {
750 - id: None,
751 - target: "svc".to_string(),
752 - started_at: "2026-03-10T00:00:00Z".to_string(),
753 - finished_at: None,
754 - duration_secs: None,
755 - exit_code: None,
756 - passed: true,
757 - summary: TestSummary {
758 - steps: vec![],
759 - total_passed: None,
760 - total_failed: None,
761 - details: vec![],
762 - },
763 - raw_output: String::new(),
764 - filter: None,
765 - };
766 - let out = format_test_result("svc", &run);
767 - assert!(out.contains("svc: PASSED"));
768 - assert!(!out.contains("Duration:"));
769 - assert!(!out.contains("Tests:"));
770 - }
771 -
772 - // format_status_target
773 -
774 - #[test]
775 - fn status_target_with_health_and_tests() {
776 - let health = HealthSnapshot {
777 - id: None,
778 - target: "mnw".to_string(),
779 - status: HealthStatus::Operational,
780 - checked_at: "2026-03-10T00:00:00Z".to_string(),
781 - response_time_ms: 95,
782 - details: Some(HealthDetails {
783 - version: Some("2.1.0".to_string()),
784 - git_sha: None,
785 - uptime: None,
786 - checks: None,
787 - monitoring: None,
788 - }),
789 - error: None,
790 - };
791 - let test = TestRun {
792 - id: None,
793 - target: "mnw".to_string(),
794 - started_at: "2026-03-10T00:00:00Z".to_string(),
795 - finished_at: Some("2026-03-10T00:01:00Z".to_string()),
796 - duration_secs: Some(60),
797 - exit_code: Some(0),
798 - passed: true,
799 - summary: TestSummary {
800 - steps: vec![],
801 - total_passed: Some(100),
802 - total_failed: Some(0),
803 - details: vec![],
804 - },
805 - raw_output: String::new(),
806 - filter: None,
807 - };
808 - let out = format_status_target(
809 - "mnw",
810 - "MakeNotWork",
811 - Some(&health),
812 - None,
813 - None,
814 - None,
815 - None,
816 - None,
817 - Some(&test),
818 - None,
819 - None,
820 - );
821 - assert!(out.contains("=== mnw (MakeNotWork) ==="));
822 - assert!(out.contains("Health: [OK] operational (95ms) v2.1.0"));
823 - assert!(out.contains("Tests: PASSED (60s)"));
824 - assert!(out.contains("100 passed, 0 failed"));
825 - }
826 -
827 - #[test]
828 - fn status_target_no_data() {
829 - let out = format_status_target(
830 - "mnw",
831 - "MakeNotWork",
832 - None,
833 - None,
834 - None,
835 - None,
836 - None,
837 - None,
838 - None,
839 - None,
840 - None,
841 - );
842 - assert!(out.contains("=== mnw (MakeNotWork) ==="));
843 - assert!(out.contains("Health: no data"));
844 - assert!(out.contains("Tests: no data"));
845 - }
846 -
847 - #[test]
848 - fn status_target_health_only() {
849 - let health = HealthSnapshot {
850 - id: None,
851 - target: "mnw".to_string(),
852 - status: HealthStatus::Degraded,
853 - checked_at: "2026-03-10T00:00:00Z".to_string(),
854 - response_time_ms: 2000,
855 - details: None,
856 - error: None,
857 - };
858 - let out = format_status_target(
859 - "mnw",
860 - "MakeNotWork",
861 - Some(&health),
862 - None,
863 - None,
864 - None,
865 - None,
866 - None,
867 - None,
868 - None,
869 - None,
870 - );
871 - assert!(out.contains("Health: [WARN] degraded (2000ms)"));
872 - assert!(out.contains("Tests: no data"));
873 - }
874 -
875 - #[test]
876 - fn status_target_failed_tests() {
877 - let test = TestRun {
878 - id: None,
879 - target: "mnw".to_string(),
880 - started_at: "2026-03-10T00:00:00Z".to_string(),
881 - finished_at: None,
882 - duration_secs: None,
883 - exit_code: Some(1),
884 - passed: false,
885 - summary: TestSummary {
886 - steps: vec![],
887 - total_passed: Some(80),
888 - total_failed: Some(5),
889 - details: vec![],
890 - },
891 - raw_output: String::new(),
892 - filter: None,
893 - };
894 - let out = format_status_target(
895 - "mnw",
896 - "MakeNotWork",
897 - None,
898 - None,
899 - None,
900 - None,
901 - None,
902 - None,
903 - Some(&test),
904 - None,
905 - None,
906 - );
907 - assert!(out.contains("Tests: FAILED"));
908 - assert!(out.contains("80 passed, 5 failed"));
909 - }
910 -
911 - // format_status_target with TLS
912 -
913 - #[test]
914 - fn status_target_tls_ok() {
915 - let tls = TlsCheckRow {
916 - id: 1,
917 - target: "mnw".to_string(),
918 - host: "makenot.work".to_string(),
919 - valid: true,
920 - days_remaining: 47,
921 - not_before: "2026-01-10T00:00:00Z".to_string(),
922 - not_after: "2026-04-27T00:00:00Z".to_string(),
923 - subject: "CN=makenot.work".to_string(),
924 - issuer: "CN=Let's Encrypt".to_string(),
925 - checked_at: "2026-03-11T00:00:00Z".to_string(),
926 - error: None,
927 - webpki_trusted: Some(true),
928 - platform_trusted: Some(true),
929 - webpki_error: None,
930 - platform_error: None,
931 - };
932 - let out = format_status_target(
933 - "mnw",
934 - "MakeNotWork",
935 - None,
936 - None,
937 - Some(&tls),
938 - None,
939 - None,
940 - None,
941 - None,
942 - None,
943 - None,
944 - );
945 - assert!(out.contains("TLS: [OK] makenot.work"));
946 - assert!(out.contains("47d remaining"));
947 - assert!(out.contains("expires 2026-04-27"));
948 - }
949 -
950 - #[test]
951 - fn status_target_tls_warning() {
952 - let tls = TlsCheckRow {
953 - id: 1,
954 - target: "mnw".to_string(),
955 - host: "makenot.work".to_string(),
956 - valid: true,
957 - days_remaining: 12,
958 - not_before: "2026-01-10T00:00:00Z".to_string(),
959 - not_after: "2026-03-23T00:00:00Z".to_string(),
960 - subject: "CN=makenot.work".to_string(),
961 - issuer: "CN=Let's Encrypt".to_string(),
962 - checked_at: "2026-03-11T00:00:00Z".to_string(),
963 - error: None,
964 - webpki_trusted: Some(true),
965 - platform_trusted: Some(true),
966 - webpki_error: None,
967 - platform_error: None,
968 - };
969 - let out = format_status_target(
970 - "mnw",
971 - "MakeNotWork",
972 - None,
973 - None,
974 - Some(&tls),
975 - None,
976 - None,
977 - None,
978 - None,
979 - None,
980 - None,
981 - );
982 - assert!(out.contains("TLS: [WARN] makenot.work"));
983 - assert!(out.contains("12d remaining"));
984 - }
985 -
986 - #[test]
987 - fn status_target_tls_error() {
988 - let tls = TlsCheckRow {
989 - id: 1,
990 - target: "mnw".to_string(),
991 - host: "makenot.work".to_string(),
992 - valid: false,
993 - days_remaining: 0,
994 - not_before: String::new(),
995 - not_after: String::new(),
996 - subject: String::new(),
997 - issuer: String::new(),
998 - checked_at: "2026-03-11T00:00:00Z".to_string(),
999 - error: Some("connection refused".to_string()),
1000 - webpki_trusted: Some(false),
1001 - platform_trusted: Some(false),
1002 - webpki_error: Some("TCP connect failed".to_string()),
1003 - platform_error: Some("TCP connect failed".to_string()),
1004 - };
1005 - let out = format_status_target(
1006 - "mnw",
1007 - "MakeNotWork",
1008 - None,
1009 - None,
1010 - Some(&tls),
1011 - None,
1012 - None,
1013 - None,
1014 - None,
1015 - None,
1016 - None,
1017 - );
1018 - assert!(out.contains("TLS: [ERR] makenot.work"));
1019 - assert!(out.contains("connection refused"));
1020 - }
1021 -
1022 - // format_status_target with incident
1023 -
Lines truncated
@@ -824,982 +824,4 @@
824 824 }
825 825
826 826 #[cfg(test)]
827 - mod tests {
828 - use super::*;
829 -
830 - fn now() -> DateTime<Utc> {
831 - "2026-07-21T18:24:39Z".parse().unwrap()
832 - }
833 -
834 - fn checked_at() -> String {
835 - "2026-07-21T18:24:00Z".into()
836 - }
837 -
838 - fn healthy(name: &str) -> TargetView {
839 - TargetView {
840 - name: name.into(),
841 - label: name.to_uppercase(),
842 - health_configured: true,
843 - health: Some(HealthView {
844 - status: HealthStatus::Operational,
845 - checked_at: checked_at(),
846 - version: Some("1.4.0".into()),
847 - error: None,
848 - }),
849 - uptime_24h: Some(100.0),
850 - latency_avg_ms: Some(42.0),
851 - tls: None,
852 - incident: None,
853 - whois: None,
854 - backups: Vec::new(),
855 - scan_pipeline: None,
856 - systemd: None,
857 - ca_bundle: None,
858 - synckit_fleet: None,
859 - tests: None,
860 - dns: None,
861 - cors: None,
862 - }
863 - }
864 -
865 - fn node<'a>(p: &'a Payload, id: &str) -> &'a Node {
866 - p.node(id).unwrap_or_else(|| panic!("no node {id}"))
867 - }
868 -
869 - #[test]
870 - fn a_healthy_target_is_ok_and_structurally_sound() {
871 - let p = payload(&[healthy("mnw")], now());
872 -
873 - assert_eq!(p.source, SOURCE);
874 - assert_eq!(p.schema, ops_status::SCHEMA_VERSION);
875 - assert_eq!(p.validate(), Ok(()));
876 - assert_eq!(node(&p, "target:mnw").status, Status::Ok);
877 - assert_eq!(node(&p, "target:mnw").label, "MNW");
878 - assert_eq!(p.worst_status(), Status::Ok);
879 - }
880 -
881 - #[test]
882 - fn uptime_and_latency_are_typed_values_not_strings() {
883 - let p = payload(&[healthy("mnw")], now());
884 - let n = node(&p, "target:mnw");
885 -
886 - let uptime = n.fields.iter().find(|f| f.label == "uptime 24h").unwrap();
887 - assert_eq!(
888 - uptime.value,
889 - Value::Progress {
890 - value: 100.0,
891 - max: 100.0,
892 - unit: Some("%".into())
893 - }
894 - );
895 - let latency = n.fields.iter().find(|f| f.label == "latency 24h").unwrap();
896 - assert_eq!(
897 - latency.value,
898 - Value::Quantity {
899 - value: 42.0,
900 - unit: Some("ms".into())
901 - }
902 - );
903 - }
904 -
905 - #[test]
906 - fn an_unreachable_target_is_failed_and_says_why() {
907 - let mut t = healthy("mnw");
908 - t.health = Some(HealthView {
909 - status: HealthStatus::Unreachable,
910 - checked_at: checked_at(),
911 - version: None,
912 - error: Some("connection timed out".into()),
913 - });
914 - let p = payload(&[t], now());
915 -
916 - let n = node(&p, "target:mnw");
917 - assert_eq!(n.status, Status::Failed);
918 - assert_eq!(n.conditions[0].condition_type, "health");
919 - assert_eq!(
920 - n.conditions[0].detail.as_deref(),
921 - Some("connection timed out")
922 - );
923 - assert_eq!(p.worst_status(), Status::Failed);
924 - }
925 -
926 - #[test]
927 - fn a_target_never_checked_is_pending_not_healthy() {
928 - let mut t = healthy("new");
929 - t.health = None;
930 - let p = payload(&[t], now());
931 -
932 - let n = node(&p, "target:new");
933 - assert_eq!(n.status, Status::Pending);
934 - assert_eq!(
935 - n.conditions[0].detail.as_deref(),
936 - Some("no health check recorded yet")
937 - );
938 - // No health snapshot means no version and no checked-at field.
939 - assert!(n.fields.iter().all(|f| f.label != "version"));
940 - assert!(n.fields.iter().all(|f| f.label != "checked"));
941 - assert_eq!(p.validate(), Ok(()));
942 - }
943 -
944 - #[test]
945 - fn an_expiring_certificate_degrades_an_otherwise_healthy_target() {
946 - // The Sando contrast: unlike a promotion gate, an expiring cert is a real
947 - // problem with the target and must color it even when health is green.
948 - let mut t = healthy("mnw");
949 - t.tls = Some(TlsView {
950 - valid: true,
951 - days_remaining: 9,
952 - checked_at: checked_at(),
953 - error: None,
954 - webpki_trusted: Some(true),
955 - platform_trusted: Some(true),
956 - platform_error: None,
957 - });
958 - let p = payload(&[t], now());
959 -
960 - let n = node(&p, "target:mnw");
961 - assert_eq!(n.status, Status::Degraded);
962 - let tls = n
963 - .conditions
964 - .iter()
965 - .find(|c| c.condition_type == "tls")
966 - .unwrap();
967 - assert_eq!(tls.status, Status::Degraded);
968 - assert!(tls.detail.as_deref().unwrap().contains("9 days"));
969 - }
970 -
971 - #[test]
972 - fn an_expired_certificate_fails_the_target() {
973 - let mut t = healthy("mnw");
974 - t.tls = Some(TlsView {
975 - valid: true,
976 - days_remaining: -3,
977 - checked_at: checked_at(),
978 - error: None,
979 - webpki_trusted: Some(true),
980 - platform_trusted: Some(true),
981 - platform_error: None,
982 - });
983 - let p = payload(&[t], now());
984 -
985 - let n = node(&p, "target:mnw");
986 - assert_eq!(n.status, Status::Failed);
987 - let tls = n
988 - .conditions
989 - .iter()
990 - .find(|c| c.condition_type == "tls")
991 - .unwrap();
992 - assert!(
993 - tls.detail
994 - .as_deref()
995 - .unwrap()
996 - .contains("expired 3 days ago")
997 - );
998 - }
999 -
1000 - #[test]
1001 - fn a_healthy_certificate_stays_ok_but_still_reports_its_runway() {
1002 - let mut t = healthy("mnw");
1003 - t.tls = Some(TlsView {
1004 - valid: true,
1005 - days_remaining: 60,
1006 - checked_at: checked_at(),
1007 - error: None,
1008 - webpki_trusted: Some(true),
1009 - platform_trusted: Some(true),
1010 - platform_error: None,
1011 - });
1012 - let p = payload(&[t], now());
1013 -
1014 - let n = node(&p, "target:mnw");
1015 - assert_eq!(n.status, Status::Ok);
1016 - let tls = n
1017 - .conditions
1018 - .iter()
1019 - .find(|c| c.condition_type == "tls")
1020 - .unwrap();
1021 - assert_eq!(tls.status, Status::Ok);
1022 - assert!(tls.detail.as_deref().unwrap().contains("60 days remaining"));
1023 - }
1024 -
1025 - #[test]
1026 - fn a_host_trust_store_that_rejects_a_publicly_valid_chain_degrades_the_target() {
1027 - // multithreaded takes its outbound trust anchors from the host CA
1028 - // bundle on every path it has, with no in-binary fallback. A bundle
1029 - // that goes stale or thin therefore breaks OAuth, link previews and S3
1030 - // at once while the certificates themselves are perfectly good, which
1031 - // is why the two stores are reported separately rather than folded.
1032 - let mut t = healthy("mnw");
1033 - t.tls = Some(TlsView {
1034 - valid: true,
1035 - days_remaining: 60,
1036 - checked_at: checked_at(),
1037 - error: None,
1038 - webpki_trusted: Some(true),
1039 - platform_trusted: Some(false),
1040 - platform_error: Some("invalid peer certificate: UnknownIssuer".into()),
1041 - });
1042 - let p = payload(&[t], now());
1043 -
1044 - let n = node(&p, "target:mnw");
1045 - assert_eq!(n.status, Status::Degraded);
1046 - let tls = n
1047 - .conditions
1048 - .iter()
1049 - .find(|c| c.condition_type == "tls")
1050 - .unwrap();
1051 - assert_eq!(tls.status, Status::Degraded);
1052 - let detail = tls.detail.as_deref().unwrap();
1053 - assert!(detail.contains("host trust store"));
1054 - assert!(detail.contains("UnknownIssuer"));
1055 - }
1056 -
1057 - #[test]
1058 - fn a_thin_ca_bundle_fails_the_target_and_a_stale_one_only_degrades_it() {
1059 - // The severity split is the point: a bundle below the certificate floor
1060 - // means outbound TLS is broken now, while a package one release behind
1061 - // is drift. Collapsing them would either page on drift or bury an outage.
1062 - let mut thin = healthy("mnw");
1063 - thin.ca_bundle = Some(CaBundleView {
1064 - status: "thin".into(),
1065 - issues: vec!["bundle holds 3 certificates, below the floor of 80".into()],
1066 - checked_at: checked_at(),
1067 - error: None,
1068 - });
1069 - assert_eq!(
1070 - node(&payload(&[thin], now()), "target:mnw").status,
1071 - Status::Failed
1072 - );
1073 -
1074 - let mut stale = healthy("mnw");
1075 - stale.ca_bundle = Some(CaBundleView {
1076 - status: "stale".into(),
1077 - issues: vec!["ca-certificates 20260601 installed, 20261101 available".into()],
1078 - checked_at: checked_at(),
1079 - error: None,
1080 - });
1081 - let p = payload(&[stale], now());
1082 - let n = node(&p, "target:mnw");
1083 - assert_eq!(n.status, Status::Degraded);
1084 - let ca = n
1085 - .conditions
1086 - .iter()
1087 - .find(|c| c.condition_type == "ca_bundle")
1088 - .unwrap();
1089 - assert!(ca.detail.as_deref().unwrap().contains("20261101 available"));
1090 - }
1091 -
1092 - #[test]
1093 - fn a_ca_bundle_probe_that_could_not_run_says_so_rather_than_reading_green() {
1094 - let mut t = healthy("mnw");
1095 - t.ca_bundle = Some(CaBundleView {
1096 - status: "error".into(),
1097 - issues: Vec::new(),
1098 - checked_at: checked_at(),
1099 - error: Some("apt-cache policy failed to run: No such file or directory".into()),
1100 - });
1101 - let p = payload(&[t], now());
1102 - let n = node(&p, "target:mnw");
1103 - assert_eq!(n.status, Status::Degraded);
1104 - let ca = n
1105 - .conditions
1106 - .iter()
1107 - .find(|c| c.condition_type == "ca_bundle")
1108 - .unwrap();
1109 - assert!(ca.detail.as_deref().unwrap().contains("probe error"));
1110 - }
1111 -
1112 - #[test]
1113 - fn a_pre_migration_tls_row_reports_expiry_and_claims_nothing_about_trust() {
1114 - // Rows written before the trust columns existed carry NULL, not false.
1115 - // Reading those as "untrusted" would light up every target on the first
1116 - // run after an upgrade, which trains the eye to ignore the condition.
1117 - let mut t = healthy("mnw");
1118 - t.tls = Some(TlsView {
1119 - valid: true,
1120 - days_remaining: 60,
1121 - checked_at: checked_at(),
1122 - error: None,
1123 - webpki_trusted: None,
1124 - platform_trusted: None,
1125 - platform_error: None,
1126 - });
1127 - let p = payload(&[t], now());
1128 -
1129 - let n = node(&p, "target:mnw");
1130 - assert_eq!(n.status, Status::Ok);
1131 - let tls = n
1132 - .conditions
1133 - .iter()
1134 - .find(|c| c.condition_type == "tls")
1135 - .unwrap();
1136 - assert_eq!(tls.status, Status::Ok);
1137 - assert!(tls.detail.as_deref().unwrap().contains("60 days remaining"));
1138 - }
1139 -
1140 - #[test]
1141 - fn an_open_incident_surfaces_with_its_transition_and_start() {
1142 - let mut t = healthy("mnw");
1143 - t.incident = Some(IncidentView {
1144 - from_status: "operational".into(),
1145 - to_status: "unreachable".into(),
1146 - started_at: "2026-07-21T17:00:00Z".into(),
1147 - });
1148 - // Health has recovered on paper but the incident is still open: the node
1149 - // must not read green while an incident stands.
1150 - let p = payload(&[t], now());
1151 -
1152 - let n = node(&p, "target:mnw");
1153 - assert_eq!(n.status, Status::Failed);
1154 - let incident = n
1155 - .conditions
1156 - .iter()
1157 - .find(|c| c.condition_type == "incident")
1158 - .unwrap();
1159 - assert_eq!(incident.status, Status::Failed);
1160 - assert_eq!(
1161 - incident.detail.as_deref(),
1162 - Some("operational to unreachable")
1163 - );
1164 - assert_eq!(
1165 - incident.since,
1166 - Some("2026-07-21T17:00:00Z".parse::<DateTime<Utc>>().unwrap())
1167 - );
1168 - }
1169 -
1170 - #[test]
1171 - fn a_failed_whois_lookup_is_degraded_not_failed() {
1172 - // Registrar WHOIS is flaky; a lookup error is not proof the domain lapsed.
1173 - let mut t = healthy("mnw");
1174 - t.whois = Some(WhoisView {
1175 - days_remaining: None,
1176 - checked_at: checked_at(),
1177 - error: Some("connection reset".into()),
1178 - });
1179 - let p = payload(&[t], now());
1180 -
1181 - let n = node(&p, "target:mnw");
1182 - assert_eq!(n.status, Status::Degraded);
1183 - }
1184 -
1185 - #[test]
1186 - fn an_expiring_domain_degrades_the_target() {
1187 - let mut t = healthy("mnw");
1188 - t.whois = Some(WhoisView {
1189 - days_remaining: Some(12),
1190 - checked_at: checked_at(),
1191 - error: None,
1192 - });
1193 - let p = payload(&[t], now());
1194 -
1195 - let n = node(&p, "target:mnw");
1196 - assert_eq!(n.status, Status::Degraded);
1197 - let whois = n
1198 - .conditions
1199 - .iter()
1200 - .find(|c| c.condition_type == "whois")
1201 - .unwrap();
1202 - assert!(whois.detail.as_deref().unwrap().contains("12 days"));
1203 - }
1204 -
1205 - #[test]
1206 - fn a_whois_check_with_no_signal_emits_no_condition() {
1207 - let mut t = healthy("mnw");
1208 - t.whois = Some(WhoisView {
1209 - days_remaining: None,
1210 - checked_at: checked_at(),
1211 - error: None,
1212 - });
1213 - let p = payload(&[t], now());
1214 -
1215 - let n = node(&p, "target:mnw");
1216 - assert!(n.conditions.iter().all(|c| c.condition_type != "whois"));
1217 - assert_eq!(n.status, Status::Ok);
1218 - }
1219 -
1220 - #[test]
1221 - fn the_loudest_of_several_problems_wins_the_target() {
1222 - let mut t = healthy("mnw");
1223 - t.health = Some(HealthView {
1224 - status: HealthStatus::Degraded,
1225 - checked_at: checked_at(),
1226 - version: Some("1.4.0".into()),
1227 - error: Some("unexpected status 503".into()),
1228 - });
1229 - t.tls = Some(TlsView {
1230 - valid: true,
1231 - days_remaining: -1,
1232 - checked_at: checked_at(),
1233 - error: None,
1234 - webpki_trusted: Some(true),
1235 - platform_trusted: Some(true),
1236 - platform_error: None,
1237 - });
1238 - let p = payload(&[t], now());
1239 -
1240 - // health is degraded, tls is failed: the target is failed.
1241 - assert_eq!(node(&p, "target:mnw").status, Status::Failed);
1242 - }
1243 -
1244 - #[test]
1245 - fn one_targets_failure_does_not_touch_another() {
1246 - let mut down = healthy("mt");
1247 - down.health = Some(HealthView {
1248 - status: HealthStatus::Error,
1249 - checked_at: checked_at(),
1250 - version: None,
1251 - error: Some("500 Internal Server Error".into()),
1252 - });
1253 - let p = payload(&[healthy("mnw"), down], now());
1254 -
1255 - assert_eq!(node(&p, "target:mnw").status, Status::Ok);
1256 - assert_eq!(node(&p, "target:mt").status, Status::Failed);
1257 - assert_eq!(p.worst_status(), Status::Failed);
1258 - assert_eq!(p.validate(), Ok(()));
1259 - }
1260 -
1261 - #[test]
1262 - fn an_unknown_incident_status_stays_legible() {
1263 - let mut t = healthy("mnw");
1264 - t.incident = Some(IncidentView {
1265 - from_status: "operational".into(),
1266 - to_status: "sideways".into(),
1267 - started_at: checked_at(),
1268 - });
1269 - let p = payload(&[t], now());
1270 -
1271 - let incident = node(&p, "target:mnw")
1272 - .conditions
1273 - .iter()
1274 - .find(|c| c.condition_type == "incident")
1275 - .unwrap();
1276 - assert_eq!(incident.status, Status::Unknown);
1277 - }
1278 -
1279 - #[test]
1280 - fn a_malformed_timestamp_costs_only_that_timestamp() {
1281 - let mut t = healthy("mnw");
1282 - t.health = Some(HealthView {
1283 - status: HealthStatus::Operational,
1284 - checked_at: "not a timestamp".into(),
1285 - version: Some("1.4.0".into()),
1286 - error: None,
1287 - });
1288 - let p = payload(&[t], now());
1289 -
1290 - let n = node(&p, "target:mnw");
1291 - assert_eq!(n.status, Status::Ok);
1292 - assert_eq!(n.conditions[0].since, None);
1293 - assert!(n.fields.iter().all(|f| f.label != "checked"));
1294 - }
1295 -
1296 - #[test]
1297 - fn a_stale_backup_degrades_the_target_and_names_the_database() {
1298 - // The 40-day-stale backup that stayed green by every check that existed.
1299 - let mut t = healthy("mnw");
1300 - t.backups = vec![BackupView {
1301 - database: "makenotwork".into(),
1302 - status: "stale".into(),
1303 - age_hours: Some(960),
1304 - checked_at: checked_at(),
1305 - error: None,
1306 - }];
1307 - let p = payload(&[t], now());
1308 -
1309 - let n = node(&p, "target:mnw");
1310 - assert_eq!(n.status, Status::Degraded);
1311 - let backup = n
1312 - .conditions
1313 - .iter()
1314 - .find(|c| c.condition_type == "backup:makenotwork")
1315 - .unwrap();
1316 - assert_eq!(backup.status, Status::Degraded);
1317 - assert!(backup.detail.as_deref().unwrap().contains("960h"));
1318 - }
1319 -
1320 - #[test]
1321 - fn a_missing_backup_fails_the_target() {
1322 - let mut t = healthy("mnw");
1323 - t.backups = vec![BackupView {
Lines truncated