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