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