Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger-ui

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;
    }
}