Skip to main content

dsp_cli/actions/auth/
set_token.rs

1//! Actions for `dsp auth set-token`.
2//!
3//! Reads a pre-issued JWT from stdin, verifies it against the server with a
4//! live probe, and — only if the probe succeeds — writes it into the auth
5//! cache. Subsequent commands then reuse the token until it expires.
6//!
7//! See plan 008 for the full validation flow and design decisions.
8
9use std::path::Path;
10
11use chrono::Utc;
12
13use crate::actions::auth_state::read_auth_state;
14use crate::client::DspClient;
15use crate::config::auth_cache::ServerEntry;
16use crate::config::{AuthCache, Config, ResolvedToken, TokenOrigin};
17use crate::diagnostic::Diagnostic;
18use crate::render::auth::AuthSetTokenOutcome;
19use crate::render::{MetaContext, Renderer};
20
21/// Cache a pre-issued bearer token read from stdin.
22///
23/// Reads a JWT from stdin, then delegates to [`run_from_line`] for the
24/// trim→empty-guard→decode→probe→cache→render flow.
25pub fn run(
26    cfg: &Config,
27    client: &dyn DspClient,
28    renderer: &mut dyn Renderer,
29) -> Result<(), Diagnostic> {
30    let mut line = String::new();
31    // `read_line` returns `Ok(0)` on EOF (not an error); the empty-guard in
32    // `run_from_line` handles that case. A real I/O failure (broken pipe, etc.)
33    // is a bad invocation rather than a CLI bug, so we surface it as Usage
34    // rather than Internal — consistent with login.rs's stdin error handling.
35    std::io::stdin()
36        .read_line(&mut line)
37        .map_err(|e| Diagnostic::Usage(format!("could not read token from stdin: {e}")))?;
38    run_from_line(line, cfg, client, renderer, None)
39}
40
41/// Trim, guard empty, then decode→probe→cache→render.
42///
43/// Extracted as a plain-function seam so tests can exercise the empty-guard
44/// and newline-trim path without touching stdin. Production callers use `run`;
45/// tests call this directly with an injected string and a tempdir cache path.
46fn run_from_line(
47    mut line: String,
48    cfg: &Config,
49    client: &dyn DspClient,
50    renderer: &mut dyn Renderer,
51    cache_path: Option<&Path>,
52) -> Result<(), Diagnostic> {
53    // Strip a single trailing line terminator first (shared with the password
54    // path; see `crate::actions::auth::trim_line_ending`).
55    crate::actions::auth::trim_line_ending(&mut line);
56
57    // Then trim all surrounding whitespace for both the empty-guard and the
58    // value forwarded onward. A JWT never contains surrounding whitespace, so
59    // this is safe and keeps the guard and the cached token symmetric (a pasted
60    // "  <jwt>  " decodes/caches cleanly rather than failing as "not a valid
61    // JWT"). Unlike `trim_line_ending`, `trim()` also eats a bare surrounding
62    // `\r` — fine here, since the forwarded token must not carry whitespace.
63    let token = line.trim();
64    if token.is_empty() {
65        return Err(Diagnostic::Usage("no token provided on stdin".to_string()));
66    }
67
68    run_impl(token, cfg, client, renderer, cache_path)
69}
70
71/// Internal entry point that accepts an explicit cache path (for tests).
72///
73/// Production callers use `run`; tests call this directly to inject a
74/// tempdir-backed cache path and a pre-built token string.
75///
76/// # Security note
77///
78/// The `token` parameter is a bearer secret and must **never** be logged,
79/// printed to stderr, or included in any `tracing::debug!` / `tracing::info!`
80/// call. It is stored into `auth.toml` (which uses a manual `Debug` impl that
81/// redacts the token) and forwarded to `client.verify_token` only.
82fn run_impl(
83    token: &str,
84    cfg: &Config,
85    client: &dyn DspClient,
86    renderer: &mut dyn Renderer,
87    cache_path: Option<&Path>,
88) -> Result<(), Diagnostic> {
89    // 1. Local decode: extract metadata without verifying the signature.
90    //    Failure means the input is not structurally a JWT → Usage error (exit 2).
91    //    We do NOT locally enforce `exp`; a locally-expired token may still pass
92    //    the live probe if the server's clock differs, and the probe is the trust
93    //    boundary in any case.
94    let meta = crate::client::jwt::extract_meta(token)
95        .ok_or_else(|| Diagnostic::Usage("input on stdin is not a valid JWT".to_string()))?;
96
97    // 2. Live probe: verify the token is currently accepted by the server.
98    //    This is the authoritative validity gate — no token is cached before this
99    //    succeeds. 401/403 → AuthRequired (exit 3); other failures propagate.
100    client.verify_token(&cfg.server, token)?;
101
102    // 3. Cache (only reached when probe returned Ok).
103    let entry = ServerEntry {
104        token: token.to_string(),
105        user: meta.sub.clone(),
106        acquired_at: Some(Utc::now()),
107        expires_at: meta.exp,
108    };
109
110    let mut cache = match cache_path {
111        Some(p) => AuthCache::load_from(p)?,
112        None => AuthCache::load()?,
113    };
114    cache.set_entry(&cfg.server, entry);
115    match cache_path {
116        Some(p) => cache.save_to(p)?,
117        None => cache.save()?,
118    }
119
120    // 4. Build MetaContext reflecting the post-set-token auth state using the
121    // shared ADR-0007 helper. The token was just stored in the cache as a
122    // Cache-origin entry with `meta.sub` as the user. Synthesize a Cache-origin
123    // ResolvedToken so `read_auth_state` picks the correct branch.
124    let resolved_for_meta = ResolvedToken {
125        token: token.to_string(),
126        origin: TokenOrigin::Cache,
127    };
128    let meta_ctx = MetaContext {
129        server_label: cfg.server.clone(),
130        auth_state: read_auth_state(Some(&resolved_for_meta), &cache, &cfg.server),
131        filter_warning: None,
132        count_caveat: None,
133        count_cost: None,
134    };
135
136    // 5. Render the outcome.
137    let outcome = AuthSetTokenOutcome {
138        server: cfg.server.clone(),
139        user: meta.sub,
140        expires_at: meta.exp,
141    };
142
143    renderer.auth_set_token(&outcome, &meta_ctx)
144}
145
146#[cfg(test)]
147mod tests {
148    use chrono::{TimeZone, Utc};
149    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
150    use tempfile::TempDir;
151
152    use super::{run_from_line, run_impl};
153    use crate::client::DspClient;
154    use crate::config::auth_cache::ServerEntry;
155    use crate::config::{AuthCache, Config};
156    use crate::diagnostic::Diagnostic;
157    use crate::model::{CreateDumpOutcome, DumpTask, LoginResponse, ProjectRef};
158    use crate::render::auth::{
159        AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
160    };
161    use crate::render::{MetaContext, Renderer};
162
163    // ── local mock client ─────────────────────────────────────────────────────
164
165    struct MockDspClient {
166        verify_token_result: Result<(), Diagnostic>,
167    }
168
169    impl MockDspClient {
170        fn ok() -> Self {
171            Self {
172                verify_token_result: Ok(()),
173            }
174        }
175
176        fn err(diag: Diagnostic) -> Self {
177            Self {
178                verify_token_result: Err(diag),
179            }
180        }
181    }
182
183    impl DspClient for MockDspClient {
184        fn login(
185            &self,
186            _server: &str,
187            _user: &str,
188            _password: &str,
189        ) -> Result<LoginResponse, Diagnostic> {
190            unimplemented!("login not used by set-token tests")
191        }
192
193        fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
194            unimplemented!("resolve_project not used by set-token tests")
195        }
196
197        fn create_project_dump(
198            &self,
199            _server: &str,
200            _project_iri: &str,
201            _skip_assets: bool,
202            _token: &str,
203        ) -> Result<CreateDumpOutcome, Diagnostic> {
204            unimplemented!("create_project_dump not used by set-token tests")
205        }
206
207        fn get_project_dump_status(
208            &self,
209            _server: &str,
210            _project_iri: &str,
211            _dump_id: &str,
212            _token: &str,
213        ) -> Result<DumpTask, Diagnostic> {
214            unimplemented!("get_project_dump_status not used by set-token tests")
215        }
216
217        fn download_project_dump(
218            &self,
219            _server: &str,
220            _project_iri: &str,
221            _dump_id: &str,
222            _token: &str,
223            _dest: &mut dyn std::io::Write,
224        ) -> Result<u64, Diagnostic> {
225            unimplemented!("download_project_dump not used by set-token tests")
226        }
227
228        fn delete_project_dump(
229            &self,
230            _server: &str,
231            _project_iri: &str,
232            _dump_id: &str,
233            _token: &str,
234        ) -> Result<(), Diagnostic> {
235            unimplemented!("delete_project_dump not used by set-token tests")
236        }
237
238        fn describe_project(
239            &self,
240            _server: &str,
241            _project: &str,
242            _token: Option<&str>,
243        ) -> Result<crate::model::ProjectDetail, Diagnostic> {
244            unimplemented!("describe_project not used by set-token tests")
245        }
246
247        fn list_projects(
248            &self,
249            _server: &str,
250            _token: Option<&str>,
251        ) -> Result<Vec<crate::model::Project>, Diagnostic> {
252            Err(Diagnostic::NotImplemented(
253                "list_projects not used in set_token.rs tests".into(),
254            ))
255        }
256
257        fn list_data_models(
258            &self,
259            _server: &str,
260            _project_iri: &str,
261            _token: Option<&str>,
262        ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
263            unimplemented!("list_data_models not used by set-token tests")
264        }
265
266        fn describe_data_model(
267            &self,
268            _server: &str,
269            _data_model_iri: &str,
270            _token: Option<&str>,
271        ) -> Result<crate::model::DataModelDetail, Diagnostic> {
272            unimplemented!("describe_data_model not used in set-token tests")
273        }
274
275        fn describe_resource_type(
276            &self,
277            _server: &str,
278            _data_model_iri: &str,
279            _resource_type: &str,
280            _token: Option<&str>,
281        ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
282            unimplemented!("describe_resource_type not used in set-token tests")
283        }
284
285        fn data_model_structure(
286            &self,
287            _server: &str,
288            _data_model_iri: &str,
289            _token: Option<&str>,
290        ) -> Result<crate::model::DataModelStructure, Diagnostic> {
291            unimplemented!("data_model_structure not used in set-token tests")
292        }
293
294        fn list_resources(
295            &self,
296            _server: &str,
297            _project_iri: &str,
298            _resource_type_iri: &str,
299            _order_by: Option<&str>,
300            _page: u32,
301            _token: Option<&str>,
302        ) -> Result<crate::model::ResourcePage, Diagnostic> {
303            unimplemented!("list_resources not used in set-token tests")
304        }
305
306        fn describe_resource(
307            &self,
308            _server: &str,
309            _resource_iri: &str,
310            _token: Option<&str>,
311            _with_values: bool,
312        ) -> Result<crate::model::ResourceDetail, Diagnostic> {
313            unimplemented!("describe_resource not used in set-token tests")
314        }
315
316        fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
317            self.verify_token_result.clone()
318        }
319
320        fn resource_counts(
321            &self,
322            _server: &str,
323            _project_iri: &str,
324            _token: Option<&str>,
325        ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
326            Ok(std::collections::HashMap::new())
327        }
328
329        fn list_vocabularies(
330            &self,
331            _server: &str,
332            _project_iri: &str,
333            _token: Option<&str>,
334        ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
335            unimplemented!("not exercised by this file's tests")
336        }
337
338        fn describe_vocabulary(
339            &self,
340            _server: &str,
341            _iri: &str,
342            _token: Option<&str>,
343        ) -> Result<crate::model::VocabularyTree, Diagnostic> {
344            unimplemented!("not exercised by this file's tests")
345        }
346
347        fn sparql_query(
348            &self,
349            _server: &str,
350            _token: &str,
351            _query: &str,
352            _accept: &str,
353            _timeout_secs: u64,
354        ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
355            Err(Diagnostic::Internal("not used in this test".into()))
356        }
357    }
358
359    // ── recording renderer ─────────────────────────────────────────────────────
360
361    struct RecordingRenderer {
362        set_token_outcome: Option<AuthSetTokenOutcome>,
363        set_token_auth_state: Option<String>,
364    }
365
366    impl RecordingRenderer {
367        fn new() -> Self {
368            Self {
369                set_token_outcome: None,
370                set_token_auth_state: None,
371            }
372        }
373    }
374
375    impl Renderer for RecordingRenderer {
376        fn diagnostic(
377            &mut self,
378            _diag: &Diagnostic,
379            _meta: &MetaContext,
380        ) -> Result<(), Diagnostic> {
381            Ok(())
382        }
383
384        fn auth_login(
385            &mut self,
386            _outcome: &AuthLoginOutcome,
387            _meta: &MetaContext,
388        ) -> Result<(), Diagnostic> {
389            Ok(())
390        }
391
392        fn auth_status(
393            &mut self,
394            _outcome: &AuthStatusOutcome,
395            _meta: &MetaContext,
396        ) -> Result<(), Diagnostic> {
397            Ok(())
398        }
399
400        fn auth_logout(
401            &mut self,
402            _outcome: &AuthLogoutOutcome,
403            _meta: &MetaContext,
404        ) -> Result<(), Diagnostic> {
405            Ok(())
406        }
407
408        fn auth_set_token(
409            &mut self,
410            outcome: &AuthSetTokenOutcome,
411            meta: &MetaContext,
412        ) -> Result<(), Diagnostic> {
413            self.set_token_outcome = Some(AuthSetTokenOutcome {
414                server: outcome.server.clone(),
415                user: outcome.user.clone(),
416                expires_at: outcome.expires_at,
417            });
418            self.set_token_auth_state = Some(meta.auth_state.clone());
419            Ok(())
420        }
421
422        fn project_dump(
423            &mut self,
424            _outcome: &crate::render::DumpOutcome,
425            _meta: &MetaContext,
426        ) -> Result<(), Diagnostic> {
427            Ok(())
428        }
429
430        fn project_dump_deleted(
431            &mut self,
432            _outcome: &crate::render::DumpDeleteOutcome,
433            _meta: &MetaContext,
434        ) -> Result<(), Diagnostic> {
435            Ok(())
436        }
437
438        fn projects(
439            &mut self,
440            _view: &crate::render::ProjectListView,
441            _meta: &MetaContext,
442        ) -> Result<(), Diagnostic> {
443            Ok(())
444        }
445
446        fn project_describe(
447            &mut self,
448            _project: &crate::model::ProjectDetail,
449            _meta: &MetaContext,
450        ) -> Result<(), Diagnostic> {
451            Ok(())
452        }
453
454        fn data_models(
455            &mut self,
456            _view: &crate::render::DataModelListView,
457            _meta: &MetaContext,
458        ) -> Result<(), Diagnostic> {
459            Ok(())
460        }
461
462        fn data_model_describe(
463            &mut self,
464            _detail: &crate::model::DataModelDetail,
465            _meta: &MetaContext,
466        ) -> Result<(), Diagnostic> {
467            Ok(())
468        }
469
470        fn resource_types(
471            &mut self,
472            _view: &crate::render::ResourceTypeListView,
473            _meta: &MetaContext,
474        ) -> Result<(), Diagnostic> {
475            Ok(())
476        }
477
478        fn resource_type_describe(
479            &mut self,
480            _detail: &crate::model::ResourceTypeDetail,
481            _meta: &MetaContext,
482        ) -> Result<(), Diagnostic> {
483            unimplemented!("resource_type_describe not used in set-token tests")
484        }
485
486        fn data_model_structure(
487            &mut self,
488            _structure: &crate::model::DataModelStructure,
489            _meta: &MetaContext,
490        ) -> Result<(), Diagnostic> {
491            unimplemented!("data_model_structure not used in set-token tests")
492        }
493
494        fn resources(
495            &mut self,
496            _view: &crate::render::ResourceListView,
497            _meta: &MetaContext,
498        ) -> Result<(), Diagnostic> {
499            Ok(())
500        }
501
502        fn resource_describe(
503            &mut self,
504            _detail: &crate::model::ResourceDetail,
505            _meta: &MetaContext,
506        ) -> Result<(), Diagnostic> {
507            Ok(())
508        }
509
510        fn vocabularies(
511            &mut self,
512            _view: &crate::render::VocabularyListView,
513            _meta: &MetaContext,
514        ) -> Result<(), Diagnostic> {
515            unimplemented!("not exercised by this file's tests")
516        }
517
518        fn vocabulary_describe(
519            &mut self,
520            _detail: &crate::model::VocabularyDetail,
521            _meta: &MetaContext,
522        ) -> Result<(), Diagnostic> {
523            unimplemented!("not exercised by this file's tests")
524        }
525    }
526
527    // ── helpers ────────────────────────────────────────────────────────────────
528
529    /// Produce a minimal JWT with the given JSON payload.
530    /// The secret is arbitrary — `extract_meta` disables signature validation.
531    fn make_jwt(payload: &serde_json::Value) -> String {
532        encode(
533            &Header::new(Algorithm::HS256),
534            payload,
535            &EncodingKey::from_secret(b"unused"),
536        )
537        .expect("test JWT encoding should not fail")
538    }
539
540    fn fixed_expires() -> chrono::DateTime<Utc> {
541        Utc.with_ymd_and_hms(2099, 6, 25, 12, 34, 56).unwrap()
542    }
543
544    fn fixed_past_expires() -> chrono::DateTime<Utc> {
545        Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
546    }
547
548    fn make_cfg(server: &str) -> Config {
549        Config {
550            server: server.to_string(),
551        }
552    }
553
554    const SERVER: &str = "https://api.test.dasch.swiss";
555
556    // ── tests ──────────────────────────────────────────────────────────────────
557
558    #[test]
559    fn happy_path_full_jwt_caches_entry_and_notifies_renderer() {
560        let dir = TempDir::new().unwrap();
561        let cache_path = dir.path().join("auth.toml");
562        let token = make_jwt(&serde_json::json!({
563            "sub": "http://rdfh.ch/users/root",
564            "exp": fixed_expires().timestamp(),
565        }));
566        let cfg = make_cfg(SERVER);
567        let client = MockDspClient::ok();
568        let mut renderer = RecordingRenderer::new();
569
570        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
571
572        // Cache assertions.
573        let loaded = AuthCache::load_from(&cache_path).unwrap();
574        assert_eq!(loaded.token(SERVER), Some(token.as_str()));
575        assert_eq!(
576            loaded.user(SERVER),
577            Some("http://rdfh.ch/users/root"),
578            "user should be set to the JWT sub claim"
579        );
580        assert!(
581            loaded.acquired_at(SERVER).is_some(),
582            "acquired_at should be set after set-token"
583        );
584        assert_eq!(
585            loaded.expires_at(SERVER),
586            Some(fixed_expires()),
587            "expires_at should match the JWT exp claim"
588        );
589
590        // Renderer assertions.
591        let outcome = renderer.set_token_outcome.unwrap();
592        assert_eq!(outcome.server, SERVER);
593        assert_eq!(outcome.user.as_deref(), Some("http://rdfh.ch/users/root"));
594        assert_eq!(outcome.expires_at, Some(fixed_expires()));
595        assert_eq!(
596            renderer.set_token_auth_state.as_deref(),
597            Some("authenticated as http://rdfh.ch/users/root"),
598            "auth_state should use ADR-0007 vocabulary and include the sub IRI"
599        );
600    }
601
602    #[test]
603    fn space_padded_jwt_is_trimmed_before_decode_and_cache() {
604        // A JWT pasted with surrounding whitespace must be trimmed for both the
605        // empty-guard and the value forwarded onward, so it decodes and caches
606        // cleanly rather than failing as "not a valid JWT". Goes through
607        // `run_from_line` (the trim seam), not `run_impl`.
608        let dir = TempDir::new().unwrap();
609        let cache_path = dir.path().join("auth.toml");
610        let token = make_jwt(&serde_json::json!({
611            "sub": "http://rdfh.ch/users/root",
612            "exp": fixed_expires().timestamp(),
613        }));
614        let cfg = make_cfg(SERVER);
615        let client = MockDspClient::ok();
616        let mut renderer = RecordingRenderer::new();
617
618        run_from_line(
619            format!("  {token}  "),
620            &cfg,
621            &client,
622            &mut renderer,
623            Some(&cache_path),
624        )
625        .unwrap();
626
627        let loaded = AuthCache::load_from(&cache_path).unwrap();
628        assert_eq!(
629            loaded.token(SERVER),
630            Some(token.as_str()),
631            "the cached token must be the whitespace-trimmed JWT"
632        );
633    }
634
635    #[test]
636    fn happy_path_sub_absent_caches_user_none() {
637        let dir = TempDir::new().unwrap();
638        let cache_path = dir.path().join("auth.toml");
639        // JWT with exp but no sub.
640        let token = make_jwt(&serde_json::json!({
641            "exp": fixed_expires().timestamp(),
642        }));
643        let cfg = make_cfg(SERVER);
644        let client = MockDspClient::ok();
645        let mut renderer = RecordingRenderer::new();
646
647        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
648
649        let loaded = AuthCache::load_from(&cache_path).unwrap();
650        assert_eq!(
651            loaded.user(SERVER),
652            None,
653            "user should be None when sub absent"
654        );
655
656        let outcome = renderer.set_token_outcome.unwrap();
657        assert_eq!(
658            outcome.user, None,
659            "outcome.user should be None when sub absent"
660        );
661        assert_eq!(
662            renderer.set_token_auth_state.as_deref(),
663            Some("authenticated"),
664            "auth_state should be 'authenticated' (no sub, ADR-0007 vocabulary)"
665        );
666    }
667
668    #[test]
669    fn probe_200_with_locally_expired_token_still_cached() {
670        // Guards the "don't pre-check exp locally" invariant from the plan:
671        // if the live probe returns Ok, we cache regardless of exp value.
672        let dir = TempDir::new().unwrap();
673        let cache_path = dir.path().join("auth.toml");
674        let token = make_jwt(&serde_json::json!({
675            "sub": "http://rdfh.ch/users/root",
676            "exp": fixed_past_expires().timestamp(),
677        }));
678        let cfg = make_cfg(SERVER);
679        let client = MockDspClient::ok(); // probe returns 200
680        let mut renderer = RecordingRenderer::new();
681
682        // Must succeed and cache the token — exp is not locally enforced.
683        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
684
685        let loaded = AuthCache::load_from(&cache_path).unwrap();
686        assert_eq!(
687            loaded.token(SERVER),
688            Some(token.as_str()),
689            "locally-expired token should still be cached when probe returns Ok"
690        );
691    }
692
693    #[test]
694    fn non_jwt_stdin_returns_usage_error_nothing_cached() {
695        let dir = TempDir::new().unwrap();
696        let cache_path = dir.path().join("auth.toml");
697        let cfg = make_cfg(SERVER);
698        let client = MockDspClient::ok();
699        let mut renderer = RecordingRenderer::new();
700
701        let err =
702            run_impl("not-a-jwt", &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
703        assert!(
704            matches!(err, Diagnostic::Usage(_)),
705            "expected Usage diagnostic for non-JWT input, got {err:?}"
706        );
707        // Pin the distinct decode-failure message so it can't silently collapse
708        // into the empty-guard message.
709        assert!(
710            err.to_string().contains("not a valid JWT"),
711            "decode-failure message must contain 'not a valid JWT', got: {err}"
712        );
713
714        // Nothing should have been cached.
715        let loaded = AuthCache::load_from(&cache_path).unwrap();
716        assert_eq!(
717            loaded.token(SERVER),
718            None,
719            "non-JWT input must not write anything to the cache"
720        );
721    }
722
723    #[test]
724    fn empty_stdin_returns_usage_no_token_nothing_cached() {
725        // Guards the real empty-guard path in `run_from_line` (previously untested:
726        // the old test called `run_impl("")` which hit the JWT-decode branch with a
727        // DIFFERENT message — "not a valid JWT" — not the empty-guard path).
728        let dir = TempDir::new().unwrap();
729        let cache_path = dir.path().join("auth.toml");
730        let cfg = make_cfg(SERVER);
731        let client = MockDspClient::ok();
732        let mut renderer = RecordingRenderer::new();
733
734        let err = run_from_line(
735            String::new(),
736            &cfg,
737            &client,
738            &mut renderer,
739            Some(&cache_path),
740        )
741        .unwrap_err();
742        assert!(
743            matches!(err, Diagnostic::Usage(_)),
744            "expected Usage for empty stdin line, got {err:?}"
745        );
746        assert!(
747            err.to_string().contains("no token provided"),
748            "empty-guard message must contain 'no token provided', got: {err}"
749        );
750
751        // Nothing should have been cached.
752        let loaded = AuthCache::load_from(&cache_path).unwrap();
753        assert_eq!(
754            loaded.token(SERVER),
755            None,
756            "empty stdin must not write anything to the cache"
757        );
758    }
759
760    #[test]
761    fn newline_only_stdin_returns_usage_no_token() {
762        // A bare newline from stdin (e.g. user pressed Enter) trims to empty string
763        // and must hit the empty-guard path, not the JWT-decode path.
764        let dir = TempDir::new().unwrap();
765        let cache_path = dir.path().join("auth.toml");
766        let cfg = make_cfg(SERVER);
767        let client = MockDspClient::ok();
768        let mut renderer = RecordingRenderer::new();
769
770        let err = run_from_line(
771            "\n".to_string(),
772            &cfg,
773            &client,
774            &mut renderer,
775            Some(&cache_path),
776        )
777        .unwrap_err();
778        assert!(
779            matches!(err, Diagnostic::Usage(_)),
780            "expected Usage for newline-only stdin line, got {err:?}"
781        );
782        assert!(
783            err.to_string().contains("no token provided"),
784            "newline-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
785        );
786    }
787
788    #[test]
789    fn whitespace_only_stdin_returns_usage_no_token() {
790        // A spaces-only line (no newline) must hit the empty-guard path.
791        // Before the fix, `line.is_empty()` let "   " fall through to JWT-decode;
792        // after the fix, `line.trim().is_empty()` catches it and returns the
793        // "no token provided" message.
794        let dir = TempDir::new().unwrap();
795        let cache_path = dir.path().join("auth.toml");
796        let cfg = make_cfg(SERVER);
797        let client = MockDspClient::ok();
798        let mut renderer = RecordingRenderer::new();
799
800        let err = run_from_line(
801            "   ".to_string(),
802            &cfg,
803            &client,
804            &mut renderer,
805            Some(&cache_path),
806        )
807        .unwrap_err();
808        assert!(
809            matches!(err, Diagnostic::Usage(_)),
810            "expected Usage for whitespace-only stdin, got {err:?}"
811        );
812        assert!(
813            err.to_string().contains("no token provided"),
814            "whitespace-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
815        );
816
817        // Nothing should have been cached.
818        let loaded = AuthCache::load_from(&cache_path).unwrap();
819        assert_eq!(
820            loaded.token(SERVER),
821            None,
822            "whitespace-only stdin must not write anything to the cache"
823        );
824    }
825
826    #[test]
827    fn probe_401_returns_auth_required_nothing_cached() {
828        let dir = TempDir::new().unwrap();
829        let cache_path = dir.path().join("auth.toml");
830        let token = make_jwt(&serde_json::json!({
831            "sub": "http://rdfh.ch/users/root",
832            "exp": fixed_expires().timestamp(),
833        }));
834        let cfg = make_cfg(SERVER);
835        let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
836            "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
837        )));
838        let mut renderer = RecordingRenderer::new();
839
840        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
841        assert!(
842            matches!(err, Diagnostic::AuthRequired(_)),
843            "expected AuthRequired for probe 401, got {err:?}"
844        );
845
846        // Nothing should have been cached.
847        let loaded = AuthCache::load_from(&cache_path).unwrap();
848        assert_eq!(
849            loaded.token(SERVER),
850            None,
851            "probe 401 must not write anything to the cache"
852        );
853    }
854
855    #[test]
856    fn probe_403_returns_auth_required_nothing_cached() {
857        // At the action layer the mock injects an identical `Diagnostic::AuthRequired`
858        // as the 401 test — this only proves AuthRequired propagates and nothing is
859        // cached. The real 401-vs-403 HTTP-status distinction is covered by
860        // `tests/set_token_http.rs`.
861        let dir = TempDir::new().unwrap();
862        let cache_path = dir.path().join("auth.toml");
863        let token = make_jwt(&serde_json::json!({
864            "sub": "http://rdfh.ch/users/root",
865            "exp": fixed_expires().timestamp(),
866        }));
867        let cfg = make_cfg(SERVER);
868        let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
869            "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
870        )));
871        let mut renderer = RecordingRenderer::new();
872
873        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
874        assert!(
875            matches!(err, Diagnostic::AuthRequired(_)),
876            "expected AuthRequired for probe 403, got {err:?}"
877        );
878
879        let loaded = AuthCache::load_from(&cache_path).unwrap();
880        assert_eq!(
881            loaded.token(SERVER),
882            None,
883            "probe 403 must not write anything to the cache"
884        );
885    }
886
887    #[test]
888    fn probe_server_error_returns_server_error_nothing_cached() {
889        // Closes the action-layer ServerError gap: when verify_token returns
890        // Err(ServerError), run_impl must propagate it and leave the cache clean.
891        // The wiremock test already covers the HTTP-level mapping; this test covers
892        // the action layer independently.
893        let dir = TempDir::new().unwrap();
894        let cache_path = dir.path().join("auth.toml");
895        let token = make_jwt(&serde_json::json!({
896            "sub": "http://rdfh.ch/users/root",
897            "exp": fixed_expires().timestamp(),
898        }));
899        let cfg = make_cfg(SERVER);
900        let client = MockDspClient::err(Diagnostic::ServerError(
901            "server returned 500 Internal Server Error".to_string(),
902        ));
903        let mut renderer = RecordingRenderer::new();
904
905        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
906        assert!(
907            matches!(err, Diagnostic::ServerError(_)),
908            "expected ServerError when verify_token returns ServerError, got {err:?}"
909        );
910
911        // Nothing should have been cached.
912        let loaded = AuthCache::load_from(&cache_path).unwrap();
913        assert_eq!(
914            loaded.token(SERVER),
915            None,
916            "server error must not write anything to the cache"
917        );
918    }
919
920    #[test]
921    fn probe_network_error_propagates_nothing_cached() {
922        let dir = TempDir::new().unwrap();
923        let cache_path = dir.path().join("auth.toml");
924        let token = make_jwt(&serde_json::json!({
925            "sub": "http://rdfh.ch/users/root",
926            "exp": fixed_expires().timestamp(),
927        }));
928        let cfg = make_cfg(SERVER);
929        let client = MockDspClient::err(Diagnostic::Network("connection refused".to_string()));
930        let mut renderer = RecordingRenderer::new();
931
932        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
933        assert!(
934            matches!(err, Diagnostic::Network(_)),
935            "expected Network diagnostic, got {err:?}"
936        );
937
938        let loaded = AuthCache::load_from(&cache_path).unwrap();
939        assert_eq!(
940            loaded.token(SERVER),
941            None,
942            "network failure must not write anything to the cache"
943        );
944    }
945
946    #[test]
947    fn probe_failure_does_not_overwrite_existing_entry() {
948        // Guards that a pre-existing valid token is not replaced by a failed set-token.
949        let dir = TempDir::new().unwrap();
950        let cache_path = dir.path().join("auth.toml");
951
952        // Pre-populate with a good entry.
953        let mut cache = AuthCache::default();
954        cache.set_entry(
955            SERVER,
956            ServerEntry {
957                token: "existing-valid-tok".to_string(),
958                user: Some("existing@user.test".to_string()),
959                acquired_at: None,
960                expires_at: None,
961            },
962        );
963        cache.save_to(&cache_path).unwrap();
964
965        let token = make_jwt(&serde_json::json!({
966            "sub": "http://rdfh.ch/users/new",
967            "exp": fixed_expires().timestamp(),
968        }));
969        let cfg = make_cfg(SERVER);
970        let client = MockDspClient::err(Diagnostic::AuthRequired("rejected".to_string()));
971        let mut renderer = RecordingRenderer::new();
972
973        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
974
975        let loaded = AuthCache::load_from(&cache_path).unwrap();
976        assert_eq!(
977            loaded.token(SERVER),
978            Some("existing-valid-tok"),
979            "failed set-token must not overwrite an existing valid cache entry"
980        );
981    }
982}