use serde::Deserialize;
use serde::Serialize;
use serde::de::DeserializeOwned;
use crate::Res;
use crate::error::RoleError;
use crate::io::remote::client::HttpClient;
use quilt_uri::Host;
const ME_QUERY: &str = "query { me { role { name } roles { name } } }";
const SWITCH_ROLE_MUTATION: &str = "\
mutation($roleName: String!) { \
switchRole(roleName: $roleName) { \
__typename \
... on Me { role { name } roles { name } } \
... on InvalidInput { errors { message } } \
... on OperationError { message } \
} }";
#[derive(Serialize)]
struct GraphQlRequest<'a, V: Serialize> {
query: &'a str,
variables: V,
}
#[derive(Deserialize)]
struct GraphQlResponse<T> {
data: Option<T>,
#[serde(default)]
errors: Vec<GraphQlTopLevelError>,
}
#[derive(Deserialize)]
struct GraphQlTopLevelError {
message: String,
}
#[derive(Deserialize, Debug, PartialEq, Eq)]
pub(super) struct MyRole {
pub(super) name: String,
}
#[derive(Deserialize, Debug, PartialEq, Eq)]
pub(super) struct Me {
pub(super) role: MyRole,
pub(super) roles: Vec<MyRole>,
}
#[derive(Deserialize)]
struct MeData {
me: Option<Me>,
}
fn graphql_url(registry: &url::Host) -> String {
format!("https://{registry}/graphql")
}
async fn execute<T: DeserializeOwned, V: Serialize + Send + Sync>(
http_client: &impl HttpClient,
registry: &url::Host,
query: &str,
variables: V,
access_token: &str,
) -> Res<T> {
let request = GraphQlRequest { query, variables };
let response: GraphQlResponse<T> = http_client
.post_json_auth(&graphql_url(registry), &request, access_token)
.await?;
if !response.errors.is_empty() {
let joined = response
.errors
.into_iter()
.map(|e| e.message)
.collect::<Vec<_>>()
.join("; ");
return Err(RoleError::GraphQl(joined).into());
}
response
.data
.ok_or_else(|| RoleError::GraphQl("registry returned no data".to_string()).into())
}
pub(super) async fn query_me(
http_client: &impl HttpClient,
registry: &url::Host,
host: &Host,
access_token: &str,
) -> Res<Me> {
let data: MeData = execute(http_client, registry, ME_QUERY, (), access_token).await?;
data.me
.ok_or_else(|| RoleError::NotAuthenticated(host.to_owned()))
.map_err(Into::into)
}
#[derive(Serialize)]
struct SwitchRoleVariables<'a> {
#[serde(rename = "roleName")]
role_name: &'a str,
}
#[derive(Deserialize)]
#[serde(tag = "__typename")]
enum SwitchRoleResult {
Me(Me),
InvalidInput { errors: Vec<InputError> },
OperationError { message: String },
}
#[derive(Deserialize)]
struct InputError {
message: String,
}
#[derive(Deserialize)]
struct SwitchRoleData {
#[serde(rename = "switchRole")]
switch_role: SwitchRoleResult,
}
pub(super) async fn mutate_switch_role(
http_client: &impl HttpClient,
registry: &url::Host,
role_name: &str,
access_token: &str,
) -> Res<Me> {
let data: SwitchRoleData = execute(
http_client,
registry,
SWITCH_ROLE_MUTATION,
SwitchRoleVariables { role_name },
access_token,
)
.await?;
match data.switch_role {
SwitchRoleResult::Me(me) => Ok(me),
SwitchRoleResult::InvalidInput { errors } => {
let joined = errors
.into_iter()
.map(|e| e.message)
.collect::<Vec<_>>()
.join("; ");
Err(RoleError::SwitchRejected(joined).into())
}
SwitchRoleResult::OperationError { message } => {
Err(RoleError::SwitchRejected(message).into())
}
}
}
const BUCKETS_QUERY: &str = "query { buckets { name } }";
#[derive(Deserialize)]
struct BucketName {
name: String,
}
#[derive(Deserialize)]
struct BucketsData {
buckets: Vec<BucketName>,
}
pub(super) async fn query_buckets(
http_client: &impl HttpClient,
registry: &url::Host,
access_token: &str,
) -> Res<Vec<String>> {
let data: BucketsData = execute(http_client, registry, BUCKETS_QUERY, (), access_token).await?;
Ok(data.buckets.into_iter().map(|b| b.name).collect())
}
#[cfg(test)]
mod tests {
use super::*;
use test_log::test;
use crate::Error;
use crate::auth::test_utils::ACCESS_TOKEN;
use crate::auth::test_utils::GraphQlTestHttpClient;
use crate::auth::test_utils::get_host;
use crate::auth::test_utils::get_registry_host;
#[test(tokio::test)]
async fn query_me_returns_active_and_available_roles() -> Res {
let client = GraphQlTestHttpClient::default();
let me = query_me(&client, &get_registry_host(), &get_host(), ACCESS_TOKEN).await?;
assert_eq!(me.role.name, "ReadWrite");
assert_eq!(
me.roles.iter().map(|r| r.name.as_str()).collect::<Vec<_>>(),
vec!["ReadWrite", "ReadOnly"]
);
Ok(())
}
#[test(tokio::test)]
async fn query_me_maps_null_me_to_not_authenticated() {
let client = GraphQlTestHttpClient {
me_is_null: true,
..GraphQlTestHttpClient::default()
};
let err = query_me(&client, &get_registry_host(), &get_host(), ACCESS_TOKEN)
.await
.unwrap_err();
assert!(
matches!(err, Error::Role(RoleError::NotAuthenticated(_))),
"expected NotAuthenticated, got {err:?}"
);
}
#[test(tokio::test)]
async fn query_me_surfaces_top_level_graphql_errors() {
let client = GraphQlTestHttpClient {
top_level_error: Some("field 'me' is deprecated".to_string()),
..GraphQlTestHttpClient::default()
};
let err = query_me(&client, &get_registry_host(), &get_host(), ACCESS_TOKEN)
.await
.unwrap_err();
assert!(
matches!(&err, Error::Role(RoleError::GraphQl(m)) if m.contains("deprecated")),
"expected GraphQl error, got {err:?}"
);
}
#[test(tokio::test)]
async fn switch_role_returns_the_new_active_role() -> Res {
let client = GraphQlTestHttpClient::default();
let me =
mutate_switch_role(&client, &get_registry_host(), "ReadOnly", ACCESS_TOKEN).await?;
assert_eq!(me.role.name, "ReadOnly");
Ok(())
}
#[test(tokio::test)]
async fn switch_role_maps_invalid_input_to_switch_rejected() {
let client = GraphQlTestHttpClient {
switch_result: serde_json::json!({
"__typename": "InvalidInput",
"errors": [{"message": "no such role", "name": "InvalidRole"}],
}),
..GraphQlTestHttpClient::default()
};
let err = mutate_switch_role(&client, &get_registry_host(), "Nope", ACCESS_TOKEN)
.await
.unwrap_err();
assert!(
matches!(&err, Error::Role(RoleError::SwitchRejected(m)) if m.contains("no such role")),
"expected SwitchRejected, got {err:?}"
);
}
#[test(tokio::test)]
async fn switch_role_maps_operation_error_to_switch_rejected() {
let client = GraphQlTestHttpClient {
switch_result: serde_json::json!({
"__typename": "OperationError",
"message": "role is locked by SSO",
"name": "RoleLocked",
}),
..GraphQlTestHttpClient::default()
};
let err = mutate_switch_role(&client, &get_registry_host(), "ReadWrite", ACCESS_TOKEN)
.await
.unwrap_err();
assert!(
matches!(&err, Error::Role(RoleError::SwitchRejected(m)) if m.contains("locked")),
"expected SwitchRejected, got {err:?}"
);
}
#[test(tokio::test)]
async fn query_buckets_returns_role_scoped_bucket_names() -> Res {
let client = GraphQlTestHttpClient {
buckets: vec!["readable-one", "readable-two"],
..GraphQlTestHttpClient::default()
};
let buckets = query_buckets(&client, &get_registry_host(), ACCESS_TOKEN).await?;
assert_eq!(buckets, vec!["readable-one", "readable-two"]);
Ok(())
}
#[test(tokio::test)]
async fn query_buckets_returns_empty_when_the_role_reaches_nothing() -> Res {
let client = GraphQlTestHttpClient {
buckets: vec![],
..GraphQlTestHttpClient::default()
};
let buckets = query_buckets(&client, &get_registry_host(), ACCESS_TOKEN).await?;
assert!(buckets.is_empty());
Ok(())
}
}