Skip to main content

uselesskey_webhook/
lib.rs

1#![forbid(unsafe_code)]
2
3//! Webhook fixtures built on `uselesskey-core`.
4//!
5//! This crate provides deterministic provider-style webhook fixtures with canonical
6//! payloads, signature input strings, and signed headers.
7
8mod fixture;
9mod model;
10mod payload;
11mod secret;
12mod signature;
13
14pub use fixture::WebhookFactoryExt;
15pub use model::{
16    NearMissScenario, NearMissWebhookFixture, WebhookFixture, WebhookPayloadSpec, WebhookProfile,
17};
18
19/// Cache domain for webhook fixtures.
20pub const DOMAIN_WEBHOOK_FIXTURE: &str = "uselesskey:webhook:fixture";
21
22#[cfg(test)]
23mod tests {
24    use super::*;
25    use std::collections::BTreeMap;
26
27    use crate::fixture::build_fixture_from_seed;
28    use crate::payload::{canonical_payload, stable_spec_bytes};
29    use crate::secret::build_secret;
30    use crate::signature::hmac_sha256_hex;
31    use rand_chacha10::ChaCha20Rng;
32    use rand_core10::{Rng, SeedableRng};
33    use uselesskey_core::{Factory, Seed};
34
35    #[test]
36    fn hmac_sha256_matches_rfc4231_test_vector() {
37        let key = [0x0b_u8; 20];
38        let digest = hmac_sha256_hex(&key, b"Hi There");
39
40        assert_eq!(
41            digest,
42            "b0344c61d8db38535ca8afceaf0bf12b\
43             881dc200c9833da726e9376c2e32cff7"
44                .replace(char::is_whitespace, "")
45        );
46    }
47
48    #[test]
49    fn hmac_sha256_preserves_block_sized_key_without_hashing() {
50        let key = [0xaa_u8; 64];
51        let digest = hmac_sha256_hex(&key, b"block-size boundary");
52
53        assert_eq!(
54            digest,
55            "4bf714ba9df6b88605adb3e0a8a8b6d0320041fc2577408eaeb6e7120a03cf43"
56        );
57    }
58
59    fn verify_github(secret: &str, payload: &str, headers: &BTreeMap<String, String>) -> bool {
60        let expected = format!(
61            "sha256={}",
62            hmac_sha256_hex(secret.as_bytes(), payload.as_bytes())
63        );
64        headers.get("X-Hub-Signature-256") == Some(&expected)
65    }
66
67    fn verify_stripe(
68        secret: &str,
69        payload: &str,
70        headers: &BTreeMap<String, String>,
71        now: i64,
72        tolerance_secs: i64,
73    ) -> bool {
74        let Some(sig_header) = headers.get("Stripe-Signature") else {
75            return false;
76        };
77        let mut ts = None;
78        let mut v1 = None;
79        for part in sig_header.split(',') {
80            if let Some(v) = part.strip_prefix("t=") {
81                ts = v.parse::<i64>().ok();
82            }
83            if let Some(v) = part.strip_prefix("v1=") {
84                v1 = Some(v.to_string());
85            }
86        }
87        let Some(ts) = ts else {
88            return false;
89        };
90        if (now - ts).abs() > tolerance_secs {
91            return false;
92        }
93        let base = format!("{ts}.{payload}");
94        let expected = hmac_sha256_hex(secret.as_bytes(), base.as_bytes());
95        v1.as_deref() == Some(expected.as_str())
96    }
97
98    fn verify_slack(
99        secret: &str,
100        payload: &str,
101        headers: &BTreeMap<String, String>,
102        now: i64,
103        tolerance_secs: i64,
104    ) -> bool {
105        let Some(ts_str) = headers.get("X-Slack-Request-Timestamp") else {
106            return false;
107        };
108        let Ok(ts) = ts_str.parse::<i64>() else {
109            return false;
110        };
111        if (now - ts).abs() > tolerance_secs {
112            return false;
113        }
114        let Some(sig) = headers.get("X-Slack-Signature") else {
115            return false;
116        };
117        let base = format!("v0:{ts}:{payload}");
118        let expected = format!("v0={}", hmac_sha256_hex(secret.as_bytes(), base.as_bytes()));
119        sig == &expected
120    }
121
122    #[test]
123    fn deterministic_github_fixture_is_stable() {
124        let fx = Factory::deterministic(Seed::from_env_value("webhook-gh").unwrap());
125        let a = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
126        let b = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
127        assert_eq!(a.secret, b.secret);
128        assert_eq!(a.payload, b.payload);
129        assert_eq!(a.headers, b.headers);
130        assert!(verify_github(&a.secret, &a.payload, &a.headers));
131    }
132
133    #[test]
134    fn provider_signature_paths_verify() {
135        let fx = Factory::deterministic(Seed::from_env_value("webhook-providers").unwrap());
136        let gh = fx.webhook(WebhookProfile::GitHub, "a", WebhookPayloadSpec::Canonical);
137        let st = fx.webhook_stripe("b", WebhookPayloadSpec::Canonical);
138        let sl = fx.webhook_slack("c", WebhookPayloadSpec::Canonical);
139
140        assert!(verify_github(&gh.secret, &gh.payload, &gh.headers));
141        assert!(verify_stripe(
142            &st.secret,
143            &st.payload,
144            &st.headers,
145            st.timestamp,
146            300
147        ));
148        assert!(verify_slack(
149            &sl.secret,
150            &sl.payload,
151            &sl.headers,
152            sl.timestamp,
153            300
154        ));
155    }
156
157    #[test]
158    fn payload_spec_stable_bytes_are_shape_sensitive() {
159        assert_eq!(WebhookPayloadSpec::Canonical.stable_bytes(), b"canonical");
160        assert_eq!(
161            WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
162            b"raw:one"
163        );
164        assert_ne!(
165            WebhookPayloadSpec::Raw("one".to_string()).stable_bytes(),
166            WebhookPayloadSpec::Raw("two".to_string()).stable_bytes()
167        );
168        assert_ne!(
169            stable_spec_bytes(WebhookProfile::GitHub, &WebhookPayloadSpec::Canonical),
170            stable_spec_bytes(WebhookProfile::Stripe, &WebhookPayloadSpec::Canonical)
171        );
172    }
173
174    #[test]
175    fn generated_timestamp_uses_expected_seeded_window() {
176        let seed = [7_u8; 32];
177        let mut rng = ChaCha20Rng::from_seed(seed);
178        let mut secret_bytes = [0_u8; 32];
179        rng.fill_bytes(&mut secret_bytes);
180        let expected = 1_700_000_000_i64 + (rng.next_u32() as i64 % 200_000_000_i64);
181
182        let fixture = build_fixture_from_seed(
183            WebhookProfile::Stripe,
184            "billing",
185            WebhookPayloadSpec::Canonical,
186            &seed,
187        );
188
189        assert_eq!(fixture.timestamp, expected);
190        assert!((1_700_000_000..1_900_000_000).contains(&fixture.timestamp));
191    }
192
193    #[test]
194    fn generated_secrets_match_provider_shapes() {
195        let mut rng = ChaCha20Rng::from_seed([9_u8; 32]);
196        let github = build_secret(WebhookProfile::GitHub, &mut rng);
197        let stripe = build_secret(WebhookProfile::Stripe, &mut rng);
198        let slack = build_secret(WebhookProfile::Slack, &mut rng);
199
200        assert_eq!(github.len(), "ghs_".len() + 43);
201        assert!(github.starts_with("ghs_"));
202        assert!(
203            github["ghs_".len()..]
204                .bytes()
205                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
206        );
207
208        assert_eq!(stripe.len(), "whsec_".len() + 64);
209        assert!(stripe.starts_with("whsec_"));
210        assert_lower_hex(&stripe["whsec_".len()..]);
211
212        assert_eq!(slack.len(), 64);
213        assert_lower_hex(&slack);
214    }
215
216    #[test]
217    fn header_shape_matches_provider_conventions() {
218        let fx = Factory::deterministic(Seed::from_env_value("webhook-headers").unwrap());
219        let gh = fx.webhook_github("r", WebhookPayloadSpec::Canonical);
220        assert!(
221            gh.headers
222                .get("X-Hub-Signature-256")
223                .is_some_and(|v| v.starts_with("sha256="))
224        );
225
226        let st = fx.webhook_stripe("r", WebhookPayloadSpec::Canonical);
227        let stripe_header = st.headers.get("Stripe-Signature").expect("stripe header");
228        assert!(stripe_header.contains("t="));
229        assert!(stripe_header.contains(",v1="));
230
231        let sl = fx.webhook_slack("r", WebhookPayloadSpec::Canonical);
232        assert!(sl.headers.contains_key("X-Slack-Request-Timestamp"));
233        assert!(
234            sl.headers
235                .get("X-Slack-Signature")
236                .is_some_and(|v| v.starts_with("v0="))
237        );
238    }
239
240    #[test]
241    fn near_miss_negatives_fail_provider_verification() {
242        let fx = Factory::deterministic(Seed::from_env_value("webhook-nearmiss").unwrap());
243        let st = fx.webhook_stripe("billing", WebhookPayloadSpec::Canonical);
244        let now = st.timestamp;
245
246        let stale = st.near_miss_stale_timestamp(300);
247        assert_eq!(stale.timestamp, st.timestamp - 301);
248        assert_eq!(
249            stale.signature_input,
250            format!("{}.{}", stale.timestamp, stale.payload)
251        );
252        assert!(!verify_stripe(
253            &st.secret,
254            &st.payload,
255            &stale.headers,
256            now,
257            300
258        ));
259
260        let wrong_secret = st.near_miss_wrong_secret();
261        assert!(!verify_stripe(
262            &st.secret,
263            &wrong_secret.payload,
264            &wrong_secret.headers,
265            wrong_secret.timestamp,
266            300
267        ));
268
269        let tampered = st.near_miss_tampered_payload();
270        assert!(!verify_stripe(
271            &tampered.secret,
272            &st.payload,
273            &tampered.headers,
274            tampered.timestamp,
275            300
276        ));
277    }
278
279    #[test]
280    fn debug_redacts_secret() {
281        let fx = Factory::random();
282        let fixture = fx.webhook_slack("debug", WebhookPayloadSpec::Canonical);
283        let out = format!("{fixture:?}");
284        assert!(!out.contains(&fixture.secret));
285        assert!(out.contains("WebhookFixture"));
286
287        let near_miss = fixture.near_miss_wrong_secret();
288        let out = format!("{near_miss:?}");
289        assert!(!out.contains(&near_miss.secret));
290        assert!(out.contains("NearMissWebhookFixture"));
291    }
292
293    #[test]
294    fn canonical_payload_escapes_special_characters_in_label() {
295        let fx = Factory::deterministic(Seed::from_env_value("webhook-label-escape").unwrap());
296        let label = "repo\"line\nbreak\\slash";
297        let fixtures = [
298            fx.webhook_github(label, WebhookPayloadSpec::Canonical),
299            fx.webhook_stripe(label, WebhookPayloadSpec::Canonical),
300            fx.webhook_slack(label, WebhookPayloadSpec::Canonical),
301        ];
302
303        for fixture in fixtures {
304            let parsed: serde_json::Value =
305                serde_json::from_str(&fixture.payload).expect("canonical payload should be valid");
306            let serialized = parsed.to_string();
307            assert!(
308                serialized.contains("repo\\\"line\\nbreak\\\\slash"),
309                "serialized payload should preserve escaped label, got: {serialized}"
310            );
311        }
312    }
313
314    #[test]
315    fn canonical_payload_preserves_plain_label_field_order() {
316        assert_eq!(
317            canonical_payload(
318                WebhookProfile::GitHub,
319                "repo",
320                WebhookPayloadSpec::Canonical,
321                12
322            ),
323            "{\"action\":\"opened\",\"repository\":{\"full_name\":\"acme/repo\"},\"number\":1012}"
324        );
325        assert_eq!(
326            canonical_payload(
327                WebhookProfile::Stripe,
328                "billing",
329                WebhookPayloadSpec::Canonical,
330                0x0f
331            ),
332            "{\"id\":\"evt_0000000f\",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"metadata\":{\"label\":\"billing\"}}}}"
333        );
334        assert_eq!(
335            canonical_payload(
336                WebhookProfile::Slack,
337                "alerts",
338                WebhookPayloadSpec::Canonical,
339                0x10
340            ),
341            "{\"type\":\"event_callback\",\"team_id\":\"T00000010\",\"event\":{\"type\":\"app_mention\",\"text\":\"ping alerts\"}}"
342        );
343    }
344
345    #[test]
346    fn hmac_sha256_hashes_keys_larger_than_block_size() {
347        let key = [0xaa_u8; 131];
348        let digest = hmac_sha256_hex(
349            &key,
350            b"Test Using Larger Than Block-Size Key - Hash Key First",
351        );
352
353        assert_eq!(
354            digest,
355            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
356        );
357    }
358
359    #[test]
360    fn raw_payload_is_used_verbatim_for_signature_inputs() -> Result<(), String> {
361        let fx = Factory::deterministic(Seed::from_env_value("webhook-raw-payload")?);
362        let raw = "{\"message\":\"keep exact spacing\", \"n\": 1}\n";
363
364        let github = fx.webhook_github("raw", WebhookPayloadSpec::Raw(raw.to_string()));
365        assert_eq!(github.payload, raw);
366        assert_eq!(github.signature_input, raw);
367        assert!(verify_github(&github.secret, raw, &github.headers));
368
369        let stripe = fx.webhook_stripe("raw", WebhookPayloadSpec::Raw(raw.to_string()));
370        assert_eq!(stripe.payload, raw);
371        assert_eq!(
372            stripe.signature_input,
373            format!("{}.{}", stripe.timestamp, raw)
374        );
375        assert!(verify_stripe(
376            &stripe.secret,
377            raw,
378            &stripe.headers,
379            stripe.timestamp,
380            300
381        ));
382
383        let slack = fx.webhook_slack("raw", WebhookPayloadSpec::Raw(raw.to_string()));
384        assert_eq!(slack.payload, raw);
385        assert_eq!(
386            slack.signature_input,
387            format!("v0:{}:{}", slack.timestamp, raw)
388        );
389        assert!(verify_slack(
390            &slack.secret,
391            raw,
392            &slack.headers,
393            slack.timestamp,
394            300
395        ));
396        Ok(())
397    }
398
399    #[test]
400    fn fixture_cache_identity_includes_profile_label_and_payload_spec() -> Result<(), String> {
401        let fx = Factory::deterministic(Seed::from_env_value("webhook-cache-identity")?);
402
403        let github = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
404        let github_again = fx.webhook_github("repo", WebhookPayloadSpec::Canonical);
405        assert_eq!(github.secret, github_again.secret);
406        assert_eq!(github.payload, github_again.payload);
407        assert_eq!(github.headers, github_again.headers);
408
409        let different_profile = fx.webhook_stripe("repo", WebhookPayloadSpec::Canonical);
410        assert_ne!(github.secret, different_profile.secret);
411        assert_ne!(github.payload, different_profile.payload);
412
413        let different_label = fx.webhook_github("other-repo", WebhookPayloadSpec::Canonical);
414        assert_ne!(github.secret, different_label.secret);
415        assert_ne!(github.payload, different_label.payload);
416
417        let different_raw = fx.webhook_github(
418            "repo",
419            WebhookPayloadSpec::Raw(r#"{"action":"opened"}"#.to_string()),
420        );
421        assert_ne!(github.secret, different_raw.secret);
422        assert_eq!(different_raw.payload, r#"{"action":"opened"}"#);
423        Ok(())
424    }
425
426    #[test]
427    fn near_miss_scenarios_are_marked_and_recomputed_for_each_profile() -> Result<(), String> {
428        let fx = Factory::deterministic(Seed::from_env_value("webhook-nearmiss-profiles")?);
429        let fixtures = [
430            fx.webhook_github("repo", WebhookPayloadSpec::Canonical),
431            fx.webhook_stripe("billing", WebhookPayloadSpec::Canonical),
432            fx.webhook_slack("alerts", WebhookPayloadSpec::Canonical),
433        ];
434
435        for fixture in fixtures {
436            let stale = fixture.near_miss_stale_timestamp(300);
437            assert_eq!(stale.scenario, NearMissScenario::StaleTimestamp);
438            assert_eq!(stale.profile, fixture.profile);
439            assert_eq!(stale.payload, fixture.payload);
440            assert_eq!(stale.timestamp, fixture.timestamp - 301);
441
442            let wrong_secret = fixture.near_miss_wrong_secret();
443            assert_eq!(wrong_secret.scenario, NearMissScenario::WrongSecret);
444            assert_eq!(wrong_secret.profile, fixture.profile);
445            assert_eq!(wrong_secret.payload, fixture.payload);
446            assert_ne!(wrong_secret.secret, fixture.secret);
447            assert!(wrong_secret.secret.ends_with("_wrong"));
448
449            let tampered = fixture.near_miss_tampered_payload();
450            assert_eq!(tampered.scenario, NearMissScenario::TamperedPayload);
451            assert_eq!(tampered.profile, fixture.profile);
452            assert_eq!(tampered.secret, fixture.secret);
453            assert_eq!(tampered.payload, format!("{}\n", fixture.payload));
454
455            match fixture.profile {
456                WebhookProfile::GitHub => {
457                    assert!(!verify_github(
458                        &fixture.secret,
459                        &wrong_secret.payload,
460                        &wrong_secret.headers
461                    ));
462                    assert!(!verify_github(
463                        &tampered.secret,
464                        &fixture.payload,
465                        &tampered.headers
466                    ));
467                }
468                WebhookProfile::Stripe => {
469                    assert!(!verify_stripe(
470                        &fixture.secret,
471                        &fixture.payload,
472                        &stale.headers,
473                        fixture.timestamp,
474                        300
475                    ));
476                    assert!(!verify_stripe(
477                        &fixture.secret,
478                        &wrong_secret.payload,
479                        &wrong_secret.headers,
480                        wrong_secret.timestamp,
481                        300
482                    ));
483                    assert!(!verify_stripe(
484                        &tampered.secret,
485                        &fixture.payload,
486                        &tampered.headers,
487                        tampered.timestamp,
488                        300
489                    ));
490                }
491                WebhookProfile::Slack => {
492                    assert!(!verify_slack(
493                        &fixture.secret,
494                        &fixture.payload,
495                        &stale.headers,
496                        fixture.timestamp,
497                        300
498                    ));
499                    assert!(!verify_slack(
500                        &fixture.secret,
501                        &wrong_secret.payload,
502                        &wrong_secret.headers,
503                        wrong_secret.timestamp,
504                        300
505                    ));
506                    assert!(!verify_slack(
507                        &tampered.secret,
508                        &fixture.payload,
509                        &tampered.headers,
510                        tampered.timestamp,
511                        300
512                    ));
513                }
514            }
515        }
516        Ok(())
517    }
518
519    fn assert_lower_hex(value: &str) {
520        assert!(
521            value
522                .bytes()
523                .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
524            "expected lowercase hex: {value}"
525        );
526    }
527
528    #[test]
529    fn webhook_profile_stable_tag_is_unique_per_variant() {
530        use crate::model::WebhookProfile;
531        let tags = [
532            WebhookProfile::GitHub.stable_tag(),
533            WebhookProfile::Stripe.stable_tag(),
534            WebhookProfile::Slack.stable_tag(),
535        ];
536        assert_eq!(tags, ["github", "stripe", "slack"]);
537
538        let unique: std::collections::HashSet<&&str> = tags.iter().collect();
539        assert_eq!(unique.len(), tags.len(), "stable_tag values must be unique");
540    }
541
542    #[test]
543    fn raw_payload_spec_is_returned_verbatim() {
544        let fx = Factory::deterministic_from_str("webhook-raw-spec");
545        let payload = "{\"custom\":\"shape\"}".to_string();
546        let fixture = fx.webhook_github("raw-repo", WebhookPayloadSpec::Raw(payload.clone()));
547
548        assert_eq!(fixture.payload, payload);
549        assert!(verify_github(
550            &fixture.secret,
551            &fixture.payload,
552            &fixture.headers
553        ));
554    }
555
556    #[test]
557    fn raw_payload_distinct_strings_yield_distinct_cache_identities() {
558        let fx = Factory::deterministic_from_str("webhook-raw-cache");
559        let one = fx.webhook_stripe("svc", WebhookPayloadSpec::Raw("one".to_string()));
560        let two = fx.webhook_stripe("svc", WebhookPayloadSpec::Raw("two".to_string()));
561
562        assert_ne!(one.payload, two.payload);
563        assert_ne!(one.signature_input, two.signature_input);
564        assert_ne!(
565            one.headers.get("Stripe-Signature"),
566            two.headers.get("Stripe-Signature")
567        );
568    }
569
570    #[test]
571    fn stale_timestamp_near_miss_works_for_all_profiles() {
572        let fx = Factory::deterministic_from_str("webhook-stale-all");
573        let max_age = 300_i64;
574
575        for profile in [
576            WebhookProfile::GitHub,
577            WebhookProfile::Stripe,
578            WebhookProfile::Slack,
579        ] {
580            let base = fx.webhook(profile, "svc", WebhookPayloadSpec::Canonical);
581            let stale = base.near_miss_stale_timestamp(max_age);
582
583            assert_eq!(stale.scenario, NearMissScenario::StaleTimestamp);
584            assert_eq!(stale.timestamp, base.timestamp - max_age - 1);
585            // GitHub does not include a timestamp in its signed input, so the
586            // signature_input is just the payload; for Stripe/Slack the input
587            // includes the (stale) timestamp.
588            match profile {
589                WebhookProfile::GitHub => {
590                    assert_eq!(stale.signature_input, stale.payload);
591                }
592                WebhookProfile::Stripe => {
593                    assert_eq!(
594                        stale.signature_input,
595                        format!("{}.{}", stale.timestamp, stale.payload)
596                    );
597                }
598                WebhookProfile::Slack => {
599                    assert_eq!(
600                        stale.signature_input,
601                        format!("v0:{}:{}", stale.timestamp, stale.payload)
602                    );
603                }
604            }
605        }
606    }
607
608    #[test]
609    fn wrong_secret_near_miss_works_for_github_and_slack() {
610        let fx = Factory::deterministic_from_str("webhook-wrong-secret");
611
612        let gh = fx.webhook_github("svc", WebhookPayloadSpec::Canonical);
613        let gh_wrong = gh.near_miss_wrong_secret();
614        assert_eq!(gh_wrong.scenario, NearMissScenario::WrongSecret);
615        assert_ne!(gh_wrong.secret, gh.secret);
616        assert!(gh_wrong.secret.ends_with("_wrong"));
617        assert!(!verify_github(
618            &gh.secret,
619            &gh_wrong.payload,
620            &gh_wrong.headers
621        ));
622
623        let sl = fx.webhook_slack("svc", WebhookPayloadSpec::Canonical);
624        let sl_wrong = sl.near_miss_wrong_secret();
625        assert_eq!(sl_wrong.scenario, NearMissScenario::WrongSecret);
626        assert_ne!(sl_wrong.secret, sl.secret);
627        assert!(!verify_slack(
628            &sl.secret,
629            &sl_wrong.payload,
630            &sl_wrong.headers,
631            sl_wrong.timestamp,
632            300
633        ));
634    }
635
636    #[test]
637    fn tampered_payload_near_miss_works_for_github_and_slack() {
638        let fx = Factory::deterministic_from_str("webhook-tampered");
639
640        let gh = fx.webhook_github("svc", WebhookPayloadSpec::Canonical);
641        let gh_tampered = gh.near_miss_tampered_payload();
642        assert_eq!(gh_tampered.scenario, NearMissScenario::TamperedPayload);
643        assert_ne!(gh_tampered.payload, gh.payload);
644        // The tampered fixture re-signs its own modified payload, so it
645        // verifies against itself; verifying the *original* payload with the
646        // tampered signature must fail.
647        assert!(!verify_github(
648            &gh.secret,
649            &gh.payload,
650            &gh_tampered.headers
651        ));
652
653        let sl = fx.webhook_slack("svc", WebhookPayloadSpec::Canonical);
654        let sl_tampered = sl.near_miss_tampered_payload();
655        assert_eq!(sl_tampered.scenario, NearMissScenario::TamperedPayload);
656        assert_ne!(sl_tampered.payload, sl.payload);
657        assert!(!verify_slack(
658            &sl.secret,
659            &sl.payload,
660            &sl_tampered.headers,
661            sl_tampered.timestamp,
662            300
663        ));
664    }
665
666    #[test]
667    fn hmac_sha256_long_key_is_hashed_first() {
668        // RFC 4231 test vector 4: 131-byte key (longer than the 64-byte block),
669        // exercising the SHA-256 pre-hash branch of hmac_sha256_hex.
670        let key = vec![0xaa_u8; 131];
671        let digest = hmac_sha256_hex(
672            &key,
673            b"Test Using Larger Than Block-Size Key - Hash Key First",
674        );
675        assert_eq!(
676            digest,
677            "60e431591ee0b67f0d8a26aacbf5b77f8e0bc6213728c5140546040f0ee37f54"
678        );
679    }
680
681    #[test]
682    fn hmac_sha256_short_key_is_zero_padded() {
683        // A short key should be zero-padded into the 64-byte block, not hashed.
684        // This exercises the else-branch of hmac_sha256_hex with a sub-block-size key.
685        let short = b"key";
686        let padded = {
687            let mut padded = [0_u8; 64];
688            padded[..short.len()].copy_from_slice(short);
689            padded.to_vec()
690        };
691        assert_eq!(
692            hmac_sha256_hex(short, b"hello"),
693            hmac_sha256_hex(&padded, b"hello"),
694            "short key must zero-pad into the same block as the explicitly padded key"
695        );
696    }
697
698    #[test]
699    fn debug_redacts_secret_for_all_profiles() {
700        let fx = Factory::deterministic_from_str("webhook-debug-all");
701
702        for profile in [
703            WebhookProfile::GitHub,
704            WebhookProfile::Stripe,
705            WebhookProfile::Slack,
706        ] {
707            let fixture = fx.webhook(profile, "svc", WebhookPayloadSpec::Canonical);
708            let dbg = format!("{fixture:?}");
709            assert!(dbg.contains("WebhookFixture"));
710            assert!(
711                !dbg.contains(&fixture.secret),
712                "Debug for {profile:?} must not leak secret: {dbg}"
713            );
714        }
715    }
716
717    #[test]
718    fn debug_redacts_secret_for_all_near_miss_scenarios() {
719        let fx = Factory::deterministic_from_str("webhook-debug-nm");
720        let base = fx.webhook_stripe("svc", WebhookPayloadSpec::Canonical);
721
722        let scenarios = [
723            base.near_miss_stale_timestamp(300),
724            base.near_miss_wrong_secret(),
725            base.near_miss_tampered_payload(),
726        ];
727
728        for fixture in scenarios {
729            let dbg = format!("{fixture:?}");
730            assert!(dbg.contains("NearMissWebhookFixture"));
731            assert!(
732                !dbg.contains(&fixture.secret),
733                "Debug for {:?} must not leak secret: {dbg}",
734                fixture.scenario
735            );
736        }
737    }
738}