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