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}"
);
}