blooio 0.3.0

Typed, low-overhead Rust client for the Blooio API (iMessage/SMS automation), with sync and async surfaces.
Documentation
//! Integration tests for the axum webhook extractor.

#![cfg(feature = "axum")]
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use std::time::{SystemTime, UNIX_EPOCH};

use axum::Router;
use axum::body::Body;
use axum::extract::FromRef;
use axum::http::{Request, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::post;
use blooio::webhook::{
    DEFAULT_TOLERANCE_SECS, ResolvedWebhook, SignatureHeader, VerifiedWebhook, WebhookRejection,
    WebhookVerificationResolver, WebhookVerifier, peek, verify_preparsed,
};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use tower::ServiceExt;

const SECRET: &str = "whsec_axum_test";

fn now() -> i64 {
    i64::try_from(
        SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .unwrap()
            .as_secs(),
    )
    .unwrap()
}

fn sign(timestamp: i64, body: &[u8]) -> String {
    let mut mac = <Hmac<Sha256>>::new_from_slice(SECRET.as_bytes()).unwrap();
    mac.update(timestamp.to_string().as_bytes());
    mac.update(b".");
    mac.update(body);
    format!(
        "t={timestamp},v1={}",
        hex::encode(mac.finalize().into_bytes())
    )
}

async fn handler(VerifiedWebhook(event): VerifiedWebhook) -> String {
    event.payload.message_id.unwrap_or_default()
}

async fn dynamic_handler(
    ResolvedWebhook { event, context }: ResolvedWebhook<DynamicResolver>,
) -> String {
    format!(
        "{}:{}",
        context.org_id,
        event.payload.message_id.unwrap_or_default()
    )
}

fn app() -> Router {
    Router::new()
        .route("/webhooks", post(handler))
        .with_state(WebhookVerifier::new(SECRET))
}

fn dynamic_app() -> Router {
    Router::new()
        .route("/webhooks", post(dynamic_handler))
        .with_state(DynamicState {
            resolver: DynamicResolver,
        })
}

async fn send(headers: &[(&str, &str)], body: &[u8]) -> StatusCode {
    let mut req = Request::builder().method("POST").uri("/webhooks");
    for (k, v) in headers {
        req = req.header(*k, *v);
    }
    let resp = app()
        .oneshot(req.body(Body::from(body.to_vec())).unwrap())
        .await
        .unwrap();
    resp.status()
}

#[tokio::test]
async fn valid_signature_is_accepted_and_parsed() {
    let body = br#"{"event":"message.received","message_id":"m_axum"}"#;
    let header = sign(now(), body);
    let resp = app()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/webhooks")
                .header("Blooio-Signature", header)
                .body(Body::from(body.to_vec()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .unwrap();
    assert_eq!(&bytes[..], b"m_axum");
}

#[tokio::test]
async fn x_blooio_signature_alias_is_accepted() {
    let body = br#"{"event":"message.received","message_id":"m_axum_alias"}"#;
    let header = sign(now(), body);
    let resp = app()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/webhooks")
                .header("x-blooio-signature", header)
                .body(Body::from(body.to_vec()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
}

#[tokio::test]
async fn dynamic_resolver_can_verify_and_return_context() {
    let body = br#"{"event":"message.received","protocol":"sms","message_id":"m_dynamic","sender":"+15550002222","internal_id":"+15550001111","text":"hi"}"#;
    let header = sign(now(), body);
    let resp = dynamic_app()
        .oneshot(
            Request::builder()
                .method("POST")
                .uri("/webhooks")
                .header("x-blooio-signature", header)
                .body(Body::from(body.to_vec()))
                .unwrap(),
        )
        .await
        .unwrap();
    assert_eq!(resp.status(), StatusCode::OK);
    let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
        .await
        .unwrap();
    assert_eq!(&bytes[..], b"org_1:m_dynamic");
}

#[tokio::test]
async fn missing_signature_is_unauthorized() {
    let status = send(&[], br#"{"event":"message.received"}"#).await;
    assert_eq!(status, StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn bad_signature_is_unauthorized() {
    let status = send(
        &[("Blooio-Signature", "t=1700000000,v1=deadbeef")],
        br#"{"event":"message.received"}"#,
    )
    .await;
    assert_eq!(status, StatusCode::UNAUTHORIZED);
}

#[derive(Clone)]
struct DynamicState {
    resolver: DynamicResolver,
}

#[derive(Clone)]
struct DynamicResolver;

#[derive(Debug, Clone, PartialEq, Eq)]
struct DynamicContext {
    org_id: &'static str,
}

#[derive(Debug)]
enum DynamicError {
    Rejection(WebhookRejection),
    UnknownInternalId,
}

impl From<WebhookRejection> for DynamicError {
    fn from(value: WebhookRejection) -> Self {
        DynamicError::Rejection(value)
    }
}

impl IntoResponse for DynamicError {
    fn into_response(self) -> Response {
        match self {
            DynamicError::Rejection(rejection) => rejection.into_response(),
            DynamicError::UnknownInternalId => StatusCode::NO_CONTENT.into_response(),
        }
    }
}

impl FromRef<DynamicState> for DynamicResolver {
    fn from_ref(state: &DynamicState) -> Self {
        state.resolver.clone()
    }
}

impl WebhookVerificationResolver for DynamicResolver {
    type Context = DynamicContext;
    type Error = DynamicError;
    type Future<'a> = std::future::Ready<Result<DynamicContext, DynamicError>>;

    fn verify<'a>(
        &'a self,
        signature: &'a SignatureHeader,
        raw_body: &'a [u8],
    ) -> Self::Future<'a> {
        std::future::ready((|| {
            signature
                .check_tolerance(now(), DEFAULT_TOLERANCE_SECS)
                .map_err(WebhookRejection::InvalidSignature)?;
            let peeked = peek(raw_body).map_err(WebhookRejection::Malformed)?;
            if peeked.internal_id.as_deref() != Some("+15550001111") {
                return Err(DynamicError::UnknownInternalId);
            }
            verify_preparsed(SECRET.as_bytes(), signature, raw_body)
                .map_err(WebhookRejection::InvalidSignature)?;
            Ok(DynamicContext { org_id: "org_1" })
        })())
    }
}