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(cfg: &Config, client: &dyn DspClient, renderer: &mut dyn Renderer) -> Result<(), Diagnostic> {
26 let mut line = String::new();
27 std::io::stdin()
32 .read_line(&mut line)
33 .map_err(|e| Diagnostic::Usage(format!("could not read token from stdin: {e}")))?;
34 run_from_line(line, cfg, client, renderer, None)
35}
36
37fn run_from_line(
43 mut line: String,
44 cfg: &Config,
45 client: &dyn DspClient,
46 renderer: &mut dyn Renderer,
47 cache_path: Option<&Path>,
48) -> Result<(), Diagnostic> {
49 crate::actions::auth::trim_line_ending(&mut line);
52
53 let token = line.trim();
60 if token.is_empty() {
61 return Err(Diagnostic::Usage("no token provided on stdin".to_string()));
62 }
63
64 run_impl(token, cfg, client, renderer, cache_path)
65}
66
67fn run_impl(
79 token: &str,
80 cfg: &Config,
81 client: &dyn DspClient,
82 renderer: &mut dyn Renderer,
83 cache_path: Option<&Path>,
84) -> Result<(), Diagnostic> {
85 let meta = crate::client::jwt::extract_meta(token)
90 .ok_or_else(|| Diagnostic::Usage("input on stdin is not a valid JWT".to_string()))?;
91
92 client.verify_token(&cfg.server, token)?;
96
97 let entry = ServerEntry {
99 token: token.to_string(),
100 user: meta.sub.clone(),
101 acquired_at: Some(Utc::now()),
102 expires_at: meta.exp,
103 };
104
105 let mut cache = match cache_path {
106 Some(p) => AuthCache::load_from(p)?,
107 None => AuthCache::load()?,
108 };
109 cache.set_entry(&cfg.server, entry);
110 match cache_path {
111 Some(p) => cache.save_to(p)?,
112 None => cache.save()?,
113 }
114
115 let resolved_for_meta = ResolvedToken { token: token.to_string(), origin: TokenOrigin::Cache };
120 let meta_ctx = MetaContext {
121 server_label: cfg.server.clone(),
122 auth_state: read_auth_state(Some(&resolved_for_meta), &cache, &cfg.server),
123 filter_warning: None,
124 count_caveat: None,
125 count_cost: None,
126 };
127
128 let outcome = AuthSetTokenOutcome {
130 server: cfg.server.clone(),
131 user: meta.sub,
132 expires_at: meta.exp,
133 };
134
135 renderer.auth_set_token(&outcome, &meta_ctx)
136}
137
138#[cfg(test)]
139mod tests {
140 use chrono::{TimeZone, Utc};
141 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
142 use tempfile::TempDir;
143
144 use super::{run_from_line, run_impl};
145 use crate::client::DspClient;
146 use crate::config::auth_cache::ServerEntry;
147 use crate::config::{AuthCache, Config};
148 use crate::diagnostic::Diagnostic;
149 use crate::model::{CreateDumpOutcome, DumpTask, LoginResponse, ProjectRef};
150 use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
151 use crate::render::{MetaContext, Renderer};
152
153 struct MockDspClient {
156 verify_token_result: Result<(), Diagnostic>,
157 }
158
159 impl MockDspClient {
160 fn ok() -> Self {
161 Self { verify_token_result: Ok(()) }
162 }
163
164 fn err(diag: Diagnostic) -> Self {
165 Self { verify_token_result: Err(diag) }
166 }
167 }
168
169 impl DspClient for MockDspClient {
170 fn login(&self, _server: &str, _user: &str, _password: &str) -> Result<LoginResponse, Diagnostic> {
171 unimplemented!("login not used by set-token tests")
172 }
173
174 fn resolve_project(&self, _server: &str, _project: &str) -> Result<ProjectRef, Diagnostic> {
175 unimplemented!("resolve_project not used by set-token tests")
176 }
177
178 fn create_project_dump(
179 &self,
180 _server: &str,
181 _project_iri: &str,
182 _skip_assets: bool,
183 _token: &str,
184 ) -> Result<CreateDumpOutcome, Diagnostic> {
185 unimplemented!("create_project_dump not used by set-token tests")
186 }
187
188 fn get_project_dump_status(
189 &self,
190 _server: &str,
191 _project_iri: &str,
192 _dump_id: &str,
193 _token: &str,
194 ) -> Result<DumpTask, Diagnostic> {
195 unimplemented!("get_project_dump_status not used by set-token tests")
196 }
197
198 fn download_project_dump(
199 &self,
200 _server: &str,
201 _project_iri: &str,
202 _dump_id: &str,
203 _token: &str,
204 _dest: &mut dyn std::io::Write,
205 ) -> Result<u64, Diagnostic> {
206 unimplemented!("download_project_dump not used by set-token tests")
207 }
208
209 fn delete_project_dump(
210 &self,
211 _server: &str,
212 _project_iri: &str,
213 _dump_id: &str,
214 _token: &str,
215 ) -> Result<(), Diagnostic> {
216 unimplemented!("delete_project_dump not used by set-token tests")
217 }
218
219 fn describe_project(
220 &self,
221 _server: &str,
222 _project: &str,
223 _token: Option<&str>,
224 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
225 unimplemented!("describe_project not used by set-token tests")
226 }
227
228 fn list_projects(&self, _server: &str, _token: Option<&str>) -> Result<Vec<crate::model::Project>, Diagnostic> {
229 Err(Diagnostic::NotImplemented(
230 "list_projects not used in set_token.rs tests".into(),
231 ))
232 }
233
234 fn list_data_models(
235 &self,
236 _server: &str,
237 _project_iri: &str,
238 _token: Option<&str>,
239 ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
240 unimplemented!("list_data_models not used by set-token tests")
241 }
242
243 fn describe_data_model(
244 &self,
245 _server: &str,
246 _data_model_iri: &str,
247 _token: Option<&str>,
248 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
249 unimplemented!("describe_data_model not used in set-token tests")
250 }
251
252 fn describe_resource_type(
253 &self,
254 _server: &str,
255 _data_model_iri: &str,
256 _resource_type: &str,
257 _token: Option<&str>,
258 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
259 unimplemented!("describe_resource_type not used in set-token tests")
260 }
261
262 fn data_model_structure(
263 &self,
264 _server: &str,
265 _data_model_iri: &str,
266 _token: Option<&str>,
267 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
268 unimplemented!("data_model_structure not used in set-token tests")
269 }
270
271 fn list_resources(
272 &self,
273 _server: &str,
274 _project_iri: &str,
275 _resource_type_iri: &str,
276 _order_by: Option<&str>,
277 _page: u32,
278 _token: Option<&str>,
279 ) -> Result<crate::model::ResourcePage, Diagnostic> {
280 unimplemented!("list_resources not used in set-token tests")
281 }
282
283 fn describe_resource(
284 &self,
285 _server: &str,
286 _resource_iri: &str,
287 _token: Option<&str>,
288 _with_values: bool,
289 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
290 unimplemented!("describe_resource not used in set-token tests")
291 }
292
293 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
294 self.verify_token_result.clone()
295 }
296
297 fn resource_counts(
298 &self,
299 _server: &str,
300 _project_iri: &str,
301 _token: Option<&str>,
302 ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
303 Ok(std::collections::HashMap::new())
304 }
305
306 fn list_vocabularies(
307 &self,
308 _server: &str,
309 _project_iri: &str,
310 _token: Option<&str>,
311 ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
312 unimplemented!("not exercised by this file's tests")
313 }
314
315 fn describe_vocabulary(
316 &self,
317 _server: &str,
318 _iri: &str,
319 _token: Option<&str>,
320 ) -> Result<crate::model::VocabularyTree, Diagnostic> {
321 unimplemented!("not exercised by this file's tests")
322 }
323
324 fn sparql_query(
325 &self,
326 _server: &str,
327 _token: &str,
328 _query: &str,
329 _accept: &str,
330 _timeout_secs: u64,
331 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
332 Err(Diagnostic::Internal("not used in this test".into()))
333 }
334 }
335
336 struct RecordingRenderer {
339 set_token_outcome: Option<AuthSetTokenOutcome>,
340 set_token_auth_state: Option<String>,
341 }
342
343 impl RecordingRenderer {
344 fn new() -> Self {
345 Self { set_token_outcome: None, set_token_auth_state: None }
346 }
347 }
348
349 impl Renderer for RecordingRenderer {
350 fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
351 Ok(())
352 }
353
354 fn auth_login(&mut self, _outcome: &AuthLoginOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
355 Ok(())
356 }
357
358 fn auth_status(&mut self, _outcome: &AuthStatusOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
359 Ok(())
360 }
361
362 fn auth_logout(&mut self, _outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
363 Ok(())
364 }
365
366 fn auth_set_token(&mut self, outcome: &AuthSetTokenOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
367 self.set_token_outcome = Some(AuthSetTokenOutcome {
368 server: outcome.server.clone(),
369 user: outcome.user.clone(),
370 expires_at: outcome.expires_at,
371 });
372 self.set_token_auth_state = Some(meta.auth_state.clone());
373 Ok(())
374 }
375
376 fn project_dump(
377 &mut self,
378 _outcome: &crate::render::DumpOutcome,
379 _meta: &MetaContext,
380 ) -> Result<(), Diagnostic> {
381 Ok(())
382 }
383
384 fn project_dump_deleted(
385 &mut self,
386 _outcome: &crate::render::DumpDeleteOutcome,
387 _meta: &MetaContext,
388 ) -> Result<(), Diagnostic> {
389 Ok(())
390 }
391
392 fn projects(&mut self, _view: &crate::render::ProjectListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
393 Ok(())
394 }
395
396 fn project_describe(
397 &mut self,
398 _project: &crate::model::ProjectDetail,
399 _meta: &MetaContext,
400 ) -> Result<(), Diagnostic> {
401 Ok(())
402 }
403
404 fn data_models(
405 &mut self,
406 _view: &crate::render::DataModelListView,
407 _meta: &MetaContext,
408 ) -> Result<(), Diagnostic> {
409 Ok(())
410 }
411
412 fn data_model_describe(
413 &mut self,
414 _detail: &crate::model::DataModelDetail,
415 _meta: &MetaContext,
416 ) -> Result<(), Diagnostic> {
417 Ok(())
418 }
419
420 fn resource_types(
421 &mut self,
422 _view: &crate::render::ResourceTypeListView,
423 _meta: &MetaContext,
424 ) -> Result<(), Diagnostic> {
425 Ok(())
426 }
427
428 fn resource_type_describe(
429 &mut self,
430 _detail: &crate::model::ResourceTypeDetail,
431 _meta: &MetaContext,
432 ) -> Result<(), Diagnostic> {
433 unimplemented!("resource_type_describe not used in set-token tests")
434 }
435
436 fn data_model_structure(
437 &mut self,
438 _structure: &crate::model::DataModelStructure,
439 _meta: &MetaContext,
440 ) -> Result<(), Diagnostic> {
441 unimplemented!("data_model_structure not used in set-token tests")
442 }
443
444 fn resources(
445 &mut self,
446 _view: &crate::render::ResourceListView,
447 _meta: &MetaContext,
448 ) -> Result<(), Diagnostic> {
449 Ok(())
450 }
451
452 fn resource_describe(
453 &mut self,
454 _detail: &crate::model::ResourceDetail,
455 _meta: &MetaContext,
456 ) -> Result<(), Diagnostic> {
457 Ok(())
458 }
459
460 fn vocabularies(
461 &mut self,
462 _view: &crate::render::VocabularyListView,
463 _meta: &MetaContext,
464 ) -> Result<(), Diagnostic> {
465 unimplemented!("not exercised by this file's tests")
466 }
467
468 fn vocabulary_describe(
469 &mut self,
470 _detail: &crate::model::VocabularyDetail,
471 _meta: &MetaContext,
472 ) -> Result<(), Diagnostic> {
473 unimplemented!("not exercised by this file's tests")
474 }
475 }
476
477 fn make_jwt(payload: &serde_json::Value) -> String {
482 encode(&Header::new(Algorithm::HS256), payload, &EncodingKey::from_secret(b"unused"))
483 .expect("test JWT encoding should not fail")
484 }
485
486 fn fixed_expires() -> chrono::DateTime<Utc> {
487 Utc.with_ymd_and_hms(2099, 6, 25, 12, 34, 56).unwrap()
488 }
489
490 fn fixed_past_expires() -> chrono::DateTime<Utc> {
491 Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
492 }
493
494 fn make_cfg(server: &str) -> Config {
495 Config { server: server.to_string() }
496 }
497
498 const SERVER: &str = "https://api.test.dasch.swiss";
499
500 #[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 dsp-cli/ADR-0007 vocabulary and include the sub IRI"
543 );
544 }
545
546 #[test]
547 fn space_padded_jwt_is_trimmed_before_decode_and_cache() {
548 let dir = TempDir::new().unwrap();
553 let cache_path = dir.path().join("auth.toml");
554 let token = make_jwt(&serde_json::json!({
555 "sub": "http://rdfh.ch/users/root",
556 "exp": fixed_expires().timestamp(),
557 }));
558 let cfg = make_cfg(SERVER);
559 let client = MockDspClient::ok();
560 let mut renderer = RecordingRenderer::new();
561
562 run_from_line(format!(" {token} "), &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
563
564 let loaded = AuthCache::load_from(&cache_path).unwrap();
565 assert_eq!(
566 loaded.token(SERVER),
567 Some(token.as_str()),
568 "the cached token must be the whitespace-trimmed JWT"
569 );
570 }
571
572 #[test]
573 fn happy_path_sub_absent_caches_user_none() {
574 let dir = TempDir::new().unwrap();
575 let cache_path = dir.path().join("auth.toml");
576 let token = make_jwt(&serde_json::json!({
578 "exp": fixed_expires().timestamp(),
579 }));
580 let cfg = make_cfg(SERVER);
581 let client = MockDspClient::ok();
582 let mut renderer = RecordingRenderer::new();
583
584 run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
585
586 let loaded = AuthCache::load_from(&cache_path).unwrap();
587 assert_eq!(loaded.user(SERVER), None, "user should be None when sub absent");
588
589 let outcome = renderer.set_token_outcome.unwrap();
590 assert_eq!(outcome.user, None, "outcome.user should be None when sub absent");
591 assert_eq!(
592 renderer.set_token_auth_state.as_deref(),
593 Some("authenticated"),
594 "auth_state should be 'authenticated' (no sub, dsp-cli/ADR-0007 vocabulary)"
595 );
596 }
597
598 #[test]
599 fn probe_200_with_locally_expired_token_still_cached() {
600 let dir = TempDir::new().unwrap();
603 let cache_path = dir.path().join("auth.toml");
604 let token = make_jwt(&serde_json::json!({
605 "sub": "http://rdfh.ch/users/root",
606 "exp": fixed_past_expires().timestamp(),
607 }));
608 let cfg = make_cfg(SERVER);
609 let client = MockDspClient::ok(); let mut renderer = RecordingRenderer::new();
611
612 run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap();
614
615 let loaded = AuthCache::load_from(&cache_path).unwrap();
616 assert_eq!(
617 loaded.token(SERVER),
618 Some(token.as_str()),
619 "locally-expired token should still be cached when probe returns Ok"
620 );
621 }
622
623 #[test]
624 fn non_jwt_stdin_returns_usage_error_nothing_cached() {
625 let dir = TempDir::new().unwrap();
626 let cache_path = dir.path().join("auth.toml");
627 let cfg = make_cfg(SERVER);
628 let client = MockDspClient::ok();
629 let mut renderer = RecordingRenderer::new();
630
631 let err = run_impl("not-a-jwt", &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
632 assert!(
633 matches!(err, Diagnostic::Usage(_)),
634 "expected Usage diagnostic for non-JWT input, got {err:?}"
635 );
636 assert!(
639 err.to_string().contains("not a valid JWT"),
640 "decode-failure message must contain 'not a valid JWT', got: {err}"
641 );
642
643 let loaded = AuthCache::load_from(&cache_path).unwrap();
645 assert_eq!(loaded.token(SERVER), None, "non-JWT input must not write anything to the cache");
646 }
647
648 #[test]
649 fn empty_stdin_returns_usage_no_token_nothing_cached() {
650 let dir = TempDir::new().unwrap();
654 let cache_path = dir.path().join("auth.toml");
655 let cfg = make_cfg(SERVER);
656 let client = MockDspClient::ok();
657 let mut renderer = RecordingRenderer::new();
658
659 let err = run_from_line(String::new(), &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
660 assert!(
661 matches!(err, Diagnostic::Usage(_)),
662 "expected Usage for empty stdin line, got {err:?}"
663 );
664 assert!(
665 err.to_string().contains("no token provided"),
666 "empty-guard message must contain 'no token provided', got: {err}"
667 );
668
669 let loaded = AuthCache::load_from(&cache_path).unwrap();
671 assert_eq!(loaded.token(SERVER), None, "empty stdin must not write anything to the cache");
672 }
673
674 #[test]
675 fn newline_only_stdin_returns_usage_no_token() {
676 let dir = TempDir::new().unwrap();
679 let cache_path = dir.path().join("auth.toml");
680 let cfg = make_cfg(SERVER);
681 let client = MockDspClient::ok();
682 let mut renderer = RecordingRenderer::new();
683
684 let err = run_from_line("\n".to_string(), &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
685 assert!(
686 matches!(err, Diagnostic::Usage(_)),
687 "expected Usage for newline-only stdin line, got {err:?}"
688 );
689 assert!(
690 err.to_string().contains("no token provided"),
691 "newline-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
692 );
693 }
694
695 #[test]
696 fn whitespace_only_stdin_returns_usage_no_token() {
697 let dir = TempDir::new().unwrap();
702 let cache_path = dir.path().join("auth.toml");
703 let cfg = make_cfg(SERVER);
704 let client = MockDspClient::ok();
705 let mut renderer = RecordingRenderer::new();
706
707 let err = run_from_line(" ".to_string(), &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
708 assert!(
709 matches!(err, Diagnostic::Usage(_)),
710 "expected Usage for whitespace-only stdin, got {err:?}"
711 );
712 assert!(
713 err.to_string().contains("no token provided"),
714 "whitespace-only stdin must hit the empty-guard path; message must contain 'no token provided', got: {err}"
715 );
716
717 let loaded = AuthCache::load_from(&cache_path).unwrap();
719 assert_eq!(
720 loaded.token(SERVER),
721 None,
722 "whitespace-only stdin must not write anything to the cache"
723 );
724 }
725
726 #[test]
727 fn probe_401_returns_auth_required_nothing_cached() {
728 let dir = TempDir::new().unwrap();
729 let cache_path = dir.path().join("auth.toml");
730 let token = make_jwt(&serde_json::json!({
731 "sub": "http://rdfh.ch/users/root",
732 "exp": fixed_expires().timestamp(),
733 }));
734 let cfg = make_cfg(SERVER);
735 let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
736 "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
737 )));
738 let mut renderer = RecordingRenderer::new();
739
740 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
741 assert!(
742 matches!(err, Diagnostic::AuthRequired(_)),
743 "expected AuthRequired for probe 401, got {err:?}"
744 );
745
746 let loaded = AuthCache::load_from(&cache_path).unwrap();
748 assert_eq!(loaded.token(SERVER), None, "probe 401 must not write anything to the cache");
749 }
750
751 #[test]
752 fn probe_403_returns_auth_required_nothing_cached() {
753 let dir = TempDir::new().unwrap();
758 let cache_path = dir.path().join("auth.toml");
759 let token = make_jwt(&serde_json::json!({
760 "sub": "http://rdfh.ch/users/root",
761 "exp": fixed_expires().timestamp(),
762 }));
763 let cfg = make_cfg(SERVER);
764 let client = MockDspClient::err(Diagnostic::AuthRequired(format!(
765 "token rejected by {SERVER} — it may be expired, revoked, or for a different environment"
766 )));
767 let mut renderer = RecordingRenderer::new();
768
769 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
770 assert!(
771 matches!(err, Diagnostic::AuthRequired(_)),
772 "expected AuthRequired for probe 403, got {err:?}"
773 );
774
775 let loaded = AuthCache::load_from(&cache_path).unwrap();
776 assert_eq!(loaded.token(SERVER), None, "probe 403 must not write anything to the cache");
777 }
778
779 #[test]
780 fn probe_server_error_returns_server_error_nothing_cached() {
781 let dir = TempDir::new().unwrap();
786 let cache_path = dir.path().join("auth.toml");
787 let token = make_jwt(&serde_json::json!({
788 "sub": "http://rdfh.ch/users/root",
789 "exp": fixed_expires().timestamp(),
790 }));
791 let cfg = make_cfg(SERVER);
792 let client =
793 MockDspClient::err(Diagnostic::ServerError("server returned 500 Internal Server Error".to_string()));
794 let mut renderer = RecordingRenderer::new();
795
796 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
797 assert!(
798 matches!(err, Diagnostic::ServerError(_)),
799 "expected ServerError when verify_token returns ServerError, got {err:?}"
800 );
801
802 let loaded = AuthCache::load_from(&cache_path).unwrap();
804 assert_eq!(loaded.token(SERVER), None, "server error must not write anything to the cache");
805 }
806
807 #[test]
808 fn probe_network_error_propagates_nothing_cached() {
809 let dir = TempDir::new().unwrap();
810 let cache_path = dir.path().join("auth.toml");
811 let token = make_jwt(&serde_json::json!({
812 "sub": "http://rdfh.ch/users/root",
813 "exp": fixed_expires().timestamp(),
814 }));
815 let cfg = make_cfg(SERVER);
816 let client = MockDspClient::err(Diagnostic::Network("connection refused".to_string()));
817 let mut renderer = RecordingRenderer::new();
818
819 let err = run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
820 assert!(
821 matches!(err, Diagnostic::Network(_)),
822 "expected Network diagnostic, got {err:?}"
823 );
824
825 let loaded = AuthCache::load_from(&cache_path).unwrap();
826 assert_eq!(
827 loaded.token(SERVER),
828 None,
829 "network failure must not write anything to the cache"
830 );
831 }
832
833 #[test]
834 fn probe_failure_does_not_overwrite_existing_entry() {
835 let dir = TempDir::new().unwrap();
837 let cache_path = dir.path().join("auth.toml");
838
839 let mut cache = AuthCache::default();
841 cache.set_entry(
842 SERVER,
843 ServerEntry {
844 token: "existing-valid-tok".to_string(),
845 user: Some("existing@user.test".to_string()),
846 acquired_at: None,
847 expires_at: None,
848 },
849 );
850 cache.save_to(&cache_path).unwrap();
851
852 let token = make_jwt(&serde_json::json!({
853 "sub": "http://rdfh.ch/users/new",
854 "exp": fixed_expires().timestamp(),
855 }));
856 let cfg = make_cfg(SERVER);
857 let client = MockDspClient::err(Diagnostic::AuthRequired("rejected".to_string()));
858 let mut renderer = RecordingRenderer::new();
859
860 run_impl(&token, &cfg, &client, &mut renderer, Some(&cache_path)).unwrap_err();
861
862 let loaded = AuthCache::load_from(&cache_path).unwrap();
863 assert_eq!(
864 loaded.token(SERVER),
865 Some("existing-valid-tok"),
866 "failed set-token must not overwrite an existing valid cache entry"
867 );
868 }
869}