1use 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
21pub fn run(
26 cfg: &Config,
27 client: &dyn DspClient,
28 renderer: &mut dyn Renderer,
29) -> Result<(), Diagnostic> {
30 let mut line = String::new();
31 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
41fn 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 crate::actions::auth::trim_line_ending(&mut line);
56
57 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
71fn 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 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 client.verify_token(&cfg.server, token)?;
101
102 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 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 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 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 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 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 #[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 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 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 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 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 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(); let mut renderer = RecordingRenderer::new();
635
636 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 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 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 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 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 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 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 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 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 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 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 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 let dir = TempDir::new().unwrap();
904 let cache_path = dir.path().join("auth.toml");
905
906 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}