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()),
)
}