Skip to main content

max / makenotwork

cover four money and data files, and lower the ratchet to 6 Three had no test at all. routes::stripe::webhook_v2 is the only place in the money path that answers a webhook by calling back out to the provider, and the three tests it had all pass against a handler that fetches nothing and writes nothing; the new file observes the hop, the redelivery, and the failed-fetch trade that leaves the event queued and unmarked. routes::stripe::connect pins the duplicate-account reuse and the JSON response shape that fetch() depends on. routes::stripe::checkout pins where the success and cancel returns send a browser, including the half-charged cross-seller case, which must clear its queue or loop forever. The fourth, routes::storage::uploads, always had 30 tests in tests/workflows/storage.rs; the file predates the "contract tests for `module`" convention and the seal could not see it. Its header now declares the subject, and three tests were added so every claim in that header can be read off a test body: presign refuses a mismatched content type, a mismatched extension, and an unknown file type. The mock's create_connect_account returned acct_test_mock, which the server's own validator rejects for the underscore, so the whole Connect flow was unreachable from a test. It now returns an alphanumeric id numbered per call. Remaining six and the ruling the last two need: mnw-server d965ba57.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-01 00:14 UTC
Signed with PGP, not checked
Commit: 17837864211787ba25f783fac7eba5899abce66e
Parent: 7b965af
7 files changed, +940 insertions, -3 deletions
@@ -33,7 +33,7 @@
33 33 /// Lower it when you cover one. Never raise it: a new untested file in these
34 34 /// areas is the thing this seal exists to refuse. If you genuinely need to add
35 35 /// one, the honest move is to write the test, not to bump the constant.
36 - const UNTESTED_HIGH_WATER: usize = 10;
36 + const UNTESTED_HIGH_WATER: usize = 6;
37 37
38 38 /// Anything that moves money or decides what someone is entitled to.
39 39 const MONEY: &[&str] = &[
@@ -252,8 +252,17 @@
252 252 _email: &str,
253 253 ) -> Result<makenotwork::payments::ProviderAccountId> {
254 254 self.faults.check("create_connect_account")?;
255 + // Stripe mints `acct_` plus an alphanumeric token, and the server
256 + // validates that shape before storing it, so an id with an underscore
257 + // in the tail (`acct_test_mock`) is rejected at the boundary and the
258 + // whole Connect onboarding path is unreachable from a test.
259 + //
260 + // Numbered per call, so a handler that creates a second account where
261 + // it should have reused the first shows up as two different ids rather
262 + // than as the same constant twice.
263 + let n = self.faults.calls("create_connect_account");
255 264 Ok(makenotwork::payments::ProviderAccountId::from_provider(
256 - "acct_test_mock".to_string(),
265 + format!("acct_{n:016}testmock"),
257 266 ))
258 267 }
259 268
@@ -130,11 +130,14 @@
130 130 mod storage;
131 131 mod storage_routes_workflows;
132 132 mod streaming;
133 + mod stripe_checkout_return_routes;
133 134 mod stripe_checkout_routes;
135 + mod stripe_connect_routes;
134 136 mod stripe_disconnect;
135 137 mod stripe_tip_checkout_routes;
136 138 mod stripe_webhook_billing_replay;
137 139 mod stripe_webhook_exactly_once;
140 + mod stripe_webhook_v2_routes;
138 141 mod stripe_webhooks;
139 142 mod subscriptions;
140 143 mod suspension;
@@ -1,4 +1,31 @@
1 - //! Storage workflow tests, presign, confirm, stream, download, access control.
1 + //! Route-layer contract tests for `routes::storage::uploads`, the presign and
2 + //! confirm pair every piece of item content passes through, plus the streaming
3 + //! and version handlers that read what it wrote.
4 + //!
5 + //! Thirty of the tests below drive `/api/upload/presign` and
6 + //! `/api/upload/confirm` directly. What they pin on that file: presign signs to
7 + //! an unserved staging key rather than the served one, refuses a stranger's
8 + //! item, and refuses a file type, extension or content type it will not accept
9 + //! before any URL is minted; the confirm step charges storage
10 + //! exactly once for a given key, charges the delta rather than the sum when a
11 + //! file is replaced, and on every refusal it both leaves the counter untouched
12 + //! and enqueues the orphaned object for deletion rather than leaking it. A
13 + //! confirm for a type that belongs to a dedicated route is rejected before any
14 + //! scan enqueue or `scan_status` flip. The internal confirm path is idempotent
15 + //! under replay and closed without an actor token.
16 + //!
17 + //! What is deliberately elsewhere, and not claimed here: the download and
18 + //! version-confirm handlers are `storage_routes_workflows`; the scan gate is
19 + //! `scanning` and `failure_paths_scan`; the tier ceilings are
20 + //! `tier_enforcement`. The image confirm handlers are only touched here through
21 + //! the item-cover route and are not this file's subject, so they are named in
22 + //! prose rather than in backticks: routes::storage::images still counts as
23 + //! uncovered, which is the truth.
24 + //!
25 + //! The header names the subject in the shape `untested_money_paths` reads, so
26 + //! the coverage seal can see it. That is a statement about the tests below, not
27 + //! a way to satisfy the count: the file predates the convention and always
28 + //! covered this module.
2 29
3 30 use crate::harness::TestHarness;
4 31 use makenotwork::storage::StorageBackend;
@@ -58,6 +85,87 @@
58 85 assert_eq!(data["expires_in"], 3600);
59 86 }
60 87
88 + /// The content type is signed into the presigned URL, so a mismatch has to be
89 + /// refused here: once the URL is minted the object can be PUT, and the confirm
90 + /// step would be reasoning about a file whose type nobody agreed to.
91 + #[tokio::test]
92 + async fn presign_refuses_a_content_type_the_file_type_does_not_allow() {
93 + let mut h = TestHarness::with_storage().await;
94 + let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
95 +
96 + let body = json!({
97 + "item_id": item_id,
98 + "file_type": "audio",
99 + "file_name": "episode.mp3",
100 + "content_type": "application/x-msdownload",
101 + });
102 + let resp = h
103 + .client
104 + .post_json("/api/upload/presign", &body.to_string())
105 + .await;
106 +
107 + assert_eq!(
108 + resp.status.as_u16(),
109 + 400,
110 + "an executable content type is not an audio upload: {}",
111 + resp.text
112 + );
113 + }
114 +
115 + /// The filename rides into the staging key, and the extension is what the
116 + /// browser and the scan worker read the file as. A `.mp3` claim with an `.exe`
117 + /// name must not be signed for.
118 + #[tokio::test]
119 + async fn presign_refuses_an_extension_the_file_type_does_not_allow() {
120 + let mut h = TestHarness::with_storage().await;
121 + let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
122 +
123 + let body = json!({
124 + "item_id": item_id,
125 + "file_type": "audio",
126 + "file_name": "episode.exe",
127 + "content_type": "audio/mpeg",
128 + });
129 + let resp = h
130 + .client
131 + .post_json("/api/upload/presign", &body.to_string())
132 + .await;
133 +
134 + assert_eq!(
135 + resp.status.as_u16(),
136 + 400,
137 + "the extension has to agree with the declared type: {}",
138 + resp.text
139 + );
140 + }
141 +
142 + /// An unknown `file_type` string is a client error, not a default. Falling back
143 + /// to any concrete type here would let a caller pick the caps and the confirm
144 + /// route that apply to their upload.
145 + #[tokio::test]
146 + async fn presign_refuses_an_unknown_file_type() {
147 + let mut h = TestHarness::with_storage().await;
148 + let (_, _, item_id) = setup_creator_with_item(&mut h, 0).await;
149 +
150 + let body = json!({
151 + "item_id": item_id,
152 + "file_type": "anything",
153 + "file_name": "episode.mp3",
154 + "content_type": "audio/mpeg",
155 + });
156 + let resp = h
157 + .client
158 + .post_json("/api/upload/presign", &body.to_string())
159 + .await;
160 +
161 + assert_eq!(
162 + resp.status.as_u16(),
163 + 400,
164 + "an unknown file type has no caps to apply: {}",
165 + resp.text
166 + );
167 + }
168 +
61 169 // Confirm
62 170
63 171 #[tokio::test]
@@ -1,0 +1,224 @@
1 + //! Route-layer contract tests for `routes::stripe::checkout`, the two handlers
2 + //! Stripe sends a buyer's browser back to: `/stripe/success` and
3 + //! `/stripe/cancel`.
4 + //!
5 + //! Every checkout session in the system names these two URLs, and both take
6 + //! their next destination from a query parameter Stripe echoes back. That makes
7 + //! `item_id` attacker-influenced text pasted into a `Location` header, which is
8 + //! why both handlers parse it as a UUID before using it and fall back to a fixed
9 + //! page when it is not one. One test file had touched `/stripe/success` at all,
10 + //! and only to drive the cart queue; nothing checked where either handler sends
11 + //! anyone.
12 + //!
13 + //! Pinned here: a single-item purchase deep-links to that item's library view
14 + //! (the buyer expects their download, not a list); a cart purchase and an
15 + //! unparseable `item_id` both land on the library index, with the unparseable
16 + //! value never reaching the header; cancel returns to the item page it came
17 + //! from, or to discovery; and the cross-seller queue's failure branch, where the
18 + //! second seller's session cannot be opened. That last one is the only path in
19 + //! either handler that can leave a buyer half-charged, and it has to clear the
20 + //! queue as it goes: a queue that survived would re-enter the failing drain on
21 + //! every subsequent return and strand the buyer in a loop.
22 + //!
23 + //! The successful drain itself belongs to `stripe_checkout_routes`, which owns
24 + //! the cart handlers and asserts the money.
25 +
26 + use crate::harness::TestHarness;
27 + use crate::harness::faults::stripe_unavailable;
28 +
29 + /// A `Location` header, or a panic naming what came back instead.
30 + fn location(resp: &crate::harness::client::TestResponse, what: &str) -> String {
31 + assert!(
32 + resp.status.is_redirection(),
33 + "{what}: expected a redirect, got {} {}",
34 + resp.status,
35 + resp.text
36 + );
37 + resp.header("location")
38 + .unwrap_or_else(|| panic!("{what}: a redirect with no Location"))
39 + .to_string()
40 + }
41 +
42 + /// A well-formed id that no row uses. Where the browser is sent is a routing
43 + /// decision the handler makes before anything is looked up, so a real item is
44 + /// not needed and using one would hide a handler that queried instead of parsed.
45 + const SOME_ITEM: &str = "ce9f7087-d503-4cf1-8f80-b2080508e5fe";
46 +
47 + /// A single-item purchase lands on that item's library view. The buyer just paid
48 + /// for one thing and wants it, not a list with it somewhere inside.
49 + #[tokio::test]
50 + async fn success_with_an_item_deep_links_to_that_items_library_view() {
51 + let mut h = TestHarness::with_mocks().await;
52 + h.signup("retbuyer", "retbuyer@test.com", "pass1234").await;
53 +
54 + let resp = h
55 + .client
56 + .get(&format!(
57 + "/stripe/success?session_id=cs_test_1&item_id={SOME_ITEM}"
58 + ))
59 + .await;
60 +
61 + assert_eq!(
62 + location(&resp, "single-item success"),
63 + format!("/l/{SOME_ITEM}?purchase=success"),
64 + "the buyer lands on the item they bought"
65 + );
66 + }
67 +
68 + /// A cart purchase has no single item to deep-link to, so it lands on the index.
69 + #[tokio::test]
70 + async fn success_without_an_item_lands_on_the_library_index() {
71 + let mut h = TestHarness::with_mocks().await;
72 + h.signup("cartret", "cartret@test.com", "pass1234").await;
73 +
74 + let resp = h.client.get("/stripe/success?session_id=cs_test_2").await;
75 +
76 + assert_eq!(
77 + location(&resp, "cart success"),
78 + "/library?purchase=success",
79 + "a cart purchase has no one item to open"
80 + );
81 + }
82 +
83 + /// `item_id` is text from a URL, and it is about to become a `Location` header.
84 + /// A handler that interpolated it unparsed would hand an attacker a redirect to
85 + /// anywhere they can spell, so the fallback has to be the fixed page and the
86 + /// hostile value must not appear in the header at all.
87 + #[tokio::test]
88 + async fn success_refuses_to_route_on_an_item_id_that_is_not_a_uuid() {
89 + let mut h = TestHarness::with_mocks().await;
90 + h.signup("badret", "badret@test.com", "pass1234").await;
91 +
92 + for hostile in [
93 + "..%2f..%2fadmin",
94 + "https:%2f%2fevil.example",
95 + "not-a-uuid",
96 + "%20",
97 + ] {
98 + let resp = h
99 + .client
100 + .get(&format!("/stripe/success?item_id={hostile}"))
101 + .await;
102 + let target = location(&resp, hostile);
103 + assert_eq!(
104 + target, "/library?purchase=success",
105 + "an unparseable item_id falls back to the index, got {target}"
106 + );
107 + }
108 + }
109 +
110 + /// A buyer who backs out returns to the page they were buying from.
111 + #[tokio::test]
112 + async fn cancel_returns_to_the_item_page() {
113 + let mut h = TestHarness::with_mocks().await;
114 +
115 + let resp = h
116 + .client
117 + .get(&format!("/stripe/cancel?item_id={SOME_ITEM}"))
118 + .await;
119 +
120 + assert_eq!(
121 + location(&resp, "cancel with an item"),
122 + format!("/i/{SOME_ITEM}"),
123 + "cancelling returns the buyer to what they were looking at"
124 + );
125 + }
126 +
127 + /// Cancel carries the same header-injection surface as success, and the same
128 + /// fallback: discovery, never the raw value.
129 + #[tokio::test]
130 + async fn cancel_falls_back_to_discovery_on_anything_that_is_not_a_uuid() {
131 + let mut h = TestHarness::with_mocks().await;
132 +
133 + for hostile in ["../../admin", "..%2fadmin", "", "1"] {
134 + let resp = h
135 + .client
136 + .get(&format!("/stripe/cancel?item_id={hostile}"))
137 + .await;
138 + let target = location(&resp, hostile);
139 + assert_eq!(
140 + target, "/discover",
141 + "an unparseable item_id falls back to discovery, got {target}"
142 + );
143 + }
144 +
145 + let resp = h.client.get("/stripe/cancel").await;
146 + assert_eq!(
147 + location(&resp, "cancel with no item"),
148 + "/discover",
149 + "no item means no page to return to"
150 + );
151 + }
152 +
153 + /// The half-charged case. A cart spanning two sellers charges one at a time, and
154 + /// the return from the first session opens the second. If that second session
155 + /// cannot be opened, the first seller's money has already moved: the buyer must
156 + /// be sent to the cart, where the unpaid line still is, and told the checkout
157 + /// was partial.
158 + ///
159 + /// The second assertion is the one that matters operationally. The queue has to
160 + /// be dropped on the way out, or every later return re-enters the same failing
161 + /// drain and the buyer can never leave the loop.
162 + #[tokio::test]
163 + async fn a_failed_second_seller_sends_the_buyer_to_the_cart_and_clears_the_queue() {
164 + let mut h = TestHarness::with_mocks().await;
165 +
166 + let one = h.create_creator_with_item("partone", "audio", 500).await;
167 + h.connect_stripe(one.user_id, "acct_test_partone").await;
168 + h.publish_project_and_item(&one.project_id, &one.item_id)
169 + .await;
170 + h.client.post_form("/logout", "").await;
171 +
172 + let two = h.create_creator_with_item("parttwo", "audio", 700).await;
173 + h.connect_stripe(two.user_id, "acct_test_parttwo").await;
174 + h.publish_project_and_item(&two.project_id, &two.item_id)
175 + .await;
176 + h.client.post_form("/logout", "").await;
177 +
178 + h.signup("partbuyer", "partbuyer@test.com", "pass1234")
179 + .await;
180 + for item in [&one.item_id, &two.item_id] {
181 + let resp = h.client.post_form(&format!("/api/cart/{item}"), "").await;
182 + assert_eq!(
183 + resp.status.as_u16(),
184 + 200,
185 + "add {item} to cart: {}",
186 + resp.text
187 + );
188 + }
189 +
190 + let resp = h
191 + .client
192 + .post_form("/stripe/checkout/cart/all", "share_contact=false")
193 + .await;
194 + assert!(
195 + resp.status.is_redirection(),
196 + "the first seller's session opens: {} {}",
197 + resp.status,
198 + resp.text
199 + );
200 +
201 + // The first seller is paid for. Now the provider goes down before the
202 + // second session can be opened.
203 + h.mock_stripe
204 + .as_ref()
205 + .expect("with_mocks provides a payment provider")
206 + .faults()
207 + .fail_always("create_cart_checkout_session", stripe_unavailable);
208 +
209 + let resp = h.client.get("/stripe/success").await;
210 + assert_eq!(
211 + location(&resp, "failed drain"),
212 + "/cart?checkout=partial",
213 + "the buyer goes back to the cart holding the line that did not go through"
214 + );
215 +
216 + // The queue is gone, so a second return is an ordinary success rather than
217 + // another trip through the failing drain.
218 + let resp = h.client.get("/stripe/success").await;
219 + assert_eq!(
220 + location(&resp, "return after a failed drain"),
221 + "/library?purchase=success",
222 + "a failed drain must not leave a queue that re-fails on every return"
223 + );
224 + }
@@ -1,0 +1,256 @@
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 + }
@@ -1,0 +1,337 @@
1 + //! Route-layer contract tests for `routes::stripe::webhook_v2`, the v2 thin-event
2 + //! endpoint Stripe uses for Connect account state.
3 + //!
4 + //! A thin event carries a reference rather than a snapshot, so this handler is
5 + //! the only place in the money path that answers a webhook by calling back out
6 + //! to the provider. That extra hop is what the tests below pin. `stripe_webhooks`
7 + //! already covers the three shallow cases (bad signature, an account event is
8 + //! accepted, an unknown type is accepted); none of them observes what the
9 + //! endpoint did afterwards, and all three pass against a handler that fetches
10 + //! nothing and writes nothing.
11 + //!
12 + //! What is pinned here. The fetched account actually lands on the user row, so
13 + //! a creator whose onboarding completed at Stripe stops being told to finish it.
14 + //! A redelivery is a no-op at the provider as well as in the database: Stripe
15 + //! sends the same event repeatedly, and a second `fetch_account` per delivery is
16 + //! both a rate-limit cost and a chance to overwrite a newer state with an older
17 + //! one. A fetch failure ACKs 200 but leaves the event unmarked and queued, which
18 + //! is the trade `webhook_v2.rs` documents: the local retry queue owns redelivery
19 + //! from that point, so an event that vanished from both places would be lost
20 + //! money with no operator signal. An event whose `related_object` is missing,
21 + //! and an event outside `v2.core.account`, are acknowledged without a fetch.
22 + //!
23 + //! Delete this file and the v2 endpoint could return 200 to everything while
24 + //! fetching nothing, writing nothing, and dropping every failure on the floor.
25 +
26 + use crate::harness::TestHarness;
27 + use crate::harness::faults::stripe_unavailable;
28 + use crate::harness::stripe::{TEST_WEBHOOK_SECRET_V2, sign_webhook_payload};
29 + use makenotwork::db::UserId;
30 +
31 + /// POST a signed v2 thin event of the given type and related object id.
32 + async fn post_v2(
33 + h: &mut TestHarness,
34 + event_id: &str,
35 + event_type: &str,
36 + related_object: Option<&str>,
37 + ) -> crate::harness::client::TestResponse {
38 + let mut event = serde_json::json!({ "id": event_id, "type": event_type });
39 + if let Some(acct) = related_object {
40 + event["related_object"] = serde_json::json!({ "id": acct, "type": "account" });
41 + }
42 + let payload = event.to_string();
43 + let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET_V2);
44 + h.client
45 + .request_with_headers(
46 + "POST",
47 + "/stripe/webhook/v2",
48 + Some(&payload),
49 + &[
50 + ("stripe-signature", signature.as_str()),
51 + ("content-type", "application/json"),
52 + ],
53 + )
54 + .await
55 + }
56 +
57 + /// A creator mid-onboarding: the account id is claimed, every capability flag is
58 + /// still false. This is the state the `account.updated` event exists to change,
59 + /// and starting from the mock's all-true answer would make the assertion vacuous.
60 + async fn seed_pending_creator(h: &mut TestHarness, username: &str, account_id: &str) -> UserId {
61 + let user_id = h
62 + .signup(username, &format!("{username}@test.com"), "pass1234")
63 + .await;
64 + sqlx::query(
65 + "UPDATE users SET stripe_account_id = $2, stripe_charges_enabled = false, \
66 + stripe_payouts_enabled = false, stripe_onboarding_complete = false WHERE id = $1",
67 + )
68 + .bind(user_id)
69 + .bind(account_id)
70 + .execute(&h.db)
71 + .await
72 + .expect("seed pending stripe account");
73 + user_id
74 + }
75 +
76 + /// `(onboarding_complete, payouts_enabled, charges_enabled)` as stored.
77 + async fn stripe_flags(h: &TestHarness, user_id: UserId) -> (bool, bool, bool) {
78 + sqlx::query_as::<_, (bool, bool, bool)>(
79 + "SELECT stripe_onboarding_complete, stripe_payouts_enabled, stripe_charges_enabled \
80 + FROM users WHERE id = $1",
81 + )
82 + .bind(user_id)
83 + .fetch_one(&h.db)
84 + .await
85 + .expect("read stripe flags")
86 + }
87 +
88 + async fn processed(h: &TestHarness, event_id: &str) -> bool {
89 + sqlx::query_scalar::<_, i64>(
90 + "SELECT COUNT(*) FROM processed_webhook_events WHERE event_id = $1",
91 + )
92 + .bind(event_id)
93 + .fetch_one(&h.db)
94 + .await
95 + .expect("count processed markers")
96 + > 0
97 + }
98 +
99 + async fn queued_failures(h: &TestHarness) -> i64 {
100 + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe_v2'")
101 + .fetch_one(&h.db)
102 + .await
103 + .expect("count queued v2 failures")
104 + }
105 +
106 + /// The number of times the provider was asked for an account.
107 + fn fetches(h: &TestHarness) -> u32 {
108 + h.mock_stripe
109 + .as_ref()
110 + .expect("with_mocks provides a payment provider")
111 + .faults()
112 + .calls("fetch_account")
113 + }
114 +
115 + /// The point of the v2 hop: the fetched account is written to the user row.
116 + ///
117 + /// The handler returning 200 proves nothing on its own; a handler that parsed
118 + /// the event and stopped would also return 200. This asserts the three
119 + /// capability flags the dashboard reads, from false to true.
120 + #[tokio::test]
121 + async fn a_v2_account_event_writes_the_fetched_state_to_the_creator() {
122 + let mut h = TestHarness::with_mocks().await;
123 + let user_id = seed_pending_creator(&mut h, "v2creator", "acct_v2_write").await;
124 +
125 + let resp = post_v2(
126 + &mut h,
127 + "evt_v2_write_001",
128 + "v2.core.account.updated",
129 + Some("acct_v2_write"),
130 + )
131 + .await;
132 + assert_eq!(resp.status.as_u16(), 200, "acknowledged: {}", resp.text);
133 +
134 + assert_eq!(
135 + stripe_flags(&h, user_id).await,
136 + (true, true, true),
137 + "the account fetched for the thin event must land on the user row"
138 + );
139 + assert_eq!(fetches(&h), 1, "exactly one fetch for one delivery");
140 + assert!(
141 + processed(&h, "evt_v2_write_001").await,
142 + "a succeeded event is marked, or every redelivery re-runs it"
143 + );
144 + }
145 +
146 + /// Stripe delivers at least once. The second delivery must not reach the
147 + /// provider: a fetch per redelivery burns rate limit and can write an account
148 + /// snapshot older than the one already stored.
149 + #[tokio::test]
150 + async fn a_redelivered_v2_event_does_not_refetch_the_account() {
151 + let mut h = TestHarness::with_mocks().await;
152 + seed_pending_creator(&mut h, "v2replay", "acct_v2_replay").await;
153 +
154 + for delivery in 1..=3 {
155 + // 503 is the handler's documented answer to a delivery that arrives
156 + // while another is still in flight, and Stripe's answer to a 503 is to
157 + // send it again. The advisory lock is released by the rollback of the
158 + // previous delivery's transaction, which the pool completes just after
159 + // the response, so back-to-back deliveries can legitimately see it held.
160 + // Retrying is what the endpoint asks its caller to do.
161 + let mut resp = post_v2(
162 + &mut h,
163 + "evt_v2_replay_001",
164 + "v2.core.account.updated",
165 + Some("acct_v2_replay"),
166 + )
167 + .await;
168 + for _ in 0..20 {
169 + if resp.status.as_u16() != 503 {
170 + break;
171 + }
172 + tokio::time::sleep(std::time::Duration::from_millis(25)).await;
173 + resp = post_v2(
174 + &mut h,
175 + "evt_v2_replay_001",
176 + "v2.core.account.updated",
177 + Some("acct_v2_replay"),
178 + )
179 + .await;
180 + }
181 + assert_eq!(
182 + resp.status.as_u16(),
183 + 200,
184 + "delivery {delivery} is ACKed: {}",
185 + resp.text
186 + );
187 + }
188 +
189 + assert_eq!(
190 + fetches(&h),
191 + 1,
192 + "three deliveries of one event id are one unit of work"
193 + );
194 + }
195 +
196 + /// The documented trade: a failed fetch ACKs 200 so Stripe stops retrying, and
197 + /// the event goes to the local queue instead. Both halves matter. Losing the
198 + /// queue row would drop a money event with no operator signal; marking the event
199 + /// processed would stop the queue's own retry from ever re-running it.
200 + #[tokio::test]
201 + async fn a_failed_fetch_is_queued_and_left_unprocessed() {
202 + let mut h = TestHarness::with_mocks().await;
203 + seed_pending_creator(&mut h, "v2fail", "acct_v2_fail").await;
204 +
205 + h.mock_stripe
206 + .as_ref()
207 + .expect("with_mocks provides a payment provider")
208 + .faults()
209 + .fail_always("fetch_account", stripe_unavailable);
210 +
211 + let resp = post_v2(
212 + &mut h,
213 + "evt_v2_fail_001",
214 + "v2.core.account.updated",
215 + Some("acct_v2_fail"),
216 + )
217 + .await;
218 + assert_eq!(
219 + resp.status.as_u16(),
220 + 200,
221 + "the in-house queue owns retry from here, so Stripe is ACKed: {}",
222 + resp.text
223 + );
224 + assert_eq!(
225 + queued_failures(&h).await,
226 + 1,
227 + "the event must be recoverable from the local queue"
228 + );
229 + assert!(
230 + !processed(&h, "evt_v2_fail_001").await,
231 + "marking a failed event processed would make the queued retry a no-op"
232 + );
233 + }
234 +
235 + /// A thin event with nothing to fetch is acknowledged rather than retried
236 + /// forever, and never reaches the provider.
237 + #[tokio::test]
238 + async fn a_v2_account_event_without_a_related_object_is_acknowledged_without_a_fetch() {
239 + let mut h = TestHarness::with_mocks().await;
240 +
241 + let resp = post_v2(&mut h, "evt_v2_bare_001", "v2.core.account.updated", None).await;
242 + assert_eq!(resp.status.as_u16(), 200, "nothing to do is not an error");
243 + assert_eq!(fetches(&h), 0, "there is no object to fetch");
244 + assert_eq!(
245 + queued_failures(&h).await,
246 + 0,
247 + "an unfetchable event is not a failure to retry"
248 + );
249 + assert!(
250 + processed(&h, "evt_v2_bare_001").await,
251 + "acknowledged means marked, so a redelivery short-circuits"
252 + );
253 + }
254 +
255 + /// Everything outside `v2.core.account` is out of scope for this endpoint. It is
256 + /// acknowledged so Stripe stops sending it, and it must not call the provider.
257 + #[tokio::test]
258 + async fn a_non_account_v2_event_is_acknowledged_without_a_fetch() {
259 + let mut h = TestHarness::with_mocks().await;
260 +
261 + let resp = post_v2(
262 + &mut h,
263 + "evt_v2_other_001",
264 + "v2.billing.meter.no_meter_found",
265 + Some("acct_v2_other"),
266 + )
267 + .await;
268 + assert_eq!(resp.status.as_u16(), 200, "unhandled is not unaccepted");
269 + assert_eq!(fetches(&h), 0, "an unhandled type fetches nothing");
270 + assert!(
271 + processed(&h, "evt_v2_other_001").await,
272 + "acknowledged means marked"
273 + );
274 + }
275 +
276 + /// No signature header at all is refused before any parsing, the same as a wrong
277 + /// one. Without this, an unsigned body reaching the parser would be one bug away
278 + /// from an unauthenticated write to the account path.
279 + #[tokio::test]
280 + async fn an_unsigned_v2_delivery_is_refused() {
281 + let mut h = TestHarness::with_mocks().await;
282 +
283 + let payload = r#"{"id":"evt_v2_nosig","type":"v2.core.account.updated","related_object":{"id":"acct_x","type":"account"}}"#;
284 + let resp = h
285 + .client
286 + .request_with_headers(
287 + "POST",
288 + "/stripe/webhook/v2",
289 + Some(payload),
290 + &[("content-type", "application/json")],
291 + )
292 + .await;
293 +
294 + assert_eq!(
295 + resp.status.as_u16(),
296 + 400,
297 + "a body with no signature is not a Stripe event: {}",
298 + resp.text
299 + );
300 + assert_eq!(fetches(&h), 0, "refused before the provider is touched");
301 + assert!(!processed(&h, "evt_v2_nosig").await, "nothing was accepted");
302 + }
303 +
304 + /// A correctly signed body that is not a thin event is a 400 rather than a
305 + /// silent 200: the signature proves it came from Stripe, so a shape we cannot
306 + /// parse is a schema change worth surfacing, not traffic to swallow.
307 + #[tokio::test]
308 + async fn a_signed_body_that_is_not_a_thin_event_is_refused() {
309 + let mut h = TestHarness::with_mocks().await;
310 +
311 + let payload = r#"{"not_an_event":true}"#;
312 + let signature = sign_webhook_payload(payload, TEST_WEBHOOK_SECRET_V2);
313 + let resp = h
314 + .client
315 + .request_with_headers(
316 + "POST",
317 + "/stripe/webhook/v2",
318 + Some(payload),
319 + &[
320 + ("stripe-signature", signature.as_str()),
321 + ("content-type", "application/json"),
322 + ],
323 + )
324 + .await;
325 +
326 + assert_eq!(
327 + resp.status.as_u16(),
328 + 400,
329 + "an unparseable thin event is refused: {}",
330 + resp.text
331 + );
332 + assert_eq!(
333 + fetches(&h),
334 + 0,
335 + "nothing to fetch from a shape we cannot read"
336 + );
337 + }