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