use crate::auth::oauth::OAuthError;
use crate::auth::session::AccessTokenResponse;
use crate::auth::Session;
use crate::config::{ShopDomain, ShopifyConfig};
use serde::Serialize;
const CLIENT_CREDENTIALS_GRANT_TYPE: &str = "client_credentials";
#[derive(Debug, Serialize)]
struct ClientCredentialsRequest<'a> {
client_id: &'a str,
client_secret: &'a str,
grant_type: &'a str,
}
pub async fn exchange_client_credentials(
config: &ShopifyConfig,
shop: &ShopDomain,
) -> Result<Session, OAuthError> {
if config.is_embedded() {
return Err(OAuthError::NotPrivateApp);
}
let token_url = format!("https://{}/admin/oauth/access_token", shop.as_ref());
let request_body = ClientCredentialsRequest {
client_id: config.api_key().as_ref(),
client_secret: config.api_secret_key().as_ref(),
grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
};
let client = reqwest::Client::new();
let response = client
.post(&token_url)
.json(&request_body)
.send()
.await
.map_err(|e| OAuthError::ClientCredentialsFailed {
status: 0,
message: format!("Network error: {e}"),
})?;
let status = response.status().as_u16();
if !response.status().is_success() {
let error_body = response.text().await.unwrap_or_default();
return Err(OAuthError::ClientCredentialsFailed {
status,
message: error_body,
});
}
let token_response: AccessTokenResponse =
response
.json()
.await
.map_err(|e| OAuthError::ClientCredentialsFailed {
status,
message: format!("Failed to parse token response: {e}"),
})?;
let session = Session::from_access_token_response(shop.clone(), &token_response);
Ok(session)
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ClientCredentialsRequest<'_>>();
};
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKey, ApiSecretKey};
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn create_private_config(secret: &str) -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new(secret).unwrap())
.is_embedded(false)
.build()
.unwrap()
}
fn create_embedded_config(secret: &str) -> ShopifyConfig {
ShopifyConfig::builder()
.api_key(ApiKey::new("test-api-key").unwrap())
.api_secret_key(ApiSecretKey::new(secret).unwrap())
.is_embedded(true)
.build()
.unwrap()
}
#[tokio::test]
async fn test_configuration_validation_rejects_embedded_config_with_not_private_app_error() {
let config = create_embedded_config("test-secret");
let shop = ShopDomain::new("test-shop").unwrap();
let result = exchange_client_credentials(&config, &shop).await;
assert!(matches!(result, Err(OAuthError::NotPrivateApp)));
}
#[tokio::test]
async fn test_configuration_validation_accepts_non_embedded_config() {
let config = create_private_config("test-secret");
let shop = ShopDomain::new("test-shop").unwrap();
let result = exchange_client_credentials(&config, &shop).await;
assert!(matches!(
result,
Err(OAuthError::ClientCredentialsFailed { .. })
));
}
#[tokio::test]
async fn test_successful_response_creates_offline_session_with_correct_id_format() {
let mock_server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/admin/oauth/access_token"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"access_token": "test-access-token",
"scope": "read_products,write_orders"
})))
.mount(&mock_server)
.await;
let shop = ShopDomain::new("test-shop").unwrap();
let response = AccessTokenResponse {
access_token: "test-access-token".to_string(),
scope: "read_products,write_orders".to_string(),
expires_in: None,
associated_user_scope: None,
associated_user: None,
session: None,
refresh_token: None,
refresh_token_expires_in: None,
};
let session = Session::from_access_token_response(shop, &response);
assert_eq!(session.id, "offline_test-shop.myshopify.com");
assert!(!session.is_online);
assert!(session.associated_user.is_none());
assert_eq!(session.access_token, "test-access-token");
}
#[tokio::test]
async fn test_http_error_responses_map_to_client_credentials_failed_with_status_code() {
let config = create_private_config("test-secret");
let shop = ShopDomain::new("test-shop").unwrap();
let result = exchange_client_credentials(&config, &shop).await;
match result {
Err(OAuthError::ClientCredentialsFailed { status, message }) => {
assert!(status == 0 || status >= 400);
assert!(!message.is_empty());
}
_ => panic!("Expected ClientCredentialsFailed error"),
}
}
#[tokio::test]
async fn test_network_errors_map_to_client_credentials_failed() {
let config = create_private_config("test-secret");
let shop = ShopDomain::new("test-shop").unwrap();
let result = exchange_client_credentials(&config, &shop).await;
assert!(
matches!(result, Err(OAuthError::ClientCredentialsFailed { .. })),
"Expected ClientCredentialsFailed error"
);
}
#[tokio::test]
async fn test_request_body_contains_correct_grant_type() {
assert_eq!(CLIENT_CREDENTIALS_GRANT_TYPE, "client_credentials");
let request = ClientCredentialsRequest {
client_id: "test-client-id",
client_secret: "test-client-secret",
grant_type: CLIENT_CREDENTIALS_GRANT_TYPE,
};
let json = serde_json::to_string(&request).unwrap();
assert!(json.contains("\"grant_type\":\"client_credentials\""));
assert!(json.contains("\"client_id\":\"test-client-id\""));
assert!(json.contains("\"client_secret\":\"test-client-secret\""));
}
#[test]
fn test_client_credentials_request_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ClientCredentialsRequest<'_>>();
}
}