michal/tit

Browse tree · Show commit · Download archive

Blob: tests/account_lifecycle.rs

Raw · Blame

#[path = "../src/account.rs"]
mod account;
#[allow(
    dead_code,
    reason = "the account test uses only SSH public-key parsing"
)]
#[path = "../src/auth.rs"]
mod auth;
#[allow(
    dead_code,
    reason = "the account test does not use every store operation"
)]
#[path = "../src/store/mod.rs"]
mod store;

use rand::rng;
use rusqlite::OptionalExtension;
use sha2::{Digest, Sha256};
use ssh_key::{Algorithm, PrivateKey};
use tempfile::TempDir;

use account::{AccountError, AccountService};
use store::{Store, StoreError};

#[test]
fn invitation_signup_key_and_recovery_lifecycle_is_atomic() {
    let directory = TempDir::new().expect("create an account directory");
    let database = directory.path().join("tit.sqlite3");
    Store::open(&database).expect("create the account database");
    let accounts = AccountService::new(database.clone());
    let first_key = public_key();
    let second_key = public_key();
    let third_key = public_key();

    let invitation = accounts.issue_invitation().expect("issue an invitation");
    assert!(invitation.starts_with("tit-invite-v1:"));
    let stored_invitation: Vec<u8> = Store::open(&database)
        .expect("open the account database")
        .connection()
        .query_row("SELECT code_hash FROM signup_invitation", [], |row| {
            row.get(0)
        })
        .expect("read the invitation hash");
    assert_eq!(stored_invitation.len(), 32);
    assert_ne!(stored_invitation, invitation.as_bytes());

    let recovery = accounts
        .signup(&invitation, "alice", &first_key, "test")
        .expect("create the account");
    accounts
        .update_profile(
            "alice",
            "A small profile.\nSecond line.",
            "alice@example.test",
        )
        .expect("update the public profile");
    let profile = accounts.profile("alice").expect("read the public profile");
    assert_eq!(profile.bio, "A small profile.\nSecond line.");
    assert_eq!(profile.contact_email, "alice@example.test");
    assert!(matches!(
        accounts.update_profile("alice", "profile", "not-an-email"),
        Err(AccountError::InvalidProfile)
    ));
    Store::open(&database)
        .expect("open the account database")
        .create_feed_token("alice", &[7; 32], 1)
        .expect("create a feed token");
    assert!(recovery.starts_with("tit-recovery-v1:"));
    assert!(matches!(
        accounts.signup(&invitation, "bob", &second_key, "test"),
        Err(AccountError::Store(StoreError::InvalidInvitation))
    ));
    let stored_recovery: Vec<u8> = Store::open(&database)
        .expect("open the account database")
        .connection()
        .query_row(
            "SELECT credential_hash FROM recovery_credential",
            [],
            |row| row.get(0),
        )
        .expect("read the recovery hash");
    assert_eq!(stored_recovery.len(), 32);
    assert_ne!(stored_recovery, recovery.as_bytes());

    let second_fingerprint = accounts
        .add_key("alice", "workstation", &second_key, "alice", "test")
        .expect("add a second key");
    let first_fingerprint: String = Store::open(&database)
        .expect("open the account database")
        .connection()
        .query_row(
            "SELECT fingerprint FROM ssh_public_key WHERE label = 'initial'",
            [],
            |row| row.get(0),
        )
        .expect("read the first fingerprint");
    accounts
        .revoke_key("alice", &first_fingerprint, "alice", "test")
        .expect("revoke the first key");
    assert!(matches!(
        accounts.revoke_key("alice", &second_fingerprint, "alice", "test"),
        Err(AccountError::Store(StoreError::LastKey))
    ));

    let replacement = accounts
        .recover("alice", &recovery, &third_key, "test")
        .expect("recover the account");
    assert_eq!(
        active_feed_tokens(&database, "alice"),
        0,
        "recovery must revoke active feed tokens"
    );
    assert!(matches!(
        accounts.recover("alice", &recovery, &first_key, "test"),
        Err(AccountError::Store(StoreError::InvalidRecovery))
    ));
    accounts
        .recover("alice", &replacement, &first_key, "test")
        .expect("use the rotated recovery code");
    assert_eq!(
        Store::open(&database)
            .expect("open the account database")
            .active_ssh_public_keys()
            .expect("list active SSH keys"),
        vec![
            auth::SshPublicKey::parse(&first_key)
                .expect("parse the first key")
                .canonical()
                .to_owned()
        ]
    );
    let audits = Store::open(&database)
        .expect("open the account database")
        .audit_events(20)
        .expect("read account audit history");
    assert!(
        audits
            .iter()
            .any(|event| event.action == "key.add" && event.outcome == "success")
    );
    assert!(
        audits
            .iter()
            .any(|event| event.action == "key.revoke" && event.outcome == "failure")
    );
    assert!(
        audits
            .iter()
            .any(|event| event.action == "account.recover" && event.outcome == "success")
    );
    for event in audits {
        assert!(!event.target.contains(&recovery));
        assert!(!event.target.contains(&replacement));
    }
}

#[test]
fn failed_signup_preserves_the_invitation_and_username_reservation() {
    let directory = TempDir::new().expect("create an account directory");
    let database = directory.path().join("tit.sqlite3");
    Store::open(&database).expect("create the account database");
    let accounts = AccountService::new(database.clone());
    let first = accounts.issue_invitation().expect("issue an invitation");
    accounts
        .signup(&first, "alice", &public_key(), "test")
        .expect("create the first account");
    Store::open(&database)
        .expect("open the account database")
        .create_feed_token("alice", &[8; 32], 1)
        .expect("create a feed token");

    let second = accounts.issue_invitation().expect("issue an invitation");
    assert!(matches!(
        accounts.signup(&second, "alice", &public_key(), "test"),
        Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
    ));
    accounts
        .signup(&second, "bob", &public_key(), "test")
        .expect("reuse the invitation after a rolled-back signup");
    accounts
        .suspend("alice", true, "admin-cli", "test")
        .expect("suspend the account");
    assert_eq!(
        active_feed_tokens(&database, "alice"),
        0,
        "suspension must revoke active feed tokens"
    );
    assert_eq!(
        Store::open(&database)
            .expect("open the account database")
            .connection()
            .query_row(
                "SELECT state FROM account WHERE username = 'alice'",
                [],
                |row| row.get::<_, String>(0),
            )
            .expect("read the account state"),
        "suspended"
    );
    let third = accounts.issue_invitation().expect("issue an invitation");
    assert!(matches!(
        accounts.signup(&third, "alice", &public_key(), "test"),
        Err(AccountError::Store(StoreError::UsernameUnavailable(_)))
    ));
    let consumed: Option<i64> = Store::open(&database)
        .expect("open the account database")
        .connection()
        .query_row(
            "SELECT consumed_at FROM signup_invitation ORDER BY id DESC LIMIT 1",
            [],
            |row| row.get(0),
        )
        .optional()
        .expect("read the invitation")
        .flatten();
    assert_eq!(consumed, None);
}

fn active_feed_tokens(database: &std::path::Path, username: &str) -> i64 {
    Store::open(database)
        .expect("open the account database")
        .connection()
        .query_row(
            "SELECT count(*)
             FROM feed_token
             JOIN account ON account.id = feed_token.account_id
             WHERE account.username = ?1 AND feed_token.revoked_at IS NULL",
            [username],
            |row| row.get(0),
        )
        .expect("count active feed tokens")
}

#[test]
fn expired_invitations_do_not_create_accounts() {
    let directory = TempDir::new().expect("create an account directory");
    let database = directory.path().join("tit.sqlite3");
    let store = Store::open(&database).expect("create the account database");
    let invitation =
        "tit-invite-v1:0000000000000000000000000000000000000000000000000000000000000000";
    let invitation_hash: [u8; 32] = Sha256::digest(invitation.as_bytes()).into();
    store
        .create_signup_invitation(&invitation_hash, 1, 2)
        .expect("store an expired invitation");
    let accounts = AccountService::new(database.clone());
    assert!(matches!(
        accounts.signup(invitation, "alice", &public_key(), "test"),
        Err(AccountError::Store(StoreError::InvalidInvitation))
    ));
    assert_eq!(
        Store::open(&database)
            .expect("open the account database")
            .connection()
            .query_row("SELECT count(*) FROM account", [], |row| row
                .get::<_, i64>(0))
            .expect("count accounts"),
        0
    );
}

fn public_key() -> String {
    PrivateKey::random(&mut rng(), Algorithm::Ed25519)
        .expect("create an SSH key")
        .public_key()
        .to_openssh()
        .expect("encode an SSH public key")
}