use serde::{Deserialize, Serialize};
use super::encode_path;
use super::BugzillaClient;
use super::{UserSearchResponse, USER_FIELDS_BASIC, USER_FIELDS_DETAILED};
use crate::error::{BzrError, Result};
use crate::types::ApiMode;
#[derive(Serialize)]
struct GroupMembershipBody {
groups: GroupMembershipAction,
}
#[derive(Serialize)]
struct GroupMembershipAction {
#[serde(skip_serializing_if = "Vec::is_empty")]
add: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
remove: Vec<String>,
}
use crate::types::{BugzillaUser, CreateGroupParams, GroupInfo, UpdateGroupParams};
#[derive(Deserialize)]
struct GroupResponse {
groups: Vec<GroupInfo>,
}
impl BugzillaClient {
pub async fn get_group_members(
&self,
group_name: &str,
detailed: bool,
) -> Result<Vec<BugzillaUser>> {
let fields = if detailed {
USER_FIELDS_DETAILED
} else {
USER_FIELDS_BASIC
};
let data: UserSearchResponse = self
.get_json_query(
"user",
&[
("group", group_name),
("include_fields", fields),
("match", "*"),
],
)
.await?;
Ok(data.users)
}
pub async fn add_user_to_group(&self, user: &str, group: &str) -> Result<()> {
let body = GroupMembershipBody {
groups: GroupMembershipAction {
add: vec![group.to_string()],
remove: Vec::new(),
},
};
self.put_json(&format!("user/{}", encode_path(user)), &body)
.await
}
pub async fn remove_user_from_group(&self, user: &str, group: &str) -> Result<()> {
let body = GroupMembershipBody {
groups: GroupMembershipAction {
add: Vec::new(),
remove: vec![group.to_string()],
},
};
self.put_json(&format!("user/{}", encode_path(user)), &body)
.await
}
pub async fn get_group(&self, group: &str) -> Result<GroupInfo> {
match self.api_mode {
ApiMode::XmlRpc => return self.xmlrpc_client()?.get_group(group).await,
ApiMode::Rest | ApiMode::Hybrid => {}
}
match self.get_group_rest(group).await {
Ok(info) => Ok(info),
Err(BzrError::Api { code: 32610, .. }) => {
tracing::info!(
"REST Group.get blocked (32610), \
falling back to XML-RPC"
);
self.xmlrpc_client()?.get_group(group).await
}
Err(e) if self.api_mode == ApiMode::Hybrid && e.is_transport_failure() => {
tracing::info!(
"REST group lookup failed ({e}), \
retrying via XML-RPC"
);
self.xmlrpc_client()?.get_group(group).await
}
Err(e) => Err(e),
}
}
async fn get_group_rest(&self, group: &str) -> Result<GroupInfo> {
let req = self.apply_auth(
self.http
.get(self.url("group"))
.query(&[("names", group), ("membership", "1")]),
);
let resp = self.send(req).await?;
let data: GroupResponse = self.parse_json(resp).await?;
data.groups
.into_iter()
.next()
.ok_or_else(|| BzrError::NotFound {
resource: "group",
id: group.to_string(),
})
}
pub async fn create_group(&self, params: &CreateGroupParams) -> Result<u64> {
self.post_json_id("group", params).await
}
pub async fn update_group(&self, group: &str, updates: &UpdateGroupParams) -> Result<()> {
self.put_json(&format!("group/{}", encode_path(group)), updates)
.await
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use wiremock::matchers::{body_json, method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
use super::super::encode_path;
use super::super::USER_FIELDS_BASIC;
use crate::client::test_helpers::{test_client, test_client_hybrid};
use crate::error::BzrError;
use crate::types::{ApiMode, AuthMethod, CreateGroupParams, UpdateGroupParams};
#[tokio::test]
async fn get_group_members_returns_users() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.and(query_param("group", "admin"))
.and(query_param("include_fields", USER_FIELDS_BASIC))
.and(query_param("match", "*"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [
{
"id": 1,
"name": "alice@example.com",
"real_name": "Alice",
"email": "alice@example.com"
},
{
"id": 2,
"name": "bob@example.com",
"real_name": "Bob",
"email": "bob@example.com"
}
]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let users = client.get_group_members("admin", false).await.unwrap();
assert_eq!(users.len(), 2);
assert_eq!(users[0].name, "alice@example.com");
}
#[tokio::test]
async fn get_group_members_details_sends_include_fields() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.and(query_param("group", "admin"))
.and(query_param(
"include_fields",
super::super::USER_FIELDS_DETAILED,
))
.and(query_param("match", "*"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [
{
"id": 1,
"name": "alice@example.com",
"real_name": "Alice",
"email": "alice@example.com",
"can_login": true,
"groups": [{"id": 10, "name": "admin", "description": "Admins"}]
}
]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let users = client.get_group_members("admin", true).await.unwrap();
assert_eq!(users.len(), 1);
assert_eq!(users[0].name, "alice@example.com");
assert_eq!(users[0].groups.len(), 1);
assert_eq!(users[0].groups[0].name, "admin");
assert_eq!(users[0].can_login, Some(true));
}
#[tokio::test]
async fn get_group_members_empty() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.and(query_param("group", "nobody"))
.and(query_param("match", "*"))
.respond_with(
ResponseTemplate::new(200).set_body_json(serde_json::json!({"users": []})),
)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let users = client.get_group_members("nobody", false).await.unwrap();
assert!(users.is_empty());
}
#[tokio::test]
async fn get_group_members_api_error() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/user"))
.and(query_param("group", "nonexistent"))
.and(query_param("match", "*"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 51,
"message": "There is no group named 'nonexistent'."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client
.get_group_members("nonexistent", false)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("nonexistent"),
"Expected error to mention group name, got: {msg}"
);
}
#[tokio::test]
async fn add_user_to_group_sends_put() {
let mock = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(format!(
"/rest/user/{}",
encode_path("alice@example.com")
)))
.and(body_json(
serde_json::json!({"groups": {"add": ["testers"]}}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [{"id": 1, "changes": {}}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
client
.add_user_to_group("alice@example.com", "testers")
.await
.unwrap();
}
#[tokio::test]
async fn remove_user_from_group_sends_put() {
let mock = MockServer::start().await;
Mock::given(method("PUT"))
.and(path(format!(
"/rest/user/{}",
encode_path("bob@example.com")
)))
.and(body_json(
serde_json::json!({"groups": {"remove": ["testers"]}}),
))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"users": [{"id": 2, "changes": {}}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
client
.remove_user_from_group("bob@example.com", "testers")
.await
.unwrap();
}
#[tokio::test]
async fn get_group_returns_info() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.and(query_param("names", "admin"))
.and(query_param("membership", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"groups": [{
"id": 1,
"name": "admin",
"description": "Administrators",
"is_active": true,
"membership": []
}]
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let info = client.get_group("admin").await.unwrap();
assert_eq!(info.name, "admin");
assert!(info.is_active);
}
#[tokio::test]
async fn get_group_rest_empty_response_returns_not_found() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.and(query_param("names", "missing"))
.and(query_param("membership", "1"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"groups": []
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client.get_group("missing").await.unwrap_err();
assert!(matches!(
err,
BzrError::NotFound {
resource: "group",
..
}
));
}
#[tokio::test]
async fn get_group_forbidden() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.and(query_param("names", "secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 51,
"message": "You are not authorized."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client.get_group("secret").await.unwrap_err();
assert!(err.to_string().contains("not authorized"));
}
#[tokio::test]
async fn hybrid_get_group_32610_falls_back_to_xmlrpc() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.and(query_param("names", "admin"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 32610,
"message": "For security reasons, you must use HTTP POST to call the 'get' method."
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200).set_body_string(xmlrpc_group_response(
1,
"admin",
"Administrators",
)),
)
.expect(1)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let info = client.get_group("admin").await.unwrap();
assert_eq!(info.name, "admin");
assert_eq!(info.description, "Administrators");
}
#[tokio::test]
async fn rest_get_group_32610_falls_back_to_xmlrpc() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.and(query_param("names", "admin"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 32610,
"message": "For security reasons, you must use HTTP POST to call the 'get' method."
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200).set_body_string(xmlrpc_group_response(
1,
"admin",
"Administrators",
)),
)
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let info = client.get_group("admin").await.unwrap();
assert_eq!(info.name, "admin");
assert_eq!(info.description, "Administrators");
}
#[tokio::test]
async fn xmlrpc_mode_get_group_bypasses_rest() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.respond_with(ResponseTemplate::new(500))
.expect(0)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(
ResponseTemplate::new(200).set_body_string(xmlrpc_group_response(
1,
"admin",
"Administrators",
)),
)
.expect(1)
.mount(&mock)
.await;
let client = super::BugzillaClient::new(
&mock.uri(),
"test-key",
AuthMethod::Header,
ApiMode::XmlRpc,
None,
false,
)
.unwrap();
let info = client.get_group("admin").await.unwrap();
assert_eq!(info.name, "admin");
}
#[tokio::test]
async fn hybrid_get_group_api_error_does_not_fall_back() {
let mock = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/rest/group"))
.and(query_param("names", "secret"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 51,
"message": "You are not authorized."
})))
.expect(1)
.mount(&mock)
.await;
Mock::given(method("POST"))
.and(path("/xmlrpc.cgi"))
.respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&mock)
.await;
let client = test_client_hybrid(&mock.uri());
let err = client.get_group("secret").await.unwrap_err();
assert!(
err.to_string().contains("not authorized"),
"expected auth error, got: {err}"
);
}
fn xmlrpc_group_response(id: i64, name: &str, description: &str) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<methodResponse><params><param><value><struct>
<member><name>groups</name><value><array><data>
<value><struct>
<member><name>id</name><value><int>{id}</int></value></member>
<member><name>name</name><value><string>{name}</string></value></member>
<member><name>description</name><value><string>{description}</string></value></member>
<member><name>is_active</name><value><boolean>1</boolean></value></member>
<member><name>membership</name><value><array><data></data></array></value></member>
</struct></value>
</data></array></value></member>
</struct></value></param></params></methodResponse>"#
)
}
#[tokio::test]
async fn create_group_returns_id() {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/group"))
.and(body_json(serde_json::json!({
"name": "testers",
"description": "Test team",
"is_active": true,
})))
.respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({"id": 5})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let id = client
.create_group(&CreateGroupParams {
name: "testers".into(),
description: "Test team".into(),
is_active: true,
})
.await
.unwrap();
assert_eq!(id, 5);
}
#[tokio::test]
async fn create_group_forbidden() {
let mock = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/rest/group"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"error": true,
"code": 51,
"message": "You are not authorized."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let err = client
.create_group(&CreateGroupParams {
name: "x".into(),
description: "x".into(),
is_active: true,
})
.await
.unwrap_err();
assert!(err.to_string().contains("not authorized"));
}
#[tokio::test]
async fn update_group_sends_put() {
let mock = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/group/testers"))
.and(body_json(serde_json::json!({
"description": "Updated testers",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"groups": [{"id": 5, "changes": {}}]
})))
.expect(1)
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = UpdateGroupParams {
description: Some("Updated testers".into()),
..Default::default()
};
client.update_group("testers", ¶ms).await.unwrap();
}
#[tokio::test]
async fn update_group_forbidden() {
let mock = MockServer::start().await;
Mock::given(method("PUT"))
.and(path("/rest/group/testers"))
.respond_with(ResponseTemplate::new(403).set_body_json(serde_json::json!({
"error": true,
"code": 51,
"message": "You are not authorized."
})))
.mount(&mock)
.await;
let client = test_client(&mock.uri());
let params = UpdateGroupParams::default();
let err = client.update_group("testers", ¶ms).await.unwrap_err();
assert!(err.to_string().contains("not authorized"));
}
}