1use std::time::Duration;
25
26use anyhow::{Context, Result};
27use hmac::{Hmac, Mac};
28use serde::{Deserialize, Serialize};
29use serde_json::json;
30use sha2::Sha256;
31
32#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub enum WebhookType {
35 #[default]
37 Generic,
38 Slack,
40 Discord,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct WebhookConfig {
47 pub url: String,
49 #[serde(default)]
51 pub webhook_type: WebhookType,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub secret: Option<String>,
55 #[serde(default = "default_timeout")]
57 pub timeout_secs: u64,
58}
59
60fn default_timeout() -> u64 {
61 30
62}
63
64impl Default for WebhookConfig {
65 fn default() -> Self {
66 Self {
67 url: String::new(),
68 webhook_type: WebhookType::default(),
69 secret: None,
70 timeout_secs: default_timeout(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct WebhookPayload {
78 pub message: String,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub title: Option<String>,
83 pub success: bool,
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub package: Option<String>,
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub version: Option<String>,
91 #[serde(skip_serializing_if = "Option::is_none")]
93 pub registry: Option<String>,
94 #[serde(skip_serializing_if = "Option::is_none")]
96 pub error: Option<String>,
97 #[serde(flatten)]
99 pub extra: std::collections::BTreeMap<String, serde_json::Value>,
100}
101
102pub fn send_webhook(config: &WebhookConfig, payload: &WebhookPayload) -> Result<()> {
104 let client = reqwest::blocking::Client::builder()
105 .timeout(Duration::from_secs(config.timeout_secs))
106 .build()
107 .context("failed to create HTTP client")?;
108
109 let body = match config.webhook_type {
110 WebhookType::Generic => serde_json::to_string(payload)?,
111 WebhookType::Slack => slack_payload(payload)?,
112 WebhookType::Discord => discord_payload(payload)?,
113 };
114
115 let signature = config
116 .secret
117 .as_deref()
118 .filter(|secret| !secret.trim().is_empty())
119 .map(|secret| webhook_signature(secret, &body))
120 .transpose()?;
121
122 let mut request = client
123 .post(&config.url)
124 .header("Content-Type", "application/json")
125 .body(body);
126
127 if let Some(signature) = signature {
128 request = request.header("X-Hub-Signature-256", signature);
129 }
130
131 let response = request.send().context("failed to send webhook request")?;
132
133 if !response.status().is_success() {
134 return Err(anyhow::anyhow!(
135 "webhook request failed with status {}: {}",
136 response.status(),
137 response.text().unwrap_or_default()
138 ));
139 }
140
141 Ok(())
142}
143
144pub async fn send_webhook_async(config: &WebhookConfig, payload: &WebhookPayload) -> Result<()> {
146 let client = reqwest::Client::builder()
147 .timeout(Duration::from_secs(config.timeout_secs))
148 .build()
149 .context("failed to create HTTP client")?;
150
151 let body = match config.webhook_type {
152 WebhookType::Generic => serde_json::to_string(payload)?,
153 WebhookType::Slack => slack_payload(payload)?,
154 WebhookType::Discord => discord_payload(payload)?,
155 };
156
157 let signature = config
158 .secret
159 .as_deref()
160 .filter(|secret| !secret.trim().is_empty())
161 .map(|secret| webhook_signature(secret, &body))
162 .transpose()?;
163
164 let mut request = client
165 .post(&config.url)
166 .header("Content-Type", "application/json")
167 .body(body);
168
169 if let Some(signature) = signature {
170 request = request.header("X-Hub-Signature-256", signature);
171 }
172
173 let response = request
174 .send()
175 .await
176 .context("failed to send webhook request")?;
177
178 if !response.status().is_success() {
179 return Err(anyhow::anyhow!(
180 "webhook request failed with status {}: {}",
181 response.status(),
182 response.text().await.unwrap_or_default()
183 ));
184 }
185
186 Ok(())
187}
188
189fn webhook_signature(secret: &str, body: &str) -> Result<String> {
190 let mut mac =
191 Hmac::<Sha256>::new_from_slice(secret.as_bytes()).context("invalid webhook secret")?;
192 mac.update(body.as_bytes());
193 let digest = mac.finalize().into_bytes();
194 Ok(format!("sha256={}", hex_encode(&digest)))
195}
196
197fn hex_encode(bytes: &[u8]) -> String {
198 let mut out = String::with_capacity(bytes.len() * 2);
199 for byte in bytes {
200 out.push_str(&format!("{:02x}", byte));
201 }
202 out
203}
204
205fn slack_payload(payload: &WebhookPayload) -> Result<String> {
207 let color = if payload.success { "good" } else { "danger" };
208
209 let mut fields = vec![];
210
211 if let Some(package) = &payload.package {
212 fields.push(json!({
213 "title": "Package",
214 "value": package,
215 "short": true
216 }));
217 }
218
219 if let Some(version) = &payload.version {
220 fields.push(json!({
221 "title": "Version",
222 "value": version,
223 "short": true
224 }));
225 }
226
227 if let Some(registry) = &payload.registry {
228 fields.push(json!({
229 "title": "Registry",
230 "value": registry,
231 "short": true
232 }));
233 }
234
235 if let Some(error) = &payload.error {
236 fields.push(json!({
237 "title": "Error",
238 "value": error,
239 "short": false
240 }));
241 }
242
243 let slack_json = json!({
244 "attachments": [{
245 "color": color,
246 "title": payload.title.as_ref().unwrap_or(&"Shipper Notification".to_string()),
247 "text": payload.message,
248 "fields": fields
249 }]
250 });
251
252 Ok(serde_json::to_string(&slack_json)?)
253}
254
255fn discord_payload(payload: &WebhookPayload) -> Result<String> {
257 let color = if payload.success {
258 65280_u32
259 } else {
260 16711680_u32
261 };
262
263 let mut fields = vec![];
264
265 if let Some(package) = &payload.package {
266 fields.push(json!({
267 "name": "Package",
268 "value": package,
269 "inline": true
270 }));
271 }
272
273 if let Some(version) = &payload.version {
274 fields.push(json!({
275 "name": "Version",
276 "value": version,
277 "inline": true
278 }));
279 }
280
281 if let Some(registry) = &payload.registry {
282 fields.push(json!({
283 "name": "Registry",
284 "value": registry,
285 "inline": true
286 }));
287 }
288
289 if let Some(error) = &payload.error {
290 fields.push(json!({
291 "name": "Error",
292 "value": error,
293 "inline": false
294 }));
295 }
296
297 let discord_json = json!({
298 "embeds": [{
299 "title": payload.title.as_ref().unwrap_or(&"Shipper Notification".to_string()),
300 "description": payload.message,
301 "color": color,
302 "fields": fields
303 }]
304 });
305
306 Ok(serde_json::to_string(&discord_json)?)
307}
308
309pub fn publish_success_payload(package: &str, version: &str, registry: &str) -> WebhookPayload {
311 WebhookPayload {
312 message: format!("Successfully published {}@{}", package, version),
313 title: Some("Package Published".to_string()),
314 success: true,
315 package: Some(package.to_string()),
316 version: Some(version.to_string()),
317 registry: Some(registry.to_string()),
318 ..Default::default()
319 }
320}
321
322pub fn publish_failure_payload(package: &str, version: &str, error: &str) -> WebhookPayload {
324 WebhookPayload {
325 message: format!("Failed to publish {}@{}", package, version),
326 title: Some("Publish Failed".to_string()),
327 success: false,
328 package: Some(package.to_string()),
329 version: Some(version.to_string()),
330 error: Some(error.to_string()),
331 ..Default::default()
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338
339 #[test]
340 fn webhook_type_default() {
341 let wt = WebhookType::default();
342 assert_eq!(wt, WebhookType::Generic);
343 }
344
345 #[test]
346 fn webhook_config_default() {
347 let config = WebhookConfig::default();
348 assert!(config.url.is_empty());
349 assert_eq!(config.webhook_type, WebhookType::Generic);
350 assert_eq!(config.timeout_secs, 30);
351 }
352
353 #[test]
354 fn webhook_payload_default() {
355 let payload = WebhookPayload::default();
356 assert!(payload.message.is_empty());
357 assert!(!payload.success);
358 }
359
360 #[test]
361 fn publish_success_payload_works() {
362 let payload = publish_success_payload("my-crate", "1.0.0", "crates-io");
363 assert!(payload.success);
364 assert_eq!(payload.package, Some("my-crate".to_string()));
365 assert_eq!(payload.version, Some("1.0.0".to_string()));
366 assert!(payload.message.contains("Successfully"));
367 }
368
369 #[test]
370 fn publish_failure_payload_works() {
371 let payload = publish_failure_payload("my-crate", "1.0.0", "network error");
372 assert!(!payload.success);
373 assert_eq!(payload.error, Some("network error".to_string()));
374 assert!(payload.message.contains("Failed"));
375 }
376
377 #[test]
378 fn slack_payload_format() {
379 let payload = publish_success_payload("test", "1.0.0", "crates-io");
380 let json = slack_payload(&payload).expect("format");
381
382 assert!(json.contains("\"attachments\""));
383 assert!(json.contains("\"color\":\"good\""));
384 assert!(json.contains("test"));
385 }
386
387 #[test]
388 fn discord_payload_format() {
389 let payload = publish_success_payload("test", "1.0.0", "crates-io");
390 let json = discord_payload(&payload).expect("format");
391
392 assert!(json.contains("\"embeds\""));
393 assert!(json.contains("\"color\":65280"));
394 assert!(json.contains("test"));
395 }
396
397 #[test]
398 fn webhook_config_serialization() {
399 let config = WebhookConfig {
400 url: "https://example.com/webhook".to_string(),
401 webhook_type: WebhookType::Slack,
402 secret: None,
403 timeout_secs: 60,
404 };
405
406 let json = serde_json::to_string(&config).expect("serialize");
407 assert!(json.contains("\"url\""));
408 assert!(json.contains("\"webhook_type\":\"Slack\""));
409 }
410
411 #[test]
412 fn webhook_payload_serialization() {
413 let payload = WebhookPayload {
414 message: "test message".to_string(),
415 success: true,
416 ..Default::default()
417 };
418
419 let json = serde_json::to_string(&payload).expect("serialize");
420 assert!(json.contains("\"message\":\"test message\""));
421 assert!(json.contains("\"success\":true"));
422 }
423
424 #[test]
425 fn slack_payload_failure_color() {
426 let payload = publish_failure_payload("test", "1.0.0", "error");
427 let json = slack_payload(&payload).expect("format");
428 assert!(json.contains("\"color\":\"danger\""));
429 }
430
431 #[test]
432 fn discord_payload_failure_color() {
433 let payload = publish_failure_payload("test", "1.0.0", "error");
434 let json = discord_payload(&payload).expect("format");
435 assert!(json.contains("\"color\":16711680"));
436 }
437
438 #[test]
439 fn webhook_signature_matches_known_hmac_sha256() {
440 let signature = webhook_signature("secret", "hello").expect("signature");
441 assert_eq!(
442 signature,
443 "sha256=88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b"
444 );
445 }
446
447 #[test]
450 fn publish_success_payload_contains_registry() {
451 let payload = publish_success_payload("foo", "2.0.0", "crates-io");
452 assert_eq!(payload.registry, Some("crates-io".to_string()));
453 assert!(payload.error.is_none());
454 assert_eq!(payload.title, Some("Package Published".to_string()));
455 }
456
457 #[test]
458 fn publish_failure_payload_has_no_registry() {
459 let payload = publish_failure_payload("foo", "0.1.0", "timeout");
460 assert!(payload.registry.is_none());
461 assert_eq!(payload.error, Some("timeout".to_string()));
462 assert_eq!(payload.title, Some("Publish Failed".to_string()));
463 }
464
465 #[test]
466 fn payload_with_all_fields() {
467 let mut extra = std::collections::BTreeMap::new();
468 extra.insert("ci".to_string(), serde_json::json!("github-actions"));
469
470 let payload = WebhookPayload {
471 message: "msg".to_string(),
472 title: Some("title".to_string()),
473 success: true,
474 package: Some("pkg".to_string()),
475 version: Some("1.0.0".to_string()),
476 registry: Some("crates-io".to_string()),
477 error: None,
478 extra,
479 };
480
481 assert_eq!(payload.message, "msg");
482 assert_eq!(payload.extra.get("ci").unwrap(), "github-actions");
483 }
484
485 #[test]
486 fn payload_extra_fields_flatten_in_json() {
487 let mut extra = std::collections::BTreeMap::new();
488 extra.insert("run_id".to_string(), serde_json::json!(42));
489
490 let payload = WebhookPayload {
491 message: "m".to_string(),
492 extra,
493 ..Default::default()
494 };
495
496 let json = serde_json::to_string(&payload).unwrap();
497 assert!(json.contains("\"run_id\":42"));
498 assert!(!json.contains("\"extra\""));
500 }
501
502 #[test]
505 fn webhook_payload_roundtrip() {
506 let payload = publish_success_payload("my-crate", "3.0.0", "crates-io");
507 let json = serde_json::to_string(&payload).unwrap();
508 let deserialized: WebhookPayload = serde_json::from_str(&json).unwrap();
509 assert_eq!(deserialized.message, payload.message);
510 assert_eq!(deserialized.success, payload.success);
511 assert_eq!(deserialized.package, payload.package);
512 assert_eq!(deserialized.version, payload.version);
513 assert_eq!(deserialized.registry, payload.registry);
514 }
515
516 #[test]
517 fn webhook_config_roundtrip() {
518 let config = WebhookConfig {
519 url: "https://example.com/hook".to_string(),
520 webhook_type: WebhookType::Discord,
521 secret: Some("s3cret".to_string()),
522 timeout_secs: 10,
523 };
524 let json = serde_json::to_string(&config).unwrap();
525 let deserialized: WebhookConfig = serde_json::from_str(&json).unwrap();
526 assert_eq!(deserialized.url, config.url);
527 assert_eq!(deserialized.webhook_type, WebhookType::Discord);
528 assert_eq!(deserialized.secret, Some("s3cret".to_string()));
529 assert_eq!(deserialized.timeout_secs, 10);
530 }
531
532 #[test]
533 fn config_deserialization_defaults() {
534 let json = r#"{"url":"https://x.com"}"#;
536 let config: WebhookConfig = serde_json::from_str(json).unwrap();
537 assert_eq!(config.webhook_type, WebhookType::Generic);
538 assert_eq!(config.timeout_secs, 30);
539 assert!(config.secret.is_none());
540 }
541
542 #[test]
543 fn config_secret_omitted_when_none() {
544 let config = WebhookConfig {
545 url: "https://x.com".to_string(),
546 secret: None,
547 ..Default::default()
548 };
549 let json = serde_json::to_string(&config).unwrap();
550 assert!(!json.contains("secret"));
551 }
552
553 #[test]
554 fn payload_optional_fields_omitted_when_none() {
555 let payload = WebhookPayload {
556 message: "hi".to_string(),
557 success: false,
558 ..Default::default()
559 };
560 let json = serde_json::to_string(&payload).unwrap();
561 assert!(!json.contains("\"title\""));
562 assert!(!json.contains("\"package\""));
563 assert!(!json.contains("\"version\""));
564 assert!(!json.contains("\"registry\""));
565 assert!(!json.contains("\"error\""));
566 }
567
568 #[test]
569 fn webhook_type_all_variants_serialize() {
570 let generic = serde_json::to_string(&WebhookType::Generic).unwrap();
571 let slack = serde_json::to_string(&WebhookType::Slack).unwrap();
572 let discord = serde_json::to_string(&WebhookType::Discord).unwrap();
573 assert_eq!(generic, "\"Generic\"");
574 assert_eq!(slack, "\"Slack\"");
575 assert_eq!(discord, "\"Discord\"");
576 }
577
578 #[test]
579 fn webhook_type_all_variants_deserialize() {
580 let g: WebhookType = serde_json::from_str("\"Generic\"").unwrap();
581 let s: WebhookType = serde_json::from_str("\"Slack\"").unwrap();
582 let d: WebhookType = serde_json::from_str("\"Discord\"").unwrap();
583 assert_eq!(g, WebhookType::Generic);
584 assert_eq!(s, WebhookType::Slack);
585 assert_eq!(d, WebhookType::Discord);
586 }
587
588 #[test]
591 fn slack_payload_with_all_fields() {
592 let payload = WebhookPayload {
593 message: "deployed".to_string(),
594 title: Some("Deploy".to_string()),
595 success: false,
596 package: Some("pkg".to_string()),
597 version: Some("0.1.0".to_string()),
598 registry: Some("reg".to_string()),
599 error: Some("oops".to_string()),
600 ..Default::default()
601 };
602 let json = slack_payload(&payload).unwrap();
603 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
604
605 let attachment = &parsed["attachments"][0];
606 assert_eq!(attachment["color"], "danger");
607 assert_eq!(attachment["title"], "Deploy");
608 assert_eq!(attachment["text"], "deployed");
609
610 let fields = attachment["fields"].as_array().unwrap();
611 assert_eq!(fields.len(), 4);
612 }
613
614 #[test]
615 fn slack_payload_no_optional_fields() {
616 let payload = WebhookPayload {
617 message: "hello".to_string(),
618 success: true,
619 ..Default::default()
620 };
621 let json = slack_payload(&payload).unwrap();
622 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
623
624 let attachment = &parsed["attachments"][0];
625 assert_eq!(attachment["color"], "good");
626 assert_eq!(attachment["title"], "Shipper Notification");
628 let fields = attachment["fields"].as_array().unwrap();
629 assert!(fields.is_empty());
630 }
631
632 #[test]
635 fn discord_payload_with_all_fields() {
636 let payload = WebhookPayload {
637 message: "done".to_string(),
638 title: Some("Release".to_string()),
639 success: true,
640 package: Some("crate-a".to_string()),
641 version: Some("2.0.0".to_string()),
642 registry: Some("crates-io".to_string()),
643 error: Some("warn".to_string()),
644 ..Default::default()
645 };
646 let json = discord_payload(&payload).unwrap();
647 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
648
649 let embed = &parsed["embeds"][0];
650 assert_eq!(embed["color"], 65280);
651 assert_eq!(embed["title"], "Release");
652 assert_eq!(embed["description"], "done");
653
654 let fields = embed["fields"].as_array().unwrap();
655 assert_eq!(fields.len(), 4);
656 }
657
658 #[test]
659 fn discord_payload_no_optional_fields() {
660 let payload = WebhookPayload {
661 message: "hi".to_string(),
662 success: false,
663 ..Default::default()
664 };
665 let json = discord_payload(&payload).unwrap();
666 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
667
668 let embed = &parsed["embeds"][0];
669 assert_eq!(embed["color"], 16711680);
670 assert_eq!(embed["title"], "Shipper Notification");
671 let fields = embed["fields"].as_array().unwrap();
672 assert!(fields.is_empty());
673 }
674
675 #[test]
678 fn signature_prefix() {
679 let sig = webhook_signature("key", "data").unwrap();
680 assert!(sig.starts_with("sha256="));
681 }
682
683 #[test]
684 fn different_secrets_produce_different_signatures() {
685 let s1 = webhook_signature("secret-a", "body").unwrap();
686 let s2 = webhook_signature("secret-b", "body").unwrap();
687 assert_ne!(s1, s2);
688 }
689
690 #[test]
691 fn different_bodies_produce_different_signatures() {
692 let s1 = webhook_signature("key", "body-a").unwrap();
693 let s2 = webhook_signature("key", "body-b").unwrap();
694 assert_ne!(s1, s2);
695 }
696
697 #[test]
698 fn signature_on_empty_body() {
699 let sig = webhook_signature("secret", "").unwrap();
700 assert!(sig.starts_with("sha256="));
701 assert!(sig.len() > "sha256=".len());
702 }
703
704 #[test]
705 fn hex_encode_empty() {
706 assert_eq!(hex_encode(&[]), "");
707 }
708
709 #[test]
710 fn hex_encode_known() {
711 assert_eq!(hex_encode(&[0x00, 0xff, 0xab]), "00ffab");
712 }
713
714 #[test]
715 fn hex_encode_all_single_digits() {
716 assert_eq!(hex_encode(&[0x0a, 0x0b, 0x0c]), "0a0b0c");
717 }
718
719 #[test]
722 fn send_webhook_invalid_url_returns_error() {
723 let config = WebhookConfig {
724 url: "not-a-url".to_string(),
725 timeout_secs: 1,
726 ..Default::default()
727 };
728 let payload = WebhookPayload {
729 message: "test".to_string(),
730 ..Default::default()
731 };
732 let result = send_webhook(&config, &payload);
733 assert!(result.is_err());
734 }
735
736 #[test]
737 fn send_webhook_connection_refused_returns_error() {
738 let config = WebhookConfig {
740 url: "http://127.0.0.1:1/webhook".to_string(),
741 timeout_secs: 1,
742 ..Default::default()
743 };
744 let payload = WebhookPayload {
745 message: "test".to_string(),
746 ..Default::default()
747 };
748 let result = send_webhook(&config, &payload);
749 assert!(result.is_err());
750 }
751
752 #[tokio::test]
753 async fn send_webhook_async_connection_refused_returns_error() {
754 let config = WebhookConfig {
755 url: "http://127.0.0.1:1/webhook".to_string(),
756 timeout_secs: 1,
757 ..Default::default()
758 };
759 let payload = WebhookPayload {
760 message: "test".to_string(),
761 ..Default::default()
762 };
763 let result = send_webhook_async(&config, &payload).await;
764 assert!(result.is_err());
765 }
766
767 #[test]
770 fn send_webhook_success_with_mock_server() {
771 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
772 let addr = server.server_addr().to_ip().unwrap();
773
774 let config = WebhookConfig {
775 url: format!("http://{addr}/hook"),
776 webhook_type: WebhookType::Generic,
777 timeout_secs: 5,
778 secret: None,
779 };
780 let payload = publish_success_payload("mypkg", "1.0.0", "crates-io");
781
782 let handle = std::thread::spawn(move || {
783 let req = server.recv().unwrap();
784 assert_eq!(req.method(), &tiny_http::Method::Post);
785 assert_eq!(req.url(), "/hook");
786 assert!(req.headers().iter().all(|h| {
788 !h.field
789 .as_str()
790 .as_str()
791 .eq_ignore_ascii_case("x-hub-signature-256")
792 }));
793 let response = tiny_http::Response::from_string("ok");
794 req.respond(response).unwrap();
795 });
796
797 let result = send_webhook(&config, &payload);
798 handle.join().unwrap();
799 assert!(result.is_ok());
800 }
801
802 #[test]
803 fn send_webhook_with_signature_header() {
804 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
805 let addr = server.server_addr().to_ip().unwrap();
806
807 let config = WebhookConfig {
808 url: format!("http://{addr}/signed"),
809 webhook_type: WebhookType::Generic,
810 timeout_secs: 5,
811 secret: Some("my-secret".to_string()),
812 };
813 let payload = WebhookPayload {
814 message: "signed".to_string(),
815 ..Default::default()
816 };
817
818 let handle = std::thread::spawn(move || {
819 let req = server.recv().unwrap();
820 let sig_header = req
821 .headers()
822 .iter()
823 .find(|h| {
824 h.field
825 .as_str()
826 .as_str()
827 .eq_ignore_ascii_case("x-hub-signature-256")
828 })
829 .expect("signature header missing");
830 assert!(sig_header.value.as_str().starts_with("sha256="));
831 let response = tiny_http::Response::from_string("ok");
832 req.respond(response).unwrap();
833 });
834
835 let result = send_webhook(&config, &payload);
836 handle.join().unwrap();
837 assert!(result.is_ok());
838 }
839
840 #[test]
841 fn send_webhook_empty_secret_skips_signature() {
842 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
843 let addr = server.server_addr().to_ip().unwrap();
844
845 let config = WebhookConfig {
846 url: format!("http://{addr}/hook"),
847 webhook_type: WebhookType::Generic,
848 timeout_secs: 5,
849 secret: Some(" ".to_string()), };
851 let payload = WebhookPayload {
852 message: "test".to_string(),
853 ..Default::default()
854 };
855
856 let handle = std::thread::spawn(move || {
857 let req = server.recv().unwrap();
858 assert!(req.headers().iter().all(|h| {
860 !h.field
861 .as_str()
862 .as_str()
863 .eq_ignore_ascii_case("x-hub-signature-256")
864 }));
865 let response = tiny_http::Response::from_string("ok");
866 req.respond(response).unwrap();
867 });
868
869 let result = send_webhook(&config, &payload);
870 handle.join().unwrap();
871 assert!(result.is_ok());
872 }
873
874 #[test]
875 fn send_webhook_server_error_returns_err() {
876 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
877 let addr = server.server_addr().to_ip().unwrap();
878
879 let config = WebhookConfig {
880 url: format!("http://{addr}/fail"),
881 timeout_secs: 5,
882 ..Default::default()
883 };
884 let payload = WebhookPayload {
885 message: "test".to_string(),
886 ..Default::default()
887 };
888
889 let handle = std::thread::spawn(move || {
890 let req = server.recv().unwrap();
891 let response = tiny_http::Response::from_string("internal error")
892 .with_status_code(tiny_http::StatusCode(500));
893 req.respond(response).unwrap();
894 });
895
896 let result = send_webhook(&config, &payload);
897 handle.join().unwrap();
898 assert!(result.is_err());
899 let err_msg = result.unwrap_err().to_string();
900 assert!(err_msg.contains("500"));
901 }
902
903 #[test]
904 fn send_webhook_slack_format_to_server() {
905 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
906 let addr = server.server_addr().to_ip().unwrap();
907
908 let config = WebhookConfig {
909 url: format!("http://{addr}/slack"),
910 webhook_type: WebhookType::Slack,
911 timeout_secs: 5,
912 secret: None,
913 };
914 let payload = publish_success_payload("crate-x", "0.1.0", "crates-io");
915
916 let handle = std::thread::spawn(move || {
917 let mut req = server.recv().unwrap();
918 let mut body = String::new();
919 req.as_reader().read_to_string(&mut body).unwrap();
920 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
921 assert!(parsed["attachments"].is_array());
923 let response = tiny_http::Response::from_string("ok");
924 req.respond(response).unwrap();
925 });
926
927 let result = send_webhook(&config, &payload);
928 handle.join().unwrap();
929 assert!(result.is_ok());
930 }
931
932 #[test]
933 fn send_webhook_discord_format_to_server() {
934 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
935 let addr = server.server_addr().to_ip().unwrap();
936
937 let config = WebhookConfig {
938 url: format!("http://{addr}/discord"),
939 webhook_type: WebhookType::Discord,
940 timeout_secs: 5,
941 secret: None,
942 };
943 let payload = publish_failure_payload("crate-y", "0.2.0", "network error");
944
945 let handle = std::thread::spawn(move || {
946 let mut req = server.recv().unwrap();
947 let mut body = String::new();
948 req.as_reader().read_to_string(&mut body).unwrap();
949 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
950 assert!(parsed["embeds"].is_array());
952 let response = tiny_http::Response::from_string("ok");
953 req.respond(response).unwrap();
954 });
955
956 let result = send_webhook(&config, &payload);
957 handle.join().unwrap();
958 assert!(result.is_ok());
959 }
960
961 #[tokio::test]
962 async fn send_webhook_async_success_with_mock_server() {
963 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
964 let addr = server.server_addr().to_ip().unwrap();
965
966 let config = WebhookConfig {
967 url: format!("http://{addr}/async-hook"),
968 webhook_type: WebhookType::Generic,
969 timeout_secs: 5,
970 secret: None,
971 };
972 let payload = publish_success_payload("async-pkg", "1.0.0", "crates-io");
973
974 let handle = std::thread::spawn(move || {
975 let req = server.recv().unwrap();
976 let response = tiny_http::Response::from_string("ok");
977 req.respond(response).unwrap();
978 });
979
980 let result = send_webhook_async(&config, &payload).await;
981 handle.join().unwrap();
982 assert!(result.is_ok());
983 }
984
985 #[tokio::test]
986 async fn send_webhook_async_server_error() {
987 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
988 let addr = server.server_addr().to_ip().unwrap();
989
990 let config = WebhookConfig {
991 url: format!("http://{addr}/fail"),
992 timeout_secs: 5,
993 ..Default::default()
994 };
995 let payload = WebhookPayload {
996 message: "test".to_string(),
997 ..Default::default()
998 };
999
1000 let handle = std::thread::spawn(move || {
1001 let req = server.recv().unwrap();
1002 let response = tiny_http::Response::from_string("bad")
1003 .with_status_code(tiny_http::StatusCode(403));
1004 req.respond(response).unwrap();
1005 });
1006
1007 let result = send_webhook_async(&config, &payload).await;
1008 handle.join().unwrap();
1009 assert!(result.is_err());
1010 let err_msg = result.unwrap_err().to_string();
1011 assert!(err_msg.contains("403"));
1012 }
1013
1014 mod snapshot_tests {
1017 use super::*;
1018
1019 #[test]
1022 fn generic_success_payload_json() {
1023 let payload = publish_success_payload("my-crate", "1.2.3", "crates-io");
1024 let json: serde_json::Value = serde_json::to_value(&payload).unwrap();
1025 insta::assert_yaml_snapshot!("generic_success_payload", json);
1026 }
1027
1028 #[test]
1029 fn generic_failure_payload_json() {
1030 let payload =
1031 publish_failure_payload("my-crate", "1.2.3", "timeout waiting for registry");
1032 let json: serde_json::Value = serde_json::to_value(&payload).unwrap();
1033 insta::assert_yaml_snapshot!("generic_failure_payload", json);
1034 }
1035
1036 #[test]
1037 fn slack_success_payload_json() {
1038 let payload = publish_success_payload("my-crate", "1.2.3", "crates-io");
1039 let body = slack_payload(&payload).unwrap();
1040 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1041 insta::assert_yaml_snapshot!("slack_success_payload", json);
1042 }
1043
1044 #[test]
1045 fn slack_failure_payload_json() {
1046 let payload = publish_failure_payload("my-crate", "1.2.3", "network error");
1047 let body = slack_payload(&payload).unwrap();
1048 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1049 insta::assert_yaml_snapshot!("slack_failure_payload", json);
1050 }
1051
1052 #[test]
1053 fn discord_success_payload_json() {
1054 let payload = publish_success_payload("my-crate", "1.2.3", "crates-io");
1055 let body = discord_payload(&payload).unwrap();
1056 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1057 insta::assert_yaml_snapshot!("discord_success_payload", json);
1058 }
1059
1060 #[test]
1061 fn discord_failure_payload_json() {
1062 let payload = publish_failure_payload("my-crate", "1.2.3", "network error");
1063 let body = discord_payload(&payload).unwrap();
1064 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
1065 insta::assert_yaml_snapshot!("discord_failure_payload", json);
1066 }
1067
1068 #[test]
1069 fn generic_minimal_payload_json() {
1070 let payload = WebhookPayload {
1071 message: "hello".to_string(),
1072 success: true,
1073 ..Default::default()
1074 };
1075 let json: serde_json::Value = serde_json::to_value(&payload).unwrap();
1076 insta::assert_yaml_snapshot!("generic_minimal_payload", json);
1077 }
1078
1079 #[test]
1080 fn generic_payload_with_extra_fields() {
1081 let mut extra = std::collections::BTreeMap::new();
1082 extra.insert("ci".to_string(), serde_json::json!("github-actions"));
1083 extra.insert("run_id".to_string(), serde_json::json!(42));
1084 let payload = WebhookPayload {
1085 message: "deployed".to_string(),
1086 title: Some("Deploy".to_string()),
1087 success: true,
1088 package: Some("my-crate".to_string()),
1089 version: Some("1.0.0".to_string()),
1090 registry: Some("crates-io".to_string()),
1091 error: None,
1092 extra,
1093 };
1094 let json: serde_json::Value = serde_json::to_value(&payload).unwrap();
1095 insta::assert_yaml_snapshot!("generic_payload_with_extras", json);
1096 }
1097
1098 #[test]
1101 fn config_generic_default() {
1102 let config = WebhookConfig {
1103 url: "https://example.com/webhook".to_string(),
1104 ..Default::default()
1105 };
1106 let json: serde_json::Value = serde_json::to_value(&config).unwrap();
1107 insta::assert_yaml_snapshot!("config_generic_default", json);
1108 }
1109
1110 #[test]
1111 fn config_slack_with_secret() {
1112 let config = WebhookConfig {
1113 url: "https://hooks.slack.com/services/T00/B00/xxx".to_string(),
1114 webhook_type: WebhookType::Slack,
1115 secret: Some("s3cret-key".to_string()),
1116 timeout_secs: 10,
1117 };
1118 let json: serde_json::Value = serde_json::to_value(&config).unwrap();
1119 insta::assert_yaml_snapshot!("config_slack_with_secret", json);
1120 }
1121
1122 #[test]
1123 fn config_discord_no_secret() {
1124 let config = WebhookConfig {
1125 url: "https://discord.com/api/webhooks/123/abc".to_string(),
1126 webhook_type: WebhookType::Discord,
1127 secret: None,
1128 timeout_secs: 60,
1129 };
1130 let json: serde_json::Value = serde_json::to_value(&config).unwrap();
1131 insta::assert_yaml_snapshot!("config_discord_no_secret", json);
1132 }
1133
1134 #[test]
1137 fn error_webhook_status_500() {
1138 let err = anyhow::anyhow!(
1139 "webhook request failed with status 500 Internal Server Error: internal error"
1140 );
1141 insta::assert_snapshot!("error_status_500", err.to_string());
1142 }
1143
1144 #[test]
1145 fn error_webhook_status_403() {
1146 let err = anyhow::anyhow!("webhook request failed with status 403 Forbidden: bad");
1147 insta::assert_snapshot!("error_status_403", err.to_string());
1148 }
1149
1150 #[test]
1151 fn error_send_failure() {
1152 let inner =
1153 std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "connection refused");
1154 let err = anyhow::Error::new(inner).context("failed to send webhook request");
1155 insta::assert_snapshot!("error_send_failure", err.to_string());
1156 }
1157
1158 #[test]
1161 fn error_invalid_url() {
1162 let config = WebhookConfig {
1163 url: "not-a-url".to_string(),
1164 timeout_secs: 1,
1165 ..Default::default()
1166 };
1167 let payload = WebhookPayload {
1168 message: "test".to_string(),
1169 ..Default::default()
1170 };
1171 let err = send_webhook(&config, &payload).unwrap_err();
1172 insta::assert_snapshot!("error_invalid_url", err.to_string());
1173 }
1174
1175 #[test]
1176 fn error_connection_refused() {
1177 let config = WebhookConfig {
1178 url: "http://127.0.0.1:1/webhook".to_string(),
1179 timeout_secs: 1,
1180 ..Default::default()
1181 };
1182 let payload = WebhookPayload {
1183 message: "test".to_string(),
1184 ..Default::default()
1185 };
1186 let err = send_webhook(&config, &payload).unwrap_err();
1187 insta::assert_snapshot!("error_connection_refused", err.to_string());
1188 }
1189 }
1190
1191 mod prop {
1194 use super::*;
1195 use proptest::prelude::*;
1196
1197 fn package_name() -> impl Strategy<Value = String> {
1199 "[a-z][a-z0-9_-]{0,39}".prop_map(|s| s)
1200 }
1201
1202 fn version_string() -> impl Strategy<Value = String> {
1204 (0u32..100, 0u32..100, 0u32..100).prop_map(|(ma, mi, pa)| format!("{ma}.{mi}.{pa}"))
1205 }
1206
1207 fn arb_payload() -> impl Strategy<Value = WebhookPayload> {
1209 (
1210 ".*", proptest::option::of(".*"), any::<bool>(), proptest::option::of(package_name()), proptest::option::of(version_string()), proptest::option::of("[a-z-]{1,20}"), proptest::option::of(".*"), )
1218 .prop_map(
1219 |(message, title, success, package, version, registry, error)| WebhookPayload {
1220 message,
1221 title,
1222 success,
1223 package,
1224 version,
1225 registry,
1226 error,
1227 extra: std::collections::BTreeMap::new(),
1228 },
1229 )
1230 }
1231
1232 proptest! {
1233 #[test]
1235 fn payload_roundtrip(payload in arb_payload()) {
1236 let json = serde_json::to_string(&payload).unwrap();
1237 let rt: WebhookPayload = serde_json::from_str(&json).unwrap();
1238 prop_assert_eq!(&rt.message, &payload.message);
1239 prop_assert_eq!(rt.success, payload.success);
1240 prop_assert_eq!(&rt.package, &payload.package);
1241 prop_assert_eq!(&rt.version, &payload.version);
1242 prop_assert_eq!(&rt.registry, &payload.registry);
1243 prop_assert_eq!(&rt.error, &payload.error);
1244 prop_assert_eq!(&rt.title, &payload.title);
1245 }
1246
1247 #[test]
1249 fn generic_json_has_required_keys(payload in arb_payload()) {
1250 let json = serde_json::to_string(&payload).unwrap();
1251 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1252 let obj = parsed.as_object().unwrap();
1253 prop_assert!(obj.contains_key("message"));
1254 prop_assert!(obj.contains_key("success"));
1255 }
1256
1257 #[test]
1259 fn slack_payload_always_valid(payload in arb_payload()) {
1260 let json = slack_payload(&payload).unwrap();
1261 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1262 prop_assert!(parsed["attachments"].is_array());
1263 let att = &parsed["attachments"][0];
1264 let color = att["color"].as_str().unwrap();
1266 prop_assert!(color == "good" || color == "danger");
1267 }
1268
1269 #[test]
1271 fn discord_payload_always_valid(payload in arb_payload()) {
1272 let json = discord_payload(&payload).unwrap();
1273 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1274 prop_assert!(parsed["embeds"].is_array());
1275 let embed = &parsed["embeds"][0];
1276 let color = embed["color"].as_u64().unwrap();
1277 prop_assert!(color == 65280 || color == 16711680);
1278 }
1279
1280 #[test]
1282 fn success_payload_preserves_inputs(
1283 name in package_name(),
1284 ver in version_string(),
1285 reg in "[a-z-]{1,20}",
1286 ) {
1287 let p = publish_success_payload(&name, &ver, ®);
1288 prop_assert_eq!(p.package.as_deref(), Some(name.as_str()));
1289 prop_assert_eq!(p.version.as_deref(), Some(ver.as_str()));
1290 prop_assert_eq!(p.registry.as_deref(), Some(reg.as_str()));
1291 prop_assert!(p.success);
1292 prop_assert!(p.message.contains(&name));
1293 prop_assert!(p.message.contains(&ver));
1294 }
1295
1296 #[test]
1298 fn failure_payload_preserves_inputs(
1299 name in package_name(),
1300 ver in version_string(),
1301 err in ".*",
1302 ) {
1303 let p = publish_failure_payload(&name, &ver, &err);
1304 prop_assert_eq!(p.package.as_deref(), Some(name.as_str()));
1305 prop_assert_eq!(p.version.as_deref(), Some(ver.as_str()));
1306 prop_assert_eq!(p.error.as_deref(), Some(err.as_str()));
1307 prop_assert!(!p.success);
1308 }
1309
1310 #[test]
1312 fn signature_deterministic_and_wellformed(
1313 secret in ".{1,64}",
1314 body in ".*",
1315 ) {
1316 let s1 = webhook_signature(&secret, &body).unwrap();
1317 let s2 = webhook_signature(&secret, &body).unwrap();
1318 prop_assert_eq!(&s1, &s2);
1319 prop_assert!(s1.starts_with("sha256="));
1320 prop_assert_eq!(s1.len(), "sha256=".len() + 64);
1322 }
1323
1324 #[test]
1326 fn config_with_arbitrary_url_serializes(
1327 url in "https?://[a-z0-9.-]{1,30}(:[0-9]{1,5})?(/[a-z0-9/_-]*)?",
1328 ) {
1329 let config = WebhookConfig {
1330 url: url.clone(),
1331 ..Default::default()
1332 };
1333 let json = serde_json::to_string(&config).unwrap();
1334 let rt: WebhookConfig = serde_json::from_str(&json).unwrap();
1335 prop_assert_eq!(&rt.url, &url);
1336 }
1337
1338 #[test]
1340 fn none_fields_omitted(msg in ".*", success in any::<bool>()) {
1341 let payload = WebhookPayload {
1342 message: msg,
1343 success,
1344 ..Default::default()
1345 };
1346 let json = serde_json::to_string(&payload).unwrap();
1347 prop_assert!(!json.contains("\"title\""));
1348 prop_assert!(!json.contains("\"package\""));
1349 prop_assert!(!json.contains("\"version\""));
1350 prop_assert!(!json.contains("\"registry\""));
1351 prop_assert!(!json.contains("\"error\""));
1352 }
1353
1354 #[test]
1356 fn hex_encode_valid(bytes in proptest::collection::vec(any::<u8>(), 0..128)) {
1357 let encoded = hex_encode(&bytes);
1358 prop_assert_eq!(encoded.len(), bytes.len() * 2);
1359 prop_assert!(encoded.chars().all(|c| c.is_ascii_hexdigit()));
1360 }
1361
1362 #[test]
1364 fn url_validation_never_panics(url in ".*") {
1365 let config = WebhookConfig {
1366 url,
1367 timeout_secs: 1,
1368 ..Default::default()
1369 };
1370 let payload = WebhookPayload {
1371 message: "test".to_string(),
1372 ..Default::default()
1373 };
1374 let _ = send_webhook(&config, &payload);
1376 }
1377 }
1378 }
1379
1380 mod edge_cases {
1383 use super::*;
1384
1385 #[test]
1388 fn send_webhook_empty_url_returns_error() {
1389 let config = WebhookConfig {
1390 url: String::new(),
1391 timeout_secs: 1,
1392 ..Default::default()
1393 };
1394 let payload = WebhookPayload {
1395 message: "test".to_string(),
1396 ..Default::default()
1397 };
1398 assert!(send_webhook(&config, &payload).is_err());
1399 }
1400
1401 #[test]
1402 fn send_webhook_ftp_scheme_returns_error() {
1403 let config = WebhookConfig {
1404 url: "ftp://example.com/webhook".to_string(),
1405 timeout_secs: 1,
1406 ..Default::default()
1407 };
1408 let payload = WebhookPayload {
1409 message: "test".to_string(),
1410 ..Default::default()
1411 };
1412 assert!(send_webhook(&config, &payload).is_err());
1413 }
1414
1415 #[test]
1416 fn send_webhook_missing_host_returns_error() {
1417 let config = WebhookConfig {
1418 url: "http://".to_string(),
1419 timeout_secs: 1,
1420 ..Default::default()
1421 };
1422 let payload = WebhookPayload {
1423 message: "test".to_string(),
1424 ..Default::default()
1425 };
1426 assert!(send_webhook(&config, &payload).is_err());
1427 }
1428
1429 #[test]
1430 fn send_webhook_just_scheme_returns_error() {
1431 let config = WebhookConfig {
1432 url: "https".to_string(),
1433 timeout_secs: 1,
1434 ..Default::default()
1435 };
1436 let payload = WebhookPayload {
1437 message: "test".to_string(),
1438 ..Default::default()
1439 };
1440 assert!(send_webhook(&config, &payload).is_err());
1441 }
1442
1443 #[test]
1444 fn send_webhook_whitespace_url_returns_error() {
1445 let config = WebhookConfig {
1446 url: " ".to_string(),
1447 timeout_secs: 1,
1448 ..Default::default()
1449 };
1450 let payload = WebhookPayload {
1451 message: "test".to_string(),
1452 ..Default::default()
1453 };
1454 assert!(send_webhook(&config, &payload).is_err());
1455 }
1456
1457 #[tokio::test]
1458 async fn send_webhook_async_empty_url_returns_error() {
1459 let config = WebhookConfig {
1460 url: String::new(),
1461 timeout_secs: 1,
1462 ..Default::default()
1463 };
1464 let payload = WebhookPayload {
1465 message: "test".to_string(),
1466 ..Default::default()
1467 };
1468 assert!(send_webhook_async(&config, &payload).await.is_err());
1469 }
1470
1471 #[test]
1474 fn send_webhook_timeout_with_slow_server() {
1475 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1476 let addr = server.server_addr().to_ip().unwrap();
1477
1478 let config = WebhookConfig {
1479 url: format!("http://{addr}/slow"),
1480 timeout_secs: 1,
1481 ..Default::default()
1482 };
1483 let payload = WebhookPayload {
1484 message: "timeout test".to_string(),
1485 ..Default::default()
1486 };
1487
1488 let handle = std::thread::spawn(move || {
1489 let req = server.recv().unwrap();
1490 std::thread::sleep(Duration::from_secs(3));
1492 let response = tiny_http::Response::from_string("too late");
1493 let _ = req.respond(response);
1494 });
1495
1496 let result = send_webhook(&config, &payload);
1497 assert!(result.is_err());
1498 let err_msg = format!("{:#}", result.unwrap_err());
1499 assert!(
1500 err_msg.contains("timed out")
1501 || err_msg.contains("timeout")
1502 || err_msg.contains("Timeout")
1503 || err_msg.contains("operation")
1504 );
1505 handle.join().unwrap();
1506 }
1507
1508 #[tokio::test]
1509 async fn send_webhook_async_timeout_with_slow_server() {
1510 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1511 let addr = server.server_addr().to_ip().unwrap();
1512
1513 let config = WebhookConfig {
1514 url: format!("http://{addr}/slow"),
1515 timeout_secs: 1,
1516 ..Default::default()
1517 };
1518 let payload = WebhookPayload {
1519 message: "async timeout test".to_string(),
1520 ..Default::default()
1521 };
1522
1523 let handle = std::thread::spawn(move || {
1524 let req = server.recv().unwrap();
1525 std::thread::sleep(Duration::from_secs(3));
1526 let _ = req.respond(tiny_http::Response::from_string("too late"));
1527 });
1528
1529 let result = send_webhook_async(&config, &payload).await;
1530 assert!(result.is_err());
1531 handle.join().unwrap();
1532 }
1533
1534 #[test]
1537 fn send_webhook_large_payload_body() {
1538 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1539 let addr = server.server_addr().to_ip().unwrap();
1540
1541 let config = WebhookConfig {
1542 url: format!("http://{addr}/large"),
1543 timeout_secs: 10,
1544 ..Default::default()
1545 };
1546
1547 let large_message = "x".repeat(110_000);
1549 let payload = WebhookPayload {
1550 message: large_message.clone(),
1551 success: true,
1552 ..Default::default()
1553 };
1554
1555 let handle = std::thread::spawn(move || {
1556 let mut req = server.recv().unwrap();
1557 let mut body = String::new();
1558 req.as_reader().read_to_string(&mut body).unwrap();
1559 assert!(body.len() > 100_000);
1560 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
1561 assert_eq!(parsed["message"].as_str().unwrap().len(), 110_000);
1562 req.respond(tiny_http::Response::from_string("ok")).unwrap();
1563 });
1564
1565 let result = send_webhook(&config, &payload);
1566 handle.join().unwrap();
1567 assert!(result.is_ok());
1568 }
1569
1570 #[test]
1571 fn large_payload_serializes_correctly() {
1572 let large_message = "a".repeat(150_000);
1573 let payload = WebhookPayload {
1574 message: large_message.clone(),
1575 success: true,
1576 ..Default::default()
1577 };
1578 let json = serde_json::to_string(&payload).unwrap();
1579 assert!(json.len() > 100_000);
1580 let rt: WebhookPayload = serde_json::from_str(&json).unwrap();
1581 assert_eq!(rt.message.len(), 150_000);
1582 }
1583
1584 #[test]
1587 fn payload_with_unicode_message() {
1588 let payload = WebhookPayload {
1589 message: "パッケージ公開成功 🎉".to_string(),
1590 title: Some("リリース通知".to_string()),
1591 success: true,
1592 package: Some("日本語パッケージ".to_string()),
1593 version: Some("1.0.0".to_string()),
1594 ..Default::default()
1595 };
1596 let json = serde_json::to_string(&payload).unwrap();
1597 let rt: WebhookPayload = serde_json::from_str(&json).unwrap();
1598 assert_eq!(rt.message, "パッケージ公開成功 🎉");
1599 assert_eq!(rt.title.as_deref(), Some("リリース通知"));
1600 assert_eq!(rt.package.as_deref(), Some("日本語パッケージ"));
1601 }
1602
1603 #[test]
1604 fn slack_payload_with_unicode() {
1605 let payload = WebhookPayload {
1606 message: "Émojis: 🚀🦀✅".to_string(),
1607 title: Some("Ünïcödé Tïtlé".to_string()),
1608 success: true,
1609 package: Some("crâte-ñame".to_string()),
1610 ..Default::default()
1611 };
1612 let json = slack_payload(&payload).unwrap();
1613 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1614 assert_eq!(parsed["attachments"][0]["text"], "Émojis: 🚀🦀✅");
1615 assert_eq!(parsed["attachments"][0]["title"], "Ünïcödé Tïtlé");
1616 }
1617
1618 #[test]
1619 fn discord_payload_with_unicode() {
1620 let payload = WebhookPayload {
1621 message: "已发布 📦".to_string(),
1622 title: Some("发布通知".to_string()),
1623 success: false,
1624 error: Some("сетевая ошибка".to_string()),
1625 ..Default::default()
1626 };
1627 let json = discord_payload(&payload).unwrap();
1628 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1629 assert_eq!(parsed["embeds"][0]["description"], "已发布 📦");
1630 assert_eq!(parsed["embeds"][0]["title"], "发布通知");
1631 }
1632
1633 #[test]
1634 fn unicode_in_webhook_to_server() {
1635 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1636 let addr = server.server_addr().to_ip().unwrap();
1637
1638 let config = WebhookConfig {
1639 url: format!("http://{addr}/unicode"),
1640 timeout_secs: 5,
1641 ..Default::default()
1642 };
1643 let payload = WebhookPayload {
1644 message: "🦀 Rust crate published! 日本語テスト".to_string(),
1645 success: true,
1646 ..Default::default()
1647 };
1648
1649 let handle = std::thread::spawn(move || {
1650 let mut req = server.recv().unwrap();
1651 let mut body = String::new();
1652 req.as_reader().read_to_string(&mut body).unwrap();
1653 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
1654 let msg = parsed["message"].as_str().unwrap();
1655 assert!(msg.contains("🦀"));
1656 assert!(msg.contains("日本語テスト"));
1657 req.respond(tiny_http::Response::from_string("ok")).unwrap();
1658 });
1659
1660 let result = send_webhook(&config, &payload);
1661 handle.join().unwrap();
1662 assert!(result.is_ok());
1663 }
1664
1665 #[test]
1668 fn concurrent_webhook_sends() {
1669 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1670 let addr = server.server_addr().to_ip().unwrap();
1671 let server = std::sync::Arc::new(server);
1672
1673 let num_requests = 5;
1674
1675 let mut server_handles = vec![];
1677 for _ in 0..num_requests {
1678 let srv = server.clone();
1679 server_handles.push(std::thread::spawn(move || {
1680 let req = srv.recv().unwrap();
1681 req.respond(tiny_http::Response::from_string("ok")).unwrap();
1682 }));
1683 }
1684
1685 let mut client_handles = vec![];
1687 for i in 0..num_requests {
1688 let url = format!("http://{addr}/concurrent");
1689 client_handles.push(std::thread::spawn(move || {
1690 let config = WebhookConfig {
1691 url,
1692 timeout_secs: 10,
1693 ..Default::default()
1694 };
1695 let payload = WebhookPayload {
1696 message: format!("concurrent message {i}"),
1697 success: true,
1698 ..Default::default()
1699 };
1700 send_webhook(&config, &payload)
1701 }));
1702 }
1703
1704 for h in client_handles {
1705 let result = h.join().unwrap();
1706 assert!(result.is_ok());
1707 }
1708 for h in server_handles {
1709 h.join().unwrap();
1710 }
1711 }
1712
1713 #[test]
1716 fn send_webhook_to_closed_port_fails_fast() {
1717 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1719 let addr = server.server_addr().to_ip().unwrap();
1720 drop(server); let config = WebhookConfig {
1723 url: format!("http://{addr}/gone"),
1724 timeout_secs: 2,
1725 ..Default::default()
1726 };
1727 let payload = WebhookPayload {
1728 message: "should fail".to_string(),
1729 ..Default::default()
1730 };
1731
1732 let start = std::time::Instant::now();
1733 let result = send_webhook(&config, &payload);
1734 let elapsed = start.elapsed();
1735
1736 assert!(result.is_err());
1737 assert!(elapsed < Duration::from_secs(10));
1739 }
1740
1741 #[test]
1742 fn multiple_sends_to_unreachable_all_fail() {
1743 let results: Vec<_> = (0..3)
1744 .map(|_| {
1745 let config = WebhookConfig {
1746 url: "http://127.0.0.1:1/unreachable".to_string(),
1747 timeout_secs: 1,
1748 ..Default::default()
1749 };
1750 let payload = WebhookPayload {
1751 message: "fail".to_string(),
1752 ..Default::default()
1753 };
1754 send_webhook(&config, &payload)
1755 })
1756 .collect();
1757
1758 assert!(results.iter().all(|r| r.is_err()));
1759 }
1760
1761 #[test]
1764 fn http_status_200_is_success() {
1765 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1766 let addr = server.server_addr().to_ip().unwrap();
1767
1768 let config = WebhookConfig {
1769 url: format!("http://{addr}/ok"),
1770 timeout_secs: 5,
1771 ..Default::default()
1772 };
1773 let payload = WebhookPayload {
1774 message: "test".to_string(),
1775 ..Default::default()
1776 };
1777
1778 let handle = std::thread::spawn(move || {
1779 let req = server.recv().unwrap();
1780 req.respond(
1781 tiny_http::Response::from_string("ok")
1782 .with_status_code(tiny_http::StatusCode(200)),
1783 )
1784 .unwrap();
1785 });
1786
1787 assert!(send_webhook(&config, &payload).is_ok());
1788 handle.join().unwrap();
1789 }
1790
1791 #[test]
1792 fn http_status_201_is_success() {
1793 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1794 let addr = server.server_addr().to_ip().unwrap();
1795
1796 let config = WebhookConfig {
1797 url: format!("http://{addr}/created"),
1798 timeout_secs: 5,
1799 ..Default::default()
1800 };
1801 let payload = WebhookPayload {
1802 message: "test".to_string(),
1803 ..Default::default()
1804 };
1805
1806 let handle = std::thread::spawn(move || {
1807 let req = server.recv().unwrap();
1808 req.respond(
1809 tiny_http::Response::from_string("created")
1810 .with_status_code(tiny_http::StatusCode(201)),
1811 )
1812 .unwrap();
1813 });
1814
1815 assert!(send_webhook(&config, &payload).is_ok());
1816 handle.join().unwrap();
1817 }
1818
1819 #[test]
1820 fn http_status_204_is_success() {
1821 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1822 let addr = server.server_addr().to_ip().unwrap();
1823
1824 let config = WebhookConfig {
1825 url: format!("http://{addr}/nocontent"),
1826 timeout_secs: 5,
1827 ..Default::default()
1828 };
1829 let payload = WebhookPayload {
1830 message: "test".to_string(),
1831 ..Default::default()
1832 };
1833
1834 let handle = std::thread::spawn(move || {
1835 let req = server.recv().unwrap();
1836 req.respond(
1837 tiny_http::Response::from_string("")
1838 .with_status_code(tiny_http::StatusCode(204)),
1839 )
1840 .unwrap();
1841 });
1842
1843 assert!(send_webhook(&config, &payload).is_ok());
1844 handle.join().unwrap();
1845 }
1846
1847 #[test]
1848 fn http_status_400_is_error() {
1849 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1850 let addr = server.server_addr().to_ip().unwrap();
1851
1852 let config = WebhookConfig {
1853 url: format!("http://{addr}/bad"),
1854 timeout_secs: 5,
1855 ..Default::default()
1856 };
1857 let payload = WebhookPayload {
1858 message: "test".to_string(),
1859 ..Default::default()
1860 };
1861
1862 let handle = std::thread::spawn(move || {
1863 let req = server.recv().unwrap();
1864 req.respond(
1865 tiny_http::Response::from_string("bad request")
1866 .with_status_code(tiny_http::StatusCode(400)),
1867 )
1868 .unwrap();
1869 });
1870
1871 let result = send_webhook(&config, &payload);
1872 handle.join().unwrap();
1873 assert!(result.is_err());
1874 assert!(result.unwrap_err().to_string().contains("400"));
1875 }
1876
1877 #[test]
1878 fn http_status_401_is_error() {
1879 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1880 let addr = server.server_addr().to_ip().unwrap();
1881
1882 let config = WebhookConfig {
1883 url: format!("http://{addr}/unauth"),
1884 timeout_secs: 5,
1885 ..Default::default()
1886 };
1887 let payload = WebhookPayload {
1888 message: "test".to_string(),
1889 ..Default::default()
1890 };
1891
1892 let handle = std::thread::spawn(move || {
1893 let req = server.recv().unwrap();
1894 req.respond(
1895 tiny_http::Response::from_string("unauthorized")
1896 .with_status_code(tiny_http::StatusCode(401)),
1897 )
1898 .unwrap();
1899 });
1900
1901 let result = send_webhook(&config, &payload);
1902 handle.join().unwrap();
1903 assert!(result.is_err());
1904 assert!(result.unwrap_err().to_string().contains("401"));
1905 }
1906
1907 #[test]
1908 fn http_status_404_is_error() {
1909 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1910 let addr = server.server_addr().to_ip().unwrap();
1911
1912 let config = WebhookConfig {
1913 url: format!("http://{addr}/notfound"),
1914 timeout_secs: 5,
1915 ..Default::default()
1916 };
1917 let payload = WebhookPayload {
1918 message: "test".to_string(),
1919 ..Default::default()
1920 };
1921
1922 let handle = std::thread::spawn(move || {
1923 let req = server.recv().unwrap();
1924 req.respond(
1925 tiny_http::Response::from_string("not found")
1926 .with_status_code(tiny_http::StatusCode(404)),
1927 )
1928 .unwrap();
1929 });
1930
1931 let result = send_webhook(&config, &payload);
1932 handle.join().unwrap();
1933 assert!(result.is_err());
1934 assert!(result.unwrap_err().to_string().contains("404"));
1935 }
1936
1937 #[test]
1938 fn http_status_429_is_error() {
1939 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1940 let addr = server.server_addr().to_ip().unwrap();
1941
1942 let config = WebhookConfig {
1943 url: format!("http://{addr}/ratelimit"),
1944 timeout_secs: 5,
1945 ..Default::default()
1946 };
1947 let payload = WebhookPayload {
1948 message: "test".to_string(),
1949 ..Default::default()
1950 };
1951
1952 let handle = std::thread::spawn(move || {
1953 let req = server.recv().unwrap();
1954 req.respond(
1955 tiny_http::Response::from_string("rate limited")
1956 .with_status_code(tiny_http::StatusCode(429)),
1957 )
1958 .unwrap();
1959 });
1960
1961 let result = send_webhook(&config, &payload);
1962 handle.join().unwrap();
1963 assert!(result.is_err());
1964 assert!(result.unwrap_err().to_string().contains("429"));
1965 }
1966
1967 #[test]
1968 fn http_status_502_is_error() {
1969 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1970 let addr = server.server_addr().to_ip().unwrap();
1971
1972 let config = WebhookConfig {
1973 url: format!("http://{addr}/badgw"),
1974 timeout_secs: 5,
1975 ..Default::default()
1976 };
1977 let payload = WebhookPayload {
1978 message: "test".to_string(),
1979 ..Default::default()
1980 };
1981
1982 let handle = std::thread::spawn(move || {
1983 let req = server.recv().unwrap();
1984 req.respond(
1985 tiny_http::Response::from_string("bad gateway")
1986 .with_status_code(tiny_http::StatusCode(502)),
1987 )
1988 .unwrap();
1989 });
1990
1991 let result = send_webhook(&config, &payload);
1992 handle.join().unwrap();
1993 assert!(result.is_err());
1994 assert!(result.unwrap_err().to_string().contains("502"));
1995 }
1996
1997 #[test]
1998 fn http_status_503_is_error() {
1999 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2000 let addr = server.server_addr().to_ip().unwrap();
2001
2002 let config = WebhookConfig {
2003 url: format!("http://{addr}/unavail"),
2004 timeout_secs: 5,
2005 ..Default::default()
2006 };
2007 let payload = WebhookPayload {
2008 message: "test".to_string(),
2009 ..Default::default()
2010 };
2011
2012 let handle = std::thread::spawn(move || {
2013 let req = server.recv().unwrap();
2014 req.respond(
2015 tiny_http::Response::from_string("service unavailable")
2016 .with_status_code(tiny_http::StatusCode(503)),
2017 )
2018 .unwrap();
2019 });
2020
2021 let result = send_webhook(&config, &payload);
2022 handle.join().unwrap();
2023 assert!(result.is_err());
2024 assert!(result.unwrap_err().to_string().contains("503"));
2025 }
2026
2027 #[tokio::test]
2028 async fn async_http_status_4xx_is_error() {
2029 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2030 let addr = server.server_addr().to_ip().unwrap();
2031
2032 let config = WebhookConfig {
2033 url: format!("http://{addr}/bad"),
2034 timeout_secs: 5,
2035 ..Default::default()
2036 };
2037 let payload = WebhookPayload {
2038 message: "test".to_string(),
2039 ..Default::default()
2040 };
2041
2042 let handle = std::thread::spawn(move || {
2043 let req = server.recv().unwrap();
2044 req.respond(
2045 tiny_http::Response::from_string("bad request")
2046 .with_status_code(tiny_http::StatusCode(422)),
2047 )
2048 .unwrap();
2049 });
2050
2051 let result = send_webhook_async(&config, &payload).await;
2052 handle.join().unwrap();
2053 assert!(result.is_err());
2054 assert!(result.unwrap_err().to_string().contains("422"));
2055 }
2056
2057 #[tokio::test]
2058 async fn async_http_status_5xx_is_error() {
2059 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2060 let addr = server.server_addr().to_ip().unwrap();
2061
2062 let config = WebhookConfig {
2063 url: format!("http://{addr}/err"),
2064 timeout_secs: 5,
2065 ..Default::default()
2066 };
2067 let payload = WebhookPayload {
2068 message: "test".to_string(),
2069 ..Default::default()
2070 };
2071
2072 let handle = std::thread::spawn(move || {
2073 let req = server.recv().unwrap();
2074 req.respond(
2075 tiny_http::Response::from_string("error")
2076 .with_status_code(tiny_http::StatusCode(500)),
2077 )
2078 .unwrap();
2079 });
2080
2081 let result = send_webhook_async(&config, &payload).await;
2082 handle.join().unwrap();
2083 assert!(result.is_err());
2084 assert!(result.unwrap_err().to_string().contains("500"));
2085 }
2086 }
2087
2088 mod snapshot_edge_cases {
2091 use super::*;
2092
2093 #[test]
2094 fn config_generic_with_secret_snapshot() {
2095 let config = WebhookConfig {
2096 url: "https://example.com/hook".to_string(),
2097 webhook_type: WebhookType::Generic,
2098 secret: Some("top-secret".to_string()),
2099 timeout_secs: 15,
2100 };
2101 insta::assert_debug_snapshot!("config_generic_with_secret", config);
2102 }
2103
2104 #[test]
2105 fn config_slack_no_secret_snapshot() {
2106 let config = WebhookConfig {
2107 url: "https://hooks.slack.com/services/T/B/x".to_string(),
2108 webhook_type: WebhookType::Slack,
2109 secret: None,
2110 timeout_secs: 30,
2111 };
2112 insta::assert_debug_snapshot!("config_slack_no_secret", config);
2113 }
2114
2115 #[test]
2116 fn config_discord_with_secret_snapshot() {
2117 let config = WebhookConfig {
2118 url: "https://discord.com/api/webhooks/123/tok".to_string(),
2119 webhook_type: WebhookType::Discord,
2120 secret: Some("discord-secret".to_string()),
2121 timeout_secs: 45,
2122 };
2123 insta::assert_debug_snapshot!("config_discord_with_secret", config);
2124 }
2125
2126 #[test]
2127 fn config_default_snapshot() {
2128 let config = WebhookConfig::default();
2129 insta::assert_debug_snapshot!("config_default_all_fields", config);
2130 }
2131
2132 #[test]
2133 fn config_minimal_timeout_snapshot() {
2134 let config = WebhookConfig {
2135 url: "http://localhost:8080/webhook".to_string(),
2136 webhook_type: WebhookType::Generic,
2137 secret: None,
2138 timeout_secs: 1,
2139 };
2140 insta::assert_debug_snapshot!("config_minimal_timeout", config);
2141 }
2142
2143 #[test]
2144 fn payload_unicode_snapshot() {
2145 let payload = WebhookPayload {
2146 message: "パッケージ公開 🚀".to_string(),
2147 title: Some("リリース".to_string()),
2148 success: true,
2149 package: Some("日本語crate".to_string()),
2150 version: Some("1.0.0".to_string()),
2151 ..Default::default()
2152 };
2153 insta::assert_debug_snapshot!("payload_unicode", payload);
2154 }
2155
2156 #[test]
2157 fn payload_error_with_details_snapshot() {
2158 let payload = publish_failure_payload(
2159 "my-crate",
2160 "2.0.0",
2161 "connection refused: server at registry.example.com:443 not reachable",
2162 );
2163 insta::assert_debug_snapshot!("payload_error_with_details", payload);
2164 }
2165
2166 #[test]
2167 fn payload_with_extra_fields_snapshot() {
2168 let mut extra = std::collections::BTreeMap::new();
2169 extra.insert("ci_provider".to_string(), serde_json::json!("github"));
2170 extra.insert("run_number".to_string(), serde_json::json!(42));
2171 extra.insert("branch".to_string(), serde_json::json!("main"));
2172
2173 let payload = WebhookPayload {
2174 message: "Published with extras".to_string(),
2175 title: Some("CI Publish".to_string()),
2176 success: true,
2177 package: Some("my-crate".to_string()),
2178 version: Some("3.0.0".to_string()),
2179 registry: Some("crates-io".to_string()),
2180 error: None,
2181 extra,
2182 };
2183 insta::assert_debug_snapshot!("payload_with_extra_ci_fields", payload);
2184 }
2185
2186 #[test]
2187 fn slack_unicode_payload_snapshot() {
2188 let payload = WebhookPayload {
2189 message: "🎉 Published crâte-ñame".to_string(),
2190 success: true,
2191 package: Some("crâte-ñame".to_string()),
2192 version: Some("1.0.0".to_string()),
2193 ..Default::default()
2194 };
2195 let body = slack_payload(&payload).unwrap();
2196 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
2197 insta::assert_debug_snapshot!("slack_unicode_payload", json);
2198 }
2199
2200 #[test]
2201 fn discord_unicode_payload_snapshot() {
2202 let payload = WebhookPayload {
2203 message: "🦀 Опубликовано".to_string(),
2204 success: false,
2205 error: Some("сетевая ошибка".to_string()),
2206 ..Default::default()
2207 };
2208 let body = discord_payload(&payload).unwrap();
2209 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
2210 insta::assert_debug_snapshot!("discord_unicode_payload", json);
2211 }
2212 }
2213
2214 #[test]
2217 fn generic_payload_all_optional_fields_present_in_json() {
2218 let payload = WebhookPayload {
2219 message: "msg".to_string(),
2220 title: Some("title".to_string()),
2221 success: true,
2222 package: Some("my-crate".to_string()),
2223 version: Some("1.2.3".to_string()),
2224 registry: Some("crates-io".to_string()),
2225 error: Some("some error".to_string()),
2226 extra: std::collections::BTreeMap::new(),
2227 };
2228 let json = serde_json::to_string(&payload).unwrap();
2229 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2230 let obj = parsed.as_object().unwrap();
2231 assert!(obj.contains_key("title"));
2232 assert!(obj.contains_key("package"));
2233 assert!(obj.contains_key("version"));
2234 assert!(obj.contains_key("registry"));
2235 assert!(obj.contains_key("error"));
2236 assert_eq!(obj["title"], "title");
2237 assert_eq!(obj["package"], "my-crate");
2238 assert_eq!(obj["version"], "1.2.3");
2239 assert_eq!(obj["registry"], "crates-io");
2240 assert_eq!(obj["error"], "some error");
2241 }
2242
2243 #[test]
2244 fn slack_fields_count_matches_present_optional_fields() {
2245 let payload = WebhookPayload {
2246 message: "m".to_string(),
2247 success: true,
2248 package: Some("pkg".to_string()),
2249 ..Default::default()
2250 };
2251 let json = slack_payload(&payload).unwrap();
2252 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2253 let fields = parsed["attachments"][0]["fields"].as_array().unwrap();
2254 assert_eq!(fields.len(), 1);
2255 assert_eq!(fields[0]["title"], "Package");
2256 assert_eq!(fields[0]["value"], "pkg");
2257 }
2258
2259 #[test]
2260 fn discord_fields_count_matches_present_optional_fields() {
2261 let payload = WebhookPayload {
2262 message: "m".to_string(),
2263 success: false,
2264 version: Some("0.1.0".to_string()),
2265 error: Some("fail".to_string()),
2266 ..Default::default()
2267 };
2268 let json = discord_payload(&payload).unwrap();
2269 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2270 let fields = parsed["embeds"][0]["fields"].as_array().unwrap();
2271 assert_eq!(fields.len(), 2);
2272 assert_eq!(fields[0]["name"], "Version");
2273 assert_eq!(fields[1]["name"], "Error");
2274 }
2275
2276 #[test]
2279 fn send_webhook_posts_content_type_json() {
2280 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2281 let addr = server.server_addr().to_ip().unwrap();
2282
2283 let config = WebhookConfig {
2284 url: format!("http://{addr}/ct"),
2285 timeout_secs: 5,
2286 ..Default::default()
2287 };
2288 let payload = WebhookPayload {
2289 message: "test".to_string(),
2290 ..Default::default()
2291 };
2292
2293 let handle = std::thread::spawn(move || {
2294 let req = server.recv().unwrap();
2295 let ct = req
2296 .headers()
2297 .iter()
2298 .find(|h| {
2299 h.field
2300 .as_str()
2301 .as_str()
2302 .eq_ignore_ascii_case("content-type")
2303 })
2304 .expect("content-type header missing");
2305 assert_eq!(ct.value.as_str(), "application/json");
2306 req.respond(tiny_http::Response::from_string("ok")).unwrap();
2307 });
2308
2309 send_webhook(&config, &payload).unwrap();
2310 handle.join().unwrap();
2311 }
2312
2313 #[test]
2314 fn send_webhook_5xx_error_message_includes_body() {
2315 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2316 let addr = server.server_addr().to_ip().unwrap();
2317
2318 let config = WebhookConfig {
2319 url: format!("http://{addr}/5xx"),
2320 timeout_secs: 5,
2321 ..Default::default()
2322 };
2323 let payload = WebhookPayload {
2324 message: "test".to_string(),
2325 ..Default::default()
2326 };
2327
2328 let handle = std::thread::spawn(move || {
2329 let req = server.recv().unwrap();
2330 req.respond(
2331 tiny_http::Response::from_string("internal server error detail xyz")
2332 .with_status_code(tiny_http::StatusCode(500)),
2333 )
2334 .unwrap();
2335 });
2336
2337 let result = send_webhook(&config, &payload);
2338 handle.join().unwrap();
2339 let err = result.unwrap_err().to_string();
2340 assert!(err.contains("500"));
2341 assert!(err.contains("internal server error detail xyz"));
2342 }
2343
2344 #[test]
2345 fn send_webhook_4xx_error_message_includes_body() {
2346 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2347 let addr = server.server_addr().to_ip().unwrap();
2348
2349 let config = WebhookConfig {
2350 url: format!("http://{addr}/4xx"),
2351 timeout_secs: 5,
2352 ..Default::default()
2353 };
2354 let payload = WebhookPayload {
2355 message: "test".to_string(),
2356 ..Default::default()
2357 };
2358
2359 let handle = std::thread::spawn(move || {
2360 let req = server.recv().unwrap();
2361 req.respond(
2362 tiny_http::Response::from_string("invalid webhook payload format")
2363 .with_status_code(tiny_http::StatusCode(422)),
2364 )
2365 .unwrap();
2366 });
2367
2368 let result = send_webhook(&config, &payload);
2369 handle.join().unwrap();
2370 let err = result.unwrap_err().to_string();
2371 assert!(err.contains("422"));
2372 assert!(err.contains("invalid webhook payload format"));
2373 }
2374
2375 #[tokio::test]
2376 async fn send_webhook_async_with_signature() {
2377 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2378 let addr = server.server_addr().to_ip().unwrap();
2379
2380 let config = WebhookConfig {
2381 url: format!("http://{addr}/async-sig"),
2382 webhook_type: WebhookType::Generic,
2383 timeout_secs: 5,
2384 secret: Some("async-secret".to_string()),
2385 };
2386 let payload = WebhookPayload {
2387 message: "async signed".to_string(),
2388 ..Default::default()
2389 };
2390
2391 let handle = std::thread::spawn(move || {
2392 let req = server.recv().unwrap();
2393 let sig_header = req
2394 .headers()
2395 .iter()
2396 .find(|h| {
2397 h.field
2398 .as_str()
2399 .as_str()
2400 .eq_ignore_ascii_case("x-hub-signature-256")
2401 })
2402 .expect("signature header missing on async path");
2403 assert!(sig_header.value.as_str().starts_with("sha256="));
2404 req.respond(tiny_http::Response::from_string("ok")).unwrap();
2405 });
2406
2407 send_webhook_async(&config, &payload).await.unwrap();
2408 handle.join().unwrap();
2409 }
2410
2411 #[test]
2414 fn signature_verified_against_independent_hmac() {
2415 let secret = "verification-secret";
2416 let body = r#"{"message":"hello","success":true}"#;
2417 let sig = webhook_signature(secret, body).unwrap();
2418 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
2420 mac.update(body.as_bytes());
2421 let expected = format!("sha256={}", hex_encode(&mac.finalize().into_bytes()));
2422 assert_eq!(sig, expected);
2423 }
2424
2425 #[test]
2426 fn empty_string_secret_skips_signature() {
2427 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2428 let addr = server.server_addr().to_ip().unwrap();
2429
2430 let config = WebhookConfig {
2431 url: format!("http://{addr}/empty-secret"),
2432 timeout_secs: 5,
2433 secret: Some(String::new()),
2434 ..Default::default()
2435 };
2436 let payload = WebhookPayload {
2437 message: "test".to_string(),
2438 ..Default::default()
2439 };
2440
2441 let handle = std::thread::spawn(move || {
2442 let req = server.recv().unwrap();
2443 assert!(req.headers().iter().all(|h| {
2444 !h.field
2445 .as_str()
2446 .as_str()
2447 .eq_ignore_ascii_case("x-hub-signature-256")
2448 }));
2449 req.respond(tiny_http::Response::from_string("ok")).unwrap();
2450 });
2451
2452 send_webhook(&config, &payload).unwrap();
2453 handle.join().unwrap();
2454 }
2455
2456 #[test]
2457 fn signature_server_can_verify_received_body() {
2458 let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
2459 let addr = server.server_addr().to_ip().unwrap();
2460 let secret = "server-verify-secret";
2461
2462 let config = WebhookConfig {
2463 url: format!("http://{addr}/verify"),
2464 timeout_secs: 5,
2465 secret: Some(secret.to_string()),
2466 ..Default::default()
2467 };
2468 let payload = WebhookPayload {
2469 message: "verifiable".to_string(),
2470 success: true,
2471 ..Default::default()
2472 };
2473
2474 let handle = std::thread::spawn(move || {
2475 let mut req = server.recv().unwrap();
2476 let sig_header = req
2477 .headers()
2478 .iter()
2479 .find(|h| {
2480 h.field
2481 .as_str()
2482 .as_str()
2483 .eq_ignore_ascii_case("x-hub-signature-256")
2484 })
2485 .expect("missing signature")
2486 .value
2487 .as_str()
2488 .to_string();
2489 let mut body = String::new();
2490 req.as_reader().read_to_string(&mut body).unwrap();
2491 let expected = webhook_signature(secret, &body).unwrap();
2493 assert_eq!(sig_header, expected, "server-side HMAC verification failed");
2494 req.respond(tiny_http::Response::from_string("ok")).unwrap();
2495 });
2496
2497 send_webhook(&config, &payload).unwrap();
2498 handle.join().unwrap();
2499 }
2500
2501 #[test]
2504 fn payload_with_special_chars_roundtrips() {
2505 let payload = WebhookPayload {
2506 message: "line1\nline2\ttab \"quoted\" \\backslash".to_string(),
2507 title: Some("title with 'quotes'".to_string()),
2508 success: false,
2509 error: Some("error: unexpected <token> & more".to_string()),
2510 ..Default::default()
2511 };
2512 let json = serde_json::to_string(&payload).unwrap();
2513 let rt: WebhookPayload = serde_json::from_str(&json).unwrap();
2514 assert_eq!(rt.message, payload.message);
2515 assert_eq!(rt.error, payload.error);
2516 }
2517
2518 #[test]
2519 fn slack_payload_with_newlines_in_message() {
2520 let payload = WebhookPayload {
2521 message: "line1\nline2\nline3".to_string(),
2522 success: true,
2523 ..Default::default()
2524 };
2525 let json = slack_payload(&payload).unwrap();
2526 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2527 assert_eq!(parsed["attachments"][0]["text"], "line1\nline2\nline3");
2528 }
2529
2530 #[test]
2531 fn discord_payload_with_long_error() {
2532 let long_error = "e".repeat(5000);
2533 let payload = WebhookPayload {
2534 message: "fail".to_string(),
2535 success: false,
2536 error: Some(long_error),
2537 ..Default::default()
2538 };
2539 let json = discord_payload(&payload).unwrap();
2540 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2541 let err_field = &parsed["embeds"][0]["fields"][0];
2542 assert_eq!(err_field["name"], "Error");
2543 assert_eq!(err_field["value"].as_str().unwrap().len(), 5000);
2544 }
2545
2546 #[test]
2547 fn config_zero_timeout_still_creates_client() {
2548 let config = WebhookConfig {
2549 url: "http://127.0.0.1:1/zero-timeout".to_string(),
2550 timeout_secs: 0,
2551 ..Default::default()
2552 };
2553 let payload = WebhookPayload {
2554 message: "test".to_string(),
2555 ..Default::default()
2556 };
2557 let _ = send_webhook(&config, &payload);
2559 }
2560
2561 mod hardened_snapshots {
2564 use super::*;
2565
2566 #[test]
2567 fn generic_payload_all_fields_populated() {
2568 let mut extra = std::collections::BTreeMap::new();
2569 extra.insert("commit_sha".to_string(), serde_json::json!("abc123"));
2570 extra.insert("pipeline".to_string(), serde_json::json!("release"));
2571
2572 let payload = WebhookPayload {
2573 message: "Published my-crate@2.0.0 to crates.io".to_string(),
2574 title: Some("Workspace Publish Complete".to_string()),
2575 success: true,
2576 package: Some("my-crate".to_string()),
2577 version: Some("2.0.0".to_string()),
2578 registry: Some("crates-io".to_string()),
2579 error: None,
2580 extra,
2581 };
2582 let json: serde_json::Value = serde_json::to_value(&payload).unwrap();
2583 insta::assert_yaml_snapshot!("hardened_generic_all_fields", json);
2584 }
2585
2586 #[test]
2587 fn slack_failure_with_multiline_error() {
2588 let payload = WebhookPayload {
2589 message: "Publish failed for my-crate@1.0.0".to_string(),
2590 title: Some("Publish Failed".to_string()),
2591 success: false,
2592 package: Some("my-crate".to_string()),
2593 version: Some("1.0.0".to_string()),
2594 error: Some("error[E0433]: failed to resolve\n --> src/lib.rs:1:5\n |\n1 | use foo::bar;\n | ^^^ not found".to_string()),
2595 ..Default::default()
2596 };
2597 let body = slack_payload(&payload).unwrap();
2598 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
2599 insta::assert_yaml_snapshot!("hardened_slack_multiline_error", json);
2600 }
2601
2602 #[test]
2603 fn discord_progress_event() {
2604 let payload = WebhookPayload {
2605 message: "Publishing 3/5 crates complete".to_string(),
2606 title: Some("Publish Progress".to_string()),
2607 success: true,
2608 ..Default::default()
2609 };
2610 let body = discord_payload(&payload).unwrap();
2611 let json: serde_json::Value = serde_json::from_str(&body).unwrap();
2612 insta::assert_yaml_snapshot!("hardened_discord_progress", json);
2613 }
2614 }
2615
2616 mod hardened_prop {
2619 use super::*;
2620 use proptest::prelude::*;
2621
2622 fn arb_webhook_type() -> impl Strategy<Value = WebhookType> {
2623 prop_oneof![
2624 Just(WebhookType::Generic),
2625 Just(WebhookType::Slack),
2626 Just(WebhookType::Discord),
2627 ]
2628 }
2629
2630 fn arb_payload() -> impl Strategy<Value = WebhookPayload> {
2631 (
2632 ".*",
2633 proptest::option::of(".*"),
2634 any::<bool>(),
2635 proptest::option::of("[a-z][a-z0-9_-]{0,39}"),
2636 proptest::option::of(
2637 "(0|[1-9][0-9]{0,2})\\.(0|[1-9][0-9]{0,2})\\.(0|[1-9][0-9]{0,2})",
2638 ),
2639 proptest::option::of("[a-z-]{1,20}"),
2640 proptest::option::of(".*"),
2641 )
2642 .prop_map(
2643 |(message, title, success, package, version, registry, error)| WebhookPayload {
2644 message,
2645 title,
2646 success,
2647 package,
2648 version,
2649 registry,
2650 error,
2651 extra: std::collections::BTreeMap::new(),
2652 },
2653 )
2654 }
2655
2656 proptest! {
2657 #[test]
2658 fn all_types_produce_valid_json(
2659 wt in arb_webhook_type(),
2660 payload in arb_payload(),
2661 ) {
2662 let body = match wt {
2663 WebhookType::Generic => serde_json::to_string(&payload).unwrap(),
2664 WebhookType::Slack => slack_payload(&payload).unwrap(),
2665 WebhookType::Discord => discord_payload(&payload).unwrap(),
2666 };
2667 let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
2668 prop_assert!(parsed.is_object());
2669 }
2670
2671 #[test]
2672 fn signature_verifiable_roundtrip(
2673 secret in ".{1,64}",
2674 body in ".*",
2675 ) {
2676 let sig = webhook_signature(&secret, &body).unwrap();
2677 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
2678 mac.update(body.as_bytes());
2679 let expected = format!("sha256={}", hex_encode(&mac.finalize().into_bytes()));
2680 prop_assert_eq!(&sig, &expected);
2681 }
2682
2683 #[test]
2684 fn extra_fields_never_shadow_required_keys(
2685 key in "[a-z_]{1,20}",
2686 val in any::<i64>(),
2687 ) {
2688 let mut extra = std::collections::BTreeMap::new();
2689 extra.insert(key.clone(), serde_json::json!(val));
2690 let payload = WebhookPayload {
2691 message: "m".to_string(),
2692 success: true,
2693 extra,
2694 ..Default::default()
2695 };
2696 let json = serde_json::to_string(&payload).unwrap();
2697 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
2698 let obj = parsed.as_object().unwrap();
2699 prop_assert!(obj.contains_key("message"));
2700 prop_assert!(obj.contains_key("success"));
2701 if key != "message"
2702 && key != "success"
2703 && key != "title"
2704 && key != "package"
2705 && key != "version"
2706 && key != "registry"
2707 && key != "error"
2708 {
2709 prop_assert!(obj.contains_key(&key));
2710 }
2711 }
2712 }
2713 }
2714}