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