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/tests/conformance.rs

//! The conformance suite against the TestHost: the reference every product's
//! host must match (gunnar-server and holger-server run the same macro).
#![cfg(feature = "test-support")]

use nornir_identity::test_host::{Kit, TestHost, TEST_ADMINS};
use nornir_identity::{conformance::Harness, Site};

struct TestHarness;

impl Harness for TestHarness {
    type Host = TestHost;
    fn host(&self, site: Site, kit: &Kit) -> TestHost {
        TestHost::new(site, kit)
    }
    fn admin_groups(&self) -> Vec<String> {
        vec![TEST_ADMINS.to_owned()]
    }
    fn member_groups(&self) -> Vec<String> {
        vec!["readers".to_owned()]
    }
}

nornir_identity::conformance_suite!(TestHarness);

#[test]
fn the_host_is_told_no_secret() {
    use nornir_identity::conformance::{first_admin, identity};
    let (id, kit) = identity(&TestHarness, Site::Root);
    id.sign_up(nornir_identity::SignUp {
        email: "kim@mock.invalid".into(),
        display: String::new(),
    })
    .unwrap();
    let (admin, _) = first_admin(&TestHarness, &id);
    let told = format!("{:?}", id.host().events());
    assert!(
        !told.contains(&admin.token),
        "no session token in the audit"
    );
    assert!(
        !told.contains(&kit.mail.sent()[0].secret),
        "no link secret in the audit"
    );
    assert!(
        !format!("{admin:?}").contains(&admin.token),
        "a session's Debug redacts its token"
    );
}

/// ★ **A refused login names the person only once their passkey signed** (0.2.1, RA5-29):
/// the product writes the refusal to that person's account audit, as Gunnar's old WebLogin
/// did. A credential id is no secret (excludeCredentials, allowCredentials), so a signature
/// that does not verify names nobody: anyone could otherwise write "refused passkey login"
/// rows into any person's audit without the key. A passkey this site does not know names
/// nobody either.
#[test]
fn a_refused_login_names_the_person_only_after_their_passkey_signed() {
    use nornir_identity::conformance::{first_admin_stepped, identity, login, redeem, relay};
    use nornir_identity::test_host::SoftPasskey;
    use nornir_identity::{Code, Event, InviteAsk};
    let refusals = |id: &nornir_identity::Identity<TestHost>| -> Vec<(Code, Option<String>)> {
        id.host()
            .events()
            .into_iter()
            .filter_map(|e| match e {
                Event::LoginRefused { code, user } => Some((code, user)),
                _ => None,
            })
            .collect()
    };
    let (id, kit) = identity(&TestHarness, Site::Private);
    let (admin, _) = first_admin_stepped(&TestHarness, &id);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: vec!["readers".into()],
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, mut bo_key) = redeem(&id, &invited.secret, "Bo", 3);
    kit.clock.advance(1);

    // A signature that does not verify, on Bo's passkey: nobody is named.
    let c = id.login_start(&relay()).unwrap();
    let mut forged = bo_key.login(&c, nornir_identity::conformance::ORIGIN, false);
    let last = forged.signature.len() - 1;
    forged.signature[last] ^= 0xff;
    assert!(id.login_finish(forged).is_err());
    // Bo deactivated: refused, and Bo was named.
    id.deactivate(&bo.session.user_id, "csv-import").unwrap();
    kit.clock.advance(1);
    assert_eq!(
        login(&id, &mut bo_key, true).unwrap_err().code,
        Code::NotActive
    );
    // A stranger's passkey names nobody.
    assert!(login(&id, &mut SoftPasskey::new(9), true).is_err());

    let bo_id = Some(bo.session.user_id.clone());
    assert_eq!(
        refusals(&id),
        vec![
            (Code::Ceremony, None),
            (Code::NotActive, bo_id),
            (Code::Ceremony, None)
        ],
        "{:?}",
        id.host().events()
    );
}

/// ★ **A replayed counter names the person: their passkey signed** (GUN-FIELD-TEST,
/// 2026-10-31). A counter that did not grow is a cloned authenticator or a replay, and the
/// signature over it VERIFIED with the person's key, so the RA5-29 rule (named once the
/// passkey signed) names them: it is the refusal an account admin most needs in that
/// person's account audit. 0.2.3 checked the counter inside the signature check and named
/// nobody, and Gunnar's enterprise test (`the_account_admin_reads_every_security_event_and_
/// nobody_else_does`) lost the row it had in 0.2.1.
#[test]
fn a_replayed_counter_names_the_person_whose_passkey_signed() {
    use nornir_identity::conformance::{first_admin_stepped, identity, login, redeem};
    use nornir_identity::test_host::SoftPasskey;
    use nornir_identity::{Code, Event, InviteAsk};
    let (id, kit) = identity(&TestHarness, Site::Private);
    let (admin, _) = first_admin_stepped(&TestHarness, &id);
    let invited = id
        .invite(
            &admin.token,
            InviteAsk {
                groups: vec!["readers".into()],
                ..InviteAsk::default()
            },
        )
        .unwrap();
    let (bo, mut bo_key) = redeem(&id, &invited.secret, "Bo", 3);
    kit.clock.advance(1);
    login(&id, &mut bo_key, true).expect("Bo logs in, and the counter counted");
    kit.clock.advance(1);
    // The same key with its counter back at the start: a clone of Bo's authenticator.
    let mut clone = SoftPasskey::new(3);
    let refused = login(&id, &mut clone, true).expect_err("a counter that did not grow is refused");
    assert_eq!(refused.code, Code::Ceremony, "{refused:?}");
    let named: Vec<Option<String>> = id
        .host()
        .events()
        .into_iter()
        .filter_map(|e| match e {
            Event::LoginRefused { user, .. } => Some(user),
            _ => None,
        })
        .collect();
    assert_eq!(
        named,
        vec![Some(bo.session.user_id.clone())],
        "{:?}",
        id.host().events()
    );
}

/// ★ **RA5-33: a used fed hash stays used when the invite table is trimmed.** The bound
/// drops closed invites first; a spent fed link is the record that its hash was used, so
/// dropping it would let the same hash, fed again, open the spent secret once more.
#[test]
fn a_used_fed_hash_outlives_the_trim_of_closed_invites() {
    use nornir_identity::conformance::{first_admin, identity, redeem, relay};
    use nornir_identity::Code;
    let (id, kit) = identity(&TestHarness, Site::Private);
    let (admin, _) = first_admin(&TestHarness, &id);
    let token = "the-installers-break-glass-token-0123456789";
    let hash = nornir_auth_webauthn::ceremony::sha256_hex(token.as_bytes());
    id.fed_enrolment(&admin.session.user_id, &hash, 3600, "installer")
        .unwrap();
    redeem(&id, token, "", 5);
    // Enough closed links to trim the table past both of its bounds.
    for n in 0..(2 * nornir_identity_session_max() + 8) {
        let other = nornir_auth_webauthn::ceremony::sha256_hex(format!("filler-{n}").as_bytes());
        id.fed_enrolment(&admin.session.user_id, &other, 60, "installer")
            .unwrap();
    }
    kit.clock.advance(120);
    let last = nornir_auth_webauthn::ceremony::sha256_hex(b"the-last-one");
    id.fed_enrolment(&admin.session.user_id, &last, 60, "installer")
        .unwrap();
    assert_eq!(
        id.fed_enrolment(&admin.session.user_id, &hash, 3600, "installer")
            .unwrap_err()
            .code,
        Code::InviteNotOpen,
        "the used hash was trimmed away and opened again"
    );
    assert_eq!(
        id.redeem_start(token, "", &relay()).unwrap_err().code,
        Code::InviteNotOpen
    );
}

fn nornir_identity_session_max() -> usize {
    nornir_identity::MAX_OPEN_SIGN_UPS
}

/// ★ **RA5-30: a redemption whose store write fails leaves the link usable.** The invite
/// was spent before the person and the passkey were written, so a failed write left the
/// link spent and, for a new invite, nothing to log in with.
#[test]
fn a_failed_redemption_write_leaves_the_link_open() {
    use nornir_identity::conformance::{first_admin_stepped, identity, redeem, relay, ORIGIN};
    use nornir_identity::test_host::SoftPasskey;
    use nornir_identity::{Code, InviteAsk};
    for write in ["put_user", "put_credential"] {
        let (id, _kit) = identity(&TestHarness, Site::Private);
        let (admin, _) = first_admin_stepped(&TestHarness, &id);
        let invited = id
            .invite(
                &admin.token,
                InviteAsk {
                    groups: vec!["readers".into()],
                    ..InviteAsk::default()
                },
            )
            .unwrap();
        let mut key = SoftPasskey::new(4);
        let c = id.redeem_start(&invited.secret, "Bo", &relay()).unwrap();
        id.host().store.fail(write, true);
        assert_eq!(
            id.redeem_finish(key.register(&c, ORIGIN)).unwrap_err().code,
            Code::Store,
            "{write}"
        );
        id.host().store.fail(write, false);
        let (bo, _) = redeem(&id, &invited.secret, "Bo", 5);
        assert!(
            id.session(&bo.token).is_ok(),
            "{write}: the link still works after the failed write"
        );
    }
}

/// ★ **RA5-30: a sign-up mails its link only once the link is stored.** It mailed first,
/// so a failed store mailed a link that did not work.
#[test]
fn a_sign_up_whose_store_fails_mails_nothing() {
    use nornir_identity::conformance::identity;
    use nornir_identity::{Code, SignUp};
    for write in ["put_user", "update_invites"] {
        let (id, kit) = identity(&TestHarness, Site::Root);
        id.host().store.fail(write, true);
        let r = id.sign_up(SignUp {
            email: "kim@mock.invalid".into(),
            display: "Kim".into(),
        });
        assert_eq!(r.unwrap_err().code, Code::Store, "{write}");
        assert!(
            kit.mail.sent().is_empty(),
            "{write}: a link was mailed that the store does not hold"
        );
    }
}