thirdshift 0.13.0

Turn a GitHub issue into a ready-for-review pull request, unattended
use super::*;

fn signed_in(accounts: &mut Accounts) -> String {
    let SignIn::SignedIn(token) = accounts.sign_in("owner@example.test", "password").unwrap()
    else {
        panic!("sign-in refused")
    };
    token
}

#[test]
fn email_codes_expire_at_fifteen_minutes_and_allow_exactly_five_tries() {
    let allowed = vec!["owner@example.test".into()];
    for (elapsed, wrong_tries, accepted) in [(899, 4, true), (900, 0, false), (1, 5, false)] {
        let dir = tempfile::tempdir().unwrap();
        let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap();
        let mut code = String::new();
        accounts
            .send_code("owner@example.test", |sent| {
                code = sent.into();
                Ok(())
            })
            .unwrap();
        drop(accounts);
        let wrong = if code == "000000" { "111111" } else { "000000" };
        for _ in 0..wrong_tries {
            let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + elapsed).unwrap();
            assert!(matches!(
                accounts
                    .set_password_by_code("owner@example.test", wrong, "password")
                    .unwrap(),
                SignIn::Refused
            ));
        }
        let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + elapsed).unwrap();
        assert_eq!(
            matches!(
                accounts
                    .set_password_by_code("owner@example.test", &code, "password")
                    .unwrap(),
                SignIn::SignedIn(_)
            ),
            accepted
        );
        assert!(matches!(
            accounts
                .set_password_by_code("owner@example.test", &code, "replay")
                .unwrap(),
            SignIn::Refused
        ));
    }
}

#[test]
fn code_send_limits_survive_restarts_and_password_changes_and_are_per_address() {
    let dir = tempfile::tempdir().unwrap();
    let allowed = vec!["owner@example.test".into(), "other@example.test".into()];
    let mut sends = 0;
    let mut code = String::new();
    for (time, expected) in [(1_000, 1), (1_059, 1), (1_060, 2)] {
        let mut accounts = Accounts::open(dir.path(), &allowed, time).unwrap();
        accounts
            .send_code("owner@example.test", |sent| {
                sends += 1;
                code = sent.into();
                Ok(())
            })
            .unwrap();
        assert_eq!(sends, expected);
    }
    {
        let mut accounts = Accounts::open(dir.path(), &allowed, 1_061).unwrap();
        assert!(matches!(
            accounts
                .set_password_by_code("owner@example.test", &code, "password")
                .unwrap(),
            SignIn::SignedIn(_)
        ));
    }
    for index in 2..11 {
        let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + index * 60).unwrap();
        accounts
            .send_code("owner@example.test", |_| {
                sends += 1;
                Ok(())
            })
            .unwrap();
        assert_eq!(sends, (index + 1).min(10));
    }
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + 24 * 60 * 60 - 1).unwrap();
    accounts
        .send_code("owner@example.test", |_| panic!("daily limit bypassed"))
        .unwrap();
    accounts
        .send_code("unknown@example.test", |_| panic!("unknown address sent"))
        .unwrap();
    accounts
        .send_code("other@example.test", |_| {
            sends += 1;
            Ok(())
        })
        .unwrap();
    assert_eq!(sends, 11);
    drop(accounts);
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + 24 * 60 * 60).unwrap();
    accounts
        .send_code("owner@example.test", |_| {
            sends += 1;
            Ok(())
        })
        .unwrap();
    assert_eq!(sends, 12, "first send ages out of the rolling day");
}

#[test]
fn five_wrong_passwords_slow_the_address_to_one_attempt_per_minute() {
    let dir = tempfile::tempdir().unwrap();
    let allowed = vec!["owner@example.test".into(), "other@example.test".into()];
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap();
    accounts
        .set_password("owner@example.test", "password")
        .unwrap();
    accounts
        .set_password("other@example.test", "password")
        .unwrap();
    for _ in 0..5 {
        assert!(matches!(
            accounts.sign_in("owner@example.test", "wrong").unwrap(),
            SignIn::Refused
        ));
    }
    assert!(
        !matches!(
            accounts.sign_in("owner@example.test", "password").unwrap(),
            SignIn::SignedIn(_)
        ),
        "sixth try must wait"
    );
    assert!(matches!(
        accounts.sign_in("other@example.test", "password").unwrap(),
        SignIn::SignedIn(_)
    ));
    drop(accounts);
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_059).unwrap();
    assert!(!matches!(
        accounts.sign_in("owner@example.test", "password").unwrap(),
        SignIn::SignedIn(_)
    ));
    drop(accounts);
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_060).unwrap();
    assert!(matches!(
        accounts.sign_in("owner@example.test", "wrong").unwrap(),
        SignIn::Refused
    ));
    assert!(
        !matches!(
            accounts.sign_in("owner@example.test", "password").unwrap(),
            SignIn::SignedIn(_)
        ),
        "each wrong try restarts the minute"
    );
    drop(accounts);
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_120).unwrap();
    assert!(matches!(
        accounts.sign_in("owner@example.test", "password").unwrap(),
        SignIn::SignedIn(_)
    ));
    assert!(
        matches!(
            accounts.sign_in("owner@example.test", "password").unwrap(),
            SignIn::SignedIn(_)
        ),
        "success resets the slowdown"
    );
}

#[test]
fn sessions_expire_thirty_days_after_last_use_and_allowlist_removal_revokes_them() {
    let dir = tempfile::tempdir().unwrap();
    let allowed = vec!["owner@example.test".into()];
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap();
    accounts
        .set_password("owner@example.test", "password")
        .unwrap();
    let token = signed_in(&mut accounts);
    drop(accounts);
    let month = 30 * 24 * 60 * 60;
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month - 1).unwrap();
    assert!(accounts.session(&token, true).unwrap().is_some());
    drop(accounts);
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month * 2 - 2).unwrap();
    assert!(accounts.session(&token, false).unwrap().is_some());
    drop(accounts);
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000 + month * 2 - 1).unwrap();
    assert!(
        accounts.session(&token, true).unwrap().is_none(),
        "expires at 30 days, even across restarts"
    );
    let token = signed_in(&mut accounts);
    drop(accounts);
    drop(Accounts::open(dir.path(), &[], 1_000 + month * 2).unwrap());
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_001 + month * 2).unwrap();
    assert!(
        accounts.session(&token, true).unwrap().is_none(),
        "re-adding the address must not revive sessions"
    );
    assert!(
        matches!(
            accounts.sign_in("owner@example.test", "password").unwrap(),
            SignIn::SignedIn(_)
        ),
        "the password is kept"
    );
}

#[test]
fn passwords_and_sessions_survive_reopening_and_password_changes_revoke_sessions() {
    let dir = tempfile::tempdir().unwrap();
    let allowed = vec!["owner@example.test".into()];
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_000).unwrap();
    accounts
        .set_password("owner@example.test", "first password")
        .unwrap();
    assert!(matches!(
        accounts.sign_in("owner@example.test", "wrong").unwrap(),
        SignIn::Refused
    ));
    let SignIn::SignedIn(token) = accounts
        .sign_in("owner@example.test", "first password")
        .unwrap()
    else {
        panic!("sign-in refused")
    };
    drop(accounts);
    let mut accounts = Accounts::open(dir.path(), &allowed, 1_001).unwrap();
    assert_eq!(
        accounts.session(&token, true).unwrap().as_deref(),
        Some("owner@example.test")
    );
    accounts
        .set_password("owner@example.test", "new password")
        .unwrap();
    assert!(accounts.session(&token, true).unwrap().is_none());
}