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

nordisk/korp-collectors

tests/roster_is_honest.rs

//! **The roster tells the truth on BOTH axes, and UNTESTED ONLINE is one of its
//! answers.**
//!
//! The owner's part (4), 2026-09-06: a collector whose parsing is proven on fixtures but
//! whose live authenticated call has never run is UNTESTED ONLINE — "a first-class state
//! to report, not a red and not a lie. Say it in the roster with the reason."
//!
//! # The two ways a roster lies
//!
//! It can say a collector is fine when its config is empty — that was row 1, and the
//! gate now refuses it. And it can say a collector is fine when nothing has ever come
//! back from its real endpoint, which is what this file is about. The second lie is the
//! quieter one: it survives every unit test, because a parser proven on a fixture its
//! own author wrote will pass forever whatever the provider actually sends.
//!
//! So the proof axis is DERIVED from evidence a host already holds — a freshness mark
//! (written only after rows landed) and the committed provider fixtures — and never
//! stored. A stored "works" flag is stale the moment a key is withdrawn.

use korp_collectors::freshness::{MarkStore, MemMarkStore, ReadMark};
use korp_collectors::provenance::{ProvenanceManifest, FIXTURE_DIR};
use korp_collectors::requires::{HaveSecrets, NoSecrets, Readiness};
use korp_collectors::roster::roster;

fn fixtures() -> ProvenanceManifest {
    let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(FIXTURE_DIR);
    ProvenanceManifest::load(&dir).expect("provenance.json parses")
}

/// **On a host with no credentials, the credential-gated collectors read
/// `needs_config` + `untested_online`, and NEITHER of those is a red.**
#[test]
fn a_credential_gated_collector_is_not_startable_and_untested_online() {
    let rows = roster(&NoSecrets, &MemMarkStore::new(), &fixtures());

    let entsoe = rows
        .iter()
        .find(|r| r.collector == "entsoe")
        .expect("entsoe is in the registry");

    match &entsoe.readiness {
        Readiness::NeedsConfig { missing } => assert!(
            missing.iter().any(|m| m.line().contains("ENTSOE_TOKEN")),
            "the row must NAME the variable; got {missing:?}",
        ),
        other => panic!("entsoe with no token must not be {}", other.key()),
    }

    assert_eq!(
        entsoe.proof.key(),
        "untested_online",
        "entsoe's parser is proven against a real ENTSO-E document, so it is not \
         `unproven`; and no live call has run, so it is not `proven_online`",
    );
    assert!(
        !entsoe.proof.is_red(),
        "UNTESTED ONLINE must not be a red: nobody can fix it by changing code, and a \
         red nobody can act on is a red everybody learns to ignore",
    );
    // The reason must say what the fixture DOES and DOES NOT prove. entsoe's only
    // credential-free payload is a refusal document, and a roster that let that read as
    // "covered" would be the same lie in a new place.
    let why = entsoe.proof.why();
    assert!(
        why.contains("UNTESTED ONLINE") && why.contains("refusal document"),
        "entsoe's reason must say the only obtainable payload is the provider's \
         refusal; got: {why}",
    );
    assert!(
        why.contains("2026-"),
        "LAW 7: a figure taken from a live source is dated. Got: {why}",
    );
}

/// **The two axes are INDEPENDENT, and `nordpool` is the proof.**
///
/// Nord Pool declares `NORDPOOL_API_TOKEN` for its licensed API, so it is not startable
/// here — and its parser is proven against 96 real prices from the exchange's own public
/// data portal. A single traffic light cannot hold both facts, and the one it would keep
/// is the one that says less.
#[test]
fn startability_and_proof_are_orthogonal() {
    let rows = roster(&NoSecrets, &MemMarkStore::new(), &fixtures());
    let np = rows
        .iter()
        .find(|r| r.collector == "nordpool")
        .expect("nordpool is in the registry");

    assert_eq!(np.readiness.key(), "needs_config", "the licensed API needs a token");
    assert_eq!(
        np.proof.key(),
        "untested_online",
        "its parser IS proven on real provider data — that is not `unproven`",
    );
    assert!(
        np.proof.why().contains("nordpool-dayahead-se3.json"),
        "the reason must name the fixture that carries the proof; got: {}",
        np.proof.why(),
    );

    // …and the same collector flips its startability axis alone when a key appears,
    // leaving the proof axis exactly where it was. If they were one field this could not
    // be asserted at all.
    let with_key = roster(
        &HaveSecrets::of(&["NORDPOOL_API_TOKEN"]),
        &MemMarkStore::new(),
        &fixtures(),
    );
    let np2 = with_key.iter().find(|r| r.collector == "nordpool").unwrap();
    assert_eq!(np2.readiness.key(), "runnable", "the key satisfies the declaration");
    assert_eq!(
        np2.proof, np.proof,
        "supplying a key must NOT change what has been proven — holding a credential is \
         not evidence that a call was ever made with it",
    );
}

/// **A landed run — and only a landed run — makes a row `proven_online`.**
///
/// The green arm of the proof axis. Without it, `untested_online` could be returned
/// unconditionally and every assertion above would still pass.
#[test]
fn a_freshness_mark_is_what_makes_a_collector_proven_online() {
    let marks = MemMarkStore::new();
    let before = roster(&NoSecrets, &marks, &fixtures());
    let polisen_before = before
        .iter()
        .find(|r| r.collector == "polisen-events")
        .expect("polisen-events is in the registry")
        .clone();
    assert_ne!(
        polisen_before.proof.key(),
        "proven_online",
        "nothing has landed yet, so nothing may claim it has",
    );

    // A mark is written ONLY after rows land, so writing one is the exact evidence a
    // completed live run leaves behind.
    let source = korp_collectors::by_name("polisen-events")
        .expect("registry")
        .requirements()
        .source;
    marks
        .advance(
            "polisen-events",
            &source,
            &ReadMark {
                kind: "query_window".into(),
                mark: "2026-09-06T09:00:00Z".into(),
                etag: None,
                last_modified: None,
                at: "2026-09-06T09:00:01Z".into(),
                ..Default::default()
            },
        )
        .expect("advance");

    let after = roster(&NoSecrets, &marks, &fixtures());
    let polisen_after = after
        .iter()
        .find(|r| r.collector == "polisen-events")
        .unwrap();
    assert_eq!(
        polisen_after.proof.key(),
        "proven_online",
        "a freshness mark is evidence that rows landed from the live source",
    );
    assert!(
        !polisen_after.proof.is_red(),
        "a proven collector is never red",
    );
    assert!(
        polisen_after.proof.why().contains("2026-09-06T09:00:01Z"),
        "the evidence must be dated and quotable; got: {}",
        polisen_after.proof.why(),
    );

    // Only the collector that landed rows moved. A mark for one collector turning the
    // whole roster green would be the same class of defect as the readiness that
    // reported and never refused.
    let moved: Vec<&str> = after
        .iter()
        .filter(|r| r.proof.key() == "proven_online")
        .map(|r| r.collector.as_str())
        .collect();
    assert_eq!(
        moved,
        vec!["polisen-events"],
        "exactly one collector landed rows; the roster must move exactly one row",
    );
}

/// **The roster covers the whole registry and every row carries a reason.**
///
/// A roster row with an empty reason is a state with no cause, which is what a UI cannot
/// explain and an operator cannot act on.
#[test]
fn every_roster_row_carries_a_reason_and_the_sweep_is_not_vacuous() {
    let rows = roster(&NoSecrets, &MemMarkStore::new(), &fixtures());
    assert_eq!(
        rows.len(),
        korp_collectors::registry().len(),
        "the roster must cover the whole registry",
    );
    assert!(
        rows.len() >= 40,
        "only {} rows — the registry is not being seen, so this sweep is vacuous",
        rows.len(),
    );
    let silent: Vec<&str> = rows
        .iter()
        .filter(|r| r.proof.why().trim().is_empty())
        .map(|r| r.collector.as_str())
        .collect();
    assert!(silent.is_empty(), "rows with no reason: {silent:?}");

    // And the roster must actually DISTINGUISH. If every row landed in one bucket, the
    // second axis would be decoration.
    let untested = rows.iter().filter(|r| r.proof.key() == "untested_online").count();
    let unproven = rows.iter().filter(|r| r.proof.key() == "unproven").count();
    assert!(
        untested >= 3 && unproven >= 1,
        "the proof axis put {untested} rows in untested_online and {unproven} in \
         unproven — an axis with one value is not an axis",
    );

    for r in &rows {
        eprintln!("{}", r.line());
    }
}

/// **"Nobody has done it yet" and "the provider publishes nothing" are DIFFERENT
/// answers, and the roster gives different ones.**
///
/// The owner named Bolagsverket and SCB specifically. Neither can be closed by writing
/// code: bolagsverket.se answers an automated fetch with a CAPTCHA, and SCB does not
/// publish företagsregistret's endpoint at all — it is issued WITH the credential, which
/// is why the collector's declared source is an RFC 2606 placeholder.
///
/// Reporting that as plain `unproven` would send the next reader to find the same
/// CAPTCHA, and painting it red would be a red nobody can act on. So it is its own
/// answer, carrying what was tried and the date it was tried.
#[test]
fn a_provider_that_publishes_nothing_reads_differently_from_work_not_yet_done() {
    let rows = roster(&NoSecrets, &MemMarkStore::new(), &fixtures());
    let by = |n: &str| {
        rows.iter()
            .find(|r| r.collector == n)
            .unwrap_or_else(|| panic!("{n} is in the registry"))
    };

    for name in ["bolagsverket-organisationer", "scb-foretagsregistret"] {
        let row = by(name);
        assert_eq!(
            row.proof.key(),
            "unprovable_without_account",
            "{name} was checked and found to publish nothing an anonymous reader can \
             take; that is not the same as nobody having tried",
        );
        assert!(
            !row.proof.is_red(),
            "{name} cannot be fixed by writing code, so it must not be a red — a red \
             nobody can act on is a red everybody learns to ignore",
        );
        let why = row.proof.why();
        assert!(
            why.contains("checked 2026-"),
            "{name}'s reason must be DATED, or a stale belief outlives its evidence; \
             got: {why}",
        );
        // The reason has to be concrete enough that the next reader can check it rather
        // than take it on trust. A bare "not available" would satisfy every assertion
        // above and teach nobody anything.
        assert!(
            why.len() > 120,
            "{name}'s reason is too thin to be checkable: {why}",
        );
    }

    // …and the distinction is real: collectors nobody has got to yet still read
    // `unproven`, and they ARE red, because that is work somebody can do.
    let unproven: Vec<&str> = rows
        .iter()
        .filter(|r| r.proof.key() == "unproven")
        .map(|r| r.collector.as_str())
        .collect();
    assert!(
        !unproven.is_empty(),
        "every row landed in a bucket that excuses it — the honest reading today is that \
         most collectors still have no provider fixture, and the roster must keep saying so",
    );
    assert!(
        rows.iter()
            .filter(|r| r.proof.key() == "unproven")
            .all(|r| r.proof.is_red()),
        "work nobody has done yet IS a red; only the two states nobody can act on are not",
    );
}