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    };
133
134    // 5. Render the outcome.
135    let outcome = AuthSetTokenOutcome {
136        server: cfg.server.clone(),
137        user: meta.sub,
138        expires_at: meta.exp,
139    };
140
141    renderer.auth_set_token(&outcome, &meta_ctx)
142}
143
144#[cfg(test)]
145mod tests {
146    use chrono::{TimeZone, Utc};
147    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
148    use tempfile::TempDir;
149
150    use super::{run_from_line, run_impl};
151    use crate::client::DspClient;
152    use crate::config::auth_cache::ServerEntry;
153    use crate::config::{AuthCache, Config};
154    use crate::diagnostic::Diagnostic;
155    use crate::model::{CreateDumpOutcome, DumpTask, LoginResponse, ProjectRef};
156    use crate::render::auth::{
157        AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
158    };
159    use crate::render::{MetaContext, Renderer};
160
161    // ── local mock client ─────────────────────────────────────────────────────
162
163    struct MockDspClient {
164        verify_token_result: Result<(), Diagnostic>,
165    }
166
167    impl MockDspClient {
168        fn ok() -> Self {
169            Self {
170                verify_token_result: Ok(()),
171            }
172        }
173
174        fn err(diag: Diagnostic) -> Self {
175            Self {
176                verify_token_result: Err(diag),
177            }
178        }
179    }
180
181    impl DspClient for MockDspClient {
182        fn login(
183            &self,
184            _server: &str,
185            _user: &str,
186            _password: &str,
187        ) -> Result<LoginResponse, Diagnostic> {
188            unimplemented!("login not used by set-token tests")
189        }
190
191        fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
192            unimplemented!("resolve_project not used by set-token tests")
193        }
194
195        fn create_project_dump(
196            &self,
197            _server: &str,
198            _project_iri: &str,
199            _skip_assets: bool,
200            _token: &str,
201        ) -> Result<CreateDumpOutcome, Diagnostic> {
202            unimplemented!("create_project_dump not used by set-token tests")
203        }
204
205        fn get_project_dump_status(
206            &self,
207            _server: &str,
208            _project_iri: &str,
209            _dump_id: &str,
210            _token: &str,
211        ) -> Result<DumpTask, Diagnostic> {
212            unimplemented!("get_project_dump_status not used by set-token tests")
213        }
214
215        fn download_project_dump(
216            &self,
217            _server: &str,
218            _project_iri: &str,
219            _dump_id: &str,
220            _token: &str,
221            _dest: &mut dyn std::io::Write,
222        ) -> Result<u64, Diagnostic> {
223            unimplemented!("download_project_dump not used by set-token tests")
224        }
225
226        fn delete_project_dump(
227            &self,
228            _server: &str,
229            _project_iri: &str,
230            _dump_id: &str,
231            _token: &str,
232        ) -> Result<(), Diagnostic> {
233            unimplemented!("delete_project_dump not used by set-token tests")
234        }
235
236        fn describe_project(
237            &self,
238            _server: &str,
239            _project: &str,
240            _token: Option<&str>,
241        ) -> Result<crate::model::ProjectDetail, Diagnostic> {
242            unimplemented!("describe_project not used by set-token tests")
243        }
244
245        fn list_projects(
246            &self,
247            _server: &str,
248            _token: Option<&str>,
249        ) -> Result<Vec<crate::model::Project>, Diagnostic> {
250            Err(Diagnostic::NotImplemented(
251                "list_projects not used in set_token.rs tests".into(),
252            ))
253        }
254
255        fn list_data_models(
256            &self,
257            _server: &str,
258            _project_iri: &str,
259            _token: Option<&str>,
260        ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
261            unimplemented!("list_data_models not used by set-token tests")
262        }
263
264        fn describe_data_model(
265            &self,
266            _server: &str,
267            _data_model_iri: &str,
268            _token: Option<&str>,
269        ) -> Result<crate::model::DataModelDetail, Diagnostic> {
270            unimplemented!("describe_data_model not used in set-token tests")
271        }
272
273        fn describe_resource_type(
274            &self,
275            _server: &str,
276            _data_model_iri: &str,
277            _resource_type: &str,
278            _token: Option<&str>,
279        ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
280            unimplemented!("describe_resource_type not used in set-token tests")
281        }
282
283        fn data_model_structure(
284            &self,
285            _server: &str,
286            _data_model_iri: &str,
287            _token: Option<&str>,
288        ) -> Result<crate::model::DataModelStructure, Diagnostic> {
289            unimplemented!("data_model_structure not used in set-token tests")
290        }
291
292        fn list_resources(
293            &self,
294            _server: &str,
295            _project_iri: &str,
296            _resource_type_iri: &str,
297            _order_by: Option<&str>,
298            _page: u32,
299            _token: Option<&str>,
300        ) -> Result<crate::model::ResourcePage, Diagnostic> {
301            unimplemented!("list_resources not used in set-token tests")
302        }
303
304        fn describe_resource(
305            &self,
306            _server: &str,
307            _resource_iri: &str,
308            _token: Option<&str>,
309            _with_values: bool,
310        ) -> Result<crate::model::ResourceDetail, Diagnostic> {
311            unimplemented!("describe_resource not used in set-token tests")
312        }
313
314        fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
315            self.verify_token_result.clone()
316        }
317    }
318
319    // ── recording renderer ─────────────────────────────────────────────────────
320
321    struct RecordingRenderer {
322        set_token_outcome: Option<AuthSetTokenOutcome>,
323        set_token_auth_state: Option<String>,
324    }
325
326    impl RecordingRenderer {
327        fn new() -> Self {
328            Self {
329                set_token_outcome: None,
330                set_token_auth_state: None,
331            }
332        }
333    }
334
335    impl Renderer for RecordingRenderer {
336        fn diagnostic(
337            &mut self,
338            _diag: &Diagnostic,
339            _meta: &MetaContext,
340        ) -> Result<(), Diagnostic> {
341            Ok(())
342        }
343
344        fn auth_login(
345            &mut self,
346            _outcome: &AuthLoginOutcome,
347            _meta: &MetaContext,
348        ) -> Result<(), Diagnostic> {
349            Ok(())
350        }
351
352        fn auth_status(
353            &mut self,
354            _outcome: &AuthStatusOutcome,
355            _meta: &MetaContext,
356        ) -> Result<(), Diagnostic> {
357            Ok(())
358        }
359
360        fn auth_logout(
361            &mut self,
362            _outcome: &AuthLogoutOutcome,
363            _meta: &MetaContext,
364        ) -> Result<(), Diagnostic> {
365            Ok(())
366        }
367
368        fn auth_set_token(
369            &mut self,
370            outcome: &AuthSetTokenOutcome,
371            meta: &MetaContext,
372        ) -> Result<(), Diagnostic> {
373            self.set_token_outcome = Some(AuthSetTokenOutcome {
374                server: outcome.server.clone(),
375                user: outcome.user.clone(),
376                expires_at: outcome.expires_at,
377            });
378            self.set_token_auth_state = Some(meta.auth_state.clone());
379            Ok(())
380        }
381
382        fn project_dump(
383            &mut self,
384            _outcome: &crate::render::DumpOutcome,
385            _meta: &MetaContext,
386        ) -> Result<(), Diagnostic> {
387            Ok(())
388        }
389
390        fn project_dump_deleted(
391            &mut self,
392            _outcome: &crate::render::DumpDeleteOutcome,
393            _meta: &MetaContext,
394        ) -> Result<(), Diagnostic> {
395            Ok(())
396        }
397
398        fn projects(
399            &mut self,
400            _view: &crate::render::ProjectListView,
401            _meta: &MetaContext,
402        ) -> Result<(), Diagnostic> {
403            Ok(())
404        }
405
406        fn project_describe(
407            &mut self,
408            _project: &crate::model::ProjectDetail,
409            _meta: &MetaContext,
410        ) -> Result<(), Diagnostic> {
411            Ok(())
412        }
413
414        fn data_models(
415            &mut self,
416            _view: &crate::render::DataModelListView,
417            _meta: &MetaContext,
418        ) -> Result<(), Diagnostic> {
419            Ok(())
420        }
421
422        fn data_model_describe(
423            &mut self,
424            _detail: &crate::model::DataModelDetail,
425            _meta: &MetaContext,
426        ) -> Result<(), Diagnostic> {
427            Ok(())
428        }
429
430        fn resource_types(
431            &mut self,
432            _view: &crate::render::ResourceTypeListView,
433            _meta: &MetaContext,
434        ) -> Result<(), Diagnostic> {
435            Ok(())
436        }
437
438        fn resource_type_describe(
439            &mut self,
440            _detail: &crate::model::ResourceTypeDetail,
441            _meta: &MetaContext,
442        ) -> Result<(), Diagnostic> {
443            unimplemented!("resource_type_describe not used in set-token tests")
444        }
445
446        fn data_model_structure(
447            &mut self,
448            _structure: &crate::model::DataModelStructure,
449            _meta: &MetaContext,
450        ) -> Result<(), Diagnostic> {
451            unimplemented!("data_model_structure not used in set-token tests")
452        }
453
454        fn resources(
455            &mut self,
456            _view: &crate::render::ResourceListView,
457            _meta: &MetaContext,
458        ) -> Result<(), Diagnostic> {
459            Ok(())
460        }
461
462        fn resource_describe(
463            &mut self,
464            _detail: &crate::model::ResourceDetail,
465            _meta: &MetaContext,
466        ) -> Result<(), Diagnostic> {
467            Ok(())
468        }
469    }
470
471    // ── helpers ────────────────────────────────────────────────────────────────
472
473    /// Produce a minimal JWT with the given JSON payload.
474    /// The secret is arbitrary — `extract_meta` disables signature validation.
475    fn make_jwt(payload: &serde_json::Value) -> String {
476        encode(
477            &Header::new(Algorithm::HS256),
478            payload,
479            &EncodingKey::from_secret(b"unused"),
480        )
481        .expect("test JWT encoding should not fail")
482    }
483
484    fn fixed_expires() -> chrono::DateTime<Utc> {
485        Utc.with_ymd_and_hms(2099, 6, 25, 12, 34, 56).unwrap()
486    }
487
488    fn fixed_past_expires() -> chrono::DateTime<Utc> {
489        Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
490    }
491
492    fn make_cfg(server: &str) -> Config {
493        Config {
494            server: server.to_string(),
495        }
496    }
497
498    const SERVER: &str = "https://api.test.dasch.swiss";
499
500    // ── tests ──────────────────────────────────────────────────────────────────
501
502    #[test]
503    fn happy_path_full_jwt_caches_entry_and_notifies_renderer() {
504        let dir = TempDir::new().unwrap();
505        let cache_path = dir.path().join("auth.toml");
506        let token = make_jwt(&serde_json::json!({
507            "sub": "http://rdfh.ch/users/root",
508            "exp": fixed_expires().timestamp(),
509        }));
510        let cfg = make_cfg(SERVER);
511        let client = MockDspClient::ok();
512        let mut renderer = RecordingRenderer::new();
513
514        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
515
516        // Cache assertions.
517        let loaded = AuthCache::load_from(&cache_path).unwrap();
518        assert_eq!(loaded.token(SERVER), Some(token.as_str()));
519        assert_eq!(
520            loaded.user(SERVER),
521            Some("http://rdfh.ch/users/root"),
522            "user should be set to the JWT sub claim"
523        );
524        assert!(
525            loaded.acquired_at(SERVER).is_some(),
526            "acquired_at should be set after set-token"
527        );
528        assert_eq!(
529            loaded.expires_at(SERVER),
530            Some(fixed_expires()),
531            "expires_at should match the JWT exp claim"
532        );
533
534        // Renderer assertions.
535        let outcome = renderer.set_token_outcome.unwrap();
536        assert_eq!(outcome.server, SERVER);
537        assert_eq!(outcome.user.as_deref(), Some("http://rdfh.ch/users/root"));
538        assert_eq!(outcome.expires_at, Some(fixed_expires()));
539        assert_eq!(
540            renderer.set_token_auth_state.as_deref(),
541            Some("authenticated as http://rdfh.ch/users/root"),
542            "auth_state should use ADR-0007 vocabulary and include the sub IRI"
543        );
544    }
545
546    #[test]
547    fn space_padded_jwt_is_trimmed_before_decode_and_cache() {
548        // A JWT pasted with surrounding whitespace must be trimmed for both the
549        // empty-guard and the value forwarded onward, so it decodes and caches
550        // cleanly rather than failing as "not a valid JWT". Goes through
551        // `run_from_line` (the trim seam), not `run_impl`.
552        let dir = TempDir::new().unwrap();
553        let cache_path = dir.path().join("auth.toml");
554        let token = make_jwt(&serde_json::json!({
555            "sub": "http://rdfh.ch/users/root",
556            "exp": fixed_expires().timestamp(),
557        }));
558        let cfg = make_cfg(SERVER);
559        let client = MockDspClient::ok();
560        let mut renderer = RecordingRenderer::new();
561
562        run_from_line(
563            format!("  {token}  "),
564            &cfg,
565            &client,
566            &mut renderer,
567            Some(&cache_path),
568        )
569        .unwrap();
570
571        let loaded = AuthCache::load_from(&cache_path).unwrap();
572        assert_eq!(
573            loaded.token(SERVER),
574            Some(token.as_str()),
575            "the cached token must be the whitespace-trimmed JWT"
576        );
577    }
578
579    #[test]
580    fn happy_path_sub_absent_caches_user_none() {
581        let dir = TempDir::new().unwrap();
582        let cache_path = dir.path().join("auth.toml");
583        // JWT with exp but no sub.
584        let token = make_jwt(&serde_json::json!({
585            "exp": fixed_expires().timestamp(),
586        }));
587        let cfg = make_cfg(SERVER);
588        let client = MockDspClient::ok();
589        let mut renderer = RecordingRenderer::new();
590
591        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
592
593        let loaded = AuthCache::load_from(&cache_path).unwrap();
594        assert_eq!(
595            loaded.user(SERVER),
596            None,
597            "user should be None when sub absent"
598        );
599
600        let outcome = renderer.set_token_outcome.unwrap();
601        assert_eq!(
602            outcome.user, None,
603            "outcome.user should be None when sub absent"
604        );
605        assert_eq!(
606            renderer.set_token_auth_state.as_deref(),
607            Some("authenticated"),
608            "auth_state should be 'authenticated' (no sub, ADR-0007 vocabulary)"
609        );
610    }
611
612    #[test]
613    fn probe_200_with_locally_expired_token_still_cached() {
614        // Guards the "don't pre-check exp locally" invariant from the plan:
615        // if the live probe returns Ok, we cache regardless of exp value.
616        let dir = TempDir::new().unwrap();
617        let cache_path = dir.path().join("auth.toml");
618        let token = make_jwt(&serde_json::json!({
619            "sub": "http://rdfh.ch/users/root",
620            "exp": fixed_past_expires().timestamp(),
621        }));
622        let cfg = make_cfg(SERVER);
623        let client = MockDspClient::ok(); // probe returns 200
624        let mut renderer = RecordingRenderer::new();
625
626        // Must succeed and cache the token — exp is not locally enforced.
627        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
628
629        let loaded = AuthCache::load_from(&cache_path).unwrap();
630        assert_eq!(
631            loaded.token(SERVER),
632            Some(token.as_str()),
633            "locally-expired token should still be cached when probe returns Ok"
634        );
635    }
636
637    #[test]
638    fn non_jwt_stdin_returns_usage_error_nothing_cached() {
639        let dir = TempDir::new().unwrap();
640        let cache_path = dir.path().join("auth.toml");
641        let cfg = make_cfg(SERVER);
642        let client = MockDspClient::ok();
643        let mut renderer = RecordingRenderer::new();
644
645        let err =
646            run_impl("not-a-jwt", &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
647        assert!(
648            matches!(err, Diagnostic::Usage(_)),
649            "expected Usage diagnostic for non-JWT input, got {err:?}"
650        );
651        // Pin the distinct decode-failure message so it can't silently collapse
652        // into the empty-guard message.
653        assert!(
654            err.to_string().contains("not a valid JWT"),
655            "decode-failure message must contain 'not a valid JWT', got: {err}"
656        );
657
658        // Nothing should have been cached.
659        let loaded = AuthCache::load_from(&cache_path).unwrap();
660        assert_eq!(
661            loaded.token(SERVER),
662            None,
663            "non-JWT input must not write anything to the cache"
664        );
665    }
666
667    #[test]
668    fn empty_stdin_returns_usage_no_token_nothing_cached() {
669        // Guards the real empty-guard path in `run_from_line` (previously untested:
670        // the old test called `run_impl("")` which hit the JWT-decode branch with a
671        // DIFFERENT message — "not a valid JWT" — not the empty-guard path).
672        let dir = TempDir::new().unwrap();
673        let cache_path = dir.path().join("auth.toml");
674        let cfg = make_cfg(SERVER);
675        let client = MockDspClient::ok();
676        let mut renderer = RecordingRenderer::new();
677
678        let err = run_from_line(
679            String::new(),
680            &cfg,
681            &client,
682            &mut renderer,
683            Some(&cache_path),
684        )
685        .unwrap_err();
686        assert!(
687            matches!(err, Diagnostic::Usage(_)),
688            "expected Usage for empty stdin line, got {err:?}"
689        );
690        assert!(
691            err.to_string().contains("no token provided"),
692            "empty-guard message must contain 'no token provided', got: {err}"
693        );
694
695        // Nothing should have been cached.
696        let loaded = AuthCache::load_from(&cache_path).unwrap();
697        assert_eq!(
698            loaded.token(SERVER),
699            None,
700            "empty stdin must not write anything to the cache"
701        );
702    }
703
704    #[test]
705    fn newline_only_stdin_returns_usage_no_token() {
706        // A bare newline from stdin (e.g. user pressed Enter) trims to empty string
707        // and must hit the empty-guard path, not the JWT-decode path.
708        let dir = TempDir::new().unwrap();
709        let cache_path = dir.path().join("auth.toml");
710        let cfg = make_cfg(SERVER);
711        let client = MockDspClient::ok();
712        let mut renderer = RecordingRenderer::new();
713
714        let err = run_from_line(
715            "\n".to_string(),
716            &cfg,
717            &client,
718            &mut renderer,
719            Some(&cache_path),
720        )
721        .unwrap_err();
722        assert!(
723            matches!(err, Diagnostic::Usage(_)),
724            "expected Usage for newline-only stdin line, got {err:?}"
725        );
726        assert!(
727            err.to_string().contains("no token provided"),
728            "newline-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
729        );
730    }
731
732    #[test]
733    fn whitespace_only_stdin_returns_usage_no_token() {
734        // A spaces-only line (no newline) must hit the empty-guard path.
735        // Before the fix, `line.is_empty()` let "   " fall through to JWT-decode;
736        // after the fix, `line.trim().is_empty()` catches it and returns the
737        // "no token provided" message.
738        let dir = TempDir::new().unwrap();
739        let cache_path = dir.path().join("auth.toml");
740        let cfg = make_cfg(SERVER);
741        let client = MockDspClient::ok();
742        let mut renderer = RecordingRenderer::new();
743
744        let err = run_from_line(
745            "   ".to_string(),
746            &cfg,
747            &client,
748            &mut renderer,
749            Some(&cache_path),
750        )
751        .unwrap_err();
752        assert!(
753            matches!(err, Diagnostic::Usage(_)),
754            "expected Usage for whitespace-only stdin, got {err:?}"
755        );
756        assert!(
757            err.to_string().contains("no token provided"),
758            "whitespace-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
759        );
760
761        // Nothing should have been cached.
762        let loaded = AuthCache::load_from(&cache_path).unwrap();
763        assert_eq!(
764            loaded.token(SERVER),
765            None,
766            "whitespace-only stdin must not write anything to the cache"
767        );
768    }
769
770    #[test]
771    fn probe_401_returns_auth_required_nothing_cached() {
772        let dir = TempDir::new().unwrap();
773        let cache_path = dir.path().join("auth.toml");
774        let token = make_jwt(&serde_json::json!({
775            "sub": "http://rdfh.ch/users/root",
776            "exp": fixed_expires().timestamp(),
777        }));
778        let cfg = make_cfg(SERVER);
779        let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
780            "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
781        )));
782        let mut renderer = RecordingRenderer::new();
783
784        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
785        assert!(
786            matches!(err, Diagnostic::AuthRequired(_)),
787            "expected AuthRequired for probe 401, got {err:?}"
788        );
789
790        // Nothing should have been cached.
791        let loaded = AuthCache::load_from(&cache_path).unwrap();
792        assert_eq!(
793            loaded.token(SERVER),
794            None,
795            "probe 401 must not write anything to the cache"
796        );
797    }
798
799    #[test]
800    fn probe_403_returns_auth_required_nothing_cached() {
801        // At the action layer the mock injects an identical `Diagnostic::AuthRequired`
802        // as the 401 test — this only proves AuthRequired propagates and nothing is
803        // cached. The real 401-vs-403 HTTP-status distinction is covered by
804        // `tests/set_token_http.rs`.
805        let dir = TempDir::new().unwrap();
806        let cache_path = dir.path().join("auth.toml");
807        let token = make_jwt(&serde_json::json!({
808            "sub": "http://rdfh.ch/users/root",
809            "exp": fixed_expires().timestamp(),
810        }));
811        let cfg = make_cfg(SERVER);
812        let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
813            "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
814        )));
815        let mut renderer = RecordingRenderer::new();
816
817        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
818        assert!(
819            matches!(err, Diagnostic::AuthRequired(_)),
820            "expected AuthRequired for probe 403, got {err:?}"
821        );
822
823        let loaded = AuthCache::load_from(&cache_path).unwrap();
824        assert_eq!(
825            loaded.token(SERVER),
826            None,
827            "probe 403 must not write anything to the cache"
828        );
829    }
830
831    #[test]
832    fn probe_server_error_returns_server_error_nothing_cached() {
833        // Closes the action-layer ServerError gap: when verify_token returns
834        // Err(ServerError), run_impl must propagate it and leave the cache clean.
835        // The wiremock test already covers the HTTP-level mapping; this test covers
836        // the action layer independently.
837        let dir = TempDir::new().unwrap();
838        let cache_path = dir.path().join("auth.toml");
839        let token = make_jwt(&serde_json::json!({
840            "sub": "http://rdfh.ch/users/root",
841            "exp": fixed_expires().timestamp(),
842        }));
843        let cfg = make_cfg(SERVER);
844        let client = MockDspClient::err(Diagnostic::ServerError(
845            "server returned 500 Internal Server Error".to_string(),
846        ));
847        let mut renderer = RecordingRenderer::new();
848
849        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
850        assert!(
851            matches!(err, Diagnostic::ServerError(_)),
852            "expected ServerError when verify_token returns ServerError, got {err:?}"
853        );
854
855        // Nothing should have been cached.
856        let loaded = AuthCache::load_from(&cache_path).unwrap();
857        assert_eq!(
858            loaded.token(SERVER),
859            None,
860            "server error must not write anything to the cache"
861        );
862    }
863
864    #[test]
865    fn probe_network_error_propagates_nothing_cached() {
866        let dir = TempDir::new().unwrap();
867        let cache_path = dir.path().join("auth.toml");
868        let token = make_jwt(&serde_json::json!({
869            "sub": "http://rdfh.ch/users/root",
870            "exp": fixed_expires().timestamp(),
871        }));
872        let cfg = make_cfg(SERVER);
873        let client = MockDspClient::err(Diagnostic::Network("connection refused".to_string()));
874        let mut renderer = RecordingRenderer::new();
875
876        let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
877        assert!(
878            matches!(err, Diagnostic::Network(_)),
879            "expected Network diagnostic, got {err:?}"
880        );
881
882        let loaded = AuthCache::load_from(&cache_path).unwrap();
883        assert_eq!(
884            loaded.token(SERVER),
885            None,
886            "network failure must not write anything to the cache"
887        );
888    }
889
890    #[test]
891    fn probe_failure_does_not_overwrite_existing_entry() {
892        // Guards that a pre-existing valid token is not replaced by a failed set-token.
893        let dir = TempDir::new().unwrap();
894        let cache_path = dir.path().join("auth.toml");
895
896        // Pre-populate with a good entry.
897        let mut cache = AuthCache::default();
898        cache.set_entry(
899            SERVER,
900            ServerEntry {
901                token: "existing-valid-tok".to_string(),
902                user: Some("existing@user.test".to_string()),
903                acquired_at: None,
904                expires_at: None,
905            },
906        );
907        cache.save_to(&cache_path).unwrap();
908
909        let token = make_jwt(&serde_json::json!({
910            "sub": "http://rdfh.ch/users/new",
911            "exp": fixed_expires().timestamp(),
912        }));
913        let cfg = make_cfg(SERVER);
914        let client = MockDspClient::err(Diagnostic::AuthRequired("rejected".to_string()));
915        let mut renderer = RecordingRenderer::new();
916
917        run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
918
919        let loaded = AuthCache::load_from(&cache_path).unwrap();
920        assert_eq!(
921            loaded.token(SERVER),
922            Some("existing-valid-tok"),
923            "failed set-token must not overwrite an existing valid cache entry"
924        );
925    }
926}