1use std::path::Path;
7
8use chrono::Utc;
9
10use crate::actions::auth_state::read_auth_state;
11use crate::cli::StatusArgs;
12use crate::config::{AuthCache, Config, TokenOrigin, resolve_token};
13use crate::diagnostic::Diagnostic;
14use crate::render::auth::AuthStatusOutcome;
15use crate::render::{MetaContext, Renderer};
16
17pub fn run(args: &StatusArgs, cfg: &Config, renderer: &mut dyn Renderer) -> Result<(), Diagnostic> {
22 let env_token = std::env::var("DSP_TOKEN").ok();
23 run_impl(args, cfg, renderer, None, env_token)
24}
25
26fn run_impl(
27 _args: &StatusArgs,
28 cfg: &Config,
29 renderer: &mut dyn Renderer,
30 cache_path: Option<&Path>,
31 env_token: Option<String>,
32) -> Result<(), Diagnostic> {
33 let env_token_would_win = env_token
38 .as_deref()
39 .map(str::trim)
40 .map(|s| !s.is_empty())
41 .unwrap_or(false);
42
43 let cache_result = match cache_path {
44 Some(p) => AuthCache::load_from(p),
45 None => AuthCache::load(),
46 };
47 let cache = match cache_result {
48 Ok(c) => c,
49 Err(e) if env_token_would_win => {
50 tracing::warn!(
51 error = %e,
52 "auth cache load failed; DSP_TOKEN is set, falling through to env token"
53 );
54 AuthCache::default()
55 }
56 Err(e) => return Err(e),
57 };
58
59 let (outcome, resolved_opt) = match resolve_token(env_token, &cache, &cfg.server) {
60 Some(resolved) if resolved.origin == TokenOrigin::Env => {
61 let token = resolved.token.clone();
63 let now = Utc::now();
64 let expires_at = crate::client::jwt::extract_exp(&token);
68 let expired = expires_at.map(|t| t < now).unwrap_or(false);
69 (
70 AuthStatusOutcome::AuthenticatedViaEnv {
71 server: cfg.server.clone(),
72 expires_at,
73 expired,
74 },
75 Some(resolved),
76 )
77 }
78 Some(resolved) => {
79 let user = cache.user(&cfg.server).map(str::to_owned);
82 let expires_at = cache.expires_at(&cfg.server);
83 let expired = expires_at.map(|t| t < Utc::now()).unwrap_or(false);
84 (
85 AuthStatusOutcome::LoggedIn {
86 server: cfg.server.clone(),
87 user,
88 expires_at,
89 expired,
90 },
91 Some(resolved),
92 )
93 }
94 None => (
95 AuthStatusOutcome::NotLoggedIn {
96 server: cfg.server.clone(),
97 },
98 None,
99 ),
100 };
101
102 let auth_state = read_auth_state(resolved_opt.as_ref(), &cache, &cfg.server);
107
108 let meta = MetaContext {
109 server_label: cfg.server.clone(),
110 auth_state,
111 filter_warning: None,
112 count_caveat: None,
113 count_cost: None,
114 };
115
116 renderer.auth_status(&outcome, &meta)?;
117 Ok(())
118}
119
120#[cfg(test)]
121mod tests {
122 use chrono::{TimeZone, Utc};
123 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
124 use tempfile::TempDir;
125
126 use super::run_impl;
127 use crate::cli::{FormatArgs, StatusArgs};
128 use crate::config::auth_cache::ServerEntry;
129 use crate::config::{AuthCache, Config};
130 use crate::diagnostic::Diagnostic;
131 use crate::render::auth::{
132 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
133 };
134 use crate::render::{Format, MetaContext, Renderer};
135
136 struct LoggedInRecord {
139 server: String,
140 user: Option<String>,
141 expires_at: Option<chrono::DateTime<Utc>>,
142 expired: bool,
143 }
144
145 struct EnvRecord {
146 server: String,
147 expires_at: Option<chrono::DateTime<Utc>>,
148 expired: bool,
149 }
150
151 struct RecordingRenderer {
152 status_outcome: Option<LoggedInRecord>,
153 env_outcome: Option<EnvRecord>,
154 not_logged_in: Option<String>,
155 last_auth_state: Option<String>,
156 }
157
158 impl RecordingRenderer {
159 fn new() -> Self {
160 Self {
161 status_outcome: None,
162 env_outcome: None,
163 not_logged_in: None,
164 last_auth_state: None,
165 }
166 }
167 }
168
169 impl Renderer for RecordingRenderer {
170 fn diagnostic(
171 &mut self,
172 _diag: &Diagnostic,
173 _meta: &MetaContext,
174 ) -> Result<(), Diagnostic> {
175 Ok(())
176 }
177
178 fn auth_login(
179 &mut self,
180 _outcome: &AuthLoginOutcome,
181 _meta: &MetaContext,
182 ) -> Result<(), Diagnostic> {
183 Ok(())
184 }
185
186 fn auth_status(
187 &mut self,
188 outcome: &AuthStatusOutcome,
189 meta: &MetaContext,
190 ) -> Result<(), Diagnostic> {
191 self.last_auth_state = Some(meta.auth_state.clone());
192 match outcome {
193 AuthStatusOutcome::LoggedIn {
194 server,
195 user,
196 expires_at,
197 expired,
198 } => {
199 self.status_outcome = Some(LoggedInRecord {
200 server: server.clone(),
201 user: user.clone(),
202 expires_at: *expires_at,
203 expired: *expired,
204 });
205 }
206 AuthStatusOutcome::AuthenticatedViaEnv {
207 server,
208 expires_at,
209 expired,
210 } => {
211 self.env_outcome = Some(EnvRecord {
212 server: server.clone(),
213 expires_at: *expires_at,
214 expired: *expired,
215 });
216 }
217 AuthStatusOutcome::NotLoggedIn { server } => {
218 self.not_logged_in = Some(server.clone());
219 }
220 }
221 Ok(())
222 }
223
224 fn auth_logout(
225 &mut self,
226 _outcome: &AuthLogoutOutcome,
227 _meta: &MetaContext,
228 ) -> Result<(), Diagnostic> {
229 Ok(())
230 }
231
232 fn auth_set_token(
233 &mut self,
234 _outcome: &AuthSetTokenOutcome,
235 _meta: &MetaContext,
236 ) -> Result<(), Diagnostic> {
237 Ok(())
238 }
239
240 fn project_dump(
241 &mut self,
242 _outcome: &crate::render::DumpOutcome,
243 _meta: &MetaContext,
244 ) -> Result<(), Diagnostic> {
245 Ok(())
246 }
247
248 fn project_dump_deleted(
249 &mut self,
250 _outcome: &crate::render::DumpDeleteOutcome,
251 _meta: &MetaContext,
252 ) -> Result<(), Diagnostic> {
253 Ok(())
254 }
255
256 fn projects(
257 &mut self,
258 _view: &crate::render::ProjectListView,
259 _meta: &MetaContext,
260 ) -> Result<(), Diagnostic> {
261 Ok(())
262 }
263
264 fn project_describe(
265 &mut self,
266 _project: &crate::model::ProjectDetail,
267 _meta: &MetaContext,
268 ) -> Result<(), Diagnostic> {
269 Ok(())
270 }
271
272 fn data_models(
273 &mut self,
274 _view: &crate::render::DataModelListView,
275 _meta: &MetaContext,
276 ) -> Result<(), Diagnostic> {
277 Ok(())
278 }
279
280 fn data_model_describe(
281 &mut self,
282 _detail: &crate::model::DataModelDetail,
283 _meta: &MetaContext,
284 ) -> Result<(), Diagnostic> {
285 Ok(())
286 }
287
288 fn resource_types(
289 &mut self,
290 _view: &crate::render::ResourceTypeListView,
291 _meta: &MetaContext,
292 ) -> Result<(), Diagnostic> {
293 Ok(())
294 }
295
296 fn resource_type_describe(
297 &mut self,
298 _detail: &crate::model::ResourceTypeDetail,
299 _meta: &MetaContext,
300 ) -> Result<(), Diagnostic> {
301 unimplemented!("resource_type_describe not used in status tests")
302 }
303
304 fn data_model_structure(
305 &mut self,
306 _structure: &crate::model::DataModelStructure,
307 _meta: &MetaContext,
308 ) -> Result<(), Diagnostic> {
309 unimplemented!("data_model_structure not used in status tests")
310 }
311
312 fn resources(
313 &mut self,
314 _view: &crate::render::ResourceListView,
315 _meta: &MetaContext,
316 ) -> Result<(), Diagnostic> {
317 Ok(())
318 }
319
320 fn resource_describe(
321 &mut self,
322 _detail: &crate::model::ResourceDetail,
323 _meta: &MetaContext,
324 ) -> Result<(), Diagnostic> {
325 Ok(())
326 }
327
328 fn vocabularies(
329 &mut self,
330 _view: &crate::render::VocabularyListView,
331 _meta: &MetaContext,
332 ) -> Result<(), Diagnostic> {
333 unimplemented!("not exercised by this file's tests")
334 }
335
336 fn vocabulary_describe(
337 &mut self,
338 _detail: &crate::model::VocabularyDetail,
339 _meta: &MetaContext,
340 ) -> Result<(), Diagnostic> {
341 unimplemented!("not exercised by this file's tests")
342 }
343 }
344
345 fn make_args(server: &str) -> (StatusArgs, Config) {
348 let args = StatusArgs {
349 server: Some(server.to_string()),
350 format: FormatArgs {
351 format: Format::Prose,
352 json: false,
353 lines: false,
354 columns: None,
355 no_header: false,
356 header_only: false,
357 },
358 };
359 let cfg = Config {
360 server: server.to_string(),
361 };
362 (args, cfg)
363 }
364
365 fn fixed_future() -> chrono::DateTime<Utc> {
366 Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0).unwrap()
367 }
368
369 fn fixed_past() -> chrono::DateTime<Utc> {
370 Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()
371 }
372
373 fn make_jwt(payload: &serde_json::Value) -> String {
376 encode(
377 &Header::new(Algorithm::HS256),
378 payload,
379 &EncodingKey::from_secret(b"unused"),
380 )
381 .expect("test JWT encoding should not fail")
382 }
383
384 fn make_jwt_with_exp(exp_ts: i64) -> String {
385 make_jwt(&serde_json::json!({ "exp": exp_ts }))
386 }
387
388 #[test]
391 fn logged_in_entry_produces_logged_in_outcome() {
392 let dir = TempDir::new().unwrap();
393 let cache_path = dir.path().join("auth.toml");
394 let (args, cfg) = make_args("https://api.test.dasch.swiss");
395
396 let mut cache = AuthCache::default();
397 cache.set_entry(
398 "https://api.test.dasch.swiss",
399 ServerEntry {
400 token: "tok-123".to_string(),
401 user: Some("u@x.test".to_string()),
402 acquired_at: None,
403 expires_at: Some(fixed_future()),
404 },
405 );
406 cache.save_to(&cache_path).unwrap();
407
408 let mut renderer = RecordingRenderer::new();
409 run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
410
411 let rec = renderer.status_outcome.unwrap();
412 assert_eq!(rec.server, "https://api.test.dasch.swiss");
413 assert_eq!(rec.user.as_deref(), Some("u@x.test"));
414 assert_eq!(rec.expires_at, Some(fixed_future()));
415 assert!(
416 !rec.expired,
417 "token with future expiry should not be expired"
418 );
419 }
420
421 #[test]
422 fn empty_cache_produces_not_logged_in_outcome() {
423 let dir = TempDir::new().unwrap();
424 let cache_path = dir.path().join("auth.toml");
425 let (args, cfg) = make_args("https://api.test.dasch.swiss");
426
427 let mut renderer = RecordingRenderer::new();
428 run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
429
430 assert!(
431 renderer.status_outcome.is_none(),
432 "expected no LoggedIn outcome"
433 );
434 assert_eq!(
435 renderer.not_logged_in.as_deref(),
436 Some("https://api.test.dasch.swiss")
437 );
438 }
439
440 #[test]
441 fn expired_entry_produces_logged_in_with_expired_true() {
442 let dir = TempDir::new().unwrap();
443 let cache_path = dir.path().join("auth.toml");
444 let (args, cfg) = make_args("https://api.test.dasch.swiss");
445
446 let mut cache = AuthCache::default();
447 cache.set_entry(
448 "https://api.test.dasch.swiss",
449 ServerEntry {
450 token: "old-tok".to_string(),
451 user: Some("u@x.test".to_string()),
452 acquired_at: None,
453 expires_at: Some(fixed_past()),
454 },
455 );
456 cache.save_to(&cache_path).unwrap();
457
458 let mut renderer = RecordingRenderer::new();
459 run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
460
461 let rec = renderer.status_outcome.unwrap();
462 assert!(rec.expired, "token with past expiry should be expired");
463 }
464
465 #[test]
466 fn run_always_returns_ok_even_for_not_logged_in() {
467 let dir = TempDir::new().unwrap();
468 let cache_path = dir.path().join("auth.toml");
469 let (args, cfg) = make_args("https://api.test.dasch.swiss");
470
471 let mut renderer = RecordingRenderer::new();
472 let result = run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None);
473 assert!(
474 result.is_ok(),
475 "status should always return Ok; got {result:?}"
476 );
477 }
478
479 #[test]
482 fn env_jwt_future_exp_produces_authenticated_via_env_not_expired() {
483 let dir = TempDir::new().unwrap();
484 let cache_path = dir.path().join("auth.toml");
485 let (args, cfg) = make_args("https://api.test.dasch.swiss");
486
487 let exp_ts = fixed_future().timestamp();
489 let token = make_jwt_with_exp(exp_ts);
490
491 let mut renderer = RecordingRenderer::new();
492 run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(token)).unwrap();
493
494 let rec = renderer
495 .env_outcome
496 .expect("expected AuthenticatedViaEnv outcome");
497 assert_eq!(rec.server, "https://api.test.dasch.swiss");
498 assert!(rec.expires_at.is_some(), "expected Some(expires_at)");
499 assert!(!rec.expired, "future exp should not be expired");
500 assert_eq!(
501 renderer.last_auth_state.as_deref(),
502 Some("authenticated via DSP_TOKEN"),
503 "_meta.auth must use ADR-0007 presence/origin vocabulary (not expiry-aware)"
504 );
505 }
506
507 #[test]
508 fn env_jwt_past_exp_produces_authenticated_via_env_expired() {
509 let dir = TempDir::new().unwrap();
510 let cache_path = dir.path().join("auth.toml");
511 let (args, cfg) = make_args("https://api.test.dasch.swiss");
512
513 let exp_ts = fixed_past().timestamp();
514 let token = make_jwt_with_exp(exp_ts);
515
516 let mut renderer = RecordingRenderer::new();
517 run_impl(&args, &cfg, &mut renderer, Some(&cache_path), Some(token)).unwrap();
518
519 let rec = renderer
520 .env_outcome
521 .expect("expected AuthenticatedViaEnv outcome");
522 assert!(rec.expired, "past exp should be expired");
523 assert_eq!(
526 renderer.last_auth_state.as_deref(),
527 Some("authenticated via DSP_TOKEN"),
528 "_meta.auth for expired env token must be 'authenticated via DSP_TOKEN'"
529 );
530 assert!(rec.expired, "data output must still report expired==true");
532 }
533
534 #[test]
535 fn env_non_jwt_produces_authenticated_via_env_expiry_unknown() {
536 let dir = TempDir::new().unwrap();
537 let cache_path = dir.path().join("auth.toml");
538 let (args, cfg) = make_args("https://api.test.dasch.swiss");
539
540 let mut renderer = RecordingRenderer::new();
541 run_impl(
542 &args,
543 &cfg,
544 &mut renderer,
545 Some(&cache_path),
546 Some("not-a-jwt".to_string()),
547 )
548 .unwrap();
549
550 let rec = renderer
551 .env_outcome
552 .expect("expected AuthenticatedViaEnv outcome");
553 assert!(
554 rec.expires_at.is_none(),
555 "non-JWT env token should have expires_at == None"
556 );
557 assert!(!rec.expired, "non-JWT env token should not be expired");
558 assert_eq!(
559 renderer.last_auth_state.as_deref(),
560 Some("authenticated via DSP_TOKEN"),
561 "_meta.auth for non-JWT env token must be 'authenticated via DSP_TOKEN'"
562 );
563 }
564
565 #[test]
566 fn env_token_wins_over_valid_cache_entry() {
567 let dir = TempDir::new().unwrap();
568 let cache_path = dir.path().join("auth.toml");
569 let (args, cfg) = make_args("https://api.test.dasch.swiss");
570
571 let mut cache = AuthCache::default();
573 cache.set_entry(
574 "https://api.test.dasch.swiss",
575 ServerEntry {
576 token: "cache-tok".to_string(),
577 user: Some("u@cache.test".to_string()),
578 acquired_at: None,
579 expires_at: Some(fixed_future()),
580 },
581 );
582 cache.save_to(&cache_path).unwrap();
583
584 let exp_ts = fixed_future().timestamp();
585 let env_token = make_jwt_with_exp(exp_ts);
586
587 let mut renderer = RecordingRenderer::new();
588 run_impl(
589 &args,
590 &cfg,
591 &mut renderer,
592 Some(&cache_path),
593 Some(env_token),
594 )
595 .unwrap();
596
597 assert!(
599 renderer.env_outcome.is_some(),
600 "env token should win over valid cache entry"
601 );
602 assert!(
603 renderer.status_outcome.is_none(),
604 "LoggedIn should not be produced when env token is present"
605 );
606 assert_eq!(
607 renderer.last_auth_state.as_deref(),
608 Some("authenticated via DSP_TOKEN"),
609 "env win over valid cache should report 'authenticated via DSP_TOKEN'"
610 );
611 }
612
613 #[test]
614 fn env_token_wins_over_expired_cache_entry() {
615 let dir = TempDir::new().unwrap();
616 let cache_path = dir.path().join("auth.toml");
617 let (args, cfg) = make_args("https://api.test.dasch.swiss");
618
619 let mut cache = AuthCache::default();
621 cache.set_entry(
622 "https://api.test.dasch.swiss",
623 ServerEntry {
624 token: "old-cache-tok".to_string(),
625 user: Some("u@cache.test".to_string()),
626 acquired_at: None,
627 expires_at: Some(fixed_past()),
628 },
629 );
630 cache.save_to(&cache_path).unwrap();
631
632 let exp_ts = fixed_future().timestamp();
633 let env_token = make_jwt_with_exp(exp_ts);
634
635 let mut renderer = RecordingRenderer::new();
636 run_impl(
637 &args,
638 &cfg,
639 &mut renderer,
640 Some(&cache_path),
641 Some(env_token),
642 )
643 .unwrap();
644
645 assert!(
646 renderer.env_outcome.is_some(),
647 "env token should win over expired cache entry"
648 );
649 assert!(
650 renderer.status_outcome.is_none(),
651 "LoggedIn should not be produced when env token is present"
652 );
653 assert_eq!(
654 renderer.last_auth_state.as_deref(),
655 Some("authenticated via DSP_TOKEN"),
656 "env win over expired cache should report 'authenticated via DSP_TOKEN'"
657 );
658 }
659
660 #[test]
661 fn whitespace_only_env_with_expired_cache_falls_through_to_cache() {
662 let dir = TempDir::new().unwrap();
663 let cache_path = dir.path().join("auth.toml");
664 let (args, cfg) = make_args("https://api.test.dasch.swiss");
665
666 let mut cache = AuthCache::default();
667 cache.set_entry(
668 "https://api.test.dasch.swiss",
669 ServerEntry {
670 token: "old-tok".to_string(),
671 user: Some("u@x.test".to_string()),
672 acquired_at: None,
673 expires_at: Some(fixed_past()),
674 },
675 );
676 cache.save_to(&cache_path).unwrap();
677
678 let mut renderer = RecordingRenderer::new();
679 run_impl(
680 &args,
681 &cfg,
682 &mut renderer,
683 Some(&cache_path),
684 Some(" ".to_string()), )
686 .unwrap();
687
688 let rec = renderer
690 .status_outcome
691 .expect("expected LoggedIn outcome from cache fall-through");
692 assert!(
693 rec.expired,
694 "cache entry has past expiry; expired should be true"
695 );
696 assert!(
697 renderer.env_outcome.is_none(),
698 "whitespace env should not produce AuthenticatedViaEnv"
699 );
700 assert_eq!(
704 renderer.last_auth_state.as_deref(),
705 Some("authenticated as u@x.test"),
706 "_meta.auth for expired cache token must be 'authenticated as <user>'"
707 );
708 }
709
710 #[test]
711 fn env_token_wins_when_cache_is_corrupt() {
712 let dir = TempDir::new().unwrap();
715 let cache_path = dir.path().join("auth.toml");
716 std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
717
718 let (args, cfg) = make_args("https://api.test.dasch.swiss");
719 let exp_ts = fixed_future().timestamp();
720 let env_token = make_jwt_with_exp(exp_ts);
721
722 let mut renderer = RecordingRenderer::new();
723 run_impl(
724 &args,
725 &cfg,
726 &mut renderer,
727 Some(&cache_path),
728 Some(env_token),
729 )
730 .unwrap();
731
732 assert!(
733 renderer.env_outcome.is_some(),
734 "env token should win over corrupt cache"
735 );
736 assert!(
737 renderer.status_outcome.is_none(),
738 "LoggedIn should not be produced when env token is present"
739 );
740 assert_eq!(
741 renderer.last_auth_state.as_deref(),
742 Some("authenticated via DSP_TOKEN"),
743 );
744 }
745
746 #[test]
747 fn corrupt_cache_without_env_token_still_errors() {
748 let dir = TempDir::new().unwrap();
753 let cache_path = dir.path().join("auth.toml");
754 std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
755
756 let (args, cfg) = make_args("https://api.test.dasch.swiss");
757 let mut renderer = RecordingRenderer::new();
758 let result = run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None);
759 assert!(
760 result.is_err(),
761 "without env token, a corrupt cache should propagate; got {result:?}"
762 );
763 }
764
765 #[test]
766 fn whitespace_only_env_with_corrupt_cache_still_errors() {
767 let dir = TempDir::new().unwrap();
771 let cache_path = dir.path().join("auth.toml");
772 std::fs::write(&cache_path, b"not valid toml [[[").unwrap();
773
774 let (args, cfg) = make_args("https://api.test.dasch.swiss");
775 let mut renderer = RecordingRenderer::new();
776 let result = run_impl(
777 &args,
778 &cfg,
779 &mut renderer,
780 Some(&cache_path),
781 Some(" ".to_string()),
782 );
783 assert!(
784 result.is_err(),
785 "whitespace env token should not swallow corrupt-cache error; got {result:?}"
786 );
787 }
788
789 #[test]
790 fn absent_env_with_cache_entry_produces_logged_in() {
791 let dir = TempDir::new().unwrap();
792 let cache_path = dir.path().join("auth.toml");
793 let (args, cfg) = make_args("https://api.test.dasch.swiss");
794
795 let mut cache = AuthCache::default();
796 cache.set_entry(
797 "https://api.test.dasch.swiss",
798 ServerEntry {
799 token: "tok-abc".to_string(),
800 user: Some("u@x.test".to_string()),
801 acquired_at: None,
802 expires_at: Some(fixed_future()),
803 },
804 );
805 cache.save_to(&cache_path).unwrap();
806
807 let mut renderer = RecordingRenderer::new();
808 run_impl(&args, &cfg, &mut renderer, Some(&cache_path), None).unwrap();
809
810 assert!(
811 renderer.status_outcome.is_some(),
812 "absent env + cache entry should produce LoggedIn"
813 );
814 assert!(
815 renderer.env_outcome.is_none(),
816 "absent env should not produce AuthenticatedViaEnv"
817 );
818 }
819}