Skip to main content

shopify_sdk/webhooks/
verification.rs

1//! Webhook signature verification for the Shopify API SDK.
2//!
3//! This module provides functions and types for verifying HMAC signatures on
4//! incoming webhook requests from Shopify.
5//!
6//! # Overview
7//!
8//! Shopify signs webhook requests using HMAC-SHA256 with the app's API secret key.
9//! This module provides both high-level and low-level verification functions:
10//!
11//! - [`verify_webhook`]: High-level function that uses `ShopifyConfig` and supports key rotation
12//! - [`verify_hmac`]: Low-level function for custom integrations
13//!
14//! # Example
15//!
16//! ```rust
17//! use shopify_sdk::webhooks::{WebhookRequest, verify_webhook, verify_hmac};
18//! use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
19//! use shopify_sdk::auth::oauth::hmac::compute_signature_base64;
20//!
21//! // Create a config with the API secret
22//! let config = ShopifyConfig::builder()
23//!     .api_key(ApiKey::new("test-key").unwrap())
24//!     .api_secret_key(ApiSecretKey::new("my-secret").unwrap())
25//!     .build()
26//!     .unwrap();
27//!
28//! // Compute a valid HMAC for testing
29//! let body = b"webhook payload";
30//! let hmac = compute_signature_base64(body, "my-secret");
31//!
32//! // Create a webhook request
33//! let request = WebhookRequest::new(
34//!     body.to_vec(),
35//!     hmac,
36//!     Some("orders/create".to_string()),
37//!     Some("example.myshopify.com".to_string()),
38//!     Some("2026-04".to_string()),
39//!     Some("webhook-123".to_string()),
40//! );
41//!
42//! // Verify the webhook (high-level)
43//! let context = verify_webhook(&config, &request).expect("verification failed");
44//! assert_eq!(context.shop_domain(), Some("example.myshopify.com"));
45//!
46//! // Or use low-level verification
47//! let body = b"payload";
48//! let hmac = compute_signature_base64(body, "secret");
49//! assert!(verify_hmac(body, &hmac, "secret"));
50//! ```
51//!
52//! # Security
53//!
54//! All HMAC comparisons use constant-time comparison to prevent timing attacks.
55//! The high-level verification function also supports key rotation by trying
56//! the primary secret key first, then falling back to the old secret key.
57
58use crate::auth::oauth::hmac::{compute_signature_base64, constant_time_compare};
59use crate::config::ShopifyConfig;
60use crate::rest::resources::v2026_04::common::WebhookTopic;
61use crate::webhooks::WebhookError;
62
63// ============================================================================
64// Header Constants
65// ============================================================================
66
67/// HTTP header name for the HMAC-SHA256 signature.
68///
69/// Shopify includes this header in all webhook requests. The value is a
70/// base64-encoded HMAC-SHA256 signature of the request body.
71pub const HEADER_HMAC: &str = "X-Shopify-Hmac-SHA256";
72
73/// HTTP header name for the webhook topic.
74///
75/// Contains the topic string (e.g., "orders/create") that identifies
76/// what event triggered the webhook.
77pub const HEADER_TOPIC: &str = "X-Shopify-Topic";
78
79/// HTTP header name for the shop domain.
80///
81/// Contains the myshopify.com domain of the shop that triggered the webhook
82/// (e.g., "example.myshopify.com").
83pub const HEADER_SHOP_DOMAIN: &str = "X-Shopify-Shop-Domain";
84
85/// HTTP header name for the API version.
86///
87/// Contains the API version used for the webhook payload format
88/// (e.g., "2026-04").
89pub const HEADER_API_VERSION: &str = "X-Shopify-API-Version";
90
91/// HTTP header name for the webhook ID.
92///
93/// Contains a unique identifier for the webhook delivery, useful for
94/// idempotency and debugging.
95pub const HEADER_WEBHOOK_ID: &str = "X-Shopify-Webhook-Id";
96
97// ============================================================================
98// WebhookRequest
99// ============================================================================
100
101/// Represents an incoming webhook request from Shopify.
102///
103/// This struct holds the raw request body and headers needed for verification.
104/// The body is stored as raw bytes to preserve the exact payload for HMAC computation.
105///
106/// # Example
107///
108/// ```rust
109/// use shopify_sdk::webhooks::WebhookRequest;
110///
111/// let request = WebhookRequest::new(
112///     b"raw body bytes".to_vec(),
113///     "hmac-signature".to_string(),
114///     Some("orders/create".to_string()),
115///     Some("example.myshopify.com".to_string()),
116///     Some("2026-04".to_string()),
117///     Some("webhook-123".to_string()),
118/// );
119///
120/// assert_eq!(request.body(), b"raw body bytes");
121/// assert_eq!(request.hmac_header(), "hmac-signature");
122/// ```
123#[derive(Debug, Clone)]
124pub struct WebhookRequest {
125    /// Raw request body as bytes.
126    body: Vec<u8>,
127    /// HMAC signature from the X-Shopify-Hmac-SHA256 header.
128    hmac_header: String,
129    /// Webhook topic from the X-Shopify-Topic header.
130    topic: Option<String>,
131    /// Shop domain from the X-Shopify-Shop-Domain header.
132    shop_domain: Option<String>,
133    /// API version from the X-Shopify-API-Version header.
134    api_version: Option<String>,
135    /// Webhook ID from the X-Shopify-Webhook-Id header.
136    webhook_id: Option<String>,
137}
138
139impl WebhookRequest {
140    /// Creates a new webhook request with the given body and headers.
141    ///
142    /// # Arguments
143    ///
144    /// * `body` - Raw request body as bytes
145    /// * `hmac_header` - Value of the X-Shopify-Hmac-SHA256 header
146    /// * `topic` - Value of the X-Shopify-Topic header (optional)
147    /// * `shop_domain` - Value of the X-Shopify-Shop-Domain header (optional)
148    /// * `api_version` - Value of the X-Shopify-API-Version header (optional)
149    /// * `webhook_id` - Value of the X-Shopify-Webhook-Id header (optional)
150    #[must_use]
151    pub fn new(
152        body: Vec<u8>,
153        hmac_header: String,
154        topic: Option<String>,
155        shop_domain: Option<String>,
156        api_version: Option<String>,
157        webhook_id: Option<String>,
158    ) -> Self {
159        Self {
160            body,
161            hmac_header,
162            topic,
163            shop_domain,
164            api_version,
165            webhook_id,
166        }
167    }
168
169    /// Returns the raw request body as a byte slice.
170    #[must_use]
171    pub fn body(&self) -> &[u8] {
172        &self.body
173    }
174
175    /// Returns the HMAC signature header value.
176    #[must_use]
177    pub fn hmac_header(&self) -> &str {
178        &self.hmac_header
179    }
180
181    /// Returns the topic header value, if present.
182    #[must_use]
183    pub fn topic(&self) -> Option<&str> {
184        self.topic.as_deref()
185    }
186
187    /// Returns the shop domain header value, if present.
188    #[must_use]
189    pub fn shop_domain(&self) -> Option<&str> {
190        self.shop_domain.as_deref()
191    }
192
193    /// Returns the API version header value, if present.
194    #[must_use]
195    pub fn api_version(&self) -> Option<&str> {
196        self.api_version.as_deref()
197    }
198
199    /// Returns the webhook ID header value, if present.
200    #[must_use]
201    pub fn webhook_id(&self) -> Option<&str> {
202        self.webhook_id.as_deref()
203    }
204}
205
206// ============================================================================
207// WebhookContext
208// ============================================================================
209
210/// Represents verified webhook metadata after successful signature verification.
211///
212/// This struct is returned by [`verify_webhook`] and contains the parsed headers
213/// from a verified webhook request. It provides both the parsed topic enum (when
214/// the topic is a known value) and the raw topic string (always available).
215///
216/// # Example
217///
218/// ```rust
219/// use shopify_sdk::webhooks::{WebhookRequest, verify_webhook, WebhookContext};
220/// use shopify_sdk::webhooks::WebhookTopic;
221/// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
222/// use shopify_sdk::auth::oauth::hmac::compute_signature_base64;
223///
224/// let config = ShopifyConfig::builder()
225///     .api_key(ApiKey::new("key").unwrap())
226///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
227///     .build()
228///     .unwrap();
229///
230/// let body = b"test";
231/// let hmac = compute_signature_base64(body, "secret");
232/// let request = WebhookRequest::new(
233///     body.to_vec(),
234///     hmac,
235///     Some("orders/create".to_string()),
236///     Some("example.myshopify.com".to_string()),
237///     None,
238///     None,
239/// );
240///
241/// let context = verify_webhook(&config, &request).unwrap();
242/// assert_eq!(context.topic(), Some(WebhookTopic::OrdersCreate));
243/// assert_eq!(context.topic_raw(), "orders/create");
244/// ```
245#[derive(Debug, Clone, PartialEq)]
246pub struct WebhookContext {
247    /// Parsed topic enum (None for unknown topics).
248    topic: Option<WebhookTopic>,
249    /// Raw topic string from the header.
250    topic_raw: String,
251    /// Shop domain from the header.
252    shop_domain: Option<String>,
253    /// API version from the header.
254    api_version: Option<String>,
255    /// Webhook ID from the header.
256    webhook_id: Option<String>,
257}
258
259impl WebhookContext {
260    /// Creates a new webhook context.
261    fn new(
262        topic: Option<WebhookTopic>,
263        topic_raw: String,
264        shop_domain: Option<String>,
265        api_version: Option<String>,
266        webhook_id: Option<String>,
267    ) -> Self {
268        Self {
269            topic,
270            topic_raw,
271            shop_domain,
272            api_version,
273            webhook_id,
274        }
275    }
276
277    /// Returns the parsed webhook topic enum, if the topic is a known value.
278    ///
279    /// Returns `None` for unknown or custom topics.
280    #[must_use]
281    pub fn topic(&self) -> Option<WebhookTopic> {
282        self.topic
283    }
284
285    /// Returns the raw topic string as received in the header.
286    ///
287    /// This is always available, even for unknown or custom topics.
288    #[must_use]
289    pub fn topic_raw(&self) -> &str {
290        &self.topic_raw
291    }
292
293    /// Returns the shop domain, if present in the webhook headers.
294    #[must_use]
295    pub fn shop_domain(&self) -> Option<&str> {
296        self.shop_domain.as_deref()
297    }
298
299    /// Returns the API version, if present in the webhook headers.
300    #[must_use]
301    pub fn api_version(&self) -> Option<&str> {
302        self.api_version.as_deref()
303    }
304
305    /// Returns the webhook ID, if present in the webhook headers.
306    #[must_use]
307    pub fn webhook_id(&self) -> Option<&str> {
308        self.webhook_id.as_deref()
309    }
310}
311
312// ============================================================================
313// Verification Functions
314// ============================================================================
315
316/// Parses a topic string into a `WebhookTopic` enum.
317///
318/// Returns `None` for unknown or custom topics.
319fn parse_topic(topic: &str) -> Option<WebhookTopic> {
320    // WebhookTopic uses serde with rename attributes like "orders/create"
321    // We can deserialize a quoted JSON string to get the enum
322    let quoted = format!("\"{}\"", topic);
323    serde_json::from_str(&quoted).ok()
324}
325
326/// Verifies the HMAC signature of a webhook request body.
327///
328/// This is a low-level function that performs HMAC verification with a single
329/// secret key. For most use cases, prefer [`verify_webhook`] which supports
330/// key rotation.
331///
332/// # Arguments
333///
334/// * `raw_body` - The raw request body bytes
335/// * `hmac_header` - The value of the X-Shopify-Hmac-SHA256 header
336/// * `secret` - The API secret key to use for verification
337///
338/// # Returns
339///
340/// `true` if the signature is valid, `false` otherwise.
341///
342/// # Example
343///
344/// ```rust
345/// use shopify_sdk::webhooks::verify_hmac;
346/// use shopify_sdk::auth::oauth::hmac::compute_signature_base64;
347///
348/// let body = b"webhook payload";
349/// let secret = "my-secret-key";
350/// let hmac = compute_signature_base64(body, secret);
351///
352/// assert!(verify_hmac(body, &hmac, secret));
353/// assert!(!verify_hmac(body, "invalid", secret));
354/// ```
355#[must_use]
356pub fn verify_hmac(raw_body: &[u8], hmac_header: &str, secret: &str) -> bool {
357    let computed = compute_signature_base64(raw_body, secret);
358    constant_time_compare(&computed, hmac_header)
359}
360
361/// Verifies a webhook request and returns the verified context.
362///
363/// This function validates the HMAC signature using the config's API secret key,
364/// with automatic fallback to the old API secret key for key rotation support.
365///
366/// # Arguments
367///
368/// * `config` - The Shopify configuration containing the API secret key(s)
369/// * `request` - The webhook request to verify
370///
371/// # Returns
372///
373/// A [`WebhookContext`] containing the verified webhook metadata on success,
374/// or a [`WebhookError::InvalidHmac`] if verification fails.
375///
376/// # Key Rotation
377///
378/// If the primary `api_secret_key` fails verification, the function will
379/// automatically try `old_api_secret_key` if configured. This allows seamless
380/// key rotation without breaking in-flight webhooks.
381///
382/// # Example
383///
384/// ```rust
385/// use shopify_sdk::webhooks::{WebhookRequest, verify_webhook};
386/// use shopify_sdk::{ShopifyConfig, ApiKey, ApiSecretKey};
387/// use shopify_sdk::auth::oauth::hmac::compute_signature_base64;
388///
389/// let config = ShopifyConfig::builder()
390///     .api_key(ApiKey::new("key").unwrap())
391///     .api_secret_key(ApiSecretKey::new("secret").unwrap())
392///     .build()
393///     .unwrap();
394///
395/// let body = b"test payload";
396/// let hmac = compute_signature_base64(body, "secret");
397/// let request = WebhookRequest::new(
398///     body.to_vec(),
399///     hmac,
400///     Some("orders/create".to_string()),
401///     None,
402///     None,
403///     None,
404/// );
405///
406/// let context = verify_webhook(&config, &request).expect("verification should succeed");
407/// assert_eq!(context.topic_raw(), "orders/create");
408/// ```
409#[must_use]
410pub fn verify_webhook(
411    config: &ShopifyConfig,
412    request: &WebhookRequest,
413) -> Result<WebhookContext, WebhookError> {
414    let body = request.body();
415    let hmac_header = request.hmac_header();
416
417    // Try primary secret key first
418    let mut verified = verify_hmac(body, hmac_header, config.api_secret_key().as_ref());
419
420    // Fall back to old secret key if configured and primary fails
421    if !verified {
422        if let Some(old_secret) = config.old_api_secret_key() {
423            verified = verify_hmac(body, hmac_header, old_secret.as_ref());
424        }
425    }
426
427    if !verified {
428        return Err(WebhookError::InvalidHmac);
429    }
430
431    // Parse topic string into enum (None for unknown topics)
432    let topic_raw = request.topic().unwrap_or("").to_string();
433    let topic = if topic_raw.is_empty() {
434        None
435    } else {
436        parse_topic(&topic_raw)
437    };
438
439    Ok(WebhookContext::new(
440        topic,
441        topic_raw,
442        request.shop_domain().map(String::from),
443        request.api_version().map(String::from),
444        request.webhook_id().map(String::from),
445    ))
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451    use crate::config::{ApiKey, ApiSecretKey};
452
453    // ========================================================================
454    // Header Constants Tests
455    // ========================================================================
456
457    #[test]
458    fn test_header_constants_match_shopify_documentation() {
459        assert_eq!(HEADER_HMAC, "X-Shopify-Hmac-SHA256");
460        assert_eq!(HEADER_TOPIC, "X-Shopify-Topic");
461        assert_eq!(HEADER_SHOP_DOMAIN, "X-Shopify-Shop-Domain");
462        assert_eq!(HEADER_API_VERSION, "X-Shopify-API-Version");
463        assert_eq!(HEADER_WEBHOOK_ID, "X-Shopify-Webhook-Id");
464    }
465
466    // ========================================================================
467    // WebhookRequest Tests
468    // ========================================================================
469
470    #[test]
471    fn test_webhook_request_new_with_all_headers() {
472        let request = WebhookRequest::new(
473            b"test body".to_vec(),
474            "hmac-value".to_string(),
475            Some("orders/create".to_string()),
476            Some("example.myshopify.com".to_string()),
477            Some("2026-04".to_string()),
478            Some("webhook-123".to_string()),
479        );
480
481        assert_eq!(request.body(), b"test body");
482        assert_eq!(request.hmac_header(), "hmac-value");
483        assert_eq!(request.topic(), Some("orders/create"));
484        assert_eq!(request.shop_domain(), Some("example.myshopify.com"));
485        assert_eq!(request.api_version(), Some("2026-04"));
486        assert_eq!(request.webhook_id(), Some("webhook-123"));
487    }
488
489    #[test]
490    fn test_webhook_request_with_minimal_headers() {
491        let request =
492            WebhookRequest::new(b"body".to_vec(), "hmac".to_string(), None, None, None, None);
493
494        assert_eq!(request.body(), b"body");
495        assert_eq!(request.hmac_header(), "hmac");
496        assert_eq!(request.topic(), None);
497        assert_eq!(request.shop_domain(), None);
498        assert_eq!(request.api_version(), None);
499        assert_eq!(request.webhook_id(), None);
500    }
501
502    // ========================================================================
503    // WebhookContext Tests
504    // ========================================================================
505
506    #[test]
507    fn test_webhook_context_accessor_methods() {
508        let context = WebhookContext::new(
509            Some(WebhookTopic::OrdersCreate),
510            "orders/create".to_string(),
511            Some("shop.myshopify.com".to_string()),
512            Some("2026-04".to_string()),
513            Some("id-123".to_string()),
514        );
515
516        assert_eq!(context.topic(), Some(WebhookTopic::OrdersCreate));
517        assert_eq!(context.topic_raw(), "orders/create");
518        assert_eq!(context.shop_domain(), Some("shop.myshopify.com"));
519        assert_eq!(context.api_version(), Some("2026-04"));
520        assert_eq!(context.webhook_id(), Some("id-123"));
521    }
522
523    #[test]
524    fn test_webhook_context_topic_returns_parsed_enum_when_valid() {
525        let context = WebhookContext::new(
526            Some(WebhookTopic::ProductsUpdate),
527            "products/update".to_string(),
528            None,
529            None,
530            None,
531        );
532
533        assert_eq!(context.topic(), Some(WebhookTopic::ProductsUpdate));
534    }
535
536    #[test]
537    fn test_webhook_context_topic_returns_none_for_unknown_topics() {
538        let context =
539            WebhookContext::new(None, "custom/unknown_topic".to_string(), None, None, None);
540
541        assert_eq!(context.topic(), None);
542        assert_eq!(context.topic_raw(), "custom/unknown_topic");
543    }
544
545    #[test]
546    fn test_webhook_context_topic_raw_always_returns_raw_string() {
547        // For known topic
548        let context1 = WebhookContext::new(
549            Some(WebhookTopic::OrdersCreate),
550            "orders/create".to_string(),
551            None,
552            None,
553            None,
554        );
555        assert_eq!(context1.topic_raw(), "orders/create");
556
557        // For unknown topic
558        let context2 = WebhookContext::new(None, "unknown/topic".to_string(), None, None, None);
559        assert_eq!(context2.topic_raw(), "unknown/topic");
560    }
561
562    // ========================================================================
563    // Verification Function Tests
564    // ========================================================================
565
566    #[test]
567    fn test_verify_hmac_returns_true_with_valid_signature() {
568        let body = b"test payload";
569        let secret = "my-secret";
570        let hmac = compute_signature_base64(body, secret);
571
572        assert!(verify_hmac(body, &hmac, secret));
573    }
574
575    #[test]
576    fn test_verify_hmac_returns_false_with_invalid_signature() {
577        let body = b"test payload";
578        let secret = "my-secret";
579
580        assert!(!verify_hmac(body, "invalid-hmac", secret));
581    }
582
583    #[test]
584    fn test_verify_hmac_handles_empty_body() {
585        let body = b"";
586        let secret = "secret";
587        let hmac = compute_signature_base64(body, secret);
588
589        assert!(verify_hmac(body, &hmac, secret));
590    }
591
592    #[test]
593    fn test_verify_webhook_succeeds_with_primary_key() {
594        let config = ShopifyConfig::builder()
595            .api_key(ApiKey::new("key").unwrap())
596            .api_secret_key(ApiSecretKey::new("primary-secret").unwrap())
597            .build()
598            .unwrap();
599
600        let body = b"webhook body";
601        let hmac = compute_signature_base64(body, "primary-secret");
602        let request = WebhookRequest::new(
603            body.to_vec(),
604            hmac,
605            Some("orders/create".to_string()),
606            Some("shop.myshopify.com".to_string()),
607            Some("2026-04".to_string()),
608            Some("webhook-id".to_string()),
609        );
610
611        let result = verify_webhook(&config, &request);
612        assert!(result.is_ok());
613
614        let context = result.unwrap();
615        assert_eq!(context.topic(), Some(WebhookTopic::OrdersCreate));
616        assert_eq!(context.shop_domain(), Some("shop.myshopify.com"));
617    }
618
619    #[test]
620    fn test_verify_webhook_falls_back_to_old_key_successfully() {
621        let config = ShopifyConfig::builder()
622            .api_key(ApiKey::new("key").unwrap())
623            .api_secret_key(ApiSecretKey::new("new-secret").unwrap())
624            .old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
625            .build()
626            .unwrap();
627
628        // Sign with OLD secret
629        let body = b"webhook body";
630        let hmac = compute_signature_base64(body, "old-secret");
631        let request = WebhookRequest::new(body.to_vec(), hmac, None, None, None, None);
632
633        let result = verify_webhook(&config, &request);
634        assert!(result.is_ok());
635    }
636
637    #[test]
638    fn test_verify_webhook_fails_when_both_keys_fail() {
639        let config = ShopifyConfig::builder()
640            .api_key(ApiKey::new("key").unwrap())
641            .api_secret_key(ApiSecretKey::new("secret-1").unwrap())
642            .old_api_secret_key(ApiSecretKey::new("secret-2").unwrap())
643            .build()
644            .unwrap();
645
646        // Sign with a DIFFERENT secret
647        let body = b"webhook body";
648        let hmac = compute_signature_base64(body, "wrong-secret");
649        let request = WebhookRequest::new(body.to_vec(), hmac, None, None, None, None);
650
651        let result = verify_webhook(&config, &request);
652        assert!(result.is_err());
653        assert!(matches!(result.unwrap_err(), WebhookError::InvalidHmac));
654    }
655
656    #[test]
657    fn test_verify_webhook_returns_correct_context() {
658        let config = ShopifyConfig::builder()
659            .api_key(ApiKey::new("key").unwrap())
660            .api_secret_key(ApiSecretKey::new("secret").unwrap())
661            .build()
662            .unwrap();
663
664        let body = b"payload";
665        let hmac = compute_signature_base64(body, "secret");
666        let request = WebhookRequest::new(
667            body.to_vec(),
668            hmac,
669            Some("products/update".to_string()),
670            Some("test.myshopify.com".to_string()),
671            Some("2026-04".to_string()),
672            Some("wh-id-123".to_string()),
673        );
674
675        let context = verify_webhook(&config, &request).unwrap();
676        assert_eq!(context.topic(), Some(WebhookTopic::ProductsUpdate));
677        assert_eq!(context.topic_raw(), "products/update");
678        assert_eq!(context.shop_domain(), Some("test.myshopify.com"));
679        assert_eq!(context.api_version(), Some("2026-04"));
680        assert_eq!(context.webhook_id(), Some("wh-id-123"));
681    }
682
683    #[test]
684    fn test_verify_webhook_parses_known_topic_into_enum() {
685        let config = ShopifyConfig::builder()
686            .api_key(ApiKey::new("key").unwrap())
687            .api_secret_key(ApiSecretKey::new("secret").unwrap())
688            .build()
689            .unwrap();
690
691        let body = b"data";
692        let hmac = compute_signature_base64(body, "secret");
693        let request = WebhookRequest::new(
694            body.to_vec(),
695            hmac,
696            Some("customers/create".to_string()),
697            None,
698            None,
699            None,
700        );
701
702        let context = verify_webhook(&config, &request).unwrap();
703        assert_eq!(context.topic(), Some(WebhookTopic::CustomersCreate));
704    }
705
706    #[test]
707    fn test_verify_webhook_handles_unknown_topic() {
708        let config = ShopifyConfig::builder()
709            .api_key(ApiKey::new("key").unwrap())
710            .api_secret_key(ApiSecretKey::new("secret").unwrap())
711            .build()
712            .unwrap();
713
714        let body = b"data";
715        let hmac = compute_signature_base64(body, "secret");
716        let request = WebhookRequest::new(
717            body.to_vec(),
718            hmac,
719            Some("custom/new_event".to_string()),
720            None,
721            None,
722            None,
723        );
724
725        let context = verify_webhook(&config, &request).unwrap();
726        assert_eq!(context.topic(), None);
727        assert_eq!(context.topic_raw(), "custom/new_event");
728    }
729
730    // ========================================================================
731    // Topic Parsing Tests
732    // ========================================================================
733
734    #[test]
735    fn test_parse_topic_known_topics() {
736        assert_eq!(
737            parse_topic("orders/create"),
738            Some(WebhookTopic::OrdersCreate)
739        );
740        assert_eq!(
741            parse_topic("products/update"),
742            Some(WebhookTopic::ProductsUpdate)
743        );
744        assert_eq!(
745            parse_topic("customers/delete"),
746            Some(WebhookTopic::CustomersDelete)
747        );
748        assert_eq!(
749            parse_topic("app/uninstalled"),
750            Some(WebhookTopic::AppUninstalled)
751        );
752    }
753
754    #[test]
755    fn test_parse_topic_unknown_topics() {
756        assert_eq!(parse_topic("unknown/topic"), None);
757        assert_eq!(parse_topic("custom_event"), None);
758        assert_eq!(parse_topic(""), None);
759    }
760}