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
348    // ── recording renderer ─────────────────────────────────────────────────────
349
350    struct RecordingRenderer {
351        set_token_outcome: Option<AuthSetTokenOutcome>,
352        set_token_auth_state: Option<String>,
353    }
354
355    impl RecordingRenderer {
356        fn new() -> Self {
357            Self {
358                set_token_outcome: None,
359                set_token_auth_state: None,
360            }
361        }
362    }
363
364    impl Renderer for RecordingRenderer {
365        fn diagnostic(
366            &mut self,
367            _diag: &Diagnostic,
368            _meta: &MetaContext,
369        ) -> Result<(), Diagnostic> {
370            Ok(())
371        }
372
373        fn auth_login(
374            &mut self,
375            _outcome: &AuthLoginOutcome,
376            _meta: &MetaContext,
377        ) -> Result<(), Diagnostic> {
378            Ok(())
379        }
380
381        fn auth_status(
382            &mut self,
383            _outcome: &AuthStatusOutcome,
384            _meta: &MetaContext,
385        ) -> Result<(), Diagnostic> {
386            Ok(())
387        }
388
389        fn auth_logout(
390            &mut self,
391            _outcome: &AuthLogoutOutcome,
392            _meta: &MetaContext,
393        ) -> Result<(), Diagnostic> {
394            Ok(())
395        }
396
397        fn auth_set_token(
398            &mut self,
399            outcome: &AuthSetTokenOutcome,
400            meta: &MetaContext,
401        ) -> Result<(), Diagnostic> {
402            self.set_token_outcome = Some(AuthSetTokenOutcome {
403                server: outcome.server.clone(),
404                user: outcome.user.clone(),
405                expires_at: outcome.expires_at,
406            });
407            self.set_token_auth_state = Some(meta.auth_state.clone());
408            Ok(())
409        }
410
411        fn project_dump(
412            &mut self,
413            _outcome: &crate::render::DumpOutcome,
414            _meta: &MetaContext,
415        ) -> Result<(), Diagnostic> {
416            Ok(())
417        }
418
419        fn project_dump_deleted(
420            &mut self,
421            _outcome: &crate::render::DumpDeleteOutcome,
422            _meta: &MetaContext,
423        ) -> Result<(), Diagnostic> {
424            Ok(())
425        }
426
427        fn projects(
428            &mut self,
429            _view: &crate::render::ProjectListView,
430            _meta: &MetaContext,
431        ) -> Result<(), Diagnostic> {
432            Ok(())
433        }
434
435        fn project_describe(
436            &mut self,
437            _project: &crate::model::ProjectDetail,
438            _meta: &MetaContext,
439        ) -> Result<(), Diagnostic> {
440            Ok(())
441        }
442
443        fn data_models(
444            &mut self,
445            _view: &crate::render::DataModelListView,
446            _meta: &MetaContext,
447        ) -> Result<(), Diagnostic> {
448            Ok(())
449        }
450
451        fn data_model_describe(
452            &mut self,
453            _detail: &crate::model::DataModelDetail,
454            _meta: &MetaContext,
455        ) -> Result<(), Diagnostic> {
456            Ok(())
457        }
458
459        fn resource_types(
460            &mut self,
461            _view: &crate::render::ResourceTypeListView,
462            _meta: &MetaContext,
463        ) -> Result<(), Diagnostic> {
464            Ok(())
465        }
466
467        fn resource_type_describe(
468            &mut self,
469            _detail: &crate::model::ResourceTypeDetail,
470            _meta: &MetaContext,
471        ) -> Result<(), Diagnostic> {
472            unimplemented!("resource_type_describe not used in set-token tests")
473        }
474
475        fn data_model_structure(
476            &mut self,
477            _structure: &crate::model::DataModelStructure,
478            _meta: &MetaContext,
479        ) -> Result<(), Diagnostic> {
480            unimplemented!("data_model_structure not used in set-token tests")
481        }
482
483        fn resources(
484            &mut self,
485            _view: &crate::render::ResourceListView,
486            _meta: &MetaContext,
487        ) -> Result<(), Diagnostic> {
488            Ok(())
489        }
490
491        fn resource_describe(
492            &mut self,
493            _detail: &crate::model::ResourceDetail,
494            _meta: &MetaContext,
495        ) -> Result<(), Diagnostic> {
496            Ok(())
497        }
498
499        fn vocabularies(
500            &mut self,
501            _view: &crate::render::VocabularyListView,
502            _meta: &MetaContext,
503        ) -> Result<(), Diagnostic> {
504            unimplemented!("not exercised by this file's tests")
505        }
506
507        fn vocabulary_describe(
508            &mut self,
509            _detail: &crate::model::VocabularyDetail,
510            _meta: &MetaContext,
511        ) -> Result<(), Diagnostic> {
512            unimplemented!("not exercised by this file's tests")
513        }
514    }
515
516    // ── helpers ────────────────────────────────────────────────────────────────
517
518    /// Produce a minimal JWT with the given JSON payload.
519    /// The secret is arbitrary — `extract_meta` disables signature validation.
520    fn make_jwt(payload: &serde_json::Value) -> String {
521        encode(
522            &Header::new(Algorithm::HS256),
523            payload,
524            &EncodingKey::from_secret(b"unused"),
525        )
526        .expect("test JWT encoding should not fail")
527    }
528
529    fn fixed_expires() -> chrono::DateTime<Utc> {
530        Utc.with_ymd_and_hms(2099, 6, 25, 12, 34, 56).unwrap()
531    }
532
533    fn fixed_past_expires() -> chrono::DateTime<Utc> {
534        Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
535    }
536
537    fn make_cfg(server: &str) -> Config {
538        Config {
539            server: server.to_string(),
540        }
541    }
542
543    const SERVER: &str = "https://api.test.dasch.swiss";
544
545    // ── tests ──────────────────────────────────────────────────────────────────
546
547    #[test]
548    fn happy_path_full_jwt_caches_entry_and_notifies_renderer() {
549        let dir = TempDir::new().unwrap();
550        let cache_path = dir.path().join("auth.toml");
551        let token = make_jwt(&serde_json::json!({
552            "sub": "http://rdfh.ch/users/root",
553            "exp": fixed_expires().timestamp(),
554        }));
555        let cfg = make_cfg(SERVER);
556        let client = MockDspClient::ok();
557        let mut renderer = RecordingRenderer::new();
558
559        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
560
561        // Cache assertions.
562        let loaded = AuthCache::load_from(&cache_path).unwrap();
563        assert_eq!(loaded.token(SERVER), Some(token.as_str()));
564        assert_eq!(
565            loaded.user(SERVER),
566            Some("http://rdfh.ch/users/root"),
567            "user should be set to the JWT sub claim"
568        );
569        assert!(
570            loaded.acquired_at(SERVER).is_some(),
571            "acquired_at should be set after set-token"
572        );
573        assert_eq!(
574            loaded.expires_at(SERVER),
575            Some(fixed_expires()),
576            "expires_at should match the JWT exp claim"
577        );
578
579        // Renderer assertions.
580        let outcome = renderer.set_token_outcome.unwrap();
581        assert_eq!(outcome.server, SERVER);
582        assert_eq!(outcome.user.as_deref(), Some("http://rdfh.ch/users/root"));
583        assert_eq!(outcome.expires_at, Some(fixed_expires()));
584        assert_eq!(
585            renderer.set_token_auth_state.as_deref(),
586            Some("authenticated as http://rdfh.ch/users/root"),
587            "auth_state should use ADR-0007 vocabulary and include the sub IRI"
588        );
589    }
590
591    #[test]
592    fn space_padded_jwt_is_trimmed_before_decode_and_cache() {
593        // A JWT pasted with surrounding whitespace must be trimmed for both the
594        // empty-guard and the value forwarded onward, so it decodes and caches
595        // cleanly rather than failing as "not a valid JWT". Goes through
596        // `run_from_line` (the trim seam), not `run_impl`.
597        let dir = TempDir::new().unwrap();
598        let cache_path = dir.path().join("auth.toml");
599        let token = make_jwt(&serde_json::json!({
600            "sub": "http://rdfh.ch/users/root",
601            "exp": fixed_expires().timestamp(),
602        }));
603        let cfg = make_cfg(SERVER);
604        let client = MockDspClient::ok();
605        let mut renderer = RecordingRenderer::new();
606
607        run_from_line(
608            format!("  {token}  "),
609            &cfg,
610            &client,
611            &mut renderer,
612            Some(&cache_path),
613        )
614        .unwrap();
615
616        let loaded = AuthCache::load_from(&cache_path).unwrap();
617        assert_eq!(
618            loaded.token(SERVER),
619            Some(token.as_str()),
620            "the cached token must be the whitespace-trimmed JWT"
621        );
622    }
623
624    #[test]
625    fn happy_path_sub_absent_caches_user_none() {
626        let dir = TempDir::new().unwrap();
627        let cache_path = dir.path().join("auth.toml");
628        // JWT with exp but no sub.
629        let token = make_jwt(&serde_json::json!({
630            "exp": fixed_expires().timestamp(),
631        }));
632        let cfg = make_cfg(SERVER);
633        let client = MockDspClient::ok();
634        let mut renderer = RecordingRenderer::new();
635
636        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
637
638        let loaded = AuthCache::load_from(&cache_path).unwrap();
639        assert_eq!(
640            loaded.user(SERVER),
641            None,
642            "user should be None when sub absent"
643        );
644
645        let outcome = renderer.set_token_outcome.unwrap();
646        assert_eq!(
647            outcome.user, None,
648            "outcome.user should be None when sub absent"
649        );
650        assert_eq!(
651            renderer.set_token_auth_state.as_deref(),
652            Some("authenticated"),
653            "auth_state should be 'authenticated' (no sub, ADR-0007 vocabulary)"
654        );
655    }
656
657    #[test]
658    fn probe_200_with_locally_expired_token_still_cached() {
659        // Guards the "don't pre-check exp locally" invariant from the plan:
660        // if the live probe returns Ok, we cache regardless of exp value.
661        let dir = TempDir::new().unwrap();
662        let cache_path = dir.path().join("auth.toml");
663        let token = make_jwt(&serde_json::json!({
664            "sub": "http://rdfh.ch/users/root",
665            "exp": fixed_past_expires().timestamp(),
666        }));
667        let cfg = make_cfg(SERVER);
668        let client = MockDspClient::ok(); // probe returns 200
669        let mut renderer = RecordingRenderer::new();
670
671        // Must succeed and cache the token — exp is not locally enforced.
672        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
673
674        let loaded = AuthCache::load_from(&cache_path).unwrap();
675        assert_eq!(
676            loaded.token(SERVER),
677            Some(token.as_str()),
678            "locally-expired token should still be cached when probe returns Ok"
679        );
680    }
681
682    #[test]
683    fn non_jwt_stdin_returns_usage_error_nothing_cached() {
684        let dir = TempDir::new().unwrap();
685        let cache_path = dir.path().join("auth.toml");
686        let cfg = make_cfg(SERVER);
687        let client = MockDspClient::ok();
688        let mut renderer = RecordingRenderer::new();
689
690        let err =
691            run_impl("not-a-jwt", &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
692        assert!(
693            matches!(err, Diagnostic::Usage(_)),
694            "expected Usage diagnostic for non-JWT input, got {err:?}"
695        );
696        // Pin the distinct decode-failure message so it can't silently collapse
697        // into the empty-guard message.
698        assert!(
699            err.to_string().contains("not a valid JWT"),
700            "decode-failure message must contain 'not a valid JWT', got: {err}"
701        );
702
703        // Nothing should have been cached.
704        let loaded = AuthCache::load_from(&cache_path).unwrap();
705        assert_eq!(
706            loaded.token(SERVER),
707            None,
708            "non-JWT input must not write anything to the cache"
709        );
710    }
711
712    #[test]
713    fn empty_stdin_returns_usage_no_token_nothing_cached() {
714        // Guards the real empty-guard path in `run_from_line` (previously untested:
715        // the old test called `run_impl("")` which hit the JWT-decode branch with a
716        // DIFFERENT message — "not a valid JWT" — not the empty-guard path).
717        let dir = TempDir::new().unwrap();
718        let cache_path = dir.path().join("auth.toml");
719        let cfg = make_cfg(SERVER);
720        let client = MockDspClient::ok();
721        let mut renderer = RecordingRenderer::new();
722
723        let err = run_from_line(
724            String::new(),
725            &cfg,
726            &client,
727            &mut renderer,
728            Some(&cache_path),
729        )
730        .unwrap_err();
731        assert!(
732            matches!(err, Diagnostic::Usage(_)),
733            "expected Usage for empty stdin line, got {err:?}"
734        );
735        assert!(
736            err.to_string().contains("no token provided"),
737            "empty-guard message must contain 'no token provided', got: {err}"
738        );
739
740        // Nothing should have been cached.
741        let loaded = AuthCache::load_from(&cache_path).unwrap();
742        assert_eq!(
743            loaded.token(SERVER),
744            None,
745            "empty stdin must not write anything to the cache"
746        );
747    }
748
749    #[test]
750    fn newline_only_stdin_returns_usage_no_token() {
751        // A bare newline from stdin (e.g. user pressed Enter) trims to empty string
752        // and must hit the empty-guard path, not the JWT-decode path.
753        let dir = TempDir::new().unwrap();
754        let cache_path = dir.path().join("auth.toml");
755        let cfg = make_cfg(SERVER);
756        let client = MockDspClient::ok();
757        let mut renderer = RecordingRenderer::new();
758
759        let err = run_from_line(
760            "\n".to_string(),
761            &cfg,
762            &client,
763            &mut renderer,
764            Some(&cache_path),
765        )
766        .unwrap_err();
767        assert!(
768            matches!(err, Diagnostic::Usage(_)),
769            "expected Usage for newline-only stdin line, got {err:?}"
770        );
771        assert!(
772            err.to_string().contains("no token provided"),
773            "newline-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
774        );
775    }
776
777    #[test]
778    fn whitespace_only_stdin_returns_usage_no_token() {
779        // A spaces-only line (no newline) must hit the empty-guard path.
780        // Before the fix, `line.is_empty()` let "   " fall through to JWT-decode;
781        // after the fix, `line.trim().is_empty()` catches it and returns the
782        // "no token provided" message.
783        let dir = TempDir::new().unwrap();
784        let cache_path = dir.path().join("auth.toml");
785        let cfg = make_cfg(SERVER);
786        let client = MockDspClient::ok();
787        let mut renderer = RecordingRenderer::new();
788
789        let err = run_from_line(
790            "   ".to_string(),
791            &cfg,
792            &client,
793            &mut renderer,
794            Some(&cache_path),
795        )
796        .unwrap_err();
797        assert!(
798            matches!(err, Diagnostic::Usage(_)),
799            "expected Usage for whitespace-only stdin, got {err:?}"
800        );
801        assert!(
802            err.to_string().contains("no token provided"),
803            "whitespace-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
804        );
805
806        // Nothing should have been cached.
807        let loaded = AuthCache::load_from(&cache_path).unwrap();
808        assert_eq!(
809            loaded.token(SERVER),
810            None,
811            "whitespace-only stdin must not write anything to the cache"
812        );
813    }
814
815    #[test]
816    fn probe_401_returns_auth_required_nothing_cached() {
817        let dir = TempDir::new().unwrap();
818        let cache_path = dir.path().join("auth.toml");
819        let token = make_jwt(&serde_json::json!({
820            "sub": "http://rdfh.ch/users/root",
821            "exp": fixed_expires().timestamp(),
822        }));
823        let cfg = make_cfg(SERVER);
824        let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
825            "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
826        )));
827        let mut renderer = RecordingRenderer::new();
828
829        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
830        assert!(
831            matches!(err, Diagnostic::AuthRequired(_)),
832            "expected AuthRequired for probe 401, got {err:?}"
833        );
834
835        // Nothing should have been cached.
836        let loaded = AuthCache::load_from(&cache_path).unwrap();
837        assert_eq!(
838            loaded.token(SERVER),
839            None,
840            "probe 401 must not write anything to the cache"
841        );
842    }
843
844    #[test]
845    fn probe_403_returns_auth_required_nothing_cached() {
846        // At the action layer the mock injects an identical `Diagnostic::AuthRequired`
847        // as the 401 test — this only proves AuthRequired propagates and nothing is
848        // cached. The real 401-vs-403 HTTP-status distinction is covered by
849        // `tests/set_token_http.rs`.
850        let dir = TempDir::new().unwrap();
851        let cache_path = dir.path().join("auth.toml");
852        let token = make_jwt(&serde_json::json!({
853            "sub": "http://rdfh.ch/users/root",
854            "exp": fixed_expires().timestamp(),
855        }));
856        let cfg = make_cfg(SERVER);
857        let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
858            "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
859        )));
860        let mut renderer = RecordingRenderer::new();
861
862        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
863        assert!(
864            matches!(err, Diagnostic::AuthRequired(_)),
865            "expected AuthRequired for probe 403, got {err:?}"
866        );
867
868        let loaded = AuthCache::load_from(&cache_path).unwrap();
869        assert_eq!(
870            loaded.token(SERVER),
871            None,
872            "probe 403 must not write anything to the cache"
873        );
874    }
875
876    #[test]
877    fn probe_server_error_returns_server_error_nothing_cached() {
878        // Closes the action-layer ServerError gap: when verify_token returns
879        // Err(ServerError), run_impl must propagate it and leave the cache clean.
880        // The wiremock test already covers the HTTP-level mapping; this test covers
881        // the action layer independently.
882        let dir = TempDir::new().unwrap();
883        let cache_path = dir.path().join("auth.toml");
884        let token = make_jwt(&serde_json::json!({
885            "sub": "http://rdfh.ch/users/root",
886            "exp": fixed_expires().timestamp(),
887        }));
888        let cfg = make_cfg(SERVER);
889        let client = MockDspClient::err(Diagnostic::ServerError(
890            "server returned 500 Internal Server Error".to_string(),
891        ));
892        let mut renderer = RecordingRenderer::new();
893
894        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
895        assert!(
896            matches!(err, Diagnostic::ServerError(_)),
897            "expected ServerError when verify_token returns ServerError, got {err:?}"
898        );
899
900        // Nothing should have been cached.
901        let loaded = AuthCache::load_from(&cache_path).unwrap();
902        assert_eq!(
903            loaded.token(SERVER),
904            None,
905            "server error must not write anything to the cache"
906        );
907    }
908
909    #[test]
910    fn probe_network_error_propagates_nothing_cached() {
911        let dir = TempDir::new().unwrap();
912        let cache_path = dir.path().join("auth.toml");
913        let token = make_jwt(&serde_json::json!({
914            "sub": "http://rdfh.ch/users/root",
915            "exp": fixed_expires().timestamp(),
916        }));
917        let cfg = make_cfg(SERVER);
918        let client = MockDspClient::err(Diagnostic::Network("connection refused".to_string()));
919        let mut renderer = RecordingRenderer::new();
920
921        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
922        assert!(
923            matches!(err, Diagnostic::Network(_)),
924            "expected Network diagnostic, got {err:?}"
925        );
926
927        let loaded = AuthCache::load_from(&cache_path).unwrap();
928        assert_eq!(
929            loaded.token(SERVER),
930            None,
931            "network failure must not write anything to the cache"
932        );
933    }
934
935    #[test]
936    fn probe_failure_does_not_overwrite_existing_entry() {
937        // Guards that a pre-existing valid token is not replaced by a failed set-token.
938        let dir = TempDir::new().unwrap();
939        let cache_path = dir.path().join("auth.toml");
940
941        // Pre-populate with a good entry.
942        let mut cache = AuthCache::default();
943        cache.set_entry(
944            SERVER,
945            ServerEntry {
946                token: "existing-valid-tok".to_string(),
947                user: Some("existing@user.test".to_string()),
948                acquired_at: None,
949                expires_at: None,
950            },
951        );
952        cache.save_to(&cache_path).unwrap();
953
954        let token = make_jwt(&serde_json::json!({
955            "sub": "http://rdfh.ch/users/new",
956            "exp": fixed_expires().timestamp(),
957        }));
958        let cfg = make_cfg(SERVER);
959        let client = MockDspClient::err(Diagnostic::AuthRequired("rejected".to_string()));
960        let mut renderer = RecordingRenderer::new();
961
962        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
963
964        let loaded = AuthCache::load_from(&cache_path).unwrap();
965        assert_eq!(
966            loaded.token(SERVER),
967            Some("existing-valid-tok"),
968            "failed set-token must not overwrite an existing valid cache entry"
969        );
970    }
971}