max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
6 files changed,
+1510 insertions,
-1 deletion
| @@ -33,7 +33,21 @@ | |||
| 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 = 6; | |
| 36 | + | /// | |
| 37 | + | /// # 2 is the floor, and it is not a to-do | |
| 38 | + | /// | |
| 39 | + | /// The two files left are `routes/stripe/mod.rs` and `db/synckit/mod.rs`. Both | |
| 40 | + | /// are declaration-only: `mod` and `pub use`, plus a route table. Neither holds | |
| 41 | + | /// behaviour a test could observe that is not already observed by testing the | |
| 42 | + | /// thing it declares, and a test written to move this number would be theatre | |
| 43 | + | /// inside a seal whose only value is that it is dumb and honest. | |
| 44 | + | /// | |
| 45 | + | /// Excluding them by rule was considered and declined. The rule is one sentence | |
| 46 | + | /// long ("how many files in these areas contain no test"), and a second concept | |
| 47 | + | /// bolted on to spare two files costs more than it saves; a floor of 2 that says | |
| 48 | + | /// why is more honest than a rule with a carve-out in it. So: do not drive this | |
| 49 | + | /// to 0, and do not treat 2 as work outstanding. | |
| 50 | + | const UNTESTED_HIGH_WATER: usize = 2; | |
| 37 | 51 | ||
| 38 | 52 | /// Anything that moves money or decides what someone is entitled to. | |
| 39 | 53 | const MONEY: &[&str] = &[ |
| @@ -128,6 +128,7 @@ | |||
| 128 | 128 | mod ssh_management; | |
| 129 | 129 | mod sso; | |
| 130 | 130 | mod storage; | |
| 131 | + | mod storage_image_routes; | |
| 131 | 132 | mod storage_routes_workflows; | |
| 132 | 133 | mod streaming; | |
| 133 | 134 | mod stripe_checkout_return_routes; | |
| @@ -136,6 +137,7 @@ | |||
| 136 | 137 | mod stripe_disconnect; | |
| 137 | 138 | mod stripe_tip_checkout_routes; | |
| 138 | 139 | mod stripe_webhook_billing_replay; | |
| 140 | + | mod stripe_webhook_dispatch; | |
| 139 | 141 | mod stripe_webhook_exactly_once; | |
| 140 | 142 | mod stripe_webhook_v2_routes; | |
| 141 | 143 | mod stripe_webhooks; | |
| @@ -146,6 +148,7 @@ | |||
| 146 | 148 | mod synckit_billing; | |
| 147 | 149 | mod synckit_blob_multipart; | |
| 148 | 150 | mod synckit_group_rotation; | |
| 151 | + | mod synckit_group_routes; | |
| 149 | 152 | mod synckit_groups_billing; | |
| 150 | 153 | mod synckit_paid_sync; | |
| 151 | 154 | mod synckit_per_key_storage; | |
| @@ -153,6 +156,7 @@ | |||
| 153 | 156 | mod synckit_security; | |
| 154 | 157 | mod synckit_selective; | |
| 155 | 158 | mod synckit_sse; | |
| 159 | + | mod synckit_sync_routes; | |
| 156 | 160 | mod tags; | |
| 157 | 161 | mod tier_enforcement; | |
| 158 | 162 | mod totp; |
| @@ -1,0 +1,470 @@ | |||
| 1 | + | //! Route-layer contract tests for `routes::storage::images`, the four handlers | |
| 2 | + | //! behind a project cover and an item cover. | |
| 3 | + | //! | |
| 4 | + | //! Five suites touch these routes in passing (`storage`, `creator_media`, | |
| 5 | + | //! `scanning`, `video`, `tier_enforcement`) and none of them is their contract | |
| 6 | + | //! test: between them they check that a cover upload works, that it does not | |
| 7 | + | //! flip the shared `items.scan_status`, and that the item route writes | |
| 8 | + | //! `cover_image_url` as well as the key. What nobody checks is the part of these | |
| 9 | + | //! handlers that exists to stop them destroying a file. | |
| 10 | + | //! | |
| 11 | + | //! A cover is presigned to a `staging/{uuid}` key, and a staging key carries no | |
| 12 | + | //! user, project or item in its path. There is nothing to do a prefix check | |
| 13 | + | //! against, so ownership of the key is proved entirely by the `pending_uploads` | |
| 14 | + | //! row written at presign. That single lookup is the only thing standing between | |
| 15 | + | //! "confirm this key" and one creator handing another creator's in-flight upload | |
| 16 | + | //! to the deletion queue, and it is deliberately placed before the size-reject | |
| 17 | + | //! path for exactly that reason. | |
| 18 | + | //! | |
| 19 | + | //! The other two guards here are both scar tissue with a run number on them. | |
| 20 | + | //! Re-confirming the key a project already displays must never queue the live | |
| 21 | + | //! object for deletion (Run #6), and the path that returns early must still | |
| 22 | + | //! clear the pending row or the orphan reaper deletes the live object a day | |
| 23 | + | //! later instead (Run #7). | |
| 24 | + | //! | |
| 25 | + | //! Measuring that turned up something worth writing down: the `pending_uploads` | |
| 26 | + | //! gate runs BEFORE the idempotency check, and a successful confirm consumes the | |
| 27 | + | //! row. So an ordinary retry (a dropped response, a double-click) is refused at | |
| 28 | + | //! the gate with a 400 and never reaches the idempotency branch at all. The | |
| 29 | + | //! branch is not dead code, it covers the narrower window where the row update | |
| 30 | + | //! committed and the pending-row cleanup did not, and it is reachable exactly | |
| 31 | + | //! then. Both are tested below, because they are two different guards arriving | |
| 32 | + | //! at the same requirement: whatever happens, the image on display survives. | |
| 33 | + | //! | |
| 34 | + | //! Replacement is the third: the old key is enqueued for deletion inside the same | |
| 35 | + | //! transaction as the row update, and the storage counter moves by the delta | |
| 36 | + | //! rather than by the sum, so a creator who replaces a cover ten times is charged | |
| 37 | + | //! for one. | |
| 38 | + | //! | |
| 39 | + | //! Delete this file and a stranger could aim the deletion queue at somebody | |
| 40 | + | //! else's upload, a retried confirm could delete the cover it was confirming, and | |
| 41 | + | //! replacing an image could bill for both copies. | |
| 42 | + | ||
| 43 | + | use crate::harness::TestHarness; | |
| 44 | + | use serde_json::{Value, json}; | |
| 45 | + | ||
| 46 | + | /// A trusted creator with a project and an item, on a tier with room to upload. | |
| 47 | + | async fn creator_with_project(h: &mut TestHarness, username: &str) -> (String, String) { | |
| 48 | + | let setup = h.create_creator_with_item(username, "audio", 0).await; | |
| 49 | + | h.trust_user(setup.user_id).await; | |
| 50 | + | h.grant_tier(setup.user_id, "small_files").await; | |
| 51 | + | (setup.project_id, setup.item_id) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | /// Presign a project cover and return the staging key. | |
| 55 | + | async fn presign_project_cover(h: &mut TestHarness, project_id: &str, file_name: &str) -> String { | |
| 56 | + | let body = json!({ | |
| 57 | + | "project_id": project_id, | |
| 58 | + | "file_name": file_name, | |
| 59 | + | "content_type": "image/png", | |
| 60 | + | }); | |
| 61 | + | let resp = h | |
| 62 | + | .client | |
| 63 | + | .post_json("/api/projects/image/presign", &body.to_string()) | |
| 64 | + | .await; | |
| 65 | + | assert_eq!(resp.status.as_u16(), 200, "presign failed: {}", resp.text); | |
| 66 | + | let data: Value = resp.json(); | |
| 67 | + | data["s3_key"] | |
| 68 | + | .as_str() | |
| 69 | + | .expect("presign returns an s3_key") | |
| 70 | + | .to_string() | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// Put bytes at the key, as the browser's PUT to the presigned URL would. | |
| 74 | + | fn upload_bytes(h: &TestHarness, key: &str, size: usize) { | |
| 75 | + | h.storage | |
| 76 | + | .as_ref() | |
| 77 | + | .expect("with_storage provides an in-memory bucket") | |
| 78 | + | .put(key, vec![b'x'; size]); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | async fn confirm_project_cover( | |
| 82 | + | h: &mut TestHarness, | |
| 83 | + | project_id: &str, | |
| 84 | + | key: &str, | |
| 85 | + | ) -> crate::harness::client::TestResponse { | |
| 86 | + | let body = json!({ "project_id": project_id, "s3_key": key }); | |
| 87 | + | h.client | |
| 88 | + | .post_json("/api/projects/image/confirm", &body.to_string()) | |
| 89 | + | .await | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | async fn deletions_for(h: &TestHarness, key: &str) -> i64 { | |
| 93 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_s3_deletions WHERE s3_key = $1") | |
| 94 | + | .bind(key) | |
| 95 | + | .fetch_one(&h.db) | |
| 96 | + | .await | |
| 97 | + | .expect("count queued deletions") | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | async fn pending_upload_rows(h: &TestHarness, key: &str) -> i64 { | |
| 101 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pending_uploads WHERE s3_key = $1") | |
| 102 | + | .bind(key) | |
| 103 | + | .fetch_one(&h.db) | |
| 104 | + | .await | |
| 105 | + | .expect("count pending uploads") | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | async fn storage_used(h: &TestHarness, username: &str) -> i64 { | |
| 109 | + | sqlx::query_scalar::<_, i64>("SELECT storage_used_bytes FROM users WHERE username = $1") | |
| 110 | + | .bind(username) | |
| 111 | + | .fetch_one(&h.db) | |
| 112 | + | .await | |
| 113 | + | .expect("read storage counter") | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | async fn cover_url(h: &TestHarness, project_id: &str) -> Option<String> { | |
| 117 | + | sqlx::query_scalar::<_, Option<String>>( | |
| 118 | + | "SELECT cover_image_url FROM projects WHERE id = $1::uuid", | |
| 119 | + | ) | |
| 120 | + | .bind(project_id) | |
| 121 | + | .fetch_one(&h.db) | |
| 122 | + | .await | |
| 123 | + | .expect("read project cover url") | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | /// The staging key is unguessable in practice but not secret, and it names | |
| 127 | + | /// nobody, so the `pending_uploads` lookup is the entire authorization. A | |
| 128 | + | /// creator confirming a key minted for someone else's upload must be refused, | |
| 129 | + | /// and, because the gate sits before the size-reject path, the object they | |
| 130 | + | /// pointed at must not be queued for deletion on the way out. | |
| 131 | + | /// | |
| 132 | + | /// This is the difference between a rejected request and a creator losing an | |
| 133 | + | /// upload because a stranger typed its key. | |
| 134 | + | #[tokio::test] | |
| 135 | + | async fn confirming_a_key_minted_for_another_creator_is_refused_and_deletes_nothing() { | |
| 136 | + | let mut h = TestHarness::with_storage().await; | |
| 137 | + | ||
| 138 | + | let (victim_project, _) = creator_with_project(&mut h, "coverowner").await; | |
| 139 | + | let victim_key = presign_project_cover(&mut h, &victim_project, "mine.png").await; | |
| 140 | + | upload_bytes(&h, &victim_key, 2048); | |
| 141 | + | h.client.post_form("/logout", "").await; | |
| 142 | + | ||
| 143 | + | let (thief_project, _) = creator_with_project(&mut h, "coverthief").await; | |
| 144 | + | let resp = confirm_project_cover(&mut h, &thief_project, &victim_key).await; | |
| 145 | + | ||
| 146 | + | assert_eq!( | |
| 147 | + | resp.status.as_u16(), | |
| 148 | + | 400, | |
| 149 | + | "a key with no pending_uploads row for this user is not confirmable: {}", | |
| 150 | + | resp.text | |
| 151 | + | ); | |
| 152 | + | assert_eq!( | |
| 153 | + | deletions_for(&h, &victim_key).await, | |
| 154 | + | 0, | |
| 155 | + | "the refusal must happen before the size-reject path, or a stranger can \ | |
| 156 | + | aim the deletion queue at an in-flight upload" | |
| 157 | + | ); | |
| 158 | + | assert_eq!( | |
| 159 | + | pending_upload_rows(&h, &victim_key).await, | |
| 160 | + | 1, | |
| 161 | + | "and the owner's claim on the key is untouched" | |
| 162 | + | ); | |
| 163 | + | assert_eq!( | |
| 164 | + | cover_url(&h, &thief_project).await, | |
| 165 | + | None, | |
| 166 | + | "nothing was written to the caller's own project either" | |
| 167 | + | ); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | /// The ordinary retry: a dropped response, a double-click, a client that resends. | |
| 171 | + | /// The key has already been consumed, so the `pending_uploads` gate refuses it | |
| 172 | + | /// before the idempotency branch is consulted. | |
| 173 | + | /// | |
| 174 | + | /// The status is the least interesting assertion here. What Run #6 was about is | |
| 175 | + | /// the second one: the object the request names is the one the project is | |
| 176 | + | /// currently displaying, and a refusal that queued it for deletion would delete | |
| 177 | + | /// the live cover on a retry that changed nothing. | |
| 178 | + | #[tokio::test] | |
| 179 | + | async fn a_retried_confirm_is_refused_without_touching_the_live_cover() { | |
| 180 | + | let mut h = TestHarness::with_storage().await; | |
| 181 | + | let (project_id, _) = creator_with_project(&mut h, "coverretry").await; | |
| 182 | + | ||
| 183 | + | let key = presign_project_cover(&mut h, &project_id, "cover.png").await; | |
| 184 | + | upload_bytes(&h, &key, 4096); | |
| 185 | + | let first = confirm_project_cover(&mut h, &project_id, &key).await; | |
| 186 | + | assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text); | |
| 187 | + | let url = cover_url(&h, &project_id) | |
| 188 | + | .await | |
| 189 | + | .expect("the cover url is written"); | |
| 190 | + | let charged = storage_used(&h, "coverretry").await; | |
| 191 | + | ||
| 192 | + | assert_eq!( | |
| 193 | + | pending_upload_rows(&h, &key).await, | |
| 194 | + | 0, | |
| 195 | + | "a successful confirm consumes the key's claim" | |
| 196 | + | ); | |
| 197 | + | ||
| 198 | + | let again = confirm_project_cover(&mut h, &project_id, &key).await; | |
| 199 | + | assert_eq!( | |
| 200 | + | again.status.as_u16(), | |
| 201 | + | 400, | |
| 202 | + | "the key is spent, so the gate refuses before anything else runs: {}", | |
| 203 | + | again.text | |
| 204 | + | ); | |
| 205 | + | assert_eq!( | |
| 206 | + | deletions_for(&h, &key).await, | |
| 207 | + | 0, | |
| 208 | + | "and the object the project is displaying is not queued for deletion (Run #6)" | |
| 209 | + | ); | |
| 210 | + | assert_eq!( | |
| 211 | + | cover_url(&h, &project_id).await.as_deref(), | |
| 212 | + | Some(url.as_str()), | |
| 213 | + | "the project still shows the same image" | |
| 214 | + | ); | |
| 215 | + | assert_eq!( | |
| 216 | + | storage_used(&h, "coverretry").await, | |
| 217 | + | charged, | |
| 218 | + | "and is still charged once" | |
| 219 | + | ); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// The window the idempotency branch actually serves: the transaction committed | |
| 223 | + | /// the new cover, and the pending-row cleanup after it did not run. The row and | |
| 224 | + | /// the live URL both name the same key, which is a state no ordinary request can | |
| 225 | + | /// produce, so it is set up directly. | |
| 226 | + | /// | |
| 227 | + | /// A confirm arriving then must recognise the key as the one already on display | |
| 228 | + | /// and return it, rather than treating it as a replacement, which would charge | |
| 229 | + | /// storage a second time and queue the live object for deletion as the "old" | |
| 230 | + | /// one. It must also clear the pending row on the way out, or the orphan reaper | |
| 231 | + | /// deletes that object 24 hours later (Run #7). | |
| 232 | + | #[tokio::test] | |
| 233 | + | async fn a_confirm_that_lands_on_the_cover_already_shown_returns_it_and_deletes_nothing() { | |
| 234 | + | let mut h = TestHarness::with_storage().await; | |
| 235 | + | let (project_id, _) = creator_with_project(&mut h, "covercrash").await; | |
| 236 | + | ||
| 237 | + | let key = presign_project_cover(&mut h, &project_id, "cover.png").await; | |
| 238 | + | upload_bytes(&h, &key, 4096); | |
| 239 | + | let first = confirm_project_cover(&mut h, &project_id, &key).await; | |
| 240 | + | assert_eq!(first.status.as_u16(), 200, "first confirm: {}", first.text); | |
| 241 | + | let url = cover_url(&h, &project_id) | |
| 242 | + | .await | |
| 243 | + | .expect("the cover url is written"); | |
| 244 | + | let charged = storage_used(&h, "covercrash").await; | |
| 245 | + | ||
| 246 | + | // Re-create the state a crash between the commit and the cleanup leaves. | |
| 247 | + | let user_id: makenotwork::db::UserId = | |
| 248 | + | sqlx::query_scalar("SELECT id FROM users WHERE username = 'covercrash'") | |
| 249 | + | .fetch_one(&h.db) | |
| 250 | + | .await | |
| 251 | + | .expect("read the creator id"); | |
| 252 | + | sqlx::query("INSERT INTO pending_uploads (user_id, s3_key, bucket) VALUES ($1, $2, 'main')") | |
| 253 | + | .bind(user_id) | |
| 254 | + | .bind(&key) | |
| 255 | + | .execute(&h.db) | |
| 256 | + | .await | |
| 257 | + | .expect("restore the pending row a crash would have left"); | |
| 258 | + | ||
| 259 | + | let again = confirm_project_cover(&mut h, &project_id, &key).await; | |
| 260 | + | assert_eq!( | |
| 261 | + | again.status.as_u16(), | |
| 262 | + | 200, | |
| 263 | + | "the key already on display is confirmed, not re-applied: {}", | |
| 264 | + | again.text | |
| 265 | + | ); | |
| 266 | + | let body: Value = again.json(); | |
| 267 | + | assert_eq!( | |
| 268 | + | body["image_url"].as_str(), | |
| 269 | + | Some(url.as_str()), | |
| 270 | + | "and the answer is the URL the project already has" | |
| 271 | + | ); | |
| 272 | + | ||
| 273 | + | assert_eq!( | |
| 274 | + | deletions_for(&h, &key).await, | |
| 275 | + | 0, | |
| 276 | + | "the live object must not be queued as the replaced one (Run #6)" | |
| 277 | + | ); | |
| 278 | + | assert_eq!( | |
| 279 | + | pending_upload_rows(&h, &key).await, | |
| 280 | + | 0, | |
| 281 | + | "and the stale row is cleared, or the reaper deletes the live cover (Run #7)" | |
| 282 | + | ); | |
| 283 | + | assert_eq!( | |
| 284 | + | storage_used(&h, "covercrash").await, | |
| 285 | + | charged, | |
| 286 | + | "one image on display, charged once" | |
| 287 | + | ); | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | /// Replacing a cover charges the difference between the two files, not their | |
| 291 | + | /// sum, and hands the old key to the deletion queue. A creator iterating on | |
| 292 | + | /// artwork does it many times; billing the sum would exhaust their quota with | |
| 293 | + | /// one image on display. | |
| 294 | + | #[tokio::test] | |
| 295 | + | async fn replacing_a_cover_charges_the_delta_and_queues_the_old_key() { | |
| 296 | + | let mut h = TestHarness::with_storage().await; | |
| 297 | + | let (project_id, _) = creator_with_project(&mut h, "coverswap").await; | |
| 298 | + | ||
| 299 | + | let first_key = presign_project_cover(&mut h, &project_id, "first.png").await; | |
| 300 | + | upload_bytes(&h, &first_key, 4000); | |
| 301 | + | let resp = confirm_project_cover(&mut h, &project_id, &first_key).await; | |
| 302 | + | assert_eq!(resp.status.as_u16(), 200, "first confirm: {}", resp.text); | |
| 303 | + | let after_first = storage_used(&h, "coverswap").await; | |
| 304 | + | assert_eq!(after_first, 4000, "the first image is charged in full"); | |
| 305 | + | ||
| 306 | + | let second_key = presign_project_cover(&mut h, &project_id, "second.png").await; | |
| 307 | + | upload_bytes(&h, &second_key, 6000); | |
| 308 | + | let resp = confirm_project_cover(&mut h, &project_id, &second_key).await; | |
| 309 | + | assert_eq!(resp.status.as_u16(), 200, "replace confirm: {}", resp.text); | |
| 310 | + | ||
| 311 | + | assert_eq!( | |
| 312 | + | storage_used(&h, "coverswap").await, | |
| 313 | + | 6000, | |
| 314 | + | "a replacement charges the delta: one image on display, one image billed" | |
| 315 | + | ); | |
| 316 | + | assert!( | |
| 317 | + | deletions_for(&h, &first_key).await > 0, | |
| 318 | + | "the replaced object is queued for deletion rather than left to the reaper" | |
| 319 | + | ); | |
| 320 | + | assert!( | |
| 321 | + | cover_url(&h, &project_id) | |
| 322 | + | .await | |
| 323 | + | .is_some_and(|u| u.contains(&second_key)), | |
| 324 | + | "and the project displays the new image" | |
| 325 | + | ); | |
| 326 | + | } | |
| 327 | + | ||
| 328 | + | /// Ownership of the target, checked on both halves of the flow. Presign is the | |
| 329 | + | /// half that matters most: it is what mints the `pending_uploads` row the | |
| 330 | + | /// confirm gate reads, so a stranger who could presign against someone else's | |
| 331 | + | /// project would hold a key the confirm gate then accepts. | |
| 332 | + | #[tokio::test] | |
| 333 | + | async fn a_strangers_project_is_refused_at_presign_and_at_confirm() { | |
| 334 | + | let mut h = TestHarness::with_storage().await; | |
| 335 | + | ||
| 336 | + | let (owned_project, _) = creator_with_project(&mut h, "coverwall").await; | |
| 337 | + | h.client.post_form("/logout", "").await; | |
| 338 | + | let (own_project, _) = creator_with_project(&mut h, "coverintruder").await; | |
| 339 | + | ||
| 340 | + | let body = json!({ | |
| 341 | + | "project_id": owned_project, | |
| 342 | + | "file_name": "theirs.png", | |
| 343 | + | "content_type": "image/png", | |
| 344 | + | }); | |
| 345 | + | let resp = h | |
| 346 | + | .client | |
| 347 | + | .post_json("/api/projects/image/presign", &body.to_string()) | |
| 348 | + | .await; | |
| 349 | + | assert_eq!( | |
| 350 | + | resp.status.as_u16(), | |
| 351 | + | 403, | |
| 352 | + | "a stranger cannot mint an upload slot against another creator's project: {}", | |
| 353 | + | resp.text | |
| 354 | + | ); | |
| 355 | + | ||
| 356 | + | // A key of the intruder's own, aimed at the project they do not own. | |
| 357 | + | let own_key = presign_project_cover(&mut h, &own_project, "ok.png").await; | |
| 358 | + | upload_bytes(&h, &own_key, 1024); | |
| 359 | + | let resp = confirm_project_cover(&mut h, &owned_project, &own_key).await; | |
| 360 | + | assert_eq!( | |
| 361 | + | resp.status.as_u16(), | |
| 362 | + | 403, | |
| 363 | + | "nor confirm into it with a key they legitimately own: {}", | |
| 364 | + | resp.text | |
| 365 | + | ); | |
| 366 | + | assert_eq!( | |
| 367 | + | cover_url(&h, &owned_project).await, | |
| 368 | + | None, | |
| 369 | + | "the target project is untouched" | |
| 370 | + | ); | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | /// A project id that resolves to nothing is a 404, distinct from the 403 a real | |
| 374 | + | /// project belonging to someone else gets. Collapsing the two would turn the | |
| 375 | + | /// endpoint into an oracle for which project ids exist. | |
| 376 | + | #[tokio::test] | |
| 377 | + | async fn an_unknown_project_is_not_found_rather_than_forbidden() { | |
| 378 | + | let mut h = TestHarness::with_storage().await; | |
| 379 | + | creator_with_project(&mut h, "covermissing").await; | |
| 380 | + | ||
| 381 | + | let body = json!({ | |
| 382 | + | "project_id": "ce9f7087-d503-4cf1-8f80-b2080508e5fe", | |
| 383 | + | "file_name": "ghost.png", | |
| 384 | + | "content_type": "image/png", | |
| 385 | + | }); | |
| 386 | + | let resp = h | |
| 387 | + | .client | |
| 388 | + | .post_json("/api/projects/image/presign", &body.to_string()) | |
| 389 | + | .await; | |
| 390 | + | ||
| 391 | + | assert_eq!( | |
| 392 | + | resp.status.as_u16(), | |
| 393 | + | 404, | |
| 394 | + | "an id that matches no project is not found: {}", | |
| 395 | + | resp.text | |
| 396 | + | ); | |
| 397 | + | } | |
| 398 | + | ||
| 399 | + | /// Both image routes accept only cover-shaped uploads, and the content type is | |
| 400 | + | /// signed into the presigned URL. A type outside that set must be refused before | |
| 401 | + | /// a URL exists, on the item route as well as the project one. | |
| 402 | + | #[tokio::test] | |
| 403 | + | async fn the_image_routes_refuse_a_non_image_upload() { | |
| 404 | + | let mut h = TestHarness::with_storage().await; | |
| 405 | + | let (project_id, item_id) = creator_with_project(&mut h, "coverwrongtype").await; | |
| 406 | + | ||
| 407 | + | let body = json!({ | |
| 408 | + | "project_id": project_id, | |
| 409 | + | "file_name": "cover.png", | |
| 410 | + | "content_type": "audio/mpeg", | |
| 411 | + | }); | |
| 412 | + | let resp = h | |
| 413 | + | .client | |
| 414 | + | .post_json("/api/projects/image/presign", &body.to_string()) | |
| 415 | + | .await; | |
| 416 | + | assert_eq!( | |
| 417 | + | resp.status.as_u16(), | |
| 418 | + | 400, | |
| 419 | + | "audio is not a project cover: {}", | |
| 420 | + | resp.text | |
| 421 | + | ); | |
| 422 | + | ||
| 423 | + | let body = json!({ | |
| 424 | + | "item_id": item_id, | |
| 425 | + | "file_name": "cover.mp3", | |
| 426 | + | "content_type": "image/png", | |
| 427 | + | }); | |
| 428 | + | let resp = h | |
| 429 | + | .client | |
| 430 | + | .post_json("/api/items/image/presign", &body.to_string()) | |
| 431 | + | .await; | |
| 432 | + | assert_eq!( | |
| 433 | + | resp.status.as_u16(), | |
| 434 | + | 400, | |
| 435 | + | "the extension has to agree with the declared image type: {}", | |
| 436 | + | resp.text | |
| 437 | + | ); | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | /// The item half carries the same staging-key gate as the project half, and it | |
| 441 | + | /// is a separate code path with its own copy of the lookup. A regression that | |
| 442 | + | /// removed one would leave the other passing. | |
| 443 | + | #[tokio::test] | |
| 444 | + | async fn the_item_route_also_refuses_a_key_it_did_not_mint() { | |
| 445 | + | let mut h = TestHarness::with_storage().await; | |
| 446 | + | ||
| 447 | + | let (project_id, _) = creator_with_project(&mut h, "itemcoverowner").await; | |
| 448 | + | let victim_key = presign_project_cover(&mut h, &project_id, "mine.png").await; | |
| 449 | + | upload_bytes(&h, &victim_key, 2048); | |
| 450 | + | h.client.post_form("/logout", "").await; | |
| 451 | + | ||
| 452 | + | let (_, thief_item) = creator_with_project(&mut h, "itemcoverthief").await; | |
| 453 | + | let body = json!({ "item_id": thief_item, "s3_key": victim_key }); | |
| 454 | + | let resp = h | |
| 455 | + | .client | |
| 456 | + | .post_json("/api/items/image/confirm", &body.to_string()) | |
| 457 | + | .await; | |
| 458 | + | ||
| 459 | + | assert_eq!( | |
| 460 | + | resp.status.as_u16(), | |
| 461 | + | 400, | |
| 462 | + | "the item route proves key ownership the same way: {}", | |
| 463 | + | resp.text | |
| 464 | + | ); | |
| 465 | + | assert_eq!( | |
| 466 | + | deletions_for(&h, &victim_key).await, | |
| 467 | + | 0, | |
| 468 | + | "and refuses before anything can be queued for deletion" | |
| 469 | + | ); | |
| 470 | + | } |
| @@ -1,0 +1,350 @@ | |||
| 1 | + | //! Route-layer contract tests for `routes::stripe::webhook`, the v1 dispatcher: | |
| 2 | + | //! the envelope handling every Stripe event passes through before any handler | |
| 3 | + | //! sees it. | |
| 4 | + | //! | |
| 5 | + | //! Its siblings cover what the handlers do. `stripe_webhook_exactly_once` owns | |
| 6 | + | //! the checkout half of the exactly-once protocol, `stripe_webhook_billing_replay` | |
| 7 | + | //! the invoice half, and `stripe_webhooks` drives one delivery of each event type | |
| 8 | + | //! through to its effect. What none of them assert is the envelope's own | |
| 9 | + | //! behaviour, which is where an event is lost rather than mishandled. | |
| 10 | + | //! | |
| 11 | + | //! Four things only this file pins. | |
| 12 | + | //! | |
| 13 | + | //! The event lock. A second delivery arriving while the first is mid-flight is | |
| 14 | + | //! answered 503 rather than parked on a pooled connection, and the sibling | |
| 15 | + | //! suites treat that 503 as timing noise to retry through. Here it is the | |
| 16 | + | //! subject: the lock is taken by the test, so the contended path is reached on | |
| 17 | + | //! purpose instead of by luck, and the delivery that loses must write nothing at | |
| 18 | + | //! all. | |
| 19 | + | //! | |
| 20 | + | //! The failure path. A handler error ACKs 200 (the local retry queue owns | |
| 21 | + | //! redelivery from that point) while leaving the event *unmarked*, so both the | |
| 22 | + | //! queue worker and a Stripe redelivery still re-run it. `stripe_webhooks` checks | |
| 23 | + | //! the queue row; the unmarked half is the one that decides whether a retry can | |
| 24 | + | //! ever happen, and nothing checked it. | |
| 25 | + | //! | |
| 26 | + | //! The unhandled arm. `MnwEvent::Unhandled` must be acknowledged and marked, not | |
| 27 | + | //! queued: Stripe sends event types MNW never asked for, and treating one as a | |
| 28 | + | //! failure would fill the retry queue with work that can never succeed. | |
| 29 | + | //! | |
| 30 | + | //! The settlement gate's other side. `dispatch_checkout_session` defers a | |
| 31 | + | //! funds-capturing checkout until it settles; the subscription-mode kinds must | |
| 32 | + | //! NOT be deferred, since they capture nothing at checkout and the subscription | |
| 33 | + | //! bills separately. `stripe_webhook_exactly_once` pins the deferral. A gate | |
| 34 | + | //! that deferred everything would pass that test and silently stop every Fan+ | |
| 35 | + | //! and creator-tier signup whose provider reported `unpaid`. | |
| 36 | + | ||
| 37 | + | use crate::harness::TestHarness; | |
| 38 | + | use crate::harness::stripe::{TEST_WEBHOOK_SECRET, sign_webhook_payload}; | |
| 39 | + | use makenotwork::db::UserId; | |
| 40 | + | ||
| 41 | + | /// Sign an event envelope and POST it, without the 503 retry loop the sibling | |
| 42 | + | /// suites use: this file is asserting on that status, so it must not swallow it. | |
| 43 | + | async fn post_once( | |
| 44 | + | h: &mut TestHarness, | |
| 45 | + | event_id: &str, | |
| 46 | + | event_type: &str, | |
| 47 | + | object: serde_json::Value, | |
| 48 | + | ) -> crate::harness::client::TestResponse { | |
| 49 | + | let payload = serde_json::json!({ | |
| 50 | + | "id": event_id, | |
| 51 | + | "type": event_type, | |
| 52 | + | "data": {"object": object}, | |
| 53 | + | }) | |
| 54 | + | .to_string(); | |
| 55 | + | let signature = sign_webhook_payload(&payload, TEST_WEBHOOK_SECRET); | |
| 56 | + | h.client | |
| 57 | + | .request_with_headers( | |
| 58 | + | "POST", | |
| 59 | + | "/stripe/webhook", | |
| 60 | + | Some(&payload), | |
| 61 | + | &[ | |
| 62 | + | ("stripe-signature", signature.as_str()), | |
| 63 | + | ("content-type", "application/json"), | |
| 64 | + | ], | |
| 65 | + | ) | |
| 66 | + | .await | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// Retry through the transient 503 the way Stripe does, for the deliveries a | |
| 70 | + | /// test wants to succeed rather than to observe. | |
| 71 | + | async fn post_until_settled( | |
| 72 | + | h: &mut TestHarness, | |
| 73 | + | event_id: &str, | |
| 74 | + | event_type: &str, | |
| 75 | + | object: serde_json::Value, | |
| 76 | + | ) -> crate::harness::client::TestResponse { | |
| 77 | + | for _ in 0..200 { | |
| 78 | + | let resp = post_once(h, event_id, event_type, object.clone()).await; | |
| 79 | + | if resp.status != 503 { | |
| 80 | + | return resp; | |
| 81 | + | } | |
| 82 | + | tokio::time::sleep(std::time::Duration::from_millis(10)).await; | |
| 83 | + | } | |
| 84 | + | panic!("{event_id}: the webhook route answered 503 for two seconds"); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | async fn processed(h: &TestHarness, event_id: &str) -> bool { | |
| 88 | + | sqlx::query_scalar::<_, i64>( | |
| 89 | + | "SELECT COUNT(*) FROM processed_webhook_events WHERE event_id = $1", | |
| 90 | + | ) | |
| 91 | + | .bind(event_id) | |
| 92 | + | .fetch_one(&h.db) | |
| 93 | + | .await | |
| 94 | + | .expect("count processed markers") | |
| 95 | + | > 0 | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | async fn queued(h: &TestHarness) -> i64 { | |
| 99 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM webhook_events WHERE source = 'stripe'") | |
| 100 | + | .fetch_one(&h.db) | |
| 101 | + | .await | |
| 102 | + | .expect("count queued retries") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | /// A checkout session with no `checkout_type`, which routes to the purchase | |
| 106 | + | /// handler, and no `buyer_id`, which that handler requires. The cheapest way to | |
| 107 | + | /// make a handler fail for reasons the envelope has to cope with. | |
| 108 | + | fn unroutable_purchase(session_id: &str, seller_id: UserId) -> serde_json::Value { | |
| 109 | + | serde_json::json!({ | |
| 110 | + | "id": session_id, | |
| 111 | + | "object": "checkout_session", | |
| 112 | + | "mode": "payment", | |
| 113 | + | "payment_status": "paid", | |
| 114 | + | "metadata": {"seller_id": seller_id.to_string()}, | |
| 115 | + | "payment_intent": format!("pi_{session_id}"), | |
| 116 | + | }) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// The contended path, reached deliberately. The test holds the same advisory | |
| 120 | + | /// lock the handler takes, so the delivery below is guaranteed to lose it. | |
| 121 | + | /// | |
| 122 | + | /// Two halves. The loser must be told to redeliver rather than made to wait on a | |
| 123 | + | /// pooled connection, and it must leave nothing behind: a 503 that had already | |
| 124 | + | /// marked the event would turn a transient collision into a permanently skipped | |
| 125 | + | /// event. Then, with the lock released, the same delivery must go through, which | |
| 126 | + | /// is what makes the 503 a deferral rather than a refusal. | |
| 127 | + | #[tokio::test] | |
| 128 | + | async fn a_delivery_that_loses_the_event_lock_is_deferred_and_writes_nothing() { | |
| 129 | + | let mut h = TestHarness::with_stripe().await; | |
| 130 | + | let event_id = "evt_dispatch_contended"; | |
| 131 | + | ||
| 132 | + | // The lock transaction borrows the pool it came from, so it takes its own | |
| 133 | + | // handle rather than `h.db`: the harness client needs `&mut h` while the | |
| 134 | + | // lock is still held, which is the whole point of the test. | |
| 135 | + | let pool = h.db.clone(); | |
| 136 | + | let held = makenotwork::db::webhook_events::try_lock_event(&pool, event_id) | |
| 137 | + | .await | |
| 138 | + | .expect("take the event lock") | |
| 139 | + | .expect("the lock is free before the test takes it"); | |
| 140 | + | ||
| 141 | + | let resp = post_once( | |
| 142 | + | &mut h, | |
| 143 | + | event_id, | |
| 144 | + | "payment_intent.created", | |
| 145 | + | serde_json::json!({"id": "pi_dispatch_contended"}), | |
| 146 | + | ) | |
| 147 | + | .await; | |
| 148 | + | assert_eq!( | |
| 149 | + | resp.status.as_u16(), | |
| 150 | + | 503, | |
| 151 | + | "a delivery that loses the lock is told to come back: {}", | |
| 152 | + | resp.text | |
| 153 | + | ); | |
| 154 | + | assert!( | |
| 155 | + | !processed(&h, event_id).await, | |
| 156 | + | "a deferred delivery must not mark the event, or the redelivery it just \ | |
| 157 | + | asked for would be skipped" | |
| 158 | + | ); | |
| 159 | + | assert_eq!( | |
| 160 | + | queued(&h).await, | |
| 161 | + | 0, | |
| 162 | + | "a deferred delivery is not a failed one and does not enter the retry queue" | |
| 163 | + | ); | |
| 164 | + | ||
| 165 | + | // Releasing the lock is the whole difference; nothing else about the | |
| 166 | + | // delivery changes. | |
| 167 | + | held.rollback().await.expect("release the event lock"); | |
| 168 | + | ||
| 169 | + | let resp = post_until_settled( | |
| 170 | + | &mut h, | |
| 171 | + | event_id, | |
| 172 | + | "payment_intent.created", | |
| 173 | + | serde_json::json!({"id": "pi_dispatch_contended"}), | |
| 174 | + | ) | |
| 175 | + | .await; | |
| 176 | + | assert_eq!( | |
| 177 | + | resp.status.as_u16(), | |
| 178 | + | 200, | |
| 179 | + | "the same delivery goes through once the lock is free: {}", | |
| 180 | + | resp.text | |
| 181 | + | ); | |
| 182 | + | assert!( | |
| 183 | + | processed(&h, event_id).await, | |
| 184 | + | "and is marked, so the next redelivery short-circuits" | |
| 185 | + | ); | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | /// A handler error is ACKed so Stripe stops its own redelivery schedule, and the | |
| 189 | + | /// event is queued locally instead. The event must NOT be marked: the whole | |
| 190 | + | /// point of the queue is that the work still has to happen, and a marked event | |
| 191 | + | /// short-circuits both the queue worker's re-run and any Stripe redelivery. | |
| 192 | + | #[tokio::test] | |
| 193 | + | async fn a_failed_handler_is_queued_and_deliberately_left_unmarked() { | |
| 194 | + | let mut h = TestHarness::with_stripe().await; | |
| 195 | + | let seller_id = h | |
| 196 | + | .signup("dispatchseller", "dispatchseller@test.com", "password123") | |
| 197 | + | .await; | |
| 198 | + | let event_id = "evt_dispatch_failed"; | |
| 199 | + | ||
| 200 | + | let resp = post_until_settled( | |
| 201 | + | &mut h, | |
| 202 | + | event_id, | |
| 203 | + | "checkout.session.completed", | |
| 204 | + | unroutable_purchase("cs_dispatch_failed", seller_id), | |
| 205 | + | ) | |
| 206 | + | .await; | |
| 207 | + | assert_eq!( | |
| 208 | + | resp.status.as_u16(), | |
| 209 | + | 200, | |
| 210 | + | "the local queue owns retry from here, so Stripe is ACKed: {}", | |
| 211 | + | resp.text | |
| 212 | + | ); | |
| 213 | + | assert_eq!( | |
| 214 | + | queued(&h).await, | |
| 215 | + | 1, | |
| 216 | + | "the event is recoverable from the queue" | |
| 217 | + | ); | |
| 218 | + | assert!( | |
| 219 | + | !processed(&h, event_id).await, | |
| 220 | + | "marking a failed event would make both retry routes a no-op" | |
| 221 | + | ); | |
| 222 | + | ||
| 223 | + | // The unmarked half, demonstrated rather than asserted about: a redelivery | |
| 224 | + | // of the same event id re-enters the handler instead of short-circuiting. | |
| 225 | + | let resp = post_until_settled( | |
| 226 | + | &mut h, | |
| 227 | + | event_id, | |
| 228 | + | "checkout.session.completed", | |
| 229 | + | unroutable_purchase("cs_dispatch_failed", seller_id), | |
| 230 | + | ) | |
| 231 | + | .await; | |
| 232 | + | assert_eq!(resp.status.as_u16(), 200, "redelivery: {}", resp.text); | |
| 233 | + | assert_eq!( | |
| 234 | + | queued(&h).await, | |
| 235 | + | 2, | |
| 236 | + | "the redelivery re-ran the handler, which is what leaving it unmarked buys" | |
| 237 | + | ); | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | /// Stripe sends event types MNW never subscribed to. They reach | |
| 241 | + | /// `MnwEvent::Unhandled`, which is a success: acknowledged, marked so the | |
| 242 | + | /// redelivery is cheap, and kept out of a retry queue where they could never | |
| 243 | + | /// succeed. | |
| 244 | + | #[tokio::test] | |
| 245 | + | async fn an_event_type_mnw_does_not_handle_is_marked_and_not_queued() { | |
| 246 | + | let mut h = TestHarness::with_stripe().await; | |
| 247 | + | let event_id = "evt_dispatch_unhandled"; | |
| 248 | + | ||
| 249 | + | let resp = post_until_settled( | |
| 250 | + | &mut h, | |
| 251 | + | event_id, | |
| 252 | + | "payment_intent.created", | |
| 253 | + | serde_json::json!({"id": "pi_dispatch_unhandled"}), | |
| 254 | + | ) | |
| 255 | + | .await; | |
| 256 | + | ||
| 257 | + | assert_eq!( | |
| 258 | + | resp.status.as_u16(), | |
| 259 | + | 200, | |
| 260 | + | "an event we do not act on is still an event we accept: {}", | |
| 261 | + | resp.text | |
| 262 | + | ); | |
| 263 | + | assert!( | |
| 264 | + | processed(&h, event_id).await, | |
| 265 | + | "marked, so a redelivery costs one dedup read" | |
| 266 | + | ); | |
| 267 | + | assert_eq!( | |
| 268 | + | queued(&h).await, | |
| 269 | + | 0, | |
| 270 | + | "an unhandled type is not a failure and must not fill the retry queue" | |
| 271 | + | ); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | /// A body with no signature header is refused before the lock, the dedup read, | |
| 275 | + | /// or any handler. The two "wrote nothing" assertions matter more than the | |
| 276 | + | /// status: this endpoint is public, and an unsigned body that got as far as the | |
| 277 | + | /// event lock would be a free way to make real deliveries answer 503. | |
| 278 | + | #[tokio::test] | |
| 279 | + | async fn an_unsigned_delivery_is_refused_before_the_event_lock() { | |
| 280 | + | let mut h = TestHarness::with_stripe().await; | |
| 281 | + | let event_id = "evt_dispatch_unsigned"; | |
| 282 | + | ||
| 283 | + | let payload = serde_json::json!({ | |
| 284 | + | "id": event_id, | |
| 285 | + | "type": "payment_intent.created", | |
| 286 | + | "data": {"object": {"id": "pi_dispatch_unsigned"}}, | |
| 287 | + | }) | |
| 288 | + | .to_string(); | |
| 289 | + | let resp = h | |
| 290 | + | .client | |
| 291 | + | .request_with_headers( | |
| 292 | + | "POST", | |
| 293 | + | "/stripe/webhook", | |
| 294 | + | Some(&payload), | |
| 295 | + | &[("content-type", "application/json")], | |
| 296 | + | ) | |
| 297 | + | .await; | |
| 298 | + | ||
| 299 | + | assert_eq!( | |
| 300 | + | resp.status.as_u16(), | |
| 301 | + | 400, | |
| 302 | + | "a body with no signature is not a Stripe event: {}", | |
| 303 | + | resp.text | |
| 304 | + | ); | |
| 305 | + | assert!(!processed(&h, event_id).await, "nothing was accepted"); | |
| 306 | + | assert_eq!(queued(&h).await, 0, "and nothing was queued"); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | /// The settlement gate applies to the kinds that capture funds at checkout, and | |
| 310 | + | /// only those. A Fan+ session reports no `payment_status` MNW should wait on, | |
| 311 | + | /// because the subscription bills on its own schedule; deferring it would leave | |
| 312 | + | /// the subscriber paying and unsubscribed until an event that never comes. | |
| 313 | + | #[tokio::test] | |
| 314 | + | async fn a_subscription_checkout_is_not_held_back_by_the_settlement_gate() { | |
| 315 | + | let mut h = TestHarness::with_mocks().await; | |
| 316 | + | let user_id = h | |
| 317 | + | .signup("dispatchfan", "dispatchfan@test.com", "password123") | |
| 318 | + | .await; | |
| 319 | + | ||
| 320 | + | let resp = post_until_settled( | |
| 321 | + | &mut h, | |
| 322 | + | "evt_dispatch_unpaid_fanplus", | |
| 323 | + | "checkout.session.completed", | |
| 324 | + | serde_json::json!({ | |
| 325 | + | "id": "cs_dispatch_unpaid_fanplus", | |
| 326 | + | "object": "checkout.session", | |
| 327 | + | "subscription": "sub_dispatch_unpaid_fanplus", | |
| 328 | + | "customer": "cus_dispatch_unpaid_fanplus", | |
| 329 | + | // The state that defers a purchase. A subscription-mode session | |
| 330 | + | // must be finalized on it regardless. | |
| 331 | + | "payment_status": "unpaid", | |
| 332 | + | "currency": "usd", | |
| 333 | + | "metadata": {"checkout_type": "fan_plus", "user_id": user_id.to_string()}, | |
| 334 | + | }), | |
| 335 | + | ) | |
| 336 | + | .await; | |
| 337 | + | assert_eq!(resp.status.as_u16(), 200, "Fan+ checkout: {}", resp.text); | |
| 338 | + | ||
| 339 | + | let rows: i64 = | |
| 340 | + | sqlx::query_scalar("SELECT COUNT(*) FROM fan_plus_subscriptions WHERE user_id = $1") | |
| 341 | + | .bind(user_id) | |
| 342 | + | .fetch_one(&h.db) | |
| 343 | + | .await | |
| 344 | + | .expect("count fan plus subscriptions"); | |
| 345 | + | assert_eq!( | |
| 346 | + | rows, 1, | |
| 347 | + | "a subscription-mode checkout captures nothing at checkout, so there is \ | |
| 348 | + | nothing to wait for and the signup must land" | |
| 349 | + | ); | |
| 350 | + | } |
| @@ -1,0 +1,360 @@ | |||
| 1 | + | //! HTTP contract tests for `routes::synckit::groups`, specifically the three | |
| 2 | + | //! guards at the top of that file and which of its thirteen handlers each one | |
| 3 | + | //! covers. | |
| 4 | + | //! | |
| 5 | + | //! `synckit_group_rotation` owns the rotation state machine, `synckit_groups_billing` | |
| 6 | + | //! the subscription gate, and `db_synckit_groups` / `db_synckit_invitations` the | |
| 7 | + | //! layer beneath. All of them authenticate as somebody who is allowed to be there. | |
| 8 | + | //! Between them exactly one handler, `rotate`, has its permission checked. | |
| 9 | + | //! | |
| 10 | + | //! The guards are `require_group` (404 for a group outside the caller's app), | |
| 11 | + | //! `require_member` (403 for group-scoped reads and writes) and `require_admin` | |
| 12 | + | //! (403 for membership management). Each is one line at the top of each handler, | |
| 13 | + | //! which is the cheapest thing in the file to leave out and the most expensive to | |
| 14 | + | //! leave out of the wrong one. A missing `require_admin` on `add_member` lets any | |
| 15 | + | //! member add anyone; a missing `require_member` on `pull` hands a stranger the | |
| 16 | + | //! whole group changelog; a missing `require_group` turns a group id into a | |
| 17 | + | //! cross-tenant read. | |
| 18 | + | //! | |
| 19 | + | //! So this file walks the matrix rather than the features: for a stranger, and | |
| 20 | + | //! then for a member who is not the admin, it asks every handler and asserts the | |
| 21 | + | //! answer. What it pins is the shape of the wall, not any one brick. | |
| 22 | + | //! | |
| 23 | + | //! One distinction is deliberate and worth keeping straight. A group in another | |
| 24 | + | //! app answers 404, not 403: the caller's app scope is applied before membership | |
| 25 | + | //! is consulted, so a developer cannot use group ids to learn what exists in | |
| 26 | + | //! somebody else's app. Within the caller's own app, a group they are not in | |
| 27 | + | //! answers 403, which tells them only what they could learn by being told to go | |
| 28 | + | //! away. | |
| 29 | + | ||
| 30 | + | use serde_json::json; | |
| 31 | + | ||
| 32 | + | use super::synckit_paid_sync::{auth_as, create_internal_app, harness_with_blobs}; | |
| 33 | + | use crate::harness::TestHarness; | |
| 34 | + | use makenotwork::db::{SyncAppId, UserId}; | |
| 35 | + | ||
| 36 | + | /// The admin-only endpoints, as `(method, path suffix, body)`. `{g}` is the | |
| 37 | + | /// group id. A member who is not the admin must be refused every one of them. | |
| 38 | + | const ADMIN_ONLY: &[(&str, &str)] = &[ | |
| 39 | + | ("POST", "/members"), | |
| 40 | + | ("GET", "/pubkeys"), | |
| 41 | + | ("POST", "/rotate"), | |
| 42 | + | ("POST", "/invitations"), | |
| 43 | + | ("GET", "/invitations"), | |
| 44 | + | ]; | |
| 45 | + | ||
| 46 | + | /// A body that would be valid if the caller were allowed. The guard runs before | |
| 47 | + | /// the body is acted on, so these exist to prove the refusal is the guard rather | |
| 48 | + | /// than a parse failure further in. | |
| 49 | + | fn plausible_body(suffix: &str, subject: UserId) -> String { | |
| 50 | + | match suffix { | |
| 51 | + | "/members" => json!({ | |
| 52 | + | "member_email": "nobody@example.com", | |
| 53 | + | "sealed_gck": "sealed_x_v1", | |
| 54 | + | "member_pubkey": "pk_x", | |
| 55 | + | }) | |
| 56 | + | .to_string(), | |
| 57 | + | "/rotate" => json!({ | |
| 58 | + | "gck_version": 2, | |
| 59 | + | "grants": [{ "user_id": subject.to_string(), "sealed_gck": "sealed_x_v2" }], | |
| 60 | + | }) | |
| 61 | + | .to_string(), | |
| 62 | + | "/invitations" => json!({ "note": "join us" }).to_string(), | |
| 63 | + | // The guards run before the device is resolved, so any well-formed id | |
| 64 | + | // reaches them; using a real device would test the device check instead. | |
| 65 | + | "/pull" => { | |
| 66 | + | json!({ "device_id": uuid::Uuid::new_v4().to_string(), "cursor": 0 }).to_string() | |
| 67 | + | } | |
| 68 | + | _ => String::new(), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | async fn call( | |
| 73 | + | h: &mut TestHarness, | |
| 74 | + | method: &str, | |
| 75 | + | path: &str, | |
| 76 | + | body: &str, | |
| 77 | + | ) -> crate::harness::client::TestResponse { | |
| 78 | + | match method { | |
| 79 | + | "GET" => h.client.get(path).await, | |
| 80 | + | "POST" => h.client.post_json(path, body).await, | |
| 81 | + | "DELETE" => h.client.delete(path).await, | |
| 82 | + | other => panic!("unhandled method {other}"), | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | async fn create_group(h: &mut TestHarness, name: &str) -> String { | |
| 87 | + | let resp = h | |
| 88 | + | .client | |
| 89 | + | .post_json( | |
| 90 | + | "/api/sync/groups", | |
| 91 | + | &json!({ | |
| 92 | + | "id": uuid::Uuid::new_v4().to_string(), | |
| 93 | + | "name": name, | |
| 94 | + | "admin_sealed_gck": "sealed_admin_v1", | |
| 95 | + | "admin_pubkey": "pk_admin", | |
| 96 | + | }) | |
| 97 | + | .to_string(), | |
| 98 | + | ) | |
| 99 | + | .await; | |
| 100 | + | assert_eq!(resp.status.as_u16(), 200, "create group: {}", resp.text); | |
| 101 | + | resp.json::<serde_json::Value>()["id"] | |
| 102 | + | .as_str() | |
| 103 | + | .expect("group id") | |
| 104 | + | .to_string() | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Seed a verified account and add it to the group as an ordinary member. | |
| 108 | + | async fn add_member(h: &mut TestHarness, group_id: &str, username: &str) -> UserId { | |
| 109 | + | let email = format!("{username}@example.com"); | |
| 110 | + | let user = h.signup(username, &email, "Password1!").await; | |
| 111 | + | sqlx::query("UPDATE users SET email_verified = true WHERE id = $1") | |
| 112 | + | .bind(user) | |
| 113 | + | .execute(&h.db) | |
| 114 | + | .await | |
| 115 | + | .expect("verify member"); | |
| 116 | + | ||
| 117 | + | let resp = h | |
| 118 | + | .client | |
| 119 | + | .post_json( | |
| 120 | + | &format!("/api/sync/groups/{group_id}/members"), | |
| 121 | + | &json!({ | |
| 122 | + | "member_email": email, | |
| 123 | + | "sealed_gck": format!("sealed_{username}_v1"), | |
| 124 | + | "member_pubkey": format!("pk_{username}"), | |
| 125 | + | }) | |
| 126 | + | .to_string(), | |
| 127 | + | ) | |
| 128 | + | .await; | |
| 129 | + | assert_eq!(resp.status.as_u16(), 204, "add member: {}", resp.text); | |
| 130 | + | user | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | /// An admin with one group, plus the app they both live in. | |
| 134 | + | async fn group_with_admin(h: &mut TestHarness, tag: &str) -> (UserId, SyncAppId, String) { | |
| 135 | + | let admin = h | |
| 136 | + | .signup( | |
| 137 | + | &format!("{tag}_admin"), | |
| 138 | + | &format!("{tag}_admin@example.com"), | |
| 139 | + | "Password1!", | |
| 140 | + | ) | |
| 141 | + | .await; | |
| 142 | + | let (app, _key) = create_internal_app(&h.db, admin).await; | |
| 143 | + | auth_as(h, admin, app, "admin-key"); | |
| 144 | + | let group_id = create_group(h, "Team").await; | |
| 145 | + | (admin, app, group_id) | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | /// Every group-scoped handler, asked by somebody in the same app who is not in | |
| 149 | + | /// the group. All of them must answer 403, and none of them may leak the group's | |
| 150 | + | /// contents on the way. | |
| 151 | + | /// | |
| 152 | + | /// The read endpoints matter most here: `pull` and `grant` return the group's | |
| 153 | + | /// encrypted changelog and a sealed key, and an endpoint that answered | |
| 154 | + | /// 200-with-nothing rather than 403 would be one refactor away from answering | |
| 155 | + | /// 200-with-something. | |
| 156 | + | #[tokio::test] | |
| 157 | + | async fn a_stranger_in_the_same_app_is_refused_every_group_endpoint() { | |
| 158 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 159 | + | let (_admin, app, group_id) = group_with_admin(&mut h, "stranger").await; | |
| 160 | + | ||
| 161 | + | let stranger = h | |
| 162 | + | .signup("stranger_eve", "stranger_eve@example.com", "Password1!") | |
| 163 | + | .await; | |
| 164 | + | auth_as(&mut h, stranger, app, "eve-key"); | |
| 165 | + | ||
| 166 | + | // Member-gated and admin-gated alike: a non-member is refused by the first | |
| 167 | + | // guard either way, so the whole surface answers the same way. | |
| 168 | + | let endpoints: Vec<(&str, String, String)> = vec![ | |
| 169 | + | ("GET", "/members".into(), String::new()), | |
| 170 | + | ( | |
| 171 | + | "POST", | |
| 172 | + | "/members".into(), | |
| 173 | + | plausible_body("/members", stranger), | |
| 174 | + | ), | |
| 175 | + | ("GET", "/pubkeys".into(), String::new()), | |
| 176 | + | ( | |
| 177 | + | "POST", | |
| 178 | + | "/rotate".into(), | |
| 179 | + | plausible_body("/rotate", stranger), | |
| 180 | + | ), | |
| 181 | + | ("GET", "/grant".into(), String::new()), | |
| 182 | + | ("POST", "/pull".into(), plausible_body("/pull", stranger)), | |
| 183 | + | ( | |
| 184 | + | "POST", | |
| 185 | + | "/invitations".into(), | |
| 186 | + | plausible_body("/invitations", stranger), | |
| 187 | + | ), | |
| 188 | + | ("GET", "/invitations".into(), String::new()), | |
| 189 | + | ]; | |
| 190 | + | ||
| 191 | + | for (method, suffix, body) in endpoints { | |
| 192 | + | let path = format!("/api/sync/groups/{group_id}{suffix}"); | |
| 193 | + | let resp = call(&mut h, method, &path, &body).await; | |
| 194 | + | assert_eq!( | |
| 195 | + | resp.status.as_u16(), | |
| 196 | + | 403, | |
| 197 | + | "{method} {suffix} must refuse a non-member, got {}: {}", | |
| 198 | + | resp.status, | |
| 199 | + | resp.text | |
| 200 | + | ); | |
| 201 | + | assert!( | |
| 202 | + | !resp.text.contains("sealed_admin_v1"), | |
| 203 | + | "{method} {suffix} leaked a grant to a non-member" | |
| 204 | + | ); | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | // And the group does not show up in what they can see. | |
| 208 | + | let resp = h.client.get("/api/sync/groups").await; | |
| 209 | + | assert_eq!(resp.status.as_u16(), 200, "list groups: {}", resp.text); | |
| 210 | + | assert!( | |
| 211 | + | !resp.text.contains(&group_id), | |
| 212 | + | "a group the caller is not in must not be listed for them" | |
| 213 | + | ); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | /// The admin-only half of the matrix, asked by a real member. This is the | |
| 217 | + | /// distinction a single missing guard erases: the caller is legitimately in the | |
| 218 | + | /// group, so `require_member` passes and only `require_admin` is left. | |
| 219 | + | /// | |
| 220 | + | /// A member who could add members could add themselves an accomplice; one who | |
| 221 | + | /// could read `/pubkeys` could seal to identities they were never given; one who | |
| 222 | + | /// could mint an invitation could hand out the group. | |
| 223 | + | #[tokio::test] | |
| 224 | + | async fn a_member_who_is_not_the_admin_is_refused_the_admin_endpoints() { | |
| 225 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 226 | + | let (_admin, app, group_id) = group_with_admin(&mut h, "member").await; | |
| 227 | + | let bob = add_member(&mut h, &group_id, "member_bob").await; | |
| 228 | + | ||
| 229 | + | auth_as(&mut h, bob, app, "bob-key"); | |
| 230 | + | ||
| 231 | + | for (method, suffix) in ADMIN_ONLY { | |
| 232 | + | let path = format!("/api/sync/groups/{group_id}{suffix}"); | |
| 233 | + | let resp = call(&mut h, method, &path, &plausible_body(suffix, bob)).await; | |
| 234 | + | assert_eq!( | |
| 235 | + | resp.status.as_u16(), | |
| 236 | + | 403, | |
| 237 | + | "{method} {suffix} is admin-only, got {}: {}", | |
| 238 | + | resp.status, | |
| 239 | + | resp.text | |
| 240 | + | ); | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // The member-gated endpoints stay open to them, which is what makes the | |
| 244 | + | // assertions above about `require_admin` rather than about being refused | |
| 245 | + | // in general. | |
| 246 | + | for suffix in ["/members", "/grant"] { | |
| 247 | + | let path = format!("/api/sync/groups/{group_id}{suffix}"); | |
| 248 | + | let resp = h.client.get(&path).await; | |
| 249 | + | assert_eq!( | |
| 250 | + | resp.status.as_u16(), | |
| 251 | + | 200, | |
| 252 | + | "GET {suffix} is open to a member: {}", | |
| 253 | + | resp.text | |
| 254 | + | ); | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// A member cannot remove another member, and in particular cannot remove the | |
| 259 | + | /// admin. `remove_member` is the one admin endpoint whose path carries a second | |
| 260 | + | /// id, so it is the one where a guard could be written against the wrong subject. | |
| 261 | + | #[tokio::test] | |
| 262 | + | async fn a_member_cannot_remove_anyone_including_the_admin() { | |
| 263 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 264 | + | let (admin, app, group_id) = group_with_admin(&mut h, "removal").await; | |
| 265 | + | let bob = add_member(&mut h, &group_id, "removal_bob").await; | |
| 266 | + | let carol = add_member(&mut h, &group_id, "removal_carol").await; | |
| 267 | + | ||
| 268 | + | auth_as(&mut h, bob, app, "bob-key"); | |
| 269 | + | ||
| 270 | + | for target in [admin, carol, bob] { | |
| 271 | + | let resp = h | |
| 272 | + | .client | |
| 273 | + | .delete(&format!("/api/sync/groups/{group_id}/members/{target}")) | |
| 274 | + | .await; | |
| 275 | + | assert_eq!( | |
| 276 | + | resp.status.as_u16(), | |
| 277 | + | 403, | |
| 278 | + | "a member removing {target} must be refused, got {}: {}", | |
| 279 | + | resp.status, | |
| 280 | + | resp.text | |
| 281 | + | ); | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // Nobody left. Read it back as the admin, since membership is admin-visible | |
| 285 | + | // and this is the assertion the three refusals above are for. | |
| 286 | + | auth_as(&mut h, admin, app, "admin-key"); | |
| 287 | + | let resp = h | |
| 288 | + | .client | |
| 289 | + | .get(&format!("/api/sync/groups/{group_id}/members")) | |
| 290 | + | .await; | |
| 291 | + | assert_eq!(resp.status.as_u16(), 200, "list members: {}", resp.text); | |
| 292 | + | let members = resp.json::<serde_json::Value>(); | |
| 293 | + | assert_eq!( | |
| 294 | + | members.as_array().map(Vec::len), | |
| 295 | + | Some(3), | |
| 296 | + | "all three are still in the group: {members}" | |
| 297 | + | ); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// App scope is applied before membership, so a group belonging to another app | |
| 301 | + | /// is 404 rather than 403 even to a caller who is its admin under a different | |
| 302 | + | /// token. Answering 403 would confirm the id exists, which is a cross-tenant | |
| 303 | + | /// read of exactly the kind the scope is there to refuse. | |
| 304 | + | #[tokio::test] | |
| 305 | + | async fn a_group_in_another_app_is_not_found_rather_than_forbidden() { | |
| 306 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 307 | + | let (admin, _first_app, group_id) = group_with_admin(&mut h, "scope").await; | |
| 308 | + | ||
| 309 | + | // The same human, a second app of their own. Nothing about the group changed; | |
| 310 | + | // only the app claim in the token did. Inserted here rather than through | |
| 311 | + | // `create_internal_app`, which mints a fixed api_key and so cannot be called | |
| 312 | + | // twice against one database. | |
| 313 | + | let second_key = "test-api-key-group-scope-second"; | |
| 314 | + | let second_app: SyncAppId = sqlx::query_scalar( | |
| 315 | + | "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix, is_internal, billing_status) \ | |
| 316 | + | VALUES ($1, 'SecondApp', $2, $3, TRUE, 'active') RETURNING id", | |
| 317 | + | ) | |
| 318 | + | .bind(admin) | |
| 319 | + | .bind(crate::harness::hash_api_key(second_key)) | |
| 320 | + | .bind(&second_key[..8]) | |
| 321 | + | .fetch_one(&h.db) | |
| 322 | + | .await | |
| 323 | + | .expect("insert the second internal app"); | |
| 324 | + | auth_as(&mut h, admin, second_app, second_key); | |
| 325 | + | ||
| 326 | + | for (method, suffix) in [("GET", "/members"), ("GET", "/grant"), ("POST", "/pull")] { | |
| 327 | + | let path = format!("/api/sync/groups/{group_id}{suffix}"); | |
| 328 | + | let resp = call(&mut h, method, &path, &plausible_body(suffix, admin)).await; | |
| 329 | + | assert_eq!( | |
| 330 | + | resp.status.as_u16(), | |
| 331 | + | 404, | |
| 332 | + | "{method} {suffix} under another app must not confirm the id exists, \ | |
| 333 | + | got {}: {}", | |
| 334 | + | resp.status, | |
| 335 | + | resp.text | |
| 336 | + | ); | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | /// A group id that exists nowhere answers the same 404 as one in another app, so | |
| 341 | + | /// the two cases are indistinguishable from outside. If they diverged, the pair | |
| 342 | + | /// would become an oracle for which group ids are real. | |
| 343 | + | #[tokio::test] | |
| 344 | + | async fn an_unknown_group_answers_the_same_as_one_in_another_app() { | |
| 345 | + | let (mut h, _blobs) = harness_with_blobs().await; | |
| 346 | + | let (_admin, _app, _group_id) = group_with_admin(&mut h, "ghost").await; | |
| 347 | + | ||
| 348 | + | let ghost = uuid::Uuid::new_v4(); | |
| 349 | + | let resp = h | |
| 350 | + | .client | |
| 351 | + | .get(&format!("/api/sync/groups/{ghost}/members")) | |
| 352 | + | .await; | |
| 353 | + | ||
| 354 | + | assert_eq!( | |
| 355 | + | resp.status.as_u16(), | |
| 356 | + | 404, | |
| 357 | + | "an id that matches no group is not found: {}", | |
| 358 | + | resp.text | |
| 359 | + | ); | |
| 360 | + | } |
| @@ -1,0 +1,311 @@ | |||
| 1 | + | //! HTTP contract tests for `routes::synckit::sync`, the endpoints an end user's | |
| 2 | + | //! app talks to once it holds a sync token. | |
| 3 | + | //! | |
| 4 | + | //! Seven suites drive parts of this file already: `synckit` (push, pull, devices, | |
| 5 | + | //! keys), `synckit_selective` (the pull filters), `synckit_paid_sync` (the | |
| 6 | + | //! subscription gate and the storage quota), `synckit_security` (device removal | |
| 7 | + | //! invalidating tokens), `synckit_sse`, `synckit_group_rotation` and | |
| 8 | + | //! `synckit_adversarial`. Between them the data path is well covered. | |
| 9 | + | //! | |
| 10 | + | //! Two endpoints on it were not covered at all, and the larger of the two is the | |
| 11 | + | //! one that moves money. | |
| 12 | + | //! | |
| 13 | + | //! `queue_storage_cap_change` re-prices a live Stripe subscription. It has three | |
| 14 | + | //! contracts worth the name and no test reached any of them. The bounds are | |
| 15 | + | //! checked before the provider is touched, so a nonsense cap costs nothing and | |
| 16 | + | //! cannot re-price anything. Stripe is updated *first* and the database second, | |
| 17 | + | //! deliberately, so a provider failure leaves the user on the cap they are paying | |
| 18 | + | //! for rather than sitting on headroom they were never billed for. And the | |
| 19 | + | //! direction decides the timing: raising takes effect immediately, because a user | |
| 20 | + | //! raising their cap is usually one already blocked by a full one, while lowering | |
| 21 | + | //! is queued to the period boundary, because they paid for the room. | |
| 22 | + | //! | |
| 23 | + | //! `sync_account` is the smaller one, and it had no test of any kind. It answers | |
| 24 | + | //! with an identity, from a bearer token, which makes "whose identity" the only | |
| 25 | + | //! question it has. | |
| 26 | + | //! | |
| 27 | + | //! Delete this file and a cap change could bill for one thing and store another, | |
| 28 | + | //! a Stripe outage could hand out free storage, and a lowered cap could take | |
| 29 | + | //! effect the moment it was asked for, inside a period the user already paid for. | |
| 30 | + | ||
| 31 | + | use super::synckit_paid_sync::{auth_as, create_internal_app, seed_subscription}; | |
| 32 | + | use crate::harness::TestHarness; | |
| 33 | + | use crate::harness::faults::stripe_unavailable; | |
| 34 | + | use makenotwork::db::{SyncAppId, UserId}; | |
| 35 | + | use makenotwork::payments::{MAX_CAP_BYTES, MIN_CAP_BYTES}; | |
| 36 | + | use serde_json::{Value, json}; | |
| 37 | + | ||
| 38 | + | const GIB: i64 = 1024 * 1024 * 1024; | |
| 39 | + | ||
| 40 | + | /// A subscribed user of an internal app, authenticated as their own device. | |
| 41 | + | /// Returns their id, the app id, and the cap they start on. | |
| 42 | + | async fn subscribed_user( | |
| 43 | + | h: &mut TestHarness, | |
| 44 | + | username: &str, | |
| 45 | + | starting_cap: i64, | |
| 46 | + | ) -> (UserId, SyncAppId) { | |
| 47 | + | let user_id = h | |
| 48 | + | .signup(username, &format!("{username}@test.com"), "Password1!") | |
| 49 | + | .await; | |
| 50 | + | let (app_id, api_key) = create_internal_app(&h.db, user_id).await; | |
| 51 | + | seed_subscription(&h.db, user_id, app_id, "active", starting_cap).await; | |
| 52 | + | auth_as(h, user_id, app_id, &api_key); | |
| 53 | + | (user_id, app_id) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// `(storage_limit_bytes, pending_storage_limit_bytes)` as stored. | |
| 57 | + | async fn caps(h: &TestHarness, user_id: UserId, app_id: SyncAppId) -> (Option<i64>, Option<i64>) { | |
| 58 | + | sqlx::query_as::<_, (Option<i64>, Option<i64>)>( | |
| 59 | + | "SELECT storage_limit_bytes, pending_storage_limit_bytes \ | |
| 60 | + | FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2", | |
| 61 | + | ) | |
| 62 | + | .bind(user_id) | |
| 63 | + | .bind(app_id) | |
| 64 | + | .fetch_one(&h.db) | |
| 65 | + | .await | |
| 66 | + | .expect("read caps") | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | /// How many times the provider was asked to re-price the subscription. | |
| 70 | + | fn repricings(h: &TestHarness) -> u32 { | |
| 71 | + | h.mock_stripe | |
| 72 | + | .as_ref() | |
| 73 | + | .expect("with_mocks provides a payment provider") | |
| 74 | + | .faults() | |
| 75 | + | .calls("update_synckit_app_sub_price") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | async fn post_cap(h: &mut TestHarness, cap_bytes: i64) -> crate::harness::client::TestResponse { | |
| 79 | + | h.client | |
| 80 | + | .post_json( | |
| 81 | + | "/api/v1/sync/subscription/storage-cap", | |
| 82 | + | &json!({ "cap_bytes": cap_bytes }).to_string(), | |
| 83 | + | ) | |
| 84 | + | .await | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// A cap outside the offered range is refused before the provider is touched. | |
| 88 | + | /// The call count is the assertion that matters: a handler that re-priced first | |
| 89 | + | /// and validated second would leave a live subscription billing for a cap the | |
| 90 | + | /// database then refused to record. | |
| 91 | + | #[tokio::test] | |
| 92 | + | async fn a_cap_outside_the_offered_range_never_reaches_the_provider() { | |
| 93 | + | let mut h = TestHarness::with_mocks().await; | |
| 94 | + | let (user_id, app_id) = subscribed_user(&mut h, "capbounds", 500 * GIB).await; | |
| 95 | + | ||
| 96 | + | for cap in [0, MIN_CAP_BYTES - 1, MAX_CAP_BYTES + 1] { | |
| 97 | + | let resp = post_cap(&mut h, cap).await; | |
| 98 | + | assert_eq!( | |
| 99 | + | resp.status.as_u16(), | |
| 100 | + | 400, | |
| 101 | + | "{cap} bytes is not a cap we sell: {}", | |
| 102 | + | resp.text | |
| 103 | + | ); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | assert_eq!( | |
| 107 | + | repricings(&h), | |
| 108 | + | 0, | |
| 109 | + | "the bounds are checked before Stripe, so a nonsense cap costs nothing" | |
| 110 | + | ); | |
| 111 | + | assert_eq!( | |
| 112 | + | caps(&h, user_id, app_id).await, | |
| 113 | + | (Some(500 * GIB), None), | |
| 114 | + | "and the stored cap is untouched" | |
| 115 | + | ); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Stripe first, database second. If the re-price fails, the user must be left | |
| 119 | + | /// on the cap they are paying for: recording the new cap anyway would hand out | |
| 120 | + | /// storage nobody is billed for, and every such failure is silent, because the | |
| 121 | + | /// user sees the error and the platform sees nothing. | |
| 122 | + | #[tokio::test] | |
| 123 | + | async fn a_provider_failure_leaves_the_stored_cap_where_it_was() { | |
| 124 | + | let mut h = TestHarness::with_mocks().await; | |
| 125 | + | let (user_id, app_id) = subscribed_user(&mut h, "capoutage", 500 * GIB).await; | |
| 126 | + | ||
| 127 | + | h.mock_stripe | |
| 128 | + | .as_ref() | |
| 129 | + | .expect("with_mocks provides a payment provider") | |
| 130 | + | .faults() | |
| 131 | + | .fail_always("update_synckit_app_sub_price", stripe_unavailable); | |
| 132 | + | ||
| 133 | + | let resp = post_cap(&mut h, 1000 * GIB).await; | |
| 134 | + | assert_eq!( | |
| 135 | + | resp.status.as_u16(), | |
| 136 | + | 503, | |
| 137 | + | "the provider is down, so the change did not happen: {}", | |
| 138 | + | resp.text | |
| 139 | + | ); | |
| 140 | + | assert_eq!( | |
| 141 | + | caps(&h, user_id, app_id).await, | |
| 142 | + | (Some(500 * GIB), None), | |
| 143 | + | "no cap is granted that Stripe was not told to bill for" | |
| 144 | + | ); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// Raising takes effect now. The user asking is usually one already blocked by a | |
| 148 | + | /// full cap, and Stripe has just been re-priced either way, so making them wait | |
| 149 | + | /// for the period boundary would sell them headroom they cannot use. | |
| 150 | + | #[tokio::test] | |
| 151 | + | async fn raising_the_cap_takes_effect_immediately() { | |
| 152 | + | let mut h = TestHarness::with_mocks().await; | |
| 153 | + | let (user_id, app_id) = subscribed_user(&mut h, "capraise", 500 * GIB).await; | |
| 154 | + | ||
| 155 | + | let resp = post_cap(&mut h, 1000 * GIB).await; | |
| 156 | + | assert_eq!(resp.status.as_u16(), 200, "raise the cap: {}", resp.text); | |
| 157 | + | ||
| 158 | + | assert_eq!( | |
| 159 | + | caps(&h, user_id, app_id).await, | |
| 160 | + | (Some(1000 * GIB), None), | |
| 161 | + | "the new cap is live and nothing is left pending" | |
| 162 | + | ); | |
| 163 | + | assert_eq!(repricings(&h), 1, "and Stripe was re-priced exactly once"); | |
| 164 | + | ||
| 165 | + | let body: Value = resp.json(); | |
| 166 | + | assert_eq!( | |
| 167 | + | body["storage_limit_bytes"].as_i64(), | |
| 168 | + | Some(1000 * GIB), | |
| 169 | + | "the answer reports the cap that is now in force" | |
| 170 | + | ); | |
| 171 | + | assert!( | |
| 172 | + | body["pending_storage_limit_bytes"].is_null(), | |
| 173 | + | "and nothing is queued, got {}", | |
| 174 | + | body["pending_storage_limit_bytes"] | |
| 175 | + | ); | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | /// Lowering waits for the period boundary. The user paid for the room they | |
| 179 | + | /// currently have, so taking it away mid-period is taking back something already | |
| 180 | + | /// bought; the renewal webhook promotes the pending cap when Stripe rolls the | |
| 181 | + | /// period. | |
| 182 | + | #[tokio::test] | |
| 183 | + | async fn lowering_the_cap_is_queued_for_the_period_boundary() { | |
| 184 | + | let mut h = TestHarness::with_mocks().await; | |
| 185 | + | let (user_id, app_id) = subscribed_user(&mut h, "caplower", 1000 * GIB).await; | |
| 186 | + | ||
| 187 | + | let resp = post_cap(&mut h, 500 * GIB).await; | |
| 188 | + | assert_eq!(resp.status.as_u16(), 200, "lower the cap: {}", resp.text); | |
| 189 | + | ||
| 190 | + | assert_eq!( | |
| 191 | + | caps(&h, user_id, app_id).await, | |
| 192 | + | (Some(1000 * GIB), Some(500 * GIB)), | |
| 193 | + | "the room stays until the period the user paid for ends" | |
| 194 | + | ); | |
| 195 | + | assert_eq!( | |
| 196 | + | repricings(&h), | |
| 197 | + | 1, | |
| 198 | + | "Stripe is re-priced now, with no proration" | |
| 199 | + | ); | |
| 200 | + | ||
| 201 | + | let body: Value = resp.json(); | |
| 202 | + | assert_eq!( | |
| 203 | + | body["storage_limit_bytes"].as_i64(), | |
| 204 | + | Some(1000 * GIB), | |
| 205 | + | "the answer still reports the cap in force" | |
| 206 | + | ); | |
| 207 | + | assert_eq!( | |
| 208 | + | body["pending_storage_limit_bytes"].as_i64(), | |
| 209 | + | Some(500 * GIB), | |
| 210 | + | "alongside the one that takes over at renewal" | |
| 211 | + | ); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | /// There is nothing to re-price without a subscription, and asking Stripe to | |
| 215 | + | /// change a subscription that does not exist is how a handler ends up acting on | |
| 216 | + | /// somebody else's. | |
| 217 | + | #[tokio::test] | |
| 218 | + | async fn a_cap_change_without_a_subscription_is_refused() { | |
| 219 | + | let mut h = TestHarness::with_mocks().await; | |
| 220 | + | let user_id = h | |
| 221 | + | .signup("capnosub", "capnosub@test.com", "Password1!") | |
| 222 | + | .await; | |
| 223 | + | let (app_id, api_key) = create_internal_app(&h.db, user_id).await; | |
| 224 | + | auth_as(&mut h, user_id, app_id, &api_key); | |
| 225 | + | ||
| 226 | + | let resp = post_cap(&mut h, 1000 * GIB).await; | |
| 227 | + | ||
| 228 | + | assert_eq!( | |
| 229 | + | resp.status.as_u16(), | |
| 230 | + | 400, | |
| 231 | + | "no subscription, nothing to adjust: {}", | |
| 232 | + | resp.text | |
| 233 | + | ); | |
| 234 | + | assert_eq!(repricings(&h), 0, "and the provider is never called"); | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | /// The subscription endpoint answers 200 with `active: false` rather than 404 | |
| 238 | + | /// when there is no subscription. Clients render a subscribe prompt off that | |
| 239 | + | /// shape, so a 404 here would show them an error screen at exactly the moment | |
| 240 | + | /// they are being asked to pay. | |
| 241 | + | #[tokio::test] | |
| 242 | + | async fn an_unsubscribed_user_gets_an_inactive_status_rather_than_a_404() { | |
| 243 | + | let mut h = TestHarness::with_mocks().await; | |
| 244 | + | let user_id = h | |
| 245 | + | .signup("substatus", "substatus@test.com", "Password1!") | |
| 246 | + | .await; | |
| 247 | + | let (app_id, api_key) = create_internal_app(&h.db, user_id).await; | |
| 248 | + | auth_as(&mut h, user_id, app_id, &api_key); | |
| 249 | + | ||
| 250 | + | let resp = h.client.get("/api/v1/sync/subscription").await; | |
| 251 | + | ||
| 252 | + | assert_eq!( | |
| 253 | + | resp.status.as_u16(), | |
| 254 | + | 200, | |
| 255 | + | "having no subscription is an answer, not an error: {}", | |
| 256 | + | resp.text | |
| 257 | + | ); | |
| 258 | + | let body: Value = resp.json(); | |
| 259 | + | assert_eq!( | |
| 260 | + | body["active"].as_bool(), | |
| 261 | + | Some(false), | |
| 262 | + | "and the answer is that they do not have one" | |
| 263 | + | ); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | /// The account endpoint answers with an identity, taken from a bearer token, so | |
| 267 | + | /// the only question it has is whose. Two subscribed users, two tokens, and each | |
| 268 | + | /// must see itself. | |
| 269 | + | #[tokio::test] | |
| 270 | + | async fn the_account_endpoint_answers_for_the_token_holder_and_nobody_else() { | |
| 271 | + | let mut h = TestHarness::with_mocks().await; | |
| 272 | + | ||
| 273 | + | let first = h.signup("acctone", "acctone@test.com", "Password1!").await; | |
| 274 | + | let (app_id, api_key) = create_internal_app(&h.db, first).await; | |
| 275 | + | auth_as(&mut h, first, app_id, &api_key); | |
| 276 | + | ||
| 277 | + | let resp = h.client.get("/api/v1/sync/account").await; | |
| 278 | + | assert_eq!(resp.status.as_u16(), 200, "account: {}", resp.text); | |
| 279 | + | let body: Value = resp.json(); | |
| 280 | + | assert_eq!(body["username"].as_str(), Some("acctone")); | |
| 281 | + | assert_eq!(body["email"].as_str(), Some("acctone@test.com")); | |
| 282 | + | ||
| 283 | + | // A second user of the same app. The app id in the token is identical; only | |
| 284 | + | // the user claim differs, which is the claim this endpoint has to read. | |
| 285 | + | let second = h.signup("accttwo", "accttwo@test.com", "Password1!").await; | |
| 286 | + | auth_as(&mut h, second, app_id, &api_key); | |
| 287 | + | ||
| 288 | + | let resp = h.client.get("/api/v1/sync/account").await; | |
| 289 | + | assert_eq!(resp.status.as_u16(), 200, "second account: {}", resp.text); | |
| 290 | + | let body: Value = resp.json(); | |
| 291 | + | assert_eq!( | |
| 292 | + | body["username"].as_str(), | |
| 293 | + | Some("accttwo"), | |
| 294 | + | "a token for the second user must not answer with the first user's name" | |
| 295 | + | ); | |
| 296 | + | assert_eq!(body["email"].as_str(), Some("accttwo@test.com")); | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | /// Without a token there is no identity to answer with. | |
| 300 | + | #[tokio::test] | |
| 301 | + | async fn the_account_endpoint_is_closed_to_an_unauthenticated_caller() { | |
| 302 | + | let mut h = TestHarness::with_mocks().await; | |
| 303 | + | ||
| 304 | + | let resp = h.client.get("/api/v1/sync/account").await; | |
| 305 | + | ||
| 306 | + | assert!( | |
| 307 | + | resp.status.is_client_error(), | |
| 308 | + | "an identity endpoint with no token is refused, got {}", | |
| 309 | + | resp.status | |
| 310 | + | ); | |
| 311 | + | } |