use crate::auth::oauth::hmac::{compute_signature_base64, constant_time_compare};
use crate::config::ShopifyConfig;
use crate::rest::resources::v2025_10::common::WebhookTopic;
use crate::webhooks::WebhookError;
pub const HEADER_HMAC: &str = "X-Shopify-Hmac-SHA256";
pub const HEADER_TOPIC: &str = "X-Shopify-Topic";
pub const HEADER_SHOP_DOMAIN: &str = "X-Shopify-Shop-Domain";
pub const HEADER_API_VERSION: &str = "X-Shopify-API-Version";
pub const HEADER_WEBHOOK_ID: &str = "X-Shopify-Webhook-Id";
#[derive(Debug, Clone)]
pub struct WebhookRequest {
body: Vec<u8>,
hmac_header: String,
topic: Option<String>,
shop_domain: Option<String>,
api_version: Option<String>,
webhook_id: Option<String>,
}
impl WebhookRequest {
#[must_use]
pub fn new(
body: Vec<u8>,
hmac_header: String,
topic: Option<String>,
shop_domain: Option<String>,
api_version: Option<String>,
webhook_id: Option<String>,
) -> Self {
Self {
body,
hmac_header,
topic,
shop_domain,
api_version,
webhook_id,
}
}
#[must_use]
pub fn body(&self) -> &[u8] {
&self.body
}
#[must_use]
pub fn hmac_header(&self) -> &str {
&self.hmac_header
}
#[must_use]
pub fn topic(&self) -> Option<&str> {
self.topic.as_deref()
}
#[must_use]
pub fn shop_domain(&self) -> Option<&str> {
self.shop_domain.as_deref()
}
#[must_use]
pub fn api_version(&self) -> Option<&str> {
self.api_version.as_deref()
}
#[must_use]
pub fn webhook_id(&self) -> Option<&str> {
self.webhook_id.as_deref()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct WebhookContext {
topic: Option<WebhookTopic>,
topic_raw: String,
shop_domain: Option<String>,
api_version: Option<String>,
webhook_id: Option<String>,
}
impl WebhookContext {
fn new(
topic: Option<WebhookTopic>,
topic_raw: String,
shop_domain: Option<String>,
api_version: Option<String>,
webhook_id: Option<String>,
) -> Self {
Self {
topic,
topic_raw,
shop_domain,
api_version,
webhook_id,
}
}
#[must_use]
pub fn topic(&self) -> Option<WebhookTopic> {
self.topic
}
#[must_use]
pub fn topic_raw(&self) -> &str {
&self.topic_raw
}
#[must_use]
pub fn shop_domain(&self) -> Option<&str> {
self.shop_domain.as_deref()
}
#[must_use]
pub fn api_version(&self) -> Option<&str> {
self.api_version.as_deref()
}
#[must_use]
pub fn webhook_id(&self) -> Option<&str> {
self.webhook_id.as_deref()
}
}
fn parse_topic(topic: &str) -> Option<WebhookTopic> {
let quoted = format!("\"{}\"", topic);
serde_json::from_str("ed).ok()
}
#[must_use]
pub fn verify_hmac(raw_body: &[u8], hmac_header: &str, secret: &str) -> bool {
let computed = compute_signature_base64(raw_body, secret);
constant_time_compare(&computed, hmac_header)
}
#[must_use]
pub fn verify_webhook(
config: &ShopifyConfig,
request: &WebhookRequest,
) -> Result<WebhookContext, WebhookError> {
let body = request.body();
let hmac_header = request.hmac_header();
let mut verified = verify_hmac(body, hmac_header, config.api_secret_key().as_ref());
if !verified {
if let Some(old_secret) = config.old_api_secret_key() {
verified = verify_hmac(body, hmac_header, old_secret.as_ref());
}
}
if !verified {
return Err(WebhookError::InvalidHmac);
}
let topic_raw = request.topic().unwrap_or("").to_string();
let topic = if topic_raw.is_empty() {
None
} else {
parse_topic(&topic_raw)
};
Ok(WebhookContext::new(
topic,
topic_raw,
request.shop_domain().map(String::from),
request.api_version().map(String::from),
request.webhook_id().map(String::from),
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{ApiKey, ApiSecretKey};
#[test]
fn test_header_constants_match_shopify_documentation() {
assert_eq!(HEADER_HMAC, "X-Shopify-Hmac-SHA256");
assert_eq!(HEADER_TOPIC, "X-Shopify-Topic");
assert_eq!(HEADER_SHOP_DOMAIN, "X-Shopify-Shop-Domain");
assert_eq!(HEADER_API_VERSION, "X-Shopify-API-Version");
assert_eq!(HEADER_WEBHOOK_ID, "X-Shopify-Webhook-Id");
}
#[test]
fn test_webhook_request_new_with_all_headers() {
let request = WebhookRequest::new(
b"test body".to_vec(),
"hmac-value".to_string(),
Some("orders/create".to_string()),
Some("example.myshopify.com".to_string()),
Some("2025-10".to_string()),
Some("webhook-123".to_string()),
);
assert_eq!(request.body(), b"test body");
assert_eq!(request.hmac_header(), "hmac-value");
assert_eq!(request.topic(), Some("orders/create"));
assert_eq!(request.shop_domain(), Some("example.myshopify.com"));
assert_eq!(request.api_version(), Some("2025-10"));
assert_eq!(request.webhook_id(), Some("webhook-123"));
}
#[test]
fn test_webhook_request_with_minimal_headers() {
let request = WebhookRequest::new(
b"body".to_vec(),
"hmac".to_string(),
None,
None,
None,
None,
);
assert_eq!(request.body(), b"body");
assert_eq!(request.hmac_header(), "hmac");
assert_eq!(request.topic(), None);
assert_eq!(request.shop_domain(), None);
assert_eq!(request.api_version(), None);
assert_eq!(request.webhook_id(), None);
}
#[test]
fn test_webhook_context_accessor_methods() {
let context = WebhookContext::new(
Some(WebhookTopic::OrdersCreate),
"orders/create".to_string(),
Some("shop.myshopify.com".to_string()),
Some("2025-10".to_string()),
Some("id-123".to_string()),
);
assert_eq!(context.topic(), Some(WebhookTopic::OrdersCreate));
assert_eq!(context.topic_raw(), "orders/create");
assert_eq!(context.shop_domain(), Some("shop.myshopify.com"));
assert_eq!(context.api_version(), Some("2025-10"));
assert_eq!(context.webhook_id(), Some("id-123"));
}
#[test]
fn test_webhook_context_topic_returns_parsed_enum_when_valid() {
let context = WebhookContext::new(
Some(WebhookTopic::ProductsUpdate),
"products/update".to_string(),
None,
None,
None,
);
assert_eq!(context.topic(), Some(WebhookTopic::ProductsUpdate));
}
#[test]
fn test_webhook_context_topic_returns_none_for_unknown_topics() {
let context = WebhookContext::new(
None,
"custom/unknown_topic".to_string(),
None,
None,
None,
);
assert_eq!(context.topic(), None);
assert_eq!(context.topic_raw(), "custom/unknown_topic");
}
#[test]
fn test_webhook_context_topic_raw_always_returns_raw_string() {
let context1 = WebhookContext::new(
Some(WebhookTopic::OrdersCreate),
"orders/create".to_string(),
None,
None,
None,
);
assert_eq!(context1.topic_raw(), "orders/create");
let context2 = WebhookContext::new(None, "unknown/topic".to_string(), None, None, None);
assert_eq!(context2.topic_raw(), "unknown/topic");
}
#[test]
fn test_verify_hmac_returns_true_with_valid_signature() {
let body = b"test payload";
let secret = "my-secret";
let hmac = compute_signature_base64(body, secret);
assert!(verify_hmac(body, &hmac, secret));
}
#[test]
fn test_verify_hmac_returns_false_with_invalid_signature() {
let body = b"test payload";
let secret = "my-secret";
assert!(!verify_hmac(body, "invalid-hmac", secret));
}
#[test]
fn test_verify_hmac_handles_empty_body() {
let body = b"";
let secret = "secret";
let hmac = compute_signature_base64(body, secret);
assert!(verify_hmac(body, &hmac, secret));
}
#[test]
fn test_verify_webhook_succeeds_with_primary_key() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("key").unwrap())
.api_secret_key(ApiSecretKey::new("primary-secret").unwrap())
.build()
.unwrap();
let body = b"webhook body";
let hmac = compute_signature_base64(body, "primary-secret");
let request = WebhookRequest::new(
body.to_vec(),
hmac,
Some("orders/create".to_string()),
Some("shop.myshopify.com".to_string()),
Some("2025-10".to_string()),
Some("webhook-id".to_string()),
);
let result = verify_webhook(&config, &request);
assert!(result.is_ok());
let context = result.unwrap();
assert_eq!(context.topic(), Some(WebhookTopic::OrdersCreate));
assert_eq!(context.shop_domain(), Some("shop.myshopify.com"));
}
#[test]
fn test_verify_webhook_falls_back_to_old_key_successfully() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("key").unwrap())
.api_secret_key(ApiSecretKey::new("new-secret").unwrap())
.old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
.build()
.unwrap();
let body = b"webhook body";
let hmac = compute_signature_base64(body, "old-secret");
let request = WebhookRequest::new(body.to_vec(), hmac, None, None, None, None);
let result = verify_webhook(&config, &request);
assert!(result.is_ok());
}
#[test]
fn test_verify_webhook_fails_when_both_keys_fail() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("key").unwrap())
.api_secret_key(ApiSecretKey::new("secret-1").unwrap())
.old_api_secret_key(ApiSecretKey::new("secret-2").unwrap())
.build()
.unwrap();
let body = b"webhook body";
let hmac = compute_signature_base64(body, "wrong-secret");
let request = WebhookRequest::new(body.to_vec(), hmac, None, None, None, None);
let result = verify_webhook(&config, &request);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), WebhookError::InvalidHmac));
}
#[test]
fn test_verify_webhook_returns_correct_context() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("key").unwrap())
.api_secret_key(ApiSecretKey::new("secret").unwrap())
.build()
.unwrap();
let body = b"payload";
let hmac = compute_signature_base64(body, "secret");
let request = WebhookRequest::new(
body.to_vec(),
hmac,
Some("products/update".to_string()),
Some("test.myshopify.com".to_string()),
Some("2025-10".to_string()),
Some("wh-id-123".to_string()),
);
let context = verify_webhook(&config, &request).unwrap();
assert_eq!(context.topic(), Some(WebhookTopic::ProductsUpdate));
assert_eq!(context.topic_raw(), "products/update");
assert_eq!(context.shop_domain(), Some("test.myshopify.com"));
assert_eq!(context.api_version(), Some("2025-10"));
assert_eq!(context.webhook_id(), Some("wh-id-123"));
}
#[test]
fn test_verify_webhook_parses_known_topic_into_enum() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("key").unwrap())
.api_secret_key(ApiSecretKey::new("secret").unwrap())
.build()
.unwrap();
let body = b"data";
let hmac = compute_signature_base64(body, "secret");
let request = WebhookRequest::new(
body.to_vec(),
hmac,
Some("customers/create".to_string()),
None,
None,
None,
);
let context = verify_webhook(&config, &request).unwrap();
assert_eq!(context.topic(), Some(WebhookTopic::CustomersCreate));
}
#[test]
fn test_verify_webhook_handles_unknown_topic() {
let config = ShopifyConfig::builder()
.api_key(ApiKey::new("key").unwrap())
.api_secret_key(ApiSecretKey::new("secret").unwrap())
.build()
.unwrap();
let body = b"data";
let hmac = compute_signature_base64(body, "secret");
let request = WebhookRequest::new(
body.to_vec(),
hmac,
Some("custom/new_event".to_string()),
None,
None,
None,
);
let context = verify_webhook(&config, &request).unwrap();
assert_eq!(context.topic(), None);
assert_eq!(context.topic_raw(), "custom/new_event");
}
#[test]
fn test_parse_topic_known_topics() {
assert_eq!(parse_topic("orders/create"), Some(WebhookTopic::OrdersCreate));
assert_eq!(
parse_topic("products/update"),
Some(WebhookTopic::ProductsUpdate)
);
assert_eq!(
parse_topic("customers/delete"),
Some(WebhookTopic::CustomersDelete)
);
assert_eq!(
parse_topic("app/uninstalled"),
Some(WebhookTopic::AppUninstalled)
);
}
#[test]
fn test_parse_topic_unknown_topics() {
assert_eq!(parse_topic("unknown/topic"), None);
assert_eq!(parse_topic("custom_event"), None);
assert_eq!(parse_topic(""), None);
}
}