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
376    // ── static password source ────────────────────────────────────────────────
377
378    struct StaticPasswordSource(String);
379
380    impl PasswordSource for StaticPasswordSource {
381        fn read(&self, _prompt: &str) -> Result<String, Diagnostic> {
382            Ok(self.0.clone())
383        }
384    }
385
386    // ── recording renderer ────────────────────────────────────────────────────
387
388    struct RecordingRenderer {
389        login_outcome: Option<AuthLoginOutcome>,
390        login_auth_state: Option<String>,
391    }
392
393    impl RecordingRenderer {
394        fn new() -> Self {
395            Self {
396                login_outcome: None,
397                login_auth_state: None,
398            }
399        }
400    }
401
402    impl Renderer for RecordingRenderer {
403        fn diagnostic(
404            &mut self,
405            _diag: &Diagnostic,
406            _meta: &MetaContext,
407        ) -> Result<(), Diagnostic> {
408            Ok(())
409        }
410
411        fn auth_login(
412            &mut self,
413            outcome: &AuthLoginOutcome,
414            meta: &MetaContext,
415        ) -> Result<(), Diagnostic> {
416            self.login_outcome = Some(AuthLoginOutcome {
417                server: outcome.server.clone(),
418                user: outcome.user.clone(),
419                expires_at: outcome.expires_at,
420            });
421            self.login_auth_state = Some(meta.auth_state.clone());
422            Ok(())
423        }
424
425        fn auth_status(
426            &mut self,
427            _outcome: &AuthStatusOutcome,
428            _meta: &MetaContext,
429        ) -> Result<(), Diagnostic> {
430            Ok(())
431        }
432
433        fn auth_logout(
434            &mut self,
435            _outcome: &AuthLogoutOutcome,
436            _meta: &MetaContext,
437        ) -> Result<(), Diagnostic> {
438            Ok(())
439        }
440
441        fn auth_set_token(
442            &mut self,
443            _outcome: &AuthSetTokenOutcome,
444            _meta: &MetaContext,
445        ) -> Result<(), Diagnostic> {
446            Ok(())
447        }
448
449        fn project_dump(
450            &mut self,
451            _outcome: &crate::render::DumpOutcome,
452            _meta: &MetaContext,
453        ) -> Result<(), Diagnostic> {
454            Ok(())
455        }
456
457        fn project_dump_deleted(
458            &mut self,
459            _outcome: &crate::render::DumpDeleteOutcome,
460            _meta: &MetaContext,
461        ) -> Result<(), Diagnostic> {
462            Ok(())
463        }
464
465        fn projects(
466            &mut self,
467            _view: &crate::render::ProjectListView,
468            _meta: &MetaContext,
469        ) -> Result<(), Diagnostic> {
470            Ok(())
471        }
472
473        fn project_describe(
474            &mut self,
475            _project: &crate::model::ProjectDetail,
476            _meta: &MetaContext,
477        ) -> Result<(), Diagnostic> {
478            Ok(())
479        }
480
481        fn data_models(
482            &mut self,
483            _view: &crate::render::DataModelListView,
484            _meta: &MetaContext,
485        ) -> Result<(), Diagnostic> {
486            Ok(())
487        }
488
489        fn data_model_describe(
490            &mut self,
491            _detail: &crate::model::DataModelDetail,
492            _meta: &MetaContext,
493        ) -> Result<(), Diagnostic> {
494            Ok(())
495        }
496
497        fn resource_types(
498            &mut self,
499            _view: &crate::render::ResourceTypeListView,
500            _meta: &MetaContext,
501        ) -> Result<(), Diagnostic> {
502            Ok(())
503        }
504
505        fn resource_type_describe(
506            &mut self,
507            _detail: &crate::model::ResourceTypeDetail,
508            _meta: &MetaContext,
509        ) -> Result<(), Diagnostic> {
510            unimplemented!("resource_type_describe not used in login tests")
511        }
512
513        fn data_model_structure(
514            &mut self,
515            _structure: &crate::model::DataModelStructure,
516            _meta: &MetaContext,
517        ) -> Result<(), Diagnostic> {
518            unimplemented!("data_model_structure not used in login tests")
519        }
520
521        fn resources(
522            &mut self,
523            _view: &crate::render::ResourceListView,
524            _meta: &MetaContext,
525        ) -> Result<(), Diagnostic> {
526            Ok(())
527        }
528
529        fn resource_describe(
530            &mut self,
531            _detail: &crate::model::ResourceDetail,
532            _meta: &MetaContext,
533        ) -> Result<(), Diagnostic> {
534            Ok(())
535        }
536
537        fn vocabularies(
538            &mut self,
539            _view: &crate::render::VocabularyListView,
540            _meta: &MetaContext,
541        ) -> Result<(), Diagnostic> {
542            unimplemented!("not exercised by this file's tests")
543        }
544
545        fn vocabulary_describe(
546            &mut self,
547            _detail: &crate::model::VocabularyDetail,
548            _meta: &MetaContext,
549        ) -> Result<(), Diagnostic> {
550            unimplemented!("not exercised by this file's tests")
551        }
552    }
553
554    // ── helpers ───────────────────────────────────────────────────────────────
555
556    fn fixed_expires() -> chrono::DateTime<Utc> {
557        Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()
558    }
559
560    fn make_args(server: &str) -> (LoginArgs, Config) {
561        let args = LoginArgs {
562            server: Some(server.to_string()),
563            user: Some("u@x.test".to_string()),
564            format: FormatArgs {
565                format: Format::Prose,
566                json: false,
567                lines: false,
568                columns: None,
569                no_header: false,
570                header_only: false,
571            },
572        };
573        let cfg = Config {
574            server: server.to_string(),
575        };
576        (args, cfg)
577    }
578
579    // ── tests ─────────────────────────────────────────────────────────────────
580
581    #[test]
582    fn happy_path_stores_entry_in_cache() {
583        let dir = TempDir::new().unwrap();
584        let cache_path = dir.path().join("auth.toml");
585        let (args, cfg) = make_args("https://api.test.dasch.swiss");
586        let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
587        let mut renderer = RecordingRenderer::new();
588        let pw = StaticPasswordSource("hunter2".to_string());
589
590        run_impl(
591            &args,
592            &cfg,
593            &client,
594            &mut renderer,
595            &pw,
596            None,
597            Some(&cache_path),
598        )
599        .unwrap();
600
601        let loaded = AuthCache::load_from(&cache_path).unwrap();
602        assert_eq!(
603            loaded.token("https://api.test.dasch.swiss"),
604            Some("tok-abc")
605        );
606        assert_eq!(
607            loaded.user("https://api.test.dasch.swiss"),
608            Some("u@x.test")
609        );
610        assert_eq!(
611            loaded.expires_at("https://api.test.dasch.swiss"),
612            Some(fixed_expires())
613        );
614        assert!(
615            loaded.acquired_at("https://api.test.dasch.swiss").is_some(),
616            "acquired_at should be set to Some(Utc::now()) after login"
617        );
618    }
619
620    #[test]
621    fn happy_path_renderer_receives_correct_outcome() {
622        let dir = TempDir::new().unwrap();
623        let cache_path = dir.path().join("auth.toml");
624        let (args, cfg) = make_args("https://api.test.dasch.swiss");
625        let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
626        let mut renderer = RecordingRenderer::new();
627        let pw = StaticPasswordSource("hunter2".to_string());
628
629        run_impl(
630            &args,
631            &cfg,
632            &client,
633            &mut renderer,
634            &pw,
635            None,
636            Some(&cache_path),
637        )
638        .unwrap();
639
640        let outcome = renderer.login_outcome.unwrap();
641        assert_eq!(outcome.server, "https://api.test.dasch.swiss");
642        assert_eq!(outcome.user, "u@x.test");
643        assert_eq!(outcome.expires_at, Some(fixed_expires()));
644        // _meta.auth must reflect the post-login state using ADR-0007 vocabulary.
645        assert_eq!(
646            renderer.login_auth_state.as_deref(),
647            Some("authenticated as u@x.test")
648        );
649    }
650
651    #[test]
652    fn error_auth_required_propagates_unchanged() {
653        let dir = TempDir::new().unwrap();
654        let cache_path = dir.path().join("auth.toml");
655        let (args, cfg) = make_args("https://api.test.dasch.swiss");
656        let client = MockDspClient::err(Diagnostic::AuthRequired(
657            "Authentication failed on https://api.test.dasch.swiss".into(),
658        ));
659        let mut renderer = RecordingRenderer::new();
660        let pw = StaticPasswordSource("bad-pw".to_string());
661
662        let err = run_impl(
663            &args,
664            &cfg,
665            &client,
666            &mut renderer,
667            &pw,
668            None,
669            Some(&cache_path),
670        )
671        .unwrap_err();
672        assert!(
673            matches!(err, Diagnostic::AuthRequired(_)),
674            "expected AuthRequired, got {err:?}"
675        );
676        // Must not include the username (ADR-0007 / PRD acceptance criterion 7).
677        assert!(
678            !err.to_string().contains("u@x.test"),
679            "error message must not contain the username; got: {err}"
680        );
681    }
682
683    #[test]
684    fn error_network_propagates_unchanged() {
685        let dir = TempDir::new().unwrap();
686        let cache_path = dir.path().join("auth.toml");
687        let (args, cfg) = make_args("https://api.test.dasch.swiss");
688        let client = MockDspClient::err(Diagnostic::Network("connection refused".into()));
689        let mut renderer = RecordingRenderer::new();
690        let pw = StaticPasswordSource("pw".to_string());
691
692        let err = run_impl(
693            &args,
694            &cfg,
695            &client,
696            &mut renderer,
697            &pw,
698            None,
699            Some(&cache_path),
700        )
701        .unwrap_err();
702        assert!(
703            matches!(err, Diagnostic::Network(_)),
704            "expected Network, got {err:?}"
705        );
706    }
707
708    #[test]
709    fn error_server_error_propagates_unchanged() {
710        let dir = TempDir::new().unwrap();
711        let cache_path = dir.path().join("auth.toml");
712        let (args, cfg) = make_args("https://api.test.dasch.swiss");
713        let client = MockDspClient::err(Diagnostic::ServerError("server returned 500".into()));
714        let mut renderer = RecordingRenderer::new();
715        let pw = StaticPasswordSource("pw".to_string());
716
717        let err = run_impl(
718            &args,
719            &cfg,
720            &client,
721            &mut renderer,
722            &pw,
723            None,
724            Some(&cache_path),
725        )
726        .unwrap_err();
727        assert!(
728            matches!(err, Diagnostic::ServerError(_)),
729            "expected ServerError, got {err:?}"
730        );
731    }
732
733    #[test]
734    fn static_password_source_reaches_client() {
735        // Verifies that the PasswordSource indirection works end-to-end:
736        // a mock client that always succeeds combined with a static password
737        // source must complete without error and store the expected entry.
738        let dir = TempDir::new().unwrap();
739        let cache_path = dir.path().join("auth.toml");
740        let (args, cfg) = make_args("https://api.test.dasch.swiss");
741        let client = MockDspClient::ok("tok-xyz", "u@x.test", None);
742        let mut renderer = RecordingRenderer::new();
743        let pw = StaticPasswordSource("hunter2".to_string());
744
745        // Should complete without touching the real TTY.
746        run_impl(
747            &args,
748            &cfg,
749            &client,
750            &mut renderer,
751            &pw,
752            None,
753            Some(&cache_path),
754        )
755        .unwrap();
756
757        let loaded = AuthCache::load_from(&cache_path).unwrap();
758        assert_eq!(
759            loaded.token("https://api.test.dasch.swiss"),
760            Some("tok-xyz")
761        );
762    }
763
764    #[test]
765    fn resolve_password_prefers_nonempty_env_value() {
766        let src = StaticPasswordSource("from-prompt".to_string());
767        let pw = resolve_password(Some("from-env".to_string()), &src).unwrap();
768        assert_eq!(
769            pw, "from-env",
770            "non-empty DSP_PASSWORD must win over the prompt"
771        );
772    }
773
774    #[test]
775    fn resolve_password_ignores_empty_env_value() {
776        let src = StaticPasswordSource("from-prompt".to_string());
777        let pw = resolve_password(Some(String::new()), &src).unwrap();
778        assert_eq!(
779            pw, "from-prompt",
780            "an empty DSP_PASSWORD must fall through to the prompt"
781        );
782    }
783
784    #[test]
785    fn resolve_password_falls_through_when_env_absent() {
786        let src = StaticPasswordSource("from-prompt".to_string());
787        let pw = resolve_password(None, &src).unwrap();
788        assert_eq!(pw, "from-prompt");
789    }
790}