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() {
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);
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);
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(); 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(_)));
}