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()
45                .read_line(&mut line)
46                .map_err(|e| Diagnostic::Usage(format!("could not read password from stdin: {e}")))?;
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 dsp-cli/ADR-0007.
68fn resolve_password(env_password: Option<String>, source: &dyn PasswordSource) -> Result<String, Diagnostic> {
69    match env_password {
70        Some(p) if !p.is_empty() => Ok(p),
71        _ => source.read("Password: "),
72    }
73}
74
75/// Log in to a DSP server.
76///
77/// Authenticates with the DSP-API, stores the token in the auth cache, and
78/// renders the outcome. Password resolution order: `DSP_PASSWORD` env var
79/// (local/dev only — see `resolve_password`), then the TTY prompt, then
80/// stdin when stdin is not a terminal (see dsp-cli/ADR-0007).
81pub fn run(
82    args: &LoginArgs,
83    cfg: &Config,
84    client: &dyn DspClient,
85    renderer: &mut dyn Renderer,
86) -> Result<(), Diagnostic> {
87    let env_password = std::env::var("DSP_PASSWORD").ok();
88    run_impl(args, cfg, client, renderer, &TtyPasswordSource, env_password, None)
89}
90
91/// Internal entry point that accepts an explicit cache path (for tests) and an
92/// injectable `PasswordSource`. Production callers use `run`; tests use this
93/// directly to inject a tempdir-backed cache path and a static password.
94fn run_impl(
95    args: &LoginArgs,
96    cfg: &Config,
97    client: &dyn DspClient,
98    renderer: &mut dyn Renderer,
99    password_source: &dyn PasswordSource,
100    env_password: Option<String>,
101    cache_path: Option<&Path>,
102) -> Result<(), Diagnostic> {
103    let user = args
104        .user
105        .as_deref()
106        .ok_or_else(|| Diagnostic::Usage("--user (email, username, or IRI) is required for login".to_string()))?;
107
108    let password = resolve_password(env_password, password_source)?;
109
110    let response = client.login(&cfg.server, user, &password)?;
111
112    let entry = ServerEntry {
113        token: response.token.clone(),
114        user: Some(response.user.clone()),
115        acquired_at: Some(Utc::now()),
116        expires_at: response.expires_at,
117    };
118
119    let mut cache = match cache_path {
120        Some(p) => AuthCache::load_from(p)?,
121        None => AuthCache::load()?,
122    };
123    cache.set_entry(&cfg.server, entry);
124    match cache_path {
125        Some(p) => cache.save_to(p)?,
126        None => cache.save()?,
127    }
128
129    // Build the dsp-cli/ADR-0007 auth-state via the shared helper. After a successful
130    // login the token is stored in the cache as a Cache-origin token with the
131    // returned user name. Synthesize a Cache-origin ResolvedToken so that
132    // `read_auth_state` picks the correct branch and looks up the user from the
133    // cache (which now contains `response.user`).
134    let resolved_for_meta = ResolvedToken { token: response.token.clone(), origin: TokenOrigin::Cache };
135    let meta = MetaContext {
136        server_label: cfg.server.clone(),
137        auth_state: read_auth_state(Some(&resolved_for_meta), &cache, &cfg.server),
138        filter_warning: None,
139        count_caveat: None,
140        count_cost: None,
141    };
142
143    let outcome = AuthLoginOutcome {
144        server: cfg.server.clone(),
145        user: response.user,
146        expires_at: response.expires_at,
147    };
148
149    renderer.auth_login(&outcome, &meta)
150}
151
152#[cfg(test)]
153mod tests {
154    use chrono::{TimeZone, Utc};
155    use tempfile::TempDir;
156
157    use super::{PasswordSource, resolve_password, run_impl};
158    use crate::cli::{FormatArgs, LoginArgs};
159    use crate::client::DspClient;
160    use crate::config::{AuthCache, Config};
161    use crate::diagnostic::Diagnostic;
162    use crate::model::LoginResponse;
163    use crate::render::auth::{AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome};
164    use crate::render::{Format, MetaContext, Renderer};
165
166    // ── local mock client ─────────────────────────────────────────────────────
167
168    struct MockDspClient {
169        result: Result<LoginResponse, Diagnostic>,
170    }
171
172    impl MockDspClient {
173        fn ok(token: &str, user: &str, expires_at: Option<chrono::DateTime<Utc>>) -> Self {
174            Self {
175                result: Ok(LoginResponse { token: token.to_string(), user: user.to_string(), expires_at }),
176            }
177        }
178
179        fn err(diag: Diagnostic) -> Self {
180            Self { result: Err(diag) }
181        }
182    }
183
184    impl DspClient for MockDspClient {
185        fn login(&self, _server: &str, _user: &str, _password: &str) -> Result<LoginResponse, Diagnostic> {
186            self.result.clone()
187        }
188
189        fn resolve_project(&self, _server: &str, _project: &str) -> Result<crate::model::ProjectRef, Diagnostic> {
190            unimplemented!("resolve_project not used by login tests")
191        }
192
193        fn create_project_dump(
194            &self,
195            _server: &str,
196            _project_iri: &str,
197            _skip_assets: bool,
198            _token: &str,
199        ) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
200            unimplemented!("create_project_dump not used by login tests")
201        }
202
203        fn get_project_dump_status(
204            &self,
205            _server: &str,
206            _project_iri: &str,
207            _dump_id: &str,
208            _token: &str,
209        ) -> Result<crate::model::DumpTask, Diagnostic> {
210            unimplemented!("get_project_dump_status not used by login tests")
211        }
212
213        fn download_project_dump(
214            &self,
215            _server: &str,
216            _project_iri: &str,
217            _dump_id: &str,
218            _token: &str,
219            _dest: &mut dyn std::io::Write,
220        ) -> Result<u64, Diagnostic> {
221            unimplemented!("download_project_dump not used by login tests")
222        }
223
224        fn delete_project_dump(
225            &self,
226            _server: &str,
227            _project_iri: &str,
228            _dump_id: &str,
229            _token: &str,
230        ) -> Result<(), Diagnostic> {
231            unimplemented!("delete_project_dump not used by login tests")
232        }
233
234        fn list_projects(&self, _server: &str, _token: Option<&str>) -> Result<Vec<crate::model::Project>, Diagnostic> {
235            Err(Diagnostic::NotImplemented("list_projects not used in login.rs tests".into()))
236        }
237
238        fn describe_project(
239            &self,
240            _server: &str,
241            _project: &str,
242            _token: Option<&str>,
243        ) -> Result<crate::model::ProjectDetail, Diagnostic> {
244            Err(Diagnostic::NotImplemented("describe_project not used in login.rs tests".into()))
245        }
246
247        fn list_data_models(
248            &self,
249            _server: &str,
250            _project_iri: &str,
251            _token: Option<&str>,
252        ) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
253            Err(Diagnostic::NotImplemented("list_data_models not used in login.rs tests".into()))
254        }
255
256        fn describe_data_model(
257            &self,
258            _server: &str,
259            _data_model_iri: &str,
260            _token: Option<&str>,
261        ) -> Result<crate::model::DataModelDetail, Diagnostic> {
262            unimplemented!("describe_data_model not used in login tests")
263        }
264
265        fn describe_resource_type(
266            &self,
267            _server: &str,
268            _data_model_iri: &str,
269            _resource_type: &str,
270            _token: Option<&str>,
271        ) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
272            unimplemented!("describe_resource_type not used in login tests")
273        }
274
275        fn data_model_structure(
276            &self,
277            _server: &str,
278            _data_model_iri: &str,
279            _token: Option<&str>,
280        ) -> Result<crate::model::DataModelStructure, Diagnostic> {
281            unimplemented!("data_model_structure not used in login tests")
282        }
283
284        fn list_resources(
285            &self,
286            _server: &str,
287            _project_iri: &str,
288            _resource_type_iri: &str,
289            _order_by: Option<&str>,
290            _page: u32,
291            _token: Option<&str>,
292        ) -> Result<crate::model::ResourcePage, Diagnostic> {
293            unimplemented!("list_resources not used in login tests")
294        }
295
296        fn describe_resource(
297            &self,
298            _server: &str,
299            _resource_iri: &str,
300            _token: Option<&str>,
301            _with_values: bool,
302        ) -> Result<crate::model::ResourceDetail, Diagnostic> {
303            unimplemented!("describe_resource not used in login tests")
304        }
305
306        fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
307            unimplemented!("verify_token not used by login tests")
308        }
309
310        fn resource_counts(
311            &self,
312            _server: &str,
313            _project_iri: &str,
314            _token: Option<&str>,
315        ) -> Result<std::collections::HashMap<String, u64>, Diagnostic> {
316            Ok(std::collections::HashMap::new())
317        }
318
319        fn list_vocabularies(
320            &self,
321            _server: &str,
322            _project_iri: &str,
323            _token: Option<&str>,
324        ) -> Result<Vec<crate::model::Vocabulary>, Diagnostic> {
325            unimplemented!("not exercised by this file's tests")
326        }
327
328        fn describe_vocabulary(
329            &self,
330            _server: &str,
331            _iri: &str,
332            _token: Option<&str>,
333        ) -> Result<crate::model::VocabularyTree, Diagnostic> {
334            unimplemented!("not exercised by this file's tests")
335        }
336
337        fn sparql_query(
338            &self,
339            _server: &str,
340            _token: &str,
341            _query: &str,
342            _accept: &str,
343            _timeout_secs: u64,
344        ) -> Result<crate::client::sparql::SparqlResponse, Diagnostic> {
345            Err(Diagnostic::Internal("not used in this test".into()))
346        }
347    }
348
349    // ── static password source ────────────────────────────────────────────────
350
351    struct StaticPasswordSource(String);
352
353    impl PasswordSource for StaticPasswordSource {
354        fn read(&self, _prompt: &str) -> Result<String, Diagnostic> {
355            Ok(self.0.clone())
356        }
357    }
358
359    // ── recording renderer ────────────────────────────────────────────────────
360
361    struct RecordingRenderer {
362        login_outcome: Option<AuthLoginOutcome>,
363        login_auth_state: Option<String>,
364    }
365
366    impl RecordingRenderer {
367        fn new() -> Self {
368            Self { login_outcome: None, login_auth_state: None }
369        }
370    }
371
372    impl Renderer for RecordingRenderer {
373        fn diagnostic(&mut self, _diag: &Diagnostic, _meta: &MetaContext) -> Result<(), Diagnostic> {
374            Ok(())
375        }
376
377        fn auth_login(&mut self, outcome: &AuthLoginOutcome, meta: &MetaContext) -> Result<(), Diagnostic> {
378            self.login_outcome = Some(AuthLoginOutcome {
379                server: outcome.server.clone(),
380                user: outcome.user.clone(),
381                expires_at: outcome.expires_at,
382            });
383            self.login_auth_state = Some(meta.auth_state.clone());
384            Ok(())
385        }
386
387        fn auth_status(&mut self, _outcome: &AuthStatusOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
388            Ok(())
389        }
390
391        fn auth_logout(&mut self, _outcome: &AuthLogoutOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
392            Ok(())
393        }
394
395        fn auth_set_token(&mut self, _outcome: &AuthSetTokenOutcome, _meta: &MetaContext) -> Result<(), Diagnostic> {
396            Ok(())
397        }
398
399        fn project_dump(
400            &mut self,
401            _outcome: &crate::render::DumpOutcome,
402            _meta: &MetaContext,
403        ) -> Result<(), Diagnostic> {
404            Ok(())
405        }
406
407        fn project_dump_deleted(
408            &mut self,
409            _outcome: &crate::render::DumpDeleteOutcome,
410            _meta: &MetaContext,
411        ) -> Result<(), Diagnostic> {
412            Ok(())
413        }
414
415        fn projects(&mut self, _view: &crate::render::ProjectListView, _meta: &MetaContext) -> Result<(), Diagnostic> {
416            Ok(())
417        }
418
419        fn project_describe(
420            &mut self,
421            _project: &crate::model::ProjectDetail,
422            _meta: &MetaContext,
423        ) -> Result<(), Diagnostic> {
424            Ok(())
425        }
426
427        fn data_models(
428            &mut self,
429            _view: &crate::render::DataModelListView,
430            _meta: &MetaContext,
431        ) -> Result<(), Diagnostic> {
432            Ok(())
433        }
434
435        fn data_model_describe(
436            &mut self,
437            _detail: &crate::model::DataModelDetail,
438            _meta: &MetaContext,
439        ) -> Result<(), Diagnostic> {
440            Ok(())
441        }
442
443        fn resource_types(
444            &mut self,
445            _view: &crate::render::ResourceTypeListView,
446            _meta: &MetaContext,
447        ) -> Result<(), Diagnostic> {
448            Ok(())
449        }
450
451        fn resource_type_describe(
452            &mut self,
453            _detail: &crate::model::ResourceTypeDetail,
454            _meta: &MetaContext,
455        ) -> Result<(), Diagnostic> {
456            unimplemented!("resource_type_describe not used in login tests")
457        }
458
459        fn data_model_structure(
460            &mut self,
461            _structure: &crate::model::DataModelStructure,
462            _meta: &MetaContext,
463        ) -> Result<(), Diagnostic> {
464            unimplemented!("data_model_structure not used in login tests")
465        }
466
467        fn resources(
468            &mut self,
469            _view: &crate::render::ResourceListView,
470            _meta: &MetaContext,
471        ) -> Result<(), Diagnostic> {
472            Ok(())
473        }
474
475        fn resource_describe(
476            &mut self,
477            _detail: &crate::model::ResourceDetail,
478            _meta: &MetaContext,
479        ) -> Result<(), Diagnostic> {
480            Ok(())
481        }
482
483        fn vocabularies(
484            &mut self,
485            _view: &crate::render::VocabularyListView,
486            _meta: &MetaContext,
487        ) -> Result<(), Diagnostic> {
488            unimplemented!("not exercised by this file's tests")
489        }
490
491        fn vocabulary_describe(
492            &mut self,
493            _detail: &crate::model::VocabularyDetail,
494            _meta: &MetaContext,
495        ) -> Result<(), Diagnostic> {
496            unimplemented!("not exercised by this file's tests")
497        }
498    }
499
500    // ── helpers ───────────────────────────────────────────────────────────────
501
502    fn fixed_expires() -> chrono::DateTime<Utc> {
503        Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()
504    }
505
506    fn make_args(server: &str) -> (LoginArgs, Config) {
507        let args = LoginArgs {
508            server: Some(server.to_string()),
509            user: Some("u@x.test".to_string()),
510            format: FormatArgs {
511                format: Format::Prose,
512                json: false,
513                lines: false,
514                columns: None,
515                no_header: false,
516                header_only: false,
517            },
518        };
519        let cfg = Config { server: server.to_string() };
520        (args, cfg)
521    }
522
523    // ── tests ─────────────────────────────────────────────────────────────────
524
525    #[test]
526    fn happy_path_stores_entry_in_cache() {
527        let dir = TempDir::new().unwrap();
528        let cache_path = dir.path().join("auth.toml");
529        let (args, cfg) = make_args("https://api.test.dasch.swiss");
530        let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
531        let mut renderer = RecordingRenderer::new();
532        let pw = StaticPasswordSource("hunter2".to_string());
533
534        run_impl(&args, &cfg, &client, &mut renderer, &pw, None, Some(&cache_path)).unwrap();
535
536        let loaded = AuthCache::load_from(&cache_path).unwrap();
537        assert_eq!(loaded.token("https://api.test.dasch.swiss"), Some("tok-abc"));
538        assert_eq!(loaded.user("https://api.test.dasch.swiss"), Some("u@x.test"));
539        assert_eq!(loaded.expires_at("https://api.test.dasch.swiss"), Some(fixed_expires()));
540        assert!(
541            loaded.acquired_at("https://api.test.dasch.swiss").is_some(),
542            "acquired_at should be set to Some(Utc::now()) after login"
543        );
544    }
545
546    #[test]
547    fn happy_path_renderer_receives_correct_outcome() {
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(&args, &cfg, &client, &mut renderer, &pw, None, Some(&cache_path)).unwrap();
556
557        let outcome = renderer.login_outcome.unwrap();
558        assert_eq!(outcome.server, "https://api.test.dasch.swiss");
559        assert_eq!(outcome.user, "u@x.test");
560        assert_eq!(outcome.expires_at, Some(fixed_expires()));
561        // _meta.auth must reflect the post-login state using dsp-cli/ADR-0007 vocabulary.
562        assert_eq!(renderer.login_auth_state.as_deref(), Some("authenticated as u@x.test"));
563    }
564
565    #[test]
566    fn error_auth_required_propagates_unchanged() {
567        let dir = TempDir::new().unwrap();
568        let cache_path = dir.path().join("auth.toml");
569        let (args, cfg) = make_args("https://api.test.dasch.swiss");
570        let client = MockDspClient::err(Diagnostic::AuthRequired(
571            "Authentication failed on https://api.test.dasch.swiss".into(),
572        ));
573        let mut renderer = RecordingRenderer::new();
574        let pw = StaticPasswordSource("bad-pw".to_string());
575
576        let err = run_impl(&args, &cfg, &client, &mut renderer, &pw, None, Some(&cache_path)).unwrap_err();
577        assert!(matches!(err, Diagnostic::AuthRequired(_)), "expected AuthRequired, got {err:?}");
578        // Must not include the username (dsp-cli/ADR-0007 / PRD acceptance criterion 7).
579        assert!(
580            !err.to_string().contains("u@x.test"),
581            "error message must not contain the username; got: {err}"
582        );
583    }
584
585    #[test]
586    fn error_network_propagates_unchanged() {
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::err(Diagnostic::Network("connection refused".into()));
591        let mut renderer = RecordingRenderer::new();
592        let pw = StaticPasswordSource("pw".to_string());
593
594        let err = run_impl(&args, &cfg, &client, &mut renderer, &pw, None, Some(&cache_path)).unwrap_err();
595        assert!(matches!(err, Diagnostic::Network(_)), "expected Network, got {err:?}");
596    }
597
598    #[test]
599    fn error_server_error_propagates_unchanged() {
600        let dir = TempDir::new().unwrap();
601        let cache_path = dir.path().join("auth.toml");
602        let (args, cfg) = make_args("https://api.test.dasch.swiss");
603        let client = MockDspClient::err(Diagnostic::ServerError("server returned 500".into()));
604        let mut renderer = RecordingRenderer::new();
605        let pw = StaticPasswordSource("pw".to_string());
606
607        let err = run_impl(&args, &cfg, &client, &mut renderer, &pw, None, Some(&cache_path)).unwrap_err();
608        assert!(matches!(err, Diagnostic::ServerError(_)), "expected ServerError, got {err:?}");
609    }
610
611    #[test]
612    fn static_password_source_reaches_client() {
613        // Verifies that the PasswordSource indirection works end-to-end:
614        // a mock client that always succeeds combined with a static password
615        // source must complete without error and store the expected entry.
616        let dir = TempDir::new().unwrap();
617        let cache_path = dir.path().join("auth.toml");
618        let (args, cfg) = make_args("https://api.test.dasch.swiss");
619        let client = MockDspClient::ok("tok-xyz", "u@x.test", None);
620        let mut renderer = RecordingRenderer::new();
621        let pw = StaticPasswordSource("hunter2".to_string());
622
623        // Should complete without touching the real TTY.
624        run_impl(&args, &cfg, &client, &mut renderer, &pw, None, Some(&cache_path)).unwrap();
625
626        let loaded = AuthCache::load_from(&cache_path).unwrap();
627        assert_eq!(loaded.token("https://api.test.dasch.swiss"), Some("tok-xyz"));
628    }
629
630    #[test]
631    fn resolve_password_prefers_nonempty_env_value() {
632        let src = StaticPasswordSource("from-prompt".to_string());
633        let pw = resolve_password(Some("from-env".to_string()), &src).unwrap();
634        assert_eq!(pw, "from-env", "non-empty DSP_PASSWORD must win over the prompt");
635    }
636
637    #[test]
638    fn resolve_password_ignores_empty_env_value() {
639        let src = StaticPasswordSource("from-prompt".to_string());
640        let pw = resolve_password(Some(String::new()), &src).unwrap();
641        assert_eq!(pw, "from-prompt", "an empty DSP_PASSWORD must fall through to the prompt");
642    }
643
644    #[test]
645    fn resolve_password_falls_through_when_env_absent() {
646        let src = StaticPasswordSource("from-prompt".to_string());
647        let pw = resolve_password(None, &src).unwrap();
648        assert_eq!(pw, "from-prompt");
649    }
650}