Skip to main content

dsp_cli/actions/auth/
login.rs

1//! Actions for `dsp auth login`.
2//!
3//! Reads the user's password via a `PasswordSource` indirection so tests can
4//! inject a static password without touching the real TTY. See Step 5 of the
5//! implementation plan.
6
7use 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
20// PasswordSource is a deliberate trait-injected seam for tests; keep it.
21trait 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 opens /dev/tty itself. If that fails (sandboxed/container
32            // env without /dev/tty), surface a user-actionable message rather
33            // than Diagnostic::Internal.
34            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            // A read failure here (closed pipe, no data on stdin) is a bad
42            // invocation, not a CLI bug — surface it as Usage rather than the
43            // From<io::Error> default of Internal.
44            std::io::stdin().read_line(&mut line).map_err(|e| {
45                Diagnostic::Usage(format!("could not read password from stdin: {e}"))
46            })?;
47            // Strip a single trailing line terminator via the shared helper.
48            // See `crate::actions::auth::trim_line_ending` for the rationale:
49            // a bare trailing `\r` is NOT stripped (it may be a legitimate
50            // password char), so we must not use `trim_end_matches`.
51            crate::actions::auth::trim_line_ending(&mut line);
52            Ok(line)
53        }
54    }
55}
56
57/// Resolve the password to use for login.
58///
59/// A non-empty `DSP_PASSWORD` value takes precedence over the interactive
60/// prompt / stdin. The env value is passed in (not read here) so this stays
61/// pure and unit-testable without mutating process env.
62///
63/// SECURITY: `DSP_PASSWORD` is a plaintext password, typically living in a
64/// `.env` file on disk. Use it only for **local / dev / test** setups —
65/// **never a production password**. For non-interactive use against real
66/// environments, prefer a scoped, expiring token (`DSP_TOKEN`) over a
67/// durable master credential. See ADR-0007.
68fn 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
78/// Log in to a DSP server.
79///
80/// Authenticates with the DSP-API, stores the token in the auth cache, and
81/// renders the outcome. Password resolution order: `DSP_PASSWORD` env var
82/// (local/dev only — see [`resolve_password`]), then the TTY prompt, then
83/// stdin when stdin is not a terminal (see ADR-0007).
84pub 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
102/// Internal entry point that accepts an explicit cache path (for tests) and an
103/// injectable `PasswordSource`. Production callers use `run`; tests use this
104/// directly to inject a tempdir-backed cache path and a static password.
105fn 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    // Build the ADR-0007 auth-state via the shared helper. After a successful
140    // login the token is stored in the cache as a Cache-origin token with the
141    // returned user name. Synthesize a Cache-origin ResolvedToken so that
142    // `read_auth_state` picks the correct branch and looks up the user from the
143    // cache (which now contains `response.user`).
144    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    // ── local mock client ─────────────────────────────────────────────────────
181
182    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    // ── static password source ────────────────────────────────────────────────
358
359    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    // ── recording renderer ────────────────────────────────────────────────────
368
369    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    // ── helpers ───────────────────────────────────────────────────────────────
520
521    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    // ── tests ─────────────────────────────────────────────────────────────────
545
546    #[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        // _meta.auth must reflect the post-login state using ADR-0007 vocabulary.
610        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        // Must not include the username (ADR-0007 / PRD acceptance criterion 7).
642        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        // Verifies that the PasswordSource indirection works end-to-end:
701        // a mock client that always succeeds combined with a static password
702        // source must complete without error and store the expected entry.
703        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        // Should complete without touching the real TTY.
711        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}