Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/Skidbladnir

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(&reg).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(&reg).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(&reg)
            .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(&reg)
        .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(&reg).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"
    );
}