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 fn sparql_query(
348 &self,
349 _server: &str,
350 _token: &str,
351 _query: &str,
352 _accept: &str,
353 _timeout_secs: u64,
354 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
355 Err(Diagnostic::Internal("not used in this test".into()))
356 }
357 }
358
359 struct RecordingRenderer {
362 set_token_outcome: Option<AuthSetTokenOutcome>,
363 set_token_auth_state: Option<String>,
364 }
365
366 impl RecordingRenderer {
367 fn new() -> Self {
368 Self {
369 set_token_outcome: None,
370 set_token_auth_state: None,
371 }
372 }
373 }
374
375 impl Renderer for RecordingRenderer {
376 fn diagnostic(
377 &mut self,
378 _diag: &Diagnostic,
379 _meta: &MetaContext,
380 ) -> Result<(), Diagnostic> {
381 Ok(())
382 }
383
384 fn auth_login(
385 &mut self,
386 _outcome: &AuthLoginOutcome,
387 _meta: &MetaContext,
388 ) -> Result<(), Diagnostic> {
389 Ok(())
390 }
391
392 fn auth_status(
393 &mut self,
394 _outcome: &AuthStatusOutcome,
395 _meta: &MetaContext,
396 ) -> Result<(), Diagnostic> {
397 Ok(())
398 }
399
400 fn auth_logout(
401 &mut self,
402 _outcome: &AuthLogoutOutcome,
403 _meta: &MetaContext,
404 ) -> Result<(), Diagnostic> {
405 Ok(())
406 }
407
408 fn auth_set_token(
409 &mut self,
410 outcome: &AuthSetTokenOutcome,
411 meta: &MetaContext,
412 ) -> Result<(), Diagnostic> {
413 self.set_token_outcome = Some(AuthSetTokenOutcome {
414 server: outcome.server.clone(),
415 user: outcome.user.clone(),
416 expires_at: outcome.expires_at,
417 });
418 self.set_token_auth_state = Some(meta.auth_state.clone());
419 Ok(())
420 }
421
422 fn project_dump(
423 &mut self,
424 _outcome: &crate::render::DumpOutcome,
425 _meta: &MetaContext,
426 ) -> Result<(), Diagnostic> {
427 Ok(())
428 }
429
430 fn project_dump_deleted(
431 &mut self,
432 _outcome: &crate::render::DumpDeleteOutcome,
433 _meta: &MetaContext,
434 ) -> Result<(), Diagnostic> {
435 Ok(())
436 }
437
438 fn projects(
439 &mut self,
440 _view: &crate::render::ProjectListView,
441 _meta: &MetaContext,
442 ) -> Result<(), Diagnostic> {
443 Ok(())
444 }
445
446 fn project_describe(
447 &mut self,
448 _project: &crate::model::ProjectDetail,
449 _meta: &MetaContext,
450 ) -> Result<(), Diagnostic> {
451 Ok(())
452 }
453
454 fn data_models(
455 &mut self,
456 _view: &crate::render::DataModelListView,
457 _meta: &MetaContext,
458 ) -> Result<(), Diagnostic> {
459 Ok(())
460 }
461
462 fn data_model_describe(
463 &mut self,
464 _detail: &crate::model::DataModelDetail,
465 _meta: &MetaContext,
466 ) -> Result<(), Diagnostic> {
467 Ok(())
468 }
469
470 fn resource_types(
471 &mut self,
472 _view: &crate::render::ResourceTypeListView,
473 _meta: &MetaContext,
474 ) -> Result<(), Diagnostic> {
475 Ok(())
476 }
477
478 fn resource_type_describe(
479 &mut self,
480 _detail: &crate::model::ResourceTypeDetail,
481 _meta: &MetaContext,
482 ) -> Result<(), Diagnostic> {
483 unimplemented!("resource_type_describe not used in set-token tests")
484 }
485
486 fn data_model_structure(
487 &mut self,
488 _structure: &crate::model::DataModelStructure,
489 _meta: &MetaContext,
490 ) -> Result<(), Diagnostic> {
491 unimplemented!("data_model_structure not used in set-token tests")
492 }
493
494 fn resources(
495 &mut self,
496 _view: &crate::render::ResourceListView,
497 _meta: &MetaContext,
498 ) -> Result<(), Diagnostic> {
499 Ok(())
500 }
501
502 fn resource_describe(
503 &mut self,
504 _detail: &crate::model::ResourceDetail,
505 _meta: &MetaContext,
506 ) -> Result<(), Diagnostic> {
507 Ok(())
508 }
509
510 fn vocabularies(
511 &mut self,
512 _view: &crate::render::VocabularyListView,
513 _meta: &MetaContext,
514 ) -> Result<(), Diagnostic> {
515 unimplemented!("not exercised by this file's tests")
516 }
517
518 fn vocabulary_describe(
519 &mut self,
520 _detail: &crate::model::VocabularyDetail,
521 _meta: &MetaContext,
522 ) -> Result<(), Diagnostic> {
523 unimplemented!("not exercised by this file's tests")
524 }
525 }
526
527 fn make_jwt(payload: &serde_json::Value) -> String {
532 encode(
533 &Header::new(Algorithm::HS256),
534 payload,
535 &EncodingKey::from_secret(b"unused"),
536 )
537 .expect("test JWT encoding should not fail")
538 }
539
540 fn fixed_expires() -> chrono::DateTime<Utc> {
541 Utc.with_ymd_and_hms(2099, 6, 25, 12, 34, 56).unwrap()
542 }
543
544 fn fixed_past_expires() -> chrono::DateTime<Utc> {
545 Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
546 }
547
548 fn make_cfg(server: &str) -> Config {
549 Config {
550 server: server.to_string(),
551 }
552 }
553
554 const SERVER: &str = "https://api.test.dasch.swiss";
555
556 #[test]
559 fn happy_path_full_jwt_caches_entry_and_notifies_renderer() {
560 let dir = TempDir::new().unwrap();
561 let cache_path = dir.path().join("auth.toml");
562 let token = make_jwt(&serde_json::json!({
563 "sub": "http://rdfh.ch/users/root",
564 "exp": fixed_expires().timestamp(),
565 }));
566 let cfg = make_cfg(SERVER);
567 let client = MockDspClient::ok();
568 let mut renderer = RecordingRenderer::new();
569
570 run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
571
572 let loaded = AuthCache::load_from(&cache_path).unwrap();
574 assert_eq!(loaded.token(SERVER), Some(token.as_str()));
575 assert_eq!(
576 loaded.user(SERVER),
577 Some("http://rdfh.ch/users/root"),
578 "user should be set to the JWT sub claim"
579 );
580 assert!(
581 loaded.acquired_at(SERVER).is_some(),
582 "acquired_at should be set after set-token"
583 );
584 assert_eq!(
585 loaded.expires_at(SERVER),
586 Some(fixed_expires()),
587 "expires_at should match the JWT exp claim"
588 );
589
590 let outcome = renderer.set_token_outcome.unwrap();
592 assert_eq!(outcome.server, SERVER);
593 assert_eq!(outcome.user.as_deref(), Some("http://rdfh.ch/users/root"));
594 assert_eq!(outcome.expires_at, Some(fixed_expires()));
595 assert_eq!(
596 renderer.set_token_auth_state.as_deref(),
597 Some("authenticated as http://rdfh.ch/users/root"),
598 "auth_state should use ADR-0007 vocabulary and include the sub IRI"
599 );
600 }
601
602 #[test]
603 fn space_padded_jwt_is_trimmed_before_decode_and_cache() {
604 let dir = TempDir::new().unwrap();
609 let cache_path = dir.path().join("auth.toml");
610 let token = make_jwt(&serde_json::json!({
611 "sub": "http://rdfh.ch/users/root",
612 "exp": fixed_expires().timestamp(),
613 }));
614 let cfg = make_cfg(SERVER);
615 let client = MockDspClient::ok();
616 let mut renderer = RecordingRenderer::new();
617
618 run_from_line(
619 format!(" {token} "),
620 &cfg,
621 &client,
622 &mut renderer,
623 Some(&cache_path),
624 )
625 .unwrap();
626
627 let loaded = AuthCache::load_from(&cache_path).unwrap();
628 assert_eq!(
629 loaded.token(SERVER),
630 Some(token.as_str()),
631 "the cached token must be the whitespace-trimmed JWT"
632 );
633 }
634
635 #[test]
636 fn happy_path_sub_absent_caches_user_none() {
637 let dir = TempDir::new().unwrap();
638 let cache_path = dir.path().join("auth.toml");
639 let token = make_jwt(&serde_json::json!({
641 "exp": fixed_expires().timestamp(),
642 }));
643 let cfg = make_cfg(SERVER);
644 let client = MockDspClient::ok();
645 let mut renderer = RecordingRenderer::new();
646
647 run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
648
649 let loaded = AuthCache::load_from(&cache_path).unwrap();
650 assert_eq!(
651 loaded.user(SERVER),
652 None,
653 "user should be None when sub absent"
654 );
655
656 let outcome = renderer.set_token_outcome.unwrap();
657 assert_eq!(
658 outcome.user, None,
659 "outcome.user should be None when sub absent"
660 );
661 assert_eq!(
662 renderer.set_token_auth_state.as_deref(),
663 Some("authenticated"),
664 "auth_state should be 'authenticated' (no sub, ADR-0007 vocabulary)"
665 );
666 }
667
668 #[test]
669 fn probe_200_with_locally_expired_token_still_cached() {
670 let dir = TempDir::new().unwrap();
673 let cache_path = dir.path().join("auth.toml");
674 let token = make_jwt(&serde_json::json!({
675 "sub": "http://rdfh.ch/users/root",
676 "exp": fixed_past_expires().timestamp(),
677 }));
678 let cfg = make_cfg(SERVER);
679 let client = MockDspClient::ok(); let mut renderer = RecordingRenderer::new();
681
682 run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
684
685 let loaded = AuthCache::load_from(&cache_path).unwrap();
686 assert_eq!(
687 loaded.token(SERVER),
688 Some(token.as_str()),
689 "locally-expired token should still be cached when probe returns Ok"
690 );
691 }
692
693 #[test]
694 fn non_jwt_stdin_returns_usage_error_nothing_cached() {
695 let dir = TempDir::new().unwrap();
696 let cache_path = dir.path().join("auth.toml");
697 let cfg = make_cfg(SERVER);
698 let client = MockDspClient::ok();
699 let mut renderer = RecordingRenderer::new();
700
701 let err =
702 run_impl("not-a-jwt", &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
703 assert!(
704 matches!(err, Diagnostic::Usage(_)),
705 "expected Usage diagnostic for non-JWT input, got {err:?}"
706 );
707 assert!(
710 err.to_string().contains("not a valid JWT"),
711 "decode-failure message must contain 'not a valid JWT', got: {err}"
712 );
713
714 let loaded = AuthCache::load_from(&cache_path).unwrap();
716 assert_eq!(
717 loaded.token(SERVER),
718 None,
719 "non-JWT input must not write anything to the cache"
720 );
721 }
722
723 #[test]
724 fn empty_stdin_returns_usage_no_token_nothing_cached() {
725 let dir = TempDir::new().unwrap();
729 let cache_path = dir.path().join("auth.toml");
730 let cfg = make_cfg(SERVER);
731 let client = MockDspClient::ok();
732 let mut renderer = RecordingRenderer::new();
733
734 let err = run_from_line(
735 String::new(),
736 &cfg,
737 &client,
738 &mut renderer,
739 Some(&cache_path),
740 )
741 .unwrap_err();
742 assert!(
743 matches!(err, Diagnostic::Usage(_)),
744 "expected Usage for empty stdin line, got {err:?}"
745 );
746 assert!(
747 err.to_string().contains("no token provided"),
748 "empty-guard message must contain 'no token provided', got: {err}"
749 );
750
751 let loaded = AuthCache::load_from(&cache_path).unwrap();
753 assert_eq!(
754 loaded.token(SERVER),
755 None,
756 "empty stdin must not write anything to the cache"
757 );
758 }
759
760 #[test]
761 fn newline_only_stdin_returns_usage_no_token() {
762 let dir = TempDir::new().unwrap();
765 let cache_path = dir.path().join("auth.toml");
766 let cfg = make_cfg(SERVER);
767 let client = MockDspClient::ok();
768 let mut renderer = RecordingRenderer::new();
769
770 let err = run_from_line(
771 "\n".to_string(),
772 &cfg,
773 &client,
774 &mut renderer,
775 Some(&cache_path),
776 )
777 .unwrap_err();
778 assert!(
779 matches!(err, Diagnostic::Usage(_)),
780 "expected Usage for newline-only stdin line, got {err:?}"
781 );
782 assert!(
783 err.to_string().contains("no token provided"),
784 "newline-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
785 );
786 }
787
788 #[test]
789 fn whitespace_only_stdin_returns_usage_no_token() {
790 let dir = TempDir::new().unwrap();
795 let cache_path = dir.path().join("auth.toml");
796 let cfg = make_cfg(SERVER);
797 let client = MockDspClient::ok();
798 let mut renderer = RecordingRenderer::new();
799
800 let err = run_from_line(
801 " ".to_string(),
802 &cfg,
803 &client,
804 &mut renderer,
805 Some(&cache_path),
806 )
807 .unwrap_err();
808 assert!(
809 matches!(err, Diagnostic::Usage(_)),
810 "expected Usage for whitespace-only stdin, got {err:?}"
811 );
812 assert!(
813 err.to_string().contains("no token provided"),
814 "whitespace-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
815 );
816
817 let loaded = AuthCache::load_from(&cache_path).unwrap();
819 assert_eq!(
820 loaded.token(SERVER),
821 None,
822 "whitespace-only stdin must not write anything to the cache"
823 );
824 }
825
826 #[test]
827 fn probe_401_returns_auth_required_nothing_cached() {
828 let dir = TempDir::new().unwrap();
829 let cache_path = dir.path().join("auth.toml");
830 let token = make_jwt(&serde_json::json!({
831 "sub": "http://rdfh.ch/users/root",
832 "exp": fixed_expires().timestamp(),
833 }));
834 let cfg = make_cfg(SERVER);
835 let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
836 "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
837 )));
838 let mut renderer = RecordingRenderer::new();
839
840 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
841 assert!(
842 matches!(err, Diagnostic::AuthRequired(_)),
843 "expected AuthRequired for probe 401, got {err:?}"
844 );
845
846 let loaded = AuthCache::load_from(&cache_path).unwrap();
848 assert_eq!(
849 loaded.token(SERVER),
850 None,
851 "probe 401 must not write anything to the cache"
852 );
853 }
854
855 #[test]
856 fn probe_403_returns_auth_required_nothing_cached() {
857 let dir = TempDir::new().unwrap();
862 let cache_path = dir.path().join("auth.toml");
863 let token = make_jwt(&serde_json::json!({
864 "sub": "http://rdfh.ch/users/root",
865 "exp": fixed_expires().timestamp(),
866 }));
867 let cfg = make_cfg(SERVER);
868 let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
869 "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
870 )));
871 let mut renderer = RecordingRenderer::new();
872
873 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
874 assert!(
875 matches!(err, Diagnostic::AuthRequired(_)),
876 "expected AuthRequired for probe 403, got {err:?}"
877 );
878
879 let loaded = AuthCache::load_from(&cache_path).unwrap();
880 assert_eq!(
881 loaded.token(SERVER),
882 None,
883 "probe 403 must not write anything to the cache"
884 );
885 }
886
887 #[test]
888 fn probe_server_error_returns_server_error_nothing_cached() {
889 let dir = TempDir::new().unwrap();
894 let cache_path = dir.path().join("auth.toml");
895 let token = make_jwt(&serde_json::json!({
896 "sub": "http://rdfh.ch/users/root",
897 "exp": fixed_expires().timestamp(),
898 }));
899 let cfg = make_cfg(SERVER);
900 let client = MockDspClient::err(Diagnostic::ServerError(
901 "server returned 500 Internal Server Error".to_string(),
902 ));
903 let mut renderer = RecordingRenderer::new();
904
905 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
906 assert!(
907 matches!(err, Diagnostic::ServerError(_)),
908 "expected ServerError when verify_token returns ServerError, got {err:?}"
909 );
910
911 let loaded = AuthCache::load_from(&cache_path).unwrap();
913 assert_eq!(
914 loaded.token(SERVER),
915 None,
916 "server error must not write anything to the cache"
917 );
918 }
919
920 #[test]
921 fn probe_network_error_propagates_nothing_cached() {
922 let dir = TempDir::new().unwrap();
923 let cache_path = dir.path().join("auth.toml");
924 let token = make_jwt(&serde_json::json!({
925 "sub": "http://rdfh.ch/users/root",
926 "exp": fixed_expires().timestamp(),
927 }));
928 let cfg = make_cfg(SERVER);
929 let client = MockDspClient::err(Diagnostic::Network("connection refused".to_string()));
930 let mut renderer = RecordingRenderer::new();
931
932 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
933 assert!(
934 matches!(err, Diagnostic::Network(_)),
935 "expected Network diagnostic, got {err:?}"
936 );
937
938 let loaded = AuthCache::load_from(&cache_path).unwrap();
939 assert_eq!(
940 loaded.token(SERVER),
941 None,
942 "network failure must not write anything to the cache"
943 );
944 }
945
946 #[test]
947 fn probe_failure_does_not_overwrite_existing_entry() {
948 let dir = TempDir::new().unwrap();
950 let cache_path = dir.path().join("auth.toml");
951
952 let mut cache = AuthCache::default();
954 cache.set_entry(
955 SERVER,
956 ServerEntry {
957 token: "existing-valid-tok".to_string(),
958 user: Some("existing@user.test".to_string()),
959 acquired_at: None,
960 expires_at: None,
961 },
962 );
963 cache.save_to(&cache_path).unwrap();
964
965 let token = make_jwt(&serde_json::json!({
966 "sub": "http://rdfh.ch/users/new",
967 "exp": fixed_expires().timestamp(),
968 }));
969 let cfg = make_cfg(SERVER);
970 let client = MockDspClient::err(Diagnostic::AuthRequired("rejected".to_string()));
971 let mut renderer = RecordingRenderer::new();
972
973 run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
974
975 let loaded = AuthCache::load_from(&cache_path).unwrap();
976 assert_eq!(
977 loaded.token(SERVER),
978 Some("existing-valid-tok"),
979 "failed set-token must not overwrite an existing valid cache entry"
980 );
981 }
982}