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>(
116 &self,
117 url: &str,
118 payload: &T,
119 ) -> Result<WebhookDelivery> {
120 let (host, port) = parse_webhook_url(url)?;
121
122 let body = serde_json::to_vec(payload)?;
125
126 let mut builder = crate::net::client_builder(self.timeout)
129 .connect_timeout(CONNECT_TIMEOUT.min(self.timeout))
130 .user_agent(crate::net::USER_AGENT);
131
132 if !self.allow_private {
133 let resolved = crate::net::resolve_public_host(&host, port).await?;
139 builder = builder.resolve_to_addrs(&host, &resolved);
140 }
141
142 let client = builder
143 .build()
144 .map_err(|e| SeerError::HttpError(format!("failed to build HTTP client: {}", e)))?;
145
146 let response = client
147 .post(url)
148 .header(reqwest::header::CONTENT_TYPE, "application/json")
149 .body(body)
150 .send()
151 .await?;
152
153 let status = response.status();
154 if !status.is_success() {
155 return Err(SeerError::HttpError(format!(
158 "webhook delivery failed: HTTP {}",
159 status
160 )));
161 }
162
163 Ok(WebhookDelivery {
164 status: status.as_u16(),
165 })
166 }
167}
168
169fn parse_webhook_url(url: &str) -> Result<(String, u16)> {
173 let parsed = Url::parse(url)
174 .map_err(|e| SeerError::InvalidInput(format!("invalid webhook URL '{}': {}", url, e)))?;
175
176 let scheme = parsed.scheme();
177 if scheme != "http" && scheme != "https" {
178 return Err(SeerError::InvalidInput(format!(
179 "webhook URL must use http or https, got '{}'",
180 scheme
181 )));
182 }
183
184 let host = crate::net::url_host(&parsed)
185 .ok_or_else(|| SeerError::InvalidInput(format!("webhook URL '{}' has no host", url)))?;
186
187 let port = parsed
190 .port_or_known_default()
191 .unwrap_or(if scheme == "http" { 80 } else { 443 });
192
193 Ok((host, port))
194}
195
196#[cfg(test)]
197#[allow(clippy::unwrap_used)]
198mod tests {
199 use super::*;
205 use wiremock::matchers::{body_json, header, method, path};
206 use wiremock::{Mock, MockServer, ResponseTemplate};
207
208 #[derive(Serialize)]
209 struct Payload {
210 domain: String,
211 event: String,
212 }
213
214 struct Unserializable;
217
218 impl Serialize for Unserializable {
219 fn serialize<S: serde::Serializer>(
220 &self,
221 _serializer: S,
222 ) -> std::result::Result<S::Ok, S::Error> {
223 Err(serde::ser::Error::custom("cannot serialize"))
224 }
225 }
226
227 #[tokio::test]
228 async fn delivers_json_payload_and_returns_status() {
229 let server = MockServer::start().await;
230 Mock::given(method("POST"))
234 .and(path("/hook"))
235 .and(header("content-type", "application/json"))
236 .and(body_json(serde_json::json!({
237 "domain": "example.com",
238 "event": "expiry",
239 })))
240 .respond_with(ResponseTemplate::new(200))
241 .expect(1)
242 .mount(&server)
243 .await;
244
245 let client = WebhookClient::new().allowing_private_hosts();
246 let payload = Payload {
247 domain: "example.com".to_string(),
248 event: "expiry".to_string(),
249 };
250 let delivery = client
251 .post_json(&format!("{}/hook", server.uri()), &payload)
252 .await
253 .unwrap();
254 assert_eq!(delivery.status, 200);
255 server.verify().await;
256 }
257
258 #[tokio::test]
259 async fn non_2xx_is_error_naming_status_not_body() {
260 let server = MockServer::start().await;
261 Mock::given(method("POST"))
262 .and(path("/hook"))
263 .respond_with(ResponseTemplate::new(500).set_body_string("internal-secret-detail"))
264 .mount(&server)
265 .await;
266
267 let client = WebhookClient::new().allowing_private_hosts();
268 let err = client
269 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
270 .await
271 .unwrap_err();
272 assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
273 let msg = err.to_string();
274 assert!(msg.contains("500"), "error must name the status: {msg}");
275 assert!(
276 !msg.contains("internal-secret-detail"),
277 "untrusted response body must not leak into the error: {msg}"
278 );
279 }
280
281 #[tokio::test]
282 async fn redirect_is_not_followed() {
283 let server = MockServer::start().await;
284 Mock::given(method("POST"))
285 .and(path("/hook"))
286 .respond_with(
287 ResponseTemplate::new(301)
288 .insert_header("location", format!("{}/elsewhere", server.uri()).as_str()),
289 )
290 .expect(1)
291 .mount(&server)
292 .await;
293 Mock::given(path("/elsewhere"))
295 .respond_with(ResponseTemplate::new(200))
296 .expect(0)
297 .mount(&server)
298 .await;
299
300 let client = WebhookClient::new().allowing_private_hosts();
301 let err = client
302 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
303 .await
304 .unwrap_err();
305 assert!(matches!(err, SeerError::HttpError(_)), "got {err:?}");
306 assert!(
307 err.to_string().contains("301"),
308 "error must carry the 3xx status: {err}"
309 );
310 server.verify().await;
311 }
312
313 #[tokio::test]
314 async fn production_client_refuses_loopback_url() {
315 let server = MockServer::start().await;
316 Mock::given(method("POST"))
318 .respond_with(ResponseTemplate::new(200))
319 .expect(0)
320 .mount(&server)
321 .await;
322
323 let client = WebhookClient::new(); let err = client
325 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
326 .await
327 .unwrap_err();
328 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
329 server.verify().await;
330 }
331
332 #[tokio::test]
333 async fn production_client_refuses_private_ip_literal() {
334 let client = WebhookClient::new();
336 for url in [
337 "http://10.0.0.1/hook",
338 "https://169.254.169.254/latest/meta-data",
339 "http://[::1]/hook",
340 ] {
341 let err = client
342 .post_json(url, &serde_json::json!({}))
343 .await
344 .unwrap_err();
345 assert!(
346 matches!(err, SeerError::InvalidInput(_)),
347 "{url} must be refused, got {err:?}"
348 );
349 }
350 }
351
352 #[tokio::test]
353 async fn rejects_non_http_scheme() {
354 let client = WebhookClient::new();
355 let err = client
356 .post_json("ftp://example.com/hook", &serde_json::json!({}))
357 .await
358 .unwrap_err();
359 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
360 assert!(
361 err.to_string().contains("http"),
362 "error should point at the scheme requirement: {err}"
363 );
364 }
365
366 #[tokio::test]
367 async fn rejects_hostless_url() {
368 let client = WebhookClient::new();
372 let err = client
373 .post_json("http://", &serde_json::json!({}))
374 .await
375 .unwrap_err();
376 assert!(matches!(err, SeerError::InvalidInput(_)), "got {err:?}");
377 }
378
379 #[tokio::test]
380 async fn serialization_failure_fails_fast_before_any_network() {
381 let client = WebhookClient::new();
385 let err = client
386 .post_json("https://webhook.example.com/hook", &Unserializable)
387 .await
388 .unwrap_err();
389 assert!(matches!(err, SeerError::JsonError(_)), "got {err:?}");
390 }
391
392 #[tokio::test]
393 async fn timeout_bounds_delivery() {
394 let server = MockServer::start().await;
395 Mock::given(method("POST"))
396 .and(path("/hook"))
397 .respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(5)))
398 .mount(&server)
399 .await;
400
401 let client = WebhookClient::new()
402 .allowing_private_hosts()
403 .with_timeout(Duration::from_millis(100));
404 let err = client
405 .post_json(&format!("{}/hook", server.uri()), &serde_json::json!({}))
406 .await
407 .unwrap_err();
408 match err {
409 SeerError::ReqwestError { transient, .. } => {
410 assert!(transient, "timeouts must classify as transient");
411 }
412 other => panic!("expected ReqwestError from timeout, got {other:?}"),
413 }
414 }
415}