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