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    };
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    // ── local mock client ─────────────────────────────────────────────────────
180
181    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    // ── static password source ────────────────────────────────────────────────
348
349    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    // ── recording renderer ────────────────────────────────────────────────────
358
359    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    // ── helpers ───────────────────────────────────────────────────────────────
510
511    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    // ── tests ─────────────────────────────────────────────────────────────────
535
536    #[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        // _meta.auth must reflect the post-login state using ADR-0007 vocabulary.
600        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        // Must not include the username (ADR-0007 / PRD acceptance criterion 7).
632        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        // Verifies that the PasswordSource indirection works end-to-end:
691        // a mock client that always succeeds combined with a static password
692        // source must complete without error and store the expected entry.
693        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        // Should complete without touching the real TTY.
701        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}