static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();
use webhooksmith::{EventStatus, NewEndpoint, WebhookEngine};
use serde_json::json;
use sqlx::PgPool;
use wiremock::{matchers::method, Mock, MockServer, ResponseTemplate};
fn engine(pool: PgPool) -> WebhookEngine {
WebhookEngine::builder()
.pool(pool)
.allow_insecure_urls()
.build_sync()
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn large_payload_stored_and_delivered(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
let big = json!({"data": "x".repeat(10_000)});
let ev = e.send("data.sync", big.clone(), ep.id).await.unwrap();
e.run_once().await.unwrap();
let after = e.event(ev.id).await.unwrap().unwrap();
assert_eq!(after.status, EventStatus::Delivered);
assert_eq!(after.payload["data"], big["data"]);
server.verify().await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn idempotent_send_deduplicates(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
let ev1 = e.send_idempotent("order.created", json!({"id": 1}), ep.id, "order-1001").await.unwrap();
let ev2 = e.send_idempotent("order.created", json!({"id": 1}), ep.id, "order-1001").await.unwrap();
assert_eq!(ev1.id, ev2.id, "same idempotency key must return same event");
let n = e.run_once().await.unwrap();
assert_eq!(n, 1);
let stats = e.queue_stats().await.unwrap();
assert_eq!(stats.delivered, 1);
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn broadcast_with_no_endpoints_returns_empty(pool: PgPool) {
let e = engine(pool);
let events = e.broadcast("order.created", json!({})).await.unwrap();
assert!(events.is_empty(), "broadcast with no endpoints must return empty vec");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn disabled_endpoint_skipped_then_reenabled(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
e.disable_endpoint(ep.id).await.unwrap();
let ev = e.send("order.created", json!({}), ep.id).await.unwrap();
let n = e.run_once().await.unwrap(); assert_eq!(n, 0, "disabled endpoint must not be claimed by worker");
e.enable_endpoint(ep.id).await.unwrap();
e.run_once().await.unwrap();
let after = e.event(ev.id).await.unwrap().unwrap();
assert_eq!(after.status, EventStatus::Delivered);
server.verify().await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn deleted_endpoint_events_cascade(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
let ev_id = e.send("test.event", json!({}), ep.id).await.unwrap().id;
e.delete_endpoint(ep.id).await.unwrap();
let found = e.event(ev_id).await.unwrap();
assert!(found.is_none(), "event must be cascade-deleted with endpoint");
let n = e.run_once().await.unwrap();
assert_eq!(n, 0);
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn max_attempts_one_goes_directly_to_dead(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register_with(NewEndpoint {
url: format!("{}/hook", server.uri()),
signing_secret: "edge_e2e_secret_32chars_______".into(),
max_attempts: Some(1),
..Default::default()
}).await.unwrap();
let ev = e.send("order.created", json!({}), ep.id).await.unwrap();
e.run_once().await.unwrap();
let after = e.event(ev.id).await.unwrap().unwrap();
assert_eq!(after.status, EventStatus::Dead);
assert_eq!(after.attempts, 1);
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn delivery_log_records_success_attempt(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(202))
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
let ev = e.send("order.created", json!({}), ep.id).await.unwrap();
e.run_once().await.unwrap();
let log = e.delivery_log(ev.id).await.unwrap();
assert_eq!(log.len(), 1);
assert!(log[0].success);
assert_eq!(log[0].response_status, Some(202));
assert!(log[0].duration_ms.unwrap_or(0) >= 0);
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn full_circuit_dlq_retry_cycle(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(500))
.up_to_n_times(5)
.mount(&server)
.await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register_with(NewEndpoint {
url: format!("{}/hook", server.uri()),
signing_secret: "edge_e2e_secret_32chars_______".into(),
max_attempts: Some(1), initial_delay_ms: Some(60_000),
..Default::default()
}).await.unwrap();
for i in 0..5 {
e.send("order.created", json!({"i": i}), ep.id).await.unwrap();
e.run_once().await.unwrap();
}
let ep_state = e.endpoint(ep.id).await.unwrap().unwrap();
assert_eq!(ep_state.consecutive_failures, 5);
assert!(ep_state.circuit_open_until.is_some());
let stats = e.queue_stats().await.unwrap();
assert_eq!(stats.dead, 5);
let retried = e.retry_all_dead(ep.id).await.unwrap();
assert_eq!(retried, 5);
let ep_after_retry = e.endpoint(ep.id).await.unwrap().unwrap();
assert_eq!(ep_after_retry.consecutive_failures, 0);
assert!(ep_after_retry.circuit_open_until.is_none());
e.run_once().await.unwrap();
let delivered = e.queue_stats().await.unwrap().delivered;
assert_eq!(delivered, 5);
server.verify().await;
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn events_global_pagination(pool: PgPool) {
let e = engine(pool);
let server = MockServer::start().await;
let ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
for i in 0..7 {
e.send("test.event", json!({"i": i}), ep.id).await.unwrap();
}
let page1 = e.events_global(EventStatus::Pending, 3, 0).await.unwrap();
let page2 = e.events_global(EventStatus::Pending, 3, 3).await.unwrap();
let page3 = e.events_global(EventStatus::Pending, 3, 6).await.unwrap();
assert_eq!(page1.len(), 3);
assert_eq!(page2.len(), 3);
assert_eq!(page3.len(), 1);
let ids1: std::collections::HashSet<_> = page1.iter().map(|e| e.id).collect();
let ids2: std::collections::HashSet<_> = page2.iter().map(|e| e.id).collect();
assert!(ids1.is_disjoint(&ids2), "pages must not overlap");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn cleanup_only_removes_old_delivered(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let e = engine(pool.clone());
let ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
let ev = e.send("test.event", json!({}), ep.id).await.unwrap();
e.run_once().await.unwrap();
assert_eq!(e.event(ev.id).await.unwrap().unwrap().status, EventStatus::Delivered);
let deleted = e.cleanup_delivered(std::time::Duration::from_secs(0)).await.unwrap();
assert_eq!(deleted, 1);
assert!(e.event(ev.id).await.unwrap().is_none(), "delivered event must be cleaned up");
let pending = e.send("test.event", json!({}), ep.id).await.unwrap();
e.cleanup_delivered(std::time::Duration::from_secs(0)).await.unwrap();
assert!(e.event(pending.id).await.unwrap().is_some(), "pending event must survive cleanup");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn short_secret_rejected(pool: PgPool) {
let e = engine(pool);
let result = e.register("https://example.com/hook", "tooshort").await;
assert!(result.is_err(), "secret shorter than 16 chars must be rejected");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn ssrf_blocked_for_private_ip(pool: PgPool) {
let strict = WebhookEngine::builder()
.pool(pool)
.build_sync();
let result = strict.register("http://192.168.1.1/webhook", "secret_at_least_16_chars").await;
assert!(result.is_err(), "private IP must be rejected without allow_insecure_urls");
let result2 = strict.register("http://169.254.169.254/latest/meta-data", "secret_at_least_16_chars").await;
assert!(result2.is_err(), "AWS metadata endpoint must be rejected");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn update_endpoint_changes_url_and_secret(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let e = engine(pool);
let ep = e.register(&format!("{}/old-hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
let updated = e.update_endpoint(ep.id, webhooksmith::UpdateEndpoint {
url: Some(format!("{}/new-hook", server.uri())),
signing_secret: Some("new_secret_that_is_32_chars_long".into()),
..Default::default()
}).await.unwrap();
assert!(updated.url.ends_with("/new-hook"));
assert_eq!(updated.signing_secret, "new_secret_that_is_32_chars_long");
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn list_endpoints_paged_consistent(pool: PgPool) {
let e = engine(pool);
let server = MockServer::start().await;
for _ in 0..6 {
e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
}
let page1 = e.list_endpoints_paged(3, 0).await.unwrap();
let page2 = e.list_endpoints_paged(3, 3).await.unwrap();
assert_eq!(page1.len(), 3);
assert_eq!(page2.len(), 3);
let ids1: std::collections::HashSet<_> = page1.iter().map(|e| e.id).collect();
let ids2: std::collections::HashSet<_> = page2.iter().map(|e| e.id).collect();
assert!(ids1.is_disjoint(&ids2));
}
#[sqlx::test(migrator = "MIGRATOR")]
async fn broadcast_idempotent_deduplicates(pool: PgPool) {
let server = MockServer::start().await;
Mock::given(method("POST"))
.respond_with(ResponseTemplate::new(200))
.mount(&server)
.await;
let e = engine(pool);
let _ep = e.register(&format!("{}/hook", server.uri()), "edge_e2e_secret_32chars_______").await.unwrap();
let v1 = e.broadcast_idempotent("order.created", json!({}), "broadcast-key-001").await.unwrap();
let v2 = e.broadcast_idempotent("order.created", json!({}), "broadcast-key-001").await.unwrap();
assert_eq!(v1.len(), 1);
assert_eq!(v2.len(), 1);
assert_eq!(v1[0].id, v2[0].id, "same broadcast idempotency key must deduplicate");
}