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("2025-10".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::v2025_10::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., "2025-10").
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("2025-10".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("2025-10".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("2025-10"));
486        assert_eq!(request.webhook_id(), Some("webhook-123"));
487    }
488
489    #[test]
490    fn test_webhook_request_with_minimal_headers() {
491        let request = WebhookRequest::new(
492            b"body".to_vec(),
493            "hmac".to_string(),
494            None,
495            None,
496            None,
497            None,
498        );
499
500        assert_eq!(request.body(), b"body");
501        assert_eq!(request.hmac_header(), "hmac");
502        assert_eq!(request.topic(), None);
503        assert_eq!(request.shop_domain(), None);
504        assert_eq!(request.api_version(), None);
505        assert_eq!(request.webhook_id(), None);
506    }
507
508    // ========================================================================
509    // WebhookContext Tests
510    // ========================================================================
511
512    #[test]
513    fn test_webhook_context_accessor_methods() {
514        let context = WebhookContext::new(
515            Some(WebhookTopic::OrdersCreate),
516            "orders/create".to_string(),
517            Some("shop.myshopify.com".to_string()),
518            Some("2025-10".to_string()),
519            Some("id-123".to_string()),
520        );
521
522        assert_eq!(context.topic(), Some(WebhookTopic::OrdersCreate));
523        assert_eq!(context.topic_raw(), "orders/create");
524        assert_eq!(context.shop_domain(), Some("shop.myshopify.com"));
525        assert_eq!(context.api_version(), Some("2025-10"));
526        assert_eq!(context.webhook_id(), Some("id-123"));
527    }
528
529    #[test]
530    fn test_webhook_context_topic_returns_parsed_enum_when_valid() {
531        let context = WebhookContext::new(
532            Some(WebhookTopic::ProductsUpdate),
533            "products/update".to_string(),
534            None,
535            None,
536            None,
537        );
538
539        assert_eq!(context.topic(), Some(WebhookTopic::ProductsUpdate));
540    }
541
542    #[test]
543    fn test_webhook_context_topic_returns_none_for_unknown_topics() {
544        let context = WebhookContext::new(
545            None,
546            "custom/unknown_topic".to_string(),
547            None,
548            None,
549            None,
550        );
551
552        assert_eq!(context.topic(), None);
553        assert_eq!(context.topic_raw(), "custom/unknown_topic");
554    }
555
556    #[test]
557    fn test_webhook_context_topic_raw_always_returns_raw_string() {
558        // For known topic
559        let context1 = WebhookContext::new(
560            Some(WebhookTopic::OrdersCreate),
561            "orders/create".to_string(),
562            None,
563            None,
564            None,
565        );
566        assert_eq!(context1.topic_raw(), "orders/create");
567
568        // For unknown topic
569        let context2 = WebhookContext::new(None, "unknown/topic".to_string(), None, None, None);
570        assert_eq!(context2.topic_raw(), "unknown/topic");
571    }
572
573    // ========================================================================
574    // Verification Function Tests
575    // ========================================================================
576
577    #[test]
578    fn test_verify_hmac_returns_true_with_valid_signature() {
579        let body = b"test payload";
580        let secret = "my-secret";
581        let hmac = compute_signature_base64(body, secret);
582
583        assert!(verify_hmac(body, &hmac, secret));
584    }
585
586    #[test]
587    fn test_verify_hmac_returns_false_with_invalid_signature() {
588        let body = b"test payload";
589        let secret = "my-secret";
590
591        assert!(!verify_hmac(body, "invalid-hmac", secret));
592    }
593
594    #[test]
595    fn test_verify_hmac_handles_empty_body() {
596        let body = b"";
597        let secret = "secret";
598        let hmac = compute_signature_base64(body, secret);
599
600        assert!(verify_hmac(body, &hmac, secret));
601    }
602
603    #[test]
604    fn test_verify_webhook_succeeds_with_primary_key() {
605        let config = ShopifyConfig::builder()
606            .api_key(ApiKey::new("key").unwrap())
607            .api_secret_key(ApiSecretKey::new("primary-secret").unwrap())
608            .build()
609            .unwrap();
610
611        let body = b"webhook body";
612        let hmac = compute_signature_base64(body, "primary-secret");
613        let request = WebhookRequest::new(
614            body.to_vec(),
615            hmac,
616            Some("orders/create".to_string()),
617            Some("shop.myshopify.com".to_string()),
618            Some("2025-10".to_string()),
619            Some("webhook-id".to_string()),
620        );
621
622        let result = verify_webhook(&config, &request);
623        assert!(result.is_ok());
624
625        let context = result.unwrap();
626        assert_eq!(context.topic(), Some(WebhookTopic::OrdersCreate));
627        assert_eq!(context.shop_domain(), Some("shop.myshopify.com"));
628    }
629
630    #[test]
631    fn test_verify_webhook_falls_back_to_old_key_successfully() {
632        let config = ShopifyConfig::builder()
633            .api_key(ApiKey::new("key").unwrap())
634            .api_secret_key(ApiSecretKey::new("new-secret").unwrap())
635            .old_api_secret_key(ApiSecretKey::new("old-secret").unwrap())
636            .build()
637            .unwrap();
638
639        // Sign with OLD secret
640        let body = b"webhook body";
641        let hmac = compute_signature_base64(body, "old-secret");
642        let request = WebhookRequest::new(body.to_vec(), hmac, None, None, None, None);
643
644        let result = verify_webhook(&config, &request);
645        assert!(result.is_ok());
646    }
647
648    #[test]
649    fn test_verify_webhook_fails_when_both_keys_fail() {
650        let config = ShopifyConfig::builder()
651            .api_key(ApiKey::new("key").unwrap())
652            .api_secret_key(ApiSecretKey::new("secret-1").unwrap())
653            .old_api_secret_key(ApiSecretKey::new("secret-2").unwrap())
654            .build()
655            .unwrap();
656
657        // Sign with a DIFFERENT secret
658        let body = b"webhook body";
659        let hmac = compute_signature_base64(body, "wrong-secret");
660        let request = WebhookRequest::new(body.to_vec(), hmac, None, None, None, None);
661
662        let result = verify_webhook(&config, &request);
663        assert!(result.is_err());
664        assert!(matches!(result.unwrap_err(), WebhookError::InvalidHmac));
665    }
666
667    #[test]
668    fn test_verify_webhook_returns_correct_context() {
669        let config = ShopifyConfig::builder()
670            .api_key(ApiKey::new("key").unwrap())
671            .api_secret_key(ApiSecretKey::new("secret").unwrap())
672            .build()
673            .unwrap();
674
675        let body = b"payload";
676        let hmac = compute_signature_base64(body, "secret");
677        let request = WebhookRequest::new(
678            body.to_vec(),
679            hmac,
680            Some("products/update".to_string()),
681            Some("test.myshopify.com".to_string()),
682            Some("2025-10".to_string()),
683            Some("wh-id-123".to_string()),
684        );
685
686        let context = verify_webhook(&config, &request).unwrap();
687        assert_eq!(context.topic(), Some(WebhookTopic::ProductsUpdate));
688        assert_eq!(context.topic_raw(), "products/update");
689        assert_eq!(context.shop_domain(), Some("test.myshopify.com"));
690        assert_eq!(context.api_version(), Some("2025-10"));
691        assert_eq!(context.webhook_id(), Some("wh-id-123"));
692    }
693
694    #[test]
695    fn test_verify_webhook_parses_known_topic_into_enum() {
696        let config = ShopifyConfig::builder()
697            .api_key(ApiKey::new("key").unwrap())
698            .api_secret_key(ApiSecretKey::new("secret").unwrap())
699            .build()
700            .unwrap();
701
702        let body = b"data";
703        let hmac = compute_signature_base64(body, "secret");
704        let request = WebhookRequest::new(
705            body.to_vec(),
706            hmac,
707            Some("customers/create".to_string()),
708            None,
709            None,
710            None,
711        );
712
713        let context = verify_webhook(&config, &request).unwrap();
714        assert_eq!(context.topic(), Some(WebhookTopic::CustomersCreate));
715    }
716
717    #[test]
718    fn test_verify_webhook_handles_unknown_topic() {
719        let config = ShopifyConfig::builder()
720            .api_key(ApiKey::new("key").unwrap())
721            .api_secret_key(ApiSecretKey::new("secret").unwrap())
722            .build()
723            .unwrap();
724
725        let body = b"data";
726        let hmac = compute_signature_base64(body, "secret");
727        let request = WebhookRequest::new(
728            body.to_vec(),
729            hmac,
730            Some("custom/new_event".to_string()),
731            None,
732            None,
733            None,
734        );
735
736        let context = verify_webhook(&config, &request).unwrap();
737        assert_eq!(context.topic(), None);
738        assert_eq!(context.topic_raw(), "custom/new_event");
739    }
740
741    // ========================================================================
742    // Topic Parsing Tests
743    // ========================================================================
744
745    #[test]
746    fn test_parse_topic_known_topics() {
747        assert_eq!(parse_topic("orders/create"), Some(WebhookTopic::OrdersCreate));
748        assert_eq!(
749            parse_topic("products/update"),
750            Some(WebhookTopic::ProductsUpdate)
751        );
752        assert_eq!(
753            parse_topic("customers/delete"),
754            Some(WebhookTopic::CustomersDelete)
755        );
756        assert_eq!(
757            parse_topic("app/uninstalled"),
758            Some(WebhookTopic::AppUninstalled)
759        );
760    }
761
762    #[test]
763    fn test_parse_topic_unknown_topics() {
764        assert_eq!(parse_topic("unknown/topic"), None);
765        assert_eq!(parse_topic("custom_event"), None);
766        assert_eq!(parse_topic(""), None);
767    }
768}