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