use actix_web::{App, HttpResponse, Responder, web, http::StatusCode, test};
use serde_json::json;
use webhooksmith::signing;
use webhooksmith_actix::{VerifiedWebhook, WebhookSecret};
const SECRET: &str = "actix_adv_secret_32chars_minimum";
fn sign(secret: &str, ts: i64, body: &[u8]) -> String {
signing::sign(secret, ts, body).unwrap()
}
fn now() -> i64 { chrono::Utc::now().timestamp() }
async fn handler(wh: VerifiedWebhook) -> impl Responder {
HttpResponse::Ok().json(json!({"event_type": wh.event_type}))
}
macro_rules! make_svc {
() => {
test::init_service(
App::new()
.app_data(WebhookSecret::new(SECRET))
.route("/hook", web::post().to(handler))
).await
};
}
#[actix_web::test]
async fn forged_signature_no_prefix_rejected() {
let svc = make_svc!();
let body = serde_json::to_vec(&json!({})).unwrap();
let ts = now();
let bad_sig = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
let req = test::TestRequest::post()
.uri("/hook")
.set_payload(body)
.insert_header(("content-type", "application/json"))
.insert_header(("x-hooksmith-timestamp", ts.to_string().as_str()))
.insert_header(("x-hooksmith-signature", bad_sig))
.to_request();
let resp = test::call_service(&svc, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[actix_web::test]
async fn empty_body_is_not_json_returns_422() {
let svc = make_svc!();
let ts = now();
let sig = sign(SECRET, ts, b"");
let req = test::TestRequest::post()
.uri("/hook")
.set_payload(vec![])
.insert_header(("content-type", "application/json"))
.insert_header(("x-hooksmith-timestamp", ts.to_string().as_str()))
.insert_header(("x-hooksmith-signature", sig.as_str()))
.to_request();
let resp = test::call_service(&svc, req).await;
assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY);
}
#[actix_web::test]
async fn null_json_body_accepted() {
let svc = make_svc!();
let ts = now();
let body = b"null";
let sig = sign(SECRET, ts, body);
let req = test::TestRequest::post()
.uri("/hook")
.set_payload(body.to_vec())
.insert_header(("content-type", "application/json"))
.insert_header(("x-hooksmith-timestamp", ts.to_string().as_str()))
.insert_header(("x-hooksmith-signature", sig.as_str()))
.to_request();
let resp = test::call_service(&svc, req).await;
assert_eq!(resp.status(), StatusCode::OK);
}
#[actix_web::test]
async fn timestamp_exactly_at_tolerance_rejected() {
let svc = make_svc!();
let body = serde_json::to_vec(&json!({})).unwrap();
let ts = now() - 300; let sig = sign(SECRET, ts, &body);
let req = test::TestRequest::post()
.uri("/hook")
.set_payload(body)
.insert_header(("content-type", "application/json"))
.insert_header(("x-hooksmith-timestamp", ts.to_string().as_str()))
.insert_header(("x-hooksmith-signature", sig.as_str()))
.to_request();
let resp = test::call_service(&svc, req).await;
assert!(
resp.status() == StatusCode::OK || resp.status() == StatusCode::UNAUTHORIZED,
"boundary timestamp must not cause a 5xx error"
);
}
#[actix_web::test]
async fn unicode_event_type_preserved() {
let svc = make_svc!();
let body = serde_json::to_vec(&json!({"ok": true})).unwrap();
let ts = now();
let sig = sign(SECRET, ts, &body);
let req = test::TestRequest::post()
.uri("/hook")
.set_payload(body)
.insert_header(("content-type", "application/json"))
.insert_header(("x-hooksmith-timestamp", ts.to_string().as_str()))
.insert_header(("x-hooksmith-signature", sig.as_str()))
.insert_header(("x-hooksmith-event-type", "order.created"))
.to_request();
let resp = test::call_service(&svc, req).await;
assert_eq!(resp.status(), StatusCode::OK);
let body: serde_json::Value = test::read_body_json(resp).await;
assert_eq!(body["event_type"], "order.created");
}
#[actix_web::test]
async fn multiple_concurrent_requests_all_accepted() {
let svc = make_svc!();
for i in 0..20 {
let body = serde_json::to_vec(&json!({"seq": i})).unwrap();
let ts = now();
let sig = sign(SECRET, ts, &body);
let req = test::TestRequest::post()
.uri("/hook")
.set_payload(body)
.insert_header(("content-type", "application/json"))
.insert_header(("x-hooksmith-timestamp", ts.to_string().as_str()))
.insert_header(("x-hooksmith-signature", sig.as_str()))
.to_request();
let resp = test::call_service(&svc, req).await;
assert_eq!(resp.status(), StatusCode::OK, "request {i} must succeed");
}
}
#[actix_web::test]
async fn get_request_returns_405() {
let svc = make_svc!();
let req = test::TestRequest::get()
.uri("/hook")
.to_request();
let resp = test::call_service(&svc, req).await;
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[actix_web::test]
async fn sig_for_different_body_rejected() {
let svc = make_svc!();
let ts = now();
let signed_body = serde_json::to_vec(&json!({"amount": 100})).unwrap();
let sig = sign(SECRET, ts, &signed_body);
let actual_body = serde_json::to_vec(&json!({"amount": 999999})).unwrap();
let req = test::TestRequest::post()
.uri("/hook")
.set_payload(actual_body)
.insert_header(("content-type", "application/json"))
.insert_header(("x-hooksmith-timestamp", ts.to_string().as_str()))
.insert_header(("x-hooksmith-signature", sig.as_str()))
.to_request();
let resp = test::call_service(&svc, req).await;
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}