socle 3.0.0

Opinionated axum service bootstrap: telemetry, database, rate limiting, and shutdown in one builder
Documentation
#![cfg(feature = "test-util")]

use std::sync::Arc;

use axum::Router;
use axum::body::Body;
use axum::extract::Extension;
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::post;
use tower::ServiceExt;

use socle::{AuditAnnotation, AuditAnnotationSlot, AuditLayer, ChannelAuditSink};

async fn create_handler(Extension(slot): Extension<AuditAnnotationSlot>) -> impl IntoResponse {
    slot.annotate(
        AuditAnnotation::default()
            .set_resource("order", "ord-1")
            .set_action("create"),
    );
    StatusCode::CREATED
}

async fn get_handler() -> impl IntoResponse {
    StatusCode::OK
}

fn build_router(sink: Arc<ChannelAuditSink>) -> Router {
    Router::new()
        .route("/orders", post(create_handler))
        .route("/orders", axum::routing::get(get_handler))
        .layer(AuditLayer::new(sink))
}

#[tokio::test]
async fn emits_one_event_per_matching_post() {
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    let sink = Arc::new(ChannelAuditSink::new(tx));
    let app = build_router(sink);

    let req = Request::builder()
        .method("POST")
        .uri("/orders")
        .body(Body::empty())
        .unwrap();

    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    tokio::task::yield_now().await;

    let event = rx.try_recv().expect("one audit event expected");
    assert_eq!(event.method, "POST");
    assert_eq!(event.status, 201);
    assert_eq!(event.resource_type.as_deref(), Some("order"));
    assert_eq!(event.resource_id.as_deref(), Some("ord-1"));
    assert_eq!(event.action.as_deref(), Some("create"));
}

#[tokio::test]
async fn no_event_for_get_request() {
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    let sink = Arc::new(ChannelAuditSink::new(tx));
    let app = build_router(sink);

    let req = Request::builder()
        .method("GET")
        .uri("/orders")
        .body(Body::empty())
        .unwrap();

    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::OK);

    tokio::task::yield_now().await;

    assert!(rx.try_recv().is_err(), "GET should not emit an audit event");
}

#[tokio::test]
async fn no_event_for_healthz_path() {
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    let sink = Arc::new(ChannelAuditSink::new(tx));

    let app = Router::new()
        .route("/healthz", axum::routing::get(get_handler))
        .route("/healthz", post(create_handler))
        .layer(AuditLayer::new(sink));

    let req = Request::builder()
        .method("POST")
        .uri("/healthz")
        .body(Body::empty())
        .unwrap();

    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    tokio::task::yield_now().await;

    assert!(
        rx.try_recv().is_err(),
        "POST /healthz should not emit an audit event"
    );
}

#[tokio::test]
async fn annotation_without_slot_does_not_panic() {
    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
    let sink = Arc::new(ChannelAuditSink::new(tx));

    async fn bare_handler() -> impl IntoResponse {
        StatusCode::CREATED
    }

    let app = Router::new()
        .route("/items", post(bare_handler))
        .layer(AuditLayer::new(sink));

    let req = Request::builder()
        .method("POST")
        .uri("/items")
        .body(Body::empty())
        .unwrap();

    let resp = app.oneshot(req).await.unwrap();
    assert_eq!(resp.status(), StatusCode::CREATED);

    tokio::task::yield_now().await;

    let event = rx
        .try_recv()
        .expect("event expected even without annotation");
    assert!(event.resource_type.is_none());
}