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 fn sparql_query(
376 &self,
377 _server: &str,
378 _token: &str,
379 _query: &str,
380 _accept: &str,
381 _timeout_secs: u64,
382 ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
383 Err(Diagnostic::Internal("not used in this test".into()))
384 }
385 }
386
387 struct StaticPasswordSource(String);
390
391 impl PasswordSource for StaticPasswordSource {
392 fn read(&self, _prompt: &str) -> Result<String, Diagnostic> {
393 Ok(self.0.clone())
394 }
395 }
396
397 struct RecordingRenderer {
400 login_outcome: Option<AuthLoginOutcome>,
401 login_auth_state: Option<String>,
402 }
403
404 impl RecordingRenderer {
405 fn new() -> Self {
406 Self {
407 login_outcome: None,
408 login_auth_state: None,
409 }
410 }
411 }
412
413 impl Renderer for RecordingRenderer {
414 fn diagnostic(
415 &mut self,
416 _diag: &Diagnostic,
417 _meta: &MetaContext,
418 ) -> Result<(), Diagnostic> {
419 Ok(())
420 }
421
422 fn auth_login(
423 &mut self,
424 outcome: &AuthLoginOutcome,
425 meta: &MetaContext,
426 ) -> Result<(), Diagnostic> {
427 self.login_outcome = Some(AuthLoginOutcome {
428 server: outcome.server.clone(),
429 user: outcome.user.clone(),
430 expires_at: outcome.expires_at,
431 });
432 self.login_auth_state = Some(meta.auth_state.clone());
433 Ok(())
434 }
435
436 fn auth_status(
437 &mut self,
438 _outcome: &AuthStatusOutcome,
439 _meta: &MetaContext,
440 ) -> Result<(), Diagnostic> {
441 Ok(())
442 }
443
444 fn auth_logout(
445 &mut self,
446 _outcome: &AuthLogoutOutcome,
447 _meta: &MetaContext,
448 ) -> Result<(), Diagnostic> {
449 Ok(())
450 }
451
452 fn auth_set_token(
453 &mut self,
454 _outcome: &AuthSetTokenOutcome,
455 _meta: &MetaContext,
456 ) -> Result<(), Diagnostic> {
457 Ok(())
458 }
459
460 fn project_dump(
461 &mut self,
462 _outcome: &crate::render::DumpOutcome,
463 _meta: &MetaContext,
464 ) -> Result<(), Diagnostic> {
465 Ok(())
466 }
467
468 fn project_dump_deleted(
469 &mut self,
470 _outcome: &crate::render::DumpDeleteOutcome,
471 _meta: &MetaContext,
472 ) -> Result<(), Diagnostic> {
473 Ok(())
474 }
475
476 fn projects(
477 &mut self,
478 _view: &crate::render::ProjectListView,
479 _meta: &MetaContext,
480 ) -> Result<(), Diagnostic> {
481 Ok(())
482 }
483
484 fn project_describe(
485 &mut self,
486 _project: &crate::model::ProjectDetail,
487 _meta: &MetaContext,
488 ) -> Result<(), Diagnostic> {
489 Ok(())
490 }
491
492 fn data_models(
493 &mut self,
494 _view: &crate::render::DataModelListView,
495 _meta: &MetaContext,
496 ) -> Result<(), Diagnostic> {
497 Ok(())
498 }
499
500 fn data_model_describe(
501 &mut self,
502 _detail: &crate::model::DataModelDetail,
503 _meta: &MetaContext,
504 ) -> Result<(), Diagnostic> {
505 Ok(())
506 }
507
508 fn resource_types(
509 &mut self,
510 _view: &crate::render::ResourceTypeListView,
511 _meta: &MetaContext,
512 ) -> Result<(), Diagnostic> {
513 Ok(())
514 }
515
516 fn resource_type_describe(
517 &mut self,
518 _detail: &crate::model::ResourceTypeDetail,
519 _meta: &MetaContext,
520 ) -> Result<(), Diagnostic> {
521 unimplemented!("resource_type_describe not used in login tests")
522 }
523
524 fn data_model_structure(
525 &mut self,
526 _structure: &crate::model::DataModelStructure,
527 _meta: &MetaContext,
528 ) -> Result<(), Diagnostic> {
529 unimplemented!("data_model_structure not used in login tests")
530 }
531
532 fn resources(
533 &mut self,
534 _view: &crate::render::ResourceListView,
535 _meta: &MetaContext,
536 ) -> Result<(), Diagnostic> {
537 Ok(())
538 }
539
540 fn resource_describe(
541 &mut self,
542 _detail: &crate::model::ResourceDetail,
543 _meta: &MetaContext,
544 ) -> Result<(), Diagnostic> {
545 Ok(())
546 }
547
548 fn vocabularies(
549 &mut self,
550 _view: &crate::render::VocabularyListView,
551 _meta: &MetaContext,
552 ) -> Result<(), Diagnostic> {
553 unimplemented!("not exercised by this file's tests")
554 }
555
556 fn vocabulary_describe(
557 &mut self,
558 _detail: &crate::model::VocabularyDetail,
559 _meta: &MetaContext,
560 ) -> Result<(), Diagnostic> {
561 unimplemented!("not exercised by this file's tests")
562 }
563 }
564
565 fn fixed_expires() -> chrono::DateTime<Utc> {
568 Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()
569 }
570
571 fn make_args(server: &str) -> (LoginArgs, Config) {
572 let args = LoginArgs {
573 server: Some(server.to_string()),
574 user: Some("u@x.test".to_string()),
575 format: FormatArgs {
576 format: Format::Prose,
577 json: false,
578 lines: false,
579 columns: None,
580 no_header: false,
581 header_only: false,
582 },
583 };
584 let cfg = Config {
585 server: server.to_string(),
586 };
587 (args, cfg)
588 }
589
590 #[test]
593 fn happy_path_stores_entry_in_cache() {
594 let dir = TempDir::new().unwrap();
595 let cache_path = dir.path().join("auth.toml");
596 let (args, cfg) = make_args("https://api.test.dasch.swiss");
597 let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
598 let mut renderer = RecordingRenderer::new();
599 let pw = StaticPasswordSource("hunter2".to_string());
600
601 run_impl(
602 &args,
603 &cfg,
604 &client,
605 &mut renderer,
606 &pw,
607 None,
608 Some(&cache_path),
609 )
610 .unwrap();
611
612 let loaded = AuthCache::load_from(&cache_path).unwrap();
613 assert_eq!(
614 loaded.token("https://api.test.dasch.swiss"),
615 Some("tok-abc")
616 );
617 assert_eq!(
618 loaded.user("https://api.test.dasch.swiss"),
619 Some("u@x.test")
620 );
621 assert_eq!(
622 loaded.expires_at("https://api.test.dasch.swiss"),
623 Some(fixed_expires())
624 );
625 assert!(
626 loaded.acquired_at("https://api.test.dasch.swiss").is_some(),
627 "acquired_at should be set to Some(Utc::now()) after login"
628 );
629 }
630
631 #[test]
632 fn happy_path_renderer_receives_correct_outcome() {
633 let dir = TempDir::new().unwrap();
634 let cache_path = dir.path().join("auth.toml");
635 let (args, cfg) = make_args("https://api.test.dasch.swiss");
636 let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
637 let mut renderer = RecordingRenderer::new();
638 let pw = StaticPasswordSource("hunter2".to_string());
639
640 run_impl(
641 &args,
642 &cfg,
643 &client,
644 &mut renderer,
645 &pw,
646 None,
647 Some(&cache_path),
648 )
649 .unwrap();
650
651 let outcome = renderer.login_outcome.unwrap();
652 assert_eq!(outcome.server, "https://api.test.dasch.swiss");
653 assert_eq!(outcome.user, "u@x.test");
654 assert_eq!(outcome.expires_at, Some(fixed_expires()));
655 assert_eq!(
657 renderer.login_auth_state.as_deref(),
658 Some("authenticated as u@x.test")
659 );
660 }
661
662 #[test]
663 fn error_auth_required_propagates_unchanged() {
664 let dir = TempDir::new().unwrap();
665 let cache_path = dir.path().join("auth.toml");
666 let (args, cfg) = make_args("https://api.test.dasch.swiss");
667 let client = MockDspClient::err(Diagnostic::AuthRequired(
668 "Authentication failed on https://api.test.dasch.swiss".into(),
669 ));
670 let mut renderer = RecordingRenderer::new();
671 let pw = StaticPasswordSource("bad-pw".to_string());
672
673 let err = run_impl(
674 &args,
675 &cfg,
676 &client,
677 &mut renderer,
678 &pw,
679 None,
680 Some(&cache_path),
681 )
682 .unwrap_err();
683 assert!(
684 matches!(err, Diagnostic::AuthRequired(_)),
685 "expected AuthRequired, got {err:?}"
686 );
687 assert!(
689 !err.to_string().contains("u@x.test"),
690 "error message must not contain the username; got: {err}"
691 );
692 }
693
694 #[test]
695 fn error_network_propagates_unchanged() {
696 let dir = TempDir::new().unwrap();
697 let cache_path = dir.path().join("auth.toml");
698 let (args, cfg) = make_args("https://api.test.dasch.swiss");
699 let client = MockDspClient::err(Diagnostic::Network("connection refused".into()));
700 let mut renderer = RecordingRenderer::new();
701 let pw = StaticPasswordSource("pw".to_string());
702
703 let err = run_impl(
704 &args,
705 &cfg,
706 &client,
707 &mut renderer,
708 &pw,
709 None,
710 Some(&cache_path),
711 )
712 .unwrap_err();
713 assert!(
714 matches!(err, Diagnostic::Network(_)),
715 "expected Network, got {err:?}"
716 );
717 }
718
719 #[test]
720 fn error_server_error_propagates_unchanged() {
721 let dir = TempDir::new().unwrap();
722 let cache_path = dir.path().join("auth.toml");
723 let (args, cfg) = make_args("https://api.test.dasch.swiss");
724 let client = MockDspClient::err(Diagnostic::ServerError("server returned 500".into()));
725 let mut renderer = RecordingRenderer::new();
726 let pw = StaticPasswordSource("pw".to_string());
727
728 let err = run_impl(
729 &args,
730 &cfg,
731 &client,
732 &mut renderer,
733 &pw,
734 None,
735 Some(&cache_path),
736 )
737 .unwrap_err();
738 assert!(
739 matches!(err, Diagnostic::ServerError(_)),
740 "expected ServerError, got {err:?}"
741 );
742 }
743
744 #[test]
745 fn static_password_source_reaches_client() {
746 let dir = TempDir::new().unwrap();
750 let cache_path = dir.path().join("auth.toml");
751 let (args, cfg) = make_args("https://api.test.dasch.swiss");
752 let client = MockDspClient::ok("tok-xyz", "u@x.test", None);
753 let mut renderer = RecordingRenderer::new();
754 let pw = StaticPasswordSource("hunter2".to_string());
755
756 run_impl(
758 &args,
759 &cfg,
760 &client,
761 &mut renderer,
762 &pw,
763 None,
764 Some(&cache_path),
765 )
766 .unwrap();
767
768 let loaded = AuthCache::load_from(&cache_path).unwrap();
769 assert_eq!(
770 loaded.token("https://api.test.dasch.swiss"),
771 Some("tok-xyz")
772 );
773 }
774
775 #[test]
776 fn resolve_password_prefers_nonempty_env_value() {
777 let src = StaticPasswordSource("from-prompt".to_string());
778 let pw = resolve_password(Some("from-env".to_string()), &src).unwrap();
779 assert_eq!(
780 pw, "from-env",
781 "non-empty DSP_PASSWORD must win over the prompt"
782 );
783 }
784
785 #[test]
786 fn resolve_password_ignores_empty_env_value() {
787 let src = StaticPasswordSource("from-prompt".to_string());
788 let pw = resolve_password(Some(String::new()), &src).unwrap();
789 assert_eq!(
790 pw, "from-prompt",
791 "an empty DSP_PASSWORD must fall through to the prompt"
792 );
793 }
794
795 #[test]
796 fn resolve_password_falls_through_when_env_absent() {
797 let src = StaticPasswordSource("from-prompt".to_string());
798 let pw = resolve_password(None, &src).unwrap();
799 assert_eq!(pw, "from-prompt");
800 }
801}