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("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("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}