crates/gunnar-grpc/tests/invites.rs
//! **Invites are the identity core's, over Gunnar's people** (IDENTITY-HOST,
//! 2026-10-27; Stage L before it: "passkeys + invites + the
//! passkey↔principal pin → gunnar-server").
//!
//! Gunnar's own `Access.CreateInvite/ListInvites/RevokeInvite` are gone: an
//! invite is edda's `nornir_identity::Identity::invite` (IdentityService
//! `CreateInvite`), and Gunnar answers only who its people are
//! ([`GunnarStore`]) and who may enrol whom ([`GunnarHost::may_enrol`]). The
//! rules these tests held for the old verbs, held now through the core over
//! Gunnar's control plane:
//!
//! * a server admin mints a passkey link for anybody; the secret is shown once
//! and never listed, and no invite makes an admin;
//! * an account admin mints one for the account's own employees, nobody else;
//! * a person mints one for themself (a passkey on another device);
//! * only a person who exists, and only an inviter withdraws an invite.
use std::sync::Arc;
use gunnar_grpc::identity::{
system_clock, GunnarHost, GunnarIdentity, GunnarStore, IdentityTables, SERVER_ADMIN,
};
use gunnar_grpc::{
AccountsService, Caller, ControlPlane, EventBus, MemoryControlPlane, ServerConfig,
ServiceContext,
};
use gunnar_proto::accounts_server::Accounts as _;
use gunnar_proto::*;
use nornir_identity::conformance::config;
use nornir_identity::{Code, Identity, IdentityHost as _, InviteAsk, NoMail, Site, UserState};
use tonic::Request;
const ACCOUNT: &str = "acme";
const DAY_SECS: i64 = 86_400;
fn now_secs() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("after 1970")
.as_secs() as i64
}
fn caller(id: &str) -> Caller {
Caller {
principal_id: id.to_owned(),
fingerprint: format!("SHA256:{id}"),
machine: false,
authenticated: true,
}
}
fn with<T>(who: &str, body: T) -> Request<T> {
let mut request = Request::new(body);
request.extensions_mut().insert(caller(who));
request
}
struct Rig {
plane: Arc<MemoryControlPlane>,
identity: GunnarIdentity,
}
impl Rig {
/// `boss` holds server Admin; `acme` owns the account and has enrolled
/// `bob`; `ola` and `kim` are ordinary people; `ci` is a machine.
async fn new() -> Rig {
let plane = Arc::new(MemoryControlPlane::new());
plane
.create_namespace(ACCOUNT, "")
.expect("acme's namespace");
for (id, kind) in [
("boss", PrincipalKind::User),
(ACCOUNT, PrincipalKind::User),
("ola", PrincipalKind::User),
("kim", PrincipalKind::User),
("ci", PrincipalKind::Machine),
] {
plane.add_principal(id, id, kind).expect("principal");
}
plane.grant_server("boss", GrantLevel::Admin);
let control: Arc<dyn ControlPlane> = plane.clone();
let ctx = ServiceContext::new(
Arc::clone(&control),
Arc::new(EventBus::new(16)),
ServerConfig::default(),
);
AccountsService::new(ctx)
.enroll_employee(with(
ACCOUNT,
EnrollEmployeeRequest {
account: ACCOUNT.into(),
id: "bob".into(),
name: "bob".into(),
public_key: String::new(),
..Default::default()
},
))
.await
.expect("acme enrolls bob");
let store = GunnarStore::new(control, IdentityTables::default(), None, system_clock());
let identity = Identity::new(
GunnarHost::new(Site::Private, store, Arc::new(NoMail), system_clock()),
config(),
);
Rig { plane, identity }
}
/// A session of `who` (an active person, as after their first passkey).
fn session(&self, who: &str) -> String {
let store = self.identity.host().store();
let mut user = store.user(who).expect("read").expect("a person");
user.state = UserState::Active;
store.put_user(&user).expect("active");
self.identity
.vouched_session(who, true)
.expect("a session")
.token
}
fn invite(
&self,
who: &str,
person: &str,
) -> Result<nornir_identity::Invited, nornir_identity::Refused> {
let token = self.session(who);
self.identity.invite(
&token,
InviteAsk {
person: Some(person.to_owned()),
..InviteAsk::default()
},
)
}
fn list(&self, who: &str) -> Vec<nornir_identity::InviteView> {
self.identity
.list_invites(&self.session(who))
.expect("listed")
}
}
fn code<T: std::fmt::Debug>(r: Result<T, nornir_identity::Refused>) -> Code {
r.expect_err("a refusal").code
}
/// ★ **A server admin mints a link for anybody, and the secret is shown
/// once**: the listing carries everything but the secret. No invite makes an
/// admin.
#[tokio::test]
async fn a_server_admin_invites_anybody_and_the_secret_is_shown_once() {
let rig = Rig::new().await;
let token = rig.session("boss");
let refused = rig.identity.invite(
&token,
InviteAsk {
groups: vec![SERVER_ADMIN.to_owned()],
..InviteAsk::default()
},
);
assert_eq!(
code(refused),
Code::InviteWouldBeAdmin,
"an invite never makes an admin"
);
let made = rig
.invite("boss", "kim")
.expect("a server admin mints kim's link");
assert!(
made.secret.len() >= 32,
"a secret worth guessing at: {:?}",
made.secret
);
assert!(
(made.expires_at - (now_secs() + DAY_SECS)).abs() < 60,
"a day by default: {made:?}"
);
let listed = rig.list("boss");
assert_eq!(listed.len(), 1, "{listed:?}");
assert_eq!(listed[0].person.as_deref(), Some("kim"));
assert_eq!(listed[0].created_by, "boss");
assert!(
!format!("{listed:?}").contains(&made.secret),
"the secret came back in a listing"
);
}
/// ★ **An account admin mints links for the account's own employees, never
/// for anybody else**, and sees the ones they minted.
#[tokio::test]
async fn an_account_admin_invites_their_employees_and_no_one_else() {
let rig = Rig::new().await;
rig.invite(ACCOUNT, "bob")
.expect("acme mints its employee's link");
assert_eq!(
code(rig.invite(ACCOUNT, "ola")),
Code::NotAdmin,
"ola is not acme's employee"
);
let token = rig.session(ACCOUNT);
let refused = rig.identity.invite(
&token,
InviteAsk {
display: "new".into(),
..InviteAsk::default()
},
);
assert_eq!(
code(refused),
Code::NotAdmin,
"an account admin makes no new person through an invite"
);
let acmes: Vec<_> = rig.list(ACCOUNT).into_iter().map(|i| i.person).collect();
assert_eq!(
acmes,
vec![Some("bob".to_owned())],
"acme sees the link it minted"
);
assert!(rig.list("ola").is_empty(), "ola sees nobody's");
}
/// ★ **A person mints a link for themself** — a passkey on another device —
/// and for nobody else.
#[tokio::test]
async fn a_person_invites_themselves_and_nobody_else() {
let rig = Rig::new().await;
rig.invite("kim", "kim").expect("kim's own link");
assert_eq!(
code(rig.invite("kim", "ola")),
Code::NotAdmin,
"kim may not mint ola's"
);
let token = rig.session("kim");
let refused = rig.identity.invite(
&token,
InviteAsk {
person: Some("kim".into()),
groups: vec!["server-write".into()],
..InviteAsk::default()
},
);
assert_eq!(
code(refused),
Code::Malformed,
"a link onto an existing person carries no groups"
);
}
/// Only somebody who exists is invited; a deactivated person gets no link.
#[tokio::test]
async fn an_invite_is_for_a_person_who_exists() {
let rig = Rig::new().await;
assert_eq!(
code(rig.invite("boss", "ghost")),
Code::Malformed,
"nobody to invite"
);
let ola = rig.session("ola");
rig.identity
.deactivate("ola", "boss")
.expect("ola deactivated");
assert_eq!(
code(rig.identity.session(&ola)),
Code::NoSession,
"her session ends with it"
);
assert_eq!(
code(rig.invite("boss", "ola")),
Code::NotActive,
"a deactivated person gets no passkey"
);
}
/// ★ **Withdrawn by whoever minted it** (or an admin); anybody else is
/// refused, and a withdrawn invite is not open any more.
#[tokio::test]
async fn an_invite_is_revoked_by_its_inviters() {
let rig = Rig::new().await;
let bobs = rig.invite(ACCOUNT, "bob").expect("bob's");
let ola = rig.session("ola");
assert_eq!(
code(rig.identity.revoke_invite(&ola, &bobs.invite_id)),
Code::NotAdmin,
"not ola's to withdraw"
);
assert_eq!(
code(rig.identity.revoke_invite(&ola, "no-such-invite")),
Code::InviteNotOpen
);
rig.identity
.revoke_invite(&rig.session(ACCOUNT), &bobs.invite_id)
.expect("acme withdraws bob's link");
assert!(rig.list("boss").is_empty(), "nothing open");
assert_eq!(
code(
rig.identity
.revoke_invite(&rig.session("boss"), &bobs.invite_id)
),
Code::InviteNotOpen,
"gone"
);
assert!(
rig.identity
.redeem_start(&bobs.secret, "", &nornir_identity::conformance::relay())
.is_err(),
"a withdrawn link opens no ceremony"
);
let _ = &rig.plane;
}