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/read_only_serving.rs

//! `operation_mode: ReadOnly` refuses every network mutation before the body is
//! collected, while reads and health probes still serve. A ReadWrite control on
//! the same config shape proves the mode is what blocks the write.

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

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,
    base: 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,
        base: format!("http://127.0.0.1:{http}"),
    }
}

async fn wait_healthy(c: &reqwest::Client, base: &str) {
    for _ in 0..100 {
        if let Ok(r) = c.get(format!("{base}/healthz")).send().await {
            if r.status() == 200 {
                return;
            }
        }
        tokio::time::sleep(std::time::Duration::from_millis(20)).await;
    }
    panic!("gateway never became healthy at {base}");
}

#[tokio::test(flavor = "multi_thread")]
async fn read_only_blocks_writes_but_serves_reads() {
    let r = start("ReadOnly", hold_port(), hold_port());
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &r.base).await;

    // Reads / probes still serve.
    let health = c.get(format!("{}/healthz", r.base)).send().await.unwrap();
    assert_eq!(
        health.status(),
        200,
        "health probe must stay open in read-only"
    );

    // A GET of a missing artifact is a normal read path (404), NOT a 403.
    let get = c
        .get(format!("{}/raw/missing.bin", r.base))
        .send()
        .await
        .unwrap();
    assert_ne!(get.status(), 403, "reads must not be blocked in read-only");

    // A PUT is refused with 403 BEFORE any body handling.
    let put = c
        .put(format!("{}/raw/evil.bin", r.base))
        .body("should never be stored")
        .send()
        .await
        .unwrap();
    assert_eq!(put.status(), 403, "read-only must reject a PUT");
    let body = put.text().await.unwrap();
    assert!(
        body.contains("read-only"),
        "403 body should say read-only, got: {body}"
    );

    // DELETE is refused too.
    let del = c
        .delete(format!("{}/raw/whatever.bin", r.base))
        .send()
        .await
        .unwrap();
    assert_eq!(del.status(), 403, "read-only must reject a DELETE");
}

#[tokio::test(flavor = "multi_thread")]
async fn read_write_control_allows_the_write() {
    // Same config shape, ReadWrite: the identical PUT is NOT 403 — proving the
    // read-only mode (not something else) is what blocked it above.
    let r = start("ReadWrite", hold_port(), hold_port());
    let c = tls_roots(reqwest::Client::builder())
        .build()
        .expect("reqwest client");
    wait_healthy(&c, &r.base).await;
    let put = c
        .put(format!("{}/raw/ok.bin", r.base))
        .body("stored under read-write")
        .send()
        .await
        .unwrap();
    assert_ne!(
        put.status(),
        403,
        "read-write mode must not blanket-403 a write (got {})",
        put.status()
    );
}

/// reqwest 0.13 with reqwest 0.12 `rustls-tls` behaviour (2026-10-02): the ring
/// provider and the bundled Mozilla roots (webpki), not 0.13's aws-lc-rs + OS
/// trust store. Roots added later on the builder are merged in, as in 0.12.
#[allow(dead_code)]
fn tls_roots(b: reqwest::ClientBuilder) -> reqwest::ClientBuilder {
    let _ = rustls::crypto::ring::default_provider().install_default();
    b.tls_certs_only(
        webpki_root_certs::TLS_SERVER_ROOT_CERTS
            .iter()
            .filter_map(|c| reqwest::Certificate::from_der(c).ok()),
    )
}