//! 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 { 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) -> Result { let source = ::config::Config::try_from(map).map_err(ConfigError::from)?; Config::from_source(source) } fn without(key: &str) -> Map { let mut map = base(); map.remove(key); map } fn with_database_field(key: &str, value: Value) -> Map { 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 { 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 { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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> { 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(()) }