server/lib/tests/unit_tests.rs
//! Unit tests for holger-server-lib config parsing and wiring.
use holger_server_lib::{read_ron_config, wire_holger};
use std::io::Write;
use tempfile::NamedTempFile;
#[cfg(feature = "testmatrix")]
use traits::test_support::fstatus;
const MINIMAL_CONFIG: &str = r#"(
repositories: [
(
ron_name: "rust-test",
ron_repo_type: "rust",
ron_upstreams: [],
ron_data_dir: Some("/tmp/holger-test-rust-data"),
ron_in: None,
ron_out: Some((
ron_storage_endpoint: "test-storage",
ron_exposed_endpoint: "test-ep",
)),
),
],
exposed_endpoints: [
(
ron_name: "test-ep",
ron_url: "127.0.0.1:50051",
),
],
storage_endpoints: [
(
ron_name: "test-storage",
ron_storage_type: "rocksdb",
ron_path: "/tmp/holger-test-data",
),
],
)"#;
#[test]
fn test_parse_minimal_config() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(MINIMAL_CONFIG.as_bytes()).unwrap();
let holger = read_ron_config(f.path()).unwrap();
assert_eq!(holger.repositories.len(), 1);
assert_eq!(holger.exposed_endpoints.len(), 1);
assert_eq!(holger.storage_endpoints.len(), 1);
assert_eq!(holger.repositories[0].ron_name, "rust-test");
#[cfg(feature = "testmatrix")]
fstatus(
"config",
"parse_minimal_config",
holger.repositories.len() == 1
&& holger.exposed_endpoints.len() == 1
&& holger.storage_endpoints.len() == 1,
&format!(
"{} repos / {} exposed / {} storage; repo[0]={}",
holger.repositories.len(),
holger.exposed_endpoints.len(),
holger.storage_endpoints.len(),
holger.repositories[0].ron_name,
),
);
}
#[test]
fn test_wire_holger() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(MINIMAL_CONFIG.as_bytes()).unwrap();
let mut holger = read_ron_config(f.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
// Route table should be built
let ep = &holger.exposed_endpoints[0];
assert!(ep.aggregated_routes.is_some());
#[cfg(feature = "testmatrix")]
{
let n = ep.aggregated_routes.as_ref().map(|r| r.len()).unwrap_or(0);
fstatus(
"wire_holger",
"routes_aggregated",
ep.aggregated_routes.is_some(),
&format!("exposed endpoint has {n} aggregated route(s)"),
);
}
}
#[test]
fn test_rust_api_fetch() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(MINIMAL_CONFIG.as_bytes()).unwrap();
let mut holger = read_ron_config(f.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
// Fetch non-existent artifact returns None
let id = traits::ArtifactId {
namespace: None,
name: "nonexistent".into(),
version: "0.0.0".into(),
};
let result = holger.fetch("rust-test", &id).unwrap();
assert!(result.is_none());
#[cfg(feature = "testmatrix")]
fstatus(
"rust_api",
"fetch_missing_is_none",
result.is_none(),
&format!(
"fetch(rust-test, nonexistent) -> {:?}",
result.as_ref().map(|b| b.len())
),
);
}
#[test]
fn test_list_repositories() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(MINIMAL_CONFIG.as_bytes()).unwrap();
let mut holger = read_ron_config(f.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
let repos = holger.list_repositories();
assert_eq!(repos, vec!["rust-test"]);
#[cfg(feature = "testmatrix")]
fstatus(
"rust_api",
"list_repositories",
repos == vec!["rust-test"],
&format!("list_repositories() = {repos:?}"),
);
}
#[test]
fn test_fetch_unknown_repo_errors() {
let mut f = NamedTempFile::new().unwrap();
f.write_all(MINIMAL_CONFIG.as_bytes()).unwrap();
let mut holger = read_ron_config(f.path()).unwrap();
holger.instantiate_backends().unwrap();
wire_holger(&mut holger).unwrap();
let id = traits::ArtifactId {
namespace: None,
name: "x".into(),
version: "1".into(),
};
let res = holger.fetch("nonexistent-repo", &id);
assert!(res.is_err());
#[cfg(feature = "testmatrix")]
fstatus(
"rust_api",
"fetch_unknown_repo_errors",
res.is_err(),
&format!("fetch(nonexistent-repo) is_err = {}", res.is_err()),
);
}
#[cfg(feature = "fmt-maven")] // stinky 1.4: the maven backend is optional
#[test]
fn test_maven_config() {
let config = r#"(
repositories: [
(
ron_name: "maven-prod",
ron_repo_type: "maven3",
ron_upstreams: [],
ron_archive_path: Some("/tmp/nonexistent.znippy"),
ron_in: None,
ron_out: Some((
ron_storage_endpoint: "storage",
ron_exposed_endpoint: "grpc-ep",
)),
),
],
exposed_endpoints: [
(
ron_name: "grpc-ep",
ron_url: "0.0.0.0:50051",
),
],
storage_endpoints: [
(
ron_name: "storage",
ron_storage_type: "znippy",
ron_path: "/tmp/holger-maven",
),
],
)"#;
let mut f = NamedTempFile::new().unwrap();
f.write_all(config.as_bytes()).unwrap();
let mut holger = read_ron_config(f.path()).unwrap();
// instantiate_backends will fail because archive doesn't exist
let result = holger.instantiate_backends();
assert!(result.is_err());
#[cfg(feature = "testmatrix")]
fstatus(
"config",
"maven_missing_archive_errors",
result.is_err(),
&format!("instantiate_backends() is_err = {}", result.is_err()),
);
}
/// Proof that the COMMITTED generated proto code (`src/generated/holger.v1.rs`,
/// produced by `cargo xtask gen-proto` via protox + tonic-build, no `protoc`) is
/// real, usable gRPC code — not a stub. We construct nested messages, then encode
/// and decode them through prost to round-trip the field values. If the generated
/// module were missing or a stub, this would not compile / not round-trip.
///
/// This is the inject-assert guard for the no-protoc proto build: green here ⟺
/// `holger-server-lib` builds against the checked-in generated code and the
/// message types behave correctly.
#[test]
fn generated_proto_types_roundtrip() {
use holger_server_lib::grpc::holger_proto::{ArtifactEntry, ArtifactId, ListArtifactsResponse};
use prost::Message;
// Inject real values into the generated message types.
let id = ArtifactId {
namespace: "se.x14".into(),
name: "holger".into(),
version: "0.6.6".into(),
};
let entry = ArtifactEntry {
id: Some(id.clone()),
size_bytes: 4_711,
content_type: "application/octet-stream".into(),
// Explicit presence: this fixture publishes a time and a digest, so
// the round-trip below proves both survive the wire as VALUES; the
// absence case is proved separately by `an_old_peer_sends_neither_field`.
published_unix_ms: Some(1_726_000_000_000),
sha256: Some("a".repeat(64)),
change_token: Some("holger://up/crates|deadbeef".into()),
};
let resp = ListArtifactsResponse {
artifacts: vec![entry],
next_page_token: "page-2".into(),
};
// Round-trip through the prost wire codec (encode → bytes → decode).
let mut buf = Vec::new();
resp.encode(&mut buf)
.expect("encode generated proto message");
assert!(!buf.is_empty(), "encoded proto must produce bytes");
let decoded =
ListArtifactsResponse::decode(buf.as_slice()).expect("decode generated proto message");
// Assert real output: every injected field survives the round-trip.
assert_eq!(decoded.next_page_token, "page-2");
assert_eq!(decoded.artifacts.len(), 1);
let got = &decoded.artifacts[0];
assert_eq!(got.size_bytes, 4_711);
assert_eq!(got.content_type, "application/octet-stream");
let got_id = got
.id
.as_ref()
.expect("artifact id present after round-trip");
assert_eq!(got_id.namespace, "se.x14");
assert_eq!(got_id.name, "holger");
assert_eq!(got_id.version, "0.6.6");
assert_eq!(
got.published_unix_ms,
Some(1_726_000_000_000),
"the time survives as a VALUE"
);
assert_eq!(got.sha256.as_deref(), Some("a".repeat(64).as_str()));
#[cfg(feature = "testmatrix")]
fstatus(
"grpc",
"generated_proto_types_roundtrip",
got_id.version == "0.6.6",
&format!(
"encoded {} bytes; round-tripped ArtifactId {{ ns={}, name={}, ver={} }}",
buf.len(),
got_id.namespace,
got_id.name,
got_id.version
),
);
}
/// ★ **WIRE COMPATIBILITY, both directions, as bytes.**
///
/// `published_unix_ms` and `sha256` are proto3 `optional` — explicit field
/// presence — and this is the test that the word is doing its job rather than
/// being decoration.
///
/// 1. **A new server answering an OLD client.** An entry that knows neither
/// field encodes to bytes that are IDENTICAL to what a build made before
/// these fields existed would have produced: an absent field takes no bytes
/// on the wire, so the old client sees exactly the message it always saw.
/// The two encodings are compared byte for byte.
/// 2. **An OLD server answering a NEW client.** Those same bytes — a message
/// with no field 4 and no field 5 — decode as `None` and `None`, which is
/// "this peer does not know", not "published at the epoch with an empty
/// hash". Without presence a proto3 scalar could not say the difference,
/// and every listing from every old server would have read as a real
/// answer of 1970 and `""` — a fabricated value that a mirror compares
/// against and a front page sorts by.
///
/// This is the same reasoning that made `skip_digest_verification` INVERTED on
/// the wire, so that the proto3 zero is the safe state. Here the safe state is
/// "unknown" and presence lets it say so outright instead of by inversion.
#[test]
fn an_old_peer_and_a_new_peer_read_each_other_without_inventing_a_value() {
use holger_server_lib::grpc::holger_proto::{ArtifactEntry, ArtifactId};
use prost::Message;
let id = ArtifactId {
namespace: String::new(),
name: "serde".into(),
version: "1.0.0".into(),
};
// What a backend that cannot answer publishes today.
let unknown = ArtifactEntry {
id: Some(id.clone()),
size_bytes: 12,
content_type: "application/octet-stream".into(),
published_unix_ms: None,
sha256: None,
change_token: None,
};
let mut new_bytes = Vec::new();
unknown.encode(&mut new_bytes).expect("encode");
// What a build from before these fields existed puts on the wire for the
// same artifact: tags 1..3 and nothing else. Hand-encoded from the three
// fields so this does not depend on the new type to describe the old one.
let mut old_bytes = Vec::new();
{
let mut id_buf = Vec::new();
id.encode(&mut id_buf).expect("encode id");
prost::encoding::bytes::encode(1, &id_buf, &mut old_bytes);
prost::encoding::int64::encode(2, &12i64, &mut old_bytes);
prost::encoding::string::encode(3, &"application/octet-stream".to_string(), &mut old_bytes);
}
assert_eq!(
new_bytes, old_bytes,
"an unknown must take NO bytes on the wire — an old client has to see the message it \
has always seen"
);
// …and the old peer's bytes read back as unknown, not as zero values.
let decoded = ArtifactEntry::decode(old_bytes.as_slice()).expect("decode old-shape bytes");
assert_eq!(
decoded.size_bytes, 12,
"the fields it does send still arrive"
);
assert_eq!(
decoded.published_unix_ms, None,
"an old server must read as 'does not know', never as 1970-01-01"
);
assert_eq!(
decoded.sha256, None,
"an old server must read as 'does not know', never as an empty digest — an empty \
digest compares EQUAL to another empty digest, which is how a mirror would call two \
different artifacts unchanged"
);
assert_eq!(
decoded.change_token, None,
"and never as an empty change token, for the same reason and with the same \
consequence: two empty tokens compare equal, and the mirror would then call a \
whole znippy-backed repository unchanged without ever having compared anything"
);
// The inverse direction: a new server's extra fields are simply unknown
// fields to an old client, which proto3 drops. Proved by decoding a FULL
// message with the old shape's own three fields still intact.
let full = ArtifactEntry {
id: Some(id),
size_bytes: 12,
content_type: "application/octet-stream".into(),
published_unix_ms: Some(1_726_000_000_000),
sha256: Some("c".repeat(64)),
change_token: Some("holger://up/crates|feedface".into()),
};
let mut full_bytes = Vec::new();
full.encode(&mut full_bytes).expect("encode");
assert!(
full_bytes.len() > old_bytes.len(),
"the values are on the wire when they are known"
);
assert!(
full_bytes.starts_with(&old_bytes),
"the fields an old client reads are unchanged and in the same place; the new ones are \
appended, and an old client drops what it does not know"
);
}