snippe 0.1.0

Async Rust client for the Snippe payments API (Tanzania) — collections, hosted checkout sessions, disbursements, and verified webhooks.
Documentation
//! End-to-end webhook signature tests.

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

use hmac::{Hmac, Mac};
use sha2::Sha256;
use snippe::webhook::{EventData, Verifier, WebhookError};

type HmacSha256 = Hmac<Sha256>;

fn sign(key: &[u8], timestamp: &str, body: &[u8]) -> String {
    let mut mac = <HmacSha256 as Mac>::new_from_slice(key).unwrap();
    mac.update(timestamp.as_bytes());
    mac.update(b".");
    mac.update(body);
    hex::encode(mac.finalize().into_bytes())
}

fn current_ts() -> String {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
        .to_string()
}

#[test]
fn raw_body_must_match_byte_for_byte() {
    // Re-serialising JSON breaks the HMAC, even when the JSON is semantically
    // identical. This test enforces that we sign-and-verify against the raw
    // bytes, never a re-encoded body.
    let key = b"signing_key";
    let original = br#"{"id":"evt_1","type":"payment.completed","api_version":"2026-01-25","created_at":"2026-01-24T10:30:00Z","data":{"reference":"r","status":"completed","amount":{"value":500,"currency":"TZS"}}}"#;
    let ts = current_ts();
    let sig = sign(key, &ts, original);

    // Round-tripping through serde adds spaces / reorders keys.
    let value: serde_json::Value = serde_json::from_slice(original).unwrap();
    let reserialised = serde_json::to_vec_pretty(&value).unwrap();
    assert_ne!(original.as_slice(), reserialised.as_slice());

    let verifier = Verifier::new(key.to_vec());
    assert!(verifier.verify(original, &ts, &sig).is_ok());
    assert!(matches!(
        verifier.verify(&reserialised, &ts, &sig).unwrap_err(),
        WebhookError::SignatureMismatch
    ));
}

#[test]
fn payment_completed_event_settlement_breakdown() {
    let key = b"k";
    let body = serde_json::to_vec(&serde_json::json!({
        "id": "evt_settle",
        "type": "payment.completed",
        "api_version": "2026-01-25",
        "created_at": "2026-01-24T10:30:00Z",
        "data": {
            "reference": "pi_settle",
            "status": "completed",
            "amount": {"value": 50000, "currency": "TZS"},
            "settlement": {
                "gross": {"value": 50000, "currency": "TZS"},
                "fees":  {"value": 1000,  "currency": "TZS"},
                "net":   {"value": 49000, "currency": "TZS"}
            },
            "channel": {"type": "mobile_money", "provider": "mpesa"}
        }
    }))
    .unwrap();
    let ts = current_ts();
    let sig = sign(key, &ts, &body);

    let v = Verifier::new(key.to_vec());
    let event = v.verify_typed(&body, &ts, &sig).unwrap();

    match event.data {
        EventData::PaymentCompleted(p) => {
            let s = p.settlement.expect("settlement breakdown present");
            assert_eq!(s.gross.value, 50_000);
            assert_eq!(s.fees.value, 1_000);
            assert_eq!(s.net.value, 49_000);
            // Fees + net == gross — sanity check
            assert_eq!(s.fees.value + s.net.value, s.gross.value);
        }
        other => panic!("expected PaymentCompleted, got {other:?}"),
    }
}

#[test]
fn rejects_old_timestamp_with_default_tolerance() {
    let key = b"k";
    let body = b"{\"id\":\"e\",\"type\":\"payment.completed\",\"api_version\":\"2026-01-25\",\"created_at\":\"x\",\"data\":{}}";
    let old = (SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
        - 600)
        .to_string(); // 10 minutes ago
    let sig = sign(key, &old, body);

    let v = Verifier::new(key.to_vec());
    let err = v.verify(body, &old, &sig).unwrap_err();
    assert!(matches!(err, WebhookError::TimestampTooOld(_)));
}