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("ed).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}