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

nordisk/edda

crates/nornir-identity/src/conformance.rs

//! **The conformance suite**: ONE set of cases every host runs (TestHost here,
//! GunnarHost in gunnar-server, HolgerHost in holger-server). A product writes a
//! [`Harness`] and one line:
//!
//! ```ignore
//! nornir_identity::conformance_suite!(crate::GunnarHarness);
//! ```
//!
//! [`transcript`] runs one scripted walk and writes every answer in a form with
//! ids, tokens and times taken out, so two products' answers can be diffed.

use crate::test_host::{Kit, SoftPasskey, MAILBOX_REFUSES};
use crate::{
    Code, Config, Identity, IdentityHost, InviteAsk, LoggedIn, MailKind, Relay, RelyingParty,
    SignUp, Site, UserState,
};

/// The relying party every case answers for.
pub const RP_ID: &str = "idh.mock.invalid";
/// Its origin.
pub const ORIGIN: &str = "https://idh.mock.invalid";

/// **What a product hands the suite.**
pub trait Harness {
    type Host: IdentityHost;
    /// A fresh host of `site` that mails through `kit.mail` and tells time by
    /// `kit.clock`, with an empty store.
    fn host(&self, site: Site, kit: &Kit) -> Self::Host;
    /// The groups that make an admin here.
    fn admin_groups(&self) -> Vec<String>;
    /// Groups that do not.
    fn member_groups(&self) -> Vec<String>;
}

fn kit_now(kit: &Kit) -> i64 {
    kit.clock.now_ms().div_euclid(1000)
}

/// The settings every case runs with.
pub fn config() -> Config {
    Config {
        parties: vec![format!("{RP_ID}={ORIGIN}")
            .parse::<RelyingParty>()
            .expect("the test relying party")],
        ..Config::default()
    }
}

/// The relay every case speaks through.
pub fn relay() -> Relay {
    Relay {
        rp_id: RP_ID.to_owned(),
        origin: ORIGIN.to_owned(),
        client: None,
    }
}

/// A fresh core over `h`'s host.
pub fn identity<Hn: Harness>(h: &Hn, site: Site) -> (Identity<Hn::Host>, Kit) {
    let kit = Kit::default();
    (Identity::new(h.host(site, &kit), config()), kit)
}

fn code<T: std::fmt::Debug>(r: Result<T, crate::Refused>) -> Code {
    match r {
        Ok(v) => panic!("expected a refusal, got {v:?}"),
        Err(e) => e.code,
    }
}

/// Redeem `secret` with a new soft passkey; the person's session and passkey.
pub fn redeem<H: IdentityHost>(
    id: &Identity<H>,
    secret: &str,
    display: &str,
    seed: u8,
) -> (LoggedIn, SoftPasskey) {
    let mut key = SoftPasskey::new(seed);
    let c = id
        .redeem_start(secret, display, &relay())
        .expect("redeem start");
    let session = id
        .redeem_finish(key.register(&c, ORIGIN))
        .expect("redeem finish");
    (session, key)
}

/// The site's first admin, logged in.
pub fn first_admin<Hn: Harness>(h: &Hn, id: &Identity<Hn::Host>) -> (LoggedIn, SoftPasskey) {
    let invited = id
        .first_admin_invite(h.admin_groups(), "installer")
        .expect("the first admin invite");
    redeem(id, &invited.secret, "Ada", 1)
}

/// The site's first admin, logged in and stepped up: an invite and a passkey
/// link need a fresh touch (RA5-2), so a case that mints one starts here.
pub fn first_admin_stepped<Hn: Harness>(
    h: &Hn,
    id: &Identity<Hn::Host>,
) -> (LoggedIn, SoftPasskey) {
    let (mut admin, mut key) = first_admin(h, id);
    admin.session = step_up(id, &admin.token, &mut key).expect("the first admin steps up");
    (admin, key)
}

/// Log `key` in, user-verified or not.
pub fn login<H: IdentityHost>(
    id: &Identity<H>,
    key: &mut SoftPasskey,
    uv: bool,
) -> Result<LoggedIn, crate::Refused> {
    let c = id.login_start(&relay())?;
    id.login_finish(key.login(&c, ORIGIN, uv))
}

/// Step `token` up with `key`.
pub fn step_up<H: IdentityHost>(
    id: &Identity<H>,
    token: &str,
    key: &mut SoftPasskey,
) -> Result<crate::SessionView, crate::Refused> {
    let c = id.step_up_start(token, None)?;
    id.step_up_finish(token, key.step_up(&c, RP_ID, ORIGIN, true))
}

// ─── The cases ──────────────────────────────────────────────────────────────

/// A Root site mails a first-login link; the link registers a passkey and logs in, once.
pub fn sign_up_on_a_root_mails_a_link_that_registers_and_logs_in<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Root);
    id.sign_up(SignUp {
        email: "Kim@Mock.Invalid".into(),
        display: "Kim".into(),
    })
    .expect("sign-up on a root");
    let mail = kit
        .mail
        .last_to("kim@mock.invalid")
        .expect("a first-login mail");
    assert_eq!(mail.kind, MailKind::FirstLogin);
    let user = id
        .host()
        .store()
        .user_by_email("kim@mock.invalid")
        .unwrap()
        .expect("the pending person");
    assert_eq!(user.state, UserState::Pending);
    let (session, mut key) = redeem(&id, &mail.secret, "", 2);
    assert_eq!(
        (
            session.session.user_id.as_str(),
            session.session.display.as_str()
        ),
        (user.id.as_str(), "Kim")
    );
    assert!(!session.session.admin, "a sign-up is never an admin");
    assert_eq!(
        id.host().store().user(&user.id).unwrap().unwrap().state,
        UserState::Active
    );
    assert_eq!(
        code(id.redeem_start(&mail.secret, "", &relay())),
        Code::InviteNotOpen,
        "the link is single use"
    );
    kit.clock.advance(1);
    assert_eq!(
        login(&id, &mut key, true).expect("login").session.user_id,
        user.id
    );
    id.sign_up(SignUp {
        email: "kim@mock.invalid".into(),
        display: String::new(),
    })
    .expect("the same answer again");
    assert_eq!(
        kit.mail.sent().len(),
        1,
        "an active account is mailed nothing"
    );
}

/// A Private site refuses sign-up, stores nothing and mails nothing.
pub fn sign_up_on_a_private_site_is_refused<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let r = id.sign_up(SignUp {
        email: "kim@mock.invalid".into(),
        display: "Kim".into(),
    });
    assert_eq!(code(r), Code::SignUpClosed);
    assert!(kit.mail.sent().is_empty());
    assert!(id.host().store().users().unwrap().is_empty());
    assert!(id.host().store().invites().unwrap().is_empty());
}

/// A mailer that refuses (airgap) makes a sign-up fail whole: no link stays open, and
/// nobody can log in (RA5-30: the link is stored before it is mailed, so a new person
/// stays pending with no link until the address signs up again).
pub fn sign_up_with_a_refusing_mailer_stores_nothing<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Root);
    kit.mail.refuse(true);
    let r = id.sign_up(SignUp {
        email: "kim@mock.invalid".into(),
        display: "Kim".into(),
    });
    assert_eq!(r.as_ref().map_err(|e| e.code), Err(Code::MailRefused));
    assert!(r.unwrap_err().detail.contains(MAILBOX_REFUSES));
    assert!(id
        .host()
        .store()
        .users()
        .unwrap()
        .iter()
        .all(|u| u.state == UserState::Pending));
    assert!(
        id.host().store().invites().unwrap().is_empty(),
        "no link stays open"
    );
    kit.mail.refuse(false);
    id.sign_up(SignUp {
        email: "kim@mock.invalid".into(),
        display: "Kim".into(),
    })
    .expect("the next sign-up works");
    assert_eq!(
        id.host().store().users().unwrap().len(),
        1,
        "and takes the pending person up"
    );
    kit.mail.refuse(true);
    assert_eq!(
        code(id.sign_up(SignUp {
            email: "not an address".into(),
            display: String::new()
        })),
        Code::Malformed
    );
}

/// An admin's invite is mailed, redeemed once, and gives its groups.
pub fn an_invite_is_redeemed_once<Hn: Harness>(h: &Hn) {
    for site in [Site::Private, Site::Root] {
        let (id, kit) = identity(h, site);
        let (admin, _) = first_admin_stepped(h, &id);
        assert!(admin.session.admin, "the first admin invite makes an admin");
        let ask = InviteAsk {
            email: Some("bo@mock.invalid".into()),
            groups: h.member_groups(),
            ..InviteAsk::default()
        };
        let invited = id.invite(&admin.token, ask).expect("an admin invites");
        assert!(invited.mailed);
        let mail = kit
            .mail
            .last_to("bo@mock.invalid")
            .expect("the invite mail");
        assert_eq!(
            (mail.kind, mail.secret.as_str()),
            (MailKind::Invite, invited.secret.as_str())
        );
        let (bo, _) = redeem(&id, &invited.secret, "Bo", 3);
        assert_eq!(bo.session.display, "Bo");
        assert!(!bo.session.admin);
        let bo_user = id
            .host()
            .store()
            .user(&bo.session.user_id)
            .unwrap()
            .unwrap();
        assert_eq!(bo_user.groups, h.member_groups());
        assert_eq!(
            code(id.redeem_start(&invited.secret, "Eve", &relay())),
            Code::InviteNotOpen,
            "single use"
        );
        assert_eq!(
            code(id.redeem_start("never-minted", "Eve", &relay())),
            Code::InviteNotOpen
        );
        kit.mail.refuse(true);
        let handed = id
            .invite(
                &admin.token,
                InviteAsk {
                    email: Some("cy@mock.invalid".into()),
                    ..InviteAsk::default()
                },
            )
            .unwrap();
        assert!(
            !handed.mailed && handed.mail_refusal.is_some(),
            "a refusing mailer leaves the admin the secret"
        );
        let (_, _) = redeem(&id, &handed.secret, "Cy", 4);
    }
}

/// An invite never makes an admin, only an admin invites, and an invite expires.
pub fn an_invite_never_makes_an_admin<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (admin, _) = first_admin_stepped(h, &id);
    let r = id.invite(
        &admin.token,
        InviteAsk {
            groups: h.admin_groups(),
            ..InviteAsk::default()
        },
    );
    assert_eq!(code(r), Code::InviteWouldBeAdmin);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, mut bo_key) = redeem(&id, &invited.secret, "Bo", 3);
    step_up(&id, &bo.token, &mut bo_key).unwrap();
    assert_eq!(
        code(id.invite(&bo.token, InviteAsk::default())),
        Code::NotAdmin
    );
    assert_eq!(
        code(id.invite("nis1.forged", InviteAsk::default())),
        Code::NoSession
    );
    assert_eq!(
        code(id.first_admin_invite(h.admin_groups(), "installer")),
        Code::Bootstrapped
    );
    let late = id
        .invite(
            &admin.token,
            InviteAsk {
                ttl_secs: Some(60),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    kit.clock.advance(61);
    assert_eq!(
        code(id.redeem_start(&late.secret, "Late", &relay())),
        Code::InviteNotOpen,
        "expired"
    );
}

/// A passkey logs in; a replay, a stranger and a wrong origin are refused.
pub fn a_passkey_logs_in_and_a_replay_is_refused<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (admin, mut key) = first_admin(h, &id);
    kit.clock.advance(1);
    let c = id.login_start(&relay()).unwrap();
    let answer = key.login(&c, ORIGIN, false);
    let session = id.login_finish(answer.clone()).expect("a login");
    assert_eq!(session.session.user_id, admin.session.user_id);
    assert!(
        !session.session.stepped_up,
        "a login without verification is no step-up"
    );
    assert_eq!(
        code(id.login_finish(answer)),
        Code::Ceremony,
        "a ceremony is answered once"
    );
    let mut stranger = SoftPasskey::new(9);
    assert_eq!(
        code(login(&id, &mut stranger, true)),
        Code::Ceremony,
        "a passkey not registered here"
    );
    let c = id.login_start(&relay()).unwrap();
    assert_eq!(
        code(id.login_finish(key.login(&c, "https://evil.mock.invalid", true))),
        Code::Ceremony,
        "another origin"
    );
    let other = Relay {
        rp_id: "other.mock.invalid".into(),
        origin: "https://other.mock.invalid".into(),
        client: None,
    };
    assert_eq!(
        code(id.login_start(&other)),
        Code::Ceremony,
        "a relying party this server is not"
    );
    let uv = login(&id, &mut key, true).unwrap();
    assert!(uv.session.stepped_up, "a user-verified login is a step-up");
    assert_ne!(uv.token, session.token);
    let stored = id
        .host()
        .store()
        .session(&nornir_identity_session::invites::hash_secret(&uv.token))
        .unwrap();
    assert!(stored.is_some(), "the store keeps the token's hash");
    assert!(
        id.host().store().session(&uv.token).unwrap().is_none(),
        "never the token"
    );
}

/// A session ends on logout; logout everywhere ends every device's.
pub fn a_session_ends_on_logout<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (first, mut key) = first_admin(h, &id);
    kit.clock.advance(1);
    let second = login(&id, &mut key, false).unwrap();
    id.logout(&first.token, false).unwrap();
    assert_eq!(code(id.session(&first.token)), Code::NoSession);
    assert!(
        id.session(&second.token).is_ok(),
        "another device is untouched"
    );
    let third = login(&id, &mut key, false).unwrap();
    id.logout(&second.token, true).unwrap();
    assert_eq!(
        code(id.session(&third.token)),
        Code::NoSession,
        "everywhere"
    );
    assert_eq!(code(id.logout(&second.token, false)), Code::NoSession);
    kit.clock.advance(1);
    assert!(
        id.session(&login(&id, &mut key, false).unwrap().token)
            .is_ok(),
        "a later login works"
    );
}

/// **A login in the very second of a logout everywhere is never handed a dead session**
/// (GHUNT-12, lane OX-GUNHUNT 2026-10-28). The floor is a whole second and voids what was
/// minted AT it, so a session minted in that second after the logout was handed out and
/// refused from its first use, for ever: Gunnar logs out everywhere, so a re-login inside
/// one second (gunnar-ui's uitest `the-same-passkey-logs-in-again`) sat signed out. Either
/// the session works, or the login is refused by name (`busy`) and one a second later works.
pub fn a_login_in_the_second_of_a_logout_everywhere_is_never_a_dead_session<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (first, mut key) = first_admin(h, &id);
    kit.clock.advance(1);
    id.logout(&first.token, true).unwrap();
    match login(&id, &mut key, false) {
        Ok(same_second) => assert!(
            id.session(&same_second.token).is_ok(),
            "the login handed out a session its own person's floor already voids"
        ),
        Err(refused) => assert_eq!(refused.code, Code::Busy, "{refused:?}"),
    }
    kit.clock.advance(1);
    assert!(
        id.session(&login(&id, &mut key, false).unwrap().token)
            .is_ok(),
        "a later login works"
    );
}

/// A session ends on deactivation (the directory's path and an admin's), and the
/// person cannot log in again.
pub fn a_session_ends_on_deactivation<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (admin, mut admin_key) = first_admin_stepped(h, &id);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, mut bo_key) = redeem(&id, &invited.secret, "Bo", 3);
    id.deactivate(&bo.session.user_id, "csv-import")
        .expect("the directory deactivates");
    assert_eq!(code(id.session(&bo.token)), Code::NoSession);
    kit.clock.advance(1);
    assert_eq!(code(login(&id, &mut bo_key, true)), Code::NotActive);
    let invited = id.invite(&admin.token, InviteAsk::default()).unwrap();
    let (cy, _) = redeem(&id, &invited.secret, "Cy", 4);
    kit.clock.advance(1);
    let plain = login(&id, &mut admin_key, false).unwrap();
    assert_eq!(
        code(id.deactivate_as(&plain.token, &cy.session.user_id)),
        Code::NeedsStepUp
    );
    assert_eq!(
        code(id.deactivate_as(&cy.token, &admin.session.user_id)),
        Code::NeedsStepUp
    );
    step_up(&id, &plain.token, &mut admin_key).unwrap();
    id.deactivate_as(&plain.token, &cy.session.user_id)
        .expect("a stepped-up admin deactivates");
    assert_eq!(code(id.session(&cy.token)), Code::NoSession);
}

/// A step-up is a fresh, verified touch of one's OWN passkey, for ten minutes.
pub fn a_step_up_is_ones_own_fresh_touch<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (first, mut admin_key) = first_admin_stepped(h, &id);
    let invited = id.invite(&first.token, InviteAsk::default()).unwrap();
    let (_, mut bo_key) = redeem(&id, &invited.secret, "Bo", 3);
    kit.clock.advance(1);
    let admin = login(&id, &mut admin_key, false).expect("the admin logs in unverified");
    assert_eq!(
        code(id.require_step_up(&admin.token)),
        Code::NeedsStepUp,
        "a login without verification"
    );
    let c = id.step_up_start(&admin.token, None).unwrap();
    assert_eq!(
        code(id.step_up_finish(&admin.token, bo_key.step_up(&c, RP_ID, ORIGIN, true))),
        Code::Ceremony,
        "another person's passkey"
    );
    let c = id.step_up_start(&admin.token, None).unwrap();
    assert_eq!(
        code(id.step_up_finish(&admin.token, admin_key.step_up(&c, RP_ID, ORIGIN, false))),
        Code::Ceremony,
        "not verified"
    );
    let c = id.step_up_start(&admin.token, None).unwrap();
    let answer = admin_key.step_up(&c, RP_ID, ORIGIN, true);
    assert!(
        id.step_up_finish(&admin.token, answer.clone())
            .expect("a step-up")
            .stepped_up
    );
    assert_eq!(
        code(id.step_up_finish(&admin.token, answer)),
        Code::Ceremony,
        "a challenge is spent once"
    );
    assert!(id.require_step_up(&admin.token).is_ok());
    kit.clock.advance(10 * 60 + 1);
    assert_eq!(
        code(id.require_step_up(&admin.token)),
        Code::NeedsStepUp,
        "the window is ten minutes"
    );
    assert_eq!(code(id.step_up_start("nis1.forged", None)), Code::NoSession);
}

/// A session expires.
pub fn a_session_expires<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (admin, _) = first_admin(h, &id);
    assert!(id.session(&admin.token).is_ok());
    kit.clock.advance(id.config().session_ttl_secs);
    assert_eq!(code(id.session(&admin.token)), Code::NoSession);
}

/// **A passkey for a person who already is one** (another device, a lost key):
/// an admin mints it for anybody, a person for themself; it is redeemed onto
/// the SAME person, whose passkeys the ceremony excludes, and both log in.
pub fn an_invite_for_a_person_adds_their_passkey<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (admin, _) = first_admin_stepped(h, &id);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, mut first_key) = redeem(&id, &invited.secret, "Bo", 3);
    let again = id
        .invite(
            &admin.token,
            InviteAsk {
                person: Some(bo.session.user_id.clone()),
                ..InviteAsk::default()
            },
        )
        .expect("an admin mints a passkey link for a person");
    let c = id
        .redeem_start(&again.secret, "", &relay())
        .expect("the link opens a creation ceremony");
    assert_eq!(
        c.user_id, bo.session.user_id,
        "the ceremony is the person's own"
    );
    assert_eq!(
        c.exclude,
        vec![first_key.id.clone()],
        "the passkeys they hold are excluded"
    );
    let mut second_key = SoftPasskey::new(5);
    let second = id
        .redeem_finish(second_key.register(&c, ORIGIN))
        .expect("redeemed onto the person");
    assert_eq!(
        second.session.user_id, bo.session.user_id,
        "the same person, not a new one"
    );
    assert_eq!(
        id.host()
            .store()
            .credentials_of(&bo.session.user_id)
            .unwrap()
            .len(),
        2
    );
    kit.clock.advance(1);
    let bo_verified = login(&id, &mut first_key, true).unwrap();
    assert_eq!(bo_verified.session.user_id, bo.session.user_id);
    assert_eq!(
        login(&id, &mut second_key, true).unwrap().session.user_id,
        bo.session.user_id
    );
    let own = id.invite(
        &bo_verified.token,
        InviteAsk {
            person: Some(bo.session.user_id.clone()),
            ..InviteAsk::default()
        },
    );
    assert!(
        own.is_ok(),
        "a person mints a passkey link for themself: {own:?}"
    );
    let theirs = id.invite(
        &bo_verified.token,
        InviteAsk {
            person: Some(admin.session.user_id.clone()),
            ..InviteAsk::default()
        },
    );
    assert_eq!(code(theirs), Code::NotAdmin, "never for somebody else");
    let grouped = InviteAsk {
        person: Some(bo.session.user_id.clone()),
        groups: h.member_groups(),
        ..InviteAsk::default()
    };
    assert_eq!(
        code(id.invite(&admin.token, grouped)),
        Code::Malformed,
        "a passkey link carries no groups"
    );
    let nobody = InviteAsk {
        person: Some("u-nobody".into()),
        ..InviteAsk::default()
    };
    assert_eq!(
        code(id.invite(&admin.token, nobody)),
        Code::Malformed,
        "nobody of that id"
    );
    let stale = id
        .invite(
            &admin.token,
            InviteAsk {
                person: Some(bo.session.user_id.clone()),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    id.deactivate(&bo.session.user_id, "csv-import").unwrap();
    assert_eq!(
        code(id.redeem_start(&stale.secret, "", &relay())),
        Code::InviteNotOpen,
        "never onto a deactivated person"
    );
}

/// **The host's own link** (an installer's first admin, a shop's paid account):
/// a passkey for a person the host made, minted without a session.
pub fn the_host_mints_a_link_for_its_own_person<Hn: Harness>(h: &Hn) {
    let (id, _kit) = identity(h, Site::Private);
    let (admin, _) = first_admin_stepped(h, &id);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, _) = redeem(&id, &invited.secret, "Bo", 3);
    let link = id
        .enrolment(&bo.session.user_id, "shop", Some(7 * 24 * 3600))
        .expect("the host's link");
    assert!(!link.mailed, "the host hands its own link over");
    assert_eq!(
        link.expires_at,
        kit_now(&_kit) + 7 * 24 * 3600,
        "it lives as long as the host says"
    );
    let (again, _) = redeem(&id, &link.secret, "", 6);
    assert_eq!(again.session.user_id, bo.session.user_id);
    assert_eq!(
        code(id.enrolment("u-nobody", "shop", None)),
        Code::Malformed
    );
    // **A fed link**: the host holds only the SHA-256 of the secret (an
    // installer's break-glass token) and the link redeems once.
    let token = "the-installers-break-glass-token-0123456789";
    let hash = nornir_auth_webauthn::ceremony::sha256_hex(token.as_bytes());
    let fed = id
        .fed_enrolment(&bo.session.user_id, &hash, 3600, "installer")
        .expect("the fed link");
    assert!(
        id.list_invites(&admin.token)
            .unwrap()
            .iter()
            .any(|i| i.id == fed),
        "it is an open invite like any other"
    );
    let (by_token, _) = redeem(&id, token, "", 7);
    assert_eq!(by_token.session.user_id, bo.session.user_id);
    assert_eq!(
        code(id.redeem_start(token, "", &relay())),
        Code::InviteNotOpen,
        "once"
    );
    // RA5-33: a used hash is never re-opened. Fed again (an installer's token, a
    // first-login hash fed again on boot), it is refused and the spent secret stays spent.
    assert_eq!(
        code(id.fed_enrolment(&bo.session.user_id, &hash, 3600, "installer")),
        Code::InviteNotOpen,
        "a used hash"
    );
    assert_eq!(
        code(id.redeem_start(token, "", &relay())),
        Code::InviteNotOpen,
        "the spent secret stays spent"
    );
    assert_eq!(
        code(id.fed_enrolment(&bo.session.user_id, token, 3600, "installer")),
        Code::Malformed,
        "a hash, never the secret"
    );
    let other = "a-second-break-glass-token-0123456789abcdef";
    let fed = id
        .fed_enrolment(
            &bo.session.user_id,
            &nornir_auth_webauthn::ceremony::sha256_hex(other.as_bytes()),
            3600,
            "installer",
        )
        .unwrap();
    assert!(
        id.withdraw_invite(&fed, "installer").unwrap(),
        "the host withdraws its own link"
    );
    assert!(!id.withdraw_invite(&fed, "installer").unwrap(), "once");
    assert_eq!(
        code(id.redeem_start(other, "", &relay())),
        Code::InviteNotOpen,
        "withdrawn"
    );
}

/// **Invites are listed and withdrawn**: an admin sees and withdraws every
/// open one; a person sees their own and withdraws nobody else's.
pub fn invites_are_listed_and_revoked<Hn: Harness>(h: &Hn) {
    let (id, _kit) = identity(h, Site::Private);
    let (admin, _) = first_admin_stepped(h, &id);
    let first = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, mut bo_key) = redeem(&id, &first.secret, "Bo", 3);
    step_up(&id, &bo.token, &mut bo_key).unwrap();
    let for_cy = id
        .invite(
            &admin.token,
            InviteAsk {
                display: "Cy".into(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let bos_own = id
        .invite(
            &bo.token,
            InviteAsk {
                person: Some(bo.session.user_id.clone()),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let all: Vec<String> = id
        .list_invites(&admin.token)
        .unwrap()
        .into_iter()
        .map(|i| i.id)
        .collect();
    assert!(
        all.contains(&for_cy.invite_id) && all.contains(&bos_own.invite_id),
        "{all:?}"
    );
    assert!(
        !all.contains(&first.invite_id),
        "a redeemed invite is not open"
    );
    let mine = id.list_invites(&bo.token).unwrap();
    assert_eq!(
        mine.iter().map(|i| i.id.clone()).collect::<Vec<_>>(),
        vec![bos_own.invite_id.clone()],
        "a person sees their own"
    );
    assert_eq!(mine[0].person.as_deref(), Some(bo.session.user_id.as_str()));
    let cy = id
        .list_invites(&admin.token)
        .unwrap()
        .into_iter()
        .find(|i| i.id == for_cy.invite_id)
        .unwrap();
    assert_eq!(
        (
            cy.display.as_str(),
            cy.person.as_deref(),
            cy.created_by.as_str()
        ),
        ("Cy", None, admin.session.user_id.as_str())
    );
    assert_eq!(
        code(id.revoke_invite(&bo.token, &for_cy.invite_id)),
        Code::NotAdmin,
        "nobody else's"
    );
    id.revoke_invite(&admin.token, &for_cy.invite_id)
        .expect("an admin withdraws an invite");
    assert_eq!(
        code(id.redeem_start(&for_cy.secret, "Cy", &relay())),
        Code::InviteNotOpen,
        "withdrawn"
    );
    id.revoke_invite(&bo.token, &bos_own.invite_id)
        .expect("a person withdraws their own");
    assert!(id.list_invites(&admin.token).unwrap().is_empty());
    assert_eq!(
        code(id.revoke_invite(&admin.token, &for_cy.invite_id)),
        Code::InviteNotOpen,
        "once"
    );
    assert_eq!(code(id.list_invites("nis1.forged")), Code::NoSession);
}

/// **Sessions end from outside a login**: the host ends one person's (a
/// revoked passkey), a stepped-up admin ends everyone's; a person the host
/// vouched for (company sign-on) is given a session, never a deactivated one.
pub fn sessions_end_and_the_host_vouches<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (admin, mut admin_key) = first_admin_stepped(h, &id);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, _) = redeem(&id, &invited.secret, "Bo", 3);
    id.end_sessions(&bo.session.user_id, "a revoked passkey")
        .expect("the host ends a person's sessions");
    assert_eq!(code(id.session(&bo.token)), Code::NoSession);
    assert!(id.session(&admin.token).is_ok(), "nobody else's");
    kit.clock.advance(1);
    let vouched = id
        .vouched_session(&bo.session.user_id, true)
        .expect("a vouched person's session");
    assert_eq!(vouched.session.user_id, bo.session.user_id);
    assert!(
        vouched.session.stepped_up,
        "the host said the person verified themself"
    );
    assert_eq!(code(id.vouched_session("u-nobody", true)), Code::NotActive);
    assert_eq!(
        code(id.end_all(&vouched.token)),
        Code::NotAdmin,
        "only an admin ends everyone's"
    );
    let plain = login(&id, &mut admin_key, false).unwrap();
    assert_eq!(code(id.end_all(&plain.token)), Code::NeedsStepUp);
    step_up(&id, &plain.token, &mut admin_key).unwrap();
    id.end_all(&plain.token)
        .expect("a stepped-up admin ends every session");
    assert_eq!(code(id.session(&vouched.token)), Code::NoSession);
    assert_eq!(
        code(id.session(&plain.token)),
        Code::NoSession,
        "their own too"
    );
    id.deactivate(&bo.session.user_id, "csv-import").unwrap();
    kit.clock.advance(1);
    assert_eq!(
        code(id.vouched_session(&bo.session.user_id, true)),
        Code::NotActive
    );
    assert!(
        login(&id, &mut admin_key, true).is_ok(),
        "a later login works"
    );
}

/// ★ RA5-2: **an invite and a passkey link need a fresh touch**, as deactivating
/// and ending everyone's sessions do. A stolen or idle session (a plain login, a
/// registration) mints nothing: not a passkey link for the owner (which would
/// add the thief's passkey to the owner's account and outlive the session), not
/// one for itself, not a new person's invite. Refused by name, nothing stored,
/// nothing mailed; after a step-up the same asks work, for ten minutes.
pub fn an_invite_needs_a_fresh_step_up<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Private);
    let (owner, mut owner_key) = first_admin(h, &id);
    let for_owner = || InviteAsk {
        person: Some(owner.session.user_id.clone()),
        email: Some("x@mock.invalid".into()),
        ..InviteAsk::default()
    };
    assert_eq!(
        code(id.invite(&owner.token, for_owner())),
        Code::NeedsStepUp,
        "a registration is no fresh touch"
    );
    kit.clock.advance(1);
    let plain = login(&id, &mut owner_key, false).unwrap();
    assert!(plain.session.admin && !plain.session.stepped_up);
    let refused = id.invite(&plain.token, for_owner()).unwrap_err();
    assert_eq!(
        refused.code,
        Code::NeedsStepUp,
        "a plain session's CreateInvite{{person=owner}} is refused by name"
    );
    assert_eq!(refused.code.as_str(), "needs_step_up");
    let new_person = InviteAsk {
        email: Some("bo@mock.invalid".into()),
        groups: h.member_groups(),
        ..InviteAsk::default()
    };
    assert_eq!(
        code(id.invite(&plain.token, new_person.clone())),
        Code::NeedsStepUp,
        "a new person's invite too"
    );
    assert!(
        id.list_invites(&plain.token).unwrap().is_empty(),
        "nothing is stored"
    );
    assert!(kit.mail.sent().is_empty(), "nothing is mailed");

    step_up(&id, &plain.token, &mut owner_key).expect("the owner steps up");
    let link = id
        .invite(&plain.token, for_owner())
        .expect("a stepped-up session mints the owner's passkey link");
    let c = id
        .redeem_start(&link.secret, "", &relay())
        .expect("it opens a creation ceremony");
    assert_eq!(c.user_id, owner.session.user_id);
    let invited = id
        .invite(&plain.token, new_person)
        .expect("and a new person's invite");
    let (bo, mut bo_key) = redeem(&id, &invited.secret, "Bo", 3);
    let own = || InviteAsk {
        person: Some(bo.session.user_id.clone()),
        ..InviteAsk::default()
    };
    assert_eq!(
        code(id.invite(&bo.token, own())),
        Code::NeedsStepUp,
        "a person's own passkey link too"
    );
    step_up(&id, &bo.token, &mut bo_key).unwrap();
    assert!(
        id.invite(&bo.token, own()).is_ok(),
        "after their own fresh touch"
    );

    kit.clock.advance(10 * 60 + 1);
    assert_eq!(
        code(id.invite(&plain.token, for_owner())),
        Code::NeedsStepUp,
        "the window is ten minutes"
    );
}

/// ★ RA5-8: **a sign-up flood evicts no invite.** Sign-ups (no session, a Root
/// site) and the links an admin or the host mints are bounded apart: past
/// [`crate::MAX_OPEN_SIGN_UPS`] open first-login links a sign-up is refused by
/// name ([`Code::Busy`]: nothing stored, nothing mailed), and an admin's invite
/// and the host's enrolment link stay open. One address holds one open link:
/// signing up again replaces it. Past ITS bound an admin's own oldest open
/// invite goes, never a sign-up's.
pub fn a_sign_up_flood_evicts_no_invite<Hn: Harness>(h: &Hn) {
    let (id, kit) = identity(h, Site::Root);
    let (admin, _) = first_admin_stepped(h, &id);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let link = id
        .enrolment(&admin.session.user_id, "installer", None)
        .unwrap();
    let flood = |n: usize| SignUp {
        email: format!("flood{n}@mock.invalid"),
        display: String::new(),
    };
    for n in 0..crate::MAX_OPEN_SIGN_UPS {
        id.sign_up(flood(n)).expect("a sign-up under the bound");
    }
    let r = id.sign_up(SignUp {
        email: "one-more@mock.invalid".into(),
        display: String::new(),
    });
    assert_eq!(
        r.as_ref().map_err(|e| e.code),
        Err(Code::Busy),
        "past the bound a sign-up is refused, not an invite evicted"
    );
    assert!(
        kit.mail.last_to("one-more@mock.invalid").is_none(),
        "nothing is mailed"
    );
    assert!(
        id.host()
            .store()
            .user_by_email("one-more@mock.invalid")
            .unwrap()
            .is_none(),
        "nothing is stored"
    );
    let open: Vec<String> = id
        .list_invites(&admin.token)
        .unwrap()
        .into_iter()
        .map(|i| i.id)
        .collect();
    assert!(
        open.contains(&invited.invite_id) && open.contains(&link.invite_id),
        "the admin's invite and the host's link stay open"
    );
    let first = kit
        .mail
        .last_to("flood0@mock.invalid")
        .expect("the first sign-up's mail")
        .secret;
    id.sign_up(flood(0))
        .expect("the same address again, at the bound");
    let again = kit.mail.last_to("flood0@mock.invalid").unwrap().secret;
    assert_ne!(first, again);
    assert_eq!(
        code(id.redeem_start(&first, "", &relay())),
        Code::InviteNotOpen,
        "one address holds one open link: the latest"
    );
    assert!(id.redeem_start(&again, "", &relay()).is_ok());
    let (bo, _) = redeem(&id, &invited.secret, "Bo", 3);
    assert!(!bo.session.admin);
    let oldest = id.invite(&admin.token, InviteAsk::default()).unwrap();
    for _ in 0..crate::MAX_OPEN_SIGN_UPS {
        id.invite(&admin.token, InviteAsk::default()).unwrap();
    }
    assert_eq!(
        code(id.redeem_start(&oldest.secret, "", &relay())),
        Code::InviteNotOpen,
        "an admin's flood drops its own oldest"
    );
    let one = kit.mail.last_to("flood1@mock.invalid").unwrap().secret;
    assert!(
        id.redeem_start(&one, "", &relay()).is_ok(),
        "never a sign-up's link"
    );
}

/// **One scripted walk, every answer written without ids, tokens or times**:
/// the same lines from every product, or a diff that names the step.
pub fn transcript<Hn: Harness>(h: &Hn) -> Vec<String> {
    fn line<T>(step: &str, r: &Result<T, crate::Refused>, ok: impl Fn(&T) -> String) -> String {
        match r {
            Ok(v) => format!("{step}: ok {}", ok(v)).trim_end().to_owned(),
            Err(e) => format!("{step}: refused {}", e.code),
        }
    }
    let view = |s: &crate::SessionView| {
        format!(
            "display={} admin={} stepped_up={}",
            s.display, s.admin, s.stepped_up
        )
    };
    let mut out = Vec::new();
    for site in [Site::Root, Site::Private] {
        let (id, kit) = identity(h, site);
        out.push(format!("site {site:?}"));
        let r = id.sign_up(SignUp {
            email: "kim@mock.invalid".into(),
            display: "Kim".into(),
        });
        out.push(line("sign_up", &r, |_| {
            format!("mails={}", kit.mail.sent().len())
        }));
        let (admin, mut key) = first_admin(h, &id);
        out.push(format!("first_admin: {}", view(&admin.session)));
        out.push(line(
            "invite_unstepped",
            &id.invite(&admin.token, InviteAsk::default()),
            |_| String::new(),
        ));
        out.push(line("step_up", &step_up(&id, &admin.token, &mut key), view));
        let r = id.invite(
            &admin.token,
            InviteAsk {
                groups: h.admin_groups(),
                ..InviteAsk::default()
            },
        );
        out.push(line("invite_admin", &r, |_| String::new()));
        let r = id.invite(
            &admin.token,
            InviteAsk {
                email: Some("bo@mock.invalid".into()),
                groups: h.member_groups(),
                ..InviteAsk::default()
            },
        );
        out.push(line("invite", &r, |i| format!("mailed={}", i.mailed)));
        if let Ok(invited) = r {
            let (bo, mut bo_key) = redeem(&id, &invited.secret, "Bo", 3);
            out.push(format!("redeem: {}", view(&bo.session)));
            out.push(line(
                "redeem_again",
                &id.redeem_start(&invited.secret, "Eve", &relay()),
                |_| String::new(),
            ));
            out.push(line(
                "bo_invites_unstepped",
                &id.invite(&bo.token, InviteAsk::default()),
                |_| String::new(),
            ));
            out.push(line(
                "bo_step_up",
                &step_up(&id, &bo.token, &mut bo_key),
                view,
            ));
            out.push(line(
                "bo_invites",
                &id.invite(&bo.token, InviteAsk::default()),
                |_| String::new(),
            ));
            kit.clock.advance(10 * 60 + 1);
            out.push(line(
                "deactivate_unstepped",
                &id.deactivate_as(&admin.token, &bo.session.user_id),
                |_| String::new(),
            ));
            out.push(line(
                "step_up_again",
                &step_up(&id, &admin.token, &mut key),
                view,
            ));
            out.push(line(
                "deactivate",
                &id.deactivate_as(&admin.token, &bo.session.user_id),
                |_| String::new(),
            ));
            out.push(line("bo_session", &id.session(&bo.token), view));
        }
        kit.clock.advance(1);
        let r = login(&id, &mut key, true);
        out.push(line("login", &r, |l| view(&l.session)));
        if let Ok(l) = r {
            out.push(line("logout", &id.logout(&l.token, false), |_| {
                String::new()
            }));
            out.push(line("after_logout", &id.session(&l.token), view));
        }
    }
    out
}

/// **The suite as `#[test]`s** for one [`Harness`] expression.
#[macro_export]
macro_rules! conformance_suite {
    ($harness:expr) => {
        #[test]
        fn sign_up_on_a_root_mails_a_link_that_registers_and_logs_in() {
            $crate::conformance::sign_up_on_a_root_mails_a_link_that_registers_and_logs_in(&$harness)
        }
        #[test]
        fn sign_up_on_a_private_site_is_refused() {
            $crate::conformance::sign_up_on_a_private_site_is_refused(&$harness)
        }
        #[test]
        fn sign_up_with_a_refusing_mailer_stores_nothing() {
            $crate::conformance::sign_up_with_a_refusing_mailer_stores_nothing(&$harness)
        }
        #[test]
        fn an_invite_is_redeemed_once() {
            $crate::conformance::an_invite_is_redeemed_once(&$harness)
        }
        #[test]
        fn an_invite_never_makes_an_admin() {
            $crate::conformance::an_invite_never_makes_an_admin(&$harness)
        }
        #[test]
        fn a_passkey_logs_in_and_a_replay_is_refused() {
            $crate::conformance::a_passkey_logs_in_and_a_replay_is_refused(&$harness)
        }
        #[test]
        fn a_session_ends_on_logout() {
            $crate::conformance::a_session_ends_on_logout(&$harness)
        }
        #[test]
        fn a_login_in_the_second_of_a_logout_everywhere_is_never_a_dead_session() {
            $crate::conformance::a_login_in_the_second_of_a_logout_everywhere_is_never_a_dead_session(&$harness)
        }
        #[test]
        fn a_session_ends_on_deactivation() {
            $crate::conformance::a_session_ends_on_deactivation(&$harness)
        }
        #[test]
        fn a_step_up_is_ones_own_fresh_touch() {
            $crate::conformance::a_step_up_is_ones_own_fresh_touch(&$harness)
        }
        #[test]
        fn a_session_expires() {
            $crate::conformance::a_session_expires(&$harness)
        }
        #[test]
        fn an_invite_for_a_person_adds_their_passkey() {
            $crate::conformance::an_invite_for_a_person_adds_their_passkey(&$harness)
        }
        #[test]
        fn the_host_mints_a_link_for_its_own_person() {
            $crate::conformance::the_host_mints_a_link_for_its_own_person(&$harness)
        }
        #[test]
        fn invites_are_listed_and_revoked() {
            $crate::conformance::invites_are_listed_and_revoked(&$harness)
        }
        #[test]
        fn sessions_end_and_the_host_vouches() {
            $crate::conformance::sessions_end_and_the_host_vouches(&$harness)
        }
        #[test]
        fn an_invite_needs_a_fresh_step_up() {
            $crate::conformance::an_invite_needs_a_fresh_step_up(&$harness)
        }
        #[test]
        fn a_sign_up_flood_evicts_no_invite() {
            $crate::conformance::a_sign_up_flood_evicts_no_invite(&$harness)
        }
        #[test]
        fn the_transcript_is_the_reference_one() {
            assert_eq!($crate::conformance::transcript(&$harness), $crate::conformance::REFERENCE);
        }
    };
}

/// **The transcript every host must write** (the TestHost's, checked in).
pub const REFERENCE: &[&str] = &[
    "site Root",
    "sign_up: ok mails=1",
    "first_admin: display=Ada admin=true stepped_up=false",
    "invite_unstepped: refused needs_step_up",
    "step_up: ok display=Ada admin=true stepped_up=true",
    "invite_admin: refused invite_would_be_admin",
    "invite: ok mailed=true",
    "redeem: display=Bo admin=false stepped_up=false",
    "redeem_again: refused invite_not_open",
    "bo_invites_unstepped: refused needs_step_up",
    "bo_step_up: ok display=Bo admin=false stepped_up=true",
    "bo_invites: refused not_admin",
    "deactivate_unstepped: refused needs_step_up",
    "step_up_again: ok display=Ada admin=true stepped_up=true",
    "deactivate: ok",
    "bo_session: refused no_session",
    "login: ok display=Ada admin=true stepped_up=true",
    "logout: ok",
    "after_logout: refused no_session",
    "site Private",
    "sign_up: refused signup_closed",
    "first_admin: display=Ada admin=true stepped_up=false",
    "invite_unstepped: refused needs_step_up",
    "step_up: ok display=Ada admin=true stepped_up=true",
    "invite_admin: refused invite_would_be_admin",
    "invite: ok mailed=true",
    "redeem: display=Bo admin=false stepped_up=false",
    "redeem_again: refused invite_not_open",
    "bo_invites_unstepped: refused needs_step_up",
    "bo_step_up: ok display=Bo admin=false stepped_up=true",
    "bo_invites: refused not_admin",
    "deactivate_unstepped: refused needs_step_up",
    "step_up_again: ok display=Ada admin=true stepped_up=true",
    "deactivate: ok",
    "bo_session: refused no_session",
    "login: ok display=Ada admin=true stepped_up=true",
    "logout: ok",
    "after_logout: refused no_session",
];