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

nordisk/holger

server/lib/tests/grpc_error_codes_on_the_wire.rs

//! **The applied-output guard for SK178** — a REAL remote gRPC client receives holger's
//! frozen error codes.
//!
//! `errcode/tests/wiring_guard.rs` proves the codes are *raised in the source*. That is a
//! source-level claim, and a source-level claim cannot see a code that is built and then
//! dropped on the way out — a metadata key tonic refuses, a `Status` rebuilt by a layer, a
//! message rewritten by an interceptor. The surface that matters is the one a client
//! actually gets.
//!
//! So this starts a real holger over TCP, dials it with the generated tonic client, and
//! asserts on the **received `tonic::Status`**: its `code()` (the tonic code), its
//! `message()` (channel 1 — the bracketed `[holger-…]` a message-only client logs), and its
//! `metadata()` (channel 2 — `x-holger-code`, what a client keys off structurally).
//!
//! ## How to see this go RED (do it before trusting a green)
//!
//! Delete the `coded(&ec::SERVER_REPO_UNKNOWN, …)` wrapper from `HolgerGrpc::get_repo` in
//! `server/lib/src/grpc.rs`, leaving a bare `Status::not_found(...)`. Then
//! `unknown_repository_arrives_coded_on_the_wire` fails on BOTH channels — the message has
//! no `[holger-server-1]` and the metadata has no `x-holger-code`. Removing the metadata
//! insert alone (keeping `ec.tag`) fails only the metadata assertion, which is why the two
//! channels are asserted separately rather than through one `parse()` helper.

use std::io::Write;
use traits::test_support::hold_port;

use holger_errcode as ec;
use holger_server_lib::grpc::holger_proto::repository_service_client::RepositoryServiceClient;
use holger_server_lib::grpc::holger_proto::{ArtifactId, FetchArtifactRequest, PutArtifactRequest};
use holger_server_lib::{read_ron_config, wire_holger, Holger};

fn config(mode: &str, data: &str, grpc: u16, http: u16) -> String {
    format!(
        r#"(
    operation_mode: {mode},
    repositories: [
        (
            ron_name: "raw",
            ron_repo_type: "raw",
            ron_upstreams: [],
            ron_in: None,
            ron_out: Some((ron_storage_endpoint: "storage", ron_exposed_endpoint: "default")),
            ron_data_dir: Some("{data}"),
        ),
    ],
    exposed_endpoints: [
        (ron_name: "default", 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: "{data}"),
    ],
)"#
    )
}

struct Running {
    _data: tempfile::TempDir,
    _cfg: tempfile::NamedTempFile,
    grpc: String,
}

fn start(mode: &str, grpc: u16, http: u16) -> Running {
    let data = tempfile::tempdir().unwrap();
    let ron = config(mode, &data.path().display().to_string(), grpc, http);
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
    holger.instantiate_backends().unwrap();
    wire_holger(&mut holger).unwrap();
    holger.start().unwrap();
    Running {
        _data: data,
        _cfg: cfg,
        grpc: format!("http://127.0.0.1:{grpc}"),
    }
}

/// Dial the real gRPC port, retrying while the listener comes up.
async fn dial(url: &str) -> RepositoryServiceClient<tonic::transport::Channel> {
    for _ in 0..100 {
        if let Ok(c) = RepositoryServiceClient::connect(url.to_string()).await {
            return c;
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("gRPC never came up at {url}");
}

/// **Assert BOTH wire channels of a received `Status`.**
///
/// Deliberately two independent assertions and NOT `errcode::parse(status.message())`:
/// `parse` would pass on the message alone, so a dropped metadata header — a real
/// failure mode, tonic rejects non-ASCII metadata values — would come back green.
#[track_caller]
fn assert_wire_code(
    st: &tonic::Status,
    expect: &'static ec::ErrCode,
    expect_grpc: tonic::Code,
    what: &str,
) {
    assert_eq!(
        st.code(),
        expect_grpc,
        "{what}: tonic code (message was {:?})",
        st.message()
    );
    // Channel 1 — in the message, bracketed, so `holger-update-1` can never be recovered
    // from a `holger-update-10` message.
    assert!(
        st.message().contains(&format!("[{}]", expect.code)),
        "{what}: the received Status MESSAGE carries no [{}] — a client that only logs \
         status.message() records no code. Got: {:?}",
        expect.code,
        st.message(),
    );
    // Channel 2 — in the metadata, so a client reacts structurally without parsing prose.
    let got = st
        .metadata()
        .get(ec::WIRE_CODE_KEY)
        .map(|v| v.to_str().expect("ascii").to_string());
    assert_eq!(
        got.as_deref(),
        Some(expect.code),
        "{what}: the received Status METADATA has no {}={} — got {:?}",
        ec::WIRE_CODE_KEY,
        expect.code,
        got,
    );
    // The code must be the registry's, not a look-alike string built at the call site.
    assert!(
        ec::by_code(expect.code).is_some(),
        "{} is not in holger_errcode::ALL",
        expect.code
    );
}

/// An unknown repository ⇒ `holger-server-1` on the wire. This is `get_repo`, the single
/// most-travelled refusal on the whole surface.
#[tokio::test(flavor = "multi_thread")]
async fn unknown_repository_arrives_coded_on_the_wire() {
    let r = start("ReadWrite", hold_port(), hold_port());
    let mut c = dial(&r.grpc).await;

    let st = c
        .fetch_artifact(FetchArtifactRequest {
            repository: "no-such-repo".into(),
            id: Some(ArtifactId {
                namespace: String::new(),
                name: "x".into(),
                version: "1".into(),
            }),
        })
        .await
        .expect_err("an unknown repository must be refused");

    assert_wire_code(
        &st,
        &ec::SERVER_REPO_UNKNOWN,
        tonic::Code::NotFound,
        "fetch_artifact on an unknown repository",
    );
}

/// A known repository but a MISSING artifact ⇒ `holger-server-2`. Paired with the test
/// above on purpose: the two refusals share one tonic code (`NotFound`) and are told apart
/// ONLY by the holger code — which is the entire argument for having one.
#[tokio::test(flavor = "multi_thread")]
async fn missing_artifact_is_a_different_code_under_the_same_tonic_code() {
    let r = start("ReadWrite", hold_port(), hold_port());
    let mut c = dial(&r.grpc).await;

    let st = c
        .fetch_artifact(FetchArtifactRequest {
            repository: "raw".into(),
            id: Some(ArtifactId {
                namespace: String::new(),
                name: "definitely-absent".into(),
                version: "9.9.9".into(),
            }),
        })
        .await
        .expect_err("a missing artifact must be refused");

    assert_wire_code(
        &st,
        &ec::SERVER_ARTIFACT_UNKNOWN,
        tonic::Code::NotFound,
        "fetch_artifact on a missing artifact",
    );
    assert_ne!(
        ec::SERVER_ARTIFACT_UNKNOWN.code,
        ec::SERVER_REPO_UNKNOWN.code,
        "'no such repo' and 'no such artifact' must not collapse into one code — both are \
         tonic NotFound, so the holger code is the only thing that separates them"
    );
}

/// A request with no `ArtifactId` at all ⇒ `holger-server-3` (malformed request).
#[tokio::test(flavor = "multi_thread")]
async fn a_malformed_request_arrives_coded_on_the_wire() {
    let r = start("ReadWrite", hold_port(), hold_port());
    let mut c = dial(&r.grpc).await;

    let st = c
        .fetch_artifact(FetchArtifactRequest {
            repository: "raw".into(),
            id: None,
        })
        .await
        .expect_err("a request with no artifact id must be refused");

    assert_wire_code(
        &st,
        &ec::SERVER_BAD_REQUEST,
        tonic::Code::InvalidArgument,
        "fetch_artifact with id: None",
    );
}

/// `operation_mode: ReadOnly` refuses a write ⇒ `holger-server-6`, from the
/// `authorize_write` gate, BEFORE the body is looked at.
#[tokio::test(flavor = "multi_thread")]
async fn read_only_mode_refuses_a_write_with_its_code_on_the_wire() {
    let r = start("ReadOnly", hold_port(), hold_port());
    let mut c = dial(&r.grpc).await;

    let st = c
        .put_artifact(PutArtifactRequest {
            repository: "raw".into(),
            id: Some(ArtifactId {
                namespace: String::new(),
                name: "evil".into(),
                version: "1".into(),
            }),
            data: b"should never be stored".to_vec().into(),
            async_job: false,
        })
        .await
        .expect_err("read-only must refuse a write");

    assert_wire_code(
        &st,
        &ec::SERVER_READ_ONLY,
        tonic::Code::PermissionDenied,
        "put_artifact under operation_mode: ReadOnly",
    );
}

/// The control for the test above: under `ReadWrite` the SAME put is not refused with
/// `holger-server-6`. Without this, a server that answered `holger-server-6` to everything
/// would pass — the identity-value trap.
#[tokio::test(flavor = "multi_thread")]
async fn read_write_mode_does_not_raise_the_read_only_code() {
    let r = start("ReadWrite", hold_port(), hold_port());
    let mut c = dial(&r.grpc).await;

    let res = c
        .put_artifact(PutArtifactRequest {
            repository: "raw".into(),
            id: Some(ArtifactId {
                namespace: String::new(),
                name: "ok".into(),
                version: "1".into(),
            }),
            data: b"stored under read-write".to_vec().into(),
            async_job: false,
        })
        .await;

    if let Err(st) = res {
        assert!(
            !st.message().contains(ec::SERVER_READ_ONLY.code),
            "a ReadWrite server must not raise {} — got {:?}",
            ec::SERVER_READ_ONLY.code,
            st.message()
        );
        assert_ne!(
            st.metadata()
                .get(ec::WIRE_CODE_KEY)
                .and_then(|v| v.to_str().ok()),
            Some(ec::SERVER_READ_ONLY.code),
            "a ReadWrite server must not put {} in the wire metadata",
            ec::SERVER_READ_ONLY.code,
        );
    }
}

/// `PromoteArtifact` against a source that holds nothing ⇒ `holger-promote-7` on the wire.
///
/// Exercises a SECOND service (`PromotionService`), proving the helper is on the whole
/// surface and not just `RepositoryService`, and it pins the cross-surface row: the local
/// `holger-server promote` verb raises this same registry entry, so one verdict has one code
/// whether it comes from the CLI or the RPC.
///
/// MEASURED, not assumed: this config DOES get a promotion engine (holger builds a default
/// one), so `holger-server-9` is not reachable here — the first attempt at this test
/// asserted `FailedPrecondition`/`holger-server-9` and got
/// `"[holger-promote-7] artifact 'x@1' not found in source repo 'raw'"`. The three
/// `holger-server-9` sites (promotion / SBOM hosting / custom properties absent) are covered
/// by the source-level wiring guard only; see the honest-coverage note in
/// `.nornir/holger-error-codes.md`.
///
/// `PromotionService` is the `supply-chain` plane since the lean cut (2026-10-17): the
/// default build does not mount it and the RPC answers `Unimplemented`. Gated on the
/// feature; the declared arm in `.nornir/testmatrix-arms.json` runs it.
#[cfg(feature = "supply-chain")]
#[tokio::test(flavor = "multi_thread")]
async fn a_second_service_also_arrives_coded_on_the_wire() {
    use holger_server_lib::grpc::holger_proto::promotion_service_client::PromotionServiceClient;
    use holger_server_lib::grpc::holger_proto::PromoteArtifactRequest;

    let r = start("ReadWrite", hold_port(), hold_port());
    // Reuse the repository client's readiness wait, then dial the promotion service.
    let _ = dial(&r.grpc).await;
    let mut c = PromotionServiceClient::connect(r.grpc.clone())
        .await
        .unwrap();

    let st = c
        .promote_artifact(PromoteArtifactRequest {
            from_repo: "raw".into(),
            to_repo: "raw".into(),
            artifact: Some(ArtifactId {
                namespace: String::new(),
                name: "x".into(),
                version: "1".into(),
            }),
            ..Default::default()
        })
        .await
        .expect_err("the source repo holds no such artifact, so this must be refused");

    assert_wire_code(
        &st,
        &ec::PROMOTE_SOURCE_NOT_FOUND,
        tonic::Code::NotFound,
        "promote_artifact with an absent source artifact",
    );
}

/// **A call that fails in the BACKEND arrives coded and says nothing of the backend**
/// (lane C3). `EstateService.Fetch` of an estate path that is a directory on this box
/// makes the read fail with an IO error (`Is a directory (os error 21)`), which is
/// exactly the kind of text that used to go out raw: the handler answered
/// `.map_err(Status::not_found)` with the `estate_wire` error string, no code on either
/// channel. The client must get a stable code and its own path back, and no errno, no
/// OS wording and no part of this box's data-root path.
#[tokio::test(flavor = "multi_thread")]
async fn a_backend_failure_arrives_coded_with_no_backend_detail() {
    use holger_server_lib::grpc::holger_estate_proto::estate_service_client::EstateServiceClient;
    use holger_server_lib::grpc::holger_estate_proto::FetchRequest;

    let (grpc, http) = (hold_port(), hold_port());
    let data = tempfile::tempdir().unwrap();
    let root = data.path().display().to_string();
    // The audit dir anchors the data root (`Holger::data_root` is its parent), so the
    // estate this server serves is `data`.
    let ron = config("ReadWrite", &root, grpc, http).replacen(
        "operation_mode: ReadWrite,",
        &format!(
            "operation_mode: ReadWrite,\n    audit: (dir: \"{root}/holger-audit\", enabled: true),"
        ),
        1,
    );
    // A directory where the estate names a file: the read below fails in the backend.
    std::fs::create_dir_all(data.path().join("holger-props/broken")).unwrap();
    let mut cfg = tempfile::NamedTempFile::new().unwrap();
    cfg.write_all(ron.as_bytes()).unwrap();
    let mut holger: Holger = read_ron_config(cfg.path()).unwrap();
    holger.instantiate_backends().unwrap();
    wire_holger(&mut holger).unwrap();
    holger.start().unwrap();
    let url = format!("http://127.0.0.1:{grpc}");
    let _ = dial(&url).await;
    let mut c = EstateServiceClient::connect(url).await.unwrap();

    let st = c
        .fetch(FetchRequest {
            path: "holger-props/broken".into(),
            offset: 0,
            max_bytes: 0,
        })
        .await
        .expect_err("reading a directory as an estate file fails in the backend");

    assert_wire_code(
        &st,
        &ec::TWIN_ESTATE_FILE_UNKNOWN,
        tonic::Code::NotFound,
        "estate fetch of a path the backend cannot read",
    );
    let msg = st.message();
    assert!(!msg.contains("os error"), "no errno may leak: {msg}");
    assert!(
        !msg.to_lowercase().contains("is a directory"),
        "no OS wording may leak: {msg}"
    );
    assert!(
        !msg.contains(&root),
        "no part of the data root may leak: {msg}"
    );
    assert!(
        msg.contains("holger-props/broken"),
        "the caller's own path is named back: {msg}"
    );
}