mod common;
use std::sync::Arc;
use nodedb::bridge::dispatch::Dispatcher;
use nodedb::config::auth::AuthMode;
use nodedb::control::security::credential::store::CredentialStore;
use nodedb::control::security::identity::Role;
use nodedb::control::server::resp::command::RespCommand;
use nodedb::control::server::resp::handler::execute as resp_execute;
use nodedb::control::server::resp::session::RespSession;
use nodedb::control::server::session_auth::authenticate;
use nodedb::control::state::SharedState;
use nodedb::types::TenantId;
use nodedb::wal::WalManager;
const MAX_FAILED: u32 = 5;
const LOCKOUT_SECS: u64 = 300;
fn state_with_lockout() -> (Arc<SharedState>, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let wal = Arc::new(WalManager::open_for_testing(&dir.path().join("test.wal")).unwrap());
let mut store = CredentialStore::open(&dir.path().join("system.redb")).unwrap();
store.set_lockout_policy(MAX_FAILED, LOCKOUT_SECS, 0);
let credentials = Arc::new(store);
let (dispatcher, _data_sides) = Dispatcher::new(1, 64);
let state = SharedState::new_with_credentials(dispatcher, wal, credentials);
state.rate_limiter.set_login_capacities(10_000, 10_000);
(state, dir)
}
async fn native_password_login(state: &SharedState, username: &str, password: &str) {
let body = serde_json::json!({
"method": "password",
"username": username,
"password": password,
});
let _ = authenticate(state, &AuthMode::Password, &body, "127.0.0.1:5000").await;
}
async fn resp_auth(state: &SharedState, username: &str, password: &str) {
let cmd = RespCommand {
name: "AUTH".to_string(),
args: vec![username.as_bytes().to_vec(), password.as_bytes().to_vec()],
};
let mut session = RespSession::default();
let _ = resp_execute(&cmd, &mut session, state).await;
}
#[tokio::test]
async fn correct_password_with_pending_change_does_not_count_as_credential_failure() {
let (state, _dir) = state_with_lockout();
state
.credentials
.create_user(
"admin",
"correct-pw",
TenantId::new(1),
vec![Role::Superuser],
)
.unwrap();
state
.credentials
.set_must_change_password("admin", true)
.unwrap();
for _ in 0..MAX_FAILED {
native_password_login(&state, "admin", "correct-pw").await;
}
assert!(
state.credentials.check_lockout("admin").is_ok(),
"a correct password rejected for a pending password change must \
not count toward credential lockout"
);
}
#[tokio::test]
async fn correct_password_past_expiry_does_not_count_as_credential_failure() {
let (state, _dir) = state_with_lockout();
state
.credentials
.create_user("ops", "correct-pw", TenantId::new(1), vec![Role::Superuser])
.unwrap();
state.credentials.set_password_expires_at("ops", 1).unwrap();
for _ in 0..MAX_FAILED {
native_password_login(&state, "ops", "correct-pw").await;
}
assert!(
state.credentials.check_lockout("ops").is_ok(),
"a correct password rejected for password expiry must not count \
toward credential lockout"
);
}
#[tokio::test]
async fn resp_auth_correct_password_with_pending_change_does_not_count() {
let (state, _dir) = state_with_lockout();
state
.credentials
.create_user(
"admin",
"correct-pw",
TenantId::new(1),
vec![Role::Superuser],
)
.unwrap();
state
.credentials
.set_must_change_password("admin", true)
.unwrap();
for _ in 0..MAX_FAILED {
resp_auth(&state, "admin", "correct-pw").await;
}
assert!(
state.credentials.check_lockout("admin").is_ok(),
"RESP AUTH with a correct password rejected for a pending password \
change must not count toward credential lockout"
);
}
#[tokio::test]
async fn wrong_password_counts_toward_lockout() {
let (state, _dir) = state_with_lockout();
state
.credentials
.create_user(
"admin",
"correct-pw",
TenantId::new(1),
vec![Role::Superuser],
)
.unwrap();
for _ in 0..MAX_FAILED {
native_password_login(&state, "admin", "wrong-pw").await;
}
assert!(
state.credentials.check_lockout("admin").is_err(),
"five wrong-password attempts must lock the account"
);
}
#[tokio::test]
async fn unknown_user_counts_toward_lockout() {
let (state, _dir) = state_with_lockout();
for _ in 0..MAX_FAILED {
native_password_login(&state, "ghost", "any-pw").await;
}
assert!(
state.credentials.check_lockout("ghost").is_err(),
"five attempts against an unknown user must lock the account"
);
}
#[tokio::test]
async fn pgwire_scram_correct_password_with_pending_change_does_not_count() {
let server = common::pgwire_harness::TestServer::start_password().await;
server
.shared
.rate_limiter
.set_login_capacities(10_000, 10_000);
server
.shared
.credentials
.set_must_change_password("nodedb", true)
.unwrap();
let conn_str = format!(
"host=127.0.0.1 port={} user=nodedb password=nodedb dbname=nodedb",
server.pg_port
);
for _ in 0..MAX_FAILED {
let attempt = tokio_postgres::connect(&conn_str, tokio_postgres::NoTls).await;
assert!(
attempt.is_err(),
"a pending password change must reject the login"
);
}
assert!(
server.shared.credentials.check_lockout("nodedb").is_ok(),
"a correct password rejected over SCRAM for a pending password \
change must not count toward credential lockout"
);
}
#[tokio::test]
async fn pgwire_locked_account_rejection_does_not_advertise_lockout() {
let server = common::pgwire_harness::TestServer::start_password().await;
server
.shared
.rate_limiter
.set_login_capacities(10_000, 10_000);
let wrong = format!(
"host=127.0.0.1 port={} user=nodedb password=wrong-pw dbname=nodedb",
server.pg_port
);
for _ in 0..MAX_FAILED {
let _ = tokio_postgres::connect(&wrong, tokio_postgres::NoTls).await;
}
assert!(
server.shared.credentials.check_lockout("nodedb").is_err(),
"five wrong-password SCRAM attempts must lock the account"
);
let err = match tokio_postgres::connect(&wrong, tokio_postgres::NoTls).await {
Ok(_) => panic!("a locked account must reject the connection"),
Err(e) => e,
};
let server_msg = err
.as_db_error()
.map(|d| d.message().to_lowercase())
.unwrap_or_default();
assert!(
!server_msg.contains("locked") && !server_msg.contains("lockout"),
"the wire rejection must not advertise the account's lockout \
state to an unauthenticated client: {server_msg}"
);
}