1use std::path::Path;
8
9use chrono::Utc;
10
11use crate::actions::auth_state::read_auth_state;
12use crate::cli::LoginArgs;
13use crate::client::DspClient;
14use crate::config::auth_cache::ServerEntry;
15use crate::config::{AuthCache, Config, ResolvedToken, TokenOrigin};
16use crate::diagnostic::Diagnostic;
17use crate::render::auth::AuthLoginOutcome;
18use crate::render::{MetaContext, Renderer};
19
20trait PasswordSource {
22 fn read(&self, prompt: &str) -> Result<String, Diagnostic>;
23}
24
25struct TtyPasswordSource;
26
27impl PasswordSource for TtyPasswordSource {
28 fn read(&self, prompt: &str) -> Result<String, Diagnostic> {
29 use std::io::IsTerminal;
30 if std::io::stdin().is_terminal() {
31 rpassword::prompt_password(prompt).map_err(|_| {
35 Diagnostic::Usage(
36 "could not open terminal for password prompt; pipe the password via stdin instead".into(),
37 )
38 })
39 } else {
40 let mut line = String::new();
41 std::io::stdin().read_line(&mut line).map_err(|e| {
45 Diagnostic::Usage(format!("could not read password from stdin: {e}"))
46 })?;
47 crate::actions::auth::trim_line_ending(&mut line);
52 Ok(line)
53 }
54 }
55}
56
57fn resolve_password(
69 env_password: Option<String>,
70 source: &dyn PasswordSource,
71) -> Result<String, Diagnostic> {
72 match env_password {
73 Some(p) if !p.is_empty() => Ok(p),
74 _ => source.read("Password: "),
75 }
76}
77
78pub fn run(
85 args: &LoginArgs,
86 cfg: &Config,
87 client: &dyn DspClient,
88 renderer: &mut dyn Renderer,
89) -> Result<(), Diagnostic> {
90 let env_password = std::env::var("DSP_PASSWORD").ok();
91 run_impl(
92 args,
93 cfg,
94 client,
95 renderer,
96 &TtyPasswordSource,
97 env_password,
98 None,
99 )
100}
101
102fn run_impl(
106 args: &LoginArgs,
107 cfg: &Config,
108 client: &dyn DspClient,
109 renderer: &mut dyn Renderer,
110 password_source: &dyn PasswordSource,
111 env_password: Option<String>,
112 cache_path: Option<&Path>,
113) -> Result<(), Diagnostic> {
114 let user = args.user.as_deref().ok_or_else(|| {
115 Diagnostic::Usage("--user (email, username, or IRI) is required for login".to_string())
116 })?;
117
118 let password = resolve_password(env_password, password_source)?;
119
120 let response = client.login(&cfg.server, user, &password)?;
121
122 let entry = ServerEntry {
123 token: response.token.clone(),
124 user: Some(response.user.clone()),
125 acquired_at: Some(Utc::now()),
126 expires_at: response.expires_at,
127 };
128
129 let mut cache = match cache_path {
130 Some(p) => AuthCache::load_from(p)?,
131 None => AuthCache::load()?,
132 };
133 cache.set_entry(&cfg.server, entry);
134 match cache_path {
135 Some(p) => cache.save_to(p)?,
136 None => cache.save()?,
137 }
138
139 let resolved_for_meta = ResolvedToken {
145 token: response.token.clone(),
146 origin: TokenOrigin::Cache,
147 };
148 let meta = MetaContext {
149 server_label: cfg.server.clone(),
150 auth_state: read_auth_state(Some(&resolved_for_meta), &cache, &cfg.server),
151 filter_warning: None,
152 count_caveat: None,
153 count_cost: None,
154 };
155
156 let outcome = AuthLoginOutcome {
157 server: cfg.server.clone(),
158 user: response.user,
159 expires_at: response.expires_at,
160 };
161
162 renderer.auth_login(&outcome, &meta)
163}
164
165#[cfg(test)]
166mod tests {
167 use chrono::{TimeZone, Utc};
168 use tempfile::TempDir;
169
170 use super::{PasswordSource, resolve_password, run_impl};
171 use crate::cli::{FormatArgs, LoginArgs};
172 use crate::client::DspClient;
173 use crate::config::{AuthCache, Config};
174 use crate::diagnostic::Diagnostic;
175 use crate::model::LoginResponse;
176 use crate::render::auth::{
177 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
178 };
179 use crate::render::{Format, MetaContext, Renderer};
180
181 struct MockDspClient {
184 result: Result<LoginResponse, Diagnostic>,
185 }
186
187 impl MockDspClient {
188 fn ok(token: &str, user: &str, expires_at: Option<chrono::DateTime<Utc>>) -> Self {
189 Self {
190 result: Ok(LoginResponse {
191 token: token.to_string(),
192 user: user.to_string(),
193 expires_at,
194 }),
195 }
196 }
197
198 fn err(diag: Diagnostic) -> Self {
199 Self { result: Err(diag) }
200 }
201 }
202
203 impl DspClient for MockDspClient {
204 fn login(
205 &self,
206 _server: &str,
207 _user: &str,
208 _password: &str,
209 ) -> Result<LoginResponse, Diagnostic> {
210 self.result.clone()
211 }
212
213 fn resolve_project(
214 &self,
215 _server: &str,
216 _project: &str,
217 ) -> Result<crate::model::ProjectRef, Diagnostic> {
218 unimplemented!("resolve_project not used by login tests")
219 }
220
221 fn create_project_dump(
222 &self,
223 _server: &str,
224 _project_iri: &str,
225 _skip_assets: bool,
226 _token: &str,
227 ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
228 unimplemented!("create_project_dump not used by login tests")
229 }
230
231 fn get_project_dump_status(
232 &self,
233 _server: &str,
234 _project_iri: &str,
235 _dump_id: &str,
236 _token: &str,
237 ) -> Result<crate::model::DumpTask, Diagnostic> {
238 unimplemented!("get_project_dump_status not used by login tests")
239 }
240
241 fn download_project_dump(
242 &self,
243 _server: &str,
244 _project_iri: &str,
245 _dump_id: &str,
246 _token: &str,
247 _dest: &mut dyn std::io::Write,
248 ) -> Result<u64, Diagnostic> {
249 unimplemented!("download_project_dump not used by login tests")
250 }
251
252 fn delete_project_dump(
253 &self,
254 _server: &str,
255 _project_iri: &str,
256 _dump_id: &str,
257 _token: &str,
258 ) -> Result<(), Diagnostic> {
259 unimplemented!("delete_project_dump not used by login tests")
260 }
261
262 fn list_projects(
263 &self,
264 _server: &str,
265 _token: Option<&str>,
266 ) -> Result<Vec<crate::model::Project>, Diagnostic> {
267 Err(Diagnostic::NotImplemented(
268 "list_projects not used in login.rs tests".into(),
269 ))
270 }
271
272 fn describe_project(
273 &self,
274 _server: &str,
275 _project: &str,
276 _token: Option<&str>,
277 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
278 Err(Diagnostic::NotImplemented(
279 "describe_project not used in login.rs tests".into(),
280 ))
281 }
282
283 fn list_data_models(
284 &self,
285 _server: &str,
286 _project_iri: &str,
287 _token: Option<&str>,
288 ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
289 Err(Diagnostic::NotImplemented(
290 "list_data_models not used in login.rs tests".into(),
291 ))
292 }
293
294 fn describe_data_model(
295 &self,
296 _server: &str,
297 _data_model_iri: &str,
298 _token: Option<&str>,
299 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
300 unimplemented!("describe_data_model not used in login tests")
301 }
302
303 fn describe_resource_type(
304 &self,
305 _server: &str,
306 _data_model_iri: &str,
307 _resource_type: &str,
308 _token: Option<&str>,
309 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
310 unimplemented!("describe_resource_type not used in login tests")
311 }
312
313 fn data_model_structure(
314 &self,
315 _server: &str,
316 _data_model_iri: &str,
317 _token: Option<&str>,
318 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
319 unimplemented!("data_model_structure not used in login tests")
320 }
321
322 fn list_resources(
323 &self,
324 _server: &str,
325 _project_iri: &str,
326 _resource_type_iri: &str,
327 _order_by: Option<&str>,
328 _page: u32,
329 _token: Option<&str>,
330 ) -> Result<crate::model::ResourcePage, Diagnostic> {
331 unimplemented!("list_resources not used in login tests")
332 }
333
334 fn describe_resource(
335 &self,
336 _server: &str,
337 _resource_iri: &str,
338 _token: Option<&str>,
339 _with_values: bool,
340 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
341 unimplemented!("describe_resource not used in login tests")
342 }
343
344 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
345 unimplemented!("verify_token not used by login tests")
346 }
347
348 fn resource_counts(
349 &self,
350 _server: &str,
351 _project_iri: &str,
352 _token: Option<&str>,
353 ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
354 Ok(std::collections::HashMap::new())
355 }
356
357 fn list_vocabularies(
358 &self,
359 _server: &str,
360 _project_iri: &str,
361 _token: Option<&str>,
362 ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
363 unimplemented!("not exercised by this file's tests")
364 }
365
366 fn describe_vocabulary(
367 &self,
368 _server: &str,
369 _iri: &str,
370 _token: Option<&str>,
371 ) -> Result<crate::model::VocabularyTree, Diagnostic> {
372 unimplemented!("not exercised by this file's tests")
373 }
374 }
375
376 struct StaticPasswordSource(String);
379
380 impl PasswordSource for StaticPasswordSource {
381 fn read(&self, _prompt: &str) -> Result<String, Diagnostic> {
382 Ok(self.0.clone())
383 }
384 }
385
386 struct RecordingRenderer {
389 login_outcome: Option<AuthLoginOutcome>,
390 login_auth_state: Option<String>,
391 }
392
393 impl RecordingRenderer {
394 fn new() -> Self {
395 Self {
396 login_outcome: None,
397 login_auth_state: None,
398 }
399 }
400 }
401
402 impl Renderer for RecordingRenderer {
403 fn diagnostic(
404 &mut self,
405 _diag: &Diagnostic,
406 _meta: &MetaContext,
407 ) -> Result<(), Diagnostic> {
408 Ok(())
409 }
410
411 fn auth_login(
412 &mut self,
413 outcome: &AuthLoginOutcome,
414 meta: &MetaContext,
415 ) -> Result<(), Diagnostic> {
416 self.login_outcome = Some(AuthLoginOutcome {
417 server: outcome.server.clone(),
418 user: outcome.user.clone(),
419 expires_at: outcome.expires_at,
420 });
421 self.login_auth_state = Some(meta.auth_state.clone());
422 Ok(())
423 }
424
425 fn auth_status(
426 &mut self,
427 _outcome: &AuthStatusOutcome,
428 _meta: &MetaContext,
429 ) -> Result<(), Diagnostic> {
430 Ok(())
431 }
432
433 fn auth_logout(
434 &mut self,
435 _outcome: &AuthLogoutOutcome,
436 _meta: &MetaContext,
437 ) -> Result<(), Diagnostic> {
438 Ok(())
439 }
440
441 fn auth_set_token(
442 &mut self,
443 _outcome: &AuthSetTokenOutcome,
444 _meta: &MetaContext,
445 ) -> Result<(), Diagnostic> {
446 Ok(())
447 }
448
449 fn project_dump(
450 &mut self,
451 _outcome: &crate::render::DumpOutcome,
452 _meta: &MetaContext,
453 ) -> Result<(), Diagnostic> {
454 Ok(())
455 }
456
457 fn project_dump_deleted(
458 &mut self,
459 _outcome: &crate::render::DumpDeleteOutcome,
460 _meta: &MetaContext,
461 ) -> Result<(), Diagnostic> {
462 Ok(())
463 }
464
465 fn projects(
466 &mut self,
467 _view: &crate::render::ProjectListView,
468 _meta: &MetaContext,
469 ) -> Result<(), Diagnostic> {
470 Ok(())
471 }
472
473 fn project_describe(
474 &mut self,
475 _project: &crate::model::ProjectDetail,
476 _meta: &MetaContext,
477 ) -> Result<(), Diagnostic> {
478 Ok(())
479 }
480
481 fn data_models(
482 &mut self,
483 _view: &crate::render::DataModelListView,
484 _meta: &MetaContext,
485 ) -> Result<(), Diagnostic> {
486 Ok(())
487 }
488
489 fn data_model_describe(
490 &mut self,
491 _detail: &crate::model::DataModelDetail,
492 _meta: &MetaContext,
493 ) -> Result<(), Diagnostic> {
494 Ok(())
495 }
496
497 fn resource_types(
498 &mut self,
499 _view: &crate::render::ResourceTypeListView,
500 _meta: &MetaContext,
501 ) -> Result<(), Diagnostic> {
502 Ok(())
503 }
504
505 fn resource_type_describe(
506 &mut self,
507 _detail: &crate::model::ResourceTypeDetail,
508 _meta: &MetaContext,
509 ) -> Result<(), Diagnostic> {
510 unimplemented!("resource_type_describe not used in login tests")
511 }
512
513 fn data_model_structure(
514 &mut self,
515 _structure: &crate::model::DataModelStructure,
516 _meta: &MetaContext,
517 ) -> Result<(), Diagnostic> {
518 unimplemented!("data_model_structure not used in login tests")
519 }
520
521 fn resources(
522 &mut self,
523 _view: &crate::render::ResourceListView,
524 _meta: &MetaContext,
525 ) -> Result<(), Diagnostic> {
526 Ok(())
527 }
528
529 fn resource_describe(
530 &mut self,
531 _detail: &crate::model::ResourceDetail,
532 _meta: &MetaContext,
533 ) -> Result<(), Diagnostic> {
534 Ok(())
535 }
536
537 fn vocabularies(
538 &mut self,
539 _view: &crate::render::VocabularyListView,
540 _meta: &MetaContext,
541 ) -> Result<(), Diagnostic> {
542 unimplemented!("not exercised by this file's tests")
543 }
544
545 fn vocabulary_describe(
546 &mut self,
547 _detail: &crate::model::VocabularyDetail,
548 _meta: &MetaContext,
549 ) -> Result<(), Diagnostic> {
550 unimplemented!("not exercised by this file's tests")
551 }
552 }
553
554 fn fixed_expires() -> chrono::DateTime<Utc> {
557 Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()
558 }
559
560 fn make_args(server: &str) -> (LoginArgs, Config) {
561 let args = LoginArgs {
562 server: Some(server.to_string()),
563 user: Some("u@x.test".to_string()),
564 format: FormatArgs {
565 format: Format::Prose,
566 json: false,
567 lines: false,
568 columns: None,
569 no_header: false,
570 header_only: false,
571 },
572 };
573 let cfg = Config {
574 server: server.to_string(),
575 };
576 (args, cfg)
577 }
578
579 #[test]
582 fn happy_path_stores_entry_in_cache() {
583 let dir = TempDir::new().unwrap();
584 let cache_path = dir.path().join("auth.toml");
585 let (args, cfg) = make_args("https://api.test.dasch.swiss");
586 let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
587 let mut renderer = RecordingRenderer::new();
588 let pw = StaticPasswordSource("hunter2".to_string());
589
590 run_impl(
591 &args,
592 &cfg,
593 &client,
594 &mut renderer,
595 &pw,
596 None,
597 Some(&cache_path),
598 )
599 .unwrap();
600
601 let loaded = AuthCache::load_from(&cache_path).unwrap();
602 assert_eq!(
603 loaded.token("https://api.test.dasch.swiss"),
604 Some("tok-abc")
605 );
606 assert_eq!(
607 loaded.user("https://api.test.dasch.swiss"),
608 Some("u@x.test")
609 );
610 assert_eq!(
611 loaded.expires_at("https://api.test.dasch.swiss"),
612 Some(fixed_expires())
613 );
614 assert!(
615 loaded.acquired_at("https://api.test.dasch.swiss").is_some(),
616 "acquired_at should be set to Some(Utc::now()) after login"
617 );
618 }
619
620 #[test]
621 fn happy_path_renderer_receives_correct_outcome() {
622 let dir = TempDir::new().unwrap();
623 let cache_path = dir.path().join("auth.toml");
624 let (args, cfg) = make_args("https://api.test.dasch.swiss");
625 let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
626 let mut renderer = RecordingRenderer::new();
627 let pw = StaticPasswordSource("hunter2".to_string());
628
629 run_impl(
630 &args,
631 &cfg,
632 &client,
633 &mut renderer,
634 &pw,
635 None,
636 Some(&cache_path),
637 )
638 .unwrap();
639
640 let outcome = renderer.login_outcome.unwrap();
641 assert_eq!(outcome.server, "https://api.test.dasch.swiss");
642 assert_eq!(outcome.user, "u@x.test");
643 assert_eq!(outcome.expires_at, Some(fixed_expires()));
644 assert_eq!(
646 renderer.login_auth_state.as_deref(),
647 Some("authenticated as u@x.test")
648 );
649 }
650
651 #[test]
652 fn error_auth_required_propagates_unchanged() {
653 let dir = TempDir::new().unwrap();
654 let cache_path = dir.path().join("auth.toml");
655 let (args, cfg) = make_args("https://api.test.dasch.swiss");
656 let client = MockDspClient::err(Diagnostic::AuthRequired(
657 "Authentication failed on https://api.test.dasch.swiss".into(),
658 ));
659 let mut renderer = RecordingRenderer::new();
660 let pw = StaticPasswordSource("bad-pw".to_string());
661
662 let err = run_impl(
663 &args,
664 &cfg,
665 &client,
666 &mut renderer,
667 &pw,
668 None,
669 Some(&cache_path),
670 )
671 .unwrap_err();
672 assert!(
673 matches!(err, Diagnostic::AuthRequired(_)),
674 "expected AuthRequired, got {err:?}"
675 );
676 assert!(
678 !err.to_string().contains("u@x.test"),
679 "error message must not contain the username; got: {err}"
680 );
681 }
682
683 #[test]
684 fn error_network_propagates_unchanged() {
685 let dir = TempDir::new().unwrap();
686 let cache_path = dir.path().join("auth.toml");
687 let (args, cfg) = make_args("https://api.test.dasch.swiss");
688 let client = MockDspClient::err(Diagnostic::Network("connection refused".into()));
689 let mut renderer = RecordingRenderer::new();
690 let pw = StaticPasswordSource("pw".to_string());
691
692 let err = run_impl(
693 &args,
694 &cfg,
695 &client,
696 &mut renderer,
697 &pw,
698 None,
699 Some(&cache_path),
700 )
701 .unwrap_err();
702 assert!(
703 matches!(err, Diagnostic::Network(_)),
704 "expected Network, got {err:?}"
705 );
706 }
707
708 #[test]
709 fn error_server_error_propagates_unchanged() {
710 let dir = TempDir::new().unwrap();
711 let cache_path = dir.path().join("auth.toml");
712 let (args, cfg) = make_args("https://api.test.dasch.swiss");
713 let client = MockDspClient::err(Diagnostic::ServerError("server returned 500".into()));
714 let mut renderer = RecordingRenderer::new();
715 let pw = StaticPasswordSource("pw".to_string());
716
717 let err = run_impl(
718 &args,
719 &cfg,
720 &client,
721 &mut renderer,
722 &pw,
723 None,
724 Some(&cache_path),
725 )
726 .unwrap_err();
727 assert!(
728 matches!(err, Diagnostic::ServerError(_)),
729 "expected ServerError, got {err:?}"
730 );
731 }
732
733 #[test]
734 fn static_password_source_reaches_client() {
735 let dir = TempDir::new().unwrap();
739 let cache_path = dir.path().join("auth.toml");
740 let (args, cfg) = make_args("https://api.test.dasch.swiss");
741 let client = MockDspClient::ok("tok-xyz", "u@x.test", None);
742 let mut renderer = RecordingRenderer::new();
743 let pw = StaticPasswordSource("hunter2".to_string());
744
745 run_impl(
747 &args,
748 &cfg,
749 &client,
750 &mut renderer,
751 &pw,
752 None,
753 Some(&cache_path),
754 )
755 .unwrap();
756
757 let loaded = AuthCache::load_from(&cache_path).unwrap();
758 assert_eq!(
759 loaded.token("https://api.test.dasch.swiss"),
760 Some("tok-xyz")
761 );
762 }
763
764 #[test]
765 fn resolve_password_prefers_nonempty_env_value() {
766 let src = StaticPasswordSource("from-prompt".to_string());
767 let pw = resolve_password(Some("from-env".to_string()), &src).unwrap();
768 assert_eq!(
769 pw, "from-env",
770 "non-empty DSP_PASSWORD must win over the prompt"
771 );
772 }
773
774 #[test]
775 fn resolve_password_ignores_empty_env_value() {
776 let src = StaticPasswordSource("from-prompt".to_string());
777 let pw = resolve_password(Some(String::new()), &src).unwrap();
778 assert_eq!(
779 pw, "from-prompt",
780 "an empty DSP_PASSWORD must fall through to the prompt"
781 );
782 }
783
784 #[test]
785 fn resolve_password_falls_through_when_env_absent() {
786 let src = StaticPasswordSource("from-prompt".to_string());
787 let pw = resolve_password(None, &src).unwrap();
788 assert_eq!(pw, "from-prompt");
789 }
790}