crates/holger-ui-server/tests/public_site_switch.rs
//! ★ **The administrator's switch for holger.rs's public face** (owner decision 2026-10-26,
//! item 5: "a switch in admin, account admin only, stored as a generic server setting,
//! audited"). Its own test binary: the site and its switch are one per process.
//!
//! A default build has no switch: `/api/public-site` answers as a path this server never
//! routed. A `public-site` build flips it only where holger keeps it: the flip is written to
//! holger's generic setting `console.public-site` with the asker's OWN bearer, so holger refuses
//! anyone but an administrator (and audits who did it); a refused flip changes nothing. At
//! start the console takes the stored position, asked with its own bearer. holger is a stand-in
//! on loopback here, with holger's door's shapes (`/-/settings/<name>`, server/lib
//! `exposed::http`).
use axum::body::Body;
use holger_ui_server::{router, Upstreams};
use http::{Request, StatusCode};
async fn call(app: &axum::Router, method: &str, path: &str, bearer: Option<&str>, body: &str) -> (StatusCode, String) {
use tower::ServiceExt;
let mut req = Request::builder().method(method).uri(path).header("host", "holger.example");
if let Some(b) = bearer {
req = req.header("authorization", format!("Bearer {b}"));
}
let r = app.clone().oneshot(req.body(Body::from(body.to_owned())).unwrap()).await.unwrap();
let status = r.status();
let bytes = axum::body::to_bytes(r.into_body(), 1 << 20).await.unwrap();
(status, String::from_utf8_lossy(&bytes).into_owned())
}
/// ★ The default build has no switch at all.
#[cfg(not(feature = "public-site"))]
#[tokio::test]
async fn the_default_build_has_no_switch() {
let app = router(None, Upstreams::new("127.0.0.1:9", "127.0.0.1:9", None));
for method in ["GET", "PUT"] {
let answer = call(&app, method, "/api/public-site", Some("admin-token"), r#"{"on":true}"#).await;
let never = call(&app, method, "/api/no-such-route", Some("admin-token"), r#"{"on":true}"#).await;
assert_eq!(answer, never, "{method} /api/public-site is routed in the default build");
assert!(!answer.1.contains("\"on\""), "{answer:?}");
}
}
#[cfg(feature = "public-site")]
mod served {
use super::*;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use std::time::Duration;
const ADMIN: &str = "admin-token";
const READER: &str = "reader-token";
const CONSOLE: &str = "console-token";
/// holger's setting door, stood in with the real door's rules: an administrator's OWN
/// credential (`authorization`) reads and writes; the console's bearer is only ever a
/// PROOF (`x-holger-proxy-authorization`), which reads and never writes, and which is
/// refused as a credential (RA2-2). Every write is written down as holger audits it.
/// `damaged` makes every read answer as holger does over a damaged settings file.
#[derive(Clone, Default)]
struct Holger {
settings: Arc<Mutex<BTreeMap<String, Vec<u8>>>>,
audit: Arc<Mutex<Vec<String>>>,
credentials_seen: Arc<Mutex<Vec<String>>>,
damaged: Arc<std::sync::atomic::AtomicBool>,
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
async fn holger(h: Holger) -> String {
use axum::extract::{Path, State};
use axum::http::HeaderMap;
let door = axum::routing::get(|State(h): State<Holger>, Path(name): Path<String>, headers: HeaderMap| async move {
h.seen(&headers);
if h.damaged.load(std::sync::atomic::Ordering::SeqCst) {
return (StatusCode::SERVICE_UNAVAILABLE, "{\"error\":\"[settings-damaged]\",\"damaged\":true}".to_owned());
}
match credential(&headers).or_else(|| proof(&headers)) {
Some(_) => {
let v = h.settings.lock().unwrap().get(&name).cloned();
(StatusCode::OK, format!("{{\"present\":{},\"value_hex\":\"{}\"}}", v.is_some(), hex(&v.unwrap_or_default())))
}
None => (StatusCode::UNAUTHORIZED, "{\"error\":\"only an administrator\"}".to_owned()),
}
})
.put(|State(h): State<Holger>, Path(name): Path<String>, headers: HeaderMap, body: axum::body::Bytes| async move {
h.seen(&headers);
match credential(&headers) {
Some(by) => {
let old = h.settings.lock().unwrap().insert(name.clone(), body.to_vec());
h.audit.lock().unwrap().push(format!("{by}: setting {name}: {:?} -> {:?}", old.map(|o| String::from_utf8_lossy(&o).into_owned()), String::from_utf8_lossy(&body)));
(StatusCode::OK, "{\"had\":false,\"previous_hex\":\"\"}".to_owned())
}
None => {
h.audit.lock().unwrap().push(format!("refused: setting {name}"));
(StatusCode::UNAUTHORIZED, "{\"error\":\"only an administrator\"}".to_owned())
}
}
});
let app = axum::Router::new().route("/-/settings/{name}", door).with_state(h);
let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let at = l.local_addr().unwrap();
tokio::spawn(async move { axum::serve(l, app).await });
at.to_string()
}
impl Holger {
fn seen(&self, headers: &axum::http::HeaderMap) {
if let Some(a) = headers.get("authorization").and_then(|v| v.to_str().ok()) {
self.credentials_seen.lock().unwrap().push(a.to_owned());
}
}
}
/// Who the person's own credential is, where they may write: an administrator only. The
/// console's bearer is NOT a credential.
fn credential(headers: &axum::http::HeaderMap) -> Option<&'static str> {
match headers.get("authorization").and_then(|v| v.to_str().ok()) {
Some(a) if a == format!("Bearer {ADMIN}") => Some("root"),
_ => None,
}
}
/// The console's proof, which reads.
fn proof(headers: &axum::http::HeaderMap) -> Option<&'static str> {
match headers.get("x-holger-proxy-authorization").and_then(|v| v.to_str().ok()) {
Some(a) if a == format!("Bearer {CONSOLE}") => Some("console"),
_ => None,
}
}
async fn until(what: &str, mut f: impl FnMut() -> bool) {
for _ in 0..200 {
if f() {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("never: {what}");
}
/// One test, in order: the site and its switch are one per process.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn the_stored_switch_is_the_authority_it_fails_closed_and_only_an_administrator_flips_it() {
assert!(std::env::var(holger_public_site::ENV_SWITCH).is_err(), "the test environment sets the switch");
holger_ui_server::recheck_every(Duration::from_millis(100));
let site = holger_ui_server::public_site();
// ★ RA2-1: holger is not answering yet and the front feeds ON: the switch stays OFF.
let closed = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().to_string();
let _slow = router(None, Upstreams::new("127.0.0.1:9", &closed, Some(CONSOLE.to_owned())));
holger_ui_server::public_site_fed(true);
tokio::time::sleep(Duration::from_millis(700)).await;
assert!(!site.switch().is_on(), "a slow holger let the feed's ON win over the stored position");
// holger answers and stores an administrator's OFF: the switch is OFF, the feed's ON
// does not move it.
let h = Holger::default();
h.settings.lock().unwrap().insert(holger_public_site::setting::NAME.to_owned(), b"off".to_vec());
let door = holger(h.clone()).await;
let app = router(None, Upstreams::new("127.0.0.1:9", &door, Some(CONSOLE.to_owned())));
until("the stored position is read", holger_ui_server::public_site_switch_stored).await;
holger_ui_server::public_site_fed(true);
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(!site.switch().is_on(), "the feed's ON undid an administrator's OFF");
// ★ A flip made elsewhere (another console, holger directly) is seen.
h.settings.lock().unwrap().insert(holger_public_site::setting::NAME.to_owned(), b"on".to_vec());
until("a flip made at holger is seen", || site.switch().is_on()).await;
// A reader is refused by holger, and nothing moves; nobody signed in neither.
let refused = call(&app, "PUT", "/api/public-site", Some(READER), r#"{"on":false}"#).await;
assert_eq!(refused.0, StatusCode::UNAUTHORIZED, "{refused:?}");
assert!(site.switch().is_on(), "a refused flip changes nothing");
assert!(call(&app, "PUT", "/api/public-site", None, r#"{"on":false}"#).await.0.is_client_error(), "nobody signed in");
assert_eq!(h.settings.lock().unwrap().get(holger_public_site::setting::NAME).map(Vec::as_slice), Some(&b"on"[..]));
// An administrator flips it with their own credential: holger keeps it and audits who.
assert_eq!(call(&app, "PUT", "/api/public-site", Some(ADMIN), r#"{"on":false}"#).await, (StatusCode::OK, r#"{"on":false}"#.to_owned()));
assert!(!site.switch().is_on(), "the flip is applied");
assert_eq!(h.settings.lock().unwrap().get(holger_public_site::setting::NAME).map(Vec::as_slice), Some(&b"off"[..]), "kept at holger");
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(!site.switch().is_on(), "the next read keeps the flip");
assert_eq!(call(&app, "GET", "/api/public-site", None, "").await.1, r#"{"on":false}"#);
let audit = h.audit.lock().unwrap().clone();
assert!(audit.iter().any(|l| l.starts_with("root: setting console.public-site")), "{audit:?}");
assert!(audit.iter().any(|l| l.starts_with("refused")), "the refused flip reached holger's audit: {audit:?}");
assert_eq!(call(&app, "PUT", "/api/public-site", Some(ADMIN), "not json").await.0, StatusCode::BAD_REQUEST);
// ★ RA2-2: the console's bearer was never sent as a credential.
let seen = h.credentials_seen.lock().unwrap().clone();
assert!(!seen.iter().any(|a| a.contains(CONSOLE)), "the console's proof was sent as a credential: {seen:?}");
// ★ RA2-1/RA2-10: holger's settings are damaged: the switch fails closed.
h.settings.lock().unwrap().insert(holger_public_site::setting::NAME.to_owned(), b"on".to_vec());
until("the stored ON is read", || site.switch().is_on()).await;
h.damaged.store(true, std::sync::atomic::Ordering::SeqCst);
until("a damaged store turns the switch off", || !site.switch().is_on()).await;
// Nothing stored at holger: the feed's position is the initial one.
h.damaged.store(false, std::sync::atomic::Ordering::SeqCst);
h.settings.lock().unwrap().clear();
holger_ui_server::public_site_fed(true);
until("nothing stored: the fed ON", || site.switch().is_on()).await;
holger_ui_server::public_site_fed(false);
until("nothing stored: the fed OFF", || !site.switch().is_on()).await;
}
}