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