webhooksmith-axum 0.1.9

Axum integration for webhooksmith — verified webhook extractor and receiver middleware
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
//! Integration tests for the admin HTTP router.
//! Uses a real Postgres database and a real axum test server.

static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../webhooksmith/migrations");

use axum::{
    body::Body,
    http::{Request, StatusCode},
    Router,
};
use serde_json::Value;
use sqlx::PgPool;
use tower::ServiceExt;
use webhooksmith::WebhookEngine;
use webhooksmith_axum::admin;
use std::sync::Arc;

fn engine(pool: PgPool) -> Arc<WebhookEngine> {
    Arc::new(
        WebhookEngine::builder()
            .pool(pool)
            .allow_insecure_urls()
            .build_sync(),
    )
}

fn app(engine: Arc<WebhookEngine>) -> Router {
    Router::new().nest("/admin", admin(engine))
}

async fn get_json(app: &Router, uri: &str) -> (StatusCode, Value) {
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .uri(uri)
                .header("accept", "application/json")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
    let json: Value = serde_json::from_slice(&bytes).unwrap_or_default();
    (status, json)
}

async fn post_json(app: &Router, uri: &str) -> (StatusCode, Value) {
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri(uri)
                .header("content-type", "application/json")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
    let json: Value = serde_json::from_slice(&bytes).unwrap_or_default();
    (status, json)
}

// ── GET /admin/stats ──────────────────────────────────────────────────────────

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

    let (status, json) = get_json(&app, "/admin/stats").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(json["pending"], 0);
    assert_eq!(json["delivering"], 0);
    assert_eq!(json["failed"], 0);
    assert_eq!(json["dead"], 0);
    assert_eq!(json["delivered"], 0);
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn stats_reflects_enqueued_events(pool: PgPool) {
    let e = engine(pool.clone());
    let app = app(Arc::clone(&e));

    let ep = e.register("https://example.com/hook", "admin_test_secret_32chars_____").await.unwrap();
    e.send("order.created", serde_json::json!({}), ep.id).await.unwrap();
    e.send("order.created", serde_json::json!({}), ep.id).await.unwrap();

    let (status, json) = get_json(&app, "/admin/stats").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(json["pending"], 2, "two pending events expected");
}

// ── GET /admin/endpoints ──────────────────────────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn endpoints_lists_registered_endpoints_paginated(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    e.register("https://example.com/hook", "admin_test_secret_32chars_____").await.unwrap();
    e.register("https://example2.com/hook", "admin_test_secret_32chars_____").await.unwrap();

    // Default limit=50 offset=0 — returns both
    let (status, json) = get_json(&app, "/admin/endpoints").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(json.as_array().unwrap().len(), 2);

    // limit=1 returns exactly 1
    let (status, page1) = get_json(&app, "/admin/endpoints?limit=1&offset=0").await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(page1.as_array().unwrap().len(), 1);

    // offset=1 returns the second
    let (_, page2) = get_json(&app, "/admin/endpoints?limit=1&offset=1").await;
    assert_eq!(page2.as_array().unwrap().len(), 1);

    // Pages must not overlap
    let id1 = page1[0]["id"].as_str().unwrap();
    let id2 = page2[0]["id"].as_str().unwrap();
    assert_ne!(id1, id2, "pages must not overlap");

    // offset past end returns empty
    let (_, page3) = get_json(&app, "/admin/endpoints?limit=10&offset=100").await;
    assert_eq!(page3.as_array().unwrap().len(), 0);
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn endpoints_exposes_circuit_breaker_fields(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    e.register("https://example.com/hook", "admin_test_secret_32chars_____").await.unwrap();

    let (status, json) = get_json(&app, "/admin/endpoints").await;
    assert_eq!(status, StatusCode::OK);
    let ep = &json[0];
    assert!(ep.get("consecutive_failures").is_some(), "consecutive_failures must be in response");
    // circuit_open_until may be null
    assert!(ep.get("circuit_open_until").is_some() || ep["circuit_open_until"].is_null());
}

// ── GET /admin/dlq/{id} ───────────────────────────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn dlq_returns_empty_for_endpoint_with_no_dead_events(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    let ep = e.register("https://example.com/hook", "admin_test_secret_32chars_____").await.unwrap();

    let (status, json) = get_json(&app, &format!("/admin/dlq/{}", ep.id)).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(json.as_array().unwrap().len(), 0);
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn dlq_returns_dead_events(pool: PgPool) {
    let e = engine(pool.clone());
    let app = app(Arc::clone(&e));

    // Insert endpoint directly with max_attempts=1 so failures go straight to dead
    let ep: uuid::Uuid = sqlx::query_scalar!(
        "INSERT INTO webhook_endpoints (url, signing_secret, max_attempts, initial_delay_ms) VALUES ($1, 'admin_test_secret_32chars_____', 1, 1000) RETURNING id",
        "https://bad-endpoint.example.com/hook",
    )
    .fetch_one(&pool)
    .await
    .unwrap();

    e.send("order.failed", serde_json::json!({}), ep).await.unwrap();
    e.send("order.failed", serde_json::json!({}), ep).await.unwrap();

    // Mark both as dead directly (simulates exhausted retries)
    sqlx::query!("UPDATE webhook_events SET status = 'dead' WHERE endpoint_id = $1", ep)
        .execute(&pool)
        .await
        .unwrap();

    let (status, json) = get_json(&app, &format!("/admin/dlq/{}", ep)).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(json.as_array().unwrap().len(), 2);
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn dlq_respects_pagination(pool: PgPool) {
    let e = engine(pool.clone());
    let app = app(Arc::clone(&e));

    let ep: uuid::Uuid = sqlx::query_scalar!(
        "INSERT INTO webhook_endpoints (url, signing_secret, max_attempts, initial_delay_ms) VALUES ($1, 'admin_test_secret_32chars_____', 1, 1000) RETURNING id",
        "https://bad-endpoint.example.com/hook",
    )
    .fetch_one(&pool)
    .await
    .unwrap();

    // Create 5 dead events
    for i in 0..5 {
        e.send("order.failed", serde_json::json!({"i": i}), ep).await.unwrap();
    }
    sqlx::query!("UPDATE webhook_events SET status = 'dead' WHERE endpoint_id = $1", ep)
        .execute(&pool)
        .await
        .unwrap();

    let (status, page1) = get_json(&app, &format!("/admin/dlq/{}?limit=2&offset=0", ep)).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(page1.as_array().unwrap().len(), 2);

    let (_, page2) = get_json(&app, &format!("/admin/dlq/{}?limit=2&offset=2", ep)).await;
    assert_eq!(page2.as_array().unwrap().len(), 2);

    let (_, page3) = get_json(&app, &format!("/admin/dlq/{}?limit=2&offset=4", ep)).await;
    assert_eq!(page3.as_array().unwrap().len(), 1);
}

// ── POST /admin/dlq/{id}/retry-all ────────────────────────────────────────────

#[sqlx::test(migrator = "MIGRATOR")]
async fn retry_all_requeues_dead_events_and_resets_circuit(pool: PgPool) {
    let e = engine(pool.clone());
    let app = app(Arc::clone(&e));

    let ep: uuid::Uuid = sqlx::query_scalar!(
        "INSERT INTO webhook_endpoints (url, signing_secret, max_attempts, initial_delay_ms) VALUES ($1, 'admin_test_secret_32chars_____', 1, 1000) RETURNING id",
        "https://bad-endpoint.example.com/hook",
    )
    .fetch_one(&pool)
    .await
    .unwrap();

    for _ in 0..3 {
        e.send("order.failed", serde_json::json!({}), ep).await.unwrap();
    }
    // Manually open the circuit + mark events dead
    sqlx::query!(
        "UPDATE webhook_events SET status = 'dead' WHERE endpoint_id = $1",
        ep
    )
    .execute(&pool).await.unwrap();
    sqlx::query!(
        "UPDATE webhook_endpoints SET consecutive_failures = 5, circuit_open_until = NOW() + INTERVAL '10 minutes' WHERE id = $1",
        ep
    )
    .execute(&pool).await.unwrap();

    // POST retry-all
    let (status, json) = post_json(&app, &format!("/admin/dlq/{}/retry-all", ep)).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(json["retried"], 3);

    // Circuit must be reset
    let endpoint = e.endpoint(ep).await.unwrap().unwrap();
    assert_eq!(endpoint.consecutive_failures, 0, "retry-all must reset circuit");
    assert!(endpoint.circuit_open_until.is_none(), "circuit must be closed after retry-all");

    // Events must be pending
    let stats = e.queue_stats().await.unwrap();
    assert_eq!(stats.pending, 3);
    assert_eq!(stats.dead, 0);
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn retry_all_returns_zero_when_no_dead_events(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    let ep = e.register("https://example.com/hook", "admin_test_secret_32chars_____").await.unwrap();

    let (status, json) = post_json(&app, &format!("/admin/dlq/{}/retry-all", ep.id)).await;
    assert_eq!(status, StatusCode::OK);
    assert_eq!(json["retried"], 0);
}

// ── GET /admin/metrics ────────────────────────────────────────────────────────

async fn get_text(app: &Router, uri: &str) -> (StatusCode, String) {
    let resp = app
        .clone()
        .oneshot(
            Request::builder()
                .uri(uri)
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();
    let status = resp.status();
    let ct = resp.headers()
        .get("content-type")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("")
        .to_owned();
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
    let _ = ct; // used implicitly below via caller
    (status, String::from_utf8(bytes.to_vec()).unwrap())
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn metrics_returns_prometheus_text(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    let (status, body) = get_text(&app, "/admin/metrics").await;
    assert_eq!(status, StatusCode::OK);

    // Must contain Prometheus HELP and TYPE lines
    assert!(body.contains("# HELP webhooksmith_events"), "must have HELP for events");
    assert!(body.contains("# TYPE webhooksmith_events gauge"), "must have TYPE gauge");
    assert!(body.contains("# HELP webhooksmith_endpoints"), "must have HELP for endpoints");

    // Must have all 5 status labels
    assert!(body.contains(r#"status="pending""#));
    assert!(body.contains(r#"status="delivering""#));
    assert!(body.contains(r#"status="failed""#));
    assert!(body.contains(r#"status="dead""#));
    assert!(body.contains(r#"status="delivered""#));

    // Must have endpoint state labels
    assert!(body.contains(r#"state="enabled""#));
    assert!(body.contains(r#"state="disabled""#));
    assert!(body.contains(r#"state="circuit_open""#));
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn metrics_reflects_enqueued_events(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    let ep = e.register("https://example.com/hook", "metrics_test_secret_32chars___").await.unwrap();
    e.send("order.created", serde_json::json!({}), ep.id).await.unwrap();
    e.send("order.created", serde_json::json!({}), ep.id).await.unwrap();

    let (_, body) = get_text(&app, "/admin/metrics").await;

    // Parse the pending gauge value
    let pending_line = body.lines()
        .find(|l| l.contains(r#"status="pending""#))
        .expect("pending gauge must exist");
    let value: i64 = pending_line.split_whitespace().last().unwrap().parse().unwrap();
    assert_eq!(value, 2, "pending gauge must reflect 2 enqueued events");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn metrics_reflects_enabled_disabled_endpoints(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    let ep1 = e.register("https://example.com/a", "metrics_test_secret_32chars___").await.unwrap();
    let _ep2 = e.register("https://example.com/b", "metrics_test_secret_32chars___").await.unwrap();
    e.disable_endpoint(ep1.id).await.unwrap();

    let (_, body) = get_text(&app, "/admin/metrics").await;

    let enabled_val: i64 = body.lines()
        .find(|l| l.contains(r#"state="enabled""#))
        .and_then(|l| l.split_whitespace().last()?.parse().ok())
        .unwrap();
    let disabled_val: i64 = body.lines()
        .find(|l| l.contains(r#"state="disabled""#))
        .and_then(|l| l.split_whitespace().last()?.parse().ok())
        .unwrap();

    assert_eq!(enabled_val, 1, "1 enabled endpoint");
    assert_eq!(disabled_val, 1, "1 disabled endpoint");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn metrics_content_type_is_prometheus(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    let resp = app
        .oneshot(
            Request::builder()
                .uri("/admin/metrics")
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(resp.status(), StatusCode::OK);
    let ct = resp.headers()
        .get("content-type")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");
    assert!(ct.contains("text/plain"), "content-type must be text/plain for Prometheus scraping");
    assert!(ct.contains("0.0.4"), "must declare Prometheus text format version 0.0.4");
}

#[sqlx::test(migrator = "MIGRATOR")]
async fn metrics_values_are_valid_integers(pool: PgPool) {
    let e = engine(pool);
    let app = app(Arc::clone(&e));

    let (status, body) = get_text(&app, "/admin/metrics").await;
    assert_eq!(status, StatusCode::OK);

    // Every non-comment, non-empty line must end with a parseable integer
    for line in body.lines() {
        if line.starts_with('#') || line.trim().is_empty() {
            continue;
        }
        let value_str = line.split_whitespace().last().unwrap_or("NaN");
        let parsed = value_str.parse::<i64>();
        assert!(
            parsed.is_ok(),
            "metric line has non-integer value: {line:?}"
        );
        assert!(
            parsed.unwrap() >= 0,
            "metric value must be non-negative: {line:?}"
        );
    }
}