1use 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
20trait 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::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 std::io::stdin()
45 .read_line(&mut line)
46 .map_err(|e| Diagnostic::Usage(format!("could not read password from stdin: {e}")))?;
47 crate::actions::auth::trim_line_ending(&mut line);
52 Ok(line)
53 }
54 }
55}
56
57fn 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
75pub 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
91fn 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 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 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 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 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 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 #[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 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 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 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 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}