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