max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
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 | + | } |