| 78 |
78 |
|
pub backups: Vec<BackupView>,
|
| 79 |
79 |
|
/// Latest scan-pipeline check. `None` if not monitored for this target.
|
| 80 |
80 |
|
pub scan_pipeline: Option<ScanView>,
|
|
81 |
+ |
/// Latest test run and PoM's staleness verdict. `None` if the target has no
|
|
82 |
+ |
/// test config.
|
|
83 |
+ |
pub tests: Option<TestsView>,
|
|
84 |
+ |
/// Latest DNS record checks. `None` if DNS is not monitored for this target.
|
|
85 |
+ |
pub dns: Option<DnsView>,
|
|
86 |
+ |
/// Latest CORS preflight checks. `None` if CORS is not monitored.
|
|
87 |
+ |
pub cors: Option<CorsView>,
|
| 81 |
88 |
|
}
|
| 82 |
89 |
|
|
| 83 |
90 |
|
pub(crate) struct HealthView {
|
| 125 |
132 |
|
pub error: Option<String>,
|
| 126 |
133 |
|
}
|
| 127 |
134 |
|
|
|
135 |
+ |
pub(crate) struct TestsView {
|
|
136 |
+ |
/// Whether any run has ever been recorded. Separates "no evidence"
|
|
137 |
+ |
/// (pending) from a run that failed.
|
|
138 |
+ |
pub ran: bool,
|
|
139 |
+ |
/// Whether the most recent run passed.
|
|
140 |
+ |
pub passed: bool,
|
|
141 |
+ |
pub total_passed: Option<i64>,
|
|
142 |
+ |
pub total_failed: Option<i64>,
|
|
143 |
+ |
/// When the most recent run started, RFC 3339.
|
|
144 |
+ |
pub started_at: Option<String>,
|
|
145 |
+ |
/// PoM's staleness verdict for the run: true when it is older than the
|
|
146 |
+ |
/// configured threshold or a version was deployed after it ran.
|
|
147 |
+ |
pub stale: bool,
|
|
148 |
+ |
pub stale_reason: Option<String>,
|
|
149 |
+ |
}
|
|
150 |
+ |
|
|
151 |
+ |
pub(crate) struct DnsView {
|
|
152 |
+ |
/// One entry per monitored record; non-empty by construction.
|
|
153 |
+ |
pub records: Vec<DnsRecordView>,
|
|
154 |
+ |
/// Latest checked_at across the records, for the condition's `since`.
|
|
155 |
+ |
pub checked_at: Option<String>,
|
|
156 |
+ |
}
|
|
157 |
+ |
|
|
158 |
+ |
pub(crate) struct DnsRecordView {
|
|
159 |
+ |
pub name: String,
|
|
160 |
+ |
pub record_type: String,
|
|
161 |
+ |
pub matches: bool,
|
|
162 |
+ |
pub error: Option<String>,
|
|
163 |
+ |
}
|
|
164 |
+ |
|
|
165 |
+ |
pub(crate) struct CorsView {
|
|
166 |
+ |
/// One entry per monitored URL; non-empty by construction.
|
|
167 |
+ |
pub checks: Vec<CorsCheckView>,
|
|
168 |
+ |
pub checked_at: Option<String>,
|
|
169 |
+ |
}
|
|
170 |
+ |
|
|
171 |
+ |
pub(crate) struct CorsCheckView {
|
|
172 |
+ |
pub url: String,
|
|
173 |
+ |
pub origin: String,
|
|
174 |
+ |
pub passes: bool,
|
|
175 |
+ |
pub error: Option<String>,
|
|
176 |
+ |
}
|
|
177 |
+ |
|
| 128 |
178 |
|
/// Restate every monitored target as the shared payload.
|
| 129 |
179 |
|
///
|
| 130 |
180 |
|
/// `now` is an argument rather than read from the clock so the mapping stays
|
| 156 |
206 |
|
if let Some(scan) = &target.scan_pipeline {
|
| 157 |
207 |
|
conditions.push(scan_condition(scan));
|
| 158 |
208 |
|
}
|
|
209 |
+ |
if let Some(tests) = &target.tests {
|
|
210 |
+ |
conditions.push(tests_condition(tests));
|
|
211 |
+ |
}
|
|
212 |
+ |
if let Some(dns) = &target.dns {
|
|
213 |
+ |
conditions.push(dns_condition(dns));
|
|
214 |
+ |
}
|
|
215 |
+ |
if let Some(cors) = &target.cors {
|
|
216 |
+ |
conditions.push(cors_condition(cors));
|
|
217 |
+ |
}
|
| 159 |
218 |
|
|
| 160 |
219 |
|
// The target's status is the worst thing said about it. There is always at
|
| 161 |
220 |
|
// least the health condition, so the max is well-defined.
|
| 364 |
423 |
|
}
|
| 365 |
424 |
|
}
|
| 366 |
425 |
|
|
|
426 |
+ |
/// Test state as a condition. A never-run target is `pending` — evidence of
|
|
427 |
+ |
/// nothing, like one never health-checked. A failing or stale run is `degraded`,
|
|
428 |
+ |
/// not `failed`: a red suite is a real regression signal, but the running
|
|
429 |
+ |
/// service is covered by `health`, so a test result colors the target yellow
|
|
430 |
+ |
/// (look at this) rather than red (it is down). Staleness reuses PoM's own
|
|
431 |
+ |
/// verdict, which fires on age past the configured threshold or a version
|
|
432 |
+ |
/// deployed since the last run.
|
|
433 |
+ |
fn tests_condition(tests: &TestsView) -> Condition {
|
|
434 |
+ |
if !tests.ran {
|
|
435 |
+ |
return Condition {
|
|
436 |
+ |
condition_type: "tests".into(),
|
|
437 |
+ |
status: Status::Pending,
|
|
438 |
+ |
since: None,
|
|
439 |
+ |
detail: Some("no tests have been run yet".into()),
|
|
440 |
+ |
};
|
|
441 |
+ |
}
|
|
442 |
+ |
let since = tests.started_at.as_deref().and_then(parse_instant);
|
|
443 |
+ |
let (status, detail) = if !tests.passed {
|
|
444 |
+ |
let detail = match (tests.total_passed, tests.total_failed) {
|
|
445 |
+ |
(Some(p), Some(f)) => format!("last run failed: {f} failed, {p} passed"),
|
|
446 |
+ |
_ => "last test run failed".into(),
|
|
447 |
+ |
};
|
|
448 |
+ |
(Status::Degraded, detail)
|
|
449 |
+ |
} else if tests.stale {
|
|
450 |
+ |
(
|
|
451 |
+ |
Status::Degraded,
|
|
452 |
+ |
tests
|
|
453 |
+ |
.stale_reason
|
|
454 |
+ |
.clone()
|
|
455 |
+ |
.unwrap_or_else(|| "test results are stale".into()),
|
|
456 |
+ |
)
|
|
457 |
+ |
} else {
|
|
458 |
+ |
let detail = match tests.total_passed {
|
|
459 |
+ |
Some(p) => format!("tests passing, {p} passed"),
|
|
460 |
+ |
None => "tests passing".into(),
|
|
461 |
+ |
};
|
|
462 |
+ |
(Status::Ok, detail)
|
|
463 |
+ |
};
|
|
464 |
+ |
Condition {
|
|
465 |
+ |
condition_type: "tests".into(),
|
|
466 |
+ |
status,
|
|
467 |
+ |
since,
|
|
468 |
+ |
detail: Some(detail),
|
|
469 |
+ |
}
|
|
470 |
+ |
}
|
|
471 |
+ |
|
|
472 |
+ |
/// DNS state as one aggregate condition across a target's monitored records. A
|
|
473 |
+ |
/// record whose lookup errored or whose value does not match is `degraded`: a
|
|
474 |
+ |
/// wrong or unverifiable record is a real misconfiguration, but a record that
|
|
475 |
+ |
/// points the site somewhere dead already shows as a failed `health` check, so
|
|
476 |
+ |
/// DNS colors the target yellow and names what is off. All records clean is
|
|
477 |
+ |
/// `ok`. One aggregate condition rather than one per record keeps a target that
|
|
478 |
+ |
/// watches several records to a single legible row.
|
|
479 |
+ |
fn dns_condition(dns: &DnsView) -> Condition {
|
|
480 |
+ |
let bad: Vec<String> = dns
|
|
481 |
+ |
.records
|
|
482 |
+ |
.iter()
|
|
483 |
+ |
.filter_map(|r| {
|
|
484 |
+ |
if let Some(error) = &r.error {
|
|
485 |
+ |
Some(format!(
|
|
486 |
+ |
"{} {} lookup failed: {error}",
|
|
487 |
+ |
r.name, r.record_type
|
|
488 |
+ |
))
|
|
489 |
+ |
} else if !r.matches {
|
|
490 |
+ |
Some(format!("{} {} does not match", r.name, r.record_type))
|
|
491 |
+ |
} else {
|
|
492 |
+ |
None
|
|
493 |
+ |
}
|
|
494 |
+ |
})
|
|
495 |
+ |
.collect();
|
|
496 |
+ |
let since = dns.checked_at.as_deref().and_then(parse_instant);
|
|
497 |
+ |
let (status, detail) = if bad.is_empty() {
|
|
498 |
+ |
(
|
|
499 |
+ |
Status::Ok,
|
|
500 |
+ |
format!(
|
|
501 |
+ |
"{} record{} match",
|
|
502 |
+ |
dns.records.len(),
|
|
503 |
+ |
plural(dns.records.len())
|
|
504 |
+ |
),
|
|
505 |
+ |
)
|
|
506 |
+ |
} else {
|
|
507 |
+ |
(Status::Degraded, bad.join("; "))
|
|
508 |
+ |
};
|
|
509 |
+ |
Condition {
|
|
510 |
+ |
condition_type: "dns".into(),
|
|
511 |
+ |
status,
|
|
512 |
+ |
since,
|
|
513 |
+ |
detail: Some(detail),
|
|
514 |
+ |
}
|
|
515 |
+ |
}
|
|
516 |
+ |
|
|
517 |
+ |
/// CORS state as one aggregate condition across a target's monitored URLs. A
|
|
518 |
+ |
/// preflight that failed to run, or that the server answered without allowing
|
|
519 |
+ |
/// the origin, is `degraded`: broken CORS breaks a browser client, but it is a
|
|
520 |
+ |
/// configuration fault rather than the service being down, so it colors the
|
|
521 |
+ |
/// target yellow. All preflights allowed is `ok`.
|
|
522 |
+ |
fn cors_condition(cors: &CorsView) -> Condition {
|
|
523 |
+ |
let bad: Vec<String> = cors
|
|
524 |
+ |
.checks
|
|
525 |
+ |
.iter()
|
|
526 |
+ |
.filter_map(|c| {
|
|
527 |
+ |
if let Some(error) = &c.error {
|
|
528 |
+ |
Some(format!("{} preflight failed: {error}", c.url))
|
|
529 |
+ |
} else if !c.passes {
|
|
530 |
+ |
Some(format!("{} does not allow {}", c.url, c.origin))
|
|
531 |
+ |
} else {
|
|
532 |
+ |
None
|
|
533 |
+ |
}
|
|
534 |
+ |
})
|
|
535 |
+ |
.collect();
|
|
536 |
+ |
let since = cors.checked_at.as_deref().and_then(parse_instant);
|
|
537 |
+ |
let (status, detail) = if bad.is_empty() {
|
|
538 |
+ |
(
|
|
539 |
+ |
Status::Ok,
|
|
540 |
+ |
format!(
|
|
541 |
+ |
"{} preflight check{} pass",
|
|
542 |
+ |
cors.checks.len(),
|
|
543 |
+ |
plural(cors.checks.len())
|
|
544 |
+ |
),
|
|
545 |
+ |
)
|
|
546 |
+ |
} else {
|
|
547 |
+ |
(Status::Degraded, bad.join("; "))
|
|
548 |
+ |
};
|
|
549 |
+ |
Condition {
|
|
550 |
+ |
condition_type: "cors".into(),
|
|
551 |
+ |
status,
|
|
552 |
+ |
since,
|
|
553 |
+ |
detail: Some(detail),
|
|
554 |
+ |
}
|
|
555 |
+ |
}
|
|
556 |
+ |
|
|
557 |
+ |
/// The plural suffix for a count: "" for one, "s" otherwise.
|
|
558 |
+ |
fn plural(n: usize) -> &'static str {
|
|
559 |
+ |
if n == 1 { "" } else { "s" }
|
|
560 |
+ |
}
|
|
561 |
+ |
|
| 367 |
562 |
|
/// Uptime as a bar, latency as a magnitude, version as a comparable — each a
|
| 368 |
563 |
|
/// number that *means* something rather than a pre-rendered string.
|
| 369 |
564 |
|
fn target_fields(target: &TargetView) -> Vec<Field> {
|
| 439 |
634 |
|
whois: None,
|
| 440 |
635 |
|
backups: Vec::new(),
|
| 441 |
636 |
|
scan_pipeline: None,
|
|
637 |
+ |
tests: None,
|
|
638 |
+ |
dns: None,
|
|
639 |
+ |
cors: None,
|
| 442 |
640 |
|
}
|
| 443 |
641 |
|
}
|
| 444 |
642 |
|
|
| 867 |
1065 |
|
assert!(scan.detail.as_deref().unwrap().contains("502 Bad Gateway"));
|
| 868 |
1066 |
|
}
|
| 869 |
1067 |
|
|
|
1068 |
+ |
fn tests_view(passed: bool, stale: bool) -> TestsView {
|
|
1069 |
+ |
TestsView {
|
|
1070 |
+ |
ran: true,
|
|
1071 |
+ |
passed,
|
|
1072 |
+ |
total_passed: Some(226),
|
|
1073 |
+ |
total_failed: if passed { Some(0) } else { Some(3) },
|
|
1074 |
+ |
started_at: Some(checked_at()),
|
|
1075 |
+ |
stale,
|
|
1076 |
+ |
stale_reason: if stale {
|
|
1077 |
+ |
Some("tests are 12 days old (threshold: 7d)".into())
|
|
1078 |
+ |
} else {
|
|
1079 |
+ |
None
|
|
1080 |
+ |
},
|
|
1081 |
+ |
}
|
|
1082 |
+ |
}
|
|
1083 |
+ |
|
|
1084 |
+ |
fn condition<'a>(n: &'a Node, ty: &str) -> &'a Condition {
|
|
1085 |
+ |
n.conditions
|
|
1086 |
+ |
.iter()
|
|
1087 |
+ |
.find(|c| c.condition_type == ty)
|
|
1088 |
+ |
.unwrap_or_else(|| panic!("no {ty} condition"))
|
|
1089 |
+ |
}
|
|
1090 |
+ |
|
|
1091 |
+ |
#[test]
|
|
1092 |
+ |
fn a_never_run_test_target_is_pending_not_healthy() {
|
|
1093 |
+ |
let mut t = healthy("mnw");
|
|
1094 |
+ |
t.tests = Some(TestsView {
|
|
1095 |
+ |
ran: false,
|
|
1096 |
+ |
passed: false,
|
|
1097 |
+ |
total_passed: None,
|
|
1098 |
+ |
total_failed: None,
|
|
1099 |
+ |
started_at: None,
|
|
1100 |
+ |
stale: true,
|
|
1101 |
+ |
stale_reason: Some("no tests have been run".into()),
|
|
1102 |
+ |
});
|
|
1103 |
+ |
let p = payload(&[t], now());
|
|
1104 |
+ |
let n = node(&p, "target:mnw");
|
|
1105 |
+ |
// Pending is quieter than degraded: no evidence is not a failure.
|
|
1106 |
+ |
assert_eq!(n.status, Status::Pending);
|
|
1107 |
+ |
assert_eq!(
|
|
1108 |
+ |
condition(n, "tests").detail.as_deref(),
|
|
1109 |
+ |
Some("no tests have been run yet")
|
|
1110 |
+ |
);
|
|
1111 |
+ |
}
|
|
1112 |
+ |
|
|
1113 |
+ |
#[test]
|
|
1114 |
+ |
fn a_failing_test_run_degrades_but_does_not_fail_the_target() {
|
|
1115 |
+ |
// A red suite is a regression signal; the running service is health's job.
|
|
1116 |
+ |
let mut t = healthy("mnw");
|
|
1117 |
+ |
t.tests = Some(tests_view(false, false));
|
|
1118 |
+ |
let p = payload(&[t], now());
|
|
1119 |
+ |
let n = node(&p, "target:mnw");
|
|
1120 |
+ |
assert_eq!(n.status, Status::Degraded);
|
|
1121 |
+ |
let c = condition(n, "tests");
|
|
1122 |
+ |
assert_eq!(c.status, Status::Degraded);
|
|
1123 |
+ |
assert!(c.detail.as_deref().unwrap().contains("3 failed"));
|
|
1124 |
+ |
}
|
|
1125 |
+ |
|
|
1126 |
+ |
#[test]
|
|
1127 |
+ |
fn a_stale_but_passing_test_run_degrades_the_target() {
|
|
1128 |
+ |
let mut t = healthy("mnw");
|
|
1129 |
+ |
t.tests = Some(tests_view(true, true));
|
|
1130 |
+ |
let p = payload(&[t], now());
|
|
1131 |
+ |
let n = node(&p, "target:mnw");
|
|
1132 |
+ |
assert_eq!(n.status, Status::Degraded);
|
|
1133 |
+ |
assert!(
|
|
1134 |
+ |
condition(n, "tests")
|
|
1135 |
+ |
.detail
|
|
1136 |
+ |
.as_deref()
|
|
1137 |
+ |
.unwrap()
|
|
1138 |
+ |
.contains("12 days old")
|
|
1139 |
+ |
);
|
|
1140 |
+ |
}
|
|
1141 |
+ |
|
|
1142 |
+ |
#[test]
|
|
1143 |
+ |
fn a_fresh_passing_test_run_leaves_the_target_ok() {
|
|
1144 |
+ |
let mut t = healthy("mnw");
|
|
1145 |
+ |
t.tests = Some(tests_view(true, false));
|
|
1146 |
+ |
let p = payload(&[t], now());
|
|
1147 |
+ |
let n = node(&p, "target:mnw");
|
|
1148 |
+ |
assert_eq!(n.status, Status::Ok);
|
|
1149 |
+ |
assert_eq!(condition(n, "tests").status, Status::Ok);
|
|
1150 |
+ |
}
|
|
1151 |
+ |
|
|
1152 |
+ |
#[test]
|
|
1153 |
+ |
fn a_dns_mismatch_degrades_the_target_and_names_the_record() {
|
|
1154 |
+ |
let mut t = healthy("mnw");
|
|
1155 |
+ |
t.dns = Some(DnsView {
|
|
1156 |
+ |
records: vec![
|
|
1157 |
+ |
DnsRecordView {
|
|
1158 |
+ |
name: "makenot.work".into(),
|
|
1159 |
+ |
record_type: "A".into(),
|
|
1160 |
+ |
matches: true,
|
|
1161 |
+ |
error: None,
|
|
1162 |
+ |
},
|
|
1163 |
+ |
DnsRecordView {
|
|
1164 |
+ |
name: "makenot.work".into(),
|
|
1165 |
+ |
record_type: "MX".into(),
|
|
1166 |
+ |
matches: false,
|
|
1167 |
+ |
error: None,
|
|
1168 |
+ |
},
|
|
1169 |
+ |
],
|
|
1170 |
+ |
checked_at: Some(checked_at()),
|
|
1171 |
+ |
});
|
|
1172 |
+ |
let p = payload(&[t], now());
|
|
1173 |
+ |
let n = node(&p, "target:mnw");
|
|
1174 |
+ |
assert_eq!(n.status, Status::Degraded);
|
|
1175 |
+ |
let c = condition(n, "dns");
|
|
1176 |
+ |
assert_eq!(c.status, Status::Degraded);
|
|
1177 |
+ |
assert!(c.detail.as_deref().unwrap().contains("MX does not match"));
|
|
1178 |
+ |
}
|
|
1179 |
+ |
|
|
1180 |
+ |
#[test]
|
|
1181 |
+ |
fn a_dns_lookup_error_reads_as_a_flaky_degrade_not_a_mismatch() {
|
|
1182 |
+ |
let mut t = healthy("mnw");
|
|
1183 |
+ |
t.dns = Some(DnsView {
|
|
1184 |
+ |
records: vec![DnsRecordView {
|
|
1185 |
+ |
name: "makenot.work".into(),
|
|
1186 |
+ |
record_type: "TXT".into(),
|
|
1187 |
+ |
matches: false,
|
|
1188 |
+ |
error: Some("SERVFAIL".into()),
|
|
1189 |
+ |
}],
|
|
1190 |
+ |
checked_at: Some(checked_at()),
|
|
1191 |
+ |
});
|
|
1192 |
+ |
let p = payload(&[t], now());
|
|
1193 |
+ |
let c = condition(node(&p, "target:mnw"), "dns");
|
|
1194 |
+ |
assert_eq!(c.status, Status::Degraded);
|
|
1195 |
+ |
assert!(
|
|
1196 |
+ |
c.detail
|
|
1197 |
+ |
.as_deref()
|
|
1198 |
+ |
.unwrap()
|
|
1199 |
+ |
.contains("lookup failed: SERVFAIL")
|
|
1200 |
+ |
);
|
|
1201 |
+ |
}
|
|
1202 |
+ |
|
|
1203 |
+ |
#[test]
|
|
1204 |
+ |
fn all_dns_records_matching_stays_ok_and_counts_them() {
|
|
1205 |
+ |
let mut t = healthy("mnw");
|
|
1206 |
+ |
t.dns = Some(DnsView {
|
|
1207 |
+ |
records: vec![DnsRecordView {
|
|
1208 |
+ |
name: "makenot.work".into(),
|
|
1209 |
+ |
record_type: "A".into(),
|
|
1210 |
+ |
matches: true,
|
|
1211 |
+ |
error: None,
|
|
1212 |
+ |
}],
|
|
1213 |
+ |
checked_at: Some(checked_at()),
|
|
1214 |
+ |
});
|
|
1215 |
+ |
let p = payload(&[t], now());
|
|
1216 |
+ |
let n = node(&p, "target:mnw");
|
|
1217 |
+ |
assert_eq!(n.status, Status::Ok);
|
|
1218 |
+ |
// Singular, not "1 records match".
|
|
1219 |
+ |
assert_eq!(
|
|
1220 |
+ |
condition(n, "dns").detail.as_deref(),
|
|
1221 |
+ |
Some("1 record match")
|
|
1222 |
+ |
);
|
|
1223 |
+ |
}
|
|
1224 |
+ |
|
|
1225 |
+ |
#[test]
|
|
1226 |
+ |
fn a_cors_misconfiguration_degrades_the_target() {
|
|
1227 |
+ |
let mut t = healthy("mnw");
|
|
1228 |
+ |
t.cors = Some(CorsView {
|
|
1229 |
+ |
checks: vec![CorsCheckView {
|
|
1230 |
+ |
url: "https://makenot.work/api".into(),
|
|
1231 |
+ |
origin: "https://app.makenot.work".into(),
|
|
1232 |
+ |
passes: false,
|
|
1233 |
+ |
error: None,
|
|
1234 |
+ |
}],
|
|
1235 |
+ |
checked_at: Some(checked_at()),
|
|
1236 |
+ |
});
|
|
1237 |
+ |
let p = payload(&[t], now());
|
|
1238 |
+ |
let n = node(&p, "target:mnw");
|
|
1239 |
+ |
assert_eq!(n.status, Status::Degraded);
|
|
1240 |
+ |
assert!(
|
|
1241 |
+ |
condition(n, "cors")
|
|
1242 |
+ |
.detail
|
|
1243 |
+ |
.as_deref()
|
|
1244 |
+ |
.unwrap()
|
|
1245 |
+ |
.contains("does not allow https://app.makenot.work")
|
|
1246 |
+ |
);
|
|
1247 |
+ |
}
|
|
1248 |
+ |
|
|
1249 |
+ |
#[test]
|
|
1250 |
+ |
fn passing_cors_preflights_stay_ok() {
|
|
1251 |
+ |
let mut t = healthy("mnw");
|
|
1252 |
+ |
t.cors = Some(CorsView {
|
|
1253 |
+ |
checks: vec![CorsCheckView {
|
|
1254 |
+ |
url: "https://makenot.work/api".into(),
|
|
1255 |
+ |
origin: "https://app.makenot.work".into(),
|
|
1256 |
+ |
passes: true,
|
|
1257 |
+ |
error: None,
|
|
1258 |
+ |
}],
|
|
1259 |
+ |
checked_at: Some(checked_at()),
|
|
1260 |
+ |
});
|
|
1261 |
+ |
let p = payload(&[t], now());
|
|
1262 |
+ |
assert_eq!(node(&p, "target:mnw").status, Status::Ok);
|
|
1263 |
+ |
}
|
|
1264 |
+ |
|
|
1265 |
+ |
#[test]
|
|
1266 |
+ |
fn the_new_conditions_do_not_disturb_a_target_that_has_none_of_them() {
|
|
1267 |
+ |
// A target without test/dns/cors config emits none of the three, exactly
|
|
1268 |
+ |
// as before — the additive property the shared contract exists to give.
|
|
1269 |
+ |
let p = payload(&[healthy("mnw")], now());
|
|
1270 |
+ |
let n = node(&p, "target:mnw");
|
|
1271 |
+ |
assert!(n.conditions.iter().all(|c| c.condition_type != "tests"));
|
|
1272 |
+ |
assert!(n.conditions.iter().all(|c| c.condition_type != "dns"));
|
|
1273 |
+ |
assert!(n.conditions.iter().all(|c| c.condition_type != "cors"));
|
|
1274 |
+ |
assert_eq!(p.validate(), Ok(()));
|
|
1275 |
+ |
}
|
|
1276 |
+ |
|
| 870 |
1277 |
|
#[test]
|
| 871 |
1278 |
|
fn render_is_a_pure_function_of_state_and_clock() {
|
| 872 |
1279 |
|
let a = payload(&[healthy("mnw")], now());
|