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