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