use serde::{Deserialize, Serialize};
use crate::validation::{Validate, ValidationError};
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct OAuthSettingsResponse {
pub mcp_enabled: bool,
pub dcr_enabled: bool,
pub cimd_enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub canonical_host: Option<String>,
pub restart_required: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
pub struct UpdateOAuthSettingsRequest {
pub mcp_enabled: Option<bool>,
pub dcr_enabled: Option<bool>,
pub cimd_enabled: Option<bool>,
pub canonical_host: Option<String>,
}
impl Validate for UpdateOAuthSettingsRequest {
fn validate(&self) -> Result<(), ValidationError> {
if let Some(ref host) = self.canonical_host {
let trimmed = host.trim();
if !trimmed.is_empty() && (trimmed.contains("://") || trimmed.contains(' ')) {
return Err(ValidationError {
field: "canonical_host",
message: "must be a plain hostname or host:port (no scheme, no spaces)"
.to_string(),
});
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
reason = "test assertions — is_ok/is_err provides readable failure messages"
)]
use super::*;
#[test]
fn response_round_trip() {
let resp = OAuthSettingsResponse {
mcp_enabled: true,
dcr_enabled: false,
cimd_enabled: true,
canonical_host: Some("auth.example.com".to_string()),
restart_required: false,
};
let json = serde_json::to_string(&resp).expect("serialize");
let de: OAuthSettingsResponse = serde_json::from_str(&json).expect("deserialize");
assert!(de.mcp_enabled);
assert!(!de.dcr_enabled);
assert!(de.cimd_enabled);
assert_eq!(de.canonical_host.as_deref(), Some("auth.example.com"));
assert!(!de.restart_required);
}
#[test]
fn response_none_canonical_host_omitted() {
let resp = OAuthSettingsResponse {
mcp_enabled: false,
dcr_enabled: false,
cimd_enabled: false,
canonical_host: None,
restart_required: false,
};
let json = serde_json::to_value(&resp).expect("serialize");
assert!(
json.get("canonical_host").is_none(),
"absent field must not appear"
);
}
#[test]
fn validate_accepts_plain_hostname() {
let req = UpdateOAuthSettingsRequest {
mcp_enabled: None,
dcr_enabled: None,
cimd_enabled: None,
canonical_host: Some("auth.example.com".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn validate_accepts_host_with_port() {
let req = UpdateOAuthSettingsRequest {
mcp_enabled: None,
dcr_enabled: None,
cimd_enabled: None,
canonical_host: Some("auth.example.com:8443".to_string()),
};
assert!(req.validate().is_ok());
}
#[test]
fn validate_accepts_empty_string_to_clear() {
let req = UpdateOAuthSettingsRequest {
mcp_enabled: None,
dcr_enabled: None,
cimd_enabled: None,
canonical_host: Some(String::new()),
};
assert!(req.validate().is_ok());
}
#[test]
fn validate_rejects_url_with_scheme() {
let req = UpdateOAuthSettingsRequest {
mcp_enabled: None,
dcr_enabled: None,
cimd_enabled: None,
canonical_host: Some("https://auth.example.com".to_string()),
};
let err = req.validate().expect_err("should reject scheme");
assert_eq!(err.field, "canonical_host");
}
#[test]
fn validate_rejects_hostname_with_spaces() {
let req = UpdateOAuthSettingsRequest {
mcp_enabled: None,
dcr_enabled: None,
cimd_enabled: None,
canonical_host: Some("auth example.com".to_string()),
};
let err = req.validate().expect_err("should reject spaces");
assert_eq!(err.field, "canonical_host");
}
}