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

nordisk/holger

holger-cli/tests/against_a_holger.rs

//! **Every command group against a REAL holger** (lane B3): the server runs in this test
//! process (`holger-server-lib`, the same `Holger::start` a box runs), with the console's
//! auth shape: the box's static bearer (Admin), a second static bearer that is a Reader,
//! and the passkey plane. Each test drives `holger-cli` exactly as a shell would
//! (`holger_cli::run_args`), and asserts what the SERVER decided: the client decides
//! nothing.
//!
//! "Answered" below means the call reached the server and the server answered by its own
//! rules (an answer, or a coded refusal), never that the client failed to ask.

use std::io::Write as _;
use std::path::{Path, PathBuf};

use holger_cli::CliError;
use holger_server_lib::promote::intake::{ArtifactState, TransitionRequest};
use holger_server_lib::{read_ron_config, wire_holger, ArtifactId, Holger};
use nornir_auth_webauthn::softkey::SoftKey;
use traits::test_support::hold_port;

const CONSOLE: &str = "c0nsole-bearer-0123456789abcdefghij";
const READER: &str = "r3ader-bearer-0123456789abcdefghijk";
const RP_ID: &str = "holger.cli.test";
const ORIGIN: &str = "https://holger.cli.test";
const WHEEL: &str = "demo_pkg-1.0.0-py3-none-any.whl";

struct Running {
    dir: tempfile::TempDir,
    _holger: Holger,
    at: String,
    base: String,
    tokens: PathBuf,
}

impl Running {
    /// `holger-cli --at <this server> [--token-file <who>] <args…>`, with its own home.
    async fn cli(&self, who: Option<&str>, args: &[&str]) -> Result<String, CliError> {
        let mut argv: Vec<String> = vec!["holger-cli".into(), "--at".into(), self.at.clone()];
        if let Some(w) = who {
            argv.push("--token-file".into());
            argv.push(self.tokens.join(w).display().to_string());
        }
        argv.extend(args.iter().map(|a| a.to_string()));
        let home = self.dir.path().join("cli-home").display().to_string();
        holger_cli::run_args(argv, move |k| {
            (k == "HOLGER_CLI_HOME").then(|| home.clone())
        })
        .await
    }

    fn path(&self, rel: &str) -> PathBuf {
        self.dir.path().join(rel)
    }
}

fn crc32(data: &[u8]) -> u32 {
    let mut crc = 0xFFFF_FFFFu32;
    for b in data {
        crc ^= *b as u32;
        for _ in 0..8 {
            crc = if crc & 1 != 0 {
                (crc >> 1) ^ 0xEDB8_8320
            } else {
                crc >> 1
            };
        }
    }
    !crc
}

/// A small, well-formed wheel: a stored-only zip with its dist-info, MIT licensed.
fn wheel(project: &str, version: &str) -> Vec<u8> {
    licensed_wheel(project, version, "MIT")
}

/// [`wheel`] under the SPDX licence expression `licence`.
fn licensed_wheel(project: &str, version: &str, licence: &str) -> Vec<u8> {
    let module = project.replace('-', "_");
    let di = format!("{module}-{version}.dist-info");
    let members: Vec<(String, Vec<u8>)> = vec![
        (format!("{module}/__init__.py"), format!("__version__ = \"{version}\"\n").into_bytes()),
        (format!("{di}/METADATA"), format!("Metadata-Version: 2.1\nName: {project}\nVersion: {version}\nLicense-Expression: {licence}\n").into_bytes()),
        (format!("{di}/WHEEL"), b"Wheel-Version: 1.0\nGenerator: holger-test\nRoot-Is-Purelib: true\nTag: py3-none-any\n".to_vec()),
        (format!("{di}/RECORD"), Vec::new()),
    ];
    let (mut out, mut central) = (Vec::new(), Vec::new());
    for (name, body) in &members {
        let crc = crc32(body);
        let offset = out.len() as u32;
        out.extend_from_slice(&0x0403_4b50u32.to_le_bytes());
        out.extend_from_slice(&[20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
        out.extend_from_slice(&crc.to_le_bytes());
        out.extend_from_slice(&(body.len() as u32).to_le_bytes());
        out.extend_from_slice(&(body.len() as u32).to_le_bytes());
        out.extend_from_slice(&(name.len() as u16).to_le_bytes());
        out.extend_from_slice(&0u16.to_le_bytes());
        out.extend_from_slice(name.as_bytes());
        out.extend_from_slice(body);
        central.extend_from_slice(&0x0201_4b50u32.to_le_bytes());
        central.extend_from_slice(&[20, 0, 20, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
        central.extend_from_slice(&crc.to_le_bytes());
        central.extend_from_slice(&(body.len() as u32).to_le_bytes());
        central.extend_from_slice(&(body.len() as u32).to_le_bytes());
        central.extend_from_slice(&(name.len() as u16).to_le_bytes());
        central.extend_from_slice(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
        central.extend_from_slice(&offset.to_le_bytes());
        central.extend_from_slice(name.as_bytes());
    }
    let cd_offset = out.len() as u32;
    let n = members.len() as u16;
    out.extend_from_slice(&central);
    out.extend_from_slice(&0x0605_4b50u32.to_le_bytes());
    out.extend_from_slice(&[0, 0, 0, 0]);
    out.extend_from_slice(&n.to_le_bytes());
    out.extend_from_slice(&n.to_le_bytes());
    out.extend_from_slice(&(central.len() as u32).to_le_bytes());
    out.extend_from_slice(&cd_offset.to_le_bytes());
    out.extend_from_slice(&0u16.to_le_bytes());
    out
}

/// The box's config, with `extra` RON at its end (an `intel:` block, or nothing) and
/// `promotion` RON inside its `promotion:` block (a licence policy, or nothing).
fn config_with(
    d: &Path,
    grpc: u16,
    http: u16,
    extra: &str,
    promotion: &str,
    required: &str,
) -> String {
    let pki =
        bench_scenarios::secure_update::mint_attestation_pki(&d.join("pki"), "cli-scan").unwrap();
    let repo = |name: &str| {
        format!(
            r#"(ron_name: "{name}", ron_repo_type: "raw", ron_upstreams: [], ron_in: None,
         ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "door")),
         ron_data_dir: Some("{d}/{name}")),"#,
            d = d.display()
        )
    };
    format!(
        r#"(
    repositories: [ {files} {incoming} {quarantine} {approved} {production} ],
    exposed_endpoints: [ (ron_name: "door", ron_url: "127.0.0.1:{grpc}", ron_http_url: Some("127.0.0.1:{http}")) ],
    storage_endpoints: [ (ron_name: "storage", ron_storage_type: "znippy", ron_path: "{d}") ],
    audit: (dir: "{d}/audit", enabled: true),
    attestation: Some((
        trust_roots: ["{root}"],
        signer_key: Some("{key}"),
        signer_cert: Some("{cert}"),
        signer_alg: Some("ecdsa-p256"),
    )),
    promotion: Some((
        ladder_name: "intake",
        {promotion}
        stages: [
            (name: "incoming", repo: "incoming"),
            (name: "quarantine", repo: "quarantine"),
            (name: "approved", repo: "approved"),
            (name: "production", repo: "production"),
        ],
        intake: Some((rules: (sandbox: off),
            incoming: "incoming", quarantine: "quarantine",
            approved: "approved", production: "production",
            // The suite drives the CLI's verbs against the raw `files` repository. A box whose
            // policy requires a scan refuses every write there, on every door (P19, SEC-REVIEW
            // 2026-11-01 P2/P3; proved in server-lib's every_write_door_goes_through_intake), so
            // only the Security page's test boots one that requires a scan ([`start_required`]).
            scan: Some((required: [{required}])),
        )),
    )),
    auth: Some((
        methods: [
            StaticToken(subject: "console", token: Some("{CONSOLE}")),
            StaticToken(subject: "rita", token: Some("{READER}")),
            Passkey(rp_id: "{RP_ID}", rp_origin: "{ORIGIN}"),
        ],
        roles: {{"console": Admin, "rita": Reader}},
        // Reads name their reader: the audit's download rows are the person's (`audit downloads`).
        require_auth_for_reads: true,
        group_roles: {{"holger-admins": Admin, "holger-writers": Writer, "holger-readers": Reader}},
    )),
    {extra}
)"#,
        files = repo("files"),
        incoming = repo("incoming"),
        quarantine = repo("quarantine"),
        approved = repo("approved"),
        production = repo("production"),
        d = d.display(),
        root = pki.root_der.display(),
        key = pki.key_pkcs8.display(),
        cert = pki.cert_der.display(),
    )
}

/// The wheel walked through the intake chain to AVAILABLE with signed scan evidence, before
/// the server starts (as `holger-server` would have, over days).
fn walk_the_wheel(cfg: &Path) {
    let mut h = read_ron_config(cfg).unwrap();
    h.instantiate_backends().unwrap();
    let audit = h.audit_log().unwrap();
    let chain = h.intake_chain(audit).unwrap().expect("an intake chain");
    let incoming = h.get_repo("incoming").unwrap().clone();
    let id = ArtifactId {
        namespace: None,
        name: WHEEL.into(),
        version: String::new(),
    };
    let bytes = wheel("demo-pkg", "1.0.0");
    chain
        .ingest("ida", "test", "incoming", &id, &bytes, || {
            incoming.put(&id, &bytes)
        })
        .unwrap();
    for (to, who) in [
        (ArtifactState::Scanned, "ida"),
        (ArtifactState::PolicyChecked, "ida"),
        (ArtifactState::Approved, "bob"),
        (ArtifactState::Sealed, "bob"),
        (ArtifactState::Promoted, "bob"),
        (ArtifactState::Available, "bob"),
    ] {
        chain
            .advance(TransitionRequest::new(id.clone(), to, who))
            .unwrap_or_else(|e| panic!("→ {to}: {e}"));
    }
}

fn start(with_wheel: bool) -> Running {
    start_with(with_wheel, |_| String::new())
}

/// [`start`] with `extra(data dir)` RON in the config (what it names is written there first).
fn start_with(with_wheel: bool, extra: impl Fn(&Path) -> String) -> Running {
    start_full(with_wheel, extra, "")
}

/// [`start`] on a box whose policy requires the vulnerability and SBOM scans (its raw `files`
/// repository then refuses writes).
fn start_required(with_wheel: bool) -> Running {
    start_scanned(with_wheel, |_| String::new(), "", "vulnerability, sbom")
}

/// [`start_with`] with `promotion` RON in the config's `promotion:` block.
fn start_full(with_wheel: bool, extra: impl Fn(&Path) -> String, promotion: &str) -> Running {
    start_scanned(with_wheel, extra, promotion, "")
}

/// [`start_full`] with `required`, the scan kinds the policy requires.
fn start_scanned(
    with_wheel: bool,
    extra: impl Fn(&Path) -> String,
    promotion: &str,
    required: &str,
) -> Running {
    let _ = rustls::crypto::ring::default_provider().install_default();
    let mut last = String::new();
    for _ in 0..8 {
        let dir = tempfile::tempdir().unwrap();
        let (grpc, http) = (hold_port(), hold_port());
        let cfg = dir.path().join("holger.ron");
        std::fs::write(
            &cfg,
            config_with(
                dir.path(),
                grpc,
                http,
                &extra(dir.path()),
                promotion,
                required,
            ),
        )
        .unwrap();
        if with_wheel {
            walk_the_wheel(&cfg);
        }
        let tokens = dir.path().join("tokens");
        std::fs::create_dir_all(&tokens).unwrap();
        std::fs::write(tokens.join("console"), CONSOLE).unwrap();
        std::fs::write(tokens.join("rita"), READER).unwrap();
        let started = (|| -> Result<Holger, String> {
            let mut h: Holger = read_ron_config(&cfg).map_err(|e| format!("config: {e}"))?;
            h.instantiate_backends()
                .map_err(|e| format!("backends: {e}"))?;
            wire_holger(&mut h).map_err(|e| format!("wire: {e}"))?;
            h.start().map_err(|e| format!("start: {e}"))?;
            Ok(h)
        })();
        match started {
            Ok(h) => {
                return Running {
                    dir,
                    _holger: h,
                    at: format!("127.0.0.1:{grpc}"),
                    base: format!("http://127.0.0.1:{http}"),
                    tokens,
                }
            }
            Err(e) => last = e,
        }
    }
    panic!("server never started; last error: {last}");
}

async fn up(r: &Running) {
    for _ in 0..250 {
        if r.cli(None, &["health"]).await.is_ok() {
            return;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("holger never answered at {}", r.at);
}

/// The call reached the server and the server answered by its own rules.
fn answered(what: &str, r: &Result<String, CliError>) {
    match r {
        Ok(_) | Err(CliError::Server(_)) => {}
        Err(e) => panic!("{what}: the client did not get an answer: {e}"),
    }
}

/// The server refused with `code`.
fn refused(what: &str, r: Result<String, CliError>, code: &str) {
    match r {
        Err(e) => assert_eq!(e.code().as_deref(), Some(code), "{what}: {e}"),
        Ok(out) => panic!("{what}: expected {code}, got an answer:\n{out}"),
    }
}

fn denied(what: &str, r: Result<String, CliError>) {
    match r {
        Err(CliError::Server(s)) => assert!(
            matches!(
                s.code(),
                tonic::Code::PermissionDenied | tonic::Code::Unauthenticated
            ),
            "{what}: {s:?}"
        ),
        other => panic!("{what}: expected a refusal by the server, got {other:?}"),
    }
}

// ─── the admin view ──────────────────────────────────────────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn the_admin_views_read_what_the_console_reads() {
    let r = start(false);
    up(&r).await;
    let health = r.cli(None, &["health"]).await.unwrap();
    assert!(health.contains("status: \"ok\""), "{health}");
    let me = r.cli(Some("console"), &["whoami"]).await.unwrap();
    assert!(
        me.contains("subject: \"console\"") && me.contains("authenticated: true"),
        "{me}"
    );
    let anon = r.cli(None, &["whoami"]).await.unwrap();
    assert!(anon.contains("authenticated: false"), "{anon}");
    let repos = r.cli(Some("console"), &["repos"]).await.unwrap();
    for name in ["files", "incoming", "production"] {
        assert!(
            repos.contains(&format!("name: \"{name}\"")),
            "{name} in {repos}"
        );
    }
    answered("profile", &r.cli(Some("console"), &["profile"]).await);
    let snap = r
        .cli(Some("console"), &["snapshot", "--log-lines", "5"])
        .await;
    assert!(snap.is_ok(), "the admin snapshot: {snap:?}");
    denied(
        "a reader's snapshot",
        r.cli(Some("rita"), &["snapshot"]).await,
    );
    let board = r.cli(Some("console"), &["board"]).await.unwrap();
    assert!(board.contains("boxes"), "{board}");
    denied("a reader's board", r.cli(Some("rita"), &["board"]).await);
}

// ─── artifacts, archive, search, SBOM, properties, usage ─────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn an_artifact_goes_up_comes_back_and_is_found() {
    let r = start(false);
    up(&r).await;
    let file = r.path("hello.txt");
    std::fs::File::create(&file)
        .unwrap()
        .write_all(b"hello holger\n")
        .unwrap();
    let f = file.display().to_string();
    let put = r
        .cli(
            Some("console"),
            &[
                "artifacts",
                "put",
                "-r",
                "files",
                "--name",
                "hello.txt",
                "--file",
                &f,
            ],
        )
        .await;
    assert!(put.is_ok(), "{put:?}");
    refused(
        "a file that is not there",
        r.cli(
            Some("console"),
            &[
                "artifacts",
                "put",
                "-r",
                "files",
                "--name",
                "x",
                "--file",
                "/no/such/file",
            ],
        )
        .await,
        "holger-cli-2",
    );
    denied(
        "a reader's upload",
        r.cli(
            Some("rita"),
            &[
                "artifacts",
                "put",
                "-r",
                "files",
                "--name",
                "r.txt",
                "--file",
                &f,
            ],
        )
        .await,
    );
    let list = r
        .cli(Some("console"), &["artifacts", "list", "-r", "files"])
        .await
        .unwrap();
    assert!(list.contains("hello.txt"), "{list}");
    let out = r.path("back.txt");
    r.cli(
        Some("rita"),
        &[
            "artifacts",
            "fetch",
            "-r",
            "files",
            "--name",
            "hello.txt",
            "--out",
            &out.display().to_string(),
        ],
    )
    .await
    .unwrap();
    assert_eq!(
        std::fs::read(&out).unwrap(),
        b"hello holger\n",
        "the same bytes back"
    );
    let found = r
        .cli(Some("console"), &["search", "--name", "hello"])
        .await
        .unwrap();
    assert!(found.contains("hello.txt"), "{found}");
    refused(
        "a property match without =",
        r.cli(Some("console"), &["search", "--property", "team"])
            .await,
        "holger-cli-1",
    );
    answered(
        "archive files",
        &r.cli(Some("console"), &["archive", "files", "-r", "files"])
            .await,
    );
    answered(
        "archive info",
        &r.cli(Some("console"), &["archive", "info", "-r", "files"])
            .await,
    );
    let usage = r.cli(Some("console"), &["usage", "-r", "files"]).await;
    answered("usage", &usage);
    let sbom = r.path("sbom.json");
    std::fs::write(
        &sbom,
        br#"{"spdxVersion":"SPDX-2.3","SPDXID":"SPDXRef-DOCUMENT","name":"hello","packages":[]}"#,
    )
    .unwrap();
    let s = sbom.display().to_string();
    answered(
        "sbom attach",
        &r.cli(
            Some("console"),
            &[
                "sbom",
                "attach",
                "-r",
                "files",
                "--name",
                "hello.txt",
                "--file",
                &s,
            ],
        )
        .await,
    );
    answered(
        "sbom fetch",
        &r.cli(
            Some("console"),
            &["sbom", "fetch", "-r", "files", "--name", "hello.txt"],
        )
        .await,
    );
    let set = r
        .cli(
            Some("console"),
            &[
                "property",
                "set",
                "-r",
                "files",
                "--name",
                "hello.txt",
                "--key",
                "team",
                "--value",
                "core",
            ],
        )
        .await;
    answered("property set", &set);
    let got = r
        .cli(
            Some("console"),
            &["property", "get", "-r", "files", "--name", "hello.txt"],
        )
        .await;
    answered("property get", &got);
    if set.is_ok() {
        assert!(
            got.unwrap().contains("core"),
            "the property set is the property read"
        );
    }
}

// ─── a large artifact goes up streamed ───────────────────────────────────────

/// ★ **A file bigger than one message goes up streamed** (stinky 14's client half):
/// `artifacts put` sends `RepositoryService.PutArtifactStream` from the file as it reads it,
/// so an artifact larger than the client's per-message bound ([`holger_cli::run::MAX_MESSAGE`],
/// which a unary `PutArtifact` can never carry) is stored whole: the server says it took
/// every byte, streamed, its audit row counts them, and its backend holds the same bytes.
#[tokio::test(flavor = "multi_thread")]
async fn a_file_bigger_than_one_message_goes_up_streamed() {
    let r = start(false);
    up(&r).await;
    let big = r.path("big.bin");
    let len = holger_cli::run::MAX_MESSAGE as u64 + 3 * 1024 * 1024 + 7;
    {
        // A marked head and tail around a sparse middle: cheap on disk, checkable back.
        let mut f = std::fs::File::create(&big).unwrap();
        f.write_all(b"HEAD-of-the-big-artifact").unwrap();
        f.set_len(len - 4).unwrap();
        use std::io::Seek as _;
        f.seek(std::io::SeekFrom::End(0)).unwrap();
        f.write_all(b"TAIL").unwrap();
    }
    assert_eq!(std::fs::metadata(&big).unwrap().len(), len);
    let b = big.display().to_string();
    denied(
        "a reader's streamed upload",
        r.cli(
            Some("rita"),
            &[
                "artifacts",
                "put",
                "-r",
                "files",
                "--name",
                "big.bin",
                "--file",
                &b,
            ],
        )
        .await,
    );
    let said = r
        .cli(
            Some("console"),
            &[
                "artifacts",
                "put",
                "-r",
                "files",
                "--name",
                "big.bin",
                "--file",
                &b,
            ],
        )
        .await
        .unwrap();
    assert!(
        said.contains(&format!("{len} bytes, streamed")),
        "the server took every byte, streamed: {said}"
    );
    let events = holger_server_lib::audit::FileAuditLog::read_dir(r.path("audit"))
        .expect("the audit log reads back");
    assert!(
        events.iter().any(|e| e.ident == "console"
            && e.status == 200
            && e.bytes == len
            && e.artifact.contains("big.bin")),
        "the upload is audited with its byte count"
    );
    let id = ArtifactId {
        namespace: None,
        name: "big.bin".into(),
        version: String::new(),
    };
    let stored = r
        ._holger
        .get_repo("files")
        .unwrap()
        .fetch(&id)
        .unwrap()
        .expect("the backend holds it");
    assert_eq!(stored.len() as u64, len);
    assert!(
        stored.starts_with(b"HEAD-of-the-big-artifact") && stored.ends_with(b"TAIL"),
        "the same bytes"
    );
}

// ─── the intake chain, promotion, audit ──────────────────────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn the_intake_chain_and_its_trail_are_read_and_driven() {
    let r = start(true);
    up(&r).await;
    let state = r
        .cli(Some("console"), &["intake", "state", "--name", WHEEL])
        .await
        .unwrap();
    assert!(state.to_ascii_lowercase().contains("available"), "{state}");
    answered(
        "intake evidence",
        &r.cli(Some("console"), &["intake", "evidence", "--name", WHEEL])
            .await,
    );
    answered(
        "intake list",
        &r.cli(
            Some("console"),
            &["intake", "list", "--queue", "quarantine"],
        )
        .await,
    );
    let sha = {
        use std::fmt::Write as _;
        let bytes = wheel("demo-pkg", "1.0.0");
        let d = holger_update_bundle::digest::sha256_hex(&bytes);
        let mut s = String::new();
        let _ = write!(s, "{d}");
        s
    };
    let trail = r
        .cli(Some("console"), &["audit", "trail", "--sha256", &sha])
        .await;
    answered("audit trail", &trail);
    answered(
        "intake advance (block)",
        &r.cli(
            Some("console"),
            &[
                "intake",
                "advance",
                "--name",
                "nothing-here.whl",
                "--verb",
                "block",
                "--reason",
                "test",
            ],
        )
        .await,
    );
    answered(
        "promote",
        &r.cli(
            Some("console"),
            &[
                "promote", "--from", "files", "--to", "files", "--name", "nothing",
            ],
        )
        .await,
    );
}

// ─── a denied licence, end to end ────────────────────────────────────────────

const GPL_WHEEL: &str = "gpl_pkg-1.0.0-py3-none-any.whl";
const MIT_WHEEL: &str = "mit_pkg-1.0.0-py3-none-any.whl";

/// The sha256 of `bytes`, lowercase hex.
fn sha_of(bytes: &[u8]) -> String {
    holger_update_bundle::digest::sha256_hex(bytes)
}

/// The audit log as text, every record of it.
fn audit_text(r: &Running) -> String {
    let mut audit = String::new();
    for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
        if let Ok(t) = std::fs::read(e.path()) {
            audit.push_str(&String::from_utf8_lossy(&t));
        }
    }
    audit
}

/// One section of a `holger-cli security` answer (`key: "<key>"` up to the next section).
fn section<'a>(said: &'a str, key: &str) -> &'a str {
    let at = said
        .find(&format!("key: \"{key}\""))
        .unwrap_or_else(|| panic!("no {key} section in {said}"));
    let rest = &said[at + 1..];
    let end = rest.find("key: \"").map_or(rest.len(), |e| e + 1);
    &said[at..at + end]
}

/// The `state` of the row named `name` in `text` (a section of the Security page).
fn row_state<'a>(text: &'a str, name: &str) -> &'a str {
    let at = text
        .find(&format!("name: \"{name}\""))
        .unwrap_or_else(|| panic!("no row {name} in {text}"));
    let s = &text[at..];
    let v = &s[s.find("state: \"").unwrap() + 8..];
    &v[..v.find('"').unwrap()]
}

/// ★ **A denied licence is refused through the real intake** (S6, 697d7530, end to end): the
/// box's `promotion.deny_licenses` names GPL-3.0; a GPL-3.0 wheel goes up `artifacts put`
/// into incoming (the gRPC door, the intake chain's ingest), and the scan step (`intake
/// advance`) refuses it by the licence, naming GPL-3.0. An MIT wheel beside it passes the
/// same step. The refusal is in the artifact's audit trail (`audit trail`), on the Security
/// page (`security`: held or refused, and the recent events), and production serves nothing
/// of it.
#[tokio::test(flavor = "multi_thread")]
async fn a_denied_licence_is_refused_through_the_intake_and_shows_on_the_security_page() {
    let r = start_full(false, |_| String::new(), r#"deny_licenses: ["GPL-3.0"],"#);
    up(&r).await;
    let (gpl, mit) = (
        licensed_wheel("gpl-pkg", "1.0.0", "GPL-3.0"),
        licensed_wheel("mit-pkg", "1.0.0", "MIT"),
    );
    for (name, bytes) in [(GPL_WHEEL, &gpl), (MIT_WHEEL, &mit)] {
        let f = r.path(name);
        std::fs::write(&f, bytes).unwrap();
        let said = r
            .cli(
                Some("console"),
                &[
                    "artifacts",
                    "put",
                    "-r",
                    "incoming",
                    "--name",
                    name,
                    "--file",
                    &f.display().to_string(),
                ],
            )
            .await;
        let said = said.unwrap_or_else(|e| panic!("{name} is ingested: {e}"));
        assert!(said.contains("INGESTED"), "{name}: {said}");
    }
    // The scan step: the MIT wheel passes, the GPL-3.0 one is refused by its licence.
    let passed = r
        .cli(Some("console"), &["intake", "advance", "--name", MIT_WHEEL])
        .await
        .unwrap();
    assert!(
        passed.contains("SCANNED"),
        "the MIT wheel is scanned: {passed}"
    );
    let refused = r
        .cli(Some("console"), &["intake", "advance", "--name", GPL_WHEEL])
        .await;
    let why = match refused {
        Err(CliError::Server(s)) => s.message().to_owned(),
        other => panic!("the GPL-3.0 wheel is refused by the server: {other:?}"),
    };
    assert!(
        why.contains("GPL-3.0"),
        "the refusal names the licence: {why}"
    );
    let state = r
        .cli(Some("console"), &["intake", "state", "--name", GPL_WHEEL])
        .await
        .unwrap();
    assert!(
        !state.contains("SCANNED") && !state.contains("AVAILABLE"),
        "it did not move: {state}"
    );
    // In the artifact's trail, by its sha256.
    let trail = r
        .cli(
            Some("console"),
            &["audit", "trail", "--sha256", &sha_of(&gpl)],
        )
        .await
        .unwrap();
    assert!(
        trail.contains("GPL-3.0"),
        "the trail holds the refusal: {trail}"
    );
    assert!(audit_text(&r).contains("GPL-3.0"), "the audit log holds it");
    // On the Security page: held or refused, by name, and among the recent events.
    let page = r.cli(Some("console"), &["security"]).await.unwrap();
    let held = section(&page, "held");
    assert!(
        held.contains(GPL_WHEEL) && held.contains("GPL-3.0"),
        "held or refused names it and why: {held}"
    );
    assert_eq!(row_state(held, GPL_WHEEL), "refused", "{held}");
    assert!(
        !held.contains(MIT_WHEEL),
        "the MIT wheel is not held: {held}"
    );
    let events = section(&page, "events");
    assert!(
        events.contains("GPL-3.0") && events.contains("refused"),
        "a recent security event: {events}"
    );
    // Never served.
    let out = r.path("gpl-back.whl").display().to_string();
    assert!(
        r.cli(
            Some("console"),
            &[
                "artifacts",
                "fetch",
                "-r",
                "production",
                "--name",
                GPL_WHEEL,
                "--out",
                &out
            ]
        )
        .await
        .is_err(),
        "production serves nothing of it"
    );
}

// ─── the Security page, against a real holger ────────────────────────────────

/// ★ **`holger-cli security` reads the server's Security page** (lane HOL-SEC): every
/// section, in the page's order, and the chain's counts are the chain's own state (the walked
/// wheel served, a new upload in, one scanned); a waiting package is in the approvals; the
/// required scanners are named; a Reader and an anonymous caller are refused.
#[tokio::test(flavor = "multi_thread")]
async fn the_security_page_counts_what_the_chain_holds() {
    let r = start_required(true);
    up(&r).await;
    for (name, version) in [("one-pkg", "1.0.0"), ("two-pkg", "2.0.0")] {
        let file = format!("{}-{version}-py3-none-any.whl", name.replace('-', "_"));
        let f = r.path(&file);
        std::fs::write(&f, wheel(name, version)).unwrap();
        r.cli(
            Some("console"),
            &[
                "artifacts",
                "put",
                "-r",
                "incoming",
                "--name",
                &file,
                "--file",
                &f.display().to_string(),
            ],
        )
        .await
        .unwrap();
    }
    for _ in 0..2 {
        r.cli(
            Some("console"),
            &[
                "intake",
                "advance",
                "--name",
                "two_pkg-2.0.0-py3-none-any.whl",
            ],
        )
        .await
        .unwrap();
    }
    let page = r.cli(Some("console"), &["security"]).await.unwrap();
    let keys = [
        "chain",
        "sources",
        "scanners",
        "approvals",
        "held",
        "access",
        "sync",
        "events",
    ];
    let at: Vec<usize> = keys
        .iter()
        .map(|k| {
            page.find(&format!("key: \"{k}\""))
                .unwrap_or_else(|| panic!("no {k} section: {page}"))
        })
        .collect();
    assert!(
        at.windows(2).all(|w| w[0] < w[1]),
        "the sections in the page's order: {at:?}"
    );
    // The chain's counts are the chain's state, as the server holds it.
    let chain = r._holger.intake().expect("an intake chain");
    let n = |states: &[&str]| {
        chain
            .list()
            .into_iter()
            .filter(|(_, s)| states.contains(&s.state.name()))
            .count()
            .to_string()
    };
    let steps = section(&page, "chain");
    assert_eq!(row_state(steps, "In"), n(&["UNTRUSTED", "INGESTED"]));
    assert_eq!(
        row_state(steps, "In"),
        "1",
        "one upload waits at the door: {steps}"
    );
    assert_eq!(row_state(steps, "Policy checked"), "1", "{steps}");
    assert_eq!(row_state(steps, "Served"), n(&["AVAILABLE"]));
    assert_eq!(row_state(steps, "Served"), "1", "the walked wheel: {steps}");
    let waiting = section(&page, "approvals");
    assert!(
        waiting.contains("two_pkg-2.0.0-py3-none-any.whl"),
        "{waiting}"
    );
    let scanners = section(&page, "scanners");
    assert!(
        scanners.contains("required"),
        "the required scanners are named: {scanners}"
    );
    assert!(
        page.contains("1 package waits for approval"),
        "the headline counts it: {page}"
    );
    denied(
        "a reader's Security page",
        r.cli(Some("rita"), &["security"]).await,
    );
    denied(
        "an anonymous Security page",
        r.cli(None, &["security"]).await,
    );
}

// ─── what did this person download ───────────────────────────────────────────

/// ★ **"What did this person download"** (7492403d): `holger-cli audit downloads` asks
/// `AuditService.QueryDownloads`, and the answer is the server's audit log: rita's fetch is
/// rita's row, by repository it is everybody's, a time range narrows, and only an Auditor or
/// an Admin reads it.
#[tokio::test(flavor = "multi_thread")]
async fn what_a_person_downloaded_is_read_from_the_audit_log() {
    let r = start(false);
    up(&r).await;
    let file = r.path("hello.txt");
    std::fs::write(&file, b"hello holger\n").unwrap();
    let f = file.display().to_string();
    r.cli(
        Some("console"),
        &[
            "artifacts",
            "put",
            "-r",
            "files",
            "--name",
            "hello.txt",
            "--file",
            &f,
        ],
    )
    .await
    .unwrap();
    let before = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_nanos() as i64;
    for who in ["rita", "console"] {
        let out = r.path(&format!("back-{who}.txt"));
        r.cli(
            Some(who),
            &[
                "artifacts",
                "fetch",
                "-r",
                "files",
                "--name",
                "hello.txt",
                "--out",
                &out.display().to_string(),
            ],
        )
        .await
        .unwrap();
    }
    let ritas = r
        .cli(Some("console"), &["audit", "downloads", "--who", "rita"])
        .await
        .unwrap();
    assert!(
        ritas.contains("ident: \"rita\"")
            && ritas.contains("repo: \"files\"")
            && ritas.contains("hello.txt"),
        "{ritas}"
    );
    assert!(
        !ritas.contains("ident: \"console\""),
        "rita's rows only: {ritas}"
    );
    assert!(ritas.contains("matched: 1"), "{ritas}");
    let files = r
        .cli(
            Some("console"),
            &["audit", "downloads", "--repository", "files"],
        )
        .await
        .unwrap();
    assert!(
        files.contains("ident: \"rita\"")
            && files.contains("ident: \"console\"")
            && files.contains("matched: 2"),
        "everybody's from files: {files}"
    );
    let none = r
        .cli(
            Some("console"),
            &["audit", "downloads", "--repository", "production"],
        )
        .await
        .unwrap();
    assert!(none.contains("matched: 0"), "{none}");
    let since = before.to_string();
    let after = r
        .cli(
            Some("console"),
            &["audit", "downloads", "--who", "rita", "--since", &since],
        )
        .await
        .unwrap();
    assert!(after.contains("matched: 1"), "{after}");
    let until = r
        .cli(
            Some("console"),
            &["audit", "downloads", "--who", "rita", "--until", &since],
        )
        .await
        .unwrap();
    assert!(
        until.contains("matched: 0"),
        "nothing before the fetch: {until}"
    );
    let one = r
        .cli(
            Some("console"),
            &[
                "audit",
                "downloads",
                "--repository",
                "files",
                "--limit",
                "1",
            ],
        )
        .await
        .unwrap();
    assert!(
        one.contains("matched: 2") && one.contains("truncated: true"),
        "{one}"
    );
    refused(
        "a time that is not a number",
        r.cli(
            Some("console"),
            &["audit", "downloads", "--since", "yesterday"],
        )
        .await,
        "holger-cli-1",
    );
    denied(
        "a reader's downloads",
        r.cli(Some("rita"), &["audit", "downloads", "--who", "rita"])
            .await,
    );
}

/// ★ **The audit search, counters and export** (lane HOL-AUDIT): `audit query` filters by
/// actor and action, `audit aggregate` counts per repository and action, `audit export`
/// writes an Arrow IPC file of the rows, `audit verify` walks the chain; a reader is refused.
#[tokio::test(flavor = "multi_thread")]
async fn the_audit_log_is_searched_counted_and_exported() {
    let r = start(false);
    up(&r).await;
    let file = r.path("hello.txt");
    std::fs::write(&file, b"hello holger\n").unwrap();
    let f = file.display().to_string();
    r.cli(
        Some("console"),
        &[
            "artifacts",
            "put",
            "-r",
            "files",
            "--name",
            "hello.txt",
            "--file",
            &f,
        ],
    )
    .await
    .unwrap();
    for who in ["rita", "rita", "console"] {
        let out = r.path(&format!("back-{who}.txt"));
        r.cli(
            Some(who),
            &[
                "artifacts",
                "fetch",
                "-r",
                "files",
                "--name",
                "hello.txt",
                "--out",
                &out.display().to_string(),
            ],
        )
        .await
        .unwrap();
    }
    let q = r
        .cli(
            Some("console"),
            &["audit", "query", "--who", "rita", "--action", "download"],
        )
        .await
        .unwrap();
    assert!(
        q.contains("matched: 2")
            && q.contains("ident: \"rita\"")
            && !q.contains("ident: \"console\""),
        "{q}"
    );
    let agg = r
        .cli(
            Some("console"),
            &[
                "audit",
                "aggregate",
                "--by",
                "repo,action",
                "--action",
                "download",
                "--ok-only",
            ],
        )
        .await
        .unwrap();
    assert!(
        agg.contains("repo: \"files\"")
            && agg.contains("action: \"download\"")
            && agg.contains("count: 3"),
        "{agg}"
    );
    let out = r.path("audit.arrow");
    let said = r
        .cli(
            Some("console"),
            &[
                "audit",
                "export",
                "--action",
                "download",
                "--out",
                &out.display().to_string(),
            ],
        )
        .await
        .unwrap();
    assert!(said.contains("3 rows"), "{said}");
    let back =
        holger_server_lib::audit::arrow_ipc::read_arrow_file_bytes(&std::fs::read(&out).unwrap())
            .unwrap();
    assert_eq!(back.len(), 3);
    let v = r.cli(Some("console"), &["audit", "verify"]).await.unwrap();
    assert!(v.contains("intact: true"), "{v}");
    refused(
        "an action the log never writes",
        r.cli(Some("console"), &["audit", "query", "--action", "teleport"])
            .await,
        "holger-server-3",
    );
    denied(
        "a reader's audit query",
        r.cli(Some("rita"), &["audit", "query"]).await,
    );
}

// ─── the intel import door ───────────────────────────────────────────────────

const INTEL_FIXTURES: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../intel/tests/fixtures");

struct IntelSigner {
    cert: PathBuf,
    key: PathBuf,
    fingerprint: String,
}

/// The intel bundle's three signers (the review, the owner, the release), the same in
/// every attempt: minted once, under `dir`.
fn intel_signers(dir: &Path) -> [IntelSigner; 3] {
    use rcgen::{CertificateParams, DnType, KeyPair, PKCS_ED25519};
    std::fs::create_dir_all(dir).unwrap();
    ["security-reviewer", "system-owner", "release"].map(|cn| {
        let key = KeyPair::generate_for(&PKCS_ED25519).unwrap();
        let mut params = CertificateParams::new(vec![]).unwrap();
        params.distinguished_name.push(DnType::CommonName, cn);
        let cert = params.self_signed(&key).unwrap();
        let (c, k) = (dir.join(format!("{cn}.crt")), dir.join(format!("{cn}.key")));
        std::fs::write(&c, cert.der()).unwrap();
        std::fs::write(&k, key.serialize_der()).unwrap();
        IntelSigner {
            cert: c,
            key: k,
            fingerprint: holger_update_bundle::digest::sha256_hex(cert.der()),
        }
    })
}

fn intel_policy(s: &[IntelSigner; 3]) -> holger_update_bundle::policy::TrustPolicy {
    use holger_update_bundle::policy::{
        ApprovedSigner, CertificateValidity, ExpiredPosture, TrustMode, TrustPolicy,
    };
    TrustPolicy {
        schema_version: holger_update_bundle::schema::POLICY_SCHEMA.into(),
        trust_mode: Some(TrustMode::PinnedSigners),
        trusted_roots: vec![],
        certificate_validity: CertificateValidity::Enforce,
        approved_signers: vec![
            ApprovedSigner {
                fingerprint: s[0].fingerprint.clone(),
                subject: None,
                roles: vec!["security-reviewer".into()],
            },
            ApprovedSigner {
                fingerprint: s[1].fingerprint.clone(),
                subject: None,
                roles: vec!["system-owner".into()],
            },
        ],
        required_approval_roles: vec!["security-reviewer".into(), "system-owner".into()],
        minimum_distinct_approvers: 2,
        trusted_release_signers: vec![s[2].fingerprint.clone()],
        release_signer_must_be_distinct: true,
        maximum_bundle_age_secs: None,
        require_expiry: true,
        on_expired_metadata: Some(ExpiredPosture::Refuse),
        require_sbom: false,
        require_vex: false,
        require_scan: false,
        allowed_products: vec!["holger.intel/v1".into()],
        allowed_models: vec![],
    }
}

/// A signed intel bundle (OSV PYSEC-2018-28 for `requests` < 2.20.0) at `sequence`.
fn intel_bundle(dir: &Path, s: &[IntelSigner; 3], sequence: u64) -> PathBuf {
    use holger_server_lib::holger_intel as intel;
    let feeds = dir.join("feeds");
    std::fs::create_dir_all(&feeds).unwrap();
    for (from, to) in [
        ("PYSEC-2018-28.json", "osv-PyPI.json"),
        ("nvd-recent.json", "nvd-recent.json"),
        ("kev.json", "kev.json"),
    ] {
        std::fs::copy(Path::new(INTEL_FIXTURES).join(from), feeds.join(to)).unwrap();
    }
    let now = intel::chrono::Utc::now();
    let raw: Vec<intel::RawFeed> = intel::FeedSpec::from_dir(&feeds)
        .unwrap()
        .into_iter()
        .map(|f| intel::RawFeed::read_file(f).unwrap())
        .collect();
    let set = intel::feeds::normalize(&raw, now).unwrap();
    let out = dir.join(format!("intel-{sequence}.holger.znippy"));
    let files = |x: &IntelSigner| intel::SignerFiles::read(&x.cert, &x.key).unwrap();
    intel::build_bundle(
        &set,
        intel::BuildOptions {
            sequence,
            created_at: now,
            expires_at: Some(now + intel::chrono::Duration::days(30)),
            supplier: intel::DEFAULT_SUPPLIER.into(),
            producer: "the holger-cli test".into(),
            policy: intel_policy(s),
            approvals: vec![
                ("security-reviewer".into(), files(&s[0])),
                ("system-owner".into(), files(&s[1])),
            ],
            release: files(&s[2]),
            out: out.clone(),
        },
    )
    .unwrap();
    out
}

/// ★ **`AdminService.ImportIntelBundle` has a caller**: `holger-cli intel import` streams a
/// signed intel bundle to a REAL holger, which verifies it, sets the anti-rollback floor only
/// with an admin's first-import authorization, audits it first and makes it the live advisory database; a reader is refused,
/// the same bundle again is idempotent, and a file that is not a bundle is refused by its code.
#[tokio::test(flavor = "multi_thread")]
async fn an_intel_bundle_is_imported_through_the_admin_door() {
    let keys = tempfile::tempdir().unwrap();
    let signers = intel_signers(keys.path());
    let policy = intel_policy(&signers).to_ron().unwrap();
    let r = start_with(false, |d| {
        let p = d.join("intel-trust.ron");
        std::fs::write(&p, &policy).unwrap();
        format!(
            r#"intel: Some((db_dir: "{}/intel-db", offline: true, trust_policy: Some("{}"))),"#,
            d.display(),
            p.display()
        )
    });
    up(&r).await;
    let bundle = intel_bundle(keys.path(), &signers, 100);
    let b = bundle.display().to_string();
    denied(
        "a reader's intel import",
        r.cli(Some("rita"), &["intel", "import", "--file", &b])
            .await,
    );
    // No floor yet and no admin's first-import authorization: refused, and nothing goes live.
    refused(
        "a floorless import",
        r.cli(Some("console"), &["intel", "import", "--file", &b])
            .await,
        "holger-import-9",
    );
    assert!(
        !r.path("intel-db/installed.json").exists(),
        "nothing went live"
    );
    // The admin's action on the secure side (lane HOL-CDS, NCSC gap 2): this vendor, product
    // and sequence may establish the floor. The thin client has no `--first-import`; it has
    // the admin's verb (HOL-NCSC), over `BundleService.AllowFirstImport`. A reader is refused.
    let (vendor, product) = (
        holger_server_lib::holger_intel::DEFAULT_SUPPLIER,
        holger_server_lib::holger_intel::INTEL_PRODUCT,
    );
    let grant = [
        "bundle",
        "allow-first-import",
        "--vendor",
        vendor,
        "--product",
        product,
        "--sequence",
        "100",
    ];
    denied(
        "a reader's first-import authorization",
        r.cli(Some("rita"), &grant).await,
    );
    let said = r.cli(Some("console"), &grant).await.unwrap();
    assert!(
        said.contains("sequence: 100")
            && said.contains(&format!("vendor: \"{vendor}\""))
            && said.contains("by: \"console\""),
        "{said}"
    );
    let said = r
        .cli(Some("console"), &["intel", "import", "--file", &b])
        .await
        .unwrap();
    assert!(
        said.contains("outcome: \"new\"") && said.contains("sequence: 100"),
        "{said}"
    );
    assert!(
        r.path("intel-db/installed.json").exists(),
        "the advisory database is live"
    );
    refused(
        "an authorization once the floor stands",
        r.cli(Some("console"), &grant).await,
        "holger-import-11",
    );
    let again = r
        .cli(Some("console"), &["intel", "import", "--file", &b])
        .await
        .unwrap();
    assert!(again.contains("idempotent-reimport"), "{again}");
    let mut audit = String::new();
    for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
        if let Ok(t) = std::fs::read(e.path()) {
            audit.push_str(&String::from_utf8_lossy(&t));
        }
    }
    assert!(
        audit.contains("intel-import") && audit.contains("seq=100"),
        "audited: {audit}"
    );
    // HOL-NCSC (NCSC P1 item 5): the reader's refused import and the floorless one left rows.
    assert!(
        audit
            .lines()
            .any(|l| l.contains("rita") && l.contains("refused") && l.contains("[holger-duty-")),
        "the unauthorised importer's refusal is audited: {audit}"
    );
    assert!(
        audit
            .lines()
            .any(|l| l.contains("intel import refused") && l.contains("[holger-import-9]")),
        "the floorless import's refusal is audited: {audit}"
    );
    let junk = r.path("junk.znippy");
    std::fs::write(&junk, b"not a bundle").unwrap();
    match r
        .cli(
            Some("console"),
            &["intel", "import", "--file", &junk.display().to_string()],
        )
        .await
    {
        Err(e) => {
            assert!(
                e.code().is_some_and(
                    |c| c.starts_with("holger-import-") || c.starts_with("holger-bundle-")
                ),
                "{e}"
            )
        }
        Ok(o) => panic!("a junk bundle imported? {o}"),
    }
    refused(
        "an import of no file",
        r.cli(
            Some("console"),
            &["intel", "import", "--file", "/no/such.znippy"],
        )
        .await,
        "holger-cli-2",
    );
}

// ─── jobs, admission, the verbs this build does not carry ────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn the_queue_and_the_admission_book_are_read_and_a_missing_verb_says_holger_cli_3() {
    let r = start(false);
    up(&r).await;
    let jobs = r.cli(Some("console"), &["jobs"]).await;
    assert!(jobs.is_ok(), "jobs: {jobs:?}");
    denied("a reader's jobs", r.cli(Some("rita"), &["jobs"]).await);
    answered(
        "admission usage",
        &r.cli(Some("console"), &["admission", "usage"]).await,
    );
    answered(
        "admission plan",
        &r.cli(Some("console"), &["admission", "plan", "--consumer", "ci"])
            .await,
    );
    // No `migration` and no `mirror` in this server's build: the server says UNIMPLEMENTED.
    refused(
        "migration discover",
        r.cli(
            Some("console"),
            &[
                "migration",
                "discover",
                "--kind",
                "nexus",
                "--base-url",
                "http://nexus.invalid",
            ],
        )
        .await,
        "holger-cli-3",
    );
    refused(
        "migration watch",
        r.cli(Some("console"), &["migration", "watch", "--job", "j"])
            .await,
        "holger-cli-3",
    );
    refused(
        "migration cancel",
        r.cli(Some("console"), &["migration", "cancel", "--job", "j"])
            .await,
        "holger-cli-3",
    );
    refused(
        "replicate",
        r.cli(
            Some("console"),
            &["replicate", "--from", "files", "--to", "production"],
        )
        .await,
        "holger-cli-3",
    );
}

// ─── the person's pages: room, account, tokens ───────────────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn the_persons_pages_are_the_servers_answers() {
    let r = start(false);
    up(&r).await;
    answered(
        "room show",
        &r.cli(Some("console"), &["room", "show", "-r", "files"])
            .await,
    );
    answered(
        "room private-estate",
        &r.cli(Some("console"), &["room", "private-estate"]).await,
    );
    answered(
        "account mine",
        &r.cli(Some("rita"), &["account", "mine"]).await,
    );
    answered(
        "account create",
        &r.cli(
            Some("console"),
            &["account", "create", "--name", "team-x", "--format", "raw"],
        )
        .await,
    );
    answered(
        "account grant",
        &r.cli(
            Some("console"),
            &[
                "account", "grant", "-r", "files", "--person", "rita", "--access", "read",
            ],
        )
        .await,
    );
    let list = r.cli(Some("console"), &["token", "list"]).await;
    assert!(list.is_ok(), "the console lists every token: {list:?}");
    // A token is a PERSON's act: the box's bearer mints none (holger-token-6), as in the console.
    refused(
        "the console's mint",
        r.cli(Some("console"), &["token", "mint", "--repository", "files"])
            .await,
        "holger-token-6",
    );
    answered(
        "token revoke",
        &r.cli(
            Some("console"),
            &["token", "revoke", "--id", "no-such-token"],
        )
        .await,
    );
}

// ─── login and logout ────────────────────────────────────────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn login_keeps_the_credential_0600_for_that_server_and_logout_forgets_it() {
    let r = start(false);
    up(&r).await;
    let said = r.cli(Some("console"), &["login"]).await.unwrap();
    assert!(
        said.contains("console") && !said.contains(CONSOLE),
        "logged in, the credential never printed: {said}"
    );
    let stored = r.dir.path().join("cli-home").join("credentials");
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        assert_eq!(
            std::fs::metadata(&stored).unwrap().permissions().mode() & 0o777,
            0o600
        );
    }
    let me = r.cli(None, &["whoami"]).await.unwrap();
    assert!(
        me.contains("subject: \"console\""),
        "the stored credential is used: {me}"
    );
    // A credential the server does not accept is not stored.
    let bad = r.path("bad-token");
    std::fs::write(&bad, "not-a-credential").unwrap();
    let mut argv = vec![
        "holger-cli".to_string(),
        "--at".into(),
        r.at.clone(),
        "--token-file".into(),
        bad.display().to_string(),
        "login".into(),
    ];
    let home = r.dir.path().join("cli-home").display().to_string();
    let env = move |k: &str| (k == "HOLGER_CLI_HOME").then(|| home.clone());
    assert!(
        holger_cli::run_args(argv.clone(), env.clone())
            .await
            .is_err(),
        "a credential the server refuses is not stored"
    );
    argv.pop();
    argv.push("logout".into());
    holger_cli::run_args(argv, env).await.unwrap();
    let anon = r.cli(None, &["whoami"]).await.unwrap();
    assert!(anon.contains("authenticated: false"), "forgotten: {anon}");
}

// ─── the two new doors: bundle export, first key ─────────────────────────────

#[tokio::test(flavor = "multi_thread")]
async fn the_bundle_export_door_streams_the_candidate_admin_only_and_audited() {
    let r = start(true);
    up(&r).await;
    let out = r.path("export.cand");
    let o = out.display().to_string();
    let args = [
        "bundle",
        "export",
        "--out",
        &o,
        "--bundle-id",
        "airgap-cli-1",
        "--sequence",
        "1",
        "--supplier",
        "online",
        "--product",
        "pypi",
        "--release-version",
        "1",
    ];
    denied("a reader's export", r.cli(Some("rita"), &args).await);
    denied("an anonymous export", r.cli(None, &args).await);
    assert!(!out.exists(), "a refused export writes nothing");
    let said = r.cli(Some("console"), &args).await.unwrap();
    assert!(
        said.contains("EXPORTED airgap-cli-1 seq 1") && said.contains(WHEEL),
        "{said}"
    );
    let members = holger_update_bundle::prepare::read_members_dir(&out).unwrap();
    assert!(
        members.keys().any(|k| k.ends_with(WHEEL)),
        "the wheel is a payload: {:?}",
        members.keys().collect::<Vec<_>>()
    );
    assert!(!members.is_empty());
    let wheel_bytes = wheel("demo-pkg", "1.0.0");
    assert!(
        members
            .iter()
            .any(|(k, v)| k.ends_with(WHEEL) && *v == wheel_bytes),
        "the payload is the wheel's bytes"
    );
    // Audited first: the record names the bundle and the actor.
    let mut audit = String::new();
    for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
        if let Ok(t) = std::fs::read(e.path()) {
            audit.push_str(&String::from_utf8_lossy(&t));
        }
    }
    assert!(
        audit.contains("airgap export airgap-cli-1 seq 1"),
        "the export is in the audit log"
    );
    // An existing directory is never overwritten.
    refused(
        "the same directory again",
        r.cli(Some("console"), &args).await,
        "holger-cli-1",
    );
    // An export of nothing that passes is refused by its own code.
    let none = r.path("none.cand").display().to_string();
    let r2 = r
        .cli(
            Some("console"),
            &[
                "bundle",
                "export",
                "--out",
                &none,
                "--bundle-id",
                "b",
                "--sequence",
                "2",
                "--supplier",
                "s",
                "--product",
                "p",
                "--release-version",
                "1",
                "--artifact",
                "nothing-like-this",
            ],
        )
        .await;
    match r2 {
        Err(e) => assert!(
            e.code().is_some_and(|c| c.starts_with("holger-airgap-")),
            "{e}"
        ),
        Ok(o) => panic!("exported nothing? {o}"),
    }
    // The import half takes a file and refuses one that is not a bundle by its own code.
    let junk = r.path("junk.bundle");
    std::fs::write(&junk, b"not a bundle").unwrap();
    let j = junk.display().to_string();
    let imported = r
        .cli(Some("console"), &["bundle", "import", "--file", &j])
        .await;
    match imported {
        Err(CliError::Server(s)) => assert!(s.message().contains("holger-"), "{s:?}"),
        other => panic!("a junk bundle: {other:?}"),
    }
    refused(
        "an import of no file",
        r.cli(
            Some("console"),
            &["bundle", "import", "--file", "/no/such.bundle"],
        )
        .await,
        "holger-cli-2",
    );
}

async fn post(
    c: &reqwest::Client,
    base: &str,
    path: &str,
    body: serde_json::Value,
) -> (u16, serde_json::Value) {
    let r = c
        .post(format!("{base}{path}"))
        .json(&body)
        .send()
        .await
        .expect("the door answers");
    let status = r.status().as_u16();
    let text = r.text().await.unwrap_or_default();
    (
        status,
        serde_json::from_str(&text).unwrap_or(serde_json::Value::String(text)),
    )
}

#[tokio::test(flavor = "multi_thread")]
async fn the_first_key_door_opens_once_for_the_box_and_shuts_when_an_admin_key_exists() {
    let r = start(false);
    up(&r).await;
    denied(
        "a reader's first key",
        r.cli(Some("rita"), &["first-key"]).await,
    );
    let first = r
        .cli(Some("console"), &["first-key", "--display", "anna"])
        .await
        .unwrap();
    let secret_1 = first.lines().next().unwrap().trim().to_owned();
    assert!(
        first.contains("/login?invite=") && first.contains("holger-admins"),
        "{first}"
    );
    // A second ask revokes the first: one link open at a time.
    let second = r
        .cli(Some("console"), &["first-key", "--display", "anna"])
        .await
        .unwrap();
    let secret = second.lines().next().unwrap().trim().to_owned();
    assert_ne!(secret, secret_1);
    let c = reqwest::Client::builder().build().unwrap();
    let (status, _) = post(
        &c,
        &r.base,
        "/-/auth/passkey/register/start",
        serde_json::json!({ "invite": secret_1, "label": "old" }),
    )
    .await;
    assert_eq!(status, 401, "the first link is spent by the second");
    // The person opens the link and touches their key: the box's first admin key.
    let mut key = SoftKey::new(0x61, b"cred-anna-first");
    let (status, start) = post(
        &c,
        &r.base,
        "/-/auth/passkey/register/start",
        serde_json::json!({ "invite": secret, "label": "laptop" }),
    )
    .await;
    assert_eq!(status, 200, "{start}");
    let ceremony = start["ceremony_id"].as_str().unwrap().to_owned();
    let challenge = start["publicKey"]["challenge"].as_str().unwrap();
    let (client_data, attestation) = key.register(RP_ID, ORIGIN, challenge, true);
    let (status, done) = post(
        &c,
        &r.base,
        "/-/auth/passkey/register/finish",
        serde_json::json!({ "ceremony_id": ceremony, "id": key.credential_id_b64(), "response": { "clientDataJSON": client_data, "attestationObject": attestation } }),
    )
    .await;
    assert_eq!(status, 200, "the first admin key is enrolled: {done}");
    // Now the door is shut, by its own code, for the box's bearer too.
    refused(
        "the first key, again",
        r.cli(Some("console"), &["first-key"]).await,
        "holger-auth-5",
    );
    let mut audit = String::new();
    for e in std::fs::read_dir(r.path("audit")).unwrap().flatten() {
        if let Ok(t) = std::fs::read(e.path()) {
            audit.push_str(&String::from_utf8_lossy(&t));
        }
    }
    assert!(
        audit.contains("first-key invite minted") && audit.contains("first-key invite refused"),
        "both are audited"
    );
}

/// One passkey registration over HTTP (start, the soft key's answer, finish), as `bearer`
/// when given: (start status, finish status and body). A refused start stops there.
async fn enrol(
    c: &reqwest::Client,
    base: &str,
    key: &mut SoftKey,
    bearer: Option<&str>,
    body: serde_json::Value,
) -> (u16, u16, serde_json::Value) {
    let send = |path: &str, body: serde_json::Value| {
        let mut rq = c.post(format!("{base}{path}")).json(&body);
        if let Some(b) = bearer {
            rq = rq.bearer_auth(b);
        }
        rq
    };
    let r = send("/-/auth/passkey/register/start", body)
        .send()
        .await
        .expect("the door answers");
    let started = r.status().as_u16();
    let start: serde_json::Value = r.json().await.unwrap_or_default();
    if started != 200 {
        return (started, 0, start);
    }
    finish(c, base, key, bearer, &start)
        .await
        .map_or_else(|(s, b)| (started, s, b), |b| (started, 200, b))
}

/// The finish half of [`enrol`] for an opened ceremony.
async fn finish(
    c: &reqwest::Client,
    base: &str,
    key: &mut SoftKey,
    bearer: Option<&str>,
    start: &serde_json::Value,
) -> Result<serde_json::Value, (u16, serde_json::Value)> {
    let (client_data, attestation) = key.register(
        RP_ID,
        ORIGIN,
        start["publicKey"]["challenge"].as_str().unwrap(),
        true,
    );
    let mut rq = c
        .post(format!("{base}/-/auth/passkey/register/finish"))
        .json(&serde_json::json!({ "ceremony_id": start["ceremony_id"], "id": key.credential_id_b64(), "response": { "clientDataJSON": client_data, "attestationObject": attestation } }));
    if let Some(b) = bearer {
        rq = rq.bearer_auth(b);
    }
    let r = rq.send().await.expect("the door answers");
    let status = r.status().as_u16();
    let body: serde_json::Value = r.json().await.unwrap_or_default();
    if status == 200 {
        Ok(body)
    } else {
        Err((status, body))
    }
}

/// ★ **RA1-2: a first-key link is dead once the first admin enrolled another way.** The box's
/// bearer mints the first-key link and the person opens it (a ceremony in flight); meanwhile
/// the bearer enrols an admin passkey itself. The link's ceremony is refused at its finish by
/// the door's own sentence, the link is spent (a new start is the dead-invite 401), and the box
/// keeps one admin key: the bootstrap credential made no second admin around the break-glass
/// retirement.
#[tokio::test(flavor = "multi_thread")]
async fn a_first_key_link_is_dead_once_an_admin_enrolled_another_way() {
    let r = start(false);
    up(&r).await;
    let minted = r
        .cli(Some("console"), &["first-key", "--display", "anna"])
        .await
        .unwrap();
    let secret = minted.lines().next().unwrap().trim().to_owned();
    let c = reqwest::Client::builder().build().unwrap();
    // The person opens the link: a ceremony in flight.
    let (status, opened) = post(
        &c,
        &r.base,
        "/-/auth/passkey/register/start",
        serde_json::json!({ "invite": secret, "label": "laptop" }),
    )
    .await;
    assert_eq!(
        status, 200,
        "no admin yet: the link opens a ceremony: {opened}"
    );
    // The box's bearer enrols an admin passkey by the admin path.
    let mut boss = SoftKey::new(0x71, b"cred-boss-other-way");
    let (s1, s2, done) = enrol(
        &c,
        &r.base,
        &mut boss,
        Some(CONSOLE),
        serde_json::json!({ "display": "boss", "role": "admin" }),
    )
    .await;
    assert_eq!(
        (s1, s2),
        (200, 200),
        "the bearer enrols the first admin another way: {done}"
    );
    // The link's ceremony finishes after it: refused by name, and the link is spent.
    let mut anna = SoftKey::new(0x72, b"cred-anna-late");
    let late = finish(&c, &r.base, &mut anna, None, &opened).await;
    let (status, body) = late.expect_err("a first-key ceremony after the first admin is refused");
    assert_eq!(status, 409, "{body}");
    assert!(
        body.to_string()
            .contains(holger_server_lib::passkey::FIRST_KEY_ENROLLED),
        "refused by the door's own sentence: {body}"
    );
    let (status, again) = post(
        &c,
        &r.base,
        "/-/auth/passkey/register/start",
        serde_json::json!({ "invite": secret, "label": "laptop" }),
    )
    .await;
    assert_eq!(status, 401, "the link is spent: {again}");
    // The door stays shut for the bearer, by its own code.
    refused(
        "the first key, after the admin",
        r.cli(Some("console"), &["first-key"]).await,
        "holger-auth-5",
    );
}