1#![forbid(unsafe_code)]
2
3mod 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
19pub 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 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 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 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 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}