Skip to main content

shipper_webhook/
lib.rs

1//! Webhook notifications for shipper.
2//!
3//! This crate provides webhook notification support for publish events,
4//! supporting Slack, Discord, and generic webhooks.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use shipper_webhook::{WebhookConfig, send_webhook, WebhookPayload};
10//!
11//! let config = WebhookConfig {
12//!     url: "https://hooks.slack.com/services/...".to_string(),
13//!     webhook_type: WebhookType::Slack,
14//! };
15//!
16//! let payload = WebhookPayload {
17//!     message: "Published my-crate@1.0.0".to_string(),
18//!     ..Default::default()
19//! };
20//!
21//! send_webhook(&config, &payload).expect("send");
22//! ```
23
24use 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/// Webhook type
33#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
34pub enum WebhookType {
35    /// Generic webhook (POST JSON)
36    #[default]
37    Generic,
38    /// Slack incoming webhook
39    Slack,
40    /// Discord webhook
41    Discord,
42}
43
44/// Webhook configuration
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct WebhookConfig {
47    /// Webhook URL
48    pub url: String,
49    /// Type of webhook
50    #[serde(default)]
51    pub webhook_type: WebhookType,
52    /// Optional secret for payload signing
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub secret: Option<String>,
55    /// Timeout in seconds
56    #[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/// Webhook payload
76#[derive(Debug, Clone, Default, Serialize, Deserialize)]
77pub struct WebhookPayload {
78    /// Main message
79    pub message: String,
80    /// Optional title
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub title: Option<String>,
83    /// Success status
84    pub success: bool,
85    /// Package name (if applicable)
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub package: Option<String>,
88    /// Version (if applicable)
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub version: Option<String>,
91    /// Registry (if applicable)
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub registry: Option<String>,
94    /// Error message (if failed)
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub error: Option<String>,
97    /// Additional fields
98    #[serde(flatten)]
99    pub extra: std::collections::BTreeMap<String, serde_json::Value>,
100}
101
102/// Send a webhook notification
103pub 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
144/// Send a webhook notification asynchronously
145pub 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
205/// Format payload for Slack
206fn 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
255/// Format payload for Discord
256fn 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
309/// Create a success payload for a published package
310pub 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
322/// Create a failure payload for a failed publish
323pub 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    // --- Payload construction ---
448
449    #[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        // Flattened means no "extra" key wrapper
499        assert!(!json.contains("\"extra\""));
500    }
501
502    // --- Serialization round-trips ---
503
504    #[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        // Only url is required; other fields should pick up defaults
535        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    // --- Slack formatting ---
589
590    #[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        // Default title
627        assert_eq!(attachment["title"], "Shipper Notification");
628        let fields = attachment["fields"].as_array().unwrap();
629        assert!(fields.is_empty());
630    }
631
632    // --- Discord formatting ---
633
634    #[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    // --- Signature / HMAC ---
676
677    #[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    // --- Error handling (send_webhook against unreachable endpoints) ---
720
721    #[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        // Port 1 is almost certainly not listening
739        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    // --- Mock server tests ---
768
769    #[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            // Should not have signature header when no secret
787            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()), // whitespace-only
850        };
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            // Whitespace-only secret should NOT produce a signature
859            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            // Slack format has "attachments"
922            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            // Discord format has "embeds"
951            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    // --- Snapshot tests (insta) ---
1015
1016    mod snapshot_tests {
1017        use super::*;
1018
1019        // -- Webhook payload JSON structure for different event types --
1020
1021        #[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        // -- Webhook configuration serialization --
1099
1100        #[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        // -- Error message formatting for delivery failures --
1135
1136        #[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        // -- URL validation error messages --
1159
1160        #[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    // --- Property-based tests (proptest) ---
1192
1193    mod prop {
1194        use super::*;
1195        use proptest::prelude::*;
1196
1197        /// Strategy for generating crate-like package names.
1198        fn package_name() -> impl Strategy<Value = String> {
1199            "[a-z][a-z0-9_-]{0,39}".prop_map(|s| s)
1200        }
1201
1202        /// Strategy for generating semver-like versions.
1203        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        /// Strategy for generating an arbitrary WebhookPayload.
1208        fn arb_payload() -> impl Strategy<Value = WebhookPayload> {
1209            (
1210                ".*",                                   // message
1211                proptest::option::of(".*"),             // title
1212                any::<bool>(),                          // success
1213                proptest::option::of(package_name()),   // package
1214                proptest::option::of(version_string()), // version
1215                proptest::option::of("[a-z-]{1,20}"),   // registry
1216                proptest::option::of(".*"),             // error
1217            )
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            // Payload serialization round-trip with arbitrary names/versions
1234            #[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            // Generic JSON always contains required keys
1248            #[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            // Slack payload is always valid JSON with "attachments" array
1258            #[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                // Color is always "good" or "danger"
1265                let color = att["color"].as_str().unwrap();
1266                prop_assert!(color == "good" || color == "danger");
1267            }
1268
1269            // Discord payload is always valid JSON with "embeds" array
1270            #[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            // publish_success_payload preserves arbitrary names and versions
1281            #[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, &reg);
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            // publish_failure_payload preserves arbitrary names and versions
1297            #[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            // HMAC signature is deterministic and well-formed
1311            #[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                // sha256 hex digest is 64 chars
1321                prop_assert_eq!(s1.len(), "sha256=".len() + 64);
1322            }
1323
1324            // WebhookConfig URL: various patterns never panic during serialization
1325            #[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            // Optional fields are omitted from JSON when None
1339            #[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            // hex_encode produces correct length and only hex chars
1355            #[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            // Webhook URL validation: arbitrary strings never panic in send_webhook
1363            #[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                // Must not panic — error is fine
1375                let _ = send_webhook(&config, &payload);
1376            }
1377        }
1378    }
1379
1380    // --- Edge-case tests ---
1381
1382    mod edge_cases {
1383        use super::*;
1384
1385        // -- Malformed webhook URLs --
1386
1387        #[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        // -- Timeout behavior --
1472
1473        #[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                // Delay longer than the client timeout
1491                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        // -- Large payload body (>100KB) --
1535
1536        #[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            // Build a payload with >100KB message
1548            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        // -- Unicode in webhook event data --
1585
1586        #[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        // -- Concurrent webhook sends --
1666
1667        #[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            // Spawn server handler threads
1676            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            // Spawn concurrent client sends
1686            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        // -- Retry behavior on connection failure --
1714
1715        #[test]
1716        fn send_webhook_to_closed_port_fails_fast() {
1717            // Use an ephemeral server, get its port, then drop it
1718            let server = tiny_http::Server::http("127.0.0.1:0").unwrap();
1719            let addr = server.server_addr().to_ip().unwrap();
1720            drop(server); // port is now closed
1721
1722            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            // Should fail within a reasonable time, not hang
1738            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        // -- HTTP status code handling --
1762
1763        #[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    // --- Snapshot tests for WebhookConfig variants and event payloads ---
2089
2090    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    // --- Hardened tests: payload construction correctness ---
2215
2216    #[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    // --- Hardened tests: HTTP delivery ---
2277
2278    #[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    // --- Hardened tests: authentication / HMAC ---
2412
2413    #[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        // Independently compute HMAC
2419        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            // Recompute HMAC on server side
2492            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    // --- Hardened tests: edge cases ---
2502
2503    #[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        // Should fail (connection refused or timeout), but not panic
2558        let _ = send_webhook(&config, &payload);
2559    }
2560
2561    // --- Hardened snapshot tests ---
2562
2563    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    // --- Hardened proptests ---
2617
2618    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}