#![cfg(feature = "integration-test")]
use chrono::Duration as ChronoDuration;
use sqlx::Row as _;
use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres;
use rustio_admin::__integration::{
admin_revoke_sessions, admin_set_temp_password, check_account_lockout, check_session_elevated,
fake_request, lock_user_account, promote_session_elevated, record_failed_login,
record_successful_login, unlock_user_account, AdminActor, AdminRevokeOutcome,
AdminTempPwOutcome, LockDuration, LockOutcome, LockState, ThrottleOutcome, UnlockOutcome,
};
use rustio_admin::auth::{self, Role};
use rustio_admin::orm::Db;
struct TestEnv {
db: Db,
_container: testcontainers::ContainerAsync<Postgres>,
}
async fn boot() -> TestEnv {
let container = Postgres::default()
.start()
.await
.expect("postgres container starts");
let port = container
.get_host_port_ipv4(5432)
.await
.expect("port mapping");
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
let db = Db::connect(&url).await.expect("Db::connect");
auth::init_tables(&db).await.expect("auth::init_tables");
TestEnv {
db,
_container: container,
}
}
async fn create_user(db: &Db, email: &str, password: &str, role: Role) -> i64 {
auth::create_user(db, email, password, role)
.await
.expect("create_user")
}
async fn create_session(db: &Db, user_id: i64) -> i64 {
let token = auth::create_session(db, user_id)
.await
.expect("create_session");
auth::current_session_id(db, &token)
.await
.expect("current_session_id")
.expect("current_session_id is Some")
}
async fn count_audit_events(db: &Db, action_type: &str, object_id: i64) -> i64 {
let row = sqlx::query(
"SELECT COUNT(*)::bigint AS n FROM rustio_admin_actions \
WHERE action_type = $1 AND object_id = $2",
)
.bind(action_type)
.bind(object_id)
.fetch_one(db.pool())
.await
.expect("count audit");
row.try_get("n").expect("count column")
}
async fn read_user_lockout(
db: &Db,
user_id: i64,
) -> (
i32,
Option<chrono::DateTime<chrono::Utc>>,
Option<chrono::DateTime<chrono::Utc>>,
bool,
) {
let row = sqlx::query(
"SELECT failed_login_count, last_failed_login_at, locked_until, must_change_password \
FROM rustio_users WHERE id = $1",
)
.bind(user_id)
.fetch_one(db.pool())
.await
.expect("read user lockout");
(
row.try_get("failed_login_count").unwrap(),
row.try_get("last_failed_login_at").unwrap(),
row.try_get("locked_until").unwrap(),
row.try_get("must_change_password").unwrap(),
)
}
async fn count_revoked_sessions(db: &Db, user_id: i64) -> i64 {
let row = sqlx::query(
"SELECT COUNT(*)::bigint AS n FROM rustio_sessions \
WHERE user_id = $1 AND revoked_at IS NOT NULL",
)
.bind(user_id)
.fetch_one(db.pool())
.await
.expect("count revoked");
row.try_get("n").unwrap()
}
#[tokio::test]
async fn admin_set_temp_password_writes_hash_flag_revokes_sessions() {
let env = boot().await;
let actor_id = create_user(
&env.db,
"admin@example.com",
"admin-pw-1234567",
Role::Administrator,
)
.await;
let target_id = create_user(&env.db, "target@example.com", "old-pw-1234567", Role::Staff).await;
create_session(&env.db, target_id).await;
let actor = AdminActor {
user_id: actor_id,
email: "admin@example.com",
};
let req = fake_request();
let outcome =
admin_set_temp_password(&env.db, &req, target_id, actor, "support ticket #42", None)
.await
.expect("admin_set_temp_password");
let temp_pw = match outcome {
AdminTempPwOutcome::Set {
temp_password,
revoked_session_count,
..
} => {
assert_eq!(revoked_session_count, 1);
temp_password
}
other => panic!("unexpected outcome: {other:?}"),
};
assert_eq!(temp_pw.len(), 16, "temp password is 16 chars per §12 lock");
let user = auth::find_user_by_email(&env.db, "target@example.com")
.await
.unwrap()
.unwrap();
assert!(auth::verify_password(&temp_pw, &user.password_hash));
assert!(user.must_change_password);
assert_eq!(count_revoked_sessions(&env.db, target_id).await, 1);
assert_eq!(
count_audit_events(&env.db, "password_reset_by_other", target_id).await,
1
);
assert_eq!(
count_audit_events(&env.db, "sessions_revoked_by_other", target_id).await,
1
);
}
#[tokio::test]
async fn lock_user_account_writes_locked_until_revokes_sessions() {
let env = boot().await;
let actor_id = create_user(
&env.db,
"admin@example.com",
"admin-pw-1234567",
Role::Administrator,
)
.await;
let target_id = create_user(
&env.db,
"target@example.com",
"target-pw-1234567",
Role::Staff,
)
.await;
create_session(&env.db, target_id).await;
create_session(&env.db, target_id).await;
let actor = AdminActor {
user_id: actor_id,
email: "admin@example.com",
};
let req = fake_request();
let outcome = lock_user_account(
&env.db,
&req,
target_id,
actor,
LockDuration::OneHour,
"suspected credential leak",
None,
)
.await
.expect("lock_user_account");
match outcome {
LockOutcome::Locked {
revoked_session_count,
..
} => {
assert_eq!(revoked_session_count, 2);
}
other => panic!("unexpected outcome: {other:?}"),
}
let (_, _, locked_until, _) = read_user_lockout(&env.db, target_id).await;
let until = locked_until.expect("locked_until is set");
let now = chrono::Utc::now();
assert!(until > now);
assert!(until <= now + ChronoDuration::hours(1) + ChronoDuration::minutes(1));
assert_eq!(count_revoked_sessions(&env.db, target_id).await, 2);
assert_eq!(
count_audit_events(&env.db, "account_locked", target_id).await,
1
);
assert_eq!(
count_audit_events(&env.db, "sessions_revoked_by_other", target_id).await,
2
);
}
#[tokio::test]
async fn unlock_user_account_clears_lock_zeroes_counter() {
let env = boot().await;
let actor_id = create_user(
&env.db,
"admin@example.com",
"admin-pw-1234567",
Role::Administrator,
)
.await;
let target_id = create_user(
&env.db,
"target@example.com",
"target-pw-1234567",
Role::Staff,
)
.await;
sqlx::query(
"UPDATE rustio_users SET locked_until = NOW() + INTERVAL '1 hour', \
failed_login_count = 3, last_failed_login_at = NOW() WHERE id = $1",
)
.bind(target_id)
.execute(env.db.pool())
.await
.unwrap();
let actor = AdminActor {
user_id: actor_id,
email: "admin@example.com",
};
let req = fake_request();
let outcome = unlock_user_account(
&env.db,
&req,
target_id,
actor,
"false alarm — verified",
None,
)
.await
.expect("unlock_user_account");
assert!(matches!(outcome, UnlockOutcome::Unlocked { .. }));
let (counter, _last_fail, locked_until, _flag) = read_user_lockout(&env.db, target_id).await;
assert_eq!(counter, 0);
assert!(locked_until.is_none());
assert_eq!(
count_audit_events(&env.db, "account_unlocked", target_id).await,
1
);
assert_eq!(count_revoked_sessions(&env.db, target_id).await, 0);
}
#[tokio::test]
async fn admin_revoke_sessions_revokes_without_locking() {
let env = boot().await;
let actor_id = create_user(
&env.db,
"admin@example.com",
"admin-pw-1234567",
Role::Administrator,
)
.await;
let target_id = create_user(
&env.db,
"target@example.com",
"target-pw-1234567",
Role::Staff,
)
.await;
create_session(&env.db, target_id).await;
create_session(&env.db, target_id).await;
create_session(&env.db, target_id).await;
let actor = AdminActor {
user_id: actor_id,
email: "admin@example.com",
};
let req = fake_request();
let outcome = admin_revoke_sessions(&env.db, &req, target_id, actor, "incident response", None)
.await
.expect("admin_revoke_sessions");
match outcome {
AdminRevokeOutcome::Revoked {
revoked_session_count,
..
} => {
assert_eq!(revoked_session_count, 3);
}
other => panic!("unexpected outcome: {other:?}"),
}
let (_, _, locked_until, _) = read_user_lockout(&env.db, target_id).await;
assert!(locked_until.is_none(), "no lock written");
assert_eq!(count_revoked_sessions(&env.db, target_id).await, 3);
assert_eq!(
count_audit_events(&env.db, "sessions_revoked_by_other", target_id).await,
3
);
assert_eq!(
count_audit_events(&env.db, "account_locked", target_id).await,
0
);
}
#[tokio::test]
async fn record_failed_login_auto_throttles_at_threshold() {
let env = boot().await;
let target_id = create_user(
&env.db,
"target@example.com",
"target-pw-1234567",
Role::Staff,
)
.await;
let throttle = rustio_admin::auth::LoginThrottle::DEFAULT;
for expected in 1..=4 {
let outcome = record_failed_login(&env.db, target_id, throttle)
.await
.unwrap();
match outcome {
ThrottleOutcome::Recorded { count } => assert_eq!(count, expected),
other => panic!("attempt {expected}: unexpected outcome {other:?}"),
}
assert!(matches!(
check_account_lockout(&env.db, target_id).await.unwrap(),
LockState::Unlocked
));
}
let outcome = record_failed_login(&env.db, target_id, throttle)
.await
.unwrap();
let until = match outcome {
ThrottleOutcome::JustLocked { count, until } => {
assert_eq!(count, 5);
until
}
other => panic!("expected JustLocked, got {other:?}"),
};
match check_account_lockout(&env.db, target_id).await.unwrap() {
LockState::Locked { until: read_until } => {
let drift = (until - read_until).num_seconds().abs();
assert!(drift <= 1, "drift {drift}s between writes");
}
LockState::Unlocked => panic!("expected locked"),
}
}
#[tokio::test]
async fn record_successful_login_zeroes_counter() {
let env = boot().await;
let target_id = create_user(
&env.db,
"target@example.com",
"target-pw-1234567",
Role::Staff,
)
.await;
let throttle = rustio_admin::auth::LoginThrottle::DEFAULT;
for _ in 0..3 {
record_failed_login(&env.db, target_id, throttle)
.await
.unwrap();
}
assert_eq!(read_user_lockout(&env.db, target_id).await.0, 3);
record_successful_login(&env.db, target_id).await.unwrap();
let (counter, last_fail, _, _) = read_user_lockout(&env.db, target_id).await;
assert_eq!(counter, 0);
assert!(last_fail.is_none());
}
#[tokio::test]
async fn promote_then_check_session_elevated_round_trip() {
let env = boot().await;
let target_id = create_user(
&env.db,
"target@example.com",
"target-pw-1234567",
Role::Staff,
)
.await;
let session_id = create_session(&env.db, target_id).await;
assert!(!check_session_elevated(&env.db, session_id).await.unwrap());
promote_session_elevated(&env.db, session_id, ChronoDuration::minutes(15))
.await
.unwrap();
assert!(check_session_elevated(&env.db, session_id).await.unwrap());
promote_session_elevated(&env.db, session_id, ChronoDuration::seconds(-1))
.await
.unwrap();
assert!(!check_session_elevated(&env.db, session_id).await.unwrap());
}