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