fraiseql_webhooks/traits.rs
1//! Testing seams for webhook dependencies.
2//!
3//! All external dependencies are abstracted behind traits for easy testing.
4
5use serde_json::Value;
6use sqlx::{Postgres, Transaction};
7
8use super::{Result, signature::SignatureError};
9
10/// Signature verification abstraction for testing
11pub trait SignatureVerifier: Send + Sync {
12 /// Provider name (e.g., "stripe", "github")
13 fn name(&self) -> &'static str;
14
15 /// Header name containing the signature
16 fn signature_header(&self) -> &'static str;
17
18 /// Verify the signature
19 ///
20 /// # Arguments
21 ///
22 /// * `payload` - Raw request body bytes
23 /// * `signature` - Signature from header
24 /// * `secret` - Webhook signing secret
25 /// * `timestamp` - Optional timestamp from headers (for replay protection)
26 /// * `url` - Full request URL (required by Twilio; ignored by most providers)
27 ///
28 /// # Errors
29 ///
30 /// Returns `SignatureError` if the signature format is invalid or cannot be parsed.
31 ///
32 /// # Returns
33 ///
34 /// `Ok(true)` if signature is valid, `Ok(false)` if invalid, `Err` for format errors
35 fn verify(
36 &self,
37 payload: &[u8],
38 signature: &str,
39 secret: &str,
40 timestamp: Option<&str>,
41 url: Option<&str>,
42 ) -> std::result::Result<bool, SignatureError>;
43
44 /// Optional: Extract timestamp from signature or headers
45 fn extract_timestamp(&self, _signature: &str) -> Option<i64> {
46 None
47 }
48}
49
50/// Idempotency store abstraction for testing
51#[allow(async_fn_in_trait)] // Reason: trait is used with concrete types only, not dyn Trait
52pub trait IdempotencyStore: Send + Sync {
53 /// Check if event has already been processed
54 async fn check(&self, provider: &str, event_id: &str) -> Result<bool>;
55
56 /// Record processed event
57 async fn record(
58 &self,
59 provider: &str,
60 event_id: &str,
61 event_type: &str,
62 status: &str,
63 ) -> Result<uuid::Uuid>;
64
65 /// Update event status
66 async fn update_status(
67 &self,
68 provider: &str,
69 event_id: &str,
70 status: &str,
71 error: Option<&str>,
72 ) -> Result<()>;
73}
74
75/// Secret provider abstraction for testing
76#[allow(async_fn_in_trait)] // Reason: trait is used with concrete types only, not dyn Trait
77pub trait SecretProvider: Send + Sync {
78 /// Get webhook secret by name
79 async fn get_secret(&self, name: &str) -> Result<String>;
80}
81
82/// Event handler abstraction for testing
83#[allow(async_fn_in_trait)] // Reason: trait is used with concrete types only, not dyn Trait
84pub trait EventHandler: Send + Sync {
85 /// Handle webhook event by calling database function
86 async fn handle(
87 &self,
88 function_name: &str,
89 params: Value,
90 tx: &mut Transaction<'_, Postgres>,
91 ) -> Result<Value>;
92}
93
94/// Clock abstraction for testing timestamp validation
95pub trait Clock: Send + Sync {
96 /// Get current Unix timestamp
97 fn now(&self) -> i64;
98}
99
100/// Production `Clock` implementation that delegates to `std::time::SystemTime`.
101pub struct SystemClock;
102
103impl Clock for SystemClock {
104 fn now(&self) -> i64 {
105 std::time::SystemTime::now()
106 .duration_since(std::time::UNIX_EPOCH)
107 .unwrap_or(std::time::Duration::ZERO)
108 .as_secs() as i64
109 }
110}