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

nordisk/gunnar

crates/gunnar-server/tests/sign_in_again.rs

//! **`sign_in_again`** (S10, owner 2026-10-11 05:53Z): a sold estate's RESEND sign-in link
//! travels as a signed order. monetize queues it naming the account, the address and the
//! console host — never a secret; the main pulls it, and HERE it mints the fresh invite
//! (the identity core's first-login link, IDENTITY-HOST: only its hash is stored) and mails
//! the link itself.
//!
//! Driven through `gunnar_repair::exec::execute`, the one dispatch a pulled order takes,
//! on the server's own [`ServerEstate`], with a postman that keeps what it was handed.
#![cfg(feature = "fleet-pull")]

use std::sync::{Arc, Mutex};

use gunnar_core::model::WebCredential;
use gunnar_core::ssh_key::PrivateKey;
use gunnar_core::{Principal, PrincipalKind};
use gunnar_repair::exec::execute;
use gunnar_repair::sign_in::SignInMail;
use gunnar_repair::{Order, OrderAck};
use gunnar_server::control_plane::CoreControlPlane;
use gunnar_server::postman::{Posting, Postman};
use gunnar_server::repair::{Entitlements, ServerEstate};
use gunnar_server::stores::ServerStores;

/// Keeps every mail; refuses them all when `down`.
#[derive(Default)]
struct Kept {
    mails: Mutex<Vec<SignInMail>>,
    down: bool,
}

impl Postman for Kept {
    fn post<'a>(&'a self, mail: &'a SignInMail) -> Posting<'a> {
        Box::pin(async move {
            if self.down {
                return Err("the relay is down".to_string());
            }
            self.mails.lock().unwrap().push(mail.clone());
            Ok(())
        })
    }
}

struct Rig {
    _dir: tempfile::TempDir,
    stores: Arc<ServerStores>,
    /// The box's identity core: the stores hold it weakly, the server (here: the rig) owns it.
    _identity: Arc<gunnar_grpc::identity::GunnarIdentity>,
    postman: Option<Arc<Kept>>,
}

impl Rig {
    /// A main that serves: alice (a person without a passkey) and her namespace.
    fn serving() -> Rig {
        let r = Rig::fresh();
        r.stores
            .auth()
            .add_principal(Principal {
                id: "alice".into(),
                name: "alice".into(),
                kind: PrincipalKind::User,
                created: 0,
            })
            .expect("alice");
        r.stores
            .catalog()
            .create_namespace("alice")
            .expect("namespace");
        r
    }

    /// A re-laid main nobody restored: it does not serve.
    fn fresh() -> Rig {
        let dir = tempfile::tempdir().expect("tempdir");
        let stores = Arc::new(ServerStores::open(dir.path()).expect("stores"));
        let control: Arc<dyn gunnar_grpc::ControlPlane> =
            Arc::new(CoreControlPlane::new(Arc::clone(&stores)));
        let identity = gunnar_server::identity::build(
            dir.path(),
            control,
            &gunnar_grpc::ServerConfig::default(),
            false,
        );
        stores.set_identity(&identity);
        Rig {
            _dir: dir,
            stores,
            _identity: identity,
            postman: Some(Arc::new(Kept::default())),
        }
    }

    fn estate(&self) -> ServerEstate {
        let pem = gunnar_core::mint_ed25519().expect("mint");
        let key = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
        let plane = CoreControlPlane::new(Arc::clone(&self.stores));
        let postman = self.postman.clone().map(|p| p as Arc<dyn Postman>);
        ServerEstate {
            key,
            own: "127.0.0.1:1".into(),
            entitlements: Entitlements::of(Arc::clone(&self.stores))
                .with_control(Arc::new(plane))
                .with_postman(postman),
        }
    }

    async fn resend(&self, seq: u64, account: &str, to: &str) -> OrderAck {
        let order = Order::SignInAgain {
            account: account.into(),
            to: to.into(),
            host: "acme.gunnar.rs".into(),
            ttl_secs: 86_400,
        };
        execute(&self.estate(), seq, &order, &|| false).await
    }

    fn mails(&self) -> Vec<SignInMail> {
        self.postman
            .as_ref()
            .map(|p| p.mails.lock().unwrap().clone())
            .unwrap_or_default()
    }

    fn core(&self) -> Arc<gunnar_grpc::identity::GunnarIdentity> {
        self.stores.identity().expect("the identity core is up")
    }

    /// The core's open invites.
    fn invites(&self) -> Vec<nornir_identity::Invite> {
        use nornir_identity::IdentityHost as _;
        let now = self.core().host().now_ms() / 1000;
        self.core()
            .host()
            .gunnar_store()
            .tables()
            .invites
            .into_iter()
            .filter(|i| i.open(now))
            .collect()
    }
}

/// The secret the mailed link carries.
fn secret_of(mail: &SignInMail) -> String {
    let at = mail
        .body
        .find("/login?invite=")
        .expect("a link in the body")
        + "/login?invite=".len();
    mail.body[at..].split('&').next().unwrap().to_string()
}

#[tokio::test]
async fn the_main_mints_a_fresh_invite_and_mails_the_link_the_ack_names_no_secret() {
    let r = Rig::serving();
    let a = r.resend(7, "alice", "buyer@example.com").await;
    assert!(a.ok, "{a:?}");
    assert_eq!(a.seq, 7);

    let mails = r.mails();
    assert_eq!(mails.len(), 1, "one mail, sent by this box");
    let mail = &mails[0];
    assert_eq!(mail.to, "buyer@example.com");
    let secret = secret_of(mail);
    assert!(
        secret.len() >= 40,
        "a 32-byte secret, base64url: {secret:?}"
    );
    assert!(
        mail.body.contains(&format!(
            "https://acme.gunnar.rs/login?invite={secret}&user=alice"
        )),
        "the first link's shape"
    );
    assert!(mail.body.contains("1 day"), "{:?}", mail.subject);

    let invites = r.invites();
    assert_eq!(invites.len(), 1, "{invites:?}");
    let invite = &invites[0];
    assert!(
        invite.created_by.starts_with("for:alice:"),
        "a link onto alice herself: {invite:?}"
    );
    assert!(
        invite.groups.is_empty(),
        "a resend grants nothing server-wide"
    );
    assert_eq!(
        invite.expires - invite.created,
        86_400,
        "it lives what the order asked"
    );
    assert!(
        !format!("{invite:?}").contains(&secret),
        "the store keeps only the hash of the mailed secret"
    );
    let relay = nornir_identity::Relay {
        rp_id: "acme.gunnar.rs".into(),
        origin: "https://acme.gunnar.rs".into(),
        client: None,
    };
    let opened = r
        .core()
        .redeem_start(&secret, "", &relay)
        .expect("the mailed secret opens alice's ceremony");
    assert_eq!(opened.user_id, "alice");

    assert!(
        !a.detail.contains(&secret),
        "the ack goes back to the shop: no secret in it: {}",
        a.detail
    );
    assert!(
        a.detail.contains("alice") && a.detail.contains("buyer@example.com"),
        "{}",
        a.detail
    );

    let again = r.resend(8, "alice", "buyer@example.com").await;
    assert!(again.ok, "{again:?}");
    assert_eq!(r.invites().len(), 2);
    assert_ne!(
        secret_of(&r.mails()[1]),
        secret,
        "every resend mints its own secret"
    );
}

#[tokio::test]
async fn a_person_who_already_holds_a_passkey_gets_no_link() {
    let r = Rig::serving();
    r.stores
        .auth()
        .add_web_credential(WebCredential {
            id: "cred-1".into(),
            principal_id: "alice".into(),
            label: "laptop".into(),
            public_key_hex: "04".into(),
            sign_count: 0,
            created: 0,
            last_used: None,
            sessions: vec![],
        })
        .expect("a passkey");
    let a = r.resend(1, "alice", "buyer@example.com").await;
    assert!(!a.ok, "{a:?}");
    assert!(a.detail.contains("passkey"), "{}", a.detail);
    assert!(r.mails().is_empty() && r.invites().is_empty());
}

#[tokio::test]
async fn no_relay_no_account_or_no_serving_is_refused_by_name_before_anything_is_minted() {
    let mut r = Rig::serving();
    r.postman = None;
    let a = r.resend(1, "alice", "buyer@example.com").await;
    assert!(
        !a.ok && a.detail.contains("mail relay") && a.detail.contains("GUNNAR_SMTP_FROM"),
        "{a:?}"
    );
    assert!(
        r.invites().is_empty(),
        "no invite minted that could not be mailed"
    );

    let r = Rig::serving();
    let a = r.resend(2, "mallory", "buyer@example.com").await;
    assert!(!a.ok && a.detail.contains("mallory"), "{a:?}");
    assert!(r.mails().is_empty() && r.invites().is_empty());

    let a = r
        .resend(3, "alice", "buyer@example.com\r\nBcc: x@evil.example")
        .await;
    assert!(!a.ok, "{a:?}");
    assert!(r.mails().is_empty() && r.invites().is_empty());

    let r = Rig::fresh();
    let a = r.resend(4, "alice", "buyer@example.com").await;
    assert!(!a.ok && a.detail.contains("serving"), "{a:?}");
    assert!(r.mails().is_empty() && r.invites().is_empty());
}

#[tokio::test]
async fn a_mail_that_does_not_go_withdraws_its_invite() {
    let mut r = Rig::serving();
    r.postman = Some(Arc::new(Kept {
        down: true,
        ..Kept::default()
    }));
    let a = r.resend(1, "alice", "buyer@example.com").await;
    assert!(
        !a.ok && a.detail.contains("could not be mailed") && a.detail.contains("ask again"),
        "{a:?}"
    );
    assert!(
        r.invites().is_empty(),
        "an invite nobody received is withdrawn: {:?}",
        r.invites()
    );
}

/// A relay that cannot mail yet (P1: a sold estate's main whose feed names no relay).
struct NotReady;

impl Postman for NotReady {
    fn post<'a>(&'a self, _mail: &'a SignInMail) -> Posting<'a> {
        Box::pin(async move { panic!("a postman that is not ready is never asked to post") })
    }
    fn ready(&self) -> Result<(), String> {
        Err("the installer fed no relay (secret.smtp_url)".into())
    }
}

/// ★ **P1: a relay that is not ready is asked BEFORE anything is minted** — the fed relay of
/// a sold estate's main whose feed names none refuses the resend by name, and no invite exists.
#[tokio::test]
async fn a_relay_that_is_not_ready_refuses_before_anything_is_minted() {
    let r = Rig::serving();
    let pem = gunnar_core::mint_ed25519().expect("mint");
    let key = PrivateKey::from_openssh(std::str::from_utf8(&pem).unwrap()).expect("parse");
    let plane = CoreControlPlane::new(Arc::clone(&r.stores));
    let estate = ServerEstate {
        key,
        own: "127.0.0.1:1".into(),
        entitlements: Entitlements::of(Arc::clone(&r.stores))
            .with_control(Arc::new(plane))
            .with_postman(Some(Arc::new(NotReady))),
    };
    let order = Order::SignInAgain {
        account: "alice".into(),
        to: "buyer@example.com".into(),
        host: "acme.gunnar.rs".into(),
        ttl_secs: 86_400,
    };
    let a = execute(&estate, 1, &order, &|| false).await;
    assert!(!a.ok && a.detail.contains("secret.smtp_url"), "{a:?}");
    assert!(
        r.invites().is_empty(),
        "no invite minted that could not be mailed"
    );
}