tests.rs 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489
  1. //! Acceptance tests for configuration loading and validation.
  2. //!
  3. //! Named so `cargo nextest run -E 'test(config)'` selects them. No test reads
  4. //! the real process environment or a live database/Redis.
  5. use std::{error::Error, time::Duration};
  6. use serde_json::{Map, Value, json};
  7. use super::{Config, ConfigError, DcrRedirectPolicy, RedisTopology};
  8. use crate::keys::KeyRingError;
  9. fn base() -> Map<String, Value> {
  10. match json!({
  11. "issuer": "https://auth.example.com",
  12. "environment": "production",
  13. "tls": {
  14. "cert_path": "/etc/d-oauth/tls.crt",
  15. "key_path": "/etc/d-oauth/tls.key",
  16. "client_ca_path": "/etc/d-oauth/client-ca.pem"
  17. },
  18. "database": {
  19. "url": "postgres://oauth:DB_PASSWORD_XYZ@db:5432/oauth?sslmode=verify-full",
  20. "root_cert_path": "/etc/d-oauth/pg-ca.pem"
  21. },
  22. "redis": {
  23. "url": "rediss://redis:6379",
  24. "durability": "waitaof",
  25. "root_cert_path": "/etc/d-oauth/redis-ca.pem"
  26. },
  27. "token_pepper": "SUPER_SECRET_PEPPER_VALUE",
  28. "token_pepper_id": "v1",
  29. "totp_encryption_key": "SUPER_SECRET_TOTP_KEY",
  30. "totp_key_id": "v1",
  31. "pii_encryption_key": "SUPER_SECRET_PII_KEY",
  32. "pii_key_id": "v1",
  33. "cookie_secure": true,
  34. "allow_open_registration": false,
  35. "dcr_redirect_policy": "catalog",
  36. "cors_allowed_origins": [],
  37. "scope_registry": [],
  38. "allow_token_exchange": false
  39. }) {
  40. Value::Object(map) => map,
  41. _ => Map::new(),
  42. }
  43. }
  44. fn try_load(map: &Map<String, Value>) -> Result<Config, ConfigError> {
  45. let source = ::config::Config::try_from(map).map_err(ConfigError::from)?;
  46. Config::from_source(source)
  47. }
  48. fn without(key: &str) -> Map<String, Value> {
  49. let mut map = base();
  50. map.remove(key);
  51. map
  52. }
  53. fn with_database_field(key: &str, value: Value) -> Map<String, Value> {
  54. let mut map = base();
  55. if let Some(database) = map.get_mut("database").and_then(Value::as_object_mut) {
  56. database.insert(key.to_owned(), value);
  57. }
  58. map
  59. }
  60. fn without_redis_durability() -> Map<String, Value> {
  61. let mut map = base();
  62. if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
  63. redis.remove("durability");
  64. }
  65. map
  66. }
  67. #[test]
  68. fn config_loads_with_empty_scope_registry() -> Result<(), Box<dyn Error>> {
  69. let cfg = try_load(&base())?;
  70. assert!(cfg.scope_registry.is_empty());
  71. assert_eq!(cfg.ttl.access, Duration::from_secs(300));
  72. assert_eq!(cfg.ttl.refresh, Duration::from_secs(2_592_000));
  73. assert_eq!(cfg.ttl.code, Duration::from_secs(60));
  74. assert_eq!(cfg.ttl.par, Duration::from_secs(90));
  75. assert_eq!(cfg.ttl.device, Duration::from_secs(600));
  76. assert_eq!(cfg.ttl.dcr_initial_access, Duration::from_secs(86_400));
  77. assert_eq!(cfg.ttl.session, Duration::from_secs(43_200));
  78. assert!(cfg.cookie_secure);
  79. assert_eq!(cfg.dcr_redirect_policy, DcrRedirectPolicy::Catalog);
  80. assert!(!cfg.allow_open_registration);
  81. assert!(cfg.trusted_proxy.is_none());
  82. assert!(cfg.cors_allowed_origins.is_empty());
  83. Ok(())
  84. }
  85. #[test]
  86. fn config_defaults_bind_and_health_addresses() -> Result<(), Box<dyn Error>> {
  87. let cfg = try_load(&base())?;
  88. assert_eq!(cfg.bind_addr.to_string(), "0.0.0.0:8080");
  89. assert_eq!(cfg.health_addr.to_string(), "0.0.0.0:8081");
  90. Ok(())
  91. }
  92. #[test]
  93. fn config_rejects_missing_issuer() {
  94. let result = try_load(&without("issuer"));
  95. assert!(matches!(result, Err(ConfigError::MissingIssuer)));
  96. }
  97. #[test]
  98. fn config_rejects_insecure_http_issuer() {
  99. let mut map = base();
  100. map.insert("issuer".to_owned(), Value::String("http://x".to_owned()));
  101. let result = try_load(&map);
  102. assert!(matches!(result, Err(ConfigError::InsecureIssuer { .. })));
  103. }
  104. #[test]
  105. fn config_rejects_issuer_with_trailing_slash() {
  106. let mut map = base();
  107. map.insert(
  108. "issuer".to_owned(),
  109. Value::String("https://auth.example.com/".to_owned()),
  110. );
  111. let result = try_load(&map);
  112. assert!(matches!(result, Err(ConfigError::InsecureIssuer { .. })));
  113. }
  114. #[test]
  115. fn config_rejects_missing_token_pepper() {
  116. let result = try_load(&without("token_pepper"));
  117. assert!(matches!(
  118. result,
  119. Err(ConfigError::KeyRing(KeyRingError::MissingTokenPepper))
  120. ));
  121. }
  122. #[test]
  123. fn config_rejects_redis_without_durability_contract() {
  124. let result = try_load(&without_redis_durability());
  125. assert!(matches!(result, Err(ConfigError::RedisDurabilityContract)));
  126. }
  127. #[test]
  128. fn config_rejects_single_node_with_replicated_durability() {
  129. let mut map = base();
  130. if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
  131. redis.insert("durability".to_owned(), Value::String("wait".to_owned()));
  132. }
  133. let result = try_load(&map);
  134. assert!(matches!(result, Err(ConfigError::RedisDurabilityContract)));
  135. }
  136. #[test]
  137. fn config_accepts_cluster_with_wait_durability() -> Result<(), Box<dyn Error>> {
  138. let mut map = base();
  139. if let Some(redis) = map.get_mut("redis").and_then(Value::as_object_mut) {
  140. redis.remove("url");
  141. redis.insert("seeds".to_owned(), json!(["redis-0:6379", "redis-1:6379"]));
  142. redis.insert("tls".to_owned(), Value::Bool(true));
  143. redis.insert("durability".to_owned(), Value::String("wait".to_owned()));
  144. }
  145. let cfg = try_load(&map)?;
  146. assert!(matches!(cfg.redis.topology, RedisTopology::Cluster { .. }));
  147. Ok(())
  148. }
  149. #[test]
  150. fn config_rejects_production_postgres_without_verify_full() {
  151. let map = with_database_field(
  152. "url",
  153. Value::String("postgres://oauth:pw@db:5432/oauth".to_owned()),
  154. );
  155. let result = try_load(&map);
  156. assert!(matches!(result, Err(ConfigError::InsecureDatabaseTls)));
  157. }
  158. #[test]
  159. fn config_allows_development_postgres_without_verify_full() -> Result<(), Box<dyn Error>> {
  160. let mut map = with_database_field(
  161. "url",
  162. Value::String("postgres://oauth:pw@127.0.0.1:5432/oauth".to_owned()),
  163. );
  164. map.insert(
  165. "environment".to_owned(),
  166. Value::String("development".to_owned()),
  167. );
  168. let cfg = try_load(&map)?;
  169. assert_eq!(cfg.environment, super::Environment::Development);
  170. Ok(())
  171. }
  172. #[test]
  173. fn config_rejects_invalid_timeout_ladder() {
  174. let map = with_database_field("lock_timeout_secs", json!(5));
  175. let result = try_load(&map);
  176. assert!(matches!(result, Err(ConfigError::InvalidTimeoutLadder)));
  177. }
  178. #[test]
  179. fn config_rejects_reserved_openid_scope() {
  180. let mut map = base();
  181. map.insert("scope_registry".to_owned(), json!([{ "name": "openid" }]));
  182. let result = try_load(&map);
  183. assert!(matches!(result, Err(ConfigError::ReservedScope { .. })));
  184. }
  185. #[test]
  186. fn config_accepts_namespaced_scope_registry() -> Result<(), Box<dyn Error>> {
  187. let mut map = base();
  188. map.insert(
  189. "scope_registry".to_owned(),
  190. json!([
  191. { "name": "api:read", "description": "read the API" },
  192. {
  193. "name": "api:write",
  194. "allowed_grants": ["authorization_code"],
  195. "allowed_client_classes": ["confidential"]
  196. }
  197. ]),
  198. );
  199. let cfg = try_load(&map)?;
  200. assert_eq!(cfg.scope_registry.len(), 2);
  201. assert_eq!(cfg.scope_registry[0].name, "api:read");
  202. Ok(())
  203. }
  204. #[test]
  205. fn config_redis_startup_assertions_are_callable() -> Result<(), Box<dyn Error>> {
  206. let cfg = try_load(&base())?;
  207. assert!(cfg.redis.assert_noeviction_policy("noeviction").is_ok());
  208. assert!(matches!(
  209. cfg.redis.assert_noeviction_policy("allkeys-lru"),
  210. Err(ConfigError::RedisEvictionPolicy { .. })
  211. ));
  212. assert!(cfg.redis.assert_aof_persistence(true).is_ok());
  213. assert!(matches!(
  214. cfg.redis.assert_aof_persistence(false),
  215. Err(ConfigError::RedisAofDisabled)
  216. ));
  217. assert!(cfg.redis.assert_topology_reachable(true).is_ok());
  218. assert!(matches!(
  219. cfg.redis.assert_topology_reachable(false),
  220. Err(ConfigError::RedisUnreachable)
  221. ));
  222. Ok(())
  223. }
  224. #[test]
  225. fn config_debug_redacts_secret_values() -> Result<(), Box<dyn Error>> {
  226. let cfg = try_load(&base())?;
  227. let rendered = format!("{cfg:?}");
  228. assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE"));
  229. assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY"));
  230. assert!(!rendered.contains("SUPER_SECRET_PII_KEY"));
  231. assert!(!rendered.contains("DB_PASSWORD_XYZ"));
  232. assert!(rendered.contains("REDACTED"));
  233. Ok(())
  234. }
  235. #[test]
  236. fn config_env_double_underscore_maps_to_nested_snake_keys() -> Result<(), Box<dyn Error>> {
  237. let mut env = std::collections::HashMap::new();
  238. env.insert(
  239. "D_OAUTH_TLS__CERT_PATH".to_owned(),
  240. "/certs/tls.crt".to_owned(),
  241. );
  242. env.insert(
  243. "D_OAUTH_TLS__KEY_PATH".to_owned(),
  244. "/certs/tls.key".to_owned(),
  245. );
  246. env.insert(
  247. "D_OAUTH_TOKEN_PEPPER".to_owned(),
  248. "dev-only-pepper-ROTATE-IN-PROD-000000".to_owned(),
  249. );
  250. let built = ::config::Config::builder()
  251. .add_source(super::env_source().source(Some(env)))
  252. .build()?;
  253. let raw: super::raw::RawConfig = built.try_deserialize()?;
  254. assert_eq!(
  255. raw.tls.cert_path,
  256. Some(std::path::PathBuf::from("/certs/tls.crt"))
  257. );
  258. assert_eq!(
  259. raw.tls.key_path,
  260. Some(std::path::PathBuf::from("/certs/tls.key"))
  261. );
  262. assert_eq!(
  263. raw.token_pepper.as_deref(),
  264. Some("dev-only-pepper-ROTATE-IN-PROD-000000")
  265. );
  266. Ok(())
  267. }
  268. #[test]
  269. fn config_rejects_missing_pii_key() {
  270. let result = try_load(&without("pii_encryption_key"));
  271. assert!(matches!(
  272. result,
  273. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  274. ));
  275. }
  276. #[test]
  277. fn config_rejects_empty_active_pii_key() {
  278. let mut map = base();
  279. map.insert(
  280. "pii_encryption_key".to_owned(),
  281. Value::String(" ".to_owned()),
  282. );
  283. let result = try_load(&map);
  284. assert!(matches!(
  285. result,
  286. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  287. ));
  288. let path = std::env::temp_dir().join(format!("d-oauth-empty-pii-key-{}.txt", std::process::id()));
  289. if std::fs::write(&path, "").is_ok() {
  290. let mut file_map = base();
  291. file_map.remove("pii_encryption_key");
  292. file_map.insert(
  293. "pii_encryption_key_file".to_owned(),
  294. Value::String(path.to_string_lossy().into_owned()),
  295. );
  296. let result = try_load(&file_map);
  297. let _ = std::fs::remove_file(&path);
  298. assert!(matches!(
  299. result,
  300. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  301. ));
  302. }
  303. }
  304. #[test]
  305. fn config_builds_pii_chain() -> Result<(), Box<dyn Error>> {
  306. let mut map = base();
  307. map.insert(
  308. "pii_previous_key".to_owned(),
  309. Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
  310. );
  311. map.insert(
  312. "pii_previous_key_id".to_owned(),
  313. Value::String("v0".to_owned()),
  314. );
  315. map.insert("pii_dual_write".to_owned(), Value::Bool(true));
  316. let cfg = try_load(&map)?;
  317. let active = cfg.keys.active_pii_key().ok_or("missing active pii key")?;
  318. assert_eq!(active.0.as_str(), "v1");
  319. let retained = cfg
  320. .keys
  321. .pii_key_by_id("v0")
  322. .ok_or("missing retained pii key")?;
  323. assert!(!cfg.keys.pii_key_by_id("v9").is_some());
  324. let _ = retained;
  325. let ids: Vec<&str> = cfg
  326. .keys
  327. .pii_keys_newest_first()
  328. .map(|(id, _)| id.as_str())
  329. .collect();
  330. assert_eq!(ids, vec!["v1", "v0"]);
  331. assert!(cfg.pii_dual_write);
  332. let dual_default = try_load(&base())?;
  333. assert!(!dual_default.pii_dual_write);
  334. Ok(())
  335. }
  336. #[test]
  337. fn config_rejects_previous_key_without_id() {
  338. let mut map = base();
  339. map.insert(
  340. "pii_previous_key".to_owned(),
  341. Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
  342. );
  343. let result = try_load(&map);
  344. assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
  345. let mut same_id = base();
  346. same_id.insert(
  347. "pii_previous_key".to_owned(),
  348. Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
  349. );
  350. same_id.insert(
  351. "pii_previous_key_id".to_owned(),
  352. Value::String("v1".to_owned()),
  353. );
  354. let result = try_load(&same_id);
  355. assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
  356. let mut totp = base();
  357. totp.insert(
  358. "totp_previous_key".to_owned(),
  359. Value::String("SUPER_SECRET_TOTP_PREVIOUS".to_owned()),
  360. );
  361. let result = try_load(&totp);
  362. assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
  363. }
  364. #[test]
  365. fn config_rejects_previous_key_without_active() {
  366. let mut pii = without("pii_encryption_key");
  367. pii.insert(
  368. "pii_previous_key".to_owned(),
  369. Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
  370. );
  371. pii.insert(
  372. "pii_previous_key_id".to_owned(),
  373. Value::String("v0".to_owned()),
  374. );
  375. let result = try_load(&pii);
  376. assert!(matches!(
  377. result,
  378. Err(ConfigError::KeyRing(KeyRingError::MissingPiiKey))
  379. ));
  380. let mut totp = without("totp_encryption_key");
  381. totp.insert(
  382. "totp_previous_key".to_owned(),
  383. Value::String("SUPER_SECRET_TOTP_PREVIOUS".to_owned()),
  384. );
  385. totp.insert(
  386. "totp_previous_key_id".to_owned(),
  387. Value::String("v0".to_owned()),
  388. );
  389. let result = try_load(&totp);
  390. assert!(matches!(
  391. result,
  392. Err(ConfigError::KeyRing(KeyRingError::MissingTotpKey))
  393. ));
  394. }
  395. #[test]
  396. fn config_treats_empty_previous_key_as_absent() -> Result<(), Box<dyn Error>> {
  397. let mut map = base();
  398. map.insert(
  399. "pii_previous_key".to_owned(),
  400. Value::String(" ".to_owned()),
  401. );
  402. map.insert(
  403. "pii_previous_key_id".to_owned(),
  404. Value::String("v0".to_owned()),
  405. );
  406. let cfg = try_load(&map)?;
  407. let ids: Vec<&str> = cfg
  408. .keys
  409. .pii_keys_newest_first()
  410. .map(|(id, _)| id.as_str())
  411. .collect();
  412. assert_eq!(ids, vec!["v1"]);
  413. let path = std::env::temp_dir().join(format!(
  414. "d-oauth-empty-pii-previous-{}.txt",
  415. std::process::id()
  416. ));
  417. if std::fs::write(&path, "").is_ok() {
  418. let mut file_map = base();
  419. file_map.insert(
  420. "pii_previous_key_file".to_owned(),
  421. Value::String(path.to_string_lossy().into_owned()),
  422. );
  423. file_map.insert(
  424. "pii_previous_key_id".to_owned(),
  425. Value::String("v0".to_owned()),
  426. );
  427. let cfg = try_load(&file_map)?;
  428. let _ = std::fs::remove_file(&path);
  429. let ids: Vec<&str> = cfg
  430. .keys
  431. .pii_keys_newest_first()
  432. .map(|(id, _)| id.as_str())
  433. .collect();
  434. assert_eq!(ids, vec!["v1"]);
  435. }
  436. Ok(())
  437. }
  438. #[test]
  439. fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>> {
  440. use secrecy::ExposeSecret as _;
  441. let path = std::env::temp_dir().join(format!(
  442. "d-oauth-read-secret-file-{}.txt",
  443. std::process::id()
  444. ));
  445. std::fs::write(&path, " spaced-secret \n")?;
  446. let secret = super::read_secret_file(Some(path.as_path()))?;
  447. let _ = std::fs::remove_file(&path);
  448. let secret = secret.ok_or("secret file returned None")?;
  449. assert_eq!(secret.expose_secret(), " spaced-secret ");
  450. Ok(())
  451. }