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