Skip to main content

ironflow_runtime/
webhook.rs

1//! Webhook authentication strategies for incoming HTTP requests.
2//!
3//! This module provides [`WebhookAuth`], an enum that supports multiple
4//! authentication schemes commonly used by webhook providers such as GitHub
5//! and GitLab. It can also be used with custom header-based or HMAC-based
6//! authentication flows.
7
8use hmac::{Hmac, Mac};
9use sha2::Sha256;
10use subtle::ConstantTimeEq;
11use tracing::warn;
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// Authentication strategy for a webhook endpoint.
16///
17/// Each variant describes how to verify that an incoming request is legitimate.
18/// Use the constructor methods ([`WebhookAuth::none`], [`WebhookAuth::header`],
19/// [`WebhookAuth::github`], [`WebhookAuth::gitlab`]) rather than building
20/// variants manually, as they normalise header names and apply the correct
21/// defaults.
22///
23/// # Examples
24///
25/// ```no_run
26/// use ironflow_runtime::webhook::WebhookAuth;
27///
28/// // No authentication
29/// let auth = WebhookAuth::none();
30///
31/// // GitHub HMAC-SHA256 authentication
32/// let auth = WebhookAuth::github("my-webhook-secret");
33///
34/// // GitLab static-token authentication
35/// let auth = WebhookAuth::gitlab("my-gitlab-token");
36///
37/// // Custom header authentication
38/// let auth = WebhookAuth::header("x-api-key", "secret-value");
39/// ```
40#[derive(Clone)]
41pub enum WebhookAuth {
42    /// No authentication - every request is accepted.
43    None,
44    /// Static header comparison.
45    ///
46    /// The request must contain a header whose value exactly matches
47    /// `expected`. The header `name` is stored in lower-case.
48    Header {
49        /// Lower-cased header name.
50        name: String,
51        /// Expected header value.
52        expected: String,
53    },
54    /// HMAC-SHA256 signature verification.
55    ///
56    /// The request must contain a header whose value is a hex-encoded
57    /// HMAC-SHA256 digest prefixed with `sha256=`. The digest is computed
58    /// over the raw request body using `secret` as the HMAC key.
59    HmacSha256 {
60        /// Header name that carries the signature (e.g. `x-hub-signature-256`).
61        header: String,
62        /// Shared secret used to compute the HMAC.
63        secret: String,
64    },
65}
66
67impl std::fmt::Debug for WebhookAuth {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        match self {
70            Self::None => write!(f, "WebhookAuth::None"),
71            Self::Header { name, .. } => f
72                .debug_struct("WebhookAuth::Header")
73                .field("name", name)
74                .field("expected", &"[REDACTED]")
75                .finish(),
76            Self::HmacSha256 { header, .. } => f
77                .debug_struct("WebhookAuth::HmacSha256")
78                .field("header", header)
79                .field("secret", &"[REDACTED]")
80                .finish(),
81        }
82    }
83}
84
85impl WebhookAuth {
86    /// Creates an authentication strategy that accepts every request.
87    ///
88    /// # Examples
89    ///
90    /// ```no_run
91    /// use ironflow_runtime::webhook::WebhookAuth;
92    ///
93    /// let auth = WebhookAuth::none();
94    /// ```
95    pub fn none() -> Self {
96        Self::None
97    }
98
99    /// Creates a static-header authentication strategy.
100    ///
101    /// The `name` is automatically lower-cased so that look-ups are
102    /// case-insensitive (HTTP headers are case-insensitive by spec).
103    ///
104    /// # Examples
105    ///
106    /// ```no_run
107    /// use ironflow_runtime::webhook::WebhookAuth;
108    ///
109    /// let auth = WebhookAuth::header("x-api-key", "super-secret");
110    /// ```
111    pub fn header(name: &str, expected: &str) -> Self {
112        if expected.is_empty() {
113            warn!(
114                "WebhookAuth::header created with an empty expected value - any request with an empty header will be accepted"
115            );
116        }
117        Self::Header {
118            name: name.to_lowercase(),
119            expected: expected.to_string(),
120        }
121    }
122
123    /// Preset for **GitLab** webhooks.
124    ///
125    /// GitLab sends the secret token in the `X-Gitlab-Token` header as a
126    /// plain-text value. This is a convenience wrapper around
127    /// [`WebhookAuth::header`].
128    ///
129    /// # Examples
130    ///
131    /// ```no_run
132    /// use ironflow_runtime::webhook::WebhookAuth;
133    ///
134    /// let auth = WebhookAuth::gitlab("my-gitlab-secret");
135    /// ```
136    pub fn gitlab(secret: &str) -> Self {
137        if secret.is_empty() {
138            warn!(
139                "WebhookAuth::gitlab created with an empty token - any request with an empty X-Gitlab-Token header will be accepted"
140            );
141        }
142        Self::Header {
143            name: "x-gitlab-token".to_string(),
144            expected: secret.to_string(),
145        }
146    }
147
148    /// Preset for **GitHub** webhooks.
149    ///
150    /// GitHub signs the payload body with HMAC-SHA256 and sends the result
151    /// in the `X-Hub-Signature-256` header, prefixed with `sha256=`.
152    /// This constructor configures the correct header name and stores the
153    /// shared secret.
154    ///
155    /// # Examples
156    ///
157    /// ```no_run
158    /// use ironflow_runtime::webhook::WebhookAuth;
159    ///
160    /// let auth = WebhookAuth::github("my-github-webhook-secret");
161    /// ```
162    pub fn github(secret: &str) -> Self {
163        if secret.is_empty() {
164            warn!(
165                "WebhookAuth::github created with an empty secret - HMAC verification will be trivially bypassable"
166            );
167        }
168        Self::HmacSha256 {
169            header: "x-hub-signature-256".to_string(),
170            secret: secret.to_string(),
171        }
172    }
173
174    /// Verifies an incoming request against this authentication strategy.
175    ///
176    /// Returns `true` if the request is authentic, `false` otherwise.
177    ///
178    /// # Behaviour per variant
179    ///
180    /// | Variant | Verification |
181    /// |---|---|
182    /// | [`None`](WebhookAuth::None) | Always returns `true`. |
183    /// | [`Header`](WebhookAuth::Header) | Checks that the named header equals the expected value. |
184    /// | [`HmacSha256`](WebhookAuth::HmacSha256) | Strips the `sha256=` prefix, hex-decodes the signature, computes HMAC-SHA256 over `body`, and compares in constant time. |
185    ///
186    /// # Examples
187    ///
188    /// ```no_run
189    /// use axum::http::HeaderMap;
190    /// use ironflow_runtime::webhook::WebhookAuth;
191    ///
192    /// let auth = WebhookAuth::none();
193    /// let headers = HeaderMap::new();
194    /// assert!(auth.verify(&headers, b"any body"));
195    /// ```
196    pub fn verify(&self, headers: &axum::http::HeaderMap, body: &[u8]) -> bool {
197        match self {
198            Self::None => true,
199            Self::Header { name, expected } => headers
200                .get(name)
201                .and_then(|v| v.to_str().ok())
202                .is_some_and(|v| v.as_bytes().ct_eq(expected.as_bytes()).into()),
203            Self::HmacSha256 { header, secret } => {
204                let Some(signature) = headers.get(header).and_then(|v| v.to_str().ok()) else {
205                    return false;
206                };
207                let Some(signature) = signature.strip_prefix("sha256=") else {
208                    return false;
209                };
210                let Ok(sig_bytes) = hex::decode(signature) else {
211                    return false;
212                };
213                let Ok(mut mac) = HmacSha256::new_from_slice(secret.as_bytes()) else {
214                    return false;
215                };
216                mac.update(body);
217                mac.verify_slice(&sig_bytes).is_ok()
218            }
219        }
220    }
221}
222
223/// Compute an HMAC-SHA256 signature string (prefixed with `sha256=`).
224///
225/// Providers that stamp each delivery with a unique identifier, and the header
226/// carrying it. The prefix disambiguates identifiers across providers, since
227/// idempotency keys are global.
228const DELIVERY_ID_HEADERS: &[(&str, &str)] = &[
229    ("x-github-delivery", "github"),
230    ("x-gitlab-event-uuid", "gitlab"),
231];
232
233/// Maximum length of a derived idempotency key, in bytes.
234///
235/// Mirrors the `Idempotency-Key` limit enforced by `POST /api/v1/runs`. Kept
236/// local so the runtime does not depend on the storage crate.
237///
238/// # Examples
239///
240/// ```
241/// use ironflow_runtime::webhook::MAX_IDEMPOTENCY_KEY_LEN;
242///
243/// assert_eq!(MAX_IDEMPOTENCY_KEY_LEN, 255);
244/// ```
245pub const MAX_IDEMPOTENCY_KEY_LEN: usize = 255;
246
247/// Extract a provider delivery identifier from request headers.
248///
249/// Returns a prefixed key such as `github:8f4e2a10-...` suitable for use as an
250/// `Idempotency-Key`. Returns `None` when no known provider header is present,
251/// when its value is not printable ASCII, or when it exceeds
252/// [`MAX_IDEMPOTENCY_KEY_LEN`] once prefixed.
253///
254/// # Examples
255///
256/// ```
257/// use axum::http::HeaderMap;
258/// use ironflow_runtime::webhook::extract_delivery_id;
259///
260/// let mut headers = HeaderMap::new();
261/// headers.insert("x-github-delivery", "abc-123".parse().unwrap());
262/// assert_eq!(extract_delivery_id(&headers), Some("github:abc-123".to_string()));
263///
264/// assert_eq!(extract_delivery_id(&HeaderMap::new()), None);
265/// ```
266pub fn extract_delivery_id(headers: &axum::http::HeaderMap) -> Option<String> {
267    for (header, provider) in DELIVERY_ID_HEADERS {
268        let Some(raw) = headers.get(*header).and_then(|v| v.to_str().ok()) else {
269            continue;
270        };
271        if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_graphic()) {
272            warn!(header = %header, "delivery id is not printable ASCII, ignoring");
273            continue;
274        }
275
276        let key = format!("{provider}:{raw}");
277        if key.len() > MAX_IDEMPOTENCY_KEY_LEN {
278            warn!(header = %header, "delivery id is too long for an idempotency key, ignoring");
279            continue;
280        }
281        return Some(key);
282    }
283    None
284}
285
286/// Shared helper for tests and integration test suites.
287#[cfg(test)]
288pub(crate) fn compute_test_hmac(secret: &[u8], body: &[u8]) -> String {
289    let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC key rejected");
290    mac.update(body);
291    format!("sha256={}", hex::encode(mac.finalize().into_bytes()))
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use axum::http::HeaderMap;
298
299    // ── WebhookAuth::None ──────────────────────────────────────────
300
301    #[test]
302    fn none_always_returns_true() {
303        let auth = WebhookAuth::none();
304        let headers = HeaderMap::new();
305        assert!(auth.verify(&headers, b"anything"));
306    }
307
308    #[test]
309    fn none_empty_body_returns_true() {
310        let auth = WebhookAuth::none();
311        let headers = HeaderMap::new();
312        assert!(auth.verify(&headers, b""));
313    }
314
315    #[test]
316    fn none_empty_headers_returns_true() {
317        let auth = WebhookAuth::none();
318        let headers = HeaderMap::new();
319        assert!(auth.verify(&headers, b"payload"));
320    }
321
322    // ── WebhookAuth::Header ────────────────────────────────────────
323
324    #[test]
325    fn header_correct_value_returns_true() {
326        let auth = WebhookAuth::header("x-api-key", "secret123");
327        let mut headers = HeaderMap::new();
328        headers.insert("x-api-key", "secret123".parse().unwrap());
329        assert!(auth.verify(&headers, b""));
330    }
331
332    #[test]
333    fn header_wrong_value_returns_false() {
334        let auth = WebhookAuth::header("x-api-key", "secret123");
335        let mut headers = HeaderMap::new();
336        headers.insert("x-api-key", "wrong".parse().unwrap());
337        assert!(!auth.verify(&headers, b""));
338    }
339
340    #[test]
341    fn header_missing_returns_false() {
342        let auth = WebhookAuth::header("x-api-key", "secret123");
343        let headers = HeaderMap::new();
344        assert!(!auth.verify(&headers, b""));
345    }
346
347    #[test]
348    fn header_name_lookup_is_case_insensitive() {
349        let auth = WebhookAuth::header("X-Api-Key", "secret123");
350        let mut headers = HeaderMap::new();
351        headers.insert("x-api-key", "secret123".parse().unwrap());
352        assert!(auth.verify(&headers, b""));
353    }
354
355    #[test]
356    fn header_value_comparison_is_case_sensitive() {
357        let auth = WebhookAuth::header("x-api-key", "Secret123");
358        let mut headers = HeaderMap::new();
359        headers.insert("x-api-key", "secret123".parse().unwrap());
360        assert!(!auth.verify(&headers, b""));
361    }
362
363    #[test]
364    fn header_empty_expected_with_empty_value_returns_true() {
365        let auth = WebhookAuth::header("x-api-key", "");
366        let mut headers = HeaderMap::new();
367        headers.insert("x-api-key", "".parse().unwrap());
368        assert!(auth.verify(&headers, b""));
369    }
370
371    // ── WebhookAuth::gitlab ────────────────────────────────────────
372
373    #[test]
374    fn gitlab_correct_token_returns_true() {
375        let auth = WebhookAuth::gitlab("gl-token-abc");
376        let mut headers = HeaderMap::new();
377        headers.insert("x-gitlab-token", "gl-token-abc".parse().unwrap());
378        assert!(auth.verify(&headers, b""));
379    }
380
381    #[test]
382    fn gitlab_wrong_token_returns_false() {
383        let auth = WebhookAuth::gitlab("gl-token-abc");
384        let mut headers = HeaderMap::new();
385        headers.insert("x-gitlab-token", "wrong".parse().unwrap());
386        assert!(!auth.verify(&headers, b""));
387    }
388
389    #[test]
390    fn gitlab_missing_header_returns_false() {
391        let auth = WebhookAuth::gitlab("gl-token-abc");
392        let headers = HeaderMap::new();
393        assert!(!auth.verify(&headers, b""));
394    }
395
396    // ── WebhookAuth::github ────────────────────────────────────────
397
398    #[test]
399    fn github_valid_hmac_returns_true() {
400        let secret = "gh-secret";
401        let body = b"payload body";
402        let auth = WebhookAuth::github(secret);
403        let sig = compute_test_hmac(secret.as_bytes(), body);
404        let mut headers = HeaderMap::new();
405        headers.insert("x-hub-signature-256", sig.parse().unwrap());
406        assert!(auth.verify(&headers, body));
407    }
408
409    #[test]
410    fn github_invalid_signature_returns_false() {
411        let auth = WebhookAuth::github("gh-secret");
412        let mut headers = HeaderMap::new();
413        headers.insert(
414            "x-hub-signature-256",
415            "sha256=0000000000000000000000000000000000000000000000000000000000000000"
416                .parse()
417                .unwrap(),
418        );
419        assert!(!auth.verify(&headers, b"payload"));
420    }
421
422    #[test]
423    fn github_missing_header_returns_false() {
424        let auth = WebhookAuth::github("gh-secret");
425        let headers = HeaderMap::new();
426        assert!(!auth.verify(&headers, b"payload"));
427    }
428
429    // ── WebhookAuth::HmacSha256 (direct) ──────────────────────────
430
431    #[test]
432    fn hmac_valid_signature_verifies() {
433        let secret = "my-secret";
434        let body = b"request body";
435        let auth = WebhookAuth::HmacSha256 {
436            header: "x-signature".to_string(),
437            secret: secret.to_string(),
438        };
439        let sig = compute_test_hmac(secret.as_bytes(), body);
440        let mut headers = HeaderMap::new();
441        headers.insert("x-signature", sig.parse().unwrap());
442        assert!(auth.verify(&headers, body));
443    }
444
445    #[test]
446    fn hmac_tampered_signature_returns_false() {
447        let secret = "my-secret";
448        let body = b"request body";
449        let auth = WebhookAuth::HmacSha256 {
450            header: "x-signature".to_string(),
451            secret: secret.to_string(),
452        };
453        let mut sig = compute_test_hmac(secret.as_bytes(), body);
454        // Tamper with the last character
455        sig.pop();
456        sig.push('0');
457        let mut headers = HeaderMap::new();
458        headers.insert("x-signature", sig.parse().unwrap());
459        assert!(!auth.verify(&headers, body));
460    }
461
462    #[test]
463    fn hmac_missing_header_returns_false() {
464        let auth = WebhookAuth::HmacSha256 {
465            header: "x-signature".to_string(),
466            secret: "my-secret".to_string(),
467        };
468        let headers = HeaderMap::new();
469        assert!(!auth.verify(&headers, b"body"));
470    }
471
472    #[test]
473    fn hmac_no_sha256_prefix_returns_false() {
474        let auth = WebhookAuth::HmacSha256 {
475            header: "x-signature".to_string(),
476            secret: "my-secret".to_string(),
477        };
478        let mut headers = HeaderMap::new();
479        headers.insert(
480            "x-signature",
481            "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"
482                .parse()
483                .unwrap(),
484        );
485        assert!(!auth.verify(&headers, b"body"));
486    }
487
488    #[test]
489    fn hmac_invalid_hex_returns_false() {
490        let auth = WebhookAuth::HmacSha256 {
491            header: "x-signature".to_string(),
492            secret: "my-secret".to_string(),
493        };
494        let mut headers = HeaderMap::new();
495        headers.insert("x-signature", "sha256=not-valid-hex!".parse().unwrap());
496        assert!(!auth.verify(&headers, b"body"));
497    }
498
499    #[test]
500    fn hmac_wrong_secret_returns_false() {
501        let body = b"request body";
502        let sig = compute_test_hmac(b"correct-secret", body);
503        let auth = WebhookAuth::HmacSha256 {
504            header: "x-signature".to_string(),
505            secret: "wrong-secret".to_string(),
506        };
507        let mut headers = HeaderMap::new();
508        headers.insert("x-signature", sig.parse().unwrap());
509        assert!(!auth.verify(&headers, body));
510    }
511
512    #[test]
513    fn hmac_empty_body_verifies() {
514        let secret = "my-secret";
515        let body = b"";
516        let auth = WebhookAuth::HmacSha256 {
517            header: "x-signature".to_string(),
518            secret: secret.to_string(),
519        };
520        let sig = compute_test_hmac(secret.as_bytes(), body);
521        let mut headers = HeaderMap::new();
522        headers.insert("x-signature", sig.parse().unwrap());
523        assert!(auth.verify(&headers, body));
524    }
525
526    #[test]
527    fn hmac_body_tampered_returns_false() {
528        let secret = "my-secret";
529        let auth = WebhookAuth::HmacSha256 {
530            header: "x-signature".to_string(),
531            secret: secret.to_string(),
532        };
533        let sig = compute_test_hmac(secret.as_bytes(), b"original body");
534        let mut headers = HeaderMap::new();
535        headers.insert("x-signature", sig.parse().unwrap());
536        assert!(!auth.verify(&headers, b"tampered body"));
537    }
538
539    #[test]
540    fn hmac_empty_secret_still_works() {
541        let secret = "";
542        let body = b"some body";
543        let auth = WebhookAuth::HmacSha256 {
544            header: "x-signature".to_string(),
545            secret: secret.to_string(),
546        };
547        let sig = compute_test_hmac(secret.as_bytes(), body);
548        let mut headers = HeaderMap::new();
549        headers.insert("x-signature", sig.parse().unwrap());
550        assert!(auth.verify(&headers, body));
551    }
552
553    #[test]
554    fn debug_redacts_header_secret() {
555        let auth = WebhookAuth::header("x-api-key", "super-secret");
556        let debug = format!("{:?}", auth);
557        assert!(debug.contains("[REDACTED]"));
558        assert!(!debug.contains("super-secret"));
559    }
560
561    #[test]
562    fn debug_redacts_hmac_secret() {
563        let auth = WebhookAuth::github("my-secret-key");
564        let debug = format!("{:?}", auth);
565        assert!(debug.contains("[REDACTED]"));
566        assert!(!debug.contains("my-secret-key"));
567    }
568
569    #[test]
570    fn debug_none_format() {
571        let auth = WebhookAuth::none();
572        let debug = format!("{:?}", auth);
573        assert_eq!(debug, "WebhookAuth::None");
574    }
575
576    #[test]
577    fn hmac_rfc4231_test_vector() {
578        // RFC 4231 Test Case 2 (adapted: Key=0x0b*20, Data="Hi There")
579        let key_bytes = hex::decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b").unwrap();
580        let body = b"Hi There";
581        let expected_mac = "b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7";
582
583        // Compute with raw key bytes to verify the test vector
584        let mut mac = HmacSha256::new_from_slice(&key_bytes).unwrap();
585        mac.update(body);
586        let computed = hex::encode(mac.finalize().into_bytes());
587        assert_eq!(computed, expected_mac);
588
589        // Now verify through WebhookAuth - but note that WebhookAuth uses
590        // secret.as_bytes() (UTF-8), not raw hex bytes. So we construct an
591        // HmacSha256 variant with the raw key by converting key_bytes to a
592        // String via unsafe (the bytes are not valid UTF-8, so we use
593        // Latin-1 mapping instead).
594        // Since the verify method uses `secret.as_bytes()`, and we need the
595        // key to be exactly `key_bytes`, we can only test this if those bytes
596        // round-trip through String::as_bytes(). They do if we build a String
597        // from those bytes using Latin-1. However Rust strings are UTF-8 and
598        // 0x0b is valid UTF-8 (it's a control char), so this works.
599        let secret_str = String::from_utf8(key_bytes).unwrap();
600        let auth = WebhookAuth::HmacSha256 {
601            header: "x-signature".to_string(),
602            secret: secret_str,
603        };
604        let sig = format!("sha256={}", expected_mac);
605        let mut headers = HeaderMap::new();
606        headers.insert("x-signature", sig.parse().unwrap());
607        assert!(auth.verify(&headers, body));
608    }
609
610    // ── extract_delivery_id ────────────────────────────────────────
611
612    #[test]
613    fn github_delivery_id_is_prefixed() {
614        let mut headers = HeaderMap::new();
615        headers.insert("x-github-delivery", "abc-123".parse().unwrap());
616        assert_eq!(
617            extract_delivery_id(&headers),
618            Some("github:abc-123".to_string())
619        );
620    }
621
622    #[test]
623    fn gitlab_event_uuid_is_prefixed() {
624        let mut headers = HeaderMap::new();
625        headers.insert("x-gitlab-event-uuid", "def-456".parse().unwrap());
626        assert_eq!(
627            extract_delivery_id(&headers),
628            Some("gitlab:def-456".to_string())
629        );
630    }
631
632    #[test]
633    fn no_provider_header_yields_none() {
634        let headers = HeaderMap::new();
635        assert_eq!(extract_delivery_id(&headers), None);
636    }
637
638    #[test]
639    fn unrelated_headers_yield_none() {
640        let mut headers = HeaderMap::new();
641        headers.insert("x-request-id", "abc".parse().unwrap());
642        assert_eq!(extract_delivery_id(&headers), None);
643    }
644
645    #[test]
646    fn github_wins_over_gitlab_when_both_are_present() {
647        let mut headers = HeaderMap::new();
648        headers.insert("x-github-delivery", "gh".parse().unwrap());
649        headers.insert("x-gitlab-event-uuid", "gl".parse().unwrap());
650        assert_eq!(extract_delivery_id(&headers), Some("github:gh".to_string()));
651    }
652
653    #[test]
654    fn empty_delivery_id_is_ignored() {
655        let mut headers = HeaderMap::new();
656        headers.insert("x-github-delivery", "".parse().unwrap());
657        assert_eq!(extract_delivery_id(&headers), None);
658    }
659
660    #[test]
661    fn delivery_id_with_a_space_is_ignored() {
662        let mut headers = HeaderMap::new();
663        headers.insert("x-github-delivery", "abc 123".parse().unwrap());
664        assert_eq!(extract_delivery_id(&headers), None);
665    }
666
667    #[test]
668    fn overlong_delivery_id_is_ignored() {
669        let mut headers = HeaderMap::new();
670        let raw = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN);
671        headers.insert("x-github-delivery", raw.parse().unwrap());
672        assert_eq!(extract_delivery_id(&headers), None);
673    }
674
675    #[test]
676    fn delivery_id_at_the_length_limit_is_accepted() {
677        let mut headers = HeaderMap::new();
678        // "github:" is 7 bytes.
679        let raw = "a".repeat(MAX_IDEMPOTENCY_KEY_LEN - 7);
680        headers.insert("x-github-delivery", raw.parse().unwrap());
681        let key = extract_delivery_id(&headers).expect("key accepted");
682        assert_eq!(key.len(), MAX_IDEMPOTENCY_KEY_LEN);
683    }
684
685    #[test]
686    fn gitlab_is_used_when_github_header_is_invalid() {
687        let mut headers = HeaderMap::new();
688        headers.insert("x-github-delivery", "".parse().unwrap());
689        headers.insert("x-gitlab-event-uuid", "def".parse().unwrap());
690        assert_eq!(
691            extract_delivery_id(&headers),
692            Some("gitlab:def".to_string())
693        );
694    }
695}