max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01EEmeiSJnmyL98QzA5Dwsvz
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
| @@ -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