config: add failing tests for load, resolution, defaults, masking
Tests define the API for the config module: - Config::load_with(path, home) / Config::load(path) read the TOML file (database/web/state/backup sections per config.template.toml) - empty strings resolve to built-in defaults: db port 3306, web port 8080, state dir $HOME/.config/rpn, backup dir <state>/backups (backup follows an explicitly set state dir) - port may be written as a TOML int or a string in real configs; non-numeric strings are a ConfigError - resolve_config_path(): --config flag > $RUSTYRPN_CONFIG > cwd/config.toml; --env=dev|test only affects that last default slot - Config::show() renders the effective config with the password masked - Config::validate_database() requires host/user/name Also adds the toml crate (forced by the TOML config decision).
This commit is contained in:
@@ -14,6 +14,7 @@ serde_json = "1.0.151"
|
||||
sha2 = "0.11.0"
|
||||
sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio", "chrono", "rust_decimal"] }
|
||||
thiserror = "2.0.20"
|
||||
toml = "1.1.5"
|
||||
tracing = "0.1.44"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Configuration loading and resolution.
|
||||
//!
|
||||
//! Lookup order: `--config <path>` flag, then `$RUSTYRPN_CONFIG`, then
|
||||
//! `config.toml` in the current directory. `--env=dev|test` selects
|
||||
//! `config.dev.toml` / `config.test.toml` in that last default slot only;
|
||||
//! an explicit `--config` path or `$RUSTYRPN_CONFIG` file wins over it.
|
||||
//! An empty string in the file means "use the built-in default" where one
|
||||
//! exists (database port 3306, web port 8080, state dir ~/.config/rpn,
|
||||
//! backup dir <state dir>/backups).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const FULL_TOML: &str = r#"
|
||||
[database]
|
||||
host = "db.example"
|
||||
port = 3307
|
||||
user = "alice"
|
||||
password = "hunter2"
|
||||
name = "rpn_prod"
|
||||
|
||||
[web]
|
||||
port = 9090
|
||||
fqdn = "rpn.example.com"
|
||||
|
||||
[state]
|
||||
dir = "/var/lib/rpn"
|
||||
|
||||
[backup]
|
||||
dir = "/var/backup/rpn"
|
||||
"#;
|
||||
|
||||
fn dir_with(name: &str, body: &str) -> (tempfile::TempDir, PathBuf) {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join(name);
|
||||
std::fs::write(&p, body).unwrap();
|
||||
(d, p)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_reads_all_sections() {
|
||||
let (_d, p) = dir_with("config.toml", FULL_TOML);
|
||||
let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap();
|
||||
assert_eq!(cfg.database.host, "db.example");
|
||||
assert_eq!(cfg.database.port, 3307);
|
||||
assert_eq!(cfg.database.user, "alice");
|
||||
assert_eq!(cfg.database.password, "hunter2");
|
||||
assert_eq!(cfg.database.name, "rpn_prod");
|
||||
assert_eq!(cfg.web.port, 9090);
|
||||
assert_eq!(cfg.web.fqdn, "rpn.example.com");
|
||||
assert_eq!(cfg.state.dir, "/var/lib/rpn");
|
||||
assert_eq!(cfg.backup.dir, "/var/backup/rpn");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_file_is_not_found_error() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let p = d.path().join("nope.toml");
|
||||
let err = Config::load_with(&p, Path::new("/home/u")).unwrap_err();
|
||||
assert!(matches!(err, ConfigError::NotFound(ref q) if *q == p), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_toml_is_parse_error() {
|
||||
let (_d, p) = dir_with("config.toml", "this is [not toml");
|
||||
let err = Config::load_with(&p, Path::new("/home/u")).unwrap_err();
|
||||
assert!(matches!(err, ConfigError::Parse { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_port_becomes_mysql_default() {
|
||||
let body = "
|
||||
[database]
|
||||
host = \"db\"
|
||||
port = \"\"
|
||||
user = \"u\"
|
||||
password = \"\"
|
||||
name = \"db\"
|
||||
";
|
||||
let (_d, p) = dir_with("config.toml", body);
|
||||
let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap();
|
||||
assert_eq!(cfg.database.port, 3306);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_numeric_port_is_invalid() {
|
||||
let body = "
|
||||
[database]
|
||||
host = \"db\"
|
||||
port = \"abc\"
|
||||
user = \"u\"
|
||||
password = \"\"
|
||||
name = \"db\"
|
||||
";
|
||||
let (_d, p) = dir_with("config.toml", body);
|
||||
let err = Config::load_with(&p, Path::new("/home/u")).unwrap_err();
|
||||
assert!(matches!(err, ConfigError::Invalid { .. }), "{err:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_sections_get_defaults() {
|
||||
let body = "[database]\nhost = \"db\"\nport = \"\"\nuser = \"u\"\npassword = \"\"\nname = \"db\"\n";
|
||||
let (_d, p) = dir_with("config.toml", body);
|
||||
let cfg = Config::load_with(&p, Path::new("/home/x")).unwrap();
|
||||
assert_eq!(cfg.web.port, 8080);
|
||||
assert!(cfg.web.fqdn.is_empty());
|
||||
assert_eq!(cfg.state.dir, "/home/x/.config/rpn");
|
||||
assert_eq!(cfg.backup.dir, "/home/x/.config/rpn/backups");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backup_default_follows_explicit_state_dir() {
|
||||
let body = "
|
||||
[database]
|
||||
host = \"db\"
|
||||
port = \"\"
|
||||
user = \"u\"
|
||||
password = \"\"
|
||||
name = \"db\"
|
||||
|
||||
[state]
|
||||
dir = \"/opt/rpn\"
|
||||
";
|
||||
let (_d, p) = dir_with("config.toml", body);
|
||||
let cfg = Config::load_with(&p, Path::new("/home/x")).unwrap();
|
||||
assert_eq!(cfg.state.dir, "/opt/rpn");
|
||||
assert_eq!(cfg.backup.dir, "/opt/rpn/backups");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_explicit_flag() {
|
||||
let path = resolve_config_path(
|
||||
Some(Path::new("/flag/config.toml")),
|
||||
Some("env-file.toml"),
|
||||
Path::new("/cwd"),
|
||||
Some("dev"),
|
||||
);
|
||||
assert_eq!(path, PathBuf::from("/flag/config.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_env_over_cwd_default() {
|
||||
let path = resolve_config_path(None, Some("/env/cfg.toml"), Path::new("/cwd"), Some("dev"));
|
||||
assert_eq!(path, PathBuf::from("/env/cfg.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_env_name_selects_env_suffixed_default() {
|
||||
let path = resolve_config_path(None, None, Path::new("/cwd"), Some("test"));
|
||||
assert_eq!(path, Path::new("/cwd").join("config.test.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_no_env_uses_production_default() {
|
||||
let path = resolve_config_path(None, None, Path::new("/cwd"), None);
|
||||
assert_eq!(path, Path::new("/cwd").join("config.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_masks_password() {
|
||||
let (_d, p) = dir_with("config.toml", FULL_TOML);
|
||||
let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap();
|
||||
let s = cfg.show();
|
||||
assert!(!s.contains("hunter2"), "password leaked into show output:\n{s}");
|
||||
assert!(s.contains("***"), "{s}");
|
||||
assert!(s.contains("alice"), "user should remain visible:\n{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_applies_defaults() {
|
||||
let body = "[database]\nhost = \"db\"\nport = \"\"\nuser = \"u\"\npassword = \"\"\nname = \"db\"\n";
|
||||
let (_d, p) = dir_with("config.toml", body);
|
||||
let cfg = Config::load_with(&p, Path::new("/home/x")).unwrap();
|
||||
let s = cfg.show();
|
||||
assert!(s.contains("3306"), "{s}");
|
||||
assert!(s.contains("/home/x/.config/rpn"), "{s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_database_requires_host_user_name() {
|
||||
let body = "[database]\nhost = \"\"\nport = \"\"\nuser = \"u\"\npassword = \"\"\nname = \"\"\n";
|
||||
let (_d, p) = dir_with("config.toml", body);
|
||||
let cfg = Config::load_with(&p, Path::new("/home/u")).unwrap();
|
||||
let err = cfg.validate_database().unwrap_err();
|
||||
assert!(err.to_string().contains("host"), "{err}");
|
||||
assert!(err.to_string().contains("name"), "{err}");
|
||||
assert!(!err.to_string().contains("user"), "user is set: {err}");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,3 @@
|
||||
//! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry.
|
||||
|
||||
pub mod config;
|
||||
|
||||
Reference in New Issue
Block a user