mod common;
use arium::auth;
use arium::auth::VerifyOutcome;
#[tokio::test]
async fn signup_then_signin_succeeds_after_verification() {
let pool = common::pool().await;
let user_id = common::make_user(&pool, "alice@example.com", "hunter22!").await;
let outcome = auth::verify_password_user(&pool, "alice@example.com", "hunter22!")
.await
.unwrap();
assert_eq!(outcome, VerifyOutcome::Verified(user_id));
}
#[tokio::test]
async fn signup_without_verification_returns_unverified_on_login() {
let pool = common::pool().await;
auth::create_password_user(&pool, "bob@example.com", "hunter22!")
.await
.unwrap();
let outcome = auth::verify_password_user(&pool, "bob@example.com", "hunter22!")
.await
.unwrap();
assert_eq!(outcome, VerifyOutcome::Unverified);
}
#[tokio::test]
async fn wrong_password_and_unknown_email_are_indistinguishable() {
let pool = common::pool().await;
common::make_user(&pool, "carol@example.com", "hunter22!").await;
let wrong = auth::verify_password_user(&pool, "carol@example.com", "WRONG")
.await
.unwrap();
let unknown = auth::verify_password_user(&pool, "nobody@example.com", "anything")
.await
.unwrap();
assert_eq!(wrong, VerifyOutcome::Invalid);
assert_eq!(unknown, VerifyOutcome::Invalid);
}
#[tokio::test]
async fn unknown_email_is_not_faster_than_wrong_password() {
use std::time::Instant;
let pool = common::pool().await;
common::make_user(&pool, "erin@example.com", "hunter22!").await;
let _ = auth::verify_password_user(&pool, "erin@example.com", "WRONG").await;
let _ = auth::verify_password_user(&pool, "ghost@example.com", "WRONG").await;
const N: usize = 8;
let (mut real, mut fake) = (Vec::with_capacity(N), Vec::with_capacity(N));
for _ in 0..N {
let t = Instant::now();
let _ = auth::verify_password_user(&pool, "erin@example.com", "WRONG").await;
real.push(t.elapsed());
let t = Instant::now();
let _ = auth::verify_password_user(&pool, "ghost@example.com", "WRONG").await;
fake.push(t.elapsed());
}
let real_min = *real.iter().min().unwrap();
let fake_min = *fake.iter().min().unwrap();
assert!(
fake_min.saturating_mul(2) >= real_min,
"unknown-email login returned too fast ({fake_min:?}) vs wrong-password \
({real_min:?}) — Argon2 likely skipped on the not-found path, \
re-opening the enumeration timing side-channel",
);
}
#[tokio::test]
async fn email_lookup_is_case_insensitive_and_whitespace_tolerant() {
let pool = common::pool().await;
let user_id = common::make_user(&pool, "Dan@Example.Com", "hunter22!").await;
assert_eq!(
auth::verify_password_user(&pool, "DAN@example.com", "hunter22!")
.await
.unwrap(),
VerifyOutcome::Verified(user_id),
);
assert_eq!(
auth::verify_password_user(&pool, " dan@example.com ", "hunter22!")
.await
.unwrap(),
VerifyOutcome::Verified(user_id),
);
}
#[tokio::test]
async fn duplicate_email_signup_is_rejected_with_user_facing_message() {
let pool = common::pool().await;
common::make_user(&pool, "eve@example.com", "hunter22!").await;
let err = auth::create_password_user(&pool, "eve@example.com", "different1!")
.await
.unwrap_err()
.to_string();
assert!(
err.contains("already exists"),
"expected duplicate-email rejection, got: {err}"
);
}
#[tokio::test]
async fn short_password_is_rejected_at_signup_boundary() {
let pool = common::pool().await;
let err = auth::create_password_user(&pool, "frank@example.com", "short")
.await
.unwrap_err()
.to_string();
assert!(err.contains("8 characters"), "wording changed: {err}");
}
#[tokio::test]
async fn invalid_email_shape_is_rejected_at_signup_boundary() {
let pool = common::pool().await;
let err = auth::create_password_user(&pool, "notanemail", "hunter22!")
.await
.unwrap_err()
.to_string();
assert!(err.to_ascii_lowercase().contains("email"), "{err}");
}
#[tokio::test]
async fn password_hash_is_argon2_and_round_trips() {
let pool = common::pool().await;
let user_id = common::make_user(&pool, "gina@example.com", "hunter22!").await;
let stored = auth::get_password_hash(&pool, user_id)
.await
.unwrap()
.unwrap();
assert!(
stored.starts_with("$argon2"),
"expected an Argon2 PHC string, got prefix {:?}",
&stored.chars().take(10).collect::<String>(),
);
assert!(auth::verify_password_against_hash(&stored, "hunter22!"));
assert!(!auth::verify_password_against_hash(&stored, "wrong"));
}
#[tokio::test]
async fn change_password_replaces_hash_and_old_password_stops_working() {
let pool = common::pool().await;
let user_id = common::make_user(&pool, "hank@example.com", "hunter22!").await;
auth::replace_password_hash(&pool, user_id, "new_password!")
.await
.unwrap();
assert_eq!(
auth::verify_password_user(&pool, "hank@example.com", "hunter22!")
.await
.unwrap(),
VerifyOutcome::Invalid,
);
assert_eq!(
auth::verify_password_user(&pool, "hank@example.com", "new_password!")
.await
.unwrap(),
VerifyOutcome::Verified(user_id),
);
}
#[tokio::test]
async fn replace_password_rejects_short_password() {
let pool = common::pool().await;
let user_id = common::make_user(&pool, "ivan@example.com", "hunter22!").await;
let err = auth::replace_password_hash(&pool, user_id, "short")
.await
.unwrap_err()
.to_string();
assert!(err.contains("8 characters"), "{err}");
}