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