webhooksmith 0.2.1

Webhook delivery for Rust — atomic outbox pattern, HMAC-SHA256 signing, retry with backoff, dead letter queue. Supports Postgres and SQLite.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
//! Event type filtering tests — verifies that broadcast() routes events
//! only to endpoints whose event_filter matches the event type.

static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!();

use webhooksmith::{EventStatus, NewEndpoint, WebhookEngine, event_matches_filter};
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()
}

async fn endpoint_with_filter(engine: &WebhookEngine, url: &str, filter: Option<Vec<&str>>) -> uuid::Uuid {
    let ep = engine.register_with(NewEndpoint {
        url: url.into(),
        signing_secret: "filter_test_secret_32chars_ok___".into(),
        description: None,
        max_attempts: None,
        initial_delay_ms: None,
        event_filter: filter.map(|f| f.into_iter().map(String::from).collect()),
    }).await.unwrap();
    ep.id
}

// ── Unit tests: pattern matching logic ────────────────────────────────────────

#[test]
fn no_filter_matches_everything() {
    assert!(event_matches_filter("order.created", &None));
    assert!(event_matches_filter("payment.captured", &None));
    assert!(event_matches_filter("anything", &None));
}

#[test]
fn empty_filter_matches_nothing() {
    assert!(!event_matches_filter("order.created", &Some(vec![])));
}

#[test]
fn wildcard_star_matches_all() {
    let f = Some(vec!["*".to_string()]);
    assert!(event_matches_filter("order.created", &f));
    assert!(event_matches_filter("anything", &f));
}

#[test]
fn exact_match_only_matches_that_type() {
    let f = Some(vec!["order.created".to_string()]);
    assert!(event_matches_filter("order.created", &f));
    assert!(!event_matches_filter("order.updated", &f));
    assert!(!event_matches_filter("payment.captured", &f));
}

#[test]
fn prefix_star_matches_correct_namespace() {
    let f = Some(vec!["order.*".to_string()]);
    assert!(event_matches_filter("order.created", &f));
    assert!(event_matches_filter("order.updated", &f));
    assert!(event_matches_filter("order.cancelled", &f));
    assert!(!event_matches_filter("payment.captured", &f));
    assert!(!event_matches_filter("orderx.created", &f)); // prefix must match "order."
}

#[test]
fn prefix_matches_exact_prefix_too() {
    // "order.*" should also match exactly "order" (the prefix itself)
    let f = Some(vec!["order.*".to_string()]);
    assert!(event_matches_filter("order", &f));
}

#[test]
fn multiple_patterns_any_match_succeeds() {
    let f = Some(vec!["order.*".to_string(), "payment.captured".to_string()]);
    assert!(event_matches_filter("order.created", &f));
    assert!(event_matches_filter("payment.captured", &f));
    assert!(!event_matches_filter("payment.failed", &f));
    assert!(!event_matches_filter("shipment.dispatched", &f));
}

// ── Integration: broadcast() routes by filter ─────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn broadcast_respects_event_filter(pool: PgPool) {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .mount(&server)
        .await;

    let engine = engine(pool);
    let url = format!("{}/hook", server.uri());

    // Endpoint A: subscribed to "order.*"
    endpoint_with_filter(&engine, &url, Some(vec!["order.*"])).await;

    // Endpoint B: subscribed only to "payment.captured"
    endpoint_with_filter(&engine, &url, Some(vec!["payment.captured"])).await;

    // Endpoint C: no filter — receives everything
    endpoint_with_filter(&engine, &url, None).await;

    // Broadcast "order.created"
    let events = engine.broadcast("order.created", json!({})).await.unwrap();
    // Should go to: endpoint A (matches "order.*") + endpoint C (no filter)
    // Should NOT go to: endpoint B (subscribed only to payment.captured)
    assert_eq!(events.len(), 2, "order.created must go to A and C, not B");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn broadcast_payment_event_routes_correctly(pool: PgPool) {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .mount(&server)
        .await;

    let engine = engine(pool);
    let url = format!("{}/hook", server.uri());

    endpoint_with_filter(&engine, &url, Some(vec!["order.*"])).await;          // A
    endpoint_with_filter(&engine, &url, Some(vec!["payment.captured"])).await; // B
    endpoint_with_filter(&engine, &url, None).await;                            // C

    let events = engine.broadcast("payment.captured", json!({})).await.unwrap();
    // Should go to: B (exact match) + C (no filter)
    // Should NOT go to: A (order.* doesn't match payment.captured)
    assert_eq!(events.len(), 2, "payment.captured must go to B and C, not A");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn broadcast_unsubscribed_event_only_reaches_unfiltered_endpoints(pool: PgPool) {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .mount(&server)
        .await;

    let engine = engine(pool);
    let url = format!("{}/hook", server.uri());

    endpoint_with_filter(&engine, &url, Some(vec!["order.*"])).await;
    endpoint_with_filter(&engine, &url, Some(vec!["payment.*"])).await;
    endpoint_with_filter(&engine, &url, None).await; // receives everything

    // "shipment.dispatched" matches neither order.* nor payment.*
    let events = engine.broadcast("shipment.dispatched", json!({})).await.unwrap();
    assert_eq!(events.len(), 1, "shipment.dispatched only goes to unfiltered endpoint");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn empty_filter_receives_nothing_from_broadcast(pool: PgPool) {
    let engine = engine(pool);
    let server = MockServer::start().await;
    let url = format!("{}/hook", server.uri());

    endpoint_with_filter(&engine, &url, Some(vec![])).await; // empty = receives nothing

    let events = engine.broadcast("any.event", json!({})).await.unwrap();
    assert_eq!(events.len(), 0, "empty filter must receive nothing");
}

// ── set_event_filter and clear_event_filter ───────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn set_and_clear_event_filter(pool: PgPool) {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .mount(&server)
        .await;

    let engine = engine(pool);
    let url = format!("{}/hook", server.uri());
    let ep_id = endpoint_with_filter(&engine, &url, None).await; // starts with no filter

    // Set filter to only receive order events
    engine.set_event_filter(ep_id, vec!["order.*".into()]).await.unwrap();

    // payment event should NOT be delivered
    let events = engine.broadcast("payment.captured", json!({})).await.unwrap();
    assert_eq!(events.len(), 0, "filtered endpoint must not receive payment.captured");

    // order event should be delivered
    let events2 = engine.broadcast("order.created", json!({})).await.unwrap();
    assert_eq!(events2.len(), 1, "filtered endpoint must receive order.created");

    // Clear filter — back to receiving everything
    engine.clear_event_filter(ep_id).await.unwrap();
    let events3 = engine.broadcast("payment.captured", json!({})).await.unwrap();
    assert_eq!(events3.len(), 1, "unfiltered endpoint must receive all events again");
}

// ── send() bypasses filter ────────────────────────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn send_bypasses_event_filter(pool: PgPool) {
    let server = MockServer::start().await;
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .mount(&server)
        .await;

    let engine = engine(pool);
    let url = format!("{}/hook", server.uri());

    // Endpoint subscribed only to "order.*"
    let ep_id = endpoint_with_filter(&engine, &url, Some(vec!["order.*"])).await;

    // Directly send a payment event — filter is irrelevant for explicit send()
    let ev = engine.send("payment.captured", json!({}), ep_id).await.unwrap();
    engine.run_once().await.unwrap();

    let after = engine.event(ev.id).await.unwrap().unwrap();
    assert_eq!(after.status, EventStatus::Delivered,
        "send() must deliver regardless of event_filter");
}

// ── Wildcard star ─────────────────────────────────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn wildcard_star_filter_receives_everything(pool: PgPool) {
    let engine = engine(pool);
    let server = MockServer::start().await;
    let url = format!("{}/hook", server.uri());
    Mock::given(method("POST"))
        .respond_with(ResponseTemplate::new(200))
        .mount(&server)
        .await;

    endpoint_with_filter(&engine, &url, Some(vec!["*"])).await;

    let e1 = engine.broadcast("order.created", json!({})).await.unwrap();
    let e2 = engine.broadcast("payment.captured", json!({})).await.unwrap();
    let e3 = engine.broadcast("anything.at.all", json!({})).await.unwrap();

    assert_eq!(e1.len(), 1);
    assert_eq!(e2.len(), 1);
    assert_eq!(e3.len(), 1);
}

// ── Backward compatible ───────────────────────────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn backward_compatible_no_filter_receives_all(pool: PgPool) {
    let engine = engine(pool);
    let server = MockServer::start().await;
    let url = format!("{}/hook", server.uri());
    engine.register(&url, "filter_test_secret_32chars_ok___").await.unwrap();

    let events = engine.broadcast("order.created", json!({})).await.unwrap();
    assert_eq!(events.len(), 1, "endpoint with no filter must receive all events");

    let events2 = engine.broadcast("payment.captured", json!({})).await.unwrap();
    assert_eq!(events2.len(), 1);
}

// ── event_filter pattern validation ──────────────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn empty_pattern_in_filter_rejected(pool: PgPool) {
    let e = engine(pool);
    let result = e.register_with(webhooksmith::NewEndpoint {
        url: "https://example.com/hook".into(),
        signing_secret: "filter_test_secret_32chars_ok___".into(),
        event_filter: Some(vec!["order.*".into(), "".into()]),
        ..Default::default()
    }).await;
    assert!(result.is_err(), "empty string pattern must be rejected");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn trailing_dot_pattern_rejected(pool: PgPool) {
    let e = engine(pool);
    let result = e.register_with(webhooksmith::NewEndpoint {
        url: "https://example.com/hook".into(),
        signing_secret: "filter_test_secret_32chars_ok___".into(),
        event_filter: Some(vec!["order.".into()]),
        ..Default::default()
    }).await;
    assert!(result.is_err(), "trailing dot without * must be rejected — use 'order.*'");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn valid_patterns_accepted(pool: PgPool) {
    let e = engine(pool);
    let result = e.register_with(webhooksmith::NewEndpoint {
        url: "https://example.com/hook".into(),
        signing_secret: "filter_test_secret_32chars_ok___".into(),
        event_filter: Some(vec!["order.created".into(), "order.*".into(), "*".into()]),
        ..Default::default()
    }).await;
    assert!(result.is_ok(), "valid patterns must be accepted");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn control_char_in_pattern_rejected(pool: PgPool) {
    let e = engine(pool);
    let result = e.register_with(webhooksmith::NewEndpoint {
        url: "https://example.com/hook".into(),
        signing_secret: "filter_test_secret_32chars_ok___".into(),
        event_filter: Some(vec!["order\ncreated".into()]),
        ..Default::default()
    }).await;
    assert!(result.is_err(), "control char in pattern must be rejected");
}

#[test]
fn newendpoint_default_works_for_struct_update_syntax() {
    let ep = webhooksmith::NewEndpoint {
        url: "https://example.com/hook".into(),
        signing_secret: "valid_secret_32chars_min________".into(),
        ..Default::default()
    };
    assert_eq!(ep.description, None);
    assert_eq!(ep.max_attempts, None);
    assert_eq!(ep.initial_delay_ms, None);
    assert_eq!(ep.event_filter, None);
}

// ── Builder API tests ─────────────────────────────────────────────────────────

#[test]
fn builder_new_creates_endpoint_with_url_and_secret() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________");
    assert_eq!(ep.url, "https://example.com/hook");
    assert_eq!(ep.signing_secret, "valid_secret_32chars_min________");
    assert_eq!(ep.description, None);
    assert_eq!(ep.max_attempts, None);
    assert_eq!(ep.initial_delay_ms, None);
    assert_eq!(ep.event_filter, None);
}

#[test]
fn builder_description_sets_field() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________")
        .description("Order service");
    assert_eq!(ep.description, Some("Order service".to_string()));
}

#[test]
fn builder_max_attempts_sets_field() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________")
        .max_attempts(5);
    assert_eq!(ep.max_attempts, Some(5));
}

#[test]
fn builder_initial_delay_ms_sets_field() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________")
        .initial_delay_ms(2000);
    assert_eq!(ep.initial_delay_ms, Some(2000));
}

#[test]
fn builder_events_sets_filter_from_slice() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________")
        .events(["order.*", "payment.captured"]);
    assert_eq!(
        ep.event_filter,
        Some(vec!["order.*".to_string(), "payment.captured".to_string()])
    );
}

#[test]
fn builder_event_adds_single_pattern() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________")
        .event("order.*")
        .event("payment.captured");
    assert_eq!(
        ep.event_filter,
        Some(vec!["order.*".to_string(), "payment.captured".to_string()])
    );
}

#[test]
fn builder_events_replaces_previous_filter() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________")
        .event("order.*")
        .events(["payment.*"]);  // replaces the previous filter
    assert_eq!(ep.event_filter, Some(vec!["payment.*".to_string()]));
}

#[test]
fn builder_chaining_all_fields() {
    let ep = NewEndpoint::new("https://example.com/hook", "valid_secret_32chars_min________")
        .description("Test endpoint")
        .max_attempts(3)
        .initial_delay_ms(500)
        .events(["order.*", "payment.captured", "refund.issued"]);

    assert_eq!(ep.url, "https://example.com/hook");
    assert_eq!(ep.description, Some("Test endpoint".to_string()));
    assert_eq!(ep.max_attempts, Some(3));
    assert_eq!(ep.initial_delay_ms, Some(500));
    assert_eq!(ep.event_filter, Some(vec![
        "order.*".to_string(),
        "payment.captured".to_string(),
        "refund.issued".to_string(),
    ]));
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn builder_register_with_filter_works_end_to_end(pool: PgPool) {
    let e = engine(pool);

    // Register via builder
    let ep = e.register_with(
        NewEndpoint::new("https://example.com/hook", "filter_test_secret_32chars_ok___")
            .description("Order webhook")
            .events(["order.*"])
            .max_attempts(3),
    ).await.unwrap();

    assert_eq!(ep.description, Some("Order webhook".to_string()));
    assert_eq!(ep.max_attempts, 3);
    assert_eq!(ep.event_filter, Some(vec!["order.*".to_string()]));

    // Matching event is enqueued
    let events = e.broadcast("order.created", serde_json::json!({"id": 1})).await.unwrap();
    assert_eq!(events.len(), 1);

    // Non-matching event is not enqueued
    let events = e.broadcast("payment.captured", serde_json::json!({"id": 2})).await.unwrap();
    assert_eq!(events.len(), 0);
}