use sqlx::SqlitePool;
use sqlx::sqlite::SqliteConnectOptions;
use std::str::FromStr;
use crate::audit::AuditEvent;
use crate::db::Db;
use crate::error::AuthError;
use crate::password::verify_password;
use crate::sessions::{
SessionConfig, generate_token, hash_token, parse_session_cookie, session_cookie,
};
use crate::types::{
Email, PasswordHash, Permission, PermissionId, PermissionName, Role, RoleId, RoleName,
RolePermission, Session, SessionId, TokenHash, User, UserId, UserPermission, UserRole,
Username,
};
async fn test_db() -> Db {
Db::connect("sqlite::memory:")
.await
.expect("Db::connect for in-memory test database")
}
fn now_str() -> String {
chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%S%.3fZ")
.to_string()
}
#[tokio::test]
async fn test_user_round_trip() {
let db = test_db().await;
let user_id = UserId::new();
let email = Email::new_unchecked("alice@example.com".to_string());
let username = Username::new_unchecked("alice".to_string());
let password_hash =
PasswordHash::new_unchecked("$argon2id$v=19$m=65536,t=2,p=1$fakesalt$fakehash".to_string());
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(&email)
.bind(Some(&username))
.bind(Some(&password_hash))
.bind(true)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
let user = sqlx::query_as::<_, User>(
"SELECT id, email, username, password_hash, email_verified, is_active, created_at, updated_at
FROM allowthem_users WHERE id = ?",
)
.bind(user_id)
.fetch_one(db.pool())
.await
.expect("fetch user");
assert_eq!(user.id, user_id);
assert_eq!(user.email, email);
assert_eq!(user.username, Some(username));
assert!(user.password_hash.is_some());
assert!(user.email_verified);
assert!(user.is_active);
let user_id2 = UserId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id2)
.bind(Email::new_unchecked("bob@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user with null password");
let user2 = sqlx::query_as::<_, User>(
"SELECT id, email, username, password_hash, email_verified, is_active, created_at, updated_at
FROM allowthem_users WHERE id = ?",
)
.bind(user_id2)
.fetch_one(db.pool())
.await
.expect("fetch user2");
assert!(user2.password_hash.is_none());
assert!(user2.username.is_none());
assert!(!user2.email_verified);
}
#[tokio::test]
async fn test_session_round_trip() {
let db = test_db().await;
let user_id = UserId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked("carol@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
let session_id = SessionId::new();
let token_hash = TokenHash::new_unchecked(
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(),
);
let expires_at = now_str();
sqlx::query(
"INSERT INTO allowthem_sessions (id, token_hash, user_id, ip_address, user_agent, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(session_id)
.bind(&token_hash)
.bind(user_id)
.bind(Some("127.0.0.1"))
.bind(Some("Mozilla/5.0"))
.bind(&expires_at)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert session");
let session = sqlx::query_as::<_, Session>(
"SELECT id, token_hash, user_id, ip_address, user_agent, expires_at, created_at
FROM allowthem_sessions WHERE id = ?",
)
.bind(session_id)
.fetch_one(db.pool())
.await
.expect("fetch session");
assert_eq!(session.id, session_id);
assert_eq!(session.user_id, user_id);
assert_eq!(session.ip_address.as_deref(), Some("127.0.0.1"));
assert_eq!(session.user_agent.as_deref(), Some("Mozilla/5.0"));
}
#[tokio::test]
async fn test_role_round_trip() {
let db = test_db().await;
let role_id = RoleId::new();
let role_name = RoleName::new_unchecked("admin".to_string());
sqlx::query(
"INSERT INTO allowthem_roles (id, name, description, created_at)
VALUES (?, ?, ?, ?)",
)
.bind(role_id)
.bind(&role_name)
.bind(Some("Administrator role"))
.bind(now_str())
.execute(db.pool())
.await
.expect("insert role");
let role = sqlx::query_as::<_, Role>(
"SELECT id, name, description, created_at FROM allowthem_roles WHERE id = ?",
)
.bind(role_id)
.fetch_one(db.pool())
.await
.expect("fetch role");
assert_eq!(role.id, role_id);
assert_eq!(role.name, role_name);
assert_eq!(role.description.as_deref(), Some("Administrator role"));
}
#[tokio::test]
async fn test_user_role_round_trip() {
let db = test_db().await;
let user_id = UserId::new();
let role_id = RoleId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked("dave@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
sqlx::query(
"INSERT INTO allowthem_roles (id, name, description, created_at) VALUES (?, ?, ?, ?)",
)
.bind(role_id)
.bind(RoleName::new_unchecked("editor".to_string()))
.bind(None::<String>)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert role");
sqlx::query("INSERT INTO allowthem_user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)")
.bind(user_id)
.bind(role_id)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user_role");
let user_role = sqlx::query_as::<_, UserRole>(
"SELECT user_id, role_id, created_at FROM allowthem_user_roles WHERE user_id = ? AND role_id = ?",
)
.bind(user_id)
.bind(role_id)
.fetch_one(db.pool())
.await
.expect("fetch user_role");
assert_eq!(user_role.user_id, user_id);
assert_eq!(user_role.role_id, role_id);
}
#[tokio::test]
async fn test_permission_round_trip() {
let db = test_db().await;
let perm_id = PermissionId::new();
let perm_name = PermissionName::new_unchecked("posts:write".to_string());
sqlx::query(
"INSERT INTO allowthem_permissions (id, name, description, created_at)
VALUES (?, ?, ?, ?)",
)
.bind(perm_id)
.bind(&perm_name)
.bind(Some("Write access to posts"))
.bind(now_str())
.execute(db.pool())
.await
.expect("insert permission");
let perm = sqlx::query_as::<_, Permission>(
"SELECT id, name, description, created_at FROM allowthem_permissions WHERE id = ?",
)
.bind(perm_id)
.fetch_one(db.pool())
.await
.expect("fetch permission");
assert_eq!(perm.id, perm_id);
assert_eq!(perm.name, perm_name);
assert_eq!(perm.description.as_deref(), Some("Write access to posts"));
}
#[tokio::test]
async fn test_role_permission_round_trip() {
let db = test_db().await;
let role_id = RoleId::new();
let perm_id = PermissionId::new();
sqlx::query(
"INSERT INTO allowthem_roles (id, name, description, created_at) VALUES (?, ?, ?, ?)",
)
.bind(role_id)
.bind(RoleName::new_unchecked("viewer".to_string()))
.bind(None::<String>)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert role");
sqlx::query(
"INSERT INTO allowthem_permissions (id, name, description, created_at) VALUES (?, ?, ?, ?)",
)
.bind(perm_id)
.bind(PermissionName::new_unchecked("posts:read".to_string()))
.bind(None::<String>)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert permission");
sqlx::query("INSERT INTO allowthem_role_permissions (role_id, permission_id) VALUES (?, ?)")
.bind(role_id)
.bind(perm_id)
.execute(db.pool())
.await
.expect("insert role_permission");
let rp = sqlx::query_as::<_, RolePermission>(
"SELECT role_id, permission_id FROM allowthem_role_permissions WHERE role_id = ? AND permission_id = ?",
)
.bind(role_id)
.bind(perm_id)
.fetch_one(db.pool())
.await
.expect("fetch role_permission");
assert_eq!(rp.role_id, role_id);
assert_eq!(rp.permission_id, perm_id);
}
#[tokio::test]
async fn test_user_permission_round_trip() {
let db = test_db().await;
let user_id = UserId::new();
let perm_id = PermissionId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked("eve@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
sqlx::query(
"INSERT INTO allowthem_permissions (id, name, description, created_at) VALUES (?, ?, ?, ?)",
)
.bind(perm_id)
.bind(PermissionName::new_unchecked("admin:read".to_string()))
.bind(None::<String>)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert permission");
sqlx::query("INSERT INTO allowthem_user_permissions (user_id, permission_id) VALUES (?, ?)")
.bind(user_id)
.bind(perm_id)
.execute(db.pool())
.await
.expect("insert user_permission");
let up = sqlx::query_as::<_, UserPermission>(
"SELECT user_id, permission_id FROM allowthem_user_permissions WHERE user_id = ? AND permission_id = ?",
)
.bind(user_id)
.bind(perm_id)
.fetch_one(db.pool())
.await
.expect("fetch user_permission");
assert_eq!(up.user_id, user_id);
assert_eq!(up.permission_id, perm_id);
}
#[tokio::test]
async fn test_unique_email_constraint() {
let db = test_db().await;
let email = Email::new_unchecked("duplicate@example.com".to_string());
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(UserId::new())
.bind(&email)
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("first insert succeeds");
let result = sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(UserId::new())
.bind(&email)
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await;
assert!(result.is_err(), "duplicate email must be rejected");
}
#[tokio::test]
async fn test_unique_username_constraint() {
let db = test_db().await;
let username = Username::new_unchecked("samename".to_string());
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(UserId::new())
.bind(Email::new_unchecked("user1@example.com".to_string()))
.bind(Some(&username))
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("first insert succeeds");
let result = sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(UserId::new())
.bind(Email::new_unchecked("user2@example.com".to_string()))
.bind(Some(&username))
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await;
assert!(result.is_err(), "duplicate username must be rejected");
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(UserId::new())
.bind(Email::new_unchecked("null1@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("null username insert 1 succeeds");
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(UserId::new())
.bind(Email::new_unchecked("null2@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("null username insert 2 succeeds — NULLs are distinct in SQLite");
}
#[tokio::test]
async fn test_foreign_key_enforcement() {
let db = test_db().await;
let result = sqlx::query(
"INSERT INTO allowthem_sessions (id, token_hash, user_id, ip_address, user_agent, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(SessionId::new())
.bind(TokenHash::new_unchecked("deadbeef".repeat(8)))
.bind(UserId::new()) .bind(None::<String>)
.bind(None::<String>)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await;
assert!(
result.is_err(),
"FK violation must be rejected (PRAGMA foreign_keys = ON)"
);
}
#[tokio::test]
async fn test_composite_primary_key() {
let db = test_db().await;
let user_id = UserId::new();
let role_id = RoleId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked("frank@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
sqlx::query(
"INSERT INTO allowthem_roles (id, name, description, created_at) VALUES (?, ?, ?, ?)",
)
.bind(role_id)
.bind(RoleName::new_unchecked("superuser".to_string()))
.bind(None::<String>)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert role");
sqlx::query("INSERT INTO allowthem_user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)")
.bind(user_id)
.bind(role_id)
.bind(now_str())
.execute(db.pool())
.await
.expect("first insert succeeds");
let result = sqlx::query(
"INSERT INTO allowthem_user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)",
)
.bind(user_id)
.bind(role_id)
.bind(now_str())
.execute(db.pool())
.await;
assert!(result.is_err(), "duplicate composite PK must be rejected");
}
#[tokio::test]
async fn test_cascade_delete_user() {
let db = test_db().await;
let user_id = UserId::new();
let role_id = RoleId::new();
let perm_id = PermissionId::new();
let session_id = SessionId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked("grace@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
sqlx::query(
"INSERT INTO allowthem_roles (id, name, description, created_at) VALUES (?, ?, ?, ?)",
)
.bind(role_id)
.bind(RoleName::new_unchecked("cascade_role".to_string()))
.bind(None::<String>)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert role");
sqlx::query(
"INSERT INTO allowthem_permissions (id, name, description, created_at) VALUES (?, ?, ?, ?)",
)
.bind(perm_id)
.bind(PermissionName::new_unchecked("cascade:read".to_string()))
.bind(None::<String>)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert permission");
sqlx::query(
"INSERT INTO allowthem_sessions (id, token_hash, user_id, ip_address, user_agent, expires_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(session_id)
.bind(TokenHash::new_unchecked("cafebabe".repeat(8)))
.bind(user_id)
.bind(None::<String>)
.bind(None::<String>)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert session");
sqlx::query("INSERT INTO allowthem_user_roles (user_id, role_id, created_at) VALUES (?, ?, ?)")
.bind(user_id)
.bind(role_id)
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user_role");
sqlx::query("INSERT INTO allowthem_user_permissions (user_id, permission_id) VALUES (?, ?)")
.bind(user_id)
.bind(perm_id)
.execute(db.pool())
.await
.expect("insert user_permission");
sqlx::query("DELETE FROM allowthem_users WHERE id = ?")
.bind(user_id)
.execute(db.pool())
.await
.expect("delete user");
let session_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_sessions WHERE user_id = ?")
.bind(user_id)
.fetch_one(db.pool())
.await
.expect("count sessions");
assert_eq!(session_count, 0, "sessions must cascade-delete with user");
let user_role_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_user_roles WHERE user_id = ?")
.bind(user_id)
.fetch_one(db.pool())
.await
.expect("count user_roles");
assert_eq!(
user_role_count, 0,
"user_roles must cascade-delete with user"
);
let user_perm_count: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_user_permissions WHERE user_id = ?")
.bind(user_id)
.fetch_one(db.pool())
.await
.expect("count user_permissions");
assert_eq!(
user_perm_count, 0,
"user_permissions must cascade-delete with user"
);
}
#[tokio::test]
async fn test_migrations_create_all_tables() {
let db = test_db().await;
let mut tables: Vec<String> =
sqlx::query_scalar("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'allowthem_%' ORDER BY name")
.fetch_all(db.pool())
.await
.expect("query sqlite_master");
tables.sort();
assert_eq!(
tables,
vec![
"allowthem_api_tokens",
"allowthem_audit_log",
"allowthem_mfa_challenges",
"allowthem_mfa_recovery_codes",
"allowthem_mfa_secrets",
"allowthem_oauth_accounts",
"allowthem_oauth_states",
"allowthem_password_reset_tokens",
"allowthem_permissions",
"allowthem_role_permissions",
"allowthem_roles",
"allowthem_sessions",
"allowthem_user_permissions",
"allowthem_user_roles",
"allowthem_users",
]
);
}
#[tokio::test]
async fn test_double_init_is_safe() {
let pool = SqlitePool::connect_with(
SqliteConnectOptions::from_str("sqlite::memory:")
.expect("valid connection string")
.pragma("foreign_keys", "ON"),
)
.await
.expect("create pool");
Db::new(pool.clone()).await.expect("first Db::new succeeds");
Db::new(pool)
.await
.expect("second Db::new succeeds — idempotent");
}
#[tokio::test]
async fn test_foreign_keys_enabled_via_connect() {
let db = test_db().await;
let fk_enabled: i64 = sqlx::query_scalar("PRAGMA foreign_keys")
.fetch_one(db.pool())
.await
.expect("PRAGMA foreign_keys");
assert_eq!(fk_enabled, 1, "foreign_keys must be ON");
}
#[tokio::test]
async fn test_connect_invalid_url() {
let result = Db::connect("not://valid").await;
assert!(result.is_err(), "invalid URL must return Err");
}
#[test]
fn test_generate_token_length() {
let token = generate_token();
assert_eq!(
token.as_str().len(),
43,
"base64url of 32 bytes must be 43 chars (no padding)"
);
}
#[test]
fn test_two_tokens_differ() {
let t1 = generate_token();
let t2 = generate_token();
assert_ne!(t1, t2, "two generated tokens must be different");
}
#[test]
fn test_hash_differs_from_token() {
let token = generate_token();
let hash = hash_token(&token);
let hash_debug = format!("{hash:?}");
let inner = hash_debug
.strip_prefix("TokenHash(\"")
.and_then(|s| s.strip_suffix("\")"))
.expect("Debug format matches");
assert_eq!(inner.len(), 64, "SHA-256 hex is 64 chars");
assert_ne!(inner, token.as_str(), "hash must differ from raw token");
}
#[tokio::test]
async fn test_create_and_lookup_session() {
let db = test_db().await;
let user_id = UserId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked("session_user@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
let token = generate_token();
let token_hash = hash_token(&token);
let expires_at = chrono::Utc::now() + chrono::Duration::hours(1);
let created = db
.create_session(
user_id,
token_hash,
Some("127.0.0.1"),
Some("TestAgent"),
expires_at,
)
.await
.expect("create_session");
assert_eq!(created.user_id, user_id);
assert_eq!(created.ip_address.as_deref(), Some("127.0.0.1"));
assert_eq!(created.user_agent.as_deref(), Some("TestAgent"));
let found = db
.lookup_session(&token)
.await
.expect("lookup_session")
.expect("session must exist");
assert_eq!(found.id, created.id);
assert_eq!(found.user_id, user_id);
}
#[tokio::test]
async fn test_expired_session_not_returned() {
let db = test_db().await;
let user_id = UserId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked("expired_user@example.com".to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert user");
let token = generate_token();
let token_hash = hash_token(&token);
let expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
db.create_session(user_id, token_hash, None, None, expires_at)
.await
.expect("create expired session");
let result = db.lookup_session(&token).await.expect("lookup_session");
assert!(result.is_none(), "expired session must not be returned");
}
async fn insert_test_user(db: &Db, email: &str) -> UserId {
let user_id = UserId::new();
sqlx::query(
"INSERT INTO allowthem_users (id, email, username, password_hash, email_verified, is_active, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(user_id)
.bind(Email::new_unchecked(email.to_string()))
.bind(None::<Username>)
.bind(None::<PasswordHash>)
.bind(false)
.bind(true)
.bind(now_str())
.bind(now_str())
.execute(db.pool())
.await
.expect("insert test user");
user_id
}
#[tokio::test]
async fn test_validate_session_non_expired_returns_session() {
let db = test_db().await;
let user_id = insert_test_user(&db, "validate1@example.com").await;
let token = generate_token();
let token_hash = hash_token(&token);
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
db.create_session(user_id, token_hash, None, None, expires_at)
.await
.expect("create_session");
let ttl = chrono::Duration::hours(24);
let session = db
.validate_session(&token, ttl)
.await
.expect("validate_session")
.expect("session must exist");
assert_eq!(session.user_id, user_id);
}
#[tokio::test]
async fn test_validate_session_expired_returns_none() {
let db = test_db().await;
let user_id = insert_test_user(&db, "validate2@example.com").await;
let token = generate_token();
let token_hash = hash_token(&token);
let expires_at = chrono::Utc::now() - chrono::Duration::hours(1);
db.create_session(user_id, token_hash, None, None, expires_at)
.await
.expect("create_session");
let ttl = chrono::Duration::hours(24);
let result = db
.validate_session(&token, ttl)
.await
.expect("validate_session");
assert!(result.is_none(), "expired session must not be returned");
}
#[tokio::test]
async fn test_validate_session_past_halfway_extends() {
let db = test_db().await;
let user_id = insert_test_user(&db, "validate3@example.com").await;
let token = generate_token();
let token_hash = hash_token(&token);
let ttl = chrono::Duration::hours(24);
let expires_at = chrono::Utc::now() + chrono::Duration::minutes(30);
db.create_session(user_id, token_hash, None, None, expires_at)
.await
.expect("create_session");
let session = db
.validate_session(&token, ttl)
.await
.expect("validate_session")
.expect("session must exist");
let remaining = session.expires_at - chrono::Utc::now();
assert!(
remaining > chrono::Duration::hours(20),
"session must have been extended; remaining: {remaining}"
);
}
#[tokio::test]
async fn test_validate_session_before_halfway_does_not_extend() {
let db = test_db().await;
let user_id = insert_test_user(&db, "validate4@example.com").await;
let token = generate_token();
let token_hash = hash_token(&token);
let ttl = chrono::Duration::hours(24);
let expires_at = chrono::Utc::now() + chrono::Duration::hours(23);
db.create_session(user_id, token_hash, None, None, expires_at)
.await
.expect("create_session");
let session = db
.validate_session(&token, ttl)
.await
.expect("validate_session")
.expect("session must exist");
let remaining = session.expires_at - chrono::Utc::now();
assert!(
remaining < chrono::Duration::hours(24),
"session must NOT have been extended beyond original; remaining: {remaining}"
);
}
#[tokio::test]
async fn test_delete_session() {
let db = test_db().await;
let user_id = insert_test_user(&db, "delete1@example.com").await;
let token = generate_token();
let token_hash = hash_token(&token);
let expires_at = chrono::Utc::now() + chrono::Duration::hours(1);
db.create_session(user_id, token_hash, None, None, expires_at)
.await
.expect("create_session");
let deleted = db.delete_session(&token).await.expect("delete_session");
assert!(
deleted,
"delete_session must return true when session existed"
);
let deleted_again = db
.delete_session(&token)
.await
.expect("delete_session again");
assert!(
!deleted_again,
"delete_session must return false when session already gone"
);
}
#[tokio::test]
async fn test_delete_user_sessions() {
let db = test_db().await;
let user_id = insert_test_user(&db, "delete_all@example.com").await;
let expires_at = chrono::Utc::now() + chrono::Duration::hours(1);
for _ in 0..3 {
let token = generate_token();
let token_hash = hash_token(&token);
db.create_session(user_id, token_hash, None, None, expires_at)
.await
.expect("create_session");
}
let count = db
.delete_user_sessions(&user_id)
.await
.expect("delete_user_sessions");
assert_eq!(count, 3, "must delete exactly 3 sessions");
let remaining: i64 =
sqlx::query_scalar("SELECT COUNT(*) FROM allowthem_sessions WHERE user_id = ?")
.bind(user_id)
.fetch_one(db.pool())
.await
.expect("count remaining");
assert_eq!(
remaining, 0,
"no sessions must remain after delete_user_sessions"
);
}
#[test]
fn test_session_cookie_format() {
let config = SessionConfig {
ttl: chrono::Duration::hours(24),
cookie_name: "allowthem_session",
secure: true,
};
let token = generate_token();
let cookie = session_cookie(&token, &config, "example.com");
assert!(
cookie.starts_with("allowthem_session="),
"must start with cookie name"
);
assert!(cookie.contains("HttpOnly"), "must have HttpOnly");
assert!(cookie.contains("SameSite=Lax"), "must have SameSite=Lax");
assert!(cookie.contains("Path=/"), "must have Path=/");
assert!(
cookie.contains("Max-Age=86400"),
"must have Max-Age=86400 (24h in seconds)"
);
assert!(
cookie.contains("Secure"),
"must have Secure when config.secure=true"
);
assert!(cookie.contains("Domain=example.com"), "must include domain");
}
#[test]
fn test_session_cookie_no_secure_in_dev() {
let config = SessionConfig {
ttl: chrono::Duration::hours(1),
cookie_name: "allowthem_session",
secure: false,
};
let token = generate_token();
let cookie = session_cookie(&token, &config, "");
assert!(
!cookie.contains("Secure"),
"must NOT have Secure when config.secure=false"
);
assert!(
!cookie.contains("Domain="),
"must NOT have Domain when domain is empty"
);
}
#[test]
fn test_parse_session_cookie_present() {
let token = generate_token();
let header = format!(
"other_cookie=abc123; allowthem_session={}; another=xyz",
token.as_str()
);
let parsed = parse_session_cookie(&header, "allowthem_session").expect("cookie must be found");
assert_eq!(
parsed.as_str(),
token.as_str(),
"parsed token must match original"
);
}
#[test]
fn test_parse_session_cookie_missing() {
let header = "other_cookie=abc123; yet_another=xyz";
let result = parse_session_cookie(header, "allowthem_session");
assert!(result.is_none(), "must return None when cookie is absent");
}
#[tokio::test]
async fn test_create_user() {
let db = test_db().await;
let email = Email::new("alice@example.com".into()).expect("valid email");
let username = Username::new_unchecked("alice".into());
let user = db
.create_user(email.clone(), "strong-password-123", Some(username.clone()))
.await
.expect("create_user");
assert_eq!(user.email, email);
assert_eq!(user.username, Some(username));
assert!(user.is_active);
assert!(!user.email_verified);
assert!(
user.password_hash.is_none(),
"create_user must not return password_hash"
);
}
#[tokio::test]
async fn test_create_user_without_username() {
let db = test_db().await;
let email = Email::new("noname@example.com".into()).expect("valid email");
let user = db
.create_user(email, "password", None)
.await
.expect("create_user");
assert!(user.username.is_none());
}
#[tokio::test]
async fn test_get_user() {
let db = test_db().await;
let email = Email::new("getme@example.com".into()).expect("valid email");
let created = db
.create_user(email.clone(), "password", None)
.await
.expect("create_user");
let fetched = db.get_user(created.id).await.expect("get_user");
assert_eq!(fetched.id, created.id);
assert_eq!(fetched.email, email);
assert!(
fetched.password_hash.is_none(),
"get_user must not return password_hash"
);
}
#[tokio::test]
async fn test_get_user_by_email() {
let db = test_db().await;
let email = Email::new("byemail@example.com".into()).expect("valid email");
let created = db
.create_user(email.clone(), "password", None)
.await
.expect("create_user");
let fetched = db
.get_user_by_email(&email)
.await
.expect("get_user_by_email");
assert_eq!(fetched.id, created.id);
}
#[tokio::test]
async fn test_get_user_by_username() {
let db = test_db().await;
let email = Email::new("byusername@example.com".into()).expect("valid email");
let username = Username::new_unchecked("lookmeup".into());
let created = db
.create_user(email, "password", Some(username.clone()))
.await
.expect("create_user");
let fetched = db
.get_user_by_username(&username)
.await
.expect("get_user_by_username");
assert_eq!(fetched.id, created.id);
}
#[tokio::test]
async fn test_get_user_not_found() {
let db = test_db().await;
let result = db.get_user(UserId::new()).await;
assert!(
matches!(result, Err(AuthError::NotFound)),
"get_user for non-existent ID must return NotFound"
);
}
#[tokio::test]
async fn test_find_for_login_by_email() {
let db = test_db().await;
let email = Email::new("login_email@example.com".into()).expect("valid email");
db.create_user(email, "mypassword", None)
.await
.expect("create_user");
let user = db
.find_for_login("login_email@example.com")
.await
.expect("find_for_login");
assert!(
user.password_hash.is_some(),
"find_for_login must return password_hash"
);
}
#[tokio::test]
async fn test_find_for_login_by_username() {
let db = test_db().await;
let email = Email::new("login_uname@example.com".into()).expect("valid email");
let username = Username::new_unchecked("loginuser".into());
db.create_user(email, "mypassword", Some(username))
.await
.expect("create_user");
let user = db
.find_for_login("loginuser")
.await
.expect("find_for_login");
assert!(
user.password_hash.is_some(),
"find_for_login by username must return password_hash"
);
}
#[tokio::test]
async fn test_find_for_login_verify_password() {
let db = test_db().await;
let email = Email::new("verify@example.com".into()).expect("valid email");
db.create_user(email, "correct-horse", None)
.await
.expect("create_user");
let user = db
.find_for_login("verify@example.com")
.await
.expect("find_for_login");
let hash = user.password_hash.as_ref().expect("password_hash present");
assert!(
verify_password("correct-horse", hash).expect("verify_password"),
"correct password must verify"
);
assert!(
!verify_password("wrong-password", hash).expect("verify_password"),
"wrong password must not verify"
);
}
#[tokio::test]
async fn test_find_for_login_not_found() {
let db = test_db().await;
let result = db.find_for_login("nonexistent").await;
assert!(
matches!(result, Err(AuthError::NotFound)),
"find_for_login for non-existent identifier must return NotFound"
);
}
#[tokio::test]
async fn test_duplicate_email_returns_conflict() {
let db = test_db().await;
let email = Email::new("dupe@example.com".into()).expect("valid email");
db.create_user(email.clone(), "pass1", None)
.await
.expect("first create_user");
let result = db.create_user(email, "pass2", None).await;
assert!(
matches!(result, Err(AuthError::Conflict(ref msg)) if msg.contains("email")),
"duplicate email must return Conflict with 'email' in message, got: {result:?}"
);
}
#[tokio::test]
async fn test_duplicate_username_returns_conflict() {
let db = test_db().await;
let username = Username::new_unchecked("samename".into());
db.create_user(
Email::new("user1@example.com".into()).expect("valid"),
"pass1",
Some(username.clone()),
)
.await
.expect("first create_user");
let result = db
.create_user(
Email::new("user2@example.com".into()).expect("valid"),
"pass2",
Some(username),
)
.await;
assert!(
matches!(result, Err(AuthError::Conflict(ref msg)) if msg.contains("username")),
"duplicate username must return Conflict with 'username' in message, got: {result:?}"
);
}
#[tokio::test]
async fn test_update_user_email() {
let db = test_db().await;
let old_email = Email::new("old@example.com".into()).expect("valid email");
let new_email = Email::new("new@example.com".into()).expect("valid email");
let user = db
.create_user(old_email.clone(), "password", None)
.await
.expect("create_user");
db.update_user_email(user.id, new_email.clone())
.await
.expect("update_user_email");
let fetched = db
.get_user_by_email(&new_email)
.await
.expect("get by new email");
assert_eq!(fetched.id, user.id);
let old_result = db.get_user_by_email(&old_email).await;
assert!(
matches!(old_result, Err(AuthError::NotFound)),
"old email must no longer resolve"
);
}
#[tokio::test]
async fn test_update_user_username() {
let db = test_db().await;
let email = Email::new("update_uname@example.com".into()).expect("valid email");
let username = Username::new_unchecked("original".into());
let new_username = Username::new_unchecked("updated".into());
let user = db
.create_user(email, "password", Some(username))
.await
.expect("create_user");
db.update_user_username(user.id, Some(new_username.clone()))
.await
.expect("update_user_username");
let fetched = db
.get_user_by_username(&new_username)
.await
.expect("get by new username");
assert_eq!(fetched.id, user.id);
db.update_user_username(user.id, None)
.await
.expect("update_user_username to None");
let fetched2 = db.get_user(user.id).await.expect("get_user");
assert!(fetched2.username.is_none(), "username must be cleared");
}
#[tokio::test]
async fn test_update_user_active() {
let db = test_db().await;
let email = Email::new("active@example.com".into()).expect("valid email");
let user = db
.create_user(email, "password", None)
.await
.expect("create_user");
assert!(user.is_active, "new user must be active");
db.update_user_active(user.id, false)
.await
.expect("update_user_active");
let fetched = db.get_user(user.id).await.expect("get_user");
assert!(!fetched.is_active, "user must be deactivated");
}
#[tokio::test]
async fn test_update_nonexistent_user() {
let db = test_db().await;
let email = Email::new("phantom@example.com".into()).expect("valid email");
let result = db.update_user_email(UserId::new(), email).await;
assert!(
matches!(result, Err(AuthError::NotFound)),
"updating non-existent user must return NotFound"
);
}
#[tokio::test]
async fn test_delete_user_crud() {
let db = test_db().await;
let email = Email::new("deleteme@example.com".into()).expect("valid email");
let user = db
.create_user(email, "password", None)
.await
.expect("create_user");
db.delete_user(user.id).await.expect("delete_user");
let result = db.get_user(user.id).await;
assert!(
matches!(result, Err(AuthError::NotFound)),
"deleted user must return NotFound"
);
}
#[tokio::test]
async fn test_delete_nonexistent_user() {
let db = test_db().await;
let result = db.delete_user(UserId::new()).await;
assert!(
matches!(result, Err(AuthError::NotFound)),
"deleting non-existent user must return NotFound"
);
}
#[test]
fn test_email_validation() {
assert!(Email::new("valid@example.com".into()).is_ok());
assert!(
Email::new(" spaced@example.com ".into()).is_ok(),
"whitespace should be trimmed"
);
assert!(
matches!(Email::new("nope".into()), Err(AuthError::InvalidEmail)),
"no @ must fail"
);
assert!(
matches!(Email::new("".into()), Err(AuthError::InvalidEmail)),
"empty must fail"
);
assert!(
matches!(
Email::new("@domain.com".into()),
Err(AuthError::InvalidEmail)
),
"empty local part must fail"
);
assert!(
matches!(Email::new("user@".into()), Err(AuthError::InvalidEmail)),
"empty domain must fail"
);
assert!(
matches!(
Email::new("user@nodot".into()),
Err(AuthError::InvalidEmail)
),
"domain without dot must fail"
);
}
#[tokio::test]
async fn test_create_and_get_role() {
let db = test_db().await;
let name = RoleName::new_unchecked("admin".to_string());
let role = db
.create_role(&name, Some("Administrator"))
.await
.expect("create_role");
assert_eq!(role.name, name);
assert_eq!(role.description.as_deref(), Some("Administrator"));
let by_id = db
.get_role(&role.id)
.await
.expect("get_role")
.expect("role must exist");
assert_eq!(by_id.id, role.id);
let by_name = db
.get_role_by_name(&name)
.await
.expect("get_role_by_name")
.expect("role must exist by name");
assert_eq!(by_name.id, role.id);
}
#[tokio::test]
async fn test_list_roles_empty_and_populated() {
let db = test_db().await;
let empty = db.list_roles().await.expect("list_roles empty");
assert!(empty.is_empty(), "no roles yet");
db.create_role(&RoleName::new_unchecked("viewer".to_string()), None)
.await
.expect("create viewer");
db.create_role(&RoleName::new_unchecked("editor".to_string()), None)
.await
.expect("create editor");
let roles = db.list_roles().await.expect("list_roles");
assert_eq!(roles.len(), 2);
}
#[tokio::test]
async fn test_delete_role() {
let db = test_db().await;
let role = db
.create_role(&RoleName::new_unchecked("temp".to_string()), None)
.await
.expect("create_role");
let deleted = db.delete_role(&role.id).await.expect("delete_role");
assert!(deleted, "must return true when role existed");
let not_found = db.get_role(&role.id).await.expect("get_role");
assert!(not_found.is_none(), "role must be gone after delete");
let deleted_again = db.delete_role(&role.id).await.expect("delete_role again");
assert!(!deleted_again, "must return false when role already gone");
}
#[tokio::test]
async fn test_duplicate_role_name_returns_conflict() {
let db = test_db().await;
let name = RoleName::new_unchecked("unique_role".to_string());
db.create_role(&name, None)
.await
.expect("first create succeeds");
let result = db.create_role(&name, Some("duplicate")).await;
assert!(
matches!(result, Err(AuthError::Conflict(_))),
"duplicate role name must return Conflict, got: {result:?}"
);
}
#[tokio::test]
async fn test_assign_and_unassign_role() {
let db = test_db().await;
let user_id = insert_test_user(&db, "roles_user@example.com").await;
let role = db
.create_role(&RoleName::new_unchecked("moderator".to_string()), None)
.await
.expect("create_role");
db.assign_role(&user_id, &role.id)
.await
.expect("assign_role");
db.assign_role(&user_id, &role.id)
.await
.expect("assign_role idempotent");
let removed = db
.unassign_role(&user_id, &role.id)
.await
.expect("unassign_role");
assert!(removed, "must return true when assignment existed");
let removed_again = db
.unassign_role(&user_id, &role.id)
.await
.expect("unassign again");
assert!(
!removed_again,
"must return false when assignment already gone"
);
}
#[tokio::test]
async fn test_has_role_true_and_false() {
let db = test_db().await;
let user_id = insert_test_user(&db, "has_role_user@example.com").await;
let name = RoleName::new_unchecked("superuser".to_string());
let role = db.create_role(&name, None).await.expect("create_role");
let before = db.has_role(&user_id, &name).await.expect("has_role before");
assert!(!before, "must be false before assignment");
db.assign_role(&user_id, &role.id)
.await
.expect("assign_role");
let after = db.has_role(&user_id, &name).await.expect("has_role after");
assert!(after, "must be true after assignment");
}
#[tokio::test]
async fn test_get_user_roles() {
let db = test_db().await;
let user_id = insert_test_user(&db, "user_roles@example.com").await;
let empty = db
.get_user_roles(&user_id)
.await
.expect("get_user_roles empty");
assert!(empty.is_empty(), "no roles assigned yet");
let r1 = db
.create_role(&RoleName::new_unchecked("read".to_string()), None)
.await
.expect("create r1");
let r2 = db
.create_role(&RoleName::new_unchecked("write".to_string()), None)
.await
.expect("create r2");
db.assign_role(&user_id, &r1.id).await.expect("assign r1");
db.assign_role(&user_id, &r2.id).await.expect("assign r2");
let roles = db.get_user_roles(&user_id).await.expect("get_user_roles");
assert_eq!(roles.len(), 2);
let names: Vec<_> = roles.iter().map(|r| r.name.clone()).collect();
assert!(names.contains(&r1.name));
assert!(names.contains(&r2.name));
}
#[tokio::test]
async fn test_create_and_get_permission() {
let db = test_db().await;
let name = PermissionName::new_unchecked("posts:read".to_string());
let perm = db
.create_permission(&name, Some("Read posts"))
.await
.expect("create_permission");
assert_eq!(perm.name, name);
assert_eq!(perm.description.as_deref(), Some("Read posts"));
let by_id = db
.get_permission(&perm.id)
.await
.expect("get_permission")
.expect("must exist by id");
assert_eq!(by_id.id, perm.id);
let by_name = db
.get_permission_by_name(&name)
.await
.expect("get_permission_by_name")
.expect("must exist by name");
assert_eq!(by_name.id, perm.id);
}
#[tokio::test]
async fn test_duplicate_permission_name_returns_conflict() {
let db = test_db().await;
let name = PermissionName::new_unchecked("unique:perm".to_string());
db.create_permission(&name, None)
.await
.expect("first create succeeds");
let result = db.create_permission(&name, Some("duplicate")).await;
assert!(
matches!(result, Err(AuthError::Conflict(_))),
"duplicate permission name must return Conflict, got: {result:?}"
);
}
#[tokio::test]
async fn test_has_permission_via_role() {
let db = test_db().await;
let user_id = insert_test_user(&db, "perm_role@example.com").await;
let role = db
.create_role(&RoleName::new_unchecked("writer".to_string()), None)
.await
.expect("create_role");
let perm = db
.create_permission(
&PermissionName::new_unchecked("posts:write".to_string()),
None,
)
.await
.expect("create_permission");
db.assign_role(&user_id, &role.id)
.await
.expect("assign_role");
db.assign_permission_to_role(&role.id, &perm.id)
.await
.expect("assign_permission_to_role");
let has = db
.has_permission(&user_id, &perm.name)
.await
.expect("has_permission");
assert!(has, "must have permission via role");
}
#[tokio::test]
async fn test_has_permission_via_direct_assignment() {
let db = test_db().await;
let user_id = insert_test_user(&db, "perm_direct@example.com").await;
let perm = db
.create_permission(
&PermissionName::new_unchecked("admin:read".to_string()),
None,
)
.await
.expect("create_permission");
db.assign_permission_to_user(&user_id, &perm.id)
.await
.expect("assign_permission_to_user");
let has = db
.has_permission(&user_id, &perm.name)
.await
.expect("has_permission");
assert!(has, "must have permission via direct assignment");
}
#[tokio::test]
async fn test_has_permission_no_assignment_returns_false() {
let db = test_db().await;
let user_id = insert_test_user(&db, "perm_none@example.com").await;
let perm = db
.create_permission(
&PermissionName::new_unchecked("secret:op".to_string()),
None,
)
.await
.expect("create_permission");
let has = db
.has_permission(&user_id, &perm.name)
.await
.expect("has_permission");
assert!(!has, "must return false when user has no assignment");
}
#[tokio::test]
async fn test_get_user_permissions_deduplicated() {
let db = test_db().await;
let user_id = insert_test_user(&db, "perm_dedup@example.com").await;
let role = db
.create_role(&RoleName::new_unchecked("dedup_role".to_string()), None)
.await
.expect("create_role");
let p1 = db
.create_permission(
&PermissionName::new_unchecked("dedup:read".to_string()),
None,
)
.await
.expect("create p1");
let p2 = db
.create_permission(
&PermissionName::new_unchecked("dedup:write".to_string()),
None,
)
.await
.expect("create p2");
db.assign_role(&user_id, &role.id)
.await
.expect("assign_role");
db.assign_permission_to_role(&role.id, &p1.id)
.await
.expect("p1 to role");
db.assign_permission_to_user(&user_id, &p1.id)
.await
.expect("p1 direct");
db.assign_permission_to_user(&user_id, &p2.id)
.await
.expect("p2 direct");
let perms = db
.get_user_permissions(&user_id)
.await
.expect("get_user_permissions");
assert_eq!(perms.len(), 2, "p1 must not be duplicated; got {perms:?}");
let perm_names: Vec<_> = perms.iter().map(|p| p.name.clone()).collect();
assert!(perm_names.contains(&p1.name));
assert!(perm_names.contains(&p2.name));
}
#[tokio::test]
async fn test_unassign_permission_from_role_and_user() {
let db = test_db().await;
let user_id = insert_test_user(&db, "perm_unassign@example.com").await;
let role = db
.create_role(&RoleName::new_unchecked("unassign_role".to_string()), None)
.await
.expect("create_role");
let perm = db
.create_permission(
&PermissionName::new_unchecked("unassign:op".to_string()),
None,
)
.await
.expect("create_permission");
db.assign_permission_to_role(&role.id, &perm.id)
.await
.expect("assign to role");
db.assign_permission_to_user(&user_id, &perm.id)
.await
.expect("assign to user");
let removed_role = db
.unassign_permission_from_role(&role.id, &perm.id)
.await
.expect("unassign from role");
assert!(removed_role, "must return true when assignment existed");
let removed_role_again = db
.unassign_permission_from_role(&role.id, &perm.id)
.await
.expect("unassign from role again");
assert!(!removed_role_again, "must return false when already gone");
let removed_user = db
.unassign_permission_from_user(&user_id, &perm.id)
.await
.expect("unassign from user");
assert!(removed_user, "must return true when assignment existed");
let removed_user_again = db
.unassign_permission_from_user(&user_id, &perm.id)
.await
.expect("unassign from user again");
assert!(!removed_user_again, "must return false when already gone");
}
#[tokio::test]
async fn test_audit_log_round_trip() {
let db = test_db().await;
let user_id = UserId::new();
db.log_audit(
AuditEvent::Login,
Some(&user_id),
None,
Some("127.0.0.1"),
Some("Mozilla/5.0"),
Some(r#"{"status":"ok"}"#),
)
.await
.expect("log audit event");
let entries = db.get_audit_log(None, 10, 0).await.expect("get audit log");
assert_eq!(entries.len(), 1);
let entry = &entries[0];
assert_eq!(entry.event_type, AuditEvent::Login);
assert_eq!(entry.user_id, Some(user_id));
assert_eq!(entry.ip_address.as_deref(), Some("127.0.0.1"));
assert_eq!(entry.user_agent.as_deref(), Some("Mozilla/5.0"));
assert_eq!(entry.detail.as_deref(), Some(r#"{"status":"ok"}"#));
}
#[tokio::test]
async fn test_audit_log_filter_by_user() {
let db = test_db().await;
let user_a = UserId::new();
let user_b = UserId::new();
db.log_audit(AuditEvent::Login, Some(&user_a), None, None, None, None)
.await
.expect("log user_a login");
db.log_audit(AuditEvent::Logout, Some(&user_b), None, None, None, None)
.await
.expect("log user_b logout");
db.log_audit(
AuditEvent::PasswordChange,
Some(&user_a),
None,
None,
None,
None,
)
.await
.expect("log user_a password change");
let entries_a = db
.get_audit_log(Some(&user_a), 10, 0)
.await
.expect("get user_a audit log");
assert_eq!(entries_a.len(), 2);
for entry in &entries_a {
assert_eq!(entry.user_id, Some(user_a));
}
let entries_b = db
.get_audit_log(Some(&user_b), 10, 0)
.await
.expect("get user_b audit log");
assert_eq!(entries_b.len(), 1);
assert_eq!(entries_b[0].event_type, AuditEvent::Logout);
}
#[tokio::test]
async fn test_audit_log_filter_by_event_type() {
let db = test_db().await;
let user_id = UserId::new();
db.log_audit(AuditEvent::Login, Some(&user_id), None, None, None, None)
.await
.expect("log login");
db.log_audit(AuditEvent::Login, Some(&user_id), None, None, None, None)
.await
.expect("log login again");
db.log_audit(AuditEvent::Logout, Some(&user_id), None, None, None, None)
.await
.expect("log logout");
db.log_audit(AuditEvent::Register, Some(&user_id), None, None, None, None)
.await
.expect("log register");
let logins = db
.get_audit_log_by_event(AuditEvent::Login, 10, 0)
.await
.expect("get logins");
assert_eq!(logins.len(), 2);
for entry in &logins {
assert_eq!(entry.event_type, AuditEvent::Login);
}
let logouts = db
.get_audit_log_by_event(AuditEvent::Logout, 10, 0)
.await
.expect("get logouts");
assert_eq!(logouts.len(), 1);
}
#[tokio::test]
async fn test_audit_log_pagination() {
let db = test_db().await;
let user_id = UserId::new();
for _ in 0..5 {
db.log_audit(AuditEvent::Login, Some(&user_id), None, None, None, None)
.await
.expect("log login");
}
let page1 = db.get_audit_log(None, 2, 0).await.expect("get page 1");
assert_eq!(page1.len(), 2);
let page2 = db.get_audit_log(None, 2, 2).await.expect("get page 2");
assert_eq!(page2.len(), 2);
let page3 = db.get_audit_log(None, 2, 4).await.expect("get page 3");
assert_eq!(page3.len(), 1);
assert_ne!(page1[0].id, page2[0].id);
assert_ne!(page2[0].id, page3[0].id);
}
#[tokio::test]
async fn test_audit_log_null_user_id() {
let db = test_db().await;
db.log_audit(
AuditEvent::LoginFailed,
None,
None,
Some("10.0.0.1"),
None,
Some(r#"{"email":"unknown@example.com"}"#),
)
.await
.expect("log failed login with no user_id");
let entries = db.get_audit_log(None, 10, 0).await.expect("get audit log");
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].event_type, AuditEvent::LoginFailed);
assert!(entries[0].user_id.is_none());
assert_eq!(entries[0].ip_address.as_deref(), Some("10.0.0.1"));
}