Skip to main content

omni_dev/datadog/
auth.rs

1//! Datadog credential management.
2//!
3//! Loads and saves Datadog API credentials from/to the
4//! `~/.omni-dev/settings.json` file — the active profile's `env` map when a
5//! profile is selected, the base `env` map otherwise (issue #1116).
6
7use anyhow::Result;
8use serde::Serialize;
9
10use crate::datadog::error::DatadogError;
11use crate::utils::env::SystemEnv;
12use crate::utils::secret::Secret;
13use crate::utils::secret_env::{secret_var, secret_var_is_set};
14use crate::utils::settings::{active_profile_from, Settings};
15
16/// Environment variable / settings key for the Datadog API key.
17pub const DATADOG_API_KEY: &str = "DATADOG_API_KEY";
18
19/// Environment variable / settings key for the Datadog application key.
20pub const DATADOG_APP_KEY: &str = "DATADOG_APP_KEY";
21
22/// Environment variable / settings key for the Datadog site (e.g. `datadoghq.com`).
23pub const DATADOG_SITE: &str = "DATADOG_SITE";
24
25/// Environment variable / settings key for an explicit Datadog API base URL.
26///
27/// When set, overrides [`DATADOG_SITE`] entirely — the client uses this URL
28/// verbatim instead of deriving `https://api.{site}`. Useful for:
29/// - Tests that point at a wiremock server (e.g. `http://127.0.0.1:PORT`).
30/// - On-prem / proxied Datadog installs that don't match `api.{site}`.
31pub const DATADOG_API_URL: &str = "DATADOG_API_URL";
32
33/// Default Datadog site when none is configured (US1 region).
34pub const DEFAULT_SITE: &str = "datadoghq.com";
35
36/// Datadog sites recognised as non-warning.
37///
38/// Any other value is accepted but logs a warning on [`load_credentials`] —
39/// Datadog adds regions occasionally and rejecting unknown values would
40/// break the CLI on each new region.
41pub const KNOWN_SITES: &[&str] = &[
42    "datadoghq.com",
43    "us3.datadoghq.com",
44    "us5.datadoghq.com",
45    "datadoghq.eu",
46    "ap1.datadoghq.com",
47    "ddog-gov.com",
48];
49
50/// Datadog API credentials.
51#[derive(Debug, Clone)]
52pub struct DatadogCredentials {
53    /// API key (organisation-scoped secret; redacted in `Debug` output).
54    pub api_key: Secret,
55
56    /// Application key (user-scoped secret; redacted in `Debug` output).
57    pub app_key: Secret,
58
59    /// Site identifier, e.g. `datadoghq.com`. Determines the base URL.
60    pub site: String,
61}
62
63/// Normalises a user-supplied site string.
64///
65/// Strips any `https://` or `http://` scheme, any `api.` subdomain prefix
66/// (users sometimes paste the full API host), and trailing slashes.
67pub fn normalize_site(raw: &str) -> String {
68    let trimmed = raw.trim();
69    let no_scheme = trimmed
70        .strip_prefix("https://")
71        .or_else(|| trimmed.strip_prefix("http://"))
72        .unwrap_or(trimmed);
73    let no_api = no_scheme.strip_prefix("api.").unwrap_or(no_scheme);
74    no_api.trim_end_matches('/').to_string()
75}
76
77/// Returns the Datadog API base URL for a given site.
78pub fn base_url_for_site(site: &str) -> String {
79    format!("https://api.{}", normalize_site(site))
80}
81
82/// Loads Datadog credentials from environment variables or settings.json.
83///
84/// Environment variables take precedence over the settings file. Emits a
85/// warning on stderr when the configured site is not in [`KNOWN_SITES`].
86pub fn load_credentials() -> Result<DatadogCredentials> {
87    load_credentials_with(&crate::utils::settings::SettingsEnv::load())
88}
89
90/// [`load_credentials`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
91///
92/// The production wrapper passes `&SettingsEnv::load()` (process env with a
93/// settings.json fallback); tests pass a pure `MapEnv`, so credential
94/// resolution is exercised without mutating the process environment or `HOME`
95/// (issue #1030).
96pub(crate) fn load_credentials_with(
97    env: &impl crate::utils::env::EnvSource,
98) -> Result<DatadogCredentials> {
99    let api_key = secret_var(env, DATADOG_API_KEY)?.ok_or(DatadogError::CredentialsNotFound)?;
100    let app_key = secret_var(env, DATADOG_APP_KEY)?.ok_or(DatadogError::CredentialsNotFound)?;
101    let site = env
102        .var(DATADOG_SITE)
103        .map(|s| normalize_site(&s))
104        .filter(|s| !s.is_empty())
105        .unwrap_or_else(|| DEFAULT_SITE.to_string());
106
107    if !KNOWN_SITES.iter().any(|k| *k == site) {
108        eprintln!("warning: Datadog site '{site}' is not a known region; proceeding anyway");
109    }
110
111    Ok(DatadogCredentials {
112        api_key,
113        app_key,
114        site,
115    })
116}
117
118/// Summary of a single Datadog credential scope.
119///
120/// Reports which credential keys are present without exposing their values.
121/// Safe to serialise and return over the MCP surface.
122#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
123pub struct DatadogScopeStatus {
124    /// Scope name (currently always `"default"`; forward-compatible for
125    /// per-instance scopes).
126    pub name: String,
127    /// Whether [`DATADOG_API_KEY`] is present.
128    pub has_api_key: bool,
129    /// Whether [`DATADOG_APP_KEY`] is present.
130    pub has_app_key: bool,
131    /// Configured site (non-secret; normalised). `None` when unset.
132    #[serde(skip_serializing_if = "Option::is_none")]
133    pub site: Option<String>,
134}
135
136/// Aggregate credential status across every known Datadog scope.
137#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
138pub struct AuthStatus {
139    /// One entry per scope. Currently a single default scope; kept as a list
140    /// so future multi-instance support does not require a schema change.
141    pub scopes: Vec<DatadogScopeStatus>,
142}
143
144/// Builds an [`AuthStatus`] from the current settings / environment.
145///
146/// Reports credential presence without leaking any secret values. Safe to
147/// call with no credentials configured.
148pub fn status() -> AuthStatus {
149    status_with(&crate::utils::settings::SettingsEnv::load())
150}
151
152/// [`status`] over an injected [`EnvSource`](crate::utils::env::EnvSource).
153///
154/// Tests pass a pure `MapEnv` to report presence without mutating the process
155/// environment or `HOME` (issue #1030).
156pub(crate) fn status_with(env: &impl crate::utils::env::EnvSource) -> AuthStatus {
157    let has_api_key = secret_var_is_set(env, DATADOG_API_KEY);
158    let has_app_key = secret_var_is_set(env, DATADOG_APP_KEY);
159    let site = env
160        .var(DATADOG_SITE)
161        .map(|s| normalize_site(&s))
162        .filter(|s| !s.is_empty());
163
164    AuthStatus {
165        scopes: vec![DatadogScopeStatus {
166            name: "default".to_string(),
167            has_api_key,
168            has_app_key,
169            site,
170        }],
171    }
172}
173
174/// Saves Datadog credentials to `~/.omni-dev/settings.json`.
175///
176/// Reads the existing settings file, merges the three credential keys into
177/// the active profile's `env` map (the base `env` when no profile is active
178/// — issue #1116), and writes back. Preserves all other settings.
179pub fn save_credentials(credentials: &DatadogCredentials) -> Result<()> {
180    save_credentials_to(
181        &Settings::get_settings_path()?,
182        active_profile_from(&SystemEnv).as_deref(),
183        credentials,
184    )
185}
186
187/// [`save_credentials`], writing to an explicit settings-file path and env
188/// map (`profiles.<name>.env` when `profile` is `Some`, base `env` otherwise).
189///
190/// Tests inject a tempdir path and an explicit profile instead of mutating
191/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
192pub(crate) fn save_credentials_to(
193    settings_path: &std::path::Path,
194    profile: Option<&str>,
195    credentials: &DatadogCredentials,
196) -> Result<()> {
197    Settings::upsert_env_vars_in(
198        settings_path,
199        profile,
200        &[
201            (DATADOG_API_KEY, credentials.api_key.expose_secret()),
202            (DATADOG_APP_KEY, credentials.app_key.expose_secret()),
203            (DATADOG_SITE, credentials.site.as_str()),
204        ],
205    )
206}
207
208/// Removes Datadog credential keys from `~/.omni-dev/settings.json` — from
209/// the active profile's `env` map when a profile is active, the base `env`
210/// otherwise (issue #1116).
211///
212/// Leaves all other settings intact. Returns `true` if any Datadog key was
213/// present and removed, `false` when the targeted map was already free of
214/// them (or the file did not exist).
215pub fn remove_credentials() -> Result<bool> {
216    remove_credentials_at(
217        &Settings::get_settings_path()?,
218        active_profile_from(&SystemEnv).as_deref(),
219    )
220}
221
222/// [`remove_credentials`], operating on an explicit settings-file path and
223/// env map (`profiles.<name>.env` when `profile` is `Some`, base `env`
224/// otherwise).
225///
226/// Tests inject a tempdir path and an explicit profile instead of mutating
227/// `HOME` / `OMNI_DEV_PROFILE` (issue #1030).
228pub(crate) fn remove_credentials_at(
229    settings_path: &std::path::Path,
230    profile: Option<&str>,
231) -> Result<bool> {
232    Settings::remove_env_vars_in(
233        settings_path,
234        profile,
235        &[DATADOG_API_KEY, DATADOG_APP_KEY, DATADOG_SITE],
236    )
237}
238
239#[cfg(test)]
240#[allow(clippy::unwrap_used, clippy::expect_used)]
241mod tests {
242    use std::fs;
243
244    use super::*;
245
246    // ── Pure helpers (safe to run in parallel) ─────────────────────────
247
248    #[test]
249    fn normalize_site_strips_scheme_and_api_prefix() {
250        assert_eq!(normalize_site("datadoghq.com"), "datadoghq.com");
251        assert_eq!(normalize_site("https://datadoghq.com"), "datadoghq.com");
252        assert_eq!(normalize_site("http://datadoghq.com"), "datadoghq.com");
253        assert_eq!(normalize_site("api.datadoghq.com"), "datadoghq.com");
254        assert_eq!(normalize_site("https://api.datadoghq.com"), "datadoghq.com");
255        assert_eq!(
256            normalize_site("https://api.us3.datadoghq.com/"),
257            "us3.datadoghq.com"
258        );
259    }
260
261    #[test]
262    fn normalize_site_trims_whitespace() {
263        assert_eq!(normalize_site("  datadoghq.com  "), "datadoghq.com");
264    }
265
266    #[test]
267    fn base_url_for_site_builds_api_host() {
268        assert_eq!(
269            base_url_for_site("datadoghq.com"),
270            "https://api.datadoghq.com"
271        );
272        assert_eq!(
273            base_url_for_site("us5.datadoghq.com"),
274            "https://api.us5.datadoghq.com"
275        );
276        assert_eq!(
277            base_url_for_site("datadoghq.eu"),
278            "https://api.datadoghq.eu"
279        );
280    }
281
282    #[test]
283    fn base_url_normalises_input() {
284        assert_eq!(
285            base_url_for_site("https://api.datadoghq.com/"),
286            "https://api.datadoghq.com"
287        );
288    }
289
290    #[test]
291    fn credentials_struct_clone_and_debug() {
292        let creds = DatadogCredentials {
293            api_key: "sekret-api-key-value".into(),
294            app_key: "sekret-app-key-value".into(),
295            site: "datadoghq.com".to_string(),
296        };
297        let cloned = creds.clone();
298        assert_eq!(cloned.api_key, creds.api_key);
299        // Debug must never print the key values (#1131).
300        let debug = format!("{creds:?}");
301        assert!(debug.contains("DatadogCredentials"));
302        assert!(
303            !debug.contains("sekret-api-key-value"),
304            "leaked api_key: {debug}"
305        );
306        assert!(
307            !debug.contains("sekret-app-key-value"),
308            "leaked app_key: {debug}"
309        );
310        assert!(debug.contains("api_key: <redacted>"));
311        assert!(debug.contains("app_key: <redacted>"));
312    }
313
314    #[test]
315    fn constant_key_names() {
316        assert_eq!(DATADOG_API_KEY, "DATADOG_API_KEY");
317        assert_eq!(DATADOG_APP_KEY, "DATADOG_APP_KEY");
318        assert_eq!(DATADOG_SITE, "DATADOG_SITE");
319        assert_eq!(DEFAULT_SITE, "datadoghq.com");
320    }
321
322    #[test]
323    fn known_sites_contains_common_regions() {
324        assert!(KNOWN_SITES.contains(&"datadoghq.com"));
325        assert!(KNOWN_SITES.contains(&"datadoghq.eu"));
326        assert!(KNOWN_SITES.contains(&"us5.datadoghq.com"));
327    }
328
329    // ── Env-parsing boundary tests (injected, no process-env mutation) ──
330
331    use crate::test_support::env::MapEnv;
332
333    #[test]
334    fn keys_can_come_from_files_and_count_as_present() {
335        let (_dir, path) = crate::test_support::env::secret_file("app-from-file\n");
336        let env = MapEnv::new()
337            .with(DATADOG_API_KEY, "api")
338            .with("DATADOG_APP_KEY_FILE", &path);
339        let creds = load_credentials_with(&env).unwrap();
340        assert_eq!(creds.app_key.expose_secret(), "app-from-file");
341        assert!(status_with(&env).scopes[0].has_app_key);
342    }
343
344    #[test]
345    fn status_reports_all_false_when_nothing_configured() {
346        let status = status_with(&MapEnv::new());
347        assert_eq!(status.scopes.len(), 1);
348        let scope = &status.scopes[0];
349        assert_eq!(scope.name, "default");
350        assert!(!scope.has_api_key);
351        assert!(!scope.has_app_key);
352        assert_eq!(scope.site, None);
353    }
354
355    #[test]
356    fn status_reports_presence_flags_without_leaking_secrets() {
357        let env = MapEnv::new()
358            .with(DATADOG_API_KEY, "sekret-api-do-not-leak")
359            .with(DATADOG_APP_KEY, "sekret-app-do-not-leak")
360            .with(DATADOG_SITE, "datadoghq.com");
361
362        let status = status_with(&env);
363        let scope = &status.scopes[0];
364        assert!(scope.has_api_key);
365        assert!(scope.has_app_key);
366        assert_eq!(scope.site.as_deref(), Some("datadoghq.com"));
367
368        let yaml = serde_yaml::to_string(&status).unwrap();
369        assert!(!yaml.contains("sekret-api-do-not-leak"));
370        assert!(!yaml.contains("sekret-app-do-not-leak"));
371    }
372
373    #[test]
374    fn status_normalises_site_value() {
375        let env = MapEnv::new().with(DATADOG_SITE, "https://api.us3.datadoghq.com/");
376        let status = status_with(&env);
377        assert_eq!(status.scopes[0].site.as_deref(), Some("us3.datadoghq.com"));
378    }
379
380    #[test]
381    fn load_credentials_errors_when_api_key_missing() {
382        let env = MapEnv::new().with(DATADOG_APP_KEY, "app");
383        let err = load_credentials_with(&env).unwrap_err();
384        assert!(err.to_string().contains("not configured"));
385    }
386
387    #[test]
388    fn load_credentials_defaults_site_when_unset() {
389        let env = MapEnv::new()
390            .with(DATADOG_API_KEY, "api")
391            .with(DATADOG_APP_KEY, "app");
392        let creds = load_credentials_with(&env).unwrap();
393        assert_eq!(creds.site, DEFAULT_SITE);
394    }
395
396    #[test]
397    fn load_credentials_warns_on_unknown_site_but_succeeds() {
398        let env = MapEnv::new()
399            .with(DATADOG_API_KEY, "api")
400            .with(DATADOG_APP_KEY, "app")
401            .with(DATADOG_SITE, "custom.example");
402        let creds = load_credentials_with(&env).unwrap();
403        assert_eq!(creds.site, "custom.example");
404    }
405
406    /// Save + remove round-trip against injected settings-file paths — no
407    /// `HOME` mutation, so the test needs no lock. Covers fresh-file creation,
408    /// merge-with-existing, and removal.
409    #[test]
410    fn save_then_remove_round_trip() {
411        // ── Part 1: creates file from scratch ──────────────────────
412        {
413            let temp_dir = {
414                std::fs::create_dir_all("tmp").ok();
415                tempfile::TempDir::new_in("tmp").unwrap()
416            };
417            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
418
419            let creds = DatadogCredentials {
420                api_key: "api-1".into(),
421                app_key: "app-1".into(),
422                site: "datadoghq.com".to_string(),
423            };
424            save_credentials_to(&settings_path, None, &creds).unwrap();
425
426            assert!(settings_path.exists());
427            let content = fs::read_to_string(&settings_path).unwrap();
428            let val: serde_json::Value = serde_json::from_str(&content).unwrap();
429            assert_eq!(val["env"]["DATADOG_API_KEY"], "api-1");
430            assert_eq!(val["env"]["DATADOG_APP_KEY"], "app-1");
431            assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.com");
432
433            // The credential store is created owner-only (issue #1128).
434            #[cfg(unix)]
435            {
436                use std::os::unix::fs::PermissionsExt;
437                let mode = fs::metadata(&settings_path).unwrap().permissions().mode();
438                assert_eq!(mode & 0o777, 0o600);
439            }
440        }
441
442        // ── Part 2: merges into existing settings ──────────────────
443        {
444            let temp_dir = {
445                std::fs::create_dir_all("tmp").ok();
446                tempfile::TempDir::new_in("tmp").unwrap()
447            };
448            let omni_dir = temp_dir.path().join(".omni-dev");
449            fs::create_dir_all(&omni_dir).unwrap();
450            let settings_path = omni_dir.join("settings.json");
451            fs::write(
452                &settings_path,
453                r#"{"env": {"OTHER_KEY": "keep_me"}, "extra": true}"#,
454            )
455            .unwrap();
456
457            let creds = DatadogCredentials {
458                api_key: "api-2".into(),
459                app_key: "app-2".into(),
460                site: "datadoghq.eu".to_string(),
461            };
462            save_credentials_to(&settings_path, None, &creds).unwrap();
463
464            let val: serde_json::Value =
465                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
466            assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
467            assert_eq!(val["extra"], true);
468            assert_eq!(val["env"]["DATADOG_SITE"], "datadoghq.eu");
469        }
470
471        // ── Part 3: remove clears the three keys, preserves others ─
472        {
473            let temp_dir = {
474                std::fs::create_dir_all("tmp").ok();
475                tempfile::TempDir::new_in("tmp").unwrap()
476            };
477            let omni_dir = temp_dir.path().join(".omni-dev");
478            fs::create_dir_all(&omni_dir).unwrap();
479            let settings_path = omni_dir.join("settings.json");
480            fs::write(
481                &settings_path,
482                r#"{"env": {
483                    "DATADOG_API_KEY": "a",
484                    "DATADOG_APP_KEY": "b",
485                    "DATADOG_SITE": "datadoghq.com",
486                    "OTHER_KEY": "keep"
487                }}"#,
488            )
489            .unwrap();
490
491            let removed = remove_credentials_at(&settings_path, None).unwrap();
492            assert!(removed);
493
494            let val: serde_json::Value =
495                serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
496            assert!(val["env"].get("DATADOG_API_KEY").is_none());
497            assert!(val["env"].get("DATADOG_APP_KEY").is_none());
498            assert!(val["env"].get("DATADOG_SITE").is_none());
499            assert_eq!(val["env"]["OTHER_KEY"], "keep");
500        }
501
502        // ── Part 4: remove returns false when nothing to remove ────
503        {
504            let temp_dir = {
505                std::fs::create_dir_all("tmp").ok();
506                tempfile::TempDir::new_in("tmp").unwrap()
507            };
508            let settings_path = temp_dir.path().join(".omni-dev").join("settings.json");
509            let removed = remove_credentials_at(&settings_path, None).unwrap();
510            assert!(!removed);
511        }
512    }
513
514    /// Save + remove round-trip against a profile-targeted env map — the
515    /// credentials land under `profiles.<name>.env` where the profile-aware
516    /// read side will find them, and the base `env` is untouched
517    /// (issue #1116).
518    #[test]
519    fn save_then_remove_round_trip_in_profile() {
520        let temp_dir = {
521            std::fs::create_dir_all("tmp").ok();
522            tempfile::TempDir::new_in("tmp").unwrap()
523        };
524        let omni_dir = temp_dir.path().join(".omni-dev");
525        fs::create_dir_all(&omni_dir).unwrap();
526        let settings_path = omni_dir.join("settings.json");
527        fs::write(&settings_path, r#"{"env": {"OTHER_KEY": "keep_me"}}"#).unwrap();
528
529        let creds = DatadogCredentials {
530            api_key: "api-p".into(),
531            app_key: "app-p".into(),
532            site: "datadoghq.eu".to_string(),
533        };
534        save_credentials_to(&settings_path, Some("work"), &creds).unwrap();
535
536        let val: serde_json::Value =
537            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
538        assert_eq!(val["profiles"]["work"]["env"]["DATADOG_API_KEY"], "api-p");
539        assert_eq!(
540            val["profiles"]["work"]["env"]["DATADOG_SITE"],
541            "datadoghq.eu"
542        );
543        assert!(val["env"].get("DATADOG_API_KEY").is_none());
544        assert_eq!(val["env"]["OTHER_KEY"], "keep_me");
545
546        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
547        assert!(removed);
548        let val: serde_json::Value =
549            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
550        assert!(val["profiles"]["work"]["env"]
551            .get("DATADOG_API_KEY")
552            .is_none());
553
554        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
555        assert!(!removed);
556    }
557
558    /// The production wrappers resolve `~/.omni-dev/settings.json` from
559    /// `HOME` and the active profile from `OMNI_DEV_PROFILE`, so this one
560    /// test must redirect both (under the shared
561    /// [`crate::atlassian::auth::test_util::EnvGuard`], which serialises all
562    /// env-mutating auth tests). Every other save/remove test injects a path
563    /// and profile instead (issue #1030).
564    #[test]
565    fn save_and_remove_credentials_resolve_default_settings_path() {
566        let guard = crate::atlassian::auth::test_util::EnvGuard::take();
567        let dir = guard.clear_credentials();
568
569        let creds = DatadogCredentials {
570            api_key: "wrapper-api".into(),
571            app_key: "wrapper-app".into(),
572            site: "datadoghq.com".to_string(),
573        };
574        save_credentials(&creds).unwrap();
575
576        let settings_path = dir.path().join(".omni-dev").join("settings.json");
577        let val: serde_json::Value =
578            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
579        assert_eq!(val["env"]["DATADOG_API_KEY"], "wrapper-api");
580
581        assert!(remove_credentials().unwrap());
582        let val: serde_json::Value =
583            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
584        assert!(val["env"].get("DATADOG_API_KEY").is_none());
585    }
586
587    /// A profile-targeted logout must not clear base-`env` credentials
588    /// belonging to the default bundle (issue #1116).
589    #[test]
590    fn remove_credentials_at_profile_ignores_base_keys() {
591        let temp_dir = {
592            std::fs::create_dir_all("tmp").ok();
593            tempfile::TempDir::new_in("tmp").unwrap()
594        };
595        let omni_dir = temp_dir.path().join(".omni-dev");
596        fs::create_dir_all(&omni_dir).unwrap();
597        let settings_path = omni_dir.join("settings.json");
598        fs::write(
599            &settings_path,
600            r#"{"env": {"DATADOG_API_KEY": "base-a", "DATADOG_APP_KEY": "base-b"}}"#,
601        )
602        .unwrap();
603
604        let removed = remove_credentials_at(&settings_path, Some("work")).unwrap();
605        assert!(!removed);
606
607        let val: serde_json::Value =
608            serde_json::from_str(&fs::read_to_string(&settings_path).unwrap()).unwrap();
609        assert_eq!(val["env"]["DATADOG_API_KEY"], "base-a");
610        assert_eq!(val["env"]["DATADOG_APP_KEY"], "base-b");
611    }
612}