use std::sync::Arc;
use url::Url;
use crate::application::a2a::UrlFilter;
use crate::domain::a2a::A2aVersion;
use crate::domain::idempotency::IdempotencyKey;
#[derive(Debug, Clone)]
pub struct WebhookUrl(Url);
impl WebhookUrl {
pub fn new(
filter: &dyn UrlFilter,
url: Url,
version: A2aVersion,
) -> Result<Self, super::FilterDenial> {
filter.check_at_config(&url, version)?;
Ok(Self(url))
}
#[must_use]
pub const fn as_url(&self) -> &Url {
&self.0
}
#[must_use]
pub fn idempotency_key(&self) -> IdempotencyKey {
IdempotencyKey::new(self.0.as_str())
}
}
#[derive(Debug, Clone)]
pub struct NotificationConfig {
pub webhook: WebhookUrl,
pub token: Option<Arc<str>>,
}
#[derive(Debug, thiserror::Error)]
pub enum NotificationConfigError {
#[error("the webhook URL was rejected: {0}")]
Refused(#[from] super::url_filter::FilterDenial),
#[error("transient delivery failure: {reason}")]
TransientFailure {
reason: String,
},
}
pub trait PushNotificationDelivery: Send + Sync {
fn deliver(
&self,
config: &NotificationConfig,
body: &[u8],
) -> impl std::future::Future<Output = Result<(), NotificationConfigError>> + Send;
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
reason = "tests assert on fallible calls with named failure messages; propagating errors would replace a clear failure with noise"
)]
use std::str::FromStr;
use super::{NotificationConfig, NotificationConfigError, WebhookUrl};
use crate::adapters::secondary::a2a::SsrfDenylist;
use crate::application::a2a::PushNotificationDelivery;
use crate::domain::a2a::A2aVersion;
type TestError = Box<dyn std::error::Error>;
const V1_0: A2aVersion = A2aVersion::V1_0;
#[test]
fn idempotency_key_is_deterministic() -> Result<(), TestError> {
let url: url::Url = "https://example.com/hook".parse()?;
let a = WebhookUrl::new(&SsrfDenylist::FILTER, url.clone(), V1_0)
.expect("the public HTTPS URL must be accepted by the default filter");
let b = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)
.expect("the same URL must be accepted on a second call");
assert_eq!(
a.idempotency_key().as_str(),
b.idempotency_key().as_str(),
"the same URL must produce the same key on every call"
);
let other: url::Url = "https://example.com/hook?x=1".parse()?;
let c = WebhookUrl::new(&SsrfDenylist::FILTER, other, V1_0)
.expect("a different public URL must be accepted");
assert_ne!(
a.idempotency_key().as_str(),
c.idempotency_key().as_str(),
"different URLs must produce different keys"
);
Ok(())
}
#[test]
fn webhook_url_rejects_a_filter_denied_url() {
let url = url::Url::from_str("http://127.0.0.1/hook")
.expect("the test fixture must parse as a URL");
let err = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)
.expect_err("a loopback URL must be denied at the constructor");
let matched = matches!(
err,
crate::application::a2a::FilterDenial::DeniedIpLiteral { .. }
);
assert!(matched, "denial was {err:?}");
}
#[test]
fn notification_config_wraps_a_filtered_webhook() -> Result<(), TestError> {
let url: url::Url = "https://example.com/hook".parse()?;
let webhook = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)?;
let config = NotificationConfig {
webhook,
token: None,
};
assert_eq!(config.webhook.as_url().scheme(), "https");
assert!(config.token.is_none());
Ok(())
}
#[test]
fn notification_config_error_preserves_the_filter_denial() -> Result<(), TestError> {
use crate::application::a2a::FilterDenial;
let denial = FilterDenial::DeniedIpLiteral {
literal: "10.0.0.1".to_owned(),
};
let err = NotificationConfigError::Refused(denial.clone());
let display = format!("{err}");
assert!(display.contains("rejected"), "display was {display}");
match err {
NotificationConfigError::Refused(d) => assert_eq!(d, denial),
NotificationConfigError::TransientFailure { reason } => {
return Err(format!("expected Refused, got TransientFailure({reason})").into());
}
}
Ok(())
}
#[derive(Debug, Default)]
struct RecordingDelivery {
delivered: std::sync::Mutex<Vec<Vec<u8>>>,
}
impl PushNotificationDelivery for RecordingDelivery {
fn deliver(
&self,
_config: &NotificationConfig,
body: &[u8],
) -> impl std::future::Future<Output = Result<(), NotificationConfigError>> + Send {
let outcome = self.delivered.lock().map_or_else(
|_| {
Err(NotificationConfigError::Refused(
crate::application::a2a::FilterDenial::Malformed,
))
},
|mut v| {
v.push(body.to_vec());
Ok(())
},
);
std::future::ready(outcome)
}
}
#[test]
fn push_notification_delivery_is_implementable() -> Result<(), TestError> {
let delivery = RecordingDelivery::default();
let url: url::Url = "https://example.com/hook".parse()?;
let webhook = WebhookUrl::new(&SsrfDenylist::FILTER, url, V1_0)?;
let config = NotificationConfig {
webhook,
token: None,
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| -> Box<dyn std::error::Error> { format!("{e:?}").into() })?;
let body = b"hello";
let outcome: Result<(), NotificationConfigError> =
runtime.block_on(delivery.deliver(&config, body));
outcome.map_err(|e| -> Box<dyn std::error::Error> { format!("{e:?}").into() })?;
let recorded = delivery
.delivered
.lock()
.map_err(|e| -> Box<dyn std::error::Error> { format!("{e:?}").into() })?
.len();
assert_eq!(recorded, 1);
Ok(())
}
}