tests/promote_contract.rs
//! **PUBLIC-API CONTRACT test for `skidbladnir::promote`** — the surface the
//! consuming deploy tool (Njord's `crates/deployer`, and anyone else) links
//! against.
//!
//! The in-crate `#[cfg(test)]` modules can reach `pub(crate)` internals; this file
//! deliberately cannot, so it pins the exact API a consumer sees. If any of these
//! names stop being public, or change shape, this test fails to **compile** —
//! which is the honest early warning for a downstream break.
//!
//! It also re-proves, from outside the crate, the four properties the module
//! exists for:
//!
//! 1. a configuration file cannot lower the production fence;
//! 2. a slot **name** cannot bypass a fence keyed on namespaces;
//! 3. a partial capture cannot be reported as success, at seal or at load;
//! 4. an unanswerable watermark blocks rather than passes.
//!
//! Run: `cargo test --features promote --test promote_contract`.
#![cfg(feature = "promote")]
use std::collections::BTreeMap;
use std::path::Path;
use skidbladnir::archive::TarZstd;
use skidbladnir::promote::{
API_VERSION, AppData, CapturedPart, Cleared, Completeness, DemotionPolicy, Discard, DumpReport,
EXAMPLE_LADDER, EnvSource, Fut, Generation, GenerationStep, Guard, Hop, Ladder, LoadPolicy,
LoadReport, Observation, Observations, Override, Quiesced, RAISE_LADDER, RaisePhase, Reason,
RungState, SealSpec, Slot, SlotRegistry, SlotRole, TargetRef, Topology, Verdict, Watermark,
seal, unseal, verify,
};
// ─────────────────────── the ladder a consumer authors ───────────────────────
/// One consumer's real-shaped ladder, written in the public schema. Note that
/// *nothing* about these names is known to the crate: `bench`, `staging-eu` and
/// `apex` are this consumer's words.
const CONSUMER_LADDER: &str = "\
apiVersion: skidbladnir.nordisk/promote.v1
meta:
customer: Contoso
owner: ops@contoso.example
slots:
- {name: bench, namespace: contoso-bench, hosts: [bench.contoso.example], role: development, guarded: false, promotes_to: [staging-eu]}
- {name: staging-eu, namespace: contoso-stg, hosts: [stg.contoso.example], role: pre-production, guarded: false, promotes_to: [apex]}
- {name: apex, namespace: contoso, hosts: [contoso.example, www.contoso.example], role: production, guarded: true, meta: {ticket-queue: SRE}}
";
fn consumer() -> SlotRegistry {
SlotRegistry::new(Ladder::from_yaml("contoso.yaml", CONSUMER_LADDER).unwrap())
}
/// The baseline a consumer declares in code — the floor a config cannot go under.
fn baseline() -> SlotRegistry {
SlotRegistry::from_slots(vec![
Slot::new("apex", "contoso", SlotRole::Production, true)
.host("contoso.example")
.host("www.contoso.example"),
])
.unwrap()
}
#[test]
fn the_schema_is_public_versioned_and_describes_a_consumers_own_ladder() {
assert_eq!(API_VERSION, "skidbladnir.nordisk/promote.v1");
let l = Ladder::from_yaml("contoso.yaml", CONSUMER_LADDER).unwrap();
assert_eq!(l.meta.get("customer").map(String::as_str), Some("Contoso"));
assert_eq!(l.rank("bench"), Some(0));
assert_eq!(l.rank("apex"), Some(2));
assert!(l.slot("apex").unwrap().is_guarded());
// …and the shipped reference instance parses through the same public path.
assert_eq!(
Ladder::from_yaml("example", EXAMPLE_LADDER)
.unwrap()
.slots
.len(),
5
);
}
#[test]
fn an_unknown_schema_version_is_refused_from_outside_the_crate_too() {
let err = Ladder::from_yaml(
"future.yaml",
"apiVersion: skidbladnir.nordisk/promote.v99\nslots: [{name: a, namespace: a, role: test, guarded: false}]\n",
)
.unwrap_err();
assert!(format!("{err:#}").contains("not supported"), "{err:#}");
}
#[test]
fn an_undeclared_role_or_guard_is_an_error_not_a_safe_default() {
for doc in [
"apiVersion: skidbladnir.nordisk/promote.v1\nslots: [{name: a, namespace: a, guarded: false}]\n",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots: [{name: a, namespace: a, role: test}]\n",
] {
assert!(
Ladder::from_yaml("x.yaml", doc).is_err(),
"must not default: {doc}"
);
}
}
// ─────────────────────── property 1: the fence cannot be lowered ───────────────
#[test]
fn a_config_file_cannot_lower_the_production_fence() {
let lying = Ladder::from_yaml(
"lying.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots: [{name: apex, namespace: contoso, hosts: [contoso.example], role: test, guarded: false}]\n",
)
.unwrap();
// Default policy: refuse the load outright.
assert!(SlotRegistry::load_over(&baseline(), lying.clone(), DemotionPolicy::Refuse).is_err());
// Tolerant policy: it loads, the slot is STILL fenced, and the attempt is
// reported — including into a panel's topology.
let r =
SlotRegistry::load_over(&baseline(), lying, DemotionPolicy::ReportAndKeepFenced).unwrap();
let g = Guard::new(&r).with_env(EnvSource::Value(None));
assert!(
g.assert(&TargetRef::Namespace("contoso")).is_err(),
"the fence must survive"
);
assert_eq!(r.demotions().len(), 1);
assert!(r.topology(&Observations::new()).notes[0].contains("IGNORED"));
}
#[test]
fn deleting_the_production_entry_does_not_delete_the_fence() {
let without = Ladder::from_yaml(
"partial.yaml",
"apiVersion: skidbladnir.nordisk/promote.v1\nslots: [{name: bench, namespace: contoso-bench, role: development, guarded: false}]\n",
)
.unwrap();
let r =
SlotRegistry::load_over(&baseline(), without, DemotionPolicy::ReportAndKeepFenced).unwrap();
assert!(
Guard::new(&r)
.with_env(EnvSource::Value(None))
.assert(&TargetRef::Namespace("contoso"))
.is_err()
);
}
// ─────────────────────── property 2: no identifier bypasses the fence ─────────
#[test]
fn every_identity_of_a_guarded_slot_hits_the_fence() {
let r = consumer();
let g = Guard::new(&r).with_env(EnvSource::Value(None));
for t in [
TargetRef::Slot("apex"),
TargetRef::Namespace("contoso"),
TargetRef::Host("contoso.example"),
TargetRef::Host("www.contoso.example"),
TargetRef::Url("postgres://u:secret@contoso.example:5432/app"),
TargetRef::Namespace("contoso-20260722-1730"), // a generation of it
] {
let err = g.assert(&t).unwrap_err();
assert!(
format!("{err}").contains("GUARDED"),
"{t:?} must be fenced: {err}"
);
assert!(
!format!("{err}").contains("secret"),
"no credential may reach a message"
);
}
}
#[test]
fn an_unregistered_identifier_is_fenced_and_the_refusal_names_the_way_out() {
let r = consumer();
let err = Guard::new(&r)
.assert(&TargetRef::Namespace("typo"))
.unwrap_err();
let m = format!("{err}");
assert!(
m.contains("no slot is registered") && m.contains("ladder file"),
"{m}"
);
assert!(
Guard::new(&r)
.allow_unknown()
.assert(&TargetRef::Namespace("typo"))
.is_ok()
);
}
#[test]
fn both_keys_and_only_both_keys_clear_a_guarded_slot() {
let r = consumer();
let flag_only = Guard::new(&r)
.with_override(Override::flagged())
.with_env(EnvSource::Value(None));
let env_only = Guard::new(&r).with_env(EnvSource::Value(Some("1".into())));
let both = Guard::new(&r)
.with_override(Override::flagged())
.with_env(EnvSource::Value(Some("1".into())));
assert!(flag_only.assert(&TargetRef::Slot("apex")).is_err());
assert!(env_only.assert(&TargetRef::Slot("apex")).is_err());
let c: Cleared = both.assert(&TargetRef::Slot("apex")).unwrap();
assert!(c.via_override() && c.is_guarded());
assert!(c.banner().unwrap().contains("PROD OVERRIDE ACTIVE"));
assert!(c.covers("contoso") && c.covers("contoso-20260722-1730") && !c.covers("contoso-stg"));
}
// ─────────────────────── the ladder is enforceable ───────────────────────────
#[test]
fn promotion_order_is_answered_from_the_schema() {
let r = consumer();
assert_eq!(r.check_promotion("bench", "staging-eu"), Hop::Legal);
assert_eq!(
r.check_promotion("bench", "apex"),
Hop::SkipsRungs(vec!["staging-eu".into()])
);
assert_eq!(r.check_promotion("apex", "bench"), Hop::WrongDirection);
let g = Guard::new(&r)
.with_override(Override::flagged())
.with_env(EnvSource::Value(Some("1".into())));
assert!(
g.assert_promotion("bench", "apex", false).is_err(),
"a skip must be refused"
);
let (_, skipped) = g.assert_promotion("bench", "apex", true).unwrap();
assert_eq!(
skipped,
vec!["staging-eu".to_string()],
"the override still reports the skip"
);
// Data goes the other way.
assert!(g.assert_data_flow("apex", "bench").is_ok());
assert!(
g.assert_data_flow("bench", "apex").is_err(),
"never copy data upward"
);
}
// ─────────────────────── the topology a viewer renders ───────────────────────
#[test]
fn the_topology_is_serde_data_and_is_honest_about_what_was_not_observed() {
let r = consumer();
let mut obs = Observations::new();
obs.insert(
"staging-eu".into(),
Observation::green("2026-07-27T10:00:00Z").revision("v1.4.2"),
);
obs.insert(
"bench".into(),
Observation::unknown(
"node-off",
"the worker node is powered down",
"2026-07-27T09:00:00Z",
),
);
let t: Topology = r.topology(&obs);
let json = serde_json::to_string(&t).unwrap();
let back: Topology = serde_json::from_str(&json).unwrap();
assert_eq!(t, back, "a viewer or an RPC must be able to carry this");
assert_eq!(t.nodes.len(), 3);
assert_eq!(t.edges.len(), 2);
assert_eq!(t.node("staging-eu").unwrap().status.state, RungState::Green);
assert_eq!(t.node("bench").unwrap().status.state, RungState::Unknown);
assert_eq!(
t.node("bench").unwrap().status.code.as_deref(),
Some("node-off")
);
// The rung nobody observed is NEVER-RUN, not green.
assert_eq!(t.node("apex").unwrap().status.state, RungState::NeverRun);
assert!(!t.node("apex").unwrap().status.state.is_green());
// The consumer's own facts ride along untouched.
assert_eq!(
t.node("apex")
.unwrap()
.meta
.get("ticket-queue")
.map(String::as_str),
Some("SRE")
);
assert_eq!(t.meta.get("customer").map(String::as_str), Some("Contoso"));
assert!(t.is_well_formed());
}
// ─────────────────────── generations ─────────────────────────────────────────
#[test]
fn generations_name_switch_and_order_themselves() {
let g = Generation::new("contoso-stg", 1_784_644_320).unwrap();
assert_eq!(g.namespace, "contoso-stg-20260721-1432");
assert!(Generation::is_of("contoso-stg", &g.namespace));
let plan = skidbladnir::promote::switch_plan(
"contoso-stg",
Some("contoso-stg-20260715-0900"),
&g.namespace,
"stg.contoso.example",
&g.namespace,
3000,
"stg-tls",
)
.unwrap();
let point = plan
.iter()
.position(|s| matches!(s, GenerationStep::PointHost { .. }))
.unwrap();
let drop = plan
.iter()
.position(|s| matches!(s, GenerationStep::DropHost { .. }))
.unwrap();
let ready = plan
.iter()
.position(|s| matches!(s, GenerationStep::WaitReplicas { .. }))
.unwrap();
assert!(
ready < point && point < drop,
"the switch order is the contract: {plan:?}"
);
assert_eq!(RAISE_LADDER.last(), Some(&RaisePhase::SwitchHost));
assert!(RaisePhase::SwitchHost.is_after_commit());
}
// ─────────────────────── an application implements the seam ──────────────────
/// A minimal `AppData` a consumer could write. It carries **no** ship-side
/// knowledge, and the ship carries no knowledge of it.
struct DemoApp {
/// What the "object store" leg will report — the switch this test flips.
objects_available: bool,
/// What the target slot answers for its watermark; `None` = it cannot say.
target_watermark: Option<Watermark>,
}
impl AppData for DemoApp {
fn describe(&self) -> &str {
"demo-app"
}
fn quiesce<'a>(&'a self, slot: &'a Slot) -> Fut<'a, anyhow::Result<Quiesced>> {
Box::pin(async move { Ok(Quiesced::new(&slot.name, vec!["deployment/web".into()])) })
}
fn resume<'a>(&'a self, _slot: &'a Slot, _q: &'a Quiesced) -> Fut<'a, anyhow::Result<()>> {
Box::pin(async move { Ok(()) })
}
fn dump<'a>(
&'a self,
_slot: &'a Slot,
staging: &'a Path,
) -> Fut<'a, anyhow::Result<DumpReport>> {
Box::pin(async move {
std::fs::create_dir_all(staging)?;
std::fs::write(staging.join("db.copy"), "rows\n")?;
let mut r = DumpReport::new().part(CapturedPart::complete("database", Some(2)));
r = if self.objects_available {
std::fs::write(staging.join("object.bin"), "bytes")?;
r.part(CapturedPart::complete("objects", Some(1)))
} else {
// The honest form of the old "degrade quietly and exit 0".
r.part(CapturedPart::skipped(
"objects",
"object-store credentials are not set",
))
};
Ok(r.watermark(Some(
Watermark::at("2026-07-20T10:00:00Z")
.with_magnitude(BTreeMap::from([("rows".to_string(), 2i64)])),
)))
})
}
fn load<'a>(
&'a self,
_slot: &'a Slot,
_staging: &'a Path,
_q: &'a Quiesced,
) -> Fut<'a, anyhow::Result<LoadReport>> {
Box::pin(
async move { Ok(LoadReport::new().part(CapturedPart::complete("database", Some(2)))) },
)
}
fn watermark<'a>(&'a self, _slot: &'a Slot) -> Fut<'a, anyhow::Result<Option<Watermark>>> {
let w = self.target_watermark.clone();
Box::pin(async move { Ok(w) })
}
fn neutralise_after_load<'a>(
&'a self,
_slot: &'a Slot,
_q: &'a Quiesced,
) -> Fut<'a, anyhow::Result<Vec<String>>> {
Box::pin(async move { Ok(vec!["queued outbound work marked handled".into()]) })
}
}
/// A four-line executor — the demo above never yields, so no runtime is needed and
/// this test does not drag tokio into a `promote`-only build.
fn block_on<F: std::future::Future>(mut f: F) -> F::Output {
let mut f = unsafe { std::pin::Pin::new_unchecked(&mut f) };
let waker = std::task::Waker::noop();
let mut cx = std::task::Context::from_waker(waker);
loop {
if let std::task::Poll::Ready(v) = f.as_mut().poll(&mut cx) {
return v;
}
std::hint::spin_loop();
}
}
// ─────────────────────── property 3: no silent partial success ───────────────
#[test]
fn a_partial_capture_cannot_be_sealed_as_a_good_bundle_or_loaded_by_accident() {
let tmp = tempfile::tempdir().unwrap();
let r = consumer();
let slot = r.ladder().slot("staging-eu").unwrap().clone();
let app = DemoApp {
objects_available: false,
target_watermark: None,
};
let staging = tmp.path().join("stage");
let report = block_on(app.dump(&slot, &staging)).unwrap();
// The application's own report already refuses to call itself a success.
assert!(!report.completeness().is_complete());
assert!(report.require_complete().is_err());
// …and so does the bundle it produces: the BYTES are perfect, and the bundle
// is still not usable.
let bundle = tmp.path().join("b.tar.zst");
let sealed = seal(
&TarZstd::default(),
&SealSpec {
staging: &staging,
out: &bundle,
source_slot: "staging-eu",
version: "20260727",
taken_at: "2026-07-27T12:00:00Z",
app: app.describe(),
},
&report,
)
.unwrap();
assert!(!sealed.is_complete());
let v = verify(&TarZstd::default(), &bundle).unwrap();
assert!(
v.intact(),
"the archive is byte-perfect — that is the whole trap"
);
assert!(!v.is_usable());
let dest = tmp.path().join("unsealed");
let err = unseal(&TarZstd::default(), &bundle, &dest, LoadPolicy::strict()).unwrap_err();
let m = format!("{err:#}");
assert!(
m.contains("NOT whole") && m.contains("objects: skipped"),
"{m}"
);
assert!(
m.contains("object-store credentials"),
"the app's own reason must survive: {m}"
);
// Accepting it is possible, but only by saying so — and the verdict still
// comes back.
let u = unseal(
&TarZstd::default(),
&bundle,
&dest,
LoadPolicy::strict().accept_incomplete(),
)
.unwrap();
assert!(!u.completeness().is_complete());
}
#[test]
fn a_whole_capture_seals_verifies_and_unseals() {
let tmp = tempfile::tempdir().unwrap();
let r = consumer();
let slot = r.ladder().slot("staging-eu").unwrap().clone();
let app = DemoApp {
objects_available: true,
target_watermark: None,
};
let staging = tmp.path().join("stage");
let report = block_on(app.dump(&slot, &staging)).unwrap();
assert_eq!(report.completeness(), Completeness::Complete);
let bundle = tmp.path().join("b.tar.zst");
let sealed = seal(
&TarZstd::default(),
&SealSpec {
staging: &staging,
out: &bundle,
source_slot: "staging-eu",
version: "20260727",
taken_at: "2026-07-27T12:00:00Z",
app: app.describe(),
},
&report,
)
.unwrap();
assert!(sealed.is_complete());
assert_eq!(sealed.manifest.app, "demo-app");
let u = unseal(
&TarZstd::default(),
&bundle,
&tmp.path().join("out"),
LoadPolicy::strict(),
)
.unwrap();
assert_eq!(
std::fs::read_to_string(u.staging.join("db.copy")).unwrap(),
"rows\n"
);
}
// ─────────────────────── property 4: unknown is not a green light ────────────
#[test]
fn a_target_that_cannot_answer_its_watermark_blocks_the_load() {
let r = consumer();
let slot = r.ladder().slot("staging-eu").unwrap().clone();
let bundle_mark = Watermark::at("2026-07-20T10:00:00Z")
.with_magnitude(BTreeMap::from([("rows".to_string(), 2i64)]));
// The application cannot say.
let app = DemoApp {
objects_available: true,
target_watermark: None,
};
let target = block_on(app.watermark(&slot)).unwrap();
let d = bundle_mark.would_discard(target.as_ref());
assert!(matches!(d, Discard::Unknown { .. }), "{d:?}");
assert!(
d.requires_confirmation(),
"unknown must block exactly as a known conflict does"
);
// The application can say, and the target has newer writes.
let app = DemoApp {
objects_available: true,
target_watermark: Some(
Watermark::at("2026-07-21T08:00:00Z")
.with_magnitude(BTreeMap::from([("rows".to_string(), 2i64)])),
),
};
let target = block_on(app.watermark(&slot)).unwrap();
assert!(matches!(
bundle_mark.would_discard(target.as_ref()),
Discard::NewerWrites { .. }
));
// A deletion moves no mark — only the counts catch it.
let app = DemoApp {
objects_available: true,
target_watermark: Some(
Watermark::at("2026-07-20T10:00:00Z")
.with_magnitude(BTreeMap::from([("rows".to_string(), 1i64)])),
),
};
let target = block_on(app.watermark(&slot)).unwrap();
assert!(matches!(
bundle_mark.would_discard(target.as_ref()),
Discard::RemovedRows { .. }
));
}
#[test]
fn the_seam_calls_are_reachable_and_report_what_they_did() {
let r = consumer();
let slot = r.ladder().slot("staging-eu").unwrap().clone();
let app = DemoApp {
objects_available: true,
target_watermark: None,
};
let q = block_on(app.quiesce(&slot)).unwrap();
assert!(q.is_for("staging-eu"));
let load = block_on(app.load(&slot, Path::new("/unused"), &q)).unwrap();
assert!(load.completeness().is_complete());
let changed = block_on(app.neutralise_after_load(&slot, &q)).unwrap();
assert_eq!(
changed.len(),
1,
"an empty vector is a statement, not a silence"
);
block_on(app.resume(&slot, &q)).unwrap();
}
// ─────────── the zone a guarded slot owns (Njord deployer parity, #171) ───────────
/// Njord's real ladder shape: a prod apex that **owns its whole DNS zone**, plus
/// declared non-prod siblings that live inside that zone.
///
/// `*.g11.se` on the prod slot is a DECLARED wildcard, not an inference — the
/// module's charter is that nothing is ever concluded from a host's shape, so the
/// operator states that prod owns the zone and the registry honours the statement.
const ZONE_LADDER: &str = "\
apiVersion: skidbladnir.nordisk/promote.v1
slots:
- name: prod
namespace: njord
hosts: [g11.se, '*.g11.se']
role: production
guarded: true
- name: lab
namespace: njord-lab
hosts: [lab.g11.se]
role: development
guarded: false
promotes_to: [test1]
- name: test1
namespace: test1
hosts: [test1.g11.se]
role: test
guarded: false
promotes_to: [prod]
";
/// **A guarded slot's declared zone fences its undeclared subdomains — and an
/// EXPLICIT sibling still wins.**
///
/// This is the property Njord's own `guard.rs` calls #171: prod's data plane
/// (`db.g11.se`) must never slip past the fence as "not prod" merely because
/// nobody listed it, while `lab.g11.se` — which somebody DID list, on a free slot —
/// must stay free. Exact identities therefore outrank the wildcard.
///
/// The teeth are in the `allow_unknown` half: read-only verbs run the guard with
/// unknown targets permitted, so `UnknownPolicy::Fence` cannot be what saves
/// `db.g11.se`. It has to resolve to the prod slot and be refused as
/// `Reason::Guarded`.
#[test]
fn a_guarded_slots_declared_zone_fences_undeclared_subdomains() {
let ladder = Ladder::from_yaml("zone.yaml", ZONE_LADDER).expect("parse the zone ladder");
let reg = SlotRegistry::new(ladder);
// A declared sibling inside the zone stays FREE — the wildcard never
// overrides an exact declaration.
for free in ["lab.g11.se", "test1.g11.se"] {
let v = Guard::new(®).check(&TargetRef::Host(free));
assert!(
v.is_cleared(),
"{free} is declared on a free slot, must stay free: {v:?}"
);
}
// The apex itself, and `www.`, are prod.
for prod in ["g11.se", "www.g11.se"] {
let v = Guard::new(®).check(&TargetRef::Host(prod));
match v {
Verdict::Refused(r) => assert_eq!(r.reason, Reason::Guarded, "{prod}"),
other => panic!("{prod} must be fenced, got {other:?}"),
}
}
// ── the gap ── an UNDECLARED subdomain of the guarded zone is PROD, and is
// refused as Guarded, not as UnknownSlot — even with unknowns allowed.
for hidden in ["db.g11.se", "minio.g11.se", "deployer.g11.se"] {
let v = Guard::new(®)
.allow_unknown()
.check(&TargetRef::Host(hidden));
match v {
Verdict::Refused(r) => assert_eq!(
r.reason,
Reason::Guarded,
"{hidden} is inside prod's declared zone, so it is GUARDED — \
being merely 'unknown' would let `allow_unknown` through"
),
other => panic!("{hidden} must be fenced as prod, got {other:?}"),
}
}
// A credential-bearing URL onto prod's data plane is refused, and the
// refusal never prints the password.
let v = Guard::new(®)
.allow_unknown()
.check(&TargetRef::Url("postgres://u:sekret@db.g11.se:5432/njord"));
match v {
Verdict::Refused(r) => {
assert_eq!(r.reason, Reason::Guarded);
assert!(
!format!("{r}").contains("sekret"),
"credentials leaked: {r}"
);
}
other => panic!("prod DB URL must be fenced, got {other:?}"),
}
// A host OUTSIDE the zone is still genuinely unknown — the wildcard widened
// the fence, it did not swallow the world.
let v = Guard::new(®).check(&TargetRef::Host("test1-postgresql"));
match v {
Verdict::Refused(r) => assert_eq!(r.reason, Reason::UnknownSlot),
other => panic!("an out-of-zone host is Unknown, got {other:?}"),
}
}
/// **The zone-wildcard validator is not blind.** A `*` that is not the exact
/// `*.<zone>` form is a typo, and a typo'd fence is the worst kind: the author
/// believes a zone is owned when nothing matches it. Each of these must be refused
/// at parse time, naming the offending entry.
#[test]
fn a_malformed_zone_wildcard_is_refused_at_parse_time() {
for bad in ["*", "*.", "*g11.se", "a.*.g11.se", "*.*.g11.se"] {
let src = format!(
"apiVersion: {API_VERSION}\n\
slots:\n\
\x20 - name: prod\n\
\x20 namespace: njord\n\
\x20 hosts: ['{bad}']\n\
\x20 role: production\n\
\x20 guarded: true\n"
);
let err = Ladder::from_yaml("bad.yaml", &src).expect_err(&format!(
"host entry '{bad}' must be refused, not accepted as a literal"
));
let msg = format!("{err:#}");
assert!(
msg.contains("malformed host entry") && msg.contains(bad),
"the refusal must name the bad entry '{bad}', got: {msg}"
);
}
// …and the legal form still parses, so the validator is discriminating rather
// than simply rejecting every `*`.
let ok = Ladder::from_yaml("ok.yaml", ZONE_LADDER).expect("`*.g11.se` is legal");
let prod = ok.slot("prod").expect("prod slot");
assert_eq!(
prod.zones().collect::<Vec<_>>(),
vec!["g11.se"],
"the zone is read back"
);
assert_eq!(
prod.exact_hosts().collect::<Vec<_>>(),
vec!["g11.se"],
"the wildcard is NOT also treated as a literal host"
);
}