Skip to main content

ig_client/application/
config.rs

1use crate::constants::{
2    DAYS_TO_BACK_LOOK, DEFAULT_API_VERSION, DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
3    DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS, DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
4    DEFAULT_DATABASE_MAX_CONNECTIONS, DEFAULT_DATABASE_URL, DEFAULT_PAGE_SIZE,
5    DEFAULT_REST_BASE_URL, DEFAULT_REST_TIMEOUT_SECS, DEFAULT_SLEEP_TIME,
6    DEFAULT_WS_RECONNECT_INTERVAL_SECS, DEFAULT_WS_URL,
7};
8use crate::utils::config::get_env_or_default;
9use dotenv::dotenv;
10use pretty_simple_display::{DebugPretty, DisplaySimple};
11use serde::{Deserialize, Serialize};
12use std::env;
13use tracing::{debug, error};
14
15/// Configuration for database connections
16///
17/// This is a pure configuration DTO with no I/O. It lives in the application
18/// config module (alongside the other `*Config` types) so the application layer
19/// can embed it in [`Config`] without depending on the storage layer. The
20/// storage layer re-exports it (see `storage::config`) and owns the actual pool
21/// construction (`storage::utils::create_connection_pool`).
22#[derive(Serialize, Deserialize, Clone)]
23pub struct DatabaseConfig {
24    /// Database connection URL
25    pub url: String,
26    /// Maximum number of connections in the connection pool
27    pub max_connections: u32,
28}
29
30// The connection `url` commonly embeds a password, so `Debug`/`Display` must
31// never print it — they redact the URL and show only `max_connections`.
32impl std::fmt::Debug for DatabaseConfig {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("DatabaseConfig")
35            .field("url", &"<redacted>")
36            .field("max_connections", &self.max_connections)
37            .finish()
38    }
39}
40
41impl std::fmt::Display for DatabaseConfig {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        write!(
44            f,
45            "DatabaseConfig {{ url: <redacted>, max_connections: {} }}",
46            self.max_connections
47        )
48    }
49}
50
51#[derive(Serialize, Deserialize, Clone)]
52/// Authentication credentials for the IG Markets API
53pub struct Credentials {
54    /// Username for the IG Markets account
55    pub username: String,
56    /// Password for the IG Markets account
57    pub password: String,
58    /// Account ID for the IG Markets account
59    pub account_id: String,
60    /// API key for the IG Markets API
61    pub api_key: String,
62    /// Client token for the IG Markets API
63    pub client_token: Option<String>,
64    /// Account token for the IG Markets API
65    pub account_token: Option<String>,
66}
67
68// `password`, `api_key` and the tokens are secrets, so `Debug`/`Display` must
69// never print them — they show `<redacted>` and leave only `username` /
70// `account_id` (non-sensitive identifiers) visible.
71impl std::fmt::Debug for Credentials {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        f.debug_struct("Credentials")
74            .field("username", &self.username)
75            .field("password", &"<redacted>")
76            .field("account_id", &self.account_id)
77            .field("api_key", &"<redacted>")
78            .field(
79                "client_token",
80                &self.client_token.as_ref().map(|_| "<redacted>"),
81            )
82            .field(
83                "account_token",
84                &self.account_token.as_ref().map(|_| "<redacted>"),
85            )
86            .finish()
87    }
88}
89
90impl std::fmt::Display for Credentials {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        write!(
93            f,
94            "Credentials {{ username: {}, account_id: {}, password: <redacted>, \
95             api_key: <redacted>, client_token: {}, account_token: {} }}",
96            self.username,
97            self.account_id,
98            if self.client_token.is_some() {
99                "<redacted>"
100            } else {
101                "None"
102            },
103            if self.account_token.is_some() {
104                "<redacted>"
105            } else {
106                "None"
107            },
108        )
109    }
110}
111
112#[derive(Debug, Serialize, Deserialize, Clone)]
113/// Main configuration for the IG Markets API client.
114///
115/// `Debug` is derived and delegates to each field's `Debug`, so the redacting
116/// `Credentials` and `DatabaseConfig` impls keep secrets out of the output;
117/// `Display` is manual for the same reason.
118pub struct Config {
119    /// Authentication credentials
120    pub credentials: Credentials,
121    /// REST API configuration
122    pub rest_api: RestApiConfig,
123    /// WebSocket API configuration
124    pub websocket: WebSocketConfig,
125    /// Database configuration for data persistence
126    pub database: DatabaseConfig,
127    /// Rate limiter configuration for API requests
128    pub rate_limiter: RateLimiterConfig,
129    /// Number of hours between transaction fetching operations
130    pub sleep_hours: u64,
131    /// Number of items to retrieve per page in API requests
132    pub page_size: u32,
133    /// Number of days to look back when fetching historical data
134    pub days_to_look_back: i64,
135    /// API version to use for authentication: `Some(2)` for CST /
136    /// X-SECURITY-TOKEN, `Some(3)` for OAuth. Both constructors set
137    /// `Some(3)` ([`crate::constants::DEFAULT_API_VERSION`]); an explicit
138    /// `None` makes login fall back to v2.
139    pub api_version: Option<u8>,
140}
141
142// Manual `Display` (replacing the derived, serde-based `DisplaySimple`, which
143// would serialize the whole tree including credential secrets). It delegates to
144// the nested configs' own `Display` impls — `Credentials` and `DatabaseConfig`
145// redact their secrets — and shows only non-sensitive scalars directly.
146impl std::fmt::Display for Config {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        write!(
149            f,
150            "Config {{ credentials: {}, rest_api: {}, websocket: {}, database: {}, \
151             rate_limiter: {}, sleep_hours: {}, page_size: {}, days_to_look_back: {}, \
152             api_version: {:?} }}",
153            self.credentials,
154            self.rest_api,
155            self.websocket,
156            self.database,
157            self.rate_limiter,
158            self.sleep_hours,
159            self.page_size,
160            self.days_to_look_back,
161            self.api_version,
162        )
163    }
164}
165
166#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
167/// Configuration for the REST API
168pub struct RestApiConfig {
169    /// Base URL for the IG Markets REST API
170    pub base_url: String,
171    /// Timeout in seconds for REST API requests
172    pub timeout: u64,
173}
174
175#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
176/// Configuration for the WebSocket API
177pub struct WebSocketConfig {
178    /// URL for the IG Markets WebSocket API
179    pub url: String,
180    /// Reconnect interval in seconds for WebSocket connections
181    pub reconnect_interval: u64,
182}
183
184#[derive(DebugPretty, DisplaySimple, Serialize, Deserialize, Clone)]
185/// Configuration for rate limiting API requests
186pub struct RateLimiterConfig {
187    /// Maximum number of requests allowed per period
188    pub max_requests: u32,
189    /// Time period in seconds for the rate limit
190    pub period_seconds: u64,
191    /// Burst size - maximum number of requests that can be made at once
192    pub burst_size: u32,
193}
194
195// The `Default` impls below are the single source of truth for the
196// non-credential defaults: they hold the literals and `Config::new()` uses them
197// as its `get_env_or_default` fallbacks. They read no environment variable and
198// load no `.env` file, so they are usable from the injection path
199// ([`Config::from_credentials`] / `Client::with_config`).
200
201impl Default for RestApiConfig {
202    /// IG **demo** REST gateway with a 30 second request timeout.
203    fn default() -> Self {
204        Self {
205            base_url: String::from(DEFAULT_REST_BASE_URL),
206            timeout: DEFAULT_REST_TIMEOUT_SECS,
207        }
208    }
209}
210
211impl Default for WebSocketConfig {
212    /// IG **demo** Lightstreamer endpoint with a 5 second reconnect interval.
213    fn default() -> Self {
214        Self {
215            url: String::from(DEFAULT_WS_URL),
216            reconnect_interval: DEFAULT_WS_RECONNECT_INTERVAL_SECS,
217        }
218    }
219}
220
221impl Default for RateLimiterConfig {
222    /// The crate-wide non-trading budget: 4 requests per 12 seconds, burst 3.
223    fn default() -> Self {
224        Self {
225            max_requests: DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS,
226            period_seconds: DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS,
227            burst_size: DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE,
228        }
229    }
230}
231
232impl Default for DatabaseConfig {
233    /// Credential-less placeholder URL — persistence will not connect until the
234    /// caller supplies a real one.
235    fn default() -> Self {
236        Self {
237            url: String::from(DEFAULT_DATABASE_URL),
238            max_connections: DEFAULT_DATABASE_MAX_CONNECTIONS,
239        }
240    }
241}
242
243impl Credentials {
244    /// Builds credentials from the four required fields, leaving both session
245    /// tokens unset.
246    ///
247    /// The tokens (`client_token` / `account_token`) are populated by the
248    /// session layer on login, so callers never provide them.
249    ///
250    /// # Arguments
251    ///
252    /// * `username` - IG account username
253    /// * `password` - IG account password
254    /// * `account_id` - IG account identifier
255    /// * `api_key` - IG API key
256    #[must_use]
257    pub fn new(username: String, password: String, account_id: String, api_key: String) -> Self {
258        Self {
259            username,
260            password,
261            account_id,
262            api_key,
263            client_token: None,
264            account_token: None,
265        }
266    }
267
268    /// Splits [`api_key`](Self::api_key) into the individual keys of a pool.
269    ///
270    /// IG enforces its non-trading allowance **per API key**, so a caller that
271    /// owns several keys on the same account can raise its aggregate throughput
272    /// by spreading requests across them. To express that, `api_key` accepts a
273    /// comma-separated list; a single key (no comma) yields a one-element pool,
274    /// which is the historical behaviour.
275    ///
276    /// Empty entries and surrounding whitespace are discarded, so
277    /// `"a, b, ,c,"` yields `["a", "b", "c"]`. When the field holds nothing
278    /// usable the result is empty and the caller decides how to fail.
279    #[must_use]
280    pub fn api_keys(&self) -> Vec<String> {
281        self.api_key
282            .split(',')
283            .map(str::trim)
284            .filter(|k| !k.is_empty())
285            .map(String::from)
286            .collect()
287    }
288}
289
290impl Default for Config {
291    /// Delegates to [`Config::new`] and therefore loads a `.env` file and reads
292    /// the `IG_*` namespace — unlike the section-level `Default` impls above,
293    /// which are env-free. `Config { .., ..Config::default() }` still touches
294    /// the environment; use [`Config::from_credentials`] as the base value when
295    /// that is not acceptable.
296    fn default() -> Self {
297        Self::new()
298    }
299}
300
301impl Config {
302    /// Creates a configuration from caller-supplied credentials, reading **no**
303    /// environment variable and loading **no** `.env` file.
304    ///
305    /// This is the injection path for embedding applications that own their
306    /// configuration source (their own namespaced env vars, a config file, a
307    /// secrets manager). Pair it with
308    /// [`Client::with_config`](crate::application::client::Client::with_config).
309    /// [`Config::new`] remains the `.env` / `IG_*` convenience path.
310    ///
311    /// Every non-credential field takes its documented default (IG **demo**
312    /// endpoints — see the `Default` impls of [`RestApiConfig`],
313    /// [`WebSocketConfig`], [`RateLimiterConfig`] and [`DatabaseConfig`]).
314    /// Override individual sections with a struct-update expression, which
315    /// stays env-free because the base value is this constructor:
316    ///
317    /// ```rust
318    /// use ig_client::prelude::*;
319    ///
320    /// let credentials = Credentials::new(
321    ///     "user".to_string(),
322    ///     "password".to_string(),
323    ///     "ABC123".to_string(),
324    ///     "api-key".to_string(),
325    /// );
326    /// let config = Config {
327    ///     rest_api: RestApiConfig {
328    ///         base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
329    ///         timeout: 30,
330    ///     },
331    ///     ..Config::from_credentials(credentials)
332    /// };
333    /// assert_eq!(config.rest_api.base_url, "https://demo-api.ig.com/gateway/deal");
334    /// ```
335    ///
336    /// # Arguments
337    ///
338    /// * `credentials` - IG credentials supplied by the caller
339    ///
340    /// # Returns
341    ///
342    /// A `Config` built entirely from `credentials` plus the documented defaults
343    #[must_use]
344    pub fn from_credentials(credentials: Credentials) -> Self {
345        Config {
346            credentials,
347            rest_api: RestApiConfig::default(),
348            websocket: WebSocketConfig::default(),
349            database: DatabaseConfig::default(),
350            rate_limiter: RateLimiterConfig::default(),
351            sleep_hours: DEFAULT_SLEEP_TIME,
352            page_size: DEFAULT_PAGE_SIZE,
353            days_to_look_back: DAYS_TO_BACK_LOOK,
354            api_version: Some(DEFAULT_API_VERSION),
355        }
356    }
357
358    /// Creates a new configuration instance from the environment.
359    ///
360    /// Loads a local `.env` file (via `dotenv`) and reads the `IG_*` /
361    /// `DATABASE_*` / `TX_*` environment variables, falling back to the
362    /// documented defaults for anything unset. Embedders that must not touch
363    /// the `.env` file or the `IG_*` namespace should use
364    /// [`Config::from_credentials`] instead.
365    ///
366    /// # Returns
367    ///
368    /// A new `Config` instance
369    pub fn new() -> Self {
370        // Explicitly load the .env file
371        match dotenv() {
372            Ok(_) => debug!("Successfully loaded .env file"),
373            Err(e) => debug!("Failed to load .env file: {e}"),
374        }
375
376        // Check if environment variables are configured
377        let username = get_env_or_default("IG_USERNAME", String::from("default_username"));
378        let password = get_env_or_default("IG_PASSWORD", String::from("default_password"));
379        let api_key = get_env_or_default("IG_API_KEY", String::from("default_api_key"));
380        let sleep_hours = get_env_or_default("TX_LOOP_INTERVAL_HOURS", DEFAULT_SLEEP_TIME);
381        let page_size = get_env_or_default("TX_PAGE_SIZE", DEFAULT_PAGE_SIZE);
382        let days_to_look_back = get_env_or_default("TX_DAYS_LOOKBACK", DAYS_TO_BACK_LOOK);
383
384        // Defaults come from the `Default` impls so the env path and the
385        // env-free path (`from_credentials`) cannot drift apart.
386        let rest_defaults = RestApiConfig::default();
387        let ws_defaults = WebSocketConfig::default();
388        let rate_limit_defaults = RateLimiterConfig::default();
389        let database_defaults = DatabaseConfig::default();
390
391        let database_url = get_env_or_default("DATABASE_URL", database_defaults.url);
392
393        // Check if we are using default values
394        if username == "default_username" {
395            error!("IG_USERNAME not found in environment variables or .env file");
396        }
397        if password == "default_password" {
398            error!("IG_PASSWORD not found in environment variables or .env file");
399        }
400        if api_key == "default_api_key" {
401            error!("IG_API_KEY not found in environment variables or .env file");
402        }
403        // Check the variable directly rather than comparing the resolved value
404        // to the placeholder: a user may intentionally set a credential-less URL
405        // equal to the placeholder, which is not the "unset" case we warn about.
406        if env::var("DATABASE_URL").is_err() {
407            // Falls back to the credential-less placeholder; persistence will not
408            // connect until DATABASE_URL is set. We never use a credentialed default.
409            error!(
410                "DATABASE_URL not found in environment variables or .env file; \
411                 using a credential-less placeholder and persistence will not connect"
412            );
413        }
414
415        Config {
416            credentials: Credentials {
417                username,
418                password,
419                account_id: get_env_or_default(
420                    "IG_ACCOUNT_ID",
421                    String::from(crate::constants::DEFAULT_ACCOUNT_ID),
422                ),
423                api_key,
424                client_token: None,
425                account_token: None,
426            },
427            rest_api: RestApiConfig {
428                base_url: get_env_or_default("IG_REST_BASE_URL", rest_defaults.base_url),
429                timeout: get_env_or_default("IG_REST_TIMEOUT", rest_defaults.timeout),
430            },
431            websocket: WebSocketConfig {
432                url: get_env_or_default("IG_WS_URL", ws_defaults.url),
433                reconnect_interval: get_env_or_default(
434                    "IG_WS_RECONNECT_INTERVAL",
435                    ws_defaults.reconnect_interval,
436                ),
437            },
438            database: DatabaseConfig {
439                url: database_url,
440                max_connections: get_env_or_default(
441                    "DATABASE_MAX_CONNECTIONS",
442                    database_defaults.max_connections,
443                ),
444            },
445            rate_limiter: RateLimiterConfig {
446                max_requests: get_env_or_default(
447                    "IG_RATE_LIMIT_MAX_REQUESTS",
448                    rate_limit_defaults.max_requests,
449                ),
450                period_seconds: get_env_or_default(
451                    "IG_RATE_LIMIT_PERIOD_SECONDS",
452                    rate_limit_defaults.period_seconds,
453                ),
454                burst_size: get_env_or_default(
455                    "IG_RATE_LIMIT_BURST_SIZE",
456                    rate_limit_defaults.burst_size,
457                ),
458            },
459            sleep_hours,
460            page_size,
461            days_to_look_back,
462            api_version: env::var("IG_API_VERSION")
463                .ok()
464                .and_then(|v| v.parse::<u8>().ok())
465                .filter(|&v| v == 2 || v == 3)
466                .or(Some(DEFAULT_API_VERSION)), // Default to API v3 (OAuth) if not specified
467        }
468    }
469}
470
471#[cfg(test)]
472mod injection_tests {
473    use super::*;
474
475    fn injected_credentials() -> Credentials {
476        Credentials::new(
477            "embedder-user".to_string(),
478            "embedder-password".to_string(),
479            "EMBEDDER-ACC".to_string(),
480            "embedder-api-key".to_string(),
481        )
482    }
483
484    #[test]
485    fn test_credentials_new_leaves_session_tokens_unset() {
486        let credentials = injected_credentials();
487        assert_eq!(credentials.username, "embedder-user");
488        assert_eq!(credentials.password, "embedder-password");
489        assert_eq!(credentials.account_id, "EMBEDDER-ACC");
490        assert_eq!(credentials.api_key, "embedder-api-key");
491        assert!(credentials.client_token.is_none());
492        assert!(credentials.account_token.is_none());
493    }
494
495    #[test]
496    fn test_config_from_credentials_keeps_injected_credentials_and_env_free_defaults() {
497        // No environment is mutated here (`set_var` is `unsafe` on edition 2024
498        // and racy under the parallel harness), so this asserts the contract
499        // rather than proving env-independence by construction: the injected
500        // credentials survive and every other field equals its documented
501        // default. The end-to-end env-independence check lives in
502        // `tests/unit/application/test_client.rs`, where the injected values
503        // cannot coincide with anything the environment holds.
504        let config = Config::from_credentials(injected_credentials());
505
506        assert_eq!(config.credentials.username, "embedder-user");
507        assert_eq!(config.credentials.api_key, "embedder-api-key");
508        assert_eq!(config.rest_api.base_url, DEFAULT_REST_BASE_URL);
509        assert_eq!(config.rest_api.timeout, DEFAULT_REST_TIMEOUT_SECS);
510        assert_eq!(config.websocket.url, DEFAULT_WS_URL);
511        assert_eq!(
512            config.websocket.reconnect_interval,
513            DEFAULT_WS_RECONNECT_INTERVAL_SECS
514        );
515        assert_eq!(config.database.url, DEFAULT_DATABASE_URL);
516        assert_eq!(
517            config.database.max_connections,
518            DEFAULT_DATABASE_MAX_CONNECTIONS
519        );
520        assert_eq!(
521            config.rate_limiter.max_requests,
522            DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
523        );
524        assert_eq!(
525            config.rate_limiter.period_seconds,
526            DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
527        );
528        assert_eq!(
529            config.rate_limiter.burst_size,
530            DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
531        );
532        assert_eq!(config.sleep_hours, DEFAULT_SLEEP_TIME);
533        assert_eq!(config.page_size, DEFAULT_PAGE_SIZE);
534        assert_eq!(config.days_to_look_back, DAYS_TO_BACK_LOOK);
535        assert_eq!(config.api_version, Some(DEFAULT_API_VERSION));
536    }
537
538    #[test]
539    fn test_config_from_credentials_struct_update_overrides_section() {
540        // The documented override pattern must not fall back to `Config::new()`
541        // (which would run `dotenv()`); the base value is the env-free ctor.
542        let config = Config {
543            rest_api: RestApiConfig {
544                base_url: "https://demo-api.ig.com/gateway/deal".to_string(),
545                timeout: 7,
546            },
547            ..Config::from_credentials(injected_credentials())
548        };
549
550        assert_eq!(
551            config.rest_api.base_url,
552            "https://demo-api.ig.com/gateway/deal"
553        );
554        assert_eq!(config.rest_api.timeout, 7);
555        // Untouched sections keep their env-free defaults.
556        assert_eq!(config.websocket.url, DEFAULT_WS_URL);
557    }
558
559    #[test]
560    fn test_section_defaults_match_documented_constants() {
561        // Guards the refactor that made `Config::new()` use these `Default`
562        // impls as its env fallbacks: the two paths must not drift apart.
563        let rest = RestApiConfig::default();
564        let ws = WebSocketConfig::default();
565        let rate_limiter = RateLimiterConfig::default();
566        let database = DatabaseConfig::default();
567
568        assert_eq!(rest.base_url, DEFAULT_REST_BASE_URL);
569        assert_eq!(rest.timeout, DEFAULT_REST_TIMEOUT_SECS);
570        assert_eq!(ws.url, DEFAULT_WS_URL);
571        assert_eq!(ws.reconnect_interval, DEFAULT_WS_RECONNECT_INTERVAL_SECS);
572        assert_eq!(
573            rate_limiter.max_requests,
574            DEFAULT_CONFIG_RATE_LIMIT_MAX_REQUESTS
575        );
576        assert_eq!(
577            rate_limiter.period_seconds,
578            DEFAULT_CONFIG_RATE_LIMIT_PERIOD_SECONDS
579        );
580        assert_eq!(
581            rate_limiter.burst_size,
582            DEFAULT_CONFIG_RATE_LIMIT_BURST_SIZE
583        );
584        assert_eq!(database.url, DEFAULT_DATABASE_URL);
585        assert_eq!(database.max_connections, DEFAULT_DATABASE_MAX_CONNECTIONS);
586        // The rate-limiter default is NOT the zero-burst fallback constant.
587        assert_ne!(
588            rate_limiter.burst_size,
589            crate::constants::DEFAULT_RATE_LIMIT_BURST_SIZE
590        );
591    }
592}
593
594#[cfg(test)]
595mod redaction_tests {
596    use super::*;
597
598    fn secret_credentials() -> Credentials {
599        Credentials {
600            username: "user@example.com".to_string(),
601            password: "SUPER-SECRET-PASSWORD".to_string(),
602            account_id: "ACC123".to_string(),
603            api_key: "SECRET-API-KEY".to_string(),
604            client_token: Some("SECRET-CST".to_string()),
605            account_token: Some("SECRET-XST".to_string()),
606        }
607    }
608
609    #[test]
610    fn test_credentials_debug_and_display_redact_secrets() {
611        let creds = secret_credentials();
612        for rendered in [format!("{creds:?}"), format!("{creds}")] {
613            for secret in [
614                "SUPER-SECRET-PASSWORD",
615                "SECRET-API-KEY",
616                "SECRET-CST",
617                "SECRET-XST",
618            ] {
619                assert!(
620                    !rendered.contains(secret),
621                    "credentials rendering leaked {secret}: {rendered}"
622                );
623            }
624            assert!(rendered.contains("<redacted>"));
625            // Non-sensitive identifiers stay visible.
626            assert!(rendered.contains("user@example.com"));
627            assert!(rendered.contains("ACC123"));
628        }
629    }
630
631    #[test]
632    fn test_config_debug_and_display_redact_credential_and_db_secrets() {
633        let config = Config {
634            credentials: secret_credentials(),
635            database: DatabaseConfig {
636                url: "postgres://dbuser:DB-SECRET-PW@host/db".to_string(),
637                max_connections: 5,
638            },
639            ..Config::default()
640        };
641        for rendered in [format!("{config:?}"), format!("{config}")] {
642            for secret in ["SUPER-SECRET-PASSWORD", "SECRET-API-KEY", "DB-SECRET-PW"] {
643                assert!(
644                    !rendered.contains(secret),
645                    "config rendering leaked {secret}: {rendered}"
646                );
647            }
648            assert!(rendered.contains("<redacted>"));
649        }
650    }
651
652    #[test]
653    fn test_api_keys_single_key_yields_one_element_pool() {
654        let c = Credentials::new("u".into(), "p".into(), "ACC".into(), "abc123".into());
655        assert_eq!(c.api_keys(), vec!["abc123".to_string()]);
656    }
657
658    #[test]
659    fn test_api_keys_comma_separated_list_yields_pool() {
660        let c = Credentials::new("u".into(), "p".into(), "ACC".into(), "a,b,c".into());
661        assert_eq!(
662            c.api_keys(),
663            vec!["a".to_string(), "b".to_string(), "c".to_string()]
664        );
665    }
666
667    #[test]
668    fn test_api_keys_trims_whitespace_and_drops_empty_entries() {
669        let c = Credentials::new("u".into(), "p".into(), "ACC".into(), " a , b , ,c, ".into());
670        assert_eq!(
671            c.api_keys(),
672            vec!["a".to_string(), "b".to_string(), "c".to_string()]
673        );
674    }
675
676    #[test]
677    fn test_api_keys_blank_value_yields_empty_pool() {
678        let c = Credentials::new("u".into(), "p".into(), "ACC".into(), "  ,  ".into());
679        assert!(c.api_keys().is_empty());
680    }
681}