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:
Generated
+31
@@ -1272,6 +1272,7 @@ dependencies = [
|
|||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1386,6 +1387,15 @@ dependencies = [
|
|||||||
"zmij",
|
"zmij",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_spanned"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_urlencoded"
|
name = "serde_urlencoded"
|
||||||
version = "0.7.1"
|
version = "0.7.1"
|
||||||
@@ -1861,6 +1871,21 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml"
|
||||||
|
version = "1.1.5+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"serde_core",
|
||||||
|
"serde_spanned",
|
||||||
|
"toml_datetime",
|
||||||
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
|
"winnow",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "1.1.1+spec-1.1.0"
|
version = "1.1.1+spec-1.1.0"
|
||||||
@@ -1891,6 +1916,12 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "toml_writer"
|
||||||
|
version = "1.1.2+spec-1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.44"
|
version = "0.1.44"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ serde_json = "1.0.151"
|
|||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio", "chrono", "rust_decimal"] }
|
sqlx = { version = "0.8.6", features = ["mysql", "runtime-tokio", "chrono", "rust_decimal"] }
|
||||||
thiserror = "2.0.20"
|
thiserror = "2.0.20"
|
||||||
|
toml = "1.1.5"
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
|
|
||||||
[dev-dependencies]
|
[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.
|
//! Domain logic for RustyRPN: ingest, invoices, Fortnox, car registry.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
|||||||
Reference in New Issue
Block a user