Skip to main content

max / makenotwork

8.8 KB · 235 lines History Blame Raw
1 //! Stripe test helpers — webhook signature computation and mock payment provider.
2
3 use hmac::{Hmac, Mac};
4 use sha2::Sha256;
5 use std::sync::Mutex;
6 use std::time::{SystemTime, UNIX_EPOCH};
7
8 use makenotwork::error::{AppError, Result};
9 use makenotwork::payments::{
10 AccountUpdate, BalanceSummary, CheckoutParams, CheckoutResult, PaymentProvider,
11 SubscriptionCheckoutParams, TipCheckoutParams,
12 };
13
14 #[allow(dead_code)]
15 type HmacSha256 = Hmac<Sha256>;
16
17 /// Known test webhook secret used by the test harness `with_stripe()` builder.
18 #[allow(dead_code)]
19 pub const TEST_WEBHOOK_SECRET: &str = "whsec_test_secret";
20
21 /// Known test webhook secret for v2 thin events.
22 #[allow(dead_code)]
23 pub const TEST_WEBHOOK_SECRET_V2: &str = "whsec_test_secret_v2";
24
25 /// Compute a valid `Stripe-Signature` header value for the given payload.
26 ///
27 /// Mirrors Stripe's signing scheme:
28 /// signed_payload = "{timestamp}.{payload}"
29 /// signature = HMAC-SHA256(secret, signed_payload)
30 /// header = "t={timestamp},v1={hex(signature)}"
31 #[allow(dead_code)]
32 pub fn sign_webhook_payload(payload: &str, secret: &str) -> String {
33 let timestamp = SystemTime::now()
34 .duration_since(UNIX_EPOCH)
35 .unwrap()
36 .as_secs();
37
38 sign_webhook_payload_with_timestamp(payload, secret, timestamp)
39 }
40
41 /// Like [`sign_webhook_payload`] but with an explicit timestamp (seconds since epoch).
42 #[allow(dead_code)]
43 pub fn sign_webhook_payload_with_timestamp(payload: &str, secret: &str, timestamp: u64) -> String {
44 let signed_payload = format!("{}.{}", timestamp, payload);
45
46 let mut mac = HmacSha256::new_from_slice(secret.as_bytes())
47 .expect("HMAC accepts any key length");
48 mac.update(signed_payload.as_bytes());
49 let result = mac.finalize();
50 let hex_sig = hex::encode(result.into_bytes());
51
52 format!("t={},v1={}", timestamp, hex_sig)
53 }
54
55 /// Record of a checkout session created by the mock.
56 #[derive(Debug, Clone)]
57 #[allow(dead_code)]
58 pub struct MockCheckout {
59 pub id: String,
60 pub url: String,
61 }
62
63 /// Mock payment provider for integration tests.
64 ///
65 /// Returns predictable fake data for all operations. Records checkout
66 /// creations so tests can assert on them. Webhook verification uses the
67 /// test webhook secrets defined above.
68 pub struct MockPaymentProvider {
69 checkouts: Mutex<Vec<MockCheckout>>,
70 next_checkout_id: Mutex<u64>,
71 }
72
73 #[allow(dead_code)]
74 impl MockPaymentProvider {
75 pub fn new() -> Self {
76 MockPaymentProvider {
77 checkouts: Mutex::new(Vec::new()),
78 next_checkout_id: Mutex::new(1),
79 }
80 }
81
82 /// Return all checkouts created so far.
83 pub fn checkouts(&self) -> Vec<MockCheckout> {
84 self.checkouts.lock().unwrap().clone()
85 }
86
87 fn next_session(&self) -> CheckoutResult {
88 let mut counter = self.next_checkout_id.lock().unwrap();
89 let id = format!("cs_test_{}", *counter);
90 let url = format!("https://checkout.stripe.com/test/{}", id);
91 *counter += 1;
92 self.checkouts.lock().unwrap().push(MockCheckout {
93 id: id.clone(),
94 url: url.clone(),
95 });
96 CheckoutResult { id, url: Some(url) }
97 }
98 }
99
100 #[async_trait::async_trait]
101 impl PaymentProvider for MockPaymentProvider {
102 async fn create_checkout_session(&self, _params: &CheckoutParams<'_>) -> Result<CheckoutResult> {
103 Ok(self.next_session())
104 }
105
106 async fn create_guest_checkout_session(&self, _params: &makenotwork::payments::GuestCheckoutParams<'_>) -> Result<CheckoutResult> {
107 Ok(self.next_session())
108 }
109
110 async fn create_subscription_checkout_session(&self, _params: &SubscriptionCheckoutParams<'_>) -> Result<CheckoutResult> {
111 Ok(self.next_session())
112 }
113
114 async fn create_tip_checkout_session(&self, _params: &TipCheckoutParams<'_>) -> Result<CheckoutResult> {
115 Ok(self.next_session())
116 }
117
118 async fn create_fan_plus_checkout_session(&self, _price_id: &str, _user_id: makenotwork::db::UserId, _success_url: &str, _cancel_url: &str) -> Result<CheckoutResult> {
119 Ok(self.next_session())
120 }
121
122 async fn create_creator_tier_checkout_session(&self, _price_id: &str, _user_id: makenotwork::db::UserId, _tier: &str, _success_url: &str, _cancel_url: &str) -> Result<CheckoutResult> {
123 Ok(self.next_session())
124 }
125
126 async fn create_cart_checkout_session(&self, _params: &makenotwork::payments::CartCheckoutParams<'_>) -> Result<CheckoutResult> {
127 Ok(self.next_session())
128 }
129
130 async fn create_connect_account(&self, _email: &str) -> Result<String> {
131 Ok("acct_test_mock".to_string())
132 }
133
134 async fn create_account_link(&self, _account_id: &str, _return_url: &str, _refresh_url: &str) -> Result<String> {
135 Ok("https://connect.stripe.com/test/onboarding".to_string())
136 }
137
138 async fn fetch_account(&self, account_id: &str) -> Result<AccountUpdate> {
139 Ok(AccountUpdate {
140 account_id: account_id.to_string(),
141 charges_enabled: true,
142 payouts_enabled: true,
143 details_submitted: true,
144 })
145 }
146
147 async fn create_subscription_product_and_price(&self, _connected_account_id: &str, _tier_name: &str, _tier_description: Option<&str>, _price_cents: i64) -> Result<(String, String)> {
148 Ok(("prod_test_mock".to_string(), "price_test_mock".to_string()))
149 }
150
151 async fn get_balance(&self, _account_id: &str) -> Result<BalanceSummary> {
152 Ok(BalanceSummary { available_cents: 0, pending_cents: 0 })
153 }
154
155 fn verify_webhook(&self, payload: &str, signature: &str) -> Result<makenotwork::payments::UntypedEvent> {
156 makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET)
157 .map_err(AppError::BadRequest)?;
158 makenotwork::payments::UntypedEvent::from_payload(payload)
159 }
160
161 fn verify_webhook_v2(&self, payload: &str, signature: &str) -> Result<serde_json::Value> {
162 makenotwork::payments::verify_signature(payload, signature, TEST_WEBHOOK_SECRET_V2)
163 .map_err(AppError::BadRequest)?;
164 serde_json::from_str(payload)
165 .map_err(|e| AppError::BadRequest(format!("Invalid payload: {}", e)))
166 }
167
168 async fn pause_subscription(&self, _stripe_sub_id: &str, _connected_account_id: &str) -> Result<()> {
169 Ok(())
170 }
171
172 async fn resume_subscription(&self, _stripe_sub_id: &str, _connected_account_id: &str) -> Result<()> {
173 Ok(())
174 }
175
176 async fn cancel_subscription(&self, _stripe_sub_id: &str, _connected_account_id: &str) -> Result<()> {
177 Ok(())
178 }
179
180 async fn set_cancel_at_period_end(&self, _stripe_sub_id: &str, _connected_account_id: &str, _cancel: bool) -> Result<()> {
181 Ok(())
182 }
183
184 async fn cancel_platform_subscription(&self, _stripe_sub_id: &str) -> Result<()> {
185 Ok(())
186 }
187
188 async fn set_platform_cancel_at_period_end(&self, _stripe_sub_id: &str, _cancel: bool) -> Result<()> {
189 Ok(())
190 }
191
192 async fn create_billing_portal_session(&self, _stripe_customer_id: &str, return_url: &str) -> Result<String> {
193 // Echo a deterministic URL so tests can assert the redirect target.
194 Ok(format!("https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url)))
195 }
196
197 async fn create_refund(&self, _payment_intent_id: &str, _connected_account_id: &str) -> Result<()> {
198 Ok(())
199 }
200
201 async fn create_synckit_customer(&self, _developer_user_id: makenotwork::db::UserId, _email: &str, _app_name: &str) -> Result<String> {
202 // Deterministic dummy; tests assert on shape, not content.
203 Ok("cus_test_synckit".to_string())
204 }
205
206 async fn create_synckit_subscription(&self, _customer_id: &str, app_id: makenotwork::db::SyncAppId, _app_name: &str, _price_cents: i64) -> Result<makenotwork::payments::SynckitSubResult> {
207 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs() as i64;
208 Ok(makenotwork::payments::SynckitSubResult {
209 subscription_id: format!("sub_test_{}", app_id),
210 current_period_start: now,
211 current_period_end: now + 30 * 24 * 60 * 60,
212 })
213 }
214
215 async fn update_synckit_subscription_price(&self, _subscription_id: &str, _new_price_cents: i64, _app_name: &str) -> Result<()> {
216 Ok(())
217 }
218
219 async fn update_synckit_app_sub_price(&self, _subscription_id: &str, _new_price_cents: i64, _interval: makenotwork::payments::SyncBillingInterval, _product_name: &str) -> Result<()> {
220 Ok(())
221 }
222
223 async fn create_synckit_app_sub_checkout_session(&self, _params: &makenotwork::payments::SynckitAppSubCheckoutParams<'_>) -> Result<CheckoutResult> {
224 Ok(self.next_session())
225 }
226
227 async fn cancel_synckit_subscription(&self, _subscription_id: &str) -> Result<()> {
228 Ok(())
229 }
230
231 async fn create_synckit_billing_portal(&self, _customer_id: &str, return_url: &str) -> Result<String> {
232 Ok(format!("https://billing.stripe.test/portal?return={}", urlencoding::encode(return_url)))
233 }
234 }
235