gatehouse 0.3.0

An in-process authorization engine for Rust with composable policies and request-scoped fact loading.
Documentation
use axum::{
    body::{to_bytes, Body},
    http::{Request, StatusCode},
    routing::{get, post},
    Router,
};
use tower::ServiceExt;
use uuid::Uuid;

mod axum_example {
    #![allow(dead_code)]
    include!(concat!(env!("CARGO_MANIFEST_DIR"), "/examples/axum.rs"));
}

fn axum_app() -> Router {
    Router::new()
        .route("/invoices", get(axum_example::list_invoices_handler))
        .route(
            "/invoices/{invoice_id}",
            get(axum_example::view_invoice_handler),
        )
        .route(
            "/invoices/{invoice_id}/edit",
            post(axum_example::edit_invoice_handler),
        )
        .route(
            "/payments/{payment_id}/approve",
            post(axum_example::approve_payment_handler),
        )
        .with_state(axum_example::AppState::demo())
}

#[tokio::test]
async fn view_invoice_allows_admin() {
    let invoice_id = Uuid::new_v4();
    let app = axum_app();

    let request = Request::builder()
        .method("GET")
        .uri(format!("/invoices/{invoice_id}"))
        .header("x-roles", "admin")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn view_invoice_denied_without_admin() {
    let invoice_id = Uuid::new_v4();
    let app = axum_app();

    let request = Request::builder()
        .method("GET")
        .uri(format!("/invoices/{invoice_id}"))
        .header("x-roles", "viewer")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn view_invoice_handles_invalid_user_header() {
    let invoice_id = Uuid::new_v4();
    let app = axum_app();

    let request = Request::builder()
        .method("GET")
        .uri(format!("/invoices/{invoice_id}"))
        .header("x-user-id", "not-a-uuid")
        .header("x-roles", "viewer")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

fn owner_id() -> Uuid {
    Uuid::parse_str("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa").unwrap()
}

fn viewer_id() -> Uuid {
    Uuid::parse_str("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee").unwrap()
}

#[tokio::test]
async fn list_invoices_uses_request_session_relationships() {
    let app = axum_app();

    let request = Request::builder()
        .method("GET")
        .uri("/invoices")
        .header("x-user-id", viewer_id().to_string())
        .header("x-roles", "viewer")
        .body(Body::empty())
        .unwrap();

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

    let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
    let body = String::from_utf8(body.to_vec()).unwrap();
    assert!(body.contains("22222222-2222-2222-2222-222222222222"));
    assert!(body.contains("33333333-3333-3333-3333-333333333333"));
    assert!(!body.contains("11111111-1111-1111-1111-111111111111"));
}

#[tokio::test]
async fn list_invoices_allows_admin_all_candidates() {
    let app = axum_app();

    let request = Request::builder()
        .method("GET")
        .uri("/invoices")
        .header("x-roles", "admin")
        .body(Body::empty())
        .unwrap();

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

    let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
    let body = String::from_utf8(body.to_vec()).unwrap();
    assert!(body.contains("11111111-1111-1111-1111-111111111111"));
    assert!(body.contains("22222222-2222-2222-2222-222222222222"));
    assert!(body.contains("33333333-3333-3333-3333-333333333333"));
}

#[tokio::test]
async fn edit_invoice_allows_owner() {
    let invoice_id = owner_id();
    let app = axum_app();

    let request = Request::builder()
        .method("POST")
        .uri(format!("/invoices/{invoice_id}/edit"))
        .header("x-user-id", invoice_id.to_string())
        .header("x-roles", "author")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn edit_invoice_denies_non_owner() {
    let invoice_id = owner_id();
    let app = axum_app();

    let request = Request::builder()
        .method("POST")
        .uri(format!("/invoices/{invoice_id}/edit"))
        .header("x-user-id", Uuid::new_v4().to_string())
        .header("x-roles", "author")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn edit_invoice_denies_stale_invoice() {
    let invoice_id = owner_id();
    let app = axum_app();

    let request = Request::builder()
        .method("POST")
        .uri(format!("/invoices/{invoice_id}/edit"))
        .header("x-user-id", invoice_id.to_string())
        .header("x-roles", "author")
        .header("x-invoice-age-days", "45")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn approve_payment_allows_finance_manager() {
    let payment_id = Uuid::new_v4();
    let app = axum_app();

    let request = Request::builder()
        .method("POST")
        .uri(format!("/payments/{payment_id}/approve"))
        .header("x-roles", "finance_manager")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn approve_payment_allows_reapproving() {
    let payment_id = Uuid::new_v4();
    let app = axum_app();

    let request = Request::builder()
        .method("POST")
        .uri(format!("/payments/{payment_id}/approve"))
        .header("x-roles", "finance_manager")
        .header("x-payment-approved", "true")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::OK);
}

#[tokio::test]
async fn approve_payment_denies_regular_user() {
    let payment_id = Uuid::new_v4();
    let app = axum_app();

    let request = Request::builder()
        .method("POST")
        .uri(format!("/payments/{payment_id}/approve"))
        .header("x-roles", "viewer")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}

#[tokio::test]
async fn approve_payment_denies_refunded_payment() {
    let payment_id = Uuid::new_v4();
    let app = axum_app();

    let request = Request::builder()
        .method("POST")
        .uri(format!("/payments/{payment_id}/approve"))
        .header("x-roles", "finance_manager")
        .header("x-payment-refunded", "true")
        .body(Body::empty())
        .unwrap();

    let response = app.oneshot(request).await.unwrap();
    assert_eq!(response.status(), StatusCode::FORBIDDEN);
}