| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566 |
- //! Acceptance tests for configuration loading and validation.
- //!
- //! Named so `cargo nextest run -E 'test(config)'` selects them. No test reads
- //! the real process environment or a live database/Redis.
- use std::{error::Error, time::Duration};
- use serde_json::{Map, Value, json};
- use super::{Config, ConfigError, DcrRedirectPolicy, RedisTopology};
- use crate::keys::KeyRingError;
- fn base() -> Map<String, Value> {
- match json!({
- "issuer": "https://auth.example.com",
- "environment": "production",
- "tls": {
- "cert_path": "/etc/d-oauth/tls.crt",
- "key_path": "/etc/d-oauth/tls.key",
- "client_ca_path": "/etc/d-oauth/client-ca.pem"
- },
- "database": {
- "url": "postgres://oauth:DB_PASSWORD_XYZ@db:5432/oauth?sslmode=verify-full",
- "root_cert_path": "/etc/d-oauth/pg-ca.pem"
- },
- "redis": {
- "url": "rediss://redis:6379",
- "durability": "waitaof",
- "root_cert_path": "/etc/d-oauth/redis-ca.pem"
- },
- "token_pepper": "SUPER_SECRET_PEPPER_VALUE-0123456789abcdef",
- "token_pepper_id": "v1",
- "totp_encryption_key": "SUPER_SECRET_TOTP_KEY-0123456789abcdef",
- "totp_key_id": "v1",
- "pii_encryption_key": "SUPER_SECRET_PII_KEY-0123456789abcdef",
- "pii_key_id": "v1",
- "cookie_secure": true,
- "allow_open_registration": false,
- "dcr_redirect_policy": "catalog",
- "cors_allowed_origins": [],
- "scope_registry": [],
- "allow_token_exchange": false
- }) {
- Value::Object(map) => map,
- _ => Map::new(),
- }
- }
- fn try_load(map: &Map<String, Value>) -> Result<Config, ConfigError> {
- let source = ::config::Config::try_from(map).map_err(ConfigError::from)?;
- Config::from_source(source)
- }
- fn without(key: &str) -> Map<String, Value> {
- let mut map = base();
- map.remove(key);
- map
- }
- fn with_database_field(key: &str, value: Value) -> Map<String, Value> {
- let mut map = base();
- if let Some(database) = map.get_mut("database").and_then(Value::as_object_mut) {
- database.insert(key.to_owned(), value);
- }
- map
- }
- fn without_redis_durability() -> Map<String, Value> {
- let mut map = base();
- if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
- redis.remove("durability");
- }
- map
- }
- fn with_ttl(access_secs: u64, refresh_secs: u64) -> Map<String, Value> {
- let mut map = base();
- map.insert(
- "ttl".to_owned(),
- json!({ "access_secs": access_secs, "refresh_secs": refresh_secs }),
- );
- map
- }
- #[test]
- fn config_loads_with_empty_scope_registry() -> Result<(), Box<dyn Error>> {
- let cfg = try_load(&base())?;
- assert!(cfg.scope_registry.is_empty());
- assert_eq!(cfg.ttl.access, Duration::from_secs(300));
- assert_eq!(cfg.ttl.refresh, Duration::from_secs(2_592_000));
- assert_eq!(cfg.ttl.code, Duration::from_secs(60));
- assert_eq!(cfg.ttl.par, Duration::from_secs(90));
- assert_eq!(cfg.ttl.device, Duration::from_secs(600));
- assert_eq!(cfg.ttl.dcr_initial_access, Duration::from_secs(86_400));
- assert_eq!(cfg.ttl.session, Duration::from_secs(43_200));
- assert!(cfg.cookie_secure);
- assert_eq!(cfg.dcr_redirect_policy, DcrRedirectPolicy::Catalog);
- assert!(!cfg.allow_open_registration);
- assert!(cfg.cors_allowed_origins.is_empty());
- Ok(())
- }
- #[test]
- fn config_defaults_bind_and_health_addresses() -> Result<(), Box<dyn Error>> {
- let cfg = try_load(&base())?;
- assert_eq!(cfg.bind_addr.to_string(), "0.0.0.0:8080");
- assert_eq!(cfg.health_addr.to_string(), "0.0.0.0:8081");
- Ok(())
- }
- #[test]
- fn config_rejects_missing_issuer() {
- let result = try_load(&without("issuer"));
- assert!(matches!(result, Err(ConfigError::MissingIssuer)));
- }
- #[test]
- fn config_rejects_insecure_http_issuer() {
- let mut map = base();
- map.insert("issuer".to_owned(), Value::String("http://x".to_owned()));
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::InsecureIssuer { .. })));
- }
- #[test]
- fn config_rejects_issuer_with_trailing_slash() {
- let mut map = base();
- map.insert(
- "issuer".to_owned(),
- Value::String("https://auth.example.com/".to_owned()),
- );
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::InsecureIssuer { .. })));
- }
- #[test]
- fn config_rejects_missing_token_pepper() {
- let result = try_load(&without("token_pepper"));
- assert!(matches!(
- result,
- Err(ConfigError::KeyRing(KeyRingError::MissingTokenPepper))
- ));
- }
- #[test]
- fn config_rejects_redis_without_durability_contract() {
- let result = try_load(&without_redis_durability());
- assert!(matches!(result, Err(ConfigError::RedisDurabilityContract)));
- }
- #[test]
- fn config_rejects_single_node_with_replicated_durability() {
- let mut map = base();
- if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
- redis.insert("durability".to_owned(), Value::String("wait".to_owned()));
- }
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::RedisDurabilityContract)));
- }
- #[test]
- fn config_accepts_cluster_with_wait_durability() -> Result<(), Box<dyn Error>> {
- let mut map = base();
- if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
- redis.remove("url");
- redis.insert("seeds".to_owned(), json!(["redis-0:6379", "redis-1:6379"]));
- redis.insert("tls".to_owned(), Value::Bool(true));
- redis.insert("durability".to_owned(), Value::String("wait".to_owned()));
- }
- let cfg = try_load(&map)?;
- assert!(matches!(cfg.redis.topology, RedisTopology::Cluster { .. }));
- Ok(())
- }
- #[test]
- fn config_rejects_production_postgres_without_verify_full() {
- let map = with_database_field(
- "url",
- Value::String("postgres://oauth:pw@db:5432/oauth".to_owned()),
- );
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::InsecureDatabaseTls)));
- }
- #[test]
- fn config_allows_development_postgres_without_verify_full() -> Result<(), Box<dyn Error>> {
- let mut map = with_database_field(
- "url",
- Value::String("postgres://oauth:pw@127.0.0.1:5432/oauth".to_owned()),
- );
- map.insert(
- "environment".to_owned(),
- Value::String("development".to_owned()),
- );
- let cfg = try_load(&map)?;
- assert_eq!(cfg.environment, super::Environment::Development);
- Ok(())
- }
- #[test]
- fn config_rejects_invalid_timeout_ladder() {
- let map = with_database_field("lock_timeout_secs", json!(5));
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::InvalidTimeoutLadder)));
- }
- #[test]
- fn config_rejects_access_ttl_longer_than_refresh() {
- let map = with_ttl(2_592_001, 2_592_000);
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::InvalidTtlLadder)));
- }
- #[test]
- fn config_accepts_access_ttl_equal_to_refresh() -> Result<(), Box<dyn Error>> {
- let cfg = try_load(&with_ttl(3_600, 3_600))?;
- assert_eq!(cfg.ttl.access, Duration::from_secs(3_600));
- Ok(())
- }
- #[test]
- fn config_rejects_reserved_openid_scope() {
- let mut map = base();
- map.insert("scope_registry".to_owned(), json!([{ "name": "openid" }]));
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::ReservedScope { .. })));
- }
- #[test]
- fn config_accepts_namespaced_scope_registry() -> Result<(), Box<dyn Error>> {
- let mut map = base();
- map.insert(
- "scope_registry".to_owned(),
- json!([
- { "name": "api:read", "description": "read the API" },
- {
- "name": "api:write",
- "allowed_grants": ["authorization_code"],
- "allowed_client_classes": ["confidential"]
- }
- ]),
- );
- let cfg = try_load(&map)?;
- assert_eq!(cfg.scope_registry.len(), 2);
- assert_eq!(cfg.scope_registry[0].name, "api:read");
- Ok(())
- }
- #[test]
- fn config_redis_startup_assertions_are_callable() -> Result<(), Box<dyn Error>> {
- let cfg = try_load(&base())?;
- assert!(cfg.redis.assert_noeviction_policy("noeviction").is_ok());
- assert!(matches!(
- cfg.redis.assert_noeviction_policy("allkeys-lru"),
- Err(ConfigError::RedisEvictionPolicy { .. })
- ));
- assert!(cfg.redis.assert_aof_persistence(true).is_ok());
- assert!(matches!(
- cfg.redis.assert_aof_persistence(false),
- Err(ConfigError::RedisAofDisabled)
- ));
- assert!(cfg.redis.assert_topology_reachable(true).is_ok());
- assert!(matches!(
- cfg.redis.assert_topology_reachable(false),
- Err(ConfigError::RedisUnreachable)
- ));
- Ok(())
- }
- #[test]
- fn config_debug_redacts_secret_values() -> Result<(), Box<dyn Error>> {
- let cfg = try_load(&base())?;
- let rendered = format!("{cfg:?}");
- assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE-0123456789abcdef"));
- assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY-0123456789abcdef"));
- assert!(!rendered.contains("SUPER_SECRET_PII_KEY-0123456789abcdef"));
- assert!(!rendered.contains("DB_PASSWORD_XYZ"));
- assert!(rendered.contains("REDACTED"));
- Ok(())
- }
- #[test]
- fn config_env_double_underscore_maps_to_nested_snake_keys() -> Result<(), Box<dyn Error>> {
- let mut env = std::collections::HashMap::new();
- env.insert(
- "D_OAUTH_TLS__CERT_PATH".to_owned(),
- "/certs/tls.crt".to_owned(),
- );
- env.insert(
- "D_OAUTH_TLS__KEY_PATH".to_owned(),
- "/certs/tls.key".to_owned(),
- );
- env.insert(
- "D_OAUTH_TOKEN_PEPPER".to_owned(),
- "dev-only-pepper-ROTATE-IN-PROD-000000".to_owned(),
- );
- let built = ::config::Config::builder()
- .add_source(super::env_source().source(Some(env)))
- .build()?;
- let raw: super::raw::RawConfig = built.try_deserialize()?;
- assert_eq!(
- raw.tls.cert_path,
- Some(std::path::PathBuf::from("/certs/tls.crt"))
- );
- assert_eq!(
- raw.tls.key_path,
- Some(std::path::PathBuf::from("/certs/tls.key"))
- );
- assert_eq!(
- raw.token_pepper.as_deref(),
- Some("dev-only-pepper-ROTATE-IN-PROD-000000")
- );
- Ok(())
- }
- #[test]
- fn config_rejects_missing_pii_key() {
- let result = try_load(&without("pii_encryption_key"));
- assert!(matches!(
- result,
- Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
- ));
- }
- #[test]
- fn config_rejects_empty_active_pii_key() {
- let mut map = base();
- map.insert(
- "pii_encryption_key".to_owned(),
- Value::String(" ".to_owned()),
- );
- let result = try_load(&map);
- assert!(matches!(
- result,
- Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
- ));
- let path = std::env::temp_dir().join(format!("d-oauth-empty-pii-key-{}.txt", std::process::id()));
- if std::fs::write(&path, "").is_ok() {
- let mut file_map = base();
- file_map.remove("pii_encryption_key");
- file_map.insert(
- "pii_encryption_key_file".to_owned(),
- Value::String(path.to_string_lossy().into_owned()),
- );
- let result = try_load(&file_map);
- let _ = std::fs::remove_file(&path);
- assert!(matches!(
- result,
- Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
- ));
- }
- }
- /// A resolved pepper/TOTP/PII key shorter than 32 bytes is rejected with the
- /// offending key named; retained previous keys are checked too.
- #[test]
- fn config_rejects_weak_key_material() {
- for (key, value) in [
- ("token_pepper", "short-pepper-material"),
- ("totp_encryption_key", "short-totp-key-material"),
- ("pii_encryption_key", "short-pii-key-material"),
- ] {
- let mut map = base();
- map.insert(key.to_owned(), Value::String(value.to_owned()));
- assert!(
- matches!(
- try_load(&map),
- Err(ConfigError::WeakKeyMaterial { key: rejected }) if rejected == key
- ),
- "{key} shorter than 32 bytes must be rejected"
- );
- }
- for key in ["totp_previous_key", "pii_previous_key"] {
- let mut map = base();
- map.insert(
- key.to_owned(),
- Value::String("short-previous-key".to_owned()),
- );
- map.insert(format!("{key}_id"), Value::String("v0".to_owned()));
- assert!(
- matches!(
- try_load(&map),
- Err(ConfigError::WeakKeyMaterial { key: rejected }) if rejected == key
- ),
- "previous key {key} shorter than 32 bytes must be rejected"
- );
- }
- }
- /// The key-length boundary is exact: 31 bytes is rejected, 32 accepted.
- #[test]
- fn config_key_length_boundary_is_32_bytes() -> Result<(), Box<dyn Error>> {
- let mut exact = base();
- exact.insert("token_pepper".to_owned(), Value::String("p".repeat(32)));
- let cfg = try_load(&exact)?;
- assert!(cfg.keys.active_pepper().is_some());
- let mut short = base();
- short.insert("token_pepper".to_owned(), Value::String("p".repeat(31)));
- assert!(matches!(
- try_load(&short),
- Err(ConfigError::WeakKeyMaterial {
- key: "token_pepper"
- })
- ));
- Ok(())
- }
- #[test]
- fn config_builds_pii_chain() -> Result<(), Box<dyn Error>> {
- let mut map = base();
- map.insert(
- "pii_previous_key".to_owned(),
- Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
- );
- map.insert(
- "pii_previous_key_id".to_owned(),
- Value::String("v0".to_owned()),
- );
- map.insert("pii_dual_write".to_owned(), Value::Bool(true));
- let cfg = try_load(&map)?;
- let active = cfg.keys.active_pii_key().ok_or("missing active pii key")?;
- assert_eq!(active.0.as_str(), "v1");
- let retained = cfg
- .keys
- .pii_key_by_id("v0")
- .ok_or("missing retained pii key")?;
- assert!(!cfg.keys.pii_key_by_id("v9").is_some());
- let _ = retained;
- let ids: Vec<&str> = cfg
- .keys
- .pii_keys_newest_first()
- .map(|(id, _)| id.as_str())
- .collect();
- assert_eq!(ids, vec!["v1", "v0"]);
- assert!(cfg.pii_dual_write);
- let dual_default = try_load(&base())?;
- assert!(!dual_default.pii_dual_write);
- Ok(())
- }
- #[test]
- fn config_rejects_previous_key_without_id() {
- let mut map = base();
- map.insert(
- "pii_previous_key".to_owned(),
- Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
- );
- let result = try_load(&map);
- assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
- let mut same_id = base();
- same_id.insert(
- "pii_previous_key".to_owned(),
- Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
- );
- same_id.insert(
- "pii_previous_key_id".to_owned(),
- Value::String("v1".to_owned()),
- );
- let result = try_load(&same_id);
- assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
- let mut totp = base();
- totp.insert(
- "totp_previous_key".to_owned(),
- Value::String("SUPER_SECRET_TOTP_PREVIOUS-0123456789abcdef".to_owned()),
- );
- let result = try_load(&totp);
- assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
- }
- #[test]
- fn config_rejects_previous_key_without_active() {
- let mut pii = without("pii_encryption_key");
- pii.insert(
- "pii_previous_key".to_owned(),
- Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
- );
- pii.insert(
- "pii_previous_key_id".to_owned(),
- Value::String("v0".to_owned()),
- );
- let result = try_load(&pii);
- assert!(matches!(
- result,
- Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
- ));
- let mut totp = without("totp_encryption_key");
- totp.insert(
- "totp_previous_key".to_owned(),
- Value::String("SUPER_SECRET_TOTP_PREVIOUS-0123456789abcdef".to_owned()),
- );
- totp.insert(
- "totp_previous_key_id".to_owned(),
- Value::String("v0".to_owned()),
- );
- let result = try_load(&totp);
- assert!(matches!(
- result,
- Err(ConfigError::KeyRing(KeyRingError::MissingTotpKey))
- ));
- }
- #[test]
- fn config_treats_empty_previous_key_as_absent() -> Result<(), Box<dyn Error>> {
- let mut map = base();
- map.insert(
- "pii_previous_key".to_owned(),
- Value::String(" ".to_owned()),
- );
- map.insert(
- "pii_previous_key_id".to_owned(),
- Value::String("v0".to_owned()),
- );
- let cfg = try_load(&map)?;
- let ids: Vec<&str> = cfg
- .keys
- .pii_keys_newest_first()
- .map(|(id, _)| id.as_str())
- .collect();
- assert_eq!(ids, vec!["v1"]);
- let path = std::env::temp_dir().join(format!(
- "d-oauth-empty-pii-previous-{}.txt",
- std::process::id()
- ));
- if std::fs::write(&path, "").is_ok() {
- let mut file_map = base();
- file_map.insert(
- "pii_previous_key_file".to_owned(),
- Value::String(path.to_string_lossy().into_owned()),
- );
- file_map.insert(
- "pii_previous_key_id".to_owned(),
- Value::String("v0".to_owned()),
- );
- let cfg = try_load(&file_map)?;
- let _ = std::fs::remove_file(&path);
- let ids: Vec<&str> = cfg
- .keys
- .pii_keys_newest_first()
- .map(|(id, _)| id.as_str())
- .collect();
- assert_eq!(ids, vec!["v1"]);
- }
- Ok(())
- }
- #[test]
- fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
- use secrecy::ExposeSecret as _;
- let path = std::env::temp_dir().join(format!(
- "d-oauth-read-secret-file-{}.txt",
- std::process::id()
- ));
- std::fs::write(&path, " spaced-secret \n")?;
- let secret = super::read_secret_file(Some(path.as_path()))?;
- let _ = std::fs::remove_file(&path);
- let secret = secret.ok_or("secret file returned None")?;
- assert_eq!(secret.expose_secret(), " spaced-secret ");
- Ok(())
- }
|