1use std::time::Duration;
30
31use reqwest::Url;
32use serde::{Deserialize, Serialize};
33
34use crate::error::{Result, SeerError};
35
36const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
38
39const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
42
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
45pub struct WebhookDelivery {
46 pub status: u16,
48}
49
50#[derive(Debug, Clone)]
55pub struct WebhookClient {
56 timeout: Duration,
58 allow_private: bool,
62}
63
64impl Default for WebhookClient {
65 fn default() -> Self {
66 Self::new()
67 }
68}
69
70impl WebhookClient {
71 pub fn new() -> Self {
73 Self {
74 timeout: DEFAULT_TIMEOUT,
75 allow_private: false,
76 }
77 }
78
79 pub fn from_config(config: &crate::config::SeerConfig) -> Self {
86 Self::new().with_timeout(config.http_timeout())
87 }
88
89 pub fn with_timeout(mut self, timeout: Duration) -> Self {
91 self.timeout = timeout;
92 self
93 }
94
95 #[cfg(test)]
97 pub(crate) fn allowing_private_hosts(mut self) -> Self {
98 self.allow_private = true;
99 self
100 }
101
102 pub async fn post_json<T: Serialize + ?Sized>(
120 &self,
121 url: &str,
122 payload: &T,
123 ) -> Result<WebhookDelivery> {
124 let (host, port) = parse_webhook_url(url)?;
125
126 let body = serde_json::to_vec(payload)?;
129
130 let mut builder = crate::net::client_builder(self.timeout)
133 .connect_timeout(CONNECT_TIMEOUT.min(self.timeout))
134 .user_agent(crate::net::USER_AGENT);
135
136 if !self.allow_private {
137 let resolved = crate::net::resolve_public_host(&host, port).await?;
143 builder = builder.resolve_to_addrs(&host, &resolved);
144 }
145
146 let client = builder
147 .build()
148 .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
149
150 let response = client
152 .post(url)
153 .header(reqwest::header::CONTENT_TYPE, "application/json")
154 .body(body)
155 .send()
156 .await
157 .map_err(|e| SeerError::from(e.without_url()))?;
158
159 let status = response.status();
160 if !status.is_success() {
161 return Err(SeerError::HttpError(format!(
164 "webhook delivery failed: HTTP {}",
165 status
166 )));
167 }
168
169 Ok(WebhookDelivery {
170 status: status.as_u16(),
171 })
172 }
173}
174
175fn parse_webhook_url(url: &str) -> Result<(String, u16)> {
179 let parsed = Url::parse(url)
181 .map_err(|e| SeerError::InvalidInput(format!("invalid webhook URL: {e}")))?;
182
183 let scheme = parsed.scheme();
184 if scheme != "http" && scheme != "https" {
185 return Err(SeerError::InvalidInput(format!(
186 "webhook URL must use http or https, got '{}'",
187 scheme
188 )));
189 }
190
191 let host = crate::net::url_host(&parsed)
192 .ok_or_else(|| SeerError::InvalidInput("webhook URL has no host".to_string()))?;
193
194 let port = parsed
197 .port_or_known_default()
198 .unwrap_or(if scheme == "http" { 80 } else { 443 });
199
200 Ok((host, port))
201}
202
203#[cfg(test)]
204#[allow(clippy::unwrap_used)]
205mod tests {
206 use super::*;
212 use wiremock::matchers::{body_json, header, method, path};
213 use wiremock::{Mock, MockServer, ResponseTemplate};
214
215 #[derive(Serialize)]
216 struct Payload {
217 domain: String,
218 event: String,
219 }
220
221 struct Unserializable;
224
225 impl Serialize for Unserializable {
226 fn serialize<S: serde::Serializer>(
227 &self,
228 _serializer: S,
229 ) -> std::result::Result<S::Ok, S::Error> {
230 Err(serde::ser::Error::custom("cannot serialize"))
231 }
232 }
233
234 #[tokio::test]
235 async fn delivers_json_payload_and_returns_status() {
236 let server = MockServer::start().await;
237 Mock::given(method("POST"))
241 .and(path("/hook"))
242 .and(header("content-type", "application/json"))
243 .and(body_json(serde_json::json!({
244 "domain": "example.com",
245 "event": "expiry",
246 })))
247 .respond_with(ResponseTemplate::new(200))
248 .expect(1)
249 .mount(&server)
250 .await;
251
252 let client = WebhookClient::new().allowing_private_hosts();
253 let payload = Payload {
254 domain: "example.com".to_string(),
255 event: "expiry".to_string(),
256 };
257 let delivery = client
258 .post_json(&format!("{}/hook", server.uri()), &payload)
259 .await
260 .unwrap();
261 assert_eq!(delivery.status, 200);
262 server.verify().await;
263 }
264
265 #[tokio::test]
266 async fn non_2xx_is_error_naming_status_not_body() {
267 let server = MockServer::start().await;
268 Mock::given(method("POST"))
269 .and(path("/hook"))
270 .respond_with(ResponseTemplate::new(500).set_body_string("internal-secret-detail"))
271 .mount(&server)
272 .await;
273
274 let client = WebhookClient::new().allowing_private_hosts();
275 let err = client
276 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
277 .await
278 .unwrap_err();
279 assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
280 let msg = err.to_string();
281 assert!(msg.contains("500"), "error must name the status: {msg}");
282 assert!(
283 !msg.contains("internal-secret-detail"),
284 "untrusted response body must not leak into the error: {msg}"
285 );
286 }
287
288 #[tokio::test]
289 async fn redirect_is_not_followed() {
290 let server = MockServer::start().await;
291 Mock::given(method("POST"))
292 .and(path("/hook"))
293 .respond_with(
294 ResponseTemplate::new(301)
295 .insert_header("location", format!("{}/elsewhere", server.uri()).as_str()),
296 )
297 .expect(1)
298 .mount(&server)
299 .await;
300 Mock::given(path("/elsewhere"))
302 .respond_with(ResponseTemplate::new(200))
303 .expect(0)
304 .mount(&server)
305 .await;
306
307 let client = WebhookClient::new().allowing_private_hosts();
308 let err = client
309 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
310 .await
311 .unwrap_err();
312 assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
313 assert!(
314 err.to_string().contains("301"),
315 "error must carry the 3xx status: {err}"
316 );
317 server.verify().await;
318 }
319
320 #[tokio::test]
321 async fn production_client_refuses_loopback_url() {
322 let server = MockServer::start().await;
323 Mock::given(method("POST"))
325 .respond_with(ResponseTemplate::new(200))
326 .expect(0)
327 .mount(&server)
328 .await;
329
330 let client = WebhookClient::new(); let err = client
332 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
333 .await
334 .unwrap_err();
335 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
336 server.verify().await;
337 }
338
339 #[tokio::test]
340 async fn production_client_refuses_private_ip_literal() {
341 let client = WebhookClient::new();
343 for url in [
344 "http://10.0.0.1/hook",
345 "https://169.254.169.254/latest/meta-data",
346 "http://[::1]/hook",
347 ] {
348 let err = client
349 .post_json(url, &serde_json::json!({}))
350 .await
351 .unwrap_err();
352 assert!(
353 matches!(err, SeerError::InvalidInput(_)),
354 "{url} must be refused, got {err:?}"
355 );
356 }
357 }
358
359 #[tokio::test]
360 async fn rejects_non_http_scheme() {
361 let client = WebhookClient::new();
362 let err = client
363 .post_json("ftp://example.com/hook", &serde_json::json!({}))
364 .await
365 .unwrap_err();
366 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
367 assert!(
368 err.to_string().contains("http"),
369 "error should point at the scheme requirement: {err}"
370 );
371 }
372
373 #[tokio::test]
374 async fn rejects_hostless_url() {
375 let client = WebhookClient::new();
379 let err = client
380 .post_json("http://", &serde_json::json!({}))
381 .await
382 .unwrap_err();
383 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
384 }
385
386 #[tokio::test]
387 async fn serialization_failure_fails_fast_before_any_network() {
388 let client = WebhookClient::new();
392 let err = client
393 .post_json("https://webhook.example.com/hook", &Unserializable)
394 .await
395 .unwrap_err();
396 assert!(matches!(err, SeerError::JsonError(_)), "got {err:?}");
397 }
398
399 #[tokio::test]
400 async fn timeout_bounds_delivery() {
401 let server = MockServer::start().await;
402 Mock::given(method("POST"))
403 .and(path("/hook"))
404 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
405 .mount(&server)
406 .await;
407
408 let client = WebhookClient::new()
409 .allowing_private_hosts()
410 .with_timeout(Duration::from_millis(100));
411 let err = client
412 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
413 .await
414 .unwrap_err();
415 match err {
416 SeerError::ReqwestError { transient, .. } => {
417 assert!(transient, "timeouts must classify as transient");
418 }
419 other => panic!("expected ReqwestError from timeout, got {other:?}"),
420 }
421 }
422
423 #[tokio::test]
426 async fn errors_never_carry_the_url() {
427 let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
429 let port = listener.local_addr().unwrap().port();
430 drop(listener);
431
432 let client = WebhookClient::new().allowing_private_hosts();
433 let err = client
434 .post_json(
435 &format!("http://127.0.0.1:{port}/hooks/s3cr3t-token?key=hunter2"),
436 &serde_json::json!({}),
437 )
438 .await
439 .unwrap_err();
440 assert!(matches!(err, SeerError::ReqwestError { .. }), "got {err:?}");
441 let msg = err.to_string();
442 assert!(!msg.contains("s3cr3t-token"), "{msg}");
443 assert!(!msg.contains("hunter2"), "{msg}");
444
445 let err = client
446 .post_json("ftp://example.com/s3cr3t-token", &serde_json::json!({}))
447 .await
448 .unwrap_err();
449 assert!(!err.to_string().contains("s3cr3t-token"), "{err}");
450 let err = client
451 .post_json("http//s3cr3t-token", &serde_json::json!({}))
452 .await
453 .unwrap_err();
454 assert!(!err.to_string().contains("s3cr3t-token"), "{err}");
455 }
456}