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 };
154
155 let outcome = AuthLoginOutcome {
156 server: cfg.server.clone(),
157 user: response.user,
158 expires_at: response.expires_at,
159 };
160
161 renderer.auth_login(&outcome, &meta)
162}
163
164#[cfg(test)]
165mod tests {
166 use chrono::{TimeZone, Utc};
167 use tempfile::TempDir;
168
169 use super::{PasswordSource, resolve_password, run_impl};
170 use crate::cli::{FormatArgs, LoginArgs};
171 use crate::client::DspClient;
172 use crate::config::{AuthCache, Config};
173 use crate::diagnostic::Diagnostic;
174 use crate::model::LoginResponse;
175 use crate::render::auth::{
176 AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
177 };
178 use crate::render::{Format, MetaContext, Renderer};
179
180 struct MockDspClient {
183 result: Result<LoginResponse, Diagnostic>,
184 }
185
186 impl MockDspClient {
187 fn ok(token: &str, user: &str, expires_at: Option<chrono::DateTime<Utc>>) -> Self {
188 Self {
189 result: Ok(LoginResponse {
190 token: token.to_string(),
191 user: user.to_string(),
192 expires_at,
193 }),
194 }
195 }
196
197 fn err(diag: Diagnostic) -> Self {
198 Self { result: Err(diag) }
199 }
200 }
201
202 impl DspClient for MockDspClient {
203 fn login(
204 &self,
205 _server: &str,
206 _user: &str,
207 _password: &str,
208 ) -> Result<LoginResponse, Diagnostic> {
209 self.result.clone()
210 }
211
212 fn resolve_project(
213 &self,
214 _server: &str,
215 _project: &str,
216 ) -> Result<crate::model::ProjectRef, Diagnostic> {
217 unimplemented!("resolve_project not used by login tests")
218 }
219
220 fn create_project_dump(
221 &self,
222 _server: &str,
223 _project_iri: &str,
224 _skip_assets: bool,
225 _token: &str,
226 ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
227 unimplemented!("create_project_dump not used by login tests")
228 }
229
230 fn get_project_dump_status(
231 &self,
232 _server: &str,
233 _project_iri: &str,
234 _dump_id: &str,
235 _token: &str,
236 ) -> Result<crate::model::DumpTask, Diagnostic> {
237 unimplemented!("get_project_dump_status not used by login tests")
238 }
239
240 fn download_project_dump(
241 &self,
242 _server: &str,
243 _project_iri: &str,
244 _dump_id: &str,
245 _token: &str,
246 _dest: &mut dyn std::io::Write,
247 ) -> Result<u64, Diagnostic> {
248 unimplemented!("download_project_dump not used by login tests")
249 }
250
251 fn delete_project_dump(
252 &self,
253 _server: &str,
254 _project_iri: &str,
255 _dump_id: &str,
256 _token: &str,
257 ) -> Result<(), Diagnostic> {
258 unimplemented!("delete_project_dump not used by login tests")
259 }
260
261 fn list_projects(
262 &self,
263 _server: &str,
264 _token: Option<&str>,
265 ) -> Result<Vec<crate::model::Project>, Diagnostic> {
266 Err(Diagnostic::NotImplemented(
267 "list_projects not used in login.rs tests".into(),
268 ))
269 }
270
271 fn describe_project(
272 &self,
273 _server: &str,
274 _project: &str,
275 _token: Option<&str>,
276 ) -> Result<crate::model::ProjectDetail, Diagnostic> {
277 Err(Diagnostic::NotImplemented(
278 "describe_project not used in login.rs tests".into(),
279 ))
280 }
281
282 fn list_data_models(
283 &self,
284 _server: &str,
285 _project_iri: &str,
286 _token: Option<&str>,
287 ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
288 Err(Diagnostic::NotImplemented(
289 "list_data_models not used in login.rs tests".into(),
290 ))
291 }
292
293 fn describe_data_model(
294 &self,
295 _server: &str,
296 _data_model_iri: &str,
297 _token: Option<&str>,
298 ) -> Result<crate::model::DataModelDetail, Diagnostic> {
299 unimplemented!("describe_data_model not used in login tests")
300 }
301
302 fn describe_resource_type(
303 &self,
304 _server: &str,
305 _data_model_iri: &str,
306 _resource_type: &str,
307 _token: Option<&str>,
308 ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
309 unimplemented!("describe_resource_type not used in login tests")
310 }
311
312 fn data_model_structure(
313 &self,
314 _server: &str,
315 _data_model_iri: &str,
316 _token: Option<&str>,
317 ) -> Result<crate::model::DataModelStructure, Diagnostic> {
318 unimplemented!("data_model_structure not used in login tests")
319 }
320
321 fn list_resources(
322 &self,
323 _server: &str,
324 _project_iri: &str,
325 _resource_type_iri: &str,
326 _order_by: Option<&str>,
327 _page: u32,
328 _token: Option<&str>,
329 ) -> Result<crate::model::ResourcePage, Diagnostic> {
330 unimplemented!("list_resources not used in login tests")
331 }
332
333 fn describe_resource(
334 &self,
335 _server: &str,
336 _resource_iri: &str,
337 _token: Option<&str>,
338 _with_values: bool,
339 ) -> Result<crate::model::ResourceDetail, Diagnostic> {
340 unimplemented!("describe_resource not used in login tests")
341 }
342
343 fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
344 unimplemented!("verify_token not used by login tests")
345 }
346
347 fn resource_counts(
348 &self,
349 _server: &str,
350 _project_iri: &str,
351 _token: Option<&str>,
352 ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
353 Ok(std::collections::HashMap::new())
354 }
355 }
356
357 struct StaticPasswordSource(String);
360
361 impl PasswordSource for StaticPasswordSource {
362 fn read(&self, _prompt: &str) -> Result<String, Diagnostic> {
363 Ok(self.0.clone())
364 }
365 }
366
367 struct RecordingRenderer {
370 login_outcome: Option<AuthLoginOutcome>,
371 login_auth_state: Option<String>,
372 }
373
374 impl RecordingRenderer {
375 fn new() -> Self {
376 Self {
377 login_outcome: None,
378 login_auth_state: None,
379 }
380 }
381 }
382
383 impl Renderer for RecordingRenderer {
384 fn diagnostic(
385 &mut self,
386 _diag: &Diagnostic,
387 _meta: &MetaContext,
388 ) -> Result<(), Diagnostic> {
389 Ok(())
390 }
391
392 fn auth_login(
393 &mut self,
394 outcome: &AuthLoginOutcome,
395 meta: &MetaContext,
396 ) -> Result<(), Diagnostic> {
397 self.login_outcome = Some(AuthLoginOutcome {
398 server: outcome.server.clone(),
399 user: outcome.user.clone(),
400 expires_at: outcome.expires_at,
401 });
402 self.login_auth_state = Some(meta.auth_state.clone());
403 Ok(())
404 }
405
406 fn auth_status(
407 &mut self,
408 _outcome: &AuthStatusOutcome,
409 _meta: &MetaContext,
410 ) -> Result<(), Diagnostic> {
411 Ok(())
412 }
413
414 fn auth_logout(
415 &mut self,
416 _outcome: &AuthLogoutOutcome,
417 _meta: &MetaContext,
418 ) -> Result<(), Diagnostic> {
419 Ok(())
420 }
421
422 fn auth_set_token(
423 &mut self,
424 _outcome: &AuthSetTokenOutcome,
425 _meta: &MetaContext,
426 ) -> Result<(), Diagnostic> {
427 Ok(())
428 }
429
430 fn project_dump(
431 &mut self,
432 _outcome: &crate::render::DumpOutcome,
433 _meta: &MetaContext,
434 ) -> Result<(), Diagnostic> {
435 Ok(())
436 }
437
438 fn project_dump_deleted(
439 &mut self,
440 _outcome: &crate::render::DumpDeleteOutcome,
441 _meta: &MetaContext,
442 ) -> Result<(), Diagnostic> {
443 Ok(())
444 }
445
446 fn projects(
447 &mut self,
448 _view: &crate::render::ProjectListView,
449 _meta: &MetaContext,
450 ) -> Result<(), Diagnostic> {
451 Ok(())
452 }
453
454 fn project_describe(
455 &mut self,
456 _project: &crate::model::ProjectDetail,
457 _meta: &MetaContext,
458 ) -> Result<(), Diagnostic> {
459 Ok(())
460 }
461
462 fn data_models(
463 &mut self,
464 _view: &crate::render::DataModelListView,
465 _meta: &MetaContext,
466 ) -> Result<(), Diagnostic> {
467 Ok(())
468 }
469
470 fn data_model_describe(
471 &mut self,
472 _detail: &crate::model::DataModelDetail,
473 _meta: &MetaContext,
474 ) -> Result<(), Diagnostic> {
475 Ok(())
476 }
477
478 fn resource_types(
479 &mut self,
480 _view: &crate::render::ResourceTypeListView,
481 _meta: &MetaContext,
482 ) -> Result<(), Diagnostic> {
483 Ok(())
484 }
485
486 fn resource_type_describe(
487 &mut self,
488 _detail: &crate::model::ResourceTypeDetail,
489 _meta: &MetaContext,
490 ) -> Result<(), Diagnostic> {
491 unimplemented!("resource_type_describe not used in login tests")
492 }
493
494 fn data_model_structure(
495 &mut self,
496 _structure: &crate::model::DataModelStructure,
497 _meta: &MetaContext,
498 ) -> Result<(), Diagnostic> {
499 unimplemented!("data_model_structure not used in login tests")
500 }
501
502 fn resources(
503 &mut self,
504 _view: &crate::render::ResourceListView,
505 _meta: &MetaContext,
506 ) -> Result<(), Diagnostic> {
507 Ok(())
508 }
509
510 fn resource_describe(
511 &mut self,
512 _detail: &crate::model::ResourceDetail,
513 _meta: &MetaContext,
514 ) -> Result<(), Diagnostic> {
515 Ok(())
516 }
517 }
518
519 fn fixed_expires() -> chrono::DateTime<Utc> {
522 Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()
523 }
524
525 fn make_args(server: &str) -> (LoginArgs, Config) {
526 let args = LoginArgs {
527 server: Some(server.to_string()),
528 user: Some("u@x.test".to_string()),
529 format: FormatArgs {
530 format: Format::Prose,
531 json: false,
532 lines: false,
533 columns: None,
534 no_header: false,
535 header_only: false,
536 },
537 };
538 let cfg = Config {
539 server: server.to_string(),
540 };
541 (args, cfg)
542 }
543
544 #[test]
547 fn happy_path_stores_entry_in_cache() {
548 let dir = TempDir::new().unwrap();
549 let cache_path = dir.path().join("auth.toml");
550 let (args, cfg) = make_args("https://api.test.dasch.swiss");
551 let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
552 let mut renderer = RecordingRenderer::new();
553 let pw = StaticPasswordSource("hunter2".to_string());
554
555 run_impl(
556 &args,
557 &cfg,
558 &client,
559 &mut renderer,
560 &pw,
561 None,
562 Some(&cache_path),
563 )
564 .unwrap();
565
566 let loaded = AuthCache::load_from(&cache_path).unwrap();
567 assert_eq!(
568 loaded.token("https://api.test.dasch.swiss"),
569 Some("tok-abc")
570 );
571 assert_eq!(
572 loaded.user("https://api.test.dasch.swiss"),
573 Some("u@x.test")
574 );
575 assert_eq!(
576 loaded.expires_at("https://api.test.dasch.swiss"),
577 Some(fixed_expires())
578 );
579 assert!(
580 loaded.acquired_at("https://api.test.dasch.swiss").is_some(),
581 "acquired_at should be set to Some(Utc::now()) after login"
582 );
583 }
584
585 #[test]
586 fn happy_path_renderer_receives_correct_outcome() {
587 let dir = TempDir::new().unwrap();
588 let cache_path = dir.path().join("auth.toml");
589 let (args, cfg) = make_args("https://api.test.dasch.swiss");
590 let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
591 let mut renderer = RecordingRenderer::new();
592 let pw = StaticPasswordSource("hunter2".to_string());
593
594 run_impl(
595 &args,
596 &cfg,
597 &client,
598 &mut renderer,
599 &pw,
600 None,
601 Some(&cache_path),
602 )
603 .unwrap();
604
605 let outcome = renderer.login_outcome.unwrap();
606 assert_eq!(outcome.server, "https://api.test.dasch.swiss");
607 assert_eq!(outcome.user, "u@x.test");
608 assert_eq!(outcome.expires_at, Some(fixed_expires()));
609 assert_eq!(
611 renderer.login_auth_state.as_deref(),
612 Some("authenticated as u@x.test")
613 );
614 }
615
616 #[test]
617 fn error_auth_required_propagates_unchanged() {
618 let dir = TempDir::new().unwrap();
619 let cache_path = dir.path().join("auth.toml");
620 let (args, cfg) = make_args("https://api.test.dasch.swiss");
621 let client = MockDspClient::err(Diagnostic::AuthRequired(
622 "Authentication failed on https://api.test.dasch.swiss".into(),
623 ));
624 let mut renderer = RecordingRenderer::new();
625 let pw = StaticPasswordSource("bad-pw".to_string());
626
627 let err = run_impl(
628 &args,
629 &cfg,
630 &client,
631 &mut renderer,
632 &pw,
633 None,
634 Some(&cache_path),
635 )
636 .unwrap_err();
637 assert!(
638 matches!(err, Diagnostic::AuthRequired(_)),
639 "expected AuthRequired, got {err:?}"
640 );
641 assert!(
643 !err.to_string().contains("u@x.test"),
644 "error message must not contain the username; got: {err}"
645 );
646 }
647
648 #[test]
649 fn error_network_propagates_unchanged() {
650 let dir = TempDir::new().unwrap();
651 let cache_path = dir.path().join("auth.toml");
652 let (args, cfg) = make_args("https://api.test.dasch.swiss");
653 let client = MockDspClient::err(Diagnostic::Network("connection refused".into()));
654 let mut renderer = RecordingRenderer::new();
655 let pw = StaticPasswordSource("pw".to_string());
656
657 let err = run_impl(
658 &args,
659 &cfg,
660 &client,
661 &mut renderer,
662 &pw,
663 None,
664 Some(&cache_path),
665 )
666 .unwrap_err();
667 assert!(
668 matches!(err, Diagnostic::Network(_)),
669 "expected Network, got {err:?}"
670 );
671 }
672
673 #[test]
674 fn error_server_error_propagates_unchanged() {
675 let dir = TempDir::new().unwrap();
676 let cache_path = dir.path().join("auth.toml");
677 let (args, cfg) = make_args("https://api.test.dasch.swiss");
678 let client = MockDspClient::err(Diagnostic::ServerError("server returned 500".into()));
679 let mut renderer = RecordingRenderer::new();
680 let pw = StaticPasswordSource("pw".to_string());
681
682 let err = run_impl(
683 &args,
684 &cfg,
685 &client,
686 &mut renderer,
687 &pw,
688 None,
689 Some(&cache_path),
690 )
691 .unwrap_err();
692 assert!(
693 matches!(err, Diagnostic::ServerError(_)),
694 "expected ServerError, got {err:?}"
695 );
696 }
697
698 #[test]
699 fn static_password_source_reaches_client() {
700 let dir = TempDir::new().unwrap();
704 let cache_path = dir.path().join("auth.toml");
705 let (args, cfg) = make_args("https://api.test.dasch.swiss");
706 let client = MockDspClient::ok("tok-xyz", "u@x.test", None);
707 let mut renderer = RecordingRenderer::new();
708 let pw = StaticPasswordSource("hunter2".to_string());
709
710 run_impl(
712 &args,
713 &cfg,
714 &client,
715 &mut renderer,
716 &pw,
717 None,
718 Some(&cache_path),
719 )
720 .unwrap();
721
722 let loaded = AuthCache::load_from(&cache_path).unwrap();
723 assert_eq!(
724 loaded.token("https://api.test.dasch.swiss"),
725 Some("tok-xyz")
726 );
727 }
728
729 #[test]
730 fn resolve_password_prefers_nonempty_env_value() {
731 let src = StaticPasswordSource("from-prompt".to_string());
732 let pw = resolve_password(Some("from-env".to_string()), &src).unwrap();
733 assert_eq!(
734 pw, "from-env",
735 "non-empty DSP_PASSWORD must win over the prompt"
736 );
737 }
738
739 #[test]
740 fn resolve_password_ignores_empty_env_value() {
741 let src = StaticPasswordSource("from-prompt".to_string());
742 let pw = resolve_password(Some(String::new()), &src).unwrap();
743 assert_eq!(
744 pw, "from-prompt",
745 "an empty DSP_PASSWORD must fall through to the prompt"
746 );
747 }
748
749 #[test]
750 fn resolve_password_falls_through_when_env_absent() {
751 let src = StaticPasswordSource("from-prompt".to_string());
752 let pw = resolve_password(None, &src).unwrap();
753 assert_eq!(pw, "from-prompt");
754 }
755}