#![allow(clippy::unwrap_used, clippy::expect_used)]
use super::*;
use crate::config::{Environment, RullstConfig};
struct EnvironmentGuard {
values: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl EnvironmentGuard {
fn clear(keys: &[&'static str]) -> Self {
let values = keys
.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect::<Vec<_>>();
for key in keys {
unsafe { std::env::remove_var(key) };
}
Self { values }
}
fn set(&self, key: &'static str, value: impl AsRef<std::ffi::OsStr>) {
unsafe { std::env::set_var(key, value) };
}
}
impl Drop for EnvironmentGuard {
fn drop(&mut self) {
for (key, value) in &self.values {
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
#[tokio::test]
async fn networking_respects_precedence_defaults_and_invalid_values() {
let _lock = crate::server::TEST_ENV_LOCK.lock().await;
let environment = EnvironmentGuard::clear(&["HOST", "RULLST_HOST", "PORT"]);
let empty = HashMap::new();
let development = Server::setup_networking(3000, None, Environment::Development, &empty)
.expect("development default address");
assert_eq!(development, "127.0.0.1:3000".parse().unwrap());
let production = Server::setup_networking(3000, Some(8080), Environment::Production, &empty)
.expect("production default address");
assert_eq!(production, "0.0.0.0:8080".parse().unwrap());
let dotenv = HashMap::from([
("HOST".to_string(), "127.0.0.2".to_string()),
("RULLST_HOST".to_string(), "127.0.0.3".to_string()),
("PORT".to_string(), "4000".to_string()),
]);
let from_dotenv = Server::setup_networking(3000, Some(8080), Environment::Test, &dotenv)
.expect("dotenv address");
assert_eq!(from_dotenv, "127.0.0.2:4000".parse().unwrap());
environment.set("RULLST_HOST", "127.0.0.4");
environment.set("PORT", "5000");
let legacy_host = Server::setup_networking(3000, None, Environment::Test, &HashMap::new())
.expect("legacy host address");
assert_eq!(legacy_host, "127.0.0.4:5000".parse().unwrap());
environment.set("HOST", "127.0.0.5");
let primary_host = Server::setup_networking(3000, None, Environment::Test, &HashMap::new())
.expect("primary host address");
assert_eq!(primary_host, "127.0.0.5:5000".parse().unwrap());
environment.set("PORT", "not-a-port");
assert!(matches!(
Server::setup_networking(3000, None, Environment::Test, &HashMap::new()),
Err(ServerError::Configuration(message)) if message.contains("valid u16")
));
environment.set("PORT", "3000");
environment.set("HOST", "not a host");
assert!(matches!(
Server::setup_networking(3000, None, Environment::Test, &HashMap::new()),
Err(ServerError::InvalidAddress { .. })
));
}
#[tokio::test]
async fn environment_resolution_obeys_process_dotenv_and_config_precedence() {
let _lock = crate::server::TEST_ENV_LOCK.lock().await;
let environment = EnvironmentGuard::clear(&["RULLST_ENV", "APP_ENV"]);
let mut config = RullstConfig::new();
config.app.env = Some("test".to_string());
assert_eq!(
resolve_environment(&config, &HashMap::new()).unwrap(),
Environment::Test
);
let dotenv = HashMap::from([("APP_ENV".to_string(), "staging".to_string())]);
assert_eq!(
resolve_environment(&config, &dotenv).unwrap(),
Environment::Staging
);
environment.set("APP_ENV", "production");
assert_eq!(
resolve_environment(&config, &dotenv).unwrap(),
Environment::Production
);
environment.set("RULLST_ENV", "development");
assert_eq!(
resolve_environment(&config, &dotenv).unwrap(),
Environment::Development
);
environment.set("RULLST_ENV", "invalid");
assert!(matches!(
resolve_environment(&config, &dotenv),
Err(ServerError::Configuration(_))
));
}
#[cfg(unix)]
#[tokio::test]
async fn non_unicode_environment_values_fail_closed() {
use std::os::unix::ffi::OsStringExt;
let _lock = crate::server::TEST_ENV_LOCK.lock().await;
let environment = EnvironmentGuard::clear(&["RULLST_NON_UNICODE_TEST"]);
environment.set(
"RULLST_NON_UNICODE_TEST",
std::ffi::OsString::from_vec(vec![0xff]),
);
assert!(matches!(
read_optional_environment_variable("RULLST_NON_UNICODE_TEST"),
Err(ServerError::Configuration(message)) if message.contains("not valid Unicode")
));
}
#[tokio::test]
async fn scheduler_shield_and_missing_hot_library_have_typed_lifecycles() {
let mut scheduled = Server::new(Router::new()).schedule(Scheduler::new());
let handle = scheduled
.start_scheduler()
.expect("scheduler start")
.expect("scheduler handle");
handle.shutdown().await.expect("scheduler shutdown");
assert!(scheduled.start_scheduler().unwrap().is_none());
let shield = crate::resilience::TrafficShield::new(
crate::resilience::TrafficShieldConfig::new().with_db_probe(false),
);
let shielded = Server::new(Router::new()).shield(shield);
let lifecycle = shielded
.start_traffic_shield()
.expect("shield start")
.expect("shield lifecycle");
lifecycle.shutdown();
assert!(
Server::new(Router::new())
.start_traffic_shield()
.unwrap()
.is_none()
);
let _lock = crate::server::TEST_ENV_LOCK.lock().await;
let environment = EnvironmentGuard::clear(&["RULLST_HMR_TOKEN"]);
environment.set(
"RULLST_HMR_TOKEN",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);
let error = Server::new_hot("/definitely/missing/rullst-app")
.run_hot_reload(
"/definitely/missing/rullst-app".to_string(),
"127.0.0.1:0".parse().unwrap(),
Environment::Development,
std::future::ready(()),
)
.await
.unwrap_err();
if cfg!(debug_assertions) {
assert!(
matches!(&error, ServerError::HotReload(_)),
"debug hot reload should reach the missing-library boundary, got {error:?}"
);
} else {
assert!(
matches!(&error, ServerError::HotReloadDisabled),
"release builds must reject hot reload before loading a library, got {error:?}"
);
}
}
#[tokio::test]
async fn a_past_scheduled_task_failure_is_logged_and_does_not_fail_shutdown() {
let _lock = crate::server::TEST_ENV_LOCK.lock().await;
let environment = EnvironmentGuard::clear(&[
"HOST",
"RULLST_HOST",
"PORT",
"RULLST_ENV",
"APP_ENV",
"DATABASE_URL",
]);
environment.set("RULLST_ENV", "test");
let logs = super::super::scheduler_supervision::tests::CapturedLogs::default();
let _subscriber = tracing::subscriber::set_default(logs.subscriber());
let scheduler = crate::scheduler::contract_tests::every_second(
std::time::Duration::from_millis(5),
|| async { tokio::time::sleep(std::time::Duration::from_secs(1)).await },
);
let result = tokio::time::timeout(
std::time::Duration::from_secs(20),
Server::new(Router::new())
.schedule(scheduler)
.run_with_shutdown(
0,
tokio::time::sleep(std::time::Duration::from_millis(2_300)),
),
)
.await
.expect("server stopped before its deadline");
assert!(result.is_ok(), "a clean drain must succeed: {result:?}");
assert!(
logs.text().contains("exceeded its 5ms timeout"),
"the task failure must be logged: {}",
logs.text()
);
}
#[test]
fn server_accepts_a_shared_application_lifecycle() {
let lifecycle = crate::lifecycle::ApplicationLifecycle::new();
let server = Server::new(Router::new()).with_lifecycle(lifecycle.clone());
assert!(server.lifecycle.is_some());
assert_eq!(
lifecycle.phase(),
crate::lifecycle::ApplicationPhase::Starting
);
}
#[tokio::test]
async fn lifecycle_helpers_are_monotonic_and_startup_failure_stops() {
let lifecycle = crate::lifecycle::ApplicationLifecycle::new();
mark_lifecycle_ready(Some(&lifecycle)).expect("ready transition");
assert_eq!(lifecycle.phase(), crate::lifecycle::ApplicationPhase::Ready);
crate::lifecycle::tests::bounded_begin_draining(&lifecycle).expect("drain transition");
assert!(matches!(
mark_lifecycle_ready(Some(&lifecycle)),
Err(ServerError::Lifecycle(_))
));
mark_lifecycle_stopped(Some(&lifecycle));
assert_eq!(
lifecycle.phase(),
crate::lifecycle::ApplicationPhase::Stopped
);
let failed = crate::lifecycle::ApplicationLifecycle::new();
crate::lifecycle::tests::bounded_begin_draining(&failed).expect("pre-start drain");
let result = Server::new(Router::new())
.with_lifecycle(failed.clone())
.run(0)
.await;
assert!(matches!(result, Err(ServerError::Lifecycle(_))));
assert_eq!(failed.phase(), crate::lifecycle::ApplicationPhase::Stopped);
}
#[tokio::test]
async fn custom_shutdown_drives_ready_drain_and_stopped_phases() {
let _lock = crate::server::TEST_ENV_LOCK.lock().await;
if run_shutdown_test_in_child() {
return;
}
let environment = EnvironmentGuard::clear(&[
"HOST",
"RULLST_HOST",
"PORT",
"RULLST_ENV",
"APP_ENV",
"DATABASE_URL",
]);
environment.set("RULLST_ENV", "test");
let lifecycle = crate::lifecycle::ApplicationLifecycle::new();
let observed = lifecycle.clone();
let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel();
let server = tokio::spawn(async move {
Server::new(Router::new())
.with_lifecycle(lifecycle)
.run_with_shutdown(0, async move {
let _ = shutdown_rx.await;
})
.await
});
tokio::time::timeout(std::time::Duration::from_secs(2), async {
while observed.phase() != crate::lifecycle::ApplicationPhase::Ready {
tokio::task::yield_now().await;
}
})
.await
.expect("server became ready");
shutdown_tx.send(()).expect("shutdown receiver alive");
tokio::time::timeout(std::time::Duration::from_secs(2), server)
.await
.expect("server stopped before deadline")
.expect("server task joined")
.expect("server shutdown succeeded");
assert_eq!(
observed.phase(),
crate::lifecycle::ApplicationPhase::Stopped
);
}
fn run_shutdown_test_in_child() -> bool {
const CHILD: &str = "RULLST_LIFECYCLE_SHUTDOWN_TEST_CHILD";
const NAME: &str =
"server::builder::tests::custom_shutdown_drives_ready_drain_and_stopped_phases";
if std::env::var_os(CHILD).as_deref() == Some(std::ffi::OsStr::new(NAME)) {
return false;
}
let executable = std::env::current_exe().expect("current test executable");
let listing = std::process::Command::new(&executable)
.args(["--exact", NAME, "--list"])
.output()
.expect("list the isolated shutdown test");
assert!(listing.status.success());
assert!(
String::from_utf8(listing.stdout)
.expect("test listing is UTF-8")
.lines()
.any(|line| line == format!("{NAME}: test")),
"the exact child filter must select the shutdown test"
);
let mut child = std::process::Command::new(executable)
.args(["--exact", NAME, "--nocapture"])
.env(CHILD, NAME)
.spawn()
.expect("start isolated shutdown test");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
if let Some(status) = child.try_wait().expect("inspect shutdown test") {
assert!(status.success(), "isolated shutdown test failed: {status}");
return true;
}
if std::time::Instant::now() >= deadline {
child.kill().expect("stop a stuck shutdown test");
let _ = child.wait().expect("reap the shutdown test");
panic!("isolated shutdown test exceeded its independent deadline");
}
std::thread::sleep(std::time::Duration::from_millis(20));
}
}
#[tokio::test]
async fn hot_reload_token_configuration_fails_closed() {
let _lock = crate::server::TEST_ENV_LOCK.lock().await;
let environment = EnvironmentGuard::clear(&["RULLST_HMR_TOKEN"]);
assert!(matches!(
resolve_hot_reload_token(),
Err(ServerError::HotReloadConfiguration(message)) if message.contains("missing")
));
environment.set("RULLST_HMR_TOKEN", "too-short");
assert!(matches!(
resolve_hot_reload_token(),
Err(ServerError::HotReloadConfiguration(message)) if message.contains("64 hexadecimal")
));
environment.set(
"RULLST_HMR_TOKEN",
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
);
assert_eq!(
resolve_hot_reload_token().unwrap().as_ref(),
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
);
}
#[cfg(not(feature = "orm"))]
#[tokio::test]
async fn database_configuration_fails_closed_without_the_orm_feature() {
let config = RullstConfig::new();
let mut server = Server::new(Router::new());
server
.init_database(&config, &HashMap::new())
.await
.unwrap();
let mut requested = Server::new(Router::new()).with_db("sqlite::memory:");
assert!(matches!(
requested.init_database(&config, &HashMap::new()).await,
Err(ServerError::Database(_))
));
}
#[test]
fn dotenv_errors_never_echo_file_content() {
let canary = "server-dotenv-secret-canary";
let content = format!("APP_NAME='unterminated\nAPP_KEY={canary}\n");
let error = super::parse_dotenv(&content).expect_err("unterminated quote must fail");
let message = error.to_string();
assert!(!message.contains(canary), "{message}");
assert!(
message.contains("invalid .env syntax in entry 1"),
"{message}"
);
let values = super::parse_dotenv("A=1\nB=two\n").expect("valid dotenv");
assert_eq!(values.get("B").map(String::as_str), Some("two"));
}
#[test]
fn development_console_requires_a_debug_build_and_development() {
assert!(development_console_enabled(true, Environment::Development));
assert!(!development_console_enabled(
false,
Environment::Development
));
for environment in [
Environment::Test,
Environment::Staging,
Environment::Production,
] {
assert!(!development_console_enabled(true, environment));
assert!(!development_console_enabled(false, environment));
}
}