bsv-payment-actix-middleware 0.1.0

BSV payment middleware for Actix-web, wire-compatible with the TypeScript payment-express-middleware
Documentation
//! Payment error types with wire-compatible JSON error responses.

use actix_web::http::StatusCode;
use actix_web::HttpResponse;

/// Payment error enum with variants matching the TypeScript payment-express-middleware.
///
/// Each variant produces a JSON error response with the exact same shape and strings
/// as the TypeScript implementation: `{"status":"error","code":"ERR_*","description":"..."}`.
///
/// Note: `ERR_PAYMENT_REQUIRED` (402) is NOT included here. The 402 response is built
/// directly in `middleware.rs` because it includes extra fields (`satoshisRequired`)
/// and custom headers that do not fit the standard `ResponseError` pattern.
#[derive(Debug, thiserror::Error)]
pub enum PaymentError {
    /// Auth middleware did not run before payment middleware.
    #[error("The payment middleware must be executed after the Auth middleware.")]
    ServerMisconfigured,

    /// The `X-BSV-Payment` header could not be parsed as JSON.
    #[error("The X-BSV-Payment header is not valid JSON.")]
    MalformedPayment,

    /// The derivation prefix header is missing or invalid.
    #[error("The X-BSV-Payment-Derivation-Prefix header is not valid.")]
    InvalidDerivationPrefix,

    /// Payment verification or internalization failed.
    #[error("{0}")]
    PaymentFailed(String),

    /// An internal error occurred during price calculation.
    #[error("An internal error occurred while determining the payment required for this request.")]
    PaymentInternal,
}

impl actix_web::error::ResponseError for PaymentError {
    fn status_code(&self) -> StatusCode {
        match self {
            Self::ServerMisconfigured => StatusCode::INTERNAL_SERVER_ERROR,
            Self::MalformedPayment => StatusCode::BAD_REQUEST,
            Self::InvalidDerivationPrefix => StatusCode::BAD_REQUEST,
            Self::PaymentFailed(_) => StatusCode::BAD_REQUEST,
            Self::PaymentInternal => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }

    fn error_response(&self) -> HttpResponse {
        let code = match self {
            Self::ServerMisconfigured => "ERR_SERVER_MISCONFIGURED",
            Self::MalformedPayment => "ERR_MALFORMED_PAYMENT",
            Self::InvalidDerivationPrefix => "ERR_INVALID_DERIVATION_PREFIX",
            Self::PaymentFailed(_) => "ERR_PAYMENT_FAILED",
            Self::PaymentInternal => "ERR_PAYMENT_INTERNAL",
        };
        HttpResponse::build(self.status_code()).json(serde_json::json!({
            "status": "error",
            "code": code,
            "description": self.to_string()
        }))
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use actix_web::body::to_bytes;
    use actix_web::error::ResponseError;

    /// Helper to extract the JSON body from an error response.
    async fn error_body_json(err: &PaymentError) -> serde_json::Value {
        let response = err.error_response();
        let body = to_bytes(response.into_body()).await.unwrap();
        serde_json::from_slice(&body).unwrap()
    }

    #[actix_rt::test]
    async fn test_server_misconfigured_status() {
        let err = PaymentError::ServerMisconfigured;
        assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
    }

    #[actix_rt::test]
    async fn test_server_misconfigured_body() {
        let err = PaymentError::ServerMisconfigured;
        let body = error_body_json(&err).await;
        assert_eq!(body["status"], "error");
        assert_eq!(body["code"], "ERR_SERVER_MISCONFIGURED");
        assert_eq!(
            body["description"],
            "The payment middleware must be executed after the Auth middleware."
        );
    }

    #[actix_rt::test]
    async fn test_malformed_payment_status() {
        let err = PaymentError::MalformedPayment;
        assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
    }

    #[actix_rt::test]
    async fn test_malformed_payment_body() {
        let err = PaymentError::MalformedPayment;
        let body = error_body_json(&err).await;
        assert_eq!(body["status"], "error");
        assert_eq!(body["code"], "ERR_MALFORMED_PAYMENT");
        assert_eq!(
            body["description"],
            "The X-BSV-Payment header is not valid JSON."
        );
    }

    #[actix_rt::test]
    async fn test_invalid_derivation_prefix_status() {
        let err = PaymentError::InvalidDerivationPrefix;
        assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
    }

    #[actix_rt::test]
    async fn test_invalid_derivation_prefix_body() {
        let err = PaymentError::InvalidDerivationPrefix;
        let body = error_body_json(&err).await;
        assert_eq!(body["status"], "error");
        assert_eq!(body["code"], "ERR_INVALID_DERIVATION_PREFIX");
        assert_eq!(
            body["description"],
            "The X-BSV-Payment-Derivation-Prefix header is not valid."
        );
    }

    #[actix_rt::test]
    async fn test_payment_failed_status() {
        let err = PaymentError::PaymentFailed("Custom msg".into());
        assert_eq!(err.status_code(), StatusCode::BAD_REQUEST);
    }

    #[actix_rt::test]
    async fn test_payment_failed_body_custom_message() {
        let err = PaymentError::PaymentFailed("Custom msg".into());
        let body = error_body_json(&err).await;
        assert_eq!(body["status"], "error");
        assert_eq!(body["code"], "ERR_PAYMENT_FAILED");
        assert_eq!(body["description"], "Custom msg");
    }

    #[actix_rt::test]
    async fn test_payment_failed_body_empty_message() {
        let err = PaymentError::PaymentFailed("".into());
        let body = error_body_json(&err).await;
        assert_eq!(body["status"], "error");
        assert_eq!(body["code"], "ERR_PAYMENT_FAILED");
        assert_eq!(body["description"], "");
    }

    #[actix_rt::test]
    async fn test_payment_internal_status() {
        let err = PaymentError::PaymentInternal;
        assert_eq!(err.status_code(), StatusCode::INTERNAL_SERVER_ERROR);
    }

    #[actix_rt::test]
    async fn test_payment_internal_body() {
        let err = PaymentError::PaymentInternal;
        let body = error_body_json(&err).await;
        assert_eq!(body["status"], "error");
        assert_eq!(body["code"], "ERR_PAYMENT_INTERNAL");
        assert_eq!(
            body["description"],
            "An internal error occurred while determining the payment required for this request."
        );
    }

    #[test]
    fn test_no_payment_required_variant() {
        // Verify that the error enum does not have a PaymentRequired variant.
        // This is a compile-time check -- if someone adds a PaymentRequired variant,
        // this match will fail to compile due to non-exhaustive pattern.
        let err = PaymentError::ServerMisconfigured;
        match err {
            PaymentError::ServerMisconfigured => {}
            PaymentError::MalformedPayment => {}
            PaymentError::InvalidDerivationPrefix => {}
            PaymentError::PaymentFailed(_) => {}
            PaymentError::PaymentInternal => {}
        }
    }

    #[actix_rt::test]
    async fn test_all_errors_have_three_fields() {
        // Every error response body must have exactly: status, code, description.
        let errors: Vec<PaymentError> = vec![
            PaymentError::ServerMisconfigured,
            PaymentError::MalformedPayment,
            PaymentError::InvalidDerivationPrefix,
            PaymentError::PaymentFailed("test".into()),
            PaymentError::PaymentInternal,
        ];
        for err in &errors {
            let body = error_body_json(err).await;
            let obj = body.as_object().unwrap();
            assert_eq!(obj.len(), 3, "Error {:?} should have exactly 3 fields", err);
            assert!(obj.contains_key("status"));
            assert!(obj.contains_key("code"));
            assert!(obj.contains_key("description"));
        }
    }
}