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

nordisk/gunnar-ui

crates/gunnar-ui-server/src/identity_core.rs

//! **The console relays the ONE identity core** (IDENTITY-HOST, 2026-10-27;
//! A34-18/19/21): login, step-up, logout, an invite's redemption and the
//! first admin's break-glass passkey are `nornir.identity.v1.IdentityService`,
//! served by gunnar. The console keeps no session store, no ceremony store and
//! no passkey table: its cookie carries the core's opaque session token, and
//! the core says who that is on every request.
//!
//! Tests only. The fake gunnar here is edda's real core (`IdentityGrpc` over
//! `TestHost`), so every ceremony is checked by the code gunnar runs, and a
//! soft passkey answers the browser's half byte for byte.

use std::sync::Arc;

use base64::Engine as _;
use nornir_identity::grpc::IdentityGrpc;
use nornir_identity::test_host::{Kit, SoftPasskey, TestHost, TEST_ADMINS};
use nornir_identity::{Ceremony, Challenge, Config, Identity, Site, User, UserState};
use tower::ServiceExt as _;

use crate::api::mirror_gate::Imports;

const RP_ID: &str = "console.example";
const ORIGIN: &str = "https://console.example";
const BREAK_GLASS: &str = "break-glass-token-of-the-installer-0001";
const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;

type Core = Identity<TestHost>;

fn console_signer() -> crate::signer::Signer {
    crate::signer::Signer::new(ssh_key::PrivateKey::from(ssh_key::private::Ed25519Keypair::from_seed(&[83u8; 32])))
        .expect("a fixed seed makes a signer")
}

pub(crate) struct Rig {
    router: axum::Router,
    pub(crate) core: Arc<Core>,
    /// **gunnar is restarting** while set (GUN-SESSION-RESTART): every call is answered
    /// UNAVAILABLE, as a booting main (or a front still dialling it) answers.
    pub(crate) down: Arc<std::sync::atomic::AtomicBool>,
}

/// **A gunnar whose identity core does not end sessions** (RA5-11): every `Logout` is
/// answered UNAVAILABLE, as an unreachable core is, when the flag is set; and every call
/// while the switch is on (a restart). Every other verb is the core's.
#[derive(Clone)]
struct RefuseLogout<S>(S, bool, Arc<std::sync::atomic::AtomicBool>);

impl<S, B> tower::Service<http::Request<B>> for RefuseLogout<S>
where
    S: tower::Service<http::Request<B>, Response = http::Response<tonic::body::Body>> + Send + 'static,
    S::Future: Send + 'static,
{
    type Response = http::Response<tonic::body::Body>;
    type Error = S::Error;
    type Future = std::pin::Pin<Box<dyn std::future::Future<Output = Result<Self::Response, S::Error>> + Send>>;

    fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> std::task::Poll<Result<(), S::Error>> {
        self.0.poll_ready(cx)
    }

    fn call(&mut self, request: http::Request<B>) -> Self::Future {
        if self.2.load(std::sync::atomic::Ordering::SeqCst) {
            return Box::pin(async { Ok(tonic::Status::unavailable("gunnar is restarting").into_http()) });
        }
        if self.1 && request.uri().path().ends_with("/Logout") {
            return Box::pin(async { Ok(tonic::Status::unavailable("the identity core is not answering").into_http()) });
        }
        Box::pin(self.0.call(request))
    }
}

/// The console over a gunnar that serves the identity core (and nothing else).
pub(crate) async fn console() -> Rig {
    console_with(false).await
}

/// [`console`], over a core that answers no `Logout` when `refuse_logout`.
pub(crate) async fn console_with(refuse_logout: bool) -> Rig {
    console_on(refuse_logout, None).await
}

/// **gunnar's word on the break-glass token** (`Bootstrap.Status.break_glass_open`, RA5-14).
#[derive(Clone)]
struct BreakGlass(Option<bool>);

#[tonic::async_trait]
impl crate::pb::bootstrap_server::Bootstrap for BreakGlass {
    async fn status(
        &self,
        _: tonic::Request<crate::pb::BootstrapStatusRequest>,
    ) -> Result<tonic::Response<crate::pb::BootstrapStatusResponse>, tonic::Status> {
        Ok(tonic::Response::new(crate::pb::BootstrapStatusResponse { break_glass_open: self.0, ..Default::default() }))
    }
}

/// [`console_with`], and a gunnar that also serves `Bootstrap.Status` answering
/// `break_glass` when it is `Some`.
pub(crate) async fn console_on(refuse_logout: bool, break_glass: Option<Option<bool>>) -> Rig {
    let kit = Kit::default();
    let config = Config {
        parties: vec![format!("{RP_ID}={ORIGIN}").parse().expect("the relying party")],
        ..Config::default()
    };
    let core = Arc::new(Identity::new(TestHost::new(Site::Private, &kit), config));
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
    let addr = listener.local_addr().expect("its address");
    let serving = IdentityGrpc::new(Arc::clone(&core)).into_server();
    let down = Arc::new(std::sync::atomic::AtomicBool::new(false));
    let switch = Arc::clone(&down);
    tokio::spawn(async move {
        let incoming = tokio_stream::wrappers::TcpListenerStream::new(listener);
        let bootstrap = break_glass.map(|open| crate::pb::bootstrap_server::BootstrapServer::new(BreakGlass(open)));
        tonic::transport::Server::builder()
            .layer(tower::layer::layer_fn(move |inner| RefuseLogout(inner, refuse_logout, Arc::clone(&switch))))
            .add_service(serving)
            .add_optional_service(bootstrap)
            .serve_with_incoming(incoming)
            .await
    });
    let dir = tempfile::tempdir().expect("a temp dir").keep();
    let store = crate::state::StateStore::open(dir.join("state.redb")).expect("a store");
    let webauthn = Arc::new(
        crate::auth::Webauthn::new(
            crate::auth::RelyingParty { rp_id: RP_ID.to_owned(), origin: ORIGIN.to_owned(), rp_name: "gunnar".to_owned() },
            store.clone(),
        )
        .expect("a subsystem"),
    );
    let endpoint = format!("http://{addr}");
    let state: crate::app::AppState = Arc::new(crate::app::App {
        webauthn: Some(webauthn.clone()),
        admin_token: Some(BREAK_GLASS.to_owned()),
        #[cfg(feature = "metered")]
        monetize: None,
        events: crate::sse::EventHub::detached(),
        gunnar: crate::grpc::Gunnar::connect_lazy(&endpoint, Arc::new(console_signer())).expect("a handle"),
        twin: None,
        guest: None,
        reviewers: Arc::new(crate::reviewer::ReviewerKeys::new(endpoint.clone(), store.clone())),
        tenants: Arc::new(crate::tenant::TenantKeys::new(endpoint, store.clone())),
        forges: Default::default(),
        imports: Arc::new(Imports::default()),
        forge_apps: Arc::default(),
        ui_dir: None,
        caches: crate::api::Caches::default(),
        objects: Default::default(),
        readmes: Default::default(),
    });
    Rig { router: crate::serve::router(state), core, down }
}

pub(crate) struct Answer {
    pub(crate) status: http::StatusCode,
    pub(crate) cookie: Option<String>,
    pub(crate) cleared: bool,
    pub(crate) body: serde_json::Value,
    pub(crate) text: String,
}

impl Rig {
    pub(crate) async fn send(&self, method: &str, uri: &str, bearer: Option<&str>, cookie: Option<&str>, body: Option<serde_json::Value>) -> Answer {
        let mut request = http::Request::builder()
            .method(method)
            .uri(uri)
            .header(http::header::ORIGIN, ORIGIN)
            .header(http::header::ACCEPT, "application/json");
        if let Some(token) = bearer {
            request = request.header(http::header::AUTHORIZATION, format!("Bearer {token}"));
        }
        if let Some(value) = cookie {
            request = request.header(http::header::COOKIE, format!("{}={value}", crate::auth::SESSION_COOKIE));
        }
        let request = match body {
            Some(json) => request.header(http::header::CONTENT_TYPE, "application/json").body(axum::body::Body::from(json.to_string())),
            None => request.body(axum::body::Body::empty()),
        }
        .expect("a request");
        let response = self.router.clone().oneshot(request).await.expect("the console answers");
        let status = response.status();
        let set: Vec<String> =
            response.headers().get_all(http::header::SET_COOKIE).iter().filter_map(|v| v.to_str().ok()).map(str::to_owned).collect();
        let prefix = format!("{}=", crate::auth::SESSION_COOKIE);
        let cookie = set
            .iter()
            .filter(|v| !v.contains("Max-Age=0"))
            .find_map(|v| v.strip_prefix(&prefix).map(|rest| rest.split(';').next().unwrap_or_default().to_owned()));
        let cleared = set.iter().any(|v| v.starts_with(&prefix) && v.contains("Max-Age=0"));
        let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.expect("a body");
        let text = String::from_utf8_lossy(&bytes).into_owned();
        Answer { status, cookie, cleared, body: serde_json::from_str(&text).unwrap_or(serde_json::Value::Null), text }
    }

    /// A person the core holds, active, with `groups`.
    pub(crate) fn person(&self, id: &str, groups: &[&str]) {
        use nornir_identity::IdentityHost as _;
        self.core
            .host()
            .store()
            .put_user(&User {
                id: id.to_owned(),
                display: id.to_owned(),
                email: None,
                groups: groups.iter().map(|g| (*g).to_owned()).collect(),
                state: UserState::Active,
                created: 0,
            })
            .expect("the test store");
    }

    /// The core's first admin, with a passkey (as the installer's link left them).
    pub(crate) fn first_admin(&self) -> (String, SoftPasskey) {
        let invited = self.core.first_admin_invite(vec![TEST_ADMINS.to_owned()], "installer").expect("the first admin invite");
        let relay = nornir_identity::Relay { rp_id: RP_ID.into(), origin: ORIGIN.into(), client: None };
        let c = self.core.redeem_start(&invited.secret, "Ada", &relay).expect("the ceremony");
        let mut key = SoftPasskey::new(7);
        let ada = self.core.redeem_finish(key.register(&c, ORIGIN)).expect("the first admin's passkey");
        (ada.session.user_id, key)
    }

    /// The login page's `login()`: start, the soft passkey, finish.
    pub(crate) async fn login(&self, key: &mut SoftPasskey, uv: bool) -> Answer {
        let options = self.send("GET", "/auth/passkey/login/start", None, None, None).await;
        assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
        let c = ceremony_of(&options.body);
        let a = key.login(&c, ORIGIN, uv);
        self.send(
            "POST",
            "/auth/passkey/login/finish",
            None,
            None,
            Some(serde_json::json!({
                "ceremony_id": options.body["ceremony_id"],
                "id": B64.encode(&a.credential_id),
                "response": {
                    "clientDataJSON": B64.encode(&a.client_data_json),
                    "authenticatorData": B64.encode(&a.authenticator_data),
                    "signature": B64.encode(&a.signature),
                }
            })),
        )
        .await
    }

    /// The login page's `register()`: `query` names the door (`invite=…`), or
    /// the break-glass `bearer`.
    pub(crate) async fn register(&self, query: &str, bearer: Option<&str>, body_extra: serde_json::Value, key: &mut SoftPasskey) -> Answer {
        let options = self.send("GET", &format!("/auth/passkey/register/start?{query}"), bearer, None, None).await;
        if options.status != http::StatusCode::OK {
            return options;
        }
        let c = ceremony_of(&options.body);
        let made = key.register(&c, ORIGIN);
        let mut body = serde_json::json!({
            "ceremony_id": options.body["ceremony_id"],
            "label": "a soft key",
            "response": {
                "clientDataJSON": B64.encode(&made.client_data_json),
                "attestationObject": B64.encode(&made.attestation_object),
            }
        });
        if let (Some(into), Some(from)) = (body.as_object_mut(), body_extra.as_object()) {
            into.extend(from.clone());
        }
        self.send("POST", "/auth/passkey/register/finish", bearer, None, Some(body)).await
    }
}

/// The creation or login options the console answered, as the core's ceremony.
pub(crate) fn ceremony_of(options: &serde_json::Value) -> Ceremony {
    let pk = &options["publicKey"];
    Ceremony {
        id: options["ceremony_id"].as_str().unwrap_or_default().to_owned(),
        challenge: pk["challenge"].as_str().expect("a challenge").to_owned(),
        rp_id: pk["rpId"].as_str().or_else(|| pk["rp"]["id"].as_str()).unwrap_or(RP_ID).to_owned(),
        user_id: String::new(),
        user_name: String::new(),
        display: String::new(),
        user_verification_required: true,
        expires_at_ms: 0,
        exclude: Vec::new(),
    }
}

/// The token a cookie carries is the core's: the core says who it is.
fn whose(rig: &Rig, cookie: &str) -> Option<String> {
    rig.core.session(cookie).ok().map(|v| v.user_id)
}

/// ★ **A login is the core's, and the cookie carries the core's session**:
/// the challenge is the core's ceremony, the core verifies the assertion, and
/// `/auth/status` names the person the core says the token is.
#[tokio::test]
async fn a_login_through_the_console_is_the_cores_and_the_cookie_carries_its_session() {
    let rig = console().await;
    let (ada, mut key) = rig.first_admin();
    let answer = rig.login(&mut key, true).await;
    assert_eq!(answer.status, http::StatusCode::OK, "{}", answer.text);
    let cookie = answer.cookie.expect("a session cookie");
    assert_eq!(whose(&rig, &cookie).as_deref(), Some(ada.as_str()), "the cookie is the core's session");
    let status = rig.send("GET", "/auth/status", None, Some(&cookie), None).await;
    assert_eq!(status.body["authenticated"], true, "{}", status.text);
    assert_eq!(status.body["user"], ada.as_str(), "{}", status.text);
    assert_eq!(status.body["role"], "admin", "the core's admin is the console's admin: {}", status.text);

    let forged = rig.send("GET", "/auth/status", None, Some("nis1.forged"), None).await;
    assert_eq!(forged.body["authenticated"], false, "a token the core does not know is nobody");
}

/// ★ **A passkey the core does not hold logs nobody in** (401), and the
/// console says which passkey to drop.
#[tokio::test]
async fn a_passkey_the_core_does_not_hold_logs_nobody_in() {
    let rig = console().await;
    rig.first_admin();
    let mut stranger = SoftPasskey::new(99);
    let answer = rig.login(&mut stranger, true).await;
    assert_eq!(answer.status, http::StatusCode::UNAUTHORIZED, "{}", answer.text);
    assert!(answer.cookie.is_none(), "a refused login hands out no session");
}

/// ★ **Logout ends the core's session**, everywhere for the person (the old
/// console ended the person's other sessions too).
#[tokio::test]
async fn logout_ends_the_cores_session() {
    let rig = console().await;
    let (_, mut key) = rig.first_admin();
    let one = rig.login(&mut key, true).await.cookie.expect("a session");
    let two = rig.login(&mut key, true).await.cookie.expect("another device's session");
    let out = rig.send("POST", "/auth/logout", None, Some(&one), None).await;
    assert_eq!(out.status, http::StatusCode::OK, "{}", out.text);
    assert!(out.cleared, "the cookie is cleared");
    assert!(whose(&rig, &one).is_none(), "the session the logout carried ended at the core");
    assert!(whose(&rig, &two).is_none(), "and the person's other sessions with it");
}

/// ★ **RA5-11: a logout the core did not do is not "ok", and the cookie stays.** The core
/// answers no `Logout` (unreachable): the console says the core's words with its status, sets
/// no clearing cookie, and the session lives on — so the page cannot tell a person on a lost
/// phone "done" while every session of theirs stands.
#[tokio::test]
async fn a_logout_the_core_refused_reports_the_cores_answer_and_keeps_the_cookie() {
    let rig = console_with(true).await;
    let (_, mut key) = rig.first_admin();
    let one = rig.login(&mut key, true).await.cookie.expect("a session");
    let out = rig.send("POST", "/auth/logout", None, Some(&one), None).await;
    assert_ne!(out.status, http::StatusCode::OK, "{}", out.text);
    assert_ne!(out.body["ok"], true, "{}", out.text);
    assert!(out.text.contains("not answering"), "the core's own words: {}", out.text);
    assert!(!out.cleared, "the cookie is kept: the session did not end");
    assert!(whose(&rig, &one).is_some(), "the core still holds the session");
}

/// ★ **RA5-14: whether the break-glass token is retired is gunnar's word, not
/// this console's store.** The first admin's passkey was made another way (at
/// the core, not through this console: no mark here), and gunnar says the
/// bootstrap is spent: the token opens nothing and the login page is told the
/// door is spent. Where gunnar says it is open, the token is the admin it is.
#[tokio::test]
async fn the_break_glass_token_is_retired_on_gunnars_word_not_the_consoles_store() {
    let spent = console_on(false, Some(Some(false))).await;
    spent.first_admin();
    let status = spent.send("GET", "/auth/status", Some(BREAK_GLASS), None, None).await;
    assert_eq!(status.body["authenticated"], false, "gunnar says spent: the token is no admin: {}", status.text);
    assert_eq!(status.body["bootstrap_spent"], true, "{}", status.text);

    let open = console_on(false, Some(Some(true))).await;
    let status = open.send("GET", "/auth/status", Some(BREAK_GLASS), None, None).await;
    assert_eq!(status.body["authenticated"], true, "gunnar says open: the token is the first admin's way in: {}", status.text);
    assert_eq!(status.body["bootstrap_spent"], false, "{}", status.text);
}

/// ★ **An invite is redeemed through the core**: the admin's invite opens a
/// creation ceremony at `register/start`, the passkey is the core's at
/// `register/finish`, and the new person logs in with it. A spent invite is
/// refused at `register/start` (410), before any passkey is made.
#[tokio::test]
async fn an_invite_is_redeemed_through_the_core_and_a_spent_one_is_refused_before_a_passkey_is_made() {
    let rig = console().await;
    let (_, mut admin_key) = rig.first_admin();
    let admin = rig.core.login_finish(admin_key.login(
        &rig.core.login_start(&nornir_identity::Relay { rp_id: RP_ID.into(), origin: ORIGIN.into(), client: None }).unwrap(),
        ORIGIN,
        true,
    ));
    let admin = admin.expect("the admin's session").token;
    let invited = rig.core.invite(&admin, nornir_identity::InviteAsk { display: "kim".into(), ..Default::default() }).expect("kim's invite");
    let mut kim = SoftPasskey::new(11);
    let done = rig.register(&format!("invite={}&user=kim", invited.secret), None, serde_json::json!({ "invite": invited.secret }), &mut kim).await;
    assert_eq!(done.status, http::StatusCode::OK, "{}", done.text);
    let login = rig.login(&mut kim, true).await;
    assert_eq!(login.status, http::StatusCode::OK, "kim logs in with the passkey the core took: {}", login.text);

    let again = rig.register(&format!("invite={}&user=kim", invited.secret), None, serde_json::json!({}), &mut SoftPasskey::new(12)).await;
    assert_eq!(again.status, http::StatusCode::GONE, "a spent invite is refused before any passkey: {}", again.text);
}

/// ★ **The first admin's passkey with the break-glass token**: gunnar holds
/// the token's SHA-256 as a fed link onto the operator; the console relays the
/// token as the secret, and the passkey lands on the operator.
#[tokio::test]
async fn the_break_glass_token_registers_the_operators_first_passkey_through_the_core() {
    let rig = console().await;
    rig.person("rickard", &[TEST_ADMINS]);
    let hash: String = {
        use sha2::Digest as _;
        sha2::Sha256::digest(BREAK_GLASS.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
    };
    rig.core.fed_enrolment("rickard", &hash, 3600, "installer").expect("the installer's fed link");
    let mut key = SoftPasskey::new(21);
    let done = rig.register("", Some(BREAK_GLASS), serde_json::json!({}), &mut key).await;
    assert_eq!(done.status, http::StatusCode::OK, "{}", done.text);
    let login = rig.login(&mut key, true).await;
    assert_eq!(login.status, http::StatusCode::OK, "{}", login.text);
    assert_eq!(whose(&rig, &login.cookie.expect("a session")).as_deref(), Some("rickard"), "the passkey is the operator's");
    let after = rig.send("GET", "/auth/status", None, None, None).await;
    assert_eq!(after.body["bootstrap_spent"], true, "the break-glass door is spent: {}", after.text);
}

/// ★ **A step-up is the core's**: a session from a plain touch is refused a
/// sensitive verb in place (`"code":"stepup"`), `GET /auth/passkey/step-up`
/// hands the core's challenge, and the user-verified touch steps the SAME
/// session up at the core.
#[tokio::test]
async fn a_step_up_is_the_cores_and_steps_up_the_same_session() {
    let rig = console().await;
    let (_, mut key) = rig.first_admin();
    let cookie = rig.login(&mut key, false).await.cookie.expect("a plain touch's session");
    assert!(!rig.core.session(&cookie).expect("live").stepped_up);
    let refused = rig.send("POST", "/auth/sessions/revoke-all", None, Some(&cookie), None).await;
    assert_eq!(refused.status, http::StatusCode::FORBIDDEN, "{}", refused.text);
    assert_eq!(refused.body["code"], "stepup", "{}", refused.text);
    let options = rig.send("GET", "/auth/passkey/step-up", None, Some(&cookie), None).await;
    assert_eq!(options.status, http::StatusCode::OK, "{}", options.text);
    let challenge = Challenge {
        challenge: options.body["publicKey"]["challenge"].as_str().expect("a challenge").to_owned(),
        expires_at_ms: 0,
    };
    let a = key.step_up(&challenge, RP_ID, ORIGIN, true);
    let up = rig
        .send(
            "POST",
            "/auth/passkey/step-up",
            None,
            Some(&cookie),
            Some(serde_json::json!({
                "ceremony_id": options.body["ceremony_id"],
                "id": B64.encode(&a.credential_id),
                "response": {
                    "clientDataJSON": B64.encode(&a.client_data_json),
                    "authenticatorData": B64.encode(&a.authenticator_data),
                    "signature": B64.encode(&a.signature),
                }
            })),
        )
        .await;
    assert_eq!(up.status, http::StatusCode::OK, "{}", up.text);
    assert!(rig.core.session(&cookie).expect("the same session").stepped_up, "the core stepped the session up");
}

/// A browser navigation (`Accept: text/html`) to `uri` with the session cookie.
async fn navigate(rig: &Rig, uri: &str, cookie: &str) -> (http::StatusCode, Option<String>, String) {
    let request = http::Request::builder()
        .method("GET")
        .uri(uri)
        .header(http::header::ACCEPT, "text/html,application/xhtml+xml")
        .header(http::header::COOKIE, format!("{}={cookie}", crate::auth::SESSION_COOKIE))
        .body(axum::body::Body::empty())
        .expect("a request");
    let response = rig.router.clone().oneshot(request).await.expect("the console answers");
    let status = response.status();
    let location = response.headers().get(http::header::LOCATION).and_then(|v| v.to_str().ok()).map(str::to_owned);
    let bytes = axum::body::to_bytes(response.into_body(), 1 << 20).await.expect("a body");
    (status, location, String::from_utf8_lossy(&bytes).into_owned())
}

/// ★ **GUN-SESSION-RESTART: a session lives through gunnar's restart** (owner 2026-11-01, after
/// the UpCloud restart: every page said "Your session is no longer valid" until Login, which
/// then signed in without a touch — the session had been alive all along). While gunnar does
/// not answer, a request that carries a session is NOT "signed out": the API answers 503
/// `restarting` (never 401), a page is not sent to `/login`, `/auth/status` says `restarting`
/// so the page waits and asks again, and the cookie is never cleared. When gunnar answers again,
/// the same cookie is the same person, with no new login.
#[tokio::test]
async fn a_session_lives_through_a_gunnar_restart_and_is_never_called_invalid_meanwhile() {
    use std::sync::atomic::Ordering;
    let rig = console().await;
    let (ada, mut key) = rig.first_admin();
    let cookie = rig.login(&mut key, true).await.cookie.expect("a session");
    let up = rig.send("GET", "/auth/status", None, Some(&cookie), None).await;
    assert_eq!(up.body["authenticated"], true, "{}", up.text);

    rig.down.store(true, Ordering::SeqCst);
    let api = rig.send("GET", "/api/repos", None, Some(&cookie), None).await;
    assert_eq!(api.status, http::StatusCode::SERVICE_UNAVAILABLE, "never a 401 while gunnar restarts: {}", api.text);
    assert_eq!(api.body["code"], "restarting", "{}", api.text);
    assert!(!api.cleared, "the cookie is kept");
    let (status, location, page) = navigate(&rig, "/repos", &cookie).await;
    assert_eq!(status, http::StatusCode::SERVICE_UNAVAILABLE, "not sent to /login: {location:?} {page}");
    assert!(location.is_none(), "no redirect: {location:?}");
    assert!(page.contains("starting"), "the page says Gunnar is starting: {page}");
    let waiting = rig.send("GET", "/auth/status", None, Some(&cookie), None).await;
    assert_eq!(waiting.body["restarting"], true, "the page is told to wait: {}", waiting.text);
    assert_ne!(waiting.body["authenticated"], true, "{}", waiting.text);
    assert!(!waiting.cleared, "the cookie is kept");

    // A request with NO session while gunnar restarts is still a visitor: the login page.
    let nobody = rig.send("GET", "/api/repos", None, None, None).await;
    assert_eq!(nobody.status, http::StatusCode::UNAUTHORIZED, "{}", nobody.text);
    let nobody_status = rig.send("GET", "/auth/status", None, None, None).await;
    assert_ne!(nobody_status.body["restarting"], true, "no session, nothing to wait for: {}", nobody_status.text);

    rig.down.store(false, Ordering::SeqCst);
    let back = rig.send("GET", "/auth/status", None, Some(&cookie), None).await;
    assert_eq!(back.body["authenticated"], true, "the same cookie, no new login: {}", back.text);
    assert_eq!(back.body["user"], ada.as_str(), "{}", back.text);
    assert_ne!(back.body["restarting"], true, "{}", back.text);
}

/// ★ **A session the core says is gone is still gone** (GUN-SESSION-RESTART must not keep
/// a dead session alive): a forged or ended token is a visitor, 401, as before.
#[tokio::test]
async fn an_ended_session_is_still_signed_out_while_gunnar_answers() {
    let rig = console().await;
    let (_, mut key) = rig.first_admin();
    let cookie = rig.login(&mut key, true).await.cookie.expect("a session");
    rig.send("POST", "/auth/logout", None, Some(&cookie), None).await;
    let api = rig.send("GET", "/api/repos", None, Some(&cookie), None).await;
    assert_eq!(api.status, http::StatusCode::UNAUTHORIZED, "{}", api.text);
    let status = rig.send("GET", "/auth/status", None, Some(&cookie), None).await;
    assert_eq!(status.body["authenticated"], false, "{}", status.text);
    assert_ne!(status.body["restarting"], true, "{}", status.text);
}