ferro-stripe 0.5.0

Stripe payment integration for the Ferro framework
Documentation
//! Stripe webhook signature verification.

use crate::Error;

/// Verifies a Stripe webhook signature and parses the event payload.
///
/// Uses HMAC-SHA256 as per the Stripe webhook verification protocol.
/// Returns the parsed [`stripe::Event`] on success.
///
/// # Errors
///
/// Returns [`Error::WebhookVerification`] when:
/// - The signature header is malformed
/// - The HMAC does not match
/// - The timestamp is more than 5 minutes old
pub fn verify_webhook(
    raw_body: &str,
    signature: &str,
    secret: &str,
) -> Result<stripe::Event, Error> {
    stripe::Webhook::construct_event(raw_body, signature, secret)
        .map_err(|e| Error::WebhookVerification(e.to_string()))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::testing::signed_webhook_payload;

    const TEST_SECRET: &str = "whsec_test_secret";

    fn minimal_event_json(event_type: &str) -> String {
        serde_json::json!({
            "id": "evt_test_001",
            "object": "event",
            "api_version": "2023-10-16",
            "created": 1533204620,
            "data": {
                "object": {
                    "id": "ii_123",
                    "object": "invoiceitem",
                    "amount": 1000,
                    "currency": "usd",
                    "customer": "cus_123",
                    "date": 1533204620,
                    "description": "Test Invoice Item",
                    "discountable": false,
                    "invoice": "in_123",
                    "livemode": false,
                    "metadata": {},
                    "period": { "start": 1533204620, "end": 1533204620 },
                    "proration": false,
                    "quantity": 1
                }
            },
            "livemode": false,
            "pending_webhooks": 1,
            "request": null,
            "type": event_type
        })
        .to_string()
    }

    #[test]
    fn verify_webhook_with_valid_signature_returns_ok() {
        let payload = minimal_event_json("invoiceitem.created");
        let (sig, _) = signed_webhook_payload(&payload, TEST_SECRET);
        let result = verify_webhook(&payload, &sig, TEST_SECRET);
        assert!(result.is_ok(), "expected Ok but got: {result:?}");
    }

    #[test]
    fn verify_webhook_with_tampered_body_returns_err() {
        let payload = minimal_event_json("invoiceitem.created");
        let (sig, _) = signed_webhook_payload(&payload, TEST_SECRET);
        let tampered = payload.replace("invoiceitem.created", "invoice.paid");
        let result = verify_webhook(&tampered, &sig, TEST_SECRET);
        assert!(
            matches!(result, Err(Error::WebhookVerification(_))),
            "expected WebhookVerification error but got: {result:?}"
        );
    }

    #[test]
    fn verify_webhook_with_wrong_secret_returns_err() {
        let payload = minimal_event_json("invoiceitem.created");
        let (sig, _) = signed_webhook_payload(&payload, TEST_SECRET);
        let result = verify_webhook(&payload, &sig, "whsec_wrong_secret");
        assert!(
            matches!(result, Err(Error::WebhookVerification(_))),
            "expected WebhookVerification error but got: {result:?}"
        );
    }
}