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

nordisk/gunnar

crates/gunnar-core/tests/sso_directory.rs

//! **Company SSO, the stored rules** (owner, 2026-10-05). What the identity
//! table itself promises, under every protocol and every door:
//!
//! * a first login creates a NEW employee of THAT account — an existing
//!   principal is never attached, whatever its name;
//! * a subject is bound per account: the same IdP subject in another account
//!   is another person there, never the first account's employee;
//! * a provisioned user (SCIM/CSV) is claimed by its directory name once, and
//!   never re-pointed to another subject;
//! * deactivation gates the employee and ends its web sessions; reactivation
//!   lifts only the directory's own gate; data is kept;
//! * the account admin is never an employee (the break-glass stays theirs).

mod common;

use common::{Fixture, KAT_ED25519, OTHER_ED25519};
use gunnar_core::sso::{self, SsoProtocol, SsoProvider, SsoSource};
use gunnar_core::{
    Level, Principal, PrincipalKind, RoomAsk, Scope, SsoImportOp, SsoImportRow, SsoLogin,
    SsoProvision, SsoUserPatch,
};

const NOW: i64 = 1_800_000_000_000;

fn fixture_with_account(account: &str) -> Fixture {
    let f = Fixture::new();
    f.catalog.create_namespace(account).expect("namespace");
    f.auth
        .add_principal(Principal::new(account, account, PrincipalKind::User).unwrap())
        .expect("account principal");
    f.auth
        .set_sso_provider(provider(account, &[]))
        .expect("provider");
    f
}

fn provider(account: &str, domains: &[&str]) -> SsoProvider {
    SsoProvider {
        account: account.to_owned(),
        protocol: SsoProtocol::Oidc,
        display_name: format!("{account} corp"),
        domains: domains.iter().map(|d| d.to_string()).collect(),
        oidc: Some(sso::OidcSettings {
            issuer: "https://idp.example.test".into(),
            client_id: "c".into(),
            ..Default::default()
        }),
        saml: None,
        require_sso: false,
        require_passkey: false,
        console_origin: "https://gunnar.example".into(),
        updated: NOW,
        updated_by: account.to_owned(),
        verified_domains: Vec::new(),
        jit: String::new(),
    }
}

fn candidates(account: &str, user: &str) -> Vec<Principal> {
    sso::principal_id_candidates(account, user)
        .into_iter()
        .map(|id| Principal::new(&id, &id, PrincipalKind::User).unwrap())
        .collect()
}

fn login(account: &str, subject: &str, user: &str, session: Option<&str>) -> SsoLogin {
    SsoLogin {
        account: account.to_owned(),
        subject: sso::subject_key("https://idp.example.test", subject),
        user_name: user.to_owned(),
        display_name: String::new(),
        email: user.to_owned(),
        email_verified: false,
        idp_subjects: vec![subject.to_owned()],
        candidates: candidates(account, user),
        room: RoomAsk {
            room_bytes: 10 << 30,
            ..RoomAsk::default()
        },
        session: session.map(|k| (k.to_owned(), NOW + 3_600_000)),
        now: NOW,
    }
}

/// ★ First login: a NEW employee of the account, with the account's Read
/// grant and no namespace of its own; the second login is the same person.
#[test]
fn a_first_login_creates_a_new_employee_and_the_second_finds_it() {
    let f = fixture_with_account("acme");
    let first = f
        .auth
        .sso_login(&login(
            "acme",
            "00u1",
            "bob@acme.example",
            Some(KAT_ED25519),
        ))
        .expect("first login");
    assert!(first.created && !first.claimed);
    assert_eq!(first.principal.id, "acme.bob");
    assert_eq!(
        f.auth.employer_of("acme.bob").unwrap().as_deref(),
        Some("acme")
    );
    let grants = f.auth.grants(Some("acme.bob")).unwrap();
    assert_eq!(grants.len(), 1);
    assert_eq!(grants[0].scope, Scope::Namespace("acme".into()));
    assert_eq!(grants[0].level, Level::Read);
    let session = first.session.expect("a session key");
    assert_eq!(session.principal_id, "acme.bob");
    assert_eq!(session.expires, Some(NOW + 3_600_000));
    assert!(f
        .auth
        .keys_of("acme.bob")
        .unwrap()
        .iter()
        .any(|k| k.fingerprint_sha256 == session.fingerprint_sha256));

    let again = f
        .auth
        .sso_login(&login(
            "acme",
            "00u1",
            "bob@acme.example",
            Some(OTHER_ED25519),
        ))
        .expect("second login");
    assert!(!again.created && !again.claimed);
    assert_eq!(again.principal.id, "acme.bob");
    assert_eq!(again.user.sessions.len(), 2);
}

/// ★ **An existing principal is never attached.** A user `acme.bob` that
/// exists on the server (not an employee, somebody else entirely) is passed
/// over: the login creates a NEW employee under the next id.
#[test]
fn an_existing_principal_is_never_attached() {
    let f = fixture_with_account("acme");
    f.auth
        .add_principal(Principal::new("acme.bob", "acme.bob", PrincipalKind::User).unwrap())
        .expect("a stranger named acme.bob");
    let done = f
        .auth
        .sso_login(&login("acme", "00u1", "bob@acme.example", None))
        .expect("login");
    assert!(done.created);
    assert_eq!(done.principal.id, "acme.bob-2");
    assert_eq!(
        f.auth.employer_of("acme.bob").unwrap(),
        None,
        "the stranger became an employee"
    );
    assert!(f.auth.sso_user("acme.bob").unwrap().is_none());
}

/// ★ **Never across accounts.** The same IdP subject logging into another
/// account is a NEW employee THERE; nothing of the first account's employee
/// is reachable from the second account's IdP.
#[test]
fn a_subject_of_one_account_is_never_another_accounts_employee() {
    let f = fixture_with_account("acme");
    f.catalog.create_namespace("globex").unwrap();
    f.auth
        .add_principal(Principal::new("globex", "globex", PrincipalKind::User).unwrap())
        .unwrap();
    f.auth.set_sso_provider(provider("globex", &[])).unwrap();
    let a = f
        .auth
        .sso_login(&login("acme", "same-sub", "bob@x.example", None))
        .unwrap();
    let g = f
        .auth
        .sso_login(&login("globex", "same-sub", "bob@x.example", None))
        .unwrap();
    assert_ne!(a.principal.id, g.principal.id);
    assert_eq!(
        f.auth.employer_of(&g.principal.id).unwrap().as_deref(),
        Some("globex")
    );
    assert_eq!(
        f.auth.employer_of(&a.principal.id).unwrap().as_deref(),
        Some("acme")
    );
    // An account with no identity provider has no SSO login at all.
    f.catalog.create_namespace("initech").unwrap();
    f.auth
        .add_principal(Principal::new("initech", "initech", PrincipalKind::User).unwrap())
        .unwrap();
    let err = f
        .auth
        .sso_login(&login("initech", "same-sub", "bob@x.example", None))
        .unwrap_err();
    assert!(err.to_string().contains("no identity provider"), "{err}");
}

/// A provisioned user is claimed by its directory name ONCE; another subject
/// presenting the same name later is refused, never re-pointed.
#[test]
fn a_provisioned_user_is_claimed_once_and_never_repointed() {
    let f = fixture_with_account("acme");
    let user = f
        .auth
        .sso_provision(&SsoProvision {
            account: "acme".into(),
            user_name: "carol@acme.example".into(),
            display_name: "Carol".into(),
            email: "carol@acme.example".into(),
            external_id: "ext-1".into(),
            active: true,
            source: SsoSource::Scim,
            candidates: candidates("acme", "carol@acme.example"),
            room: RoomAsk {
                room_bytes: 10 << 30,
                ..RoomAsk::default()
            },
            actor: "acme".into(),
            now: NOW,
        })
        .expect("provisioned");
    assert_eq!(user.principal_id, "acme.carol");
    assert!(user.subject.is_none());
    // The admin's binding: the row's externalId is carol's id at the IdP
    // (A34-2: a user name alone never claims).
    let claimed = f
        .auth
        .sso_login(&login("acme", "ext-1", "carol@acme.example", None))
        .expect("claim");
    assert!(claimed.claimed && !claimed.created);
    assert_eq!(claimed.principal.id, "acme.carol");
    let err = f
        .auth
        .sso_login(&login("acme", "sub-mallory", "carol@acme.example", None))
        .unwrap_err();
    assert!(err.to_string().contains("never re-pointed"), "{err}");
    // A second provisioning of the same name is SCIM's `uniqueness`.
    let dup = f.auth.sso_provision(&SsoProvision {
        account: "acme".into(),
        user_name: "carol@acme.example".into(),
        display_name: String::new(),
        email: String::new(),
        external_id: String::new(),
        active: true,
        source: SsoSource::Csv,
        candidates: candidates("acme", "carol@acme.example"),
        room: RoomAsk::default(),
        actor: "acme".into(),
        now: NOW,
    });
    assert!(
        matches!(dup, Err(gunnar_core::Error::NameExists { .. })),
        "{dup:?}"
    );
}

/// ★ Deactivation: gated (push refused — `may` says no anywhere), every web
/// session key revoked, data kept; the login is refused. Reactivation lifts
/// the directory's own gate and nobody else's.
#[test]
fn deactivation_gates_revokes_sessions_and_reactivation_lifts_only_its_own_gate() {
    let f = fixture_with_account("acme");
    let repo = f.repo("acme", "app");
    let done = f
        .auth
        .sso_login(&login(
            "acme",
            "00u1",
            "bob@acme.example",
            Some(KAT_ED25519),
        ))
        .unwrap();
    let bob = done.principal.id.clone();
    f.auth
        .grant(gunnar_core::Grant {
            principal: bob.clone(),
            scope: Scope::Repo(repo.path()),
            level: Level::Write,
        })
        .unwrap();
    assert!(f.auth.may(&bob, &repo, Level::Write).unwrap());

    let (user, changed) = f
        .auth
        .sso_user_update(
            "acme",
            &bob,
            &SsoUserPatch {
                active: Some(false),
                ..Default::default()
            },
            "scim",
            NOW,
        )
        .expect("deactivate");
    assert!(changed && !user.active && user.sessions.is_empty());
    assert!(
        !f.auth.may(&bob, &repo, Level::Write).unwrap(),
        "a deactivated employee can still push"
    );
    assert!(!f.auth.may(&bob, &repo, Level::Read).unwrap());
    assert!(
        f.auth.keys_of(&bob).unwrap().is_empty(),
        "a session key survived the deactivation"
    );
    assert!(
        f.auth.principal(&bob).unwrap().is_some(),
        "the employee itself is kept"
    );
    let err = f
        .auth
        .sso_login(&login(
            "acme",
            "00u1",
            "bob@acme.example",
            Some(OTHER_ED25519),
        ))
        .unwrap_err();
    assert!(err.to_string().contains("deactivated"), "{err}");

    let (user, changed) = f
        .auth
        .sso_user_update(
            "acme",
            &bob,
            &SsoUserPatch {
                active: Some(true),
                ..Default::default()
            },
            "scim",
            NOW,
        )
        .unwrap();
    assert!(changed && user.active);
    assert!(f.auth.may(&bob, &repo, Level::Write).unwrap());

    // An operator's suspension is not the directory's to lift.
    f.auth
        .gate(gunnar_core::AccountGate::administrative(
            &bob,
            "operator",
            "under investigation",
        ))
        .unwrap();
    f.auth
        .sso_user_update(
            "acme",
            &bob,
            &SsoUserPatch {
                active: Some(false),
                ..Default::default()
            },
            "scim",
            NOW,
        )
        .unwrap();
    f.auth
        .sso_user_update(
            "acme",
            &bob,
            &SsoUserPatch {
                active: Some(true),
                ..Default::default()
            },
            "scim",
            NOW,
        )
        .unwrap();
    let gate = f
        .auth
        .gate_of(&bob)
        .unwrap()
        .expect("the operator's gate stays");
    assert_eq!(gate.holder, gunnar_core::ADMINISTRATIVE_HOLDER);
}

/// SCIM DELETE: deactivated and unlisted, the employee kept; the name is free
/// for a NEW person, never for the removed one.
#[test]
fn a_deleted_user_is_kept_gated_and_its_name_goes_to_a_new_employee() {
    let f = fixture_with_account("acme");
    let done = f
        .auth
        .sso_login(&login("acme", "00u1", "bob@acme.example", None))
        .unwrap();
    let removed = f
        .auth
        .sso_user_delete("acme", &done.principal.id, "scim", NOW)
        .unwrap();
    assert!(removed.deleted && !removed.active);
    assert!(f.auth.gate_of(&done.principal.id).unwrap().is_some());
    let err = f
        .auth
        .sso_login(&login("acme", "00u1", "bob@acme.example", None))
        .unwrap_err();
    assert!(err.to_string().contains("deactivated"), "{err}");
    let fresh = f
        .auth
        .sso_login(&login("acme", "00u2", "bob@acme.example", None))
        .unwrap();
    assert!(fresh.created);
    assert_ne!(fresh.principal.id, done.principal.id);
}

/// The account admin is not an employee; an employee cannot configure an
/// identity provider; a domain leads to one account only — the one that PROVED
/// it (A34-5): a typed domain leads nowhere and blocks nobody.
#[test]
fn providers_are_accounts_and_domains_are_unique() {
    let f = fixture_with_account("acme");
    f.auth
        .set_sso_provider(provider("acme", &["acme.example"]))
        .unwrap();
    assert_eq!(
        f.auth.sso_account_for_domain("acme.example").unwrap(),
        None,
        "a typed domain routed"
    );
    let proven = f
        .auth
        .verify_sso_domain("acme", "acme.example")
        .expect("proven");
    assert_eq!(proven.verified_domains, vec!["acme.example".to_owned()]);
    assert_eq!(
        f.auth
            .sso_account_for_domain("acme.example")
            .unwrap()
            .as_deref(),
        Some("acme")
    );
    let err = f
        .auth
        .verify_sso_domain("acme", "other.example")
        .unwrap_err();
    assert!(err.to_string().contains("[sso-domain-unknown]"), "{err}");
    let done = f
        .auth
        .sso_login(&login("acme", "00u1", "bob@acme.example", None))
        .unwrap();
    let err = f
        .auth
        .set_sso_provider(provider(&done.principal.id, &[]))
        .unwrap_err();
    assert!(err.to_string().contains("employee"), "{err}");
    f.catalog.create_namespace("globex").unwrap();
    f.auth
        .add_principal(Principal::new("globex", "globex", PrincipalKind::User).unwrap())
        .unwrap();
    f.auth
        .set_sso_provider(provider("globex", &["acme.example"]))
        .expect("naming a domain squats nothing");
    let err = f
        .auth
        .verify_sso_domain("globex", "acme.example")
        .unwrap_err();
    assert!(err.to_string().contains("[sso-domain-taken]"), "{err}");
    f.auth.remove_sso_provider("globex").unwrap();
    assert_eq!(
        f.auth
            .sso_account_for_domain("acme.example")
            .unwrap()
            .as_deref(),
        Some("acme"),
        "globex's removal took acme's domain"
    );
    // Re-saving keeps a proof of a domain still named, and drops the others.
    f.auth
        .set_sso_provider(provider("acme", &["acme.example", "acme.test"]))
        .unwrap();
    assert_eq!(
        f.auth
            .sso_account_for_domain("acme.example")
            .unwrap()
            .as_deref(),
        Some("acme")
    );
    f.auth
        .set_sso_provider(provider("acme", &["acme.test"]))
        .unwrap();
    assert_eq!(
        f.auth.sso_account_for_domain("acme.example").unwrap(),
        None,
        "the old domain still leads to acme"
    );
    assert!(
        f.auth.employer_of("acme").unwrap().is_none(),
        "the account admin became an employee"
    );
}

/// A JIT employee takes an open seat first, and is refused when the account
/// is out of room — nothing is written.
#[test]
fn a_new_employee_takes_a_seat_or_room_and_a_short_account_creates_nobody() {
    let f = fixture_with_account("acme");
    // 10 GiB of room in all: one user of 10 GiB fits, the second does not.
    let mut l = login("acme", "a", "ann@acme.example", None);
    l.room = RoomAsk {
        room_bytes: 10 << 30,
        cap: Some(10 << 30),
        stored: 0,
        ..RoomAsk::default()
    };
    f.auth.sso_login(&l).expect("one fits");
    let mut l2 = login("acme", "b", "ben@acme.example", None);
    l2.room = l.room.clone();
    let err = f.auth.sso_login(&l2).unwrap_err();
    assert!(
        matches!(err, gunnar_core::Error::RoomShort { .. }),
        "{err:?}"
    );
    assert!(f.auth.sso_user("acme.ben").unwrap().is_none());
    assert!(f.auth.principal("acme.ben").unwrap().is_none());
}

/// Removing an employee takes its directory row and its subject with it, so
/// the next principal of that id is never logged in by the old subject.
#[test]
fn removing_the_employee_unbinds_its_subject() {
    let f = fixture_with_account("acme");
    let done = f
        .auth
        .sso_login(&login("acme", "00u1", "bob@acme.example", None))
        .unwrap();
    f.auth.remove_principal(&done.principal.id).unwrap();
    assert!(f.auth.sso_user(&done.principal.id).unwrap().is_none());
    let subject = sso::subject_key("https://idp.example.test", "00u1");
    assert!(f
        .auth
        .sso_principal_for_subject("acme", &subject)
        .unwrap()
        .is_none());
}

/// ★ A34-2: **a user name alone never claims a provisioned user.** A login
/// that offers no stable IdP id equal to the row's `externalId` and no
/// verified e-mail is refused by name, and the row stays unbound; before,
/// the first subject whose user name matched took the employee over.
#[test]
fn a34_2_a_matching_user_name_alone_never_claims_a_provisioned_user() {
    let f = fixture_with_account("acme");
    f.auth
        .sso_provision(&SsoProvision {
            account: "acme".into(),
            user_name: "dora@acme.example".into(),
            display_name: "Dora".into(),
            email: "dora@acme.example".into(),
            external_id: "ext-dora".into(),
            active: true,
            source: SsoSource::Scim,
            candidates: candidates("acme", "dora@acme.example"),
            room: RoomAsk {
                room_bytes: 10 << 30,
                ..RoomAsk::default()
            },
            actor: "acme".into(),
            now: NOW,
        })
        .expect("provisioned");
    let err = f
        .auth
        .sso_login(&login("acme", "sub-mallory", "dora@acme.example", None))
        .unwrap_err();
    assert!(err.to_string().contains("claim"), "refused by name: {err}");
    assert!(
        f.auth
            .sso_user("acme.dora")
            .unwrap()
            .unwrap()
            .subject
            .is_none(),
        "the row was bound"
    );
}

/// Provision `user_name` (its address too) into acme by SCIM, `external_id`
/// as given; the row.
fn provision_into_acme(f: &Fixture, user_name: &str, external_id: &str) -> sso::SsoUser {
    f.auth
        .sso_provision(&SsoProvision {
            account: "acme".into(),
            user_name: user_name.into(),
            display_name: String::new(),
            email: user_name.into(),
            external_id: external_id.into(),
            active: true,
            source: SsoSource::Scim,
            candidates: candidates("acme", user_name),
            room: RoomAsk {
                room_bytes: 10 << 30,
                ..RoomAsk::default()
            },
            actor: "acme".into(),
            now: NOW,
        })
        .expect("provisioned")
}

/// A login whose IdP ASSERTS the e-mail is verified (`email_verified=true`).
fn verified_login(account: &str, subject: &str, user: &str) -> SsoLogin {
    SsoLogin {
        email_verified: true,
        ..login(account, subject, user, None)
    }
}

/// ★ A34-2: **an unverified e-mail never claims**, even when it is the
/// row's address and in the account's domain.
#[test]
fn a34_2_an_unverified_email_never_claims() {
    let f = fixture_with_account("acme");
    f.auth
        .set_sso_provider(provider("acme", &["acme.example"]))
        .unwrap();
    let row = provision_into_acme(&f, "erin@acme.example", "hr-1");
    let err = f
        .auth
        .sso_login(&login("acme", "sub-erin", "erin@acme.example", None))
        .unwrap_err();
    assert!(
        err.to_string().contains("verified"),
        "refused by name: {err}"
    );
    assert!(f
        .auth
        .sso_user(&row.principal_id)
        .unwrap()
        .unwrap()
        .subject
        .is_none());
}

/// ★ A34-2: **a verified e-mail in one of the account's domains claims**,
/// and the binding says how it was made.
#[test]
fn a34_2_a_verified_email_in_a_claimed_domain_claims() {
    let f = fixture_with_account("acme");
    f.auth
        .set_sso_provider(provider("acme", &["acme.example"]))
        .unwrap();
    f.auth
        .verify_sso_domain("acme", "acme.example")
        .expect("proven");
    let row = provision_into_acme(&f, "erin@acme.example", "hr-1");
    let done = f
        .auth
        .sso_login(&verified_login("acme", "sub-erin", "erin@acme.example"))
        .expect("a verified e-mail in acme.example claims");
    assert!(done.claimed && !done.created);
    assert_eq!(done.claimed_by, Some("verified_email"));
    assert_eq!(done.principal.id, row.principal_id);
}

/// ★ A34-2: **a verified e-mail outside the account's domains never claims.**
#[test]
fn a34_2_a_verified_email_outside_the_accounts_domains_never_claims() {
    let f = fixture_with_account("acme");
    f.auth
        .set_sso_provider(provider("acme", &["acme.example"]))
        .unwrap();
    let row = provision_into_acme(&f, "erin@contractor.mock.invalid", "hr-1");
    let err = f
        .auth
        .sso_login(&verified_login(
            "acme",
            "sub-erin",
            "erin@contractor.mock.invalid",
        ))
        .unwrap_err();
    assert!(err.to_string().contains("domain"), "refused by name: {err}");
    assert!(f
        .auth
        .sso_user(&row.principal_id)
        .unwrap()
        .unwrap()
        .subject
        .is_none());
}

/// ★ A34-2: **the admin's binding claims**: the row's `externalId` is the
/// IdP's subject, with no e-mail rule at all.
#[test]
fn a34_2_the_admins_external_id_binding_claims() {
    let f = fixture_with_account("acme");
    let row = provision_into_acme(&f, "erin@acme.example", "sub-erin");
    let done = f
        .auth
        .sso_login(&login("acme", "sub-erin", "erin@acme.example", None))
        .expect("externalId == the IdP subject claims");
    assert_eq!(done.claimed_by, Some("external_id"));
    assert_eq!(done.principal.id, row.principal_id);
}

/// ★ A34-2: **a second identity cannot steal a bound user**, not even with
/// a verified e-mail in the account's domain.
#[test]
fn a34_2_a_second_identity_cannot_steal_a_bound_user() {
    let f = fixture_with_account("acme");
    f.auth
        .set_sso_provider(provider("acme", &["acme.example"]))
        .unwrap();
    f.auth
        .verify_sso_domain("acme", "acme.example")
        .expect("proven");
    let row = provision_into_acme(&f, "erin@acme.example", "hr-1");
    f.auth
        .sso_login(&verified_login("acme", "sub-erin", "erin@acme.example"))
        .expect("claimed");
    let err = f
        .auth
        .sso_login(&verified_login("acme", "sub-thief", "erin@acme.example"))
        .unwrap_err();
    assert!(err.to_string().contains("never re-pointed"), "{err}");
    assert_eq!(
        f.auth.sso_user(&row.principal_id).unwrap().unwrap().subject,
        Some(sso::subject_key("https://idp.example.test", "sub-erin"))
    );
}

fn provision_of(account: &str, user: &str) -> SsoProvision {
    SsoProvision {
        account: account.into(),
        user_name: user.into(),
        display_name: String::new(),
        email: String::new(),
        external_id: String::new(),
        active: true,
        source: SsoSource::Csv,
        candidates: candidates(account, user),
        room: RoomAsk {
            room_bytes: 10 << 30,
            ..RoomAsk::default()
        },
        actor: "csv:acme".into(),
        now: NOW,
    }
}

fn create(line: usize, user: &str) -> SsoImportRow {
    SsoImportRow {
        label: format!("line {line}: {user}"),
        op: SsoImportOp::Create(provision_of("acme", user)),
    }
}

/// ★ **A34-17: a whole import is ONE write, all or nothing.** A row the
/// table refuses (here: an update of a person the directory does not hold)
/// leaves every earlier row unapplied, and the refusal names that row. A dry
/// run runs the same rows on a copy and writes nothing.
#[test]
fn an_import_is_one_write_all_or_nothing() {
    let f = fixture_with_account("acme");
    let bad = vec![
        create(2, "ann@acme.example"),
        create(3, "ben@acme.example"),
        SsoImportRow {
            label: "line 4: gone@acme.example".into(),
            op: SsoImportOp::Update {
                principal_id: "acme.gone".into(),
                patch: SsoUserPatch {
                    active: Some(false),
                    ..SsoUserPatch::default()
                },
            },
        },
    ];
    let (label, _) = f
        .auth
        .sso_import("acme", &bad, "csv:acme", NOW, true)
        .unwrap_err();
    assert_eq!(label, "line 4: gone@acme.example");
    assert!(
        f.auth.sso_users_of("acme").unwrap().is_empty(),
        "a refused import applied rows"
    );
    let good = vec![create(2, "ann@acme.example"), create(3, "ben@acme.example")];
    f.auth
        .sso_import("acme", &good, "csv:acme", NOW, false)
        .expect("a dry run");
    assert!(
        f.auth.sso_users_of("acme").unwrap().is_empty(),
        "a dry run wrote"
    );
    let (label, _) = f
        .auth
        .sso_import("acme", &bad, "csv:acme", NOW, false)
        .unwrap_err();
    assert_eq!(
        label, "line 4: gone@acme.example",
        "the dry run sees the same refusal"
    );
    f.auth
        .sso_import("acme", &good, "csv:acme", NOW, true)
        .expect("the import");
    let mut ids: Vec<String> = f
        .auth
        .sso_users_of("acme")
        .unwrap()
        .into_iter()
        .map(|u| u.principal_id)
        .collect();
    ids.sort();
    assert_eq!(ids, vec!["acme.ann".to_owned(), "acme.ben".to_owned()]);
}

/// ★ **A34-42: the 10th person with the same local part gets an id.** The
/// numbered ids stop at `-9`; the next take a suffix derived from the whole
/// user name.
#[test]
fn ten_people_with_the_same_local_part_each_get_an_id() {
    let f = fixture_with_account("acme");
    let mut ids = Vec::new();
    for n in 1..=11 {
        let user = format!("bob@site{n}.example");
        ids.push(
            f.auth
                .sso_provision(&provision_of("acme", &user))
                .expect("provisioned")
                .principal_id,
        );
    }
    assert_eq!(ids[0], "acme.bob");
    assert_eq!(ids[8], "acme.bob-9");
    assert!(
        ids[9].starts_with("acme.bob-") && ids[9].len() == "acme.bob-".len() + 6,
        "{ids:?}"
    );
    let unique: std::collections::BTreeSet<_> = ids.iter().collect();
    assert_eq!(unique.len(), 11, "{ids:?}");
}

/// ★ A34-11: **a rebind unbinds the old subject and lets the next sign-in bind
/// the same employee**, on the claim rules; an externalId names its new id.
#[test]
fn a34_11_a_rebind_binds_the_same_employee_afresh() {
    let f = fixture_with_account("acme");
    f.auth
        .set_sso_provider(provider("acme", &["acme.example"]))
        .unwrap();
    f.auth
        .verify_sso_domain("acme", "acme.example")
        .expect("proven");
    let first = f
        .auth
        .sso_login(&login("acme", "old-sub", "erin@acme.example", None))
        .expect("JIT");
    let id = first.principal.id.clone();
    let err = f
        .auth
        .sso_login(&verified_login("acme", "new-sub", "erin@acme.example"))
        .unwrap_err();
    assert!(err.to_string().contains("[sso-claim-bound]"), "{err}");
    let opened = f
        .auth
        .sso_user_rebind("acme", std::slice::from_ref(&id), None)
        .expect("rebind");
    assert!(opened[0].rebind && opened[0].subject.is_none());
    let again = f
        .auth
        .sso_login(&verified_login("acme", "new-sub", "erin@acme.example"))
        .expect("rebound");
    assert_eq!(again.principal.id, id);
    assert!(again.claimed && !again.created);
    let row = f.auth.sso_user(&id).unwrap().unwrap();
    assert!(!row.rebind);
    assert_eq!(
        row.subject,
        Some(sso::subject_key("https://idp.example.test", "new-sub"))
    );
    // Everyone at once (an issuer migration), and an externalId only for one.
    assert!(f
        .auth
        .sso_user_rebind("acme", &[id.clone(), "x".into()], Some("z"))
        .is_err());
    let all = f
        .auth
        .sso_user_rebind("acme", &[], None)
        .expect("all bound");
    assert_eq!(all.len(), 1);
}

/// ★ A34-25: **JIT is the provider's**: `off` makes nobody, `domains` only a
/// verified e-mail in a proven domain.
#[test]
fn a34_25_jit_follows_the_providers_policy() {
    let f = fixture_with_account("acme");
    let mut p = provider("acme", &["acme.example"]);
    p.jit = "off".into();
    f.auth.set_sso_provider(p.clone()).unwrap();
    f.auth
        .verify_sso_domain("acme", "acme.example")
        .expect("proven");
    let err = f
        .auth
        .sso_login(&verified_login("acme", "s1", "ann@acme.example"))
        .unwrap_err();
    assert!(err.to_string().contains("[sso-jit-off]"), "{err}");
    p.jit = "domains".into();
    f.auth.set_sso_provider(p).unwrap();
    let err = f
        .auth
        .sso_login(&login("acme", "s1", "ann@acme.example", None))
        .unwrap_err();
    assert!(err.to_string().contains("[sso-jit-domain]"), "{err}");
    assert!(
        f.auth
            .sso_login(&verified_login("acme", "s1", "ann@acme.example"))
            .expect("verified, proven")
            .created
    );
}