Skip to main content

max / makenotwork

11.6 KB · 313 lines History Blame Raw
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_eq!(
307 resp.status.as_u16(),
308 401,
309 "an identity endpoint with no token is unauthorized, got {}",
310 resp.status
311 );
312 }
313