Skip to main content

max / makenotwork

9.4 KB · 257 lines History Blame Raw
1 //! Route-layer contract tests for `routes::stripe::connect`, the Account Links
2 //! flow a creator walks once to become able to take money at all.
3 //!
4 //! Nothing covered this file. `stripe_disconnect` covers taking the connection
5 //! away; the four handlers that put it there had no test, which left the two
6 //! things this flow can get expensively wrong unobserved.
7 //!
8 //! The first is duplicate connected accounts. `stripe_connect_proceed` creates a
9 //! Standard account at the provider, and Standard accounts are not auto-cleaned:
10 //! every extra one is a live account on the platform that somebody has to delete
11 //! by hand in the Stripe dashboard. A creator who abandons onboarding and comes
12 //! back is the ordinary case, so reuse of the claimed id is the contract, not an
13 //! optimisation.
14 //!
15 //! The second is the response shape. The handler answers JSON rather than a 303
16 //! because the page calls it with `fetch()`, and `fetch()` cannot follow a
17 //! cross-origin redirect to Stripe (Stripe sends no CORS headers). A well-meaning
18 //! change to `Redirect::to` would look more idiomatic, pass any test that only
19 //! checked for success, and strand every creator on a silently failing button.
20 //!
21 //! Also pinned: the flow is closed to sandbox accounts (a sandbox user reaching
22 //! Stripe would create a real connected account for a fake person), the
23 //! disclaimer needs a session, and the two cross-site landing pages render for
24 //! an unauthenticated request, since the browser arrives at them from Stripe
25 //! carrying whatever cookies a cross-site navigation carries.
26
27 use crate::harness::TestHarness;
28 use crate::harness::faults::stripe_unavailable;
29 use makenotwork::db::UserId;
30
31 /// The `stripe_account_id` stored for a user, if any.
32 async fn stored_account(h: &TestHarness, user_id: UserId) -> Option<String> {
33 sqlx::query_scalar::<_, Option<String>>("SELECT stripe_account_id FROM users WHERE id = $1")
34 .bind(user_id)
35 .fetch_one(&h.db)
36 .await
37 .expect("read stripe_account_id")
38 }
39
40 /// How many times the provider was asked to create a connected account.
41 fn creations(h: &TestHarness) -> u32 {
42 h.mock_stripe
43 .as_ref()
44 .expect("with_mocks provides a payment provider")
45 .faults()
46 .calls("create_connect_account")
47 }
48
49 /// The URL the page will send the creator to.
50 fn onboarding_url(resp: &crate::harness::client::TestResponse) -> String {
51 resp.json::<serde_json::Value>()
52 .get("url")
53 .and_then(|v| v.as_str())
54 .unwrap_or_else(|| panic!("proceed must answer an object with a url: {}", resp.text))
55 .to_string()
56 }
57
58 /// Onboarding is answered as JSON, not as a redirect, and the body carries the
59 /// provider's link. The status assertion is the load-bearing half: a 3xx here
60 /// is a dead button in the browser, however correct the Location header is.
61 #[tokio::test]
62 async fn proceed_answers_json_because_fetch_cannot_follow_stripes_redirect() {
63 let mut h = TestHarness::with_mocks().await;
64 h.signup("connectjson", "connectjson@test.com", "pass1234")
65 .await;
66
67 let resp = h.client.post_form("/stripe/connect/proceed", "").await;
68
69 assert_eq!(
70 resp.status.as_u16(),
71 200,
72 "a redirect cannot be followed by the fetch() that calls this: {}",
73 resp.text
74 );
75 assert!(
76 resp.header("content-type")
77 .is_some_and(|c| c.starts_with("application/json")),
78 "the page reads a JSON body, got {:?}",
79 resp.header("content-type")
80 );
81 assert!(
82 onboarding_url(&resp).starts_with("https://"),
83 "the body must carry the provider's onboarding link"
84 );
85 }
86
87 /// A creator who abandons onboarding and starts again reuses the account they
88 /// already claimed. A second `create_connect_account` would leave a live Standard
89 /// account behind that only a human in the Stripe dashboard can remove.
90 #[tokio::test]
91 async fn a_second_proceed_reuses_the_claimed_account_rather_than_creating_another() {
92 let mut h = TestHarness::with_mocks().await;
93 let user_id = h
94 .signup("connectagain", "connectagain@test.com", "pass1234")
95 .await;
96
97 let first = h.client.post_form("/stripe/connect/proceed", "").await;
98 assert_eq!(first.status.as_u16(), 200, "first proceed: {}", first.text);
99 let claimed = stored_account(&h, user_id)
100 .await
101 .expect("proceed claims an account id for the user");
102
103 let second = h.client.post_form("/stripe/connect/proceed", "").await;
104 assert_eq!(
105 second.status.as_u16(),
106 200,
107 "second proceed: {}",
108 second.text
109 );
110
111 assert_eq!(
112 creations(&h),
113 1,
114 "the second visit must reuse the claimed account, not create an orphan"
115 );
116 assert_eq!(
117 stored_account(&h, user_id).await.as_deref(),
118 Some(claimed.as_str()),
119 "the stored account id must not move under a repeat visit"
120 );
121 }
122
123 /// The id the handler stores is the provider's, and it is stored before the
124 /// account link is built. Anything else means the link and the row disagree
125 /// about which account the creator is onboarding.
126 #[tokio::test]
127 async fn proceed_stores_a_stripe_shaped_account_id() {
128 let mut h = TestHarness::with_mocks().await;
129 let user_id = h
130 .signup("connectshape", "connectshape@test.com", "pass1234")
131 .await;
132
133 let resp = h.client.post_form("/stripe/connect/proceed", "").await;
134 assert_eq!(resp.status.as_u16(), 200, "proceed: {}", resp.text);
135
136 let stored = stored_account(&h, user_id)
137 .await
138 .expect("an account id is stored");
139 assert!(
140 stored.starts_with("acct_"),
141 "the column is Stripe-shaped and the check belongs here, got {stored}"
142 );
143 }
144
145 /// A provider outage must not leave the user row claiming an account that was
146 /// never created: the next attempt would reuse an id Stripe has never heard of,
147 /// and the creator could never onboard again without operator help.
148 #[tokio::test]
149 async fn a_provider_outage_at_proceed_claims_nothing() {
150 let mut h = TestHarness::with_mocks().await;
151 let user_id = h
152 .signup("connectdown", "connectdown@test.com", "pass1234")
153 .await;
154
155 h.mock_stripe
156 .as_ref()
157 .expect("with_mocks provides a payment provider")
158 .faults()
159 .fail_always("create_connect_account", stripe_unavailable);
160
161 let resp = h.client.post_form("/stripe/connect/proceed", "").await;
162 assert_eq!(
163 resp.status.as_u16(),
164 503,
165 "an outage is surfaced as unavailable, not as the creator's mistake, got {}",
166 resp.status
167 );
168 assert_eq!(
169 stored_account(&h, user_id).await,
170 None,
171 "no account was created, so none may be claimed"
172 );
173 }
174
175 /// Onboarding is for real people. A sandbox account reaching this handler would
176 /// create a genuine Standard account at Stripe for a throwaway identity.
177 #[tokio::test]
178 async fn a_sandbox_account_cannot_start_onboarding() {
179 let mut h = TestHarness::with_mocks().await;
180 h.client.get("/sandbox").await;
181 let created = h.client.post_form("/sandbox", "").await;
182 assert!(
183 created.status.is_redirection(),
184 "sandbox signup should redirect, got {}",
185 created.status
186 );
187
188 let resp = h.client.post_form("/stripe/connect/proceed", "").await;
189
190 assert_eq!(
191 resp.status.as_u16(),
192 403,
193 "sandbox is refused before Stripe is touched, got {}",
194 resp.status
195 );
196 assert_eq!(creations(&h), 0, "and no account was created");
197 }
198
199 /// Both the disclaimer and the proceed handler are behind the session guard.
200 #[tokio::test]
201 async fn onboarding_is_closed_to_anonymous_callers() {
202 let mut h = TestHarness::with_mocks().await;
203
204 let disclaimer = h.client.get("/stripe/connect").await;
205 assert_eq!(
206 disclaimer.status.as_u16(),
207 401,
208 "the disclaimer is behind the session guard, got {}",
209 disclaimer.status
210 );
211
212 // The POST is refused at 403 rather than 401: with no session there is no
213 // CSRF token either, and that guard runs first. Either way the handler is
214 // never entered.
215 let proceed = h.client.post_form("/stripe/connect/proceed", "").await;
216 assert_eq!(
217 proceed.status.as_u16(),
218 403,
219 "proceed is refused before the handler, got {}",
220 proceed.status
221 );
222 assert_eq!(creations(&h), 0, "nothing was created for a stranger");
223 }
224
225 /// Stripe sends the creator back by cross-site navigation, which carries no
226 /// usable session in a modern browser. Both landing pages therefore have to
227 /// render for a request the server cannot assume is authenticated, and each has
228 /// to name where it is sending the browser next.
229 #[tokio::test]
230 async fn the_stripe_landing_pages_render_unauthenticated() {
231 let mut h = TestHarness::with_mocks().await;
232
233 let ret = h.client.get("/stripe/connect/return").await;
234 assert_eq!(
235 ret.status.as_u16(),
236 200,
237 "the return page arrives cross-site with no session, got {}",
238 ret.status
239 );
240 assert!(
241 ret.text.contains("/dashboard?tab=payments"),
242 "the return page must land the creator on the payments tab"
243 );
244
245 let refresh = h.client.get("/stripe/connect/refresh").await;
246 assert_eq!(
247 refresh.status.as_u16(),
248 200,
249 "the refresh page arrives cross-site with no session, got {}",
250 refresh.status
251 );
252 assert!(
253 refresh.text.contains("/stripe/connect"),
254 "an expired link must send the creator back to restart setup"
255 );
256 }
257