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