//! Route-layer contract tests for `routes::stripe::connect`, the Account Links //! flow a creator walks once to become able to take money at all. //! //! Nothing covered this file. `stripe_disconnect` covers taking the connection //! away; the four handlers that put it there had no test, which left the two //! things this flow can get expensively wrong unobserved. //! //! The first is duplicate connected accounts. `stripe_connect_proceed` creates a //! Standard account at the provider, and Standard accounts are not auto-cleaned: //! every extra one is a live account on the platform that somebody has to delete //! by hand in the Stripe dashboard. A creator who abandons onboarding and comes //! back is the ordinary case, so reuse of the claimed id is the contract, not an //! optimisation. //! //! The second is the response shape. The handler answers JSON rather than a 303 //! because the page calls it with `fetch()`, and `fetch()` cannot follow a //! cross-origin redirect to Stripe (Stripe sends no CORS headers). A well-meaning //! change to `Redirect::to` would look more idiomatic, pass any test that only //! checked for success, and strand every creator on a silently failing button. //! //! Also pinned: the flow is closed to sandbox accounts (a sandbox user reaching //! Stripe would create a real connected account for a fake person), the //! disclaimer needs a session, and the two cross-site landing pages render for //! an unauthenticated request, since the browser arrives at them from Stripe //! carrying whatever cookies a cross-site navigation carries. use crate::harness::TestHarness; use crate::harness::faults::stripe_unavailable; use makenotwork::db::UserId; /// The `stripe_account_id` stored for a user, if any. async fn stored_account(h: &TestHarness, user_id: UserId) -> Option { sqlx::query_scalar::<_, Option>("SELECT stripe_account_id FROM users WHERE id = $1") .bind(user_id) .fetch_one(&h.db) .await .expect("read stripe_account_id") } /// How many times the provider was asked to create a connected account. fn creations(h: &TestHarness) -> u32 { h.mock_stripe .as_ref() .expect("with_mocks provides a payment provider") .faults() .calls("create_connect_account") } /// The URL the page will send the creator to. fn onboarding_url(resp: &crate::harness::client::TestResponse) -> String { resp.json::() .get("url") .and_then(|v| v.as_str()) .unwrap_or_else(|| panic!("proceed must answer an object with a url: {}", resp.text)) .to_string() } /// Onboarding is answered as JSON, not as a redirect, and the body carries the /// provider's link. The status assertion is the load-bearing half: a 3xx here /// is a dead button in the browser, however correct the Location header is. #[tokio::test] async fn proceed_answers_json_because_fetch_cannot_follow_stripes_redirect() { let mut h = TestHarness::with_mocks().await; h.signup("connectjson", "connectjson@test.com", "pass1234") .await; let resp = h.client.post_form("/stripe/connect/proceed", "").await; assert_eq!( resp.status.as_u16(), 200, "a redirect cannot be followed by the fetch() that calls this: {}", resp.text ); assert!( resp.header("content-type") .is_some_and(|c| c.starts_with("application/json")), "the page reads a JSON body, got {:?}", resp.header("content-type") ); assert!( onboarding_url(&resp).starts_with("https://"), "the body must carry the provider's onboarding link" ); } /// A creator who abandons onboarding and starts again reuses the account they /// already claimed. A second `create_connect_account` would leave a live Standard /// account behind that only a human in the Stripe dashboard can remove. #[tokio::test] async fn a_second_proceed_reuses_the_claimed_account_rather_than_creating_another() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("connectagain", "connectagain@test.com", "pass1234") .await; let first = h.client.post_form("/stripe/connect/proceed", "").await; assert_eq!(first.status.as_u16(), 200, "first proceed: {}", first.text); let claimed = stored_account(&h, user_id) .await .expect("proceed claims an account id for the user"); let second = h.client.post_form("/stripe/connect/proceed", "").await; assert_eq!( second.status.as_u16(), 200, "second proceed: {}", second.text ); assert_eq!( creations(&h), 1, "the second visit must reuse the claimed account, not create an orphan" ); assert_eq!( stored_account(&h, user_id).await.as_deref(), Some(claimed.as_str()), "the stored account id must not move under a repeat visit" ); } /// The id the handler stores is the provider's, and it is stored before the /// account link is built. Anything else means the link and the row disagree /// about which account the creator is onboarding. #[tokio::test] async fn proceed_stores_a_stripe_shaped_account_id() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("connectshape", "connectshape@test.com", "pass1234") .await; let resp = h.client.post_form("/stripe/connect/proceed", "").await; assert_eq!(resp.status.as_u16(), 200, "proceed: {}", resp.text); let stored = stored_account(&h, user_id) .await .expect("an account id is stored"); assert!( stored.starts_with("acct_"), "the column is Stripe-shaped and the check belongs here, got {stored}" ); } /// A provider outage must not leave the user row claiming an account that was /// never created: the next attempt would reuse an id Stripe has never heard of, /// and the creator could never onboard again without operator help. #[tokio::test] async fn a_provider_outage_at_proceed_claims_nothing() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("connectdown", "connectdown@test.com", "pass1234") .await; h.mock_stripe .as_ref() .expect("with_mocks provides a payment provider") .faults() .fail_always("create_connect_account", stripe_unavailable); let resp = h.client.post_form("/stripe/connect/proceed", "").await; assert_eq!( resp.status.as_u16(), 503, "an outage is surfaced as unavailable, not as the creator's mistake, got {}", resp.status ); assert_eq!( stored_account(&h, user_id).await, None, "no account was created, so none may be claimed" ); } /// Onboarding is for real people. A sandbox account reaching this handler would /// create a genuine Standard account at Stripe for a throwaway identity. #[tokio::test] async fn a_sandbox_account_cannot_start_onboarding() { let mut h = TestHarness::with_mocks().await; h.client.get("/sandbox").await; let created = h.client.post_form("/sandbox", "").await; assert!( created.status.is_redirection(), "sandbox signup should redirect, got {}", created.status ); let resp = h.client.post_form("/stripe/connect/proceed", "").await; assert_eq!( resp.status.as_u16(), 403, "sandbox is refused before Stripe is touched, got {}", resp.status ); assert_eq!(creations(&h), 0, "and no account was created"); } /// Both the disclaimer and the proceed handler are behind the session guard. #[tokio::test] async fn onboarding_is_closed_to_anonymous_callers() { let mut h = TestHarness::with_mocks().await; let disclaimer = h.client.get("/stripe/connect").await; assert_eq!( disclaimer.status.as_u16(), 401, "the disclaimer is behind the session guard, got {}", disclaimer.status ); // The POST is refused at 403 rather than 401: with no session there is no // CSRF token either, and that guard runs first. Either way the handler is // never entered. let proceed = h.client.post_form("/stripe/connect/proceed", "").await; assert_eq!( proceed.status.as_u16(), 403, "proceed is refused before the handler, got {}", proceed.status ); assert_eq!(creations(&h), 0, "nothing was created for a stranger"); } /// Stripe sends the creator back by cross-site navigation, which carries no /// usable session in a modern browser. Both landing pages therefore have to /// render for a request the server cannot assume is authenticated, and each has /// to name where it is sending the browser next. #[tokio::test] async fn the_stripe_landing_pages_render_unauthenticated() { let mut h = TestHarness::with_mocks().await; let ret = h.client.get("/stripe/connect/return").await; assert_eq!( ret.status.as_u16(), 200, "the return page arrives cross-site with no session, got {}", ret.status ); assert!( ret.text.contains("/dashboard?tab=payments"), "the return page must land the creator on the payments tab" ); let refresh = h.client.get("/stripe/connect/refresh").await; assert_eq!( refresh.status.as_u16(), 200, "the refresh page arrives cross-site with no session, got {}", refresh.status ); assert!( refresh.text.contains("/stripe/connect"), "an expired link must send the creator back to restart setup" ); }