use std::collections::HashMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::client::GuacamoleClient;
use crate::error::Result;
use crate::history::HistoryEntry;
use crate::patch::PatchOperation;
use crate::validation::validate_username;
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct User {
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub attributes: Option<HashMap<String, Option<String>>>,
}
impl fmt::Debug for User {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("User")
.field("username", &self.username)
.field("password", &"<redacted>")
.field("attributes", &self.attributes)
.finish()
}
}
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct PasswordChange {
#[serde(skip_serializing_if = "Option::is_none")]
pub old_password: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub new_password: Option<String>,
}
impl fmt::Debug for PasswordChange {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PasswordChange")
.field("old_password", &"<redacted>")
.field("new_password", &"<redacted>")
.finish()
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct UserPermissions {
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_permissions: Option<HashMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub connection_group_permissions: Option<HashMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sharing_profile_permissions: Option<HashMap<String, Vec<String>>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_permissions: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_permissions: Option<HashMap<String, Vec<String>>>,
#[serde(flatten)]
pub extra: HashMap<String, Value>,
}
impl GuacamoleClient {
pub async fn list_users(
&self,
data_source: Option<&str>,
) -> Result<HashMap<String, User>> {
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!("/api/session/data/{ds}/users"))?;
let response = self.http.get(&url).send().await?;
Self::parse_response(response, "users").await
}
pub async fn get_user(
&self,
data_source: Option<&str>,
username: &str,
) -> Result<User> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!("/api/session/data/{ds}/users/{username}"))?;
let response = self.http.get(&url).send().await?;
Self::parse_response(response, &format!("user {username}")).await
}
pub async fn get_self(
&self,
data_source: Option<&str>,
) -> Result<User> {
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!("/api/session/data/{ds}/self"))?;
let response = self.http.get(&url).send().await?;
Self::parse_response(response, "self").await
}
pub async fn create_user(
&self,
data_source: Option<&str>,
user: &User,
) -> Result<()> {
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!("/api/session/data/{ds}/users"))?;
let response = self.http.post(&url).json(user).send().await?;
Self::handle_error(response, "create user").await?;
Ok(())
}
pub async fn update_user(
&self,
data_source: Option<&str>,
username: &str,
user: &User,
) -> Result<()> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!("/api/session/data/{ds}/users/{username}"))?;
let response = self.http.put(&url).json(user).send().await?;
Self::handle_error(response, &format!("user {username}")).await?;
Ok(())
}
pub async fn delete_user(
&self,
data_source: Option<&str>,
username: &str,
) -> Result<()> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!("/api/session/data/{ds}/users/{username}"))?;
let response = self.http.delete(&url).send().await?;
Self::handle_error(response, &format!("user {username}")).await?;
Ok(())
}
pub async fn update_user_password(
&self,
data_source: Option<&str>,
username: &str,
password_change: &PasswordChange,
) -> Result<()> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!(
"/api/session/data/{ds}/users/{username}/password"
))?;
let response = self.http.put(&url).json(password_change).send().await?;
Self::handle_error(response, &format!("user {username} password")).await?;
Ok(())
}
pub async fn get_user_permissions(
&self,
data_source: Option<&str>,
username: &str,
) -> Result<UserPermissions> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!(
"/api/session/data/{ds}/users/{username}/permissions"
))?;
let response = self.http.get(&url).send().await?;
Self::parse_response(response, &format!("user {username} permissions")).await
}
pub async fn get_user_effective_permissions(
&self,
data_source: Option<&str>,
username: &str,
) -> Result<UserPermissions> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!(
"/api/session/data/{ds}/users/{username}/effectivePermissions"
))?;
let response = self.http.get(&url).send().await?;
Self::parse_response(response, &format!("user {username} effective permissions"))
.await
}
pub async fn get_user_groups(
&self,
data_source: Option<&str>,
username: &str,
) -> Result<Vec<String>> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!(
"/api/session/data/{ds}/users/{username}/userGroups"
))?;
let response = self.http.get(&url).send().await?;
Self::parse_response(response, &format!("user {username} groups")).await
}
pub async fn get_user_history(
&self,
data_source: Option<&str>,
username: &str,
) -> Result<Vec<HistoryEntry>> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!(
"/api/session/data/{ds}/users/{username}/history"
))?;
let response = self.http.get(&url).send().await?;
Self::parse_response(response, &format!("user {username} history")).await
}
pub async fn update_user_groups(
&self,
data_source: Option<&str>,
username: &str,
patches: &[PatchOperation],
) -> Result<()> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!(
"/api/session/data/{ds}/users/{username}/userGroups"
))?;
let response = self.http.patch(&url).json(patches).send().await?;
Self::handle_error(response, &format!("user {username} groups")).await?;
Ok(())
}
pub async fn update_user_permissions(
&self,
data_source: Option<&str>,
username: &str,
patches: &[PatchOperation],
) -> Result<()> {
validate_username(username)?;
let ds = self.resolve_data_source(data_source)?;
let url = self.url(&format!(
"/api/session/data/{ds}/users/{username}/permissions"
))?;
let response = self.http.patch(&url).json(patches).send().await?;
Self::handle_error(response, &format!("user {username} permissions")).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn user_serde_roundtrip() {
let user = User {
username: Some("guacadmin".to_string()),
password: Some("secret".to_string()),
attributes: Some(HashMap::from([(
"disabled".to_string(),
Some("false".to_string()),
)])),
};
let json = serde_json::to_string(&user).unwrap();
let deserialized: User = serde_json::from_str(&json).unwrap();
assert_eq!(user, deserialized);
}
#[test]
fn user_debug_redacts_password() {
let user = User {
username: Some("admin".to_string()),
password: Some("hunter2".to_string()),
..Default::default()
};
let debug = format!("{user:?}");
assert!(!debug.contains("hunter2"), "Debug must not leak password");
assert!(debug.contains("<redacted>"));
assert!(debug.contains("admin"));
}
#[test]
fn user_skip_none_fields() {
let user = User::default();
let json = serde_json::to_value(&user).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.is_empty());
}
#[test]
fn password_change_debug_redacts_both() {
let pc = PasswordChange {
old_password: Some("old-secret".to_string()),
new_password: Some("new-secret".to_string()),
};
let debug = format!("{pc:?}");
assert!(!debug.contains("old-secret"));
assert!(!debug.contains("new-secret"));
assert!(debug.contains("<redacted>"));
}
#[test]
fn password_change_serde_roundtrip() {
let pc = PasswordChange {
old_password: Some("old".to_string()),
new_password: Some("new".to_string()),
};
let json = serde_json::to_string(&pc).unwrap();
let deserialized: PasswordChange = serde_json::from_str(&json).unwrap();
assert_eq!(pc, deserialized);
}
#[test]
fn password_change_camel_case() {
let pc = PasswordChange {
old_password: Some("old".to_string()),
new_password: Some("new".to_string()),
};
let json = serde_json::to_value(&pc).unwrap();
assert!(json.get("oldPassword").is_some());
assert!(json.get("newPassword").is_some());
}
#[test]
fn user_permissions_serde_roundtrip() {
let json_str = r#"{
"connectionPermissions": {"1": ["READ"], "2": ["READ", "UPDATE"]},
"connectionGroupPermissions": {},
"sharingProfilePermissions": {"3": ["READ"]},
"systemPermissions": ["ADMINISTER"],
"userPermissions": {}
}"#;
let perms: UserPermissions = serde_json::from_str(json_str).unwrap();
assert_eq!(
perms.system_permissions,
Some(vec!["ADMINISTER".to_string()])
);
assert!(perms.connection_permissions.is_some());
let conn_perms = perms.connection_permissions.as_ref().unwrap();
assert_eq!(conn_perms.get("1"), Some(&vec!["READ".to_string()]));
assert_eq!(
conn_perms.get("2"),
Some(&vec!["READ".to_string(), "UPDATE".to_string()])
);
let json_roundtrip = serde_json::to_string(&perms).unwrap();
let deserialized: UserPermissions = serde_json::from_str(&json_roundtrip).unwrap();
assert_eq!(perms, deserialized);
}
#[test]
fn user_permissions_skip_none_fields() {
let perms = UserPermissions::default();
let json = serde_json::to_value(&perms).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.is_empty());
}
#[test]
fn deserialize_user_from_api_json() {
let json = r#"{
"username": "student",
"attributes": {
"disabled": "",
"expired": "",
"access-window-start": "",
"access-window-end": ""
}
}"#;
let user: User = serde_json::from_str(json).unwrap();
assert_eq!(user.username.as_deref(), Some("student"));
assert!(user.password.is_none());
assert!(user.attributes.is_some());
}
#[test]
fn deserialize_user_unknown_fields_ignored() {
let json = r#"{"username": "test", "unknownField": 42}"#;
let user: User = serde_json::from_str(json).unwrap();
assert_eq!(user.username.as_deref(), Some("test"));
}
#[test]
fn user_permissions_extra_captures_unknown_fields() {
let json = r#"{
"connectionPermissions": {"1": ["READ"]},
"customPermissionType": {"x": ["ADMIN"]}
}"#;
let perms: UserPermissions = serde_json::from_str(json).unwrap();
assert!(perms.extra.contains_key("customPermissionType"));
let custom = perms.extra["customPermissionType"].as_object().unwrap();
assert_eq!(custom["x"], serde_json::json!(["ADMIN"]));
}
#[test]
fn user_permissions_extra_survives_roundtrip() {
let json = r#"{
"connectionPermissions": {"1": ["READ"]},
"futurePermissions": {"a": ["WRITE"]}
}"#;
let perms: UserPermissions = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&perms).unwrap();
let deserialized: UserPermissions = serde_json::from_str(&serialized).unwrap();
assert_eq!(perms, deserialized);
assert!(deserialized.extra.contains_key("futurePermissions"));
}
#[test]
fn user_null_attribute_values() {
let json = r#"{
"username": "admin",
"attributes": {
"disabled": "false",
"expired": null
}
}"#;
let user: User = serde_json::from_str(json).unwrap();
let attrs = user.attributes.as_ref().unwrap();
assert_eq!(attrs.get("disabled"), Some(&Some("false".to_string())));
assert_eq!(attrs.get("expired"), Some(&None));
}
#[test]
fn password_change_skip_none_fields() {
let pc = PasswordChange::default();
let json = serde_json::to_value(&pc).unwrap();
let obj = json.as_object().unwrap();
assert!(obj.is_empty());
}
#[test]
fn password_change_unknown_fields_ignored() {
let json = r#"{"oldPassword": "old", "unknownField": true}"#;
let pc: PasswordChange = serde_json::from_str(json).unwrap();
assert_eq!(pc.old_password.as_deref(), Some("old"));
}
}