Skip to main content

dsp_cli/actions/auth/
status.rs

1//! Actions for `dsp auth status`.
2//!
3//! Reads the auth cache and reports whether a token is present and whether it
4//! has expired. No network call is made — this is a pure cache inspection.
5
6use std::path::Path;
7
8use chrono::Utc;
9
10use crate::actions::auth_state::read_auth_state;
11use crate::cli::StatusArgs;
12use crate::config::{AuthCache, Config, TokenOrigin, resolve_token};
13use crate::diagnostic::Diagnostic;
14use crate::render::auth::AuthStatusOutcome;
15use crate::render::{MetaContext, Renderer};
16
17/// Show authentication status for a DSP server.
18///
19/// Loads the auth cache, looks up the server, and renders the appropriate
20/// outcome. Always returns `Ok(())` — missing/expired tokens are not errors.
21pub fn run(args: &StatusArgs, cfg: &Config, renderer: &mut dyn Renderer) -> Result<(), Diagnostic> {
22    let env_token = std::env::var("DSP_TOKEN").ok();
23    run_impl(args, cfg, renderer, None, env_token)
24}
25
26fn run_impl(
27    _args: &StatusArgs,
28    cfg: &Config,
29    renderer: &mut dyn Renderer,
30    cache_path: Option<&Path>,
31    env_token: Option<String>,
32) -> Result<(), Diagnostic> {
33    // dsp-cli/ADR-0007 says a non-blank `DSP_TOKEN` wins regardless of cache state.
34    // A corrupt or unreadable `auth.toml` therefore must not mask the env
35    // token: treat a cache-load failure as an empty cache when the env token
36    // would resolve. (Matches the trim-and-empty rule in `resolve_token`.)
37    let env_token_would_win = env_token.as_deref().map(str::trim).map(|s| !s.is_empty()).unwrap_or(false);
38
39    let cache_result = match cache_path {
40        Some(p) => AuthCache::load_from(p),
41        None => AuthCache::load(),
42    };
43    let cache = match cache_result {
44        Ok(c) => c,
45        Err(e) if env_token_would_win => {
46            crate::util::warn_auth_cache_load_failed(&e, "DSP_TOKEN is set, falling through to env token");
47            AuthCache::default()
48        }
49        Err(e) => return Err(e),
50    };
51
52    let (outcome, resolved_opt) = match resolve_token(env_token, &cache, &cfg.server) {
53        Some(resolved) if resolved.origin == TokenOrigin::Env => {
54            // do not log/format this binding — it is the raw bearer secret
55            let token = resolved.token.clone();
56            let now = Utc::now();
57            // extract_exp is display-only (status makes no HTTP call) and never
58            // gates access. It reads the JWT `exp` claim without validating the
59            // signature, identical to the login path's cache-store behaviour.
60            let expires_at = crate::client::jwt::extract_exp(&token);
61            let expired = expires_at.map(|t| t < now).unwrap_or(false);
62            (
63                AuthStatusOutcome::AuthenticatedViaEnv { server: cfg.server.clone(), expires_at, expired },
64                Some(resolved),
65            )
66        }
67        Some(resolved) => {
68            // Cache origin — read user/expires_at from cache directly (the
69            // resolver only confirms origin; the full entry still comes from cache).
70            let user = cache.user(&cfg.server).map(str::to_owned);
71            let expires_at = cache.expires_at(&cfg.server);
72            let expired = expires_at.map(|t| t < Utc::now()).unwrap_or(false);
73            (
74                AuthStatusOutcome::LoggedIn { server: cfg.server.clone(), user, expires_at, expired },
75                Some(resolved),
76            )
77        }
78        None => (AuthStatusOutcome::NotLoggedIn { server: cfg.server.clone() }, None),
79    };
80
81    // _meta.auth uses presence/origin semantics (dsp-cli/ADR-0007 uniform vocabulary),
82    // independent of expiry. This is the same as the read commands: an expired
83    // cached token still reports "authenticated as {user}" in _meta.auth.
84    // The richer expiry/not-logged-in detail lives in the data output above.
85    let auth_state = read_auth_state(resolved_opt.as_ref(), &cache, &cfg.server);
86
87    let meta = MetaContext {
88        server_label: cfg.server.clone(),
89        auth_state,
90        filter_warning: None,
91        count_caveat: None,
92        count_cost: None,
93    };
94
95    renderer.auth_status(&outcome, &meta)?;
96    Ok(())
97}
98
99#[cfg(test)]
100mod tests {
101    use std::sync::{Arc, Mutex};
102
103    use chrono::{TimeZone, Utc};
104    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
105    use tempfile::TempDir;
106
107    use super::run_impl;
108    use crate::cli::{FormatArgs, StatusArgs};
109    use crate::config::auth_cache::ServerEntry;
110    use crate::config::{AuthCache, Config};
111    use crate::diagnostic::Diagnostic;
112    use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
113    use crate::render::{Format, MetaContext, Renderer};
114
115    // ── recording renderer ────────────────────────────────────────────────────
116
117    struct LoggedInRecord {
118        server: String,
119        user: Option<String>,
120        expires_at: Option<chrono::DateTime<Utc>>,
121        expired: bool,
122    }
123
124    struct EnvRecord {
125        server: String,
126        expires_at: Option<chrono::DateTime<Utc>>,
127        expired: bool,
128    }
129
130    struct RecordingRenderer {
131        status_outcome: Option<LoggedInRecord>,
132        env_outcome: Option<EnvRecord>,
133        not_logged_in: Option<String>,
134        last_auth_state: Option<String>,
135    }
136
137    impl RecordingRenderer {
138        fn new() -> Self {
139            Self {
140                status_outcome: None,
141                env_outcome: None,
142                not_logged_in: None,
143                last_auth_state: None,
144            }
145        }
146    }
147
148    impl Renderer for RecordingRenderer {
149        fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
150            Ok(())
151        }
152
153        fn auth_login(&mut self, _outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
154            Ok(())
155        }
156
157        fn auth_status(&mut self, outcome: &AuthStatusOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
158            self.last_auth_state = Some(meta.auth_state.clone());
159            match outcome {
160                AuthStatusOutcome::LoggedIn { server, user, expires_at, expired } => {
161                    self.status_outcome = Some(LoggedInRecord {
162                        server: server.clone(),
163                        user: user.clone(),
164                        expires_at: *expires_at,
165                        expired: *expired,
166                    });
167                }
168                AuthStatusOutcome::AuthenticatedViaEnv { server, expires_at, expired } => {
169                    self.env_outcome = Some(EnvRecord {
170                        server: server.clone(),
171                        expires_at: *expires_at,
172                        expired: *expired,
173                    });
174                }
175                AuthStatusOutcome::NotLoggedIn { server } => {
176                    self.not_logged_in = Some(server.clone());
177                }
178            }
179            Ok(())
180        }
181
182        fn auth_logout(&mut self, _outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
183            Ok(())
184        }
185
186        fn auth_set_token(&mut self, _outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
187            Ok(())
188        }
189
190        fn project_dump(
191            &mut self,
192            _outcome: &crate::render::DumpOutcome,
193            _meta: &MetaContext,
194        ) -> Result<(), Diagnostic> {
195            Ok(())
196        }
197
198        fn project_dump_deleted(
199            &mut self,
200            _outcome: &crate::render::DumpDeleteOutcome,
201            _meta: &MetaContext,
202        ) -> Result<(), Diagnostic> {
203            Ok(())
204        }
205
206        fn projects(&mut self, _view: &crate::render::ProjectListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
207            Ok(())
208        }
209
210        fn project_describe(
211            &mut self,
212            _project: &crate::model::ProjectDetail,
213            _meta: &MetaContext,
214        ) -> Result<(), Diagnostic> {
215            Ok(())
216        }
217
218        fn data_models(
219            &mut self,
220            _view: &crate::render::DataModelListView,
221            _meta: &MetaContext,
222        ) -> Result<(), Diagnostic> {
223            Ok(())
224        }
225
226        fn data_model_describe(
227            &mut self,
228            _detail: &crate::model::DataModelDetail,
229            _meta: &MetaContext,
230        ) -> Result<(), Diagnostic> {
231            Ok(())
232        }
233
234        fn resource_types(
235            &mut self,
236            _view: &crate::render::ResourceTypeListView,
237            _meta: &MetaContext,
238        ) -> Result<(), Diagnostic> {
239            Ok(())
240        }
241
242        fn resource_type_describe(
243            &mut self,
244            _detail: &crate::model::ResourceTypeDetail,
245            _meta: &MetaContext,
246        ) -> Result<(), Diagnostic> {
247            unimplemented!("resource_type_describe not used in status tests")
248        }
249
250        fn data_model_structure(
251            &mut self,
252            _structure: &crate::model::DataModelStructure,
253            _meta: &MetaContext,
254        ) -> Result<(), Diagnostic> {
255            unimplemented!("data_model_structure not used in status tests")
256        }
257
258        fn resources(
259            &mut self,
260            _view: &crate::render::ResourceListView,
261            _meta: &MetaContext,
262        ) -> Result<(), Diagnostic> {
263            Ok(())
264        }
265
266        fn resource_describe(
267            &mut self,
268            _detail: &crate::model::ResourceDetail,
269            _meta: &MetaContext,
270        ) -> Result<(), Diagnostic> {
271            Ok(())
272        }
273
274        fn vocabularies(
275            &mut self,
276            _view: &crate::render::VocabularyListView,
277            _meta: &MetaContext,
278        ) -> Result<(), Diagnostic> {
279            unimplemented!("not exercised by this file's tests")
280        }
281
282        fn vocabulary_describe(
283            &mut self,
284            _detail: &crate::model::VocabularyDetail,
285            _meta: &MetaContext,
286        ) -> Result<(), Diagnostic> {
287            unimplemented!("not exercised by this file's tests")
288        }
289    }
290
291    // ── helpers ───────────────────────────────────────────────────────────────
292
293    fn make_args(server: &str) -> (StatusArgs, Config) {
294        let args = StatusArgs {
295            server: Some(server.to_string()),
296            format: FormatArgs {
297                format: Format::Prose,
298                json: false,
299                lines: false,
300                columns: None,
301                no_header: false,
302                header_only: false,
303            },
304        };
305        let cfg = Config { server: server.to_string() };
306        (args, cfg)
307    }
308
309    fn fixed_future() -> chrono::DateTime<Utc> {
310        Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap()
311    }
312
313    fn fixed_past() -> chrono::DateTime<Utc> {
314        Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
315    }
316
317    /// Produce a minimal JWT with the given JSON payload.
318    /// The secret is arbitrary — `extract_exp` disables signature validation.
319    fn make_jwt(payload: &serde_json::Value) -> String {
320        encode(&Header::new(Algorithm::HS256), payload, &EncodingKey::from_secret(b"unused"))
321            .expect("test JWT encoding should not fail")
322    }
323
324    fn make_jwt_with_exp(exp_ts: i64) -> String {
325        make_jwt(&serde_json::json!({ "exp": exp_ts }))
326    }
327
328    // ── existing tests (regression guard) ────────────────────────────────────
329
330    #[test]
331    fn logged_in_entry_produces_logged_in_outcome() {
332        let dir = TempDir::new().unwrap();
333        let cache_path = dir.path().join("auth.toml");
334        let (args, cfg) = make_args("https://api.test.dasch.swiss");
335
336        let mut cache = AuthCache::default();
337        cache.set_entry(
338            "https://api.test.dasch.swiss",
339            ServerEntry {
340                token: "tok-123".to_string(),
341                user: Some("u@x.test".to_string()),
342                acquired_at: None,
343                expires_at: Some(fixed_future()),
344            },
345        );
346        cache.save_to(&cache_path).unwrap();
347
348        let mut renderer = RecordingRenderer::new();
349        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
350
351        let rec = renderer.status_outcome.unwrap();
352        assert_eq!(rec.server, "https://api.test.dasch.swiss");
353        assert_eq!(rec.user.as_deref(), Some("u@x.test"));
354        assert_eq!(rec.expires_at, Some(fixed_future()));
355        assert!(!rec.expired, "token with future expiry should not be expired");
356    }
357
358    #[test]
359    fn empty_cache_produces_not_logged_in_outcome() {
360        let dir = TempDir::new().unwrap();
361        let cache_path = dir.path().join("auth.toml");
362        let (args, cfg) = make_args("https://api.test.dasch.swiss");
363
364        let mut renderer = RecordingRenderer::new();
365        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
366
367        assert!(renderer.status_outcome.is_none(), "expected no LoggedIn outcome");
368        assert_eq!(renderer.not_logged_in.as_deref(), Some("https://api.test.dasch.swiss"));
369    }
370
371    #[test]
372    fn expired_entry_produces_logged_in_with_expired_true() {
373        let dir = TempDir::new().unwrap();
374        let cache_path = dir.path().join("auth.toml");
375        let (args, cfg) = make_args("https://api.test.dasch.swiss");
376
377        let mut cache = AuthCache::default();
378        cache.set_entry(
379            "https://api.test.dasch.swiss",
380            ServerEntry {
381                token: "old-tok".to_string(),
382                user: Some("u@x.test".to_string()),
383                acquired_at: None,
384                expires_at: Some(fixed_past()),
385            },
386        );
387        cache.save_to(&cache_path).unwrap();
388
389        let mut renderer = RecordingRenderer::new();
390        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
391
392        let rec = renderer.status_outcome.unwrap();
393        assert!(rec.expired, "token with past expiry should be expired");
394    }
395
396    #[test]
397    fn run_always_returns_ok_even_for_not_logged_in() {
398        let dir = TempDir::new().unwrap();
399        let cache_path = dir.path().join("auth.toml");
400        let (args, cfg) = make_args("https://api.test.dasch.swiss");
401
402        let mut renderer = RecordingRenderer::new();
403        let result = run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None);
404        assert!(result.is_ok(), "status should always return Ok; got {result:?}");
405    }
406
407    // ── env-token tests ───────────────────────────────────────────────────────
408
409    #[test]
410    fn env_jwt_future_exp_produces_authenticated_via_env_not_expired() {
411        let dir = TempDir::new().unwrap();
412        let cache_path = dir.path().join("auth.toml");
413        let (args, cfg) = make_args("https://api.test.dasch.swiss");
414
415        // far-future exp: year 2099 ≈ Unix 4070908800
416        let exp_ts = fixed_future().timestamp();
417        let token = make_jwt_with_exp(exp_ts);
418
419        let mut renderer = RecordingRenderer::new();
420        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(token)).unwrap();
421
422        let rec = renderer.env_outcome.expect("expected AuthenticatedViaEnv outcome");
423        assert_eq!(rec.server, "https://api.test.dasch.swiss");
424        assert!(rec.expires_at.is_some(), "expected Some(expires_at)");
425        assert!(!rec.expired, "future exp should not be expired");
426        assert_eq!(
427            renderer.last_auth_state.as_deref(),
428            Some("authenticated via DSP_TOKEN"),
429            "_meta.auth must use dsp-cli/ADR-0007 presence/origin vocabulary (not expiry-aware)"
430        );
431    }
432
433    #[test]
434    fn env_jwt_past_exp_produces_authenticated_via_env_expired() {
435        let dir = TempDir::new().unwrap();
436        let cache_path = dir.path().join("auth.toml");
437        let (args, cfg) = make_args("https://api.test.dasch.swiss");
438
439        let exp_ts = fixed_past().timestamp();
440        let token = make_jwt_with_exp(exp_ts);
441
442        let mut renderer = RecordingRenderer::new();
443        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(token)).unwrap();
444
445        let rec = renderer.env_outcome.expect("expected AuthenticatedViaEnv outcome");
446        assert!(rec.expired, "past exp should be expired");
447        // _meta.auth uses presence/origin semantics — expired env token still
448        // reports "authenticated via DSP_TOKEN", not an expiry string.
449        assert_eq!(
450            renderer.last_auth_state.as_deref(),
451            Some("authenticated via DSP_TOKEN"),
452            "_meta.auth for expired env token must be 'authenticated via DSP_TOKEN'"
453        );
454        // The data output (AuthenticatedViaEnv.expired) correctly reflects the expiry.
455        assert!(rec.expired, "data output must still report expired==true");
456    }
457
458    #[test]
459    fn env_non_jwt_produces_authenticated_via_env_expiry_unknown() {
460        let dir = TempDir::new().unwrap();
461        let cache_path = dir.path().join("auth.toml");
462        let (args, cfg) = make_args("https://api.test.dasch.swiss");
463
464        let mut renderer = RecordingRenderer::new();
465        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some("not-a-jwt".to_string())).unwrap();
466
467        let rec = renderer.env_outcome.expect("expected AuthenticatedViaEnv outcome");
468        assert!(rec.expires_at.is_none(), "non-JWT env token should have expires_at == None");
469        assert!(!rec.expired, "non-JWT env token should not be expired");
470        assert_eq!(
471            renderer.last_auth_state.as_deref(),
472            Some("authenticated via DSP_TOKEN"),
473            "_meta.auth for non-JWT env token must be 'authenticated via DSP_TOKEN'"
474        );
475    }
476
477    #[test]
478    fn env_token_wins_over_valid_cache_entry() {
479        let dir = TempDir::new().unwrap();
480        let cache_path = dir.path().join("auth.toml");
481        let (args, cfg) = make_args("https://api.test.dasch.swiss");
482
483        // Populate cache with a valid entry for the same server.
484        let mut cache = AuthCache::default();
485        cache.set_entry(
486            "https://api.test.dasch.swiss",
487            ServerEntry {
488                token: "cache-tok".to_string(),
489                user: Some("u@cache.test".to_string()),
490                acquired_at: None,
491                expires_at: Some(fixed_future()),
492            },
493        );
494        cache.save_to(&cache_path).unwrap();
495
496        let exp_ts = fixed_future().timestamp();
497        let env_token = make_jwt_with_exp(exp_ts);
498
499        let mut renderer = RecordingRenderer::new();
500        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(env_token)).unwrap();
501
502        // Env wins: must be AuthenticatedViaEnv, not LoggedIn.
503        assert!(renderer.env_outcome.is_some(), "env token should win over valid cache entry");
504        assert!(
505            renderer.status_outcome.is_none(),
506            "LoggedIn should not be produced when env token is present"
507        );
508        assert_eq!(
509            renderer.last_auth_state.as_deref(),
510            Some("authenticated via DSP_TOKEN"),
511            "env win over valid cache should report 'authenticated via DSP_TOKEN'"
512        );
513    }
514
515    #[test]
516    fn env_token_wins_over_expired_cache_entry() {
517        let dir = TempDir::new().unwrap();
518        let cache_path = dir.path().join("auth.toml");
519        let (args, cfg) = make_args("https://api.test.dasch.swiss");
520
521        // Populate cache with an expired entry.
522        let mut cache = AuthCache::default();
523        cache.set_entry(
524            "https://api.test.dasch.swiss",
525            ServerEntry {
526                token: "old-cache-tok".to_string(),
527                user: Some("u@cache.test".to_string()),
528                acquired_at: None,
529                expires_at: Some(fixed_past()),
530            },
531        );
532        cache.save_to(&cache_path).unwrap();
533
534        let exp_ts = fixed_future().timestamp();
535        let env_token = make_jwt_with_exp(exp_ts);
536
537        let mut renderer = RecordingRenderer::new();
538        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(env_token)).unwrap();
539
540        assert!(renderer.env_outcome.is_some(), "env token should win over expired cache entry");
541        assert!(
542            renderer.status_outcome.is_none(),
543            "LoggedIn should not be produced when env token is present"
544        );
545        assert_eq!(
546            renderer.last_auth_state.as_deref(),
547            Some("authenticated via DSP_TOKEN"),
548            "env win over expired cache should report 'authenticated via DSP_TOKEN'"
549        );
550    }
551
552    #[test]
553    fn whitespace_only_env_with_expired_cache_falls_through_to_cache() {
554        let dir = TempDir::new().unwrap();
555        let cache_path = dir.path().join("auth.toml");
556        let (args, cfg) = make_args("https://api.test.dasch.swiss");
557
558        let mut cache = AuthCache::default();
559        cache.set_entry(
560            "https://api.test.dasch.swiss",
561            ServerEntry {
562                token: "old-tok".to_string(),
563                user: Some("u@x.test".to_string()),
564                acquired_at: None,
565                expires_at: Some(fixed_past()),
566            },
567        );
568        cache.save_to(&cache_path).unwrap();
569
570        let mut renderer = RecordingRenderer::new();
571        run_impl(
572            &args,
573            &cfg,
574            &mut renderer,
575            Some(&cache_path),
576            Some("  ".to_string()), // whitespace-only → treated as absent
577        )
578        .unwrap();
579
580        // Falls through to cache: must be LoggedIn with expired==true.
581        let rec = renderer
582            .status_outcome
583            .expect("expected LoggedIn outcome from cache fall-through");
584        assert!(rec.expired, "cache entry has past expiry; expired should be true");
585        assert!(
586            renderer.env_outcome.is_none(),
587            "whitespace env should not produce AuthenticatedViaEnv"
588        );
589        // _meta.auth uses presence/origin semantics — expired cached token with
590        // a known user still reports "authenticated as {user}", not an expiry string.
591        // The expiry detail lives in the data output (LoggedIn.expired == true).
592        assert_eq!(
593            renderer.last_auth_state.as_deref(),
594            Some("authenticated as u@x.test"),
595            "_meta.auth for expired cache token must be 'authenticated as <user>'"
596        );
597    }
598
599    #[test]
600    fn env_token_wins_when_cache_is_corrupt() {
601        // dsp-cli/ADR-0007: DSP_TOKEN wins regardless of cache state. A corrupt
602        // auth.toml must not mask the env token in `dsp auth status`.
603        let dir = TempDir::new().unwrap();
604        let cache_path = dir.path().join("auth.toml");
605        std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
606
607        let (args, cfg) = make_args("https://api.test.dasch.swiss");
608        let exp_ts = fixed_future().timestamp();
609        let env_token = make_jwt_with_exp(exp_ts);
610
611        let mut renderer = RecordingRenderer::new();
612        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(env_token)).unwrap();
613
614        assert!(renderer.env_outcome.is_some(), "env token should win over corrupt cache");
615        assert!(
616            renderer.status_outcome.is_none(),
617            "LoggedIn should not be produced when env token is present"
618        );
619        assert_eq!(renderer.last_auth_state.as_deref(), Some("authenticated via DSP_TOKEN"),);
620    }
621
622    /// `tracing_subscriber::fmt::MakeWriter` sink that appends everything written
623    /// to a shared buffer, so a test can inspect exactly what a scoped subscriber
624    /// would have written to stderr.
625    #[derive(Clone)]
626    struct CapturingWriter(Arc<Mutex<Vec<u8>>>);
627
628    impl std::io::Write for CapturingWriter {
629        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
630            self.0.lock().unwrap().extend_from_slice(buf);
631            Ok(buf.len())
632        }
633
634        fn flush(&mut self) -> std::io::Result<()> {
635            Ok(())
636        }
637    }
638
639    #[test]
640    fn corrupt_cache_does_not_leak_token_bytes_at_default_verbosity() {
641        // Regression guard: a `toml` parse error quotes the offending source
642        // line. If the load-failure log includes that raw error at `warn`
643        // (`init_tracing`'s default level, i.e. verbosity 0), a cache file
644        // truncated mid-write inside a token line would echo token bytes to
645        // stderr. The fix moves the raw error to `debug` and keeps the `warn`
646        // line body-free.
647        const FAKE_TOKEN: &str = "eyFAKE.TOKEN.LEAK-DO-NOT-SHOW-1234567890";
648
649        let dir = TempDir::new().unwrap();
650        let cache_path = dir.path().join("auth.toml");
651        // Missing closing quote: a realistic "write cut off mid-token" corruption
652        // whose parse error necessarily points at (and quotes) this exact line.
653        std::fs::write(
654            &cache_path,
655            format!("[\"https://api.test.dasch.swiss\"]\ntoken = \"{FAKE_TOKEN}\n"),
656        )
657        .unwrap();
658
659        let (args, cfg) = make_args("https://api.test.dasch.swiss");
660        let exp_ts = fixed_future().timestamp();
661        let env_token = make_jwt_with_exp(exp_ts);
662
663        let captured = Arc::new(Mutex::new(Vec::<u8>::new()));
664        let writer = CapturingWriter(captured.clone());
665        // Mirrors `init_tracing(0)` (dsp-cli/ADR-0012: default verbosity is WARN),
666        // scoped to this thread only so it cannot affect other tests.
667        let subscriber = tracing_subscriber::fmt()
668            .with_max_level(tracing::Level::WARN)
669            .with_writer(move || writer.clone())
670            .finish();
671
672        let mut renderer = RecordingRenderer::new();
673        tracing::subscriber::with_default(subscriber, || {
674            run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(env_token)).unwrap();
675        });
676
677        let logged = String::from_utf8(captured.lock().unwrap().clone()).unwrap();
678        assert!(
679            !logged.contains(FAKE_TOKEN),
680            "warn-level log leaked token bytes from the toml parse error: {logged}"
681        );
682        assert!(
683            logged.contains("auth cache"),
684            "expected a body-free warning that the auth cache could not be read; got: {logged}"
685        );
686    }
687
688    #[test]
689    fn corrupt_cache_without_env_token_still_errors() {
690        // Regression guard for the other side of the env-wins fix: when no env
691        // token is set, a corrupt cache must still propagate the error rather
692        // than silently producing `not_logged_in`. The user needs to know the
693        // cache is broken so they can fix or delete it.
694        let dir = TempDir::new().unwrap();
695        let cache_path = dir.path().join("auth.toml");
696        std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
697
698        let (args, cfg) = make_args("https://api.test.dasch.swiss");
699        let mut renderer = RecordingRenderer::new();
700        let result = run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None);
701        assert!(
702            result.is_err(),
703            "without env token, a corrupt cache should propagate; got {result:?}"
704        );
705    }
706
707    #[test]
708    fn whitespace_only_env_with_corrupt_cache_still_errors() {
709        // Mirrors the cache-fall-through whitespace test: a blank env token
710        // must not be enough to swallow the cache-load error. The trim rule
711        // here matches `resolve_token`'s blank-handling.
712        let dir = TempDir::new().unwrap();
713        let cache_path = dir.path().join("auth.toml");
714        std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
715
716        let (args, cfg) = make_args("https://api.test.dasch.swiss");
717        let mut renderer = RecordingRenderer::new();
718        let result = run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some("  ".to_string()));
719        assert!(
720            result.is_err(),
721            "whitespace env token should not swallow corrupt-cache error; got {result:?}"
722        );
723    }
724
725    #[test]
726    fn absent_env_with_cache_entry_produces_logged_in() {
727        let dir = TempDir::new().unwrap();
728        let cache_path = dir.path().join("auth.toml");
729        let (args, cfg) = make_args("https://api.test.dasch.swiss");
730
731        let mut cache = AuthCache::default();
732        cache.set_entry(
733            "https://api.test.dasch.swiss",
734            ServerEntry {
735                token: "tok-abc".to_string(),
736                user: Some("u@x.test".to_string()),
737                acquired_at: None,
738                expires_at: Some(fixed_future()),
739            },
740        );
741        cache.save_to(&cache_path).unwrap();
742
743        let mut renderer = RecordingRenderer::new();
744        run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
745
746        assert!(
747            renderer.status_outcome.is_some(),
748            "absent env + cache entry should produce LoggedIn"
749        );
750        assert!(
751            renderer.env_outcome.is_none(),
752            "absent env should not produce AuthenticatedViaEnv"
753        );
754    }
755}