mod valid_login;
mod whoami;
use reqwest::header::HeaderValue;
use crate::error::{BzrError, Result};
use crate::types::{ApiMode, AuthMethod};
use self::valid_login::{detect_valid_login_auth, verify_header_auth_via_rest, ValidLoginOutcome};
use self::whoami::{detect_whoami_auth, WhoamiOutcome};
use super::version::detect_version_and_mode;
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DetectedServerSettings {
pub auth_method: AuthMethod,
pub api_mode: ApiMode,
pub server_version: Option<String>,
}
pub async fn detect_server_settings(
url: &str,
api_key: &str,
email: Option<&str>,
tls_config: &crate::tls::TlsConfig,
) -> Result<DetectedServerSettings> {
let http = crate::tls::build_tls_client(tls_config)?;
let method = detect_auth_method(&http, url, api_key, email).await?;
let (version, api_mode) = detect_version_and_mode(&http, url, api_key, method).await;
tracing::info!(
%method,
%api_mode,
version = version.as_deref().unwrap_or("unknown"),
"detected server settings"
);
Ok(DetectedServerSettings {
auth_method: method,
api_mode,
server_version: version,
})
}
async fn detect_auth_method(
http: &reqwest::Client,
base_url: &str,
api_key: &str,
email: Option<&str>,
) -> Result<AuthMethod> {
let base = base_url.trim_end_matches('/');
if !base.starts_with("https://") {
tracing::warn!(
url = base,
"server URL is not HTTPS -- API key will be sent in plaintext"
);
}
let key_header = HeaderValue::from_str(api_key)
.map_err(|_| BzrError::config("invalid API key characters"))?;
let whoami = detect_whoami_auth(http, base, api_key, &key_header).await;
match whoami {
WhoamiOutcome::Authenticated(method) => return Ok(method),
WhoamiOutcome::NotFound => {
tracing::info!("falling back to rest/valid_login for older Bugzilla");
}
WhoamiOutcome::NetworkError => {
tracing::warn!(
"could not reach server during auth detection; \
defaulting to header auth"
);
return Ok(AuthMethod::Header);
}
WhoamiOutcome::AuthRejected | WhoamiOutcome::UnparseableResponse => {}
}
if let Some(login) = email {
match detect_valid_login_auth(http, base, api_key, &key_header, login).await {
ValidLoginOutcome::Authenticated(method) => {
if method == AuthMethod::QueryParam
&& verify_header_auth_via_rest(http, base, &key_header).await
{
tracing::info!(
"header auth works on API endpoints despite valid_login \
rejecting it; preferring header"
);
return Ok(AuthMethod::Header);
}
return Ok(method);
}
ValidLoginOutcome::NetworkError => {
tracing::warn!(
"valid_login probes failed due to network error; \
defaulting to header auth"
);
return Ok(AuthMethod::Header);
}
ValidLoginOutcome::AuthRejected => {}
}
}
let hint = match whoami {
WhoamiOutcome::NotFound if email.is_none() => {
"auth detection failed: rest/whoami not available and no \
--email provided for rest/valid_login fallback. \
Re-run `bzr config set-server` with --email your@email."
}
WhoamiOutcome::NotFound => {
"auth detection failed: rest/valid_login did not confirm \
your credentials. Check your API key and email address."
}
_ => {
"auth detection failed: could not authenticate with the \
server. Check your API key and server URL."
}
};
Err(BzrError::Auth(hint.into()))
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use wiremock::matchers::{header, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::*;
use crate::client::test_helpers::test_http_client;
use crate::http::AUTH_HEADER_NAME;
#[tokio::test]
async fn header_auth_succeeds() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 42})))
.mount(&server)
.await;
let result = detect_auth_method(&test_http_client(), &server.uri(), "test-key", None)
.await
.unwrap();
assert_eq!(result, AuthMethod::Header);
}
#[tokio::test]
async fn falls_back_to_query_param() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.and(query_param(crate::http::AUTH_QUERY_PARAM, "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 7})))
.mount(&server)
.await;
let result = detect_auth_method(&test_http_client(), &server.uri(), "test-key", None)
.await
.unwrap();
assert_eq!(result, AuthMethod::QueryParam);
}
#[tokio::test]
async fn whoami_404_falls_back_to_valid_login_header() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/valid_login"))
.and(query_param("login", "user@example.com"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": true})),
)
.mount(&server)
.await;
let result = detect_auth_method(
&test_http_client(),
&server.uri(),
"test-key",
Some("user@example.com"),
)
.await
.unwrap();
assert_eq!(result, AuthMethod::Header);
}
#[tokio::test]
async fn valid_login_query_param_but_header_works_on_api() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/valid_login"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": false})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/valid_login"))
.and(query_param("login", "user@example.com"))
.and(query_param(crate::http::AUTH_QUERY_PARAM, "test-key"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": true})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"bugs": []})))
.mount(&server)
.await;
let result = detect_auth_method(
&test_http_client(),
&server.uri(),
"test-key",
Some("user@example.com"),
)
.await
.unwrap();
assert_eq!(result, AuthMethod::Header);
}
#[tokio::test]
async fn valid_login_query_param_and_header_fails_on_api() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/valid_login"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": false})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/valid_login"))
.and(query_param("login", "user@example.com"))
.and(query_param(crate::http::AUTH_QUERY_PARAM, "test-key"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": true})),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/bug"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let result = detect_auth_method(
&test_http_client(),
&server.uri(),
"test-key",
Some("user@example.com"),
)
.await
.unwrap();
assert_eq!(result, AuthMethod::QueryParam);
}
#[tokio::test]
async fn whoami_401_no_email_gives_api_key_error() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let result = detect_auth_method(&test_http_client(), &server.uri(), "test-key", None).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Check your API key"),
"should mention API key, got: {err}"
);
}
#[tokio::test]
async fn network_error_defaults_to_header() {
let result =
detect_auth_method(&test_http_client(), "https://127.0.0.1:1", "test-key", None).await;
assert!(result.is_ok(), "should default to header, got: {result:?}");
assert_eq!(result.unwrap(), AuthMethod::Header);
}
#[tokio::test]
async fn whoami_404_no_email_suggests_email_flag() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
let result = detect_auth_method(&test_http_client(), &server.uri(), "test-key", None).await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("--email"),
"should suggest --email flag, got: {err}"
);
}
#[tokio::test]
async fn valid_login_accepts_integer_result() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/valid_login"))
.and(query_param("login", "user@example.com"))
.and(header(AUTH_HEADER_NAME, "test-key"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": 1})),
)
.mount(&server)
.await;
let result = detect_auth_method(
&test_http_client(),
&server.uri(),
"test-key",
Some("user@example.com"),
)
.await
.unwrap();
assert_eq!(result, AuthMethod::Header);
}
#[tokio::test]
async fn both_methods_fail_with_email() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(404))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/valid_login"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"result": false})),
)
.mount(&server)
.await;
let result = detect_auth_method(
&test_http_client(),
&server.uri(),
"test-key",
Some("bad@example.com"),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("valid_login did not confirm"),
"expected 'valid_login did not confirm' message; got: {err}"
);
assert!(
!err.contains("--email"),
"should not suggest --email when email was already provided; got: {err}"
);
}
#[tokio::test]
async fn whoami_id_zero_is_auth_rejected() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 0})))
.mount(&server)
.await;
let result = detect_auth_method(&test_http_client(), &server.uri(), "test-key", None).await;
assert!(result.is_err(), "id=0 must not authenticate");
}
#[tokio::test]
async fn detect_server_settings_returns_all_fields() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/version"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"version": "5.1.2"})),
)
.mount(&server)
.await;
let detected = detect_server_settings(
&server.uri(),
"test-key",
None,
&crate::tls::TlsConfig::default(),
)
.await
.unwrap();
assert_eq!(detected.auth_method, AuthMethod::Header);
assert_eq!(detected.api_mode, ApiMode::Rest);
assert_eq!(detected.server_version.as_deref(), Some("5.1.2"));
}
#[tokio::test]
async fn invalid_api_key_characters_are_rejected() {
let server = MockServer::start().await;
let result = detect_auth_method(
&test_http_client(),
&server.uri(),
"bad\nkey",
Some("user@test"),
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("invalid API key characters"));
}
#[tokio::test]
async fn detect_server_settings_keeps_version_none_when_probe_fails() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/whoami"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 1})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/rest/version"))
.respond_with(ResponseTemplate::new(200).set_body_string("not json"))
.expect(1)
.mount(&server)
.await;
let detected = detect_server_settings(
&server.uri(),
"test-key",
None,
&crate::tls::TlsConfig::default(),
)
.await
.unwrap();
assert_eq!(detected.auth_method, AuthMethod::Header);
assert_eq!(detected.api_mode, ApiMode::Hybrid);
assert!(detected.server_version.is_none());
}
}