use std::path::Path;
use chrono::Utc;
use crate::actions::auth_state::read_auth_state;
use crate::cli::LoginArgs;
use crate::client::DspClient;
use crate::config::auth_cache::ServerEntry;
use crate::config::{AuthCache, Config, ResolvedToken, TokenOrigin};
use crate::diagnostic::Diagnostic;
use crate::render::auth::AuthLoginOutcome;
use crate::render::{MetaContext, Renderer};
trait PasswordSource {
fn read(&self, prompt: &str) -> Result<String, Diagnostic>;
}
struct TtyPasswordSource;
impl PasswordSource for TtyPasswordSource {
fn read(&self, prompt: &str) -> Result<String, Diagnostic> {
use std::io::IsTerminal;
if std::io::stdin().is_terminal() {
rpassword::prompt_password(prompt).map_err(|_| {
Diagnostic::Usage(
"could not open terminal for password prompt; pipe the password via stdin instead".into(),
)
})
} else {
let mut line = String::new();
std::io::stdin().read_line(&mut line).map_err(|e| {
Diagnostic::Usage(format!("could not read password from stdin: {e}"))
})?;
crate::actions::auth::trim_line_ending(&mut line);
Ok(line)
}
}
}
fn resolve_password(
env_password: Option<String>,
source: &dyn PasswordSource,
) -> Result<String, Diagnostic> {
match env_password {
Some(p) if !p.is_empty() => Ok(p),
_ => source.read("Password: "),
}
}
pub fn run(
args: &LoginArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
) -> Result<(), Diagnostic> {
let env_password = std::env::var("DSP_PASSWORD").ok();
run_impl(
args,
cfg,
client,
renderer,
&TtyPasswordSource,
env_password,
None,
)
}
fn run_impl(
args: &LoginArgs,
cfg: &Config,
client: &dyn DspClient,
renderer: &mut dyn Renderer,
password_source: &dyn PasswordSource,
env_password: Option<String>,
cache_path: Option<&Path>,
) -> Result<(), Diagnostic> {
let user = args.user.as_deref().ok_or_else(|| {
Diagnostic::Usage("--user (email, username, or IRI) is required for login".to_string())
})?;
let password = resolve_password(env_password, password_source)?;
let response = client.login(&cfg.server, user, &password)?;
let entry = ServerEntry {
token: response.token.clone(),
user: Some(response.user.clone()),
acquired_at: Some(Utc::now()),
expires_at: response.expires_at,
};
let mut cache = match cache_path {
Some(p) => AuthCache::load_from(p)?,
None => AuthCache::load()?,
};
cache.set_entry(&cfg.server, entry);
match cache_path {
Some(p) => cache.save_to(p)?,
None => cache.save()?,
}
let resolved_for_meta = ResolvedToken {
token: response.token.clone(),
origin: TokenOrigin::Cache,
};
let meta = MetaContext {
server_label: cfg.server.clone(),
auth_state: read_auth_state(Some(&resolved_for_meta), &cache, &cfg.server),
filter_warning: None,
};
let outcome = AuthLoginOutcome {
server: cfg.server.clone(),
user: response.user,
expires_at: response.expires_at,
};
renderer.auth_login(&outcome, &meta)
}
#[cfg(test)]
mod tests {
use chrono::{TimeZone, Utc};
use tempfile::TempDir;
use super::{PasswordSource, resolve_password, run_impl};
use crate::cli::{FormatArgs, LoginArgs};
use crate::client::DspClient;
use crate::config::{AuthCache, Config};
use crate::diagnostic::Diagnostic;
use crate::model::LoginResponse;
use crate::render::auth::{
AuthLoginOutcome, AuthLogoutOutcome, AuthSetTokenOutcome, AuthStatusOutcome,
};
use crate::render::{Format, MetaContext, Renderer};
struct MockDspClient {
result: Result<LoginResponse, Diagnostic>,
}
impl MockDspClient {
fn ok(token: &str, user: &str, expires_at: Option<chrono::DateTime<Utc>>) -> Self {
Self {
result: Ok(LoginResponse {
token: token.to_string(),
user: user.to_string(),
expires_at,
}),
}
}
fn err(diag: Diagnostic) -> Self {
Self { result: Err(diag) }
}
}
impl DspClient for MockDspClient {
fn login(
&self,
_server: &str,
_user: &str,
_password: &str,
) -> Result<LoginResponse, Diagnostic> {
self.result.clone()
}
fn resolve_project(
&self,
_server: &str,
_project: &str,
) -> Result<crate::model::ProjectRef, Diagnostic> {
unimplemented!("resolve_project not used by login tests")
}
fn create_project_dump(
&self,
_server: &str,
_project_iri: &str,
_skip_assets: bool,
_token: &str,
) -> Result<crate::model::CreateDumpOutcome, Diagnostic> {
unimplemented!("create_project_dump not used by login tests")
}
fn get_project_dump_status(
&self,
_server: &str,
_project_iri: &str,
_dump_id: &str,
_token: &str,
) -> Result<crate::model::DumpTask, Diagnostic> {
unimplemented!("get_project_dump_status not used by login tests")
}
fn download_project_dump(
&self,
_server: &str,
_project_iri: &str,
_dump_id: &str,
_token: &str,
_dest: &mut dyn std::io::Write,
) -> Result<u64, Diagnostic> {
unimplemented!("download_project_dump not used by login tests")
}
fn delete_project_dump(
&self,
_server: &str,
_project_iri: &str,
_dump_id: &str,
_token: &str,
) -> Result<(), Diagnostic> {
unimplemented!("delete_project_dump not used by login tests")
}
fn list_projects(
&self,
_server: &str,
_token: Option<&str>,
) -> Result<Vec<crate::model::Project>, Diagnostic> {
Err(Diagnostic::NotImplemented(
"list_projects not used in login.rs tests".into(),
))
}
fn describe_project(
&self,
_server: &str,
_project: &str,
_token: Option<&str>,
) -> Result<crate::model::ProjectDetail, Diagnostic> {
Err(Diagnostic::NotImplemented(
"describe_project not used in login.rs tests".into(),
))
}
fn list_data_models(
&self,
_server: &str,
_project_iri: &str,
_token: Option<&str>,
) -> Result<Vec<crate::model::DataModel>, Diagnostic> {
Err(Diagnostic::NotImplemented(
"list_data_models not used in login.rs tests".into(),
))
}
fn describe_data_model(
&self,
_server: &str,
_data_model_iri: &str,
_token: Option<&str>,
) -> Result<crate::model::DataModelDetail, Diagnostic> {
unimplemented!("describe_data_model not used in login tests")
}
fn describe_resource_type(
&self,
_server: &str,
_data_model_iri: &str,
_resource_type: &str,
_token: Option<&str>,
) -> Result<crate::model::ResourceTypeDetail, Diagnostic> {
unimplemented!("describe_resource_type not used in login tests")
}
fn data_model_structure(
&self,
_server: &str,
_data_model_iri: &str,
_token: Option<&str>,
) -> Result<crate::model::DataModelStructure, Diagnostic> {
unimplemented!("data_model_structure not used in login tests")
}
fn list_resources(
&self,
_server: &str,
_project_iri: &str,
_resource_type_iri: &str,
_order_by: Option<&str>,
_page: u32,
_token: Option<&str>,
) -> Result<crate::model::ResourcePage, Diagnostic> {
unimplemented!("list_resources not used in login tests")
}
fn describe_resource(
&self,
_server: &str,
_resource_iri: &str,
_token: Option<&str>,
_with_values: bool,
) -> Result<crate::model::ResourceDetail, Diagnostic> {
unimplemented!("describe_resource not used in login tests")
}
fn verify_token(&self, _server: &str, _token: &str) -> Result<(), Diagnostic> {
unimplemented!("verify_token not used by login tests")
}
}
struct StaticPasswordSource(String);
impl PasswordSource for StaticPasswordSource {
fn read(&self, _prompt: &str) -> Result<String, Diagnostic> {
Ok(self.0.clone())
}
}
struct RecordingRenderer {
login_outcome: Option<AuthLoginOutcome>,
login_auth_state: Option<String>,
}
impl RecordingRenderer {
fn new() -> Self {
Self {
login_outcome: None,
login_auth_state: None,
}
}
}
impl Renderer for RecordingRenderer {
fn diagnostic(
&mut self,
_diag: &Diagnostic,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_login(
&mut self,
outcome: &AuthLoginOutcome,
meta: &MetaContext,
) -> Result<(), Diagnostic> {
self.login_outcome = Some(AuthLoginOutcome {
server: outcome.server.clone(),
user: outcome.user.clone(),
expires_at: outcome.expires_at,
});
self.login_auth_state = Some(meta.auth_state.clone());
Ok(())
}
fn auth_status(
&mut self,
_outcome: &AuthStatusOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_logout(
&mut self,
_outcome: &AuthLogoutOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn auth_set_token(
&mut self,
_outcome: &AuthSetTokenOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn project_dump(
&mut self,
_outcome: &crate::render::DumpOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn project_dump_deleted(
&mut self,
_outcome: &crate::render::DumpDeleteOutcome,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn projects(
&mut self,
_view: &crate::render::ProjectListView,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn project_describe(
&mut self,
_project: &crate::model::ProjectDetail,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn data_models(
&mut self,
_view: &crate::render::DataModelListView,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn data_model_describe(
&mut self,
_detail: &crate::model::DataModelDetail,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn resource_types(
&mut self,
_view: &crate::render::ResourceTypeListView,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn resource_type_describe(
&mut self,
_detail: &crate::model::ResourceTypeDetail,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
unimplemented!("resource_type_describe not used in login tests")
}
fn data_model_structure(
&mut self,
_structure: &crate::model::DataModelStructure,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
unimplemented!("data_model_structure not used in login tests")
}
fn resources(
&mut self,
_view: &crate::render::ResourceListView,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
fn resource_describe(
&mut self,
_detail: &crate::model::ResourceDetail,
_meta: &MetaContext,
) -> Result<(), Diagnostic> {
Ok(())
}
}
fn fixed_expires() -> chrono::DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 25, 12, 34, 56).unwrap()
}
fn make_args(server: &str) -> (LoginArgs, Config) {
let args = LoginArgs {
server: Some(server.to_string()),
user: Some("u@x.test".to_string()),
format: FormatArgs {
format: Format::Prose,
json: false,
lines: false,
columns: None,
no_header: false,
header_only: false,
},
};
let cfg = Config {
server: server.to_string(),
};
(args, cfg)
}
#[test]
fn happy_path_stores_entry_in_cache() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let (args, cfg) = make_args("https://api.test.dasch.swiss");
let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
let mut renderer = RecordingRenderer::new();
let pw = StaticPasswordSource("hunter2".to_string());
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&pw,
None,
Some(&cache_path),
)
.unwrap();
let loaded = AuthCache::load_from(&cache_path).unwrap();
assert_eq!(
loaded.token("https://api.test.dasch.swiss"),
Some("tok-abc")
);
assert_eq!(
loaded.user("https://api.test.dasch.swiss"),
Some("u@x.test")
);
assert_eq!(
loaded.expires_at("https://api.test.dasch.swiss"),
Some(fixed_expires())
);
assert!(
loaded.acquired_at("https://api.test.dasch.swiss").is_some(),
"acquired_at should be set to Some(Utc::now()) after login"
);
}
#[test]
fn happy_path_renderer_receives_correct_outcome() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let (args, cfg) = make_args("https://api.test.dasch.swiss");
let client = MockDspClient::ok("tok-abc", "u@x.test", Some(fixed_expires()));
let mut renderer = RecordingRenderer::new();
let pw = StaticPasswordSource("hunter2".to_string());
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&pw,
None,
Some(&cache_path),
)
.unwrap();
let outcome = renderer.login_outcome.unwrap();
assert_eq!(outcome.server, "https://api.test.dasch.swiss");
assert_eq!(outcome.user, "u@x.test");
assert_eq!(outcome.expires_at, Some(fixed_expires()));
assert_eq!(
renderer.login_auth_state.as_deref(),
Some("authenticated as u@x.test")
);
}
#[test]
fn error_auth_required_propagates_unchanged() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let (args, cfg) = make_args("https://api.test.dasch.swiss");
let client = MockDspClient::err(Diagnostic::AuthRequired(
"Authentication failed on https://api.test.dasch.swiss".into(),
));
let mut renderer = RecordingRenderer::new();
let pw = StaticPasswordSource("bad-pw".to_string());
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&pw,
None,
Some(&cache_path),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::AuthRequired(_)),
"expected AuthRequired, got {err:?}"
);
assert!(
!err.to_string().contains("u@x.test"),
"error message must not contain the username; got: {err}"
);
}
#[test]
fn error_network_propagates_unchanged() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let (args, cfg) = make_args("https://api.test.dasch.swiss");
let client = MockDspClient::err(Diagnostic::Network("connection refused".into()));
let mut renderer = RecordingRenderer::new();
let pw = StaticPasswordSource("pw".to_string());
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&pw,
None,
Some(&cache_path),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::Network(_)),
"expected Network, got {err:?}"
);
}
#[test]
fn error_server_error_propagates_unchanged() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let (args, cfg) = make_args("https://api.test.dasch.swiss");
let client = MockDspClient::err(Diagnostic::ServerError("server returned 500".into()));
let mut renderer = RecordingRenderer::new();
let pw = StaticPasswordSource("pw".to_string());
let err = run_impl(
&args,
&cfg,
&client,
&mut renderer,
&pw,
None,
Some(&cache_path),
)
.unwrap_err();
assert!(
matches!(err, Diagnostic::ServerError(_)),
"expected ServerError, got {err:?}"
);
}
#[test]
fn static_password_source_reaches_client() {
let dir = TempDir::new().unwrap();
let cache_path = dir.path().join("auth.toml");
let (args, cfg) = make_args("https://api.test.dasch.swiss");
let client = MockDspClient::ok("tok-xyz", "u@x.test", None);
let mut renderer = RecordingRenderer::new();
let pw = StaticPasswordSource("hunter2".to_string());
run_impl(
&args,
&cfg,
&client,
&mut renderer,
&pw,
None,
Some(&cache_path),
)
.unwrap();
let loaded = AuthCache::load_from(&cache_path).unwrap();
assert_eq!(
loaded.token("https://api.test.dasch.swiss"),
Some("tok-xyz")
);
}
#[test]
fn resolve_password_prefers_nonempty_env_value() {
let src = StaticPasswordSource("from-prompt".to_string());
let pw = resolve_password(Some("from-env".to_string()), &src).unwrap();
assert_eq!(
pw, "from-env",
"non-empty DSP_PASSWORD must win over the prompt"
);
}
#[test]
fn resolve_password_ignores_empty_env_value() {
let src = StaticPasswordSource("from-prompt".to_string());
let pw = resolve_password(Some(String::new()), &src).unwrap();
assert_eq!(
pw, "from-prompt",
"an empty DSP_PASSWORD must fall through to the prompt"
);
}
#[test]
fn resolve_password_falls_through_when_env_absent() {
let src = StaticPasswordSource("from-prompt".to_string());
let pw = resolve_password(None, &src).unwrap();
assert_eq!(pw, "from-prompt");
}
}