//! HTTP contract tests for `routes::synckit::sync`, the endpoints an end user's //! app talks to once it holds a sync token. //! //! Seven suites drive parts of this file already: `synckit` (push, pull, devices, //! keys), `synckit_selective` (the pull filters), `synckit_paid_sync` (the //! subscription gate and the storage quota), `synckit_security` (device removal //! invalidating tokens), `synckit_sse`, `synckit_group_rotation` and //! `synckit_adversarial`. Between them the data path is well covered. //! //! Two endpoints on it were not covered at all, and the larger of the two is the //! one that moves money. //! //! `queue_storage_cap_change` re-prices a live Stripe subscription. It has three //! contracts worth the name and no test reached any of them. The bounds are //! checked before the provider is touched, so a nonsense cap costs nothing and //! cannot re-price anything. Stripe is updated *first* and the database second, //! deliberately, so a provider failure leaves the user on the cap they are paying //! for rather than sitting on headroom they were never billed for. And the //! direction decides the timing: raising takes effect immediately, because a user //! raising their cap is usually one already blocked by a full one, while lowering //! is queued to the period boundary, because they paid for the room. //! //! `sync_account` is the smaller one, and it had no test of any kind. It answers //! with an identity, from a bearer token, which makes "whose identity" the only //! question it has. //! //! Delete this file and a cap change could bill for one thing and store another, //! a Stripe outage could hand out free storage, and a lowered cap could take //! effect the moment it was asked for, inside a period the user already paid for. use super::synckit_paid_sync::{auth_as, create_internal_app, seed_subscription}; use crate::harness::TestHarness; use crate::harness::faults::stripe_unavailable; use makenotwork::db::{SyncAppId, UserId}; use makenotwork::payments::{MAX_CAP_BYTES, MIN_CAP_BYTES}; use serde_json::{Value, json}; const GIB: i64 = 1024 * 1024 * 1024; /// A subscribed user of an internal app, authenticated as their own device. /// Returns their id, the app id, and the cap they start on. async fn subscribed_user( h: &mut TestHarness, username: &str, starting_cap: i64, ) -> (UserId, SyncAppId) { let user_id = h .signup(username, &format!("{username}@test.com"), "Password1!") .await; let (app_id, api_key) = create_internal_app(&h.db, user_id).await; seed_subscription(&h.db, user_id, app_id, "active", starting_cap).await; auth_as(h, user_id, app_id, &api_key); (user_id, app_id) } /// `(storage_limit_bytes, pending_storage_limit_bytes)` as stored. async fn caps(h: &TestHarness, user_id: UserId, app_id: SyncAppId) -> (Option, Option) { sqlx::query_as::<_, (Option, Option)>( "SELECT storage_limit_bytes, pending_storage_limit_bytes \ FROM app_sync_subscriptions WHERE user_id = $1 AND app_id = $2", ) .bind(user_id) .bind(app_id) .fetch_one(&h.db) .await .expect("read caps") } /// How many times the provider was asked to re-price the subscription. fn repricings(h: &TestHarness) -> u32 { h.mock_stripe .as_ref() .expect("with_mocks provides a payment provider") .faults() .calls("update_synckit_app_sub_price") } async fn post_cap(h: &mut TestHarness, cap_bytes: i64) -> crate::harness::client::TestResponse { h.client .post_json( "/api/v1/sync/subscription/storage-cap", &json!({ "cap_bytes": cap_bytes }).to_string(), ) .await } /// A cap outside the offered range is refused before the provider is touched. /// The call count is the assertion that matters: a handler that re-priced first /// and validated second would leave a live subscription billing for a cap the /// database then refused to record. #[tokio::test] async fn a_cap_outside_the_offered_range_never_reaches_the_provider() { let mut h = TestHarness::with_mocks().await; let (user_id, app_id) = subscribed_user(&mut h, "capbounds", 500 * GIB).await; for cap in [0, MIN_CAP_BYTES - 1, MAX_CAP_BYTES + 1] { let resp = post_cap(&mut h, cap).await; assert_eq!( resp.status.as_u16(), 400, "{cap} bytes is not a cap we sell: {}", resp.text ); } assert_eq!( repricings(&h), 0, "the bounds are checked before Stripe, so a nonsense cap costs nothing" ); assert_eq!( caps(&h, user_id, app_id).await, (Some(500 * GIB), None), "and the stored cap is untouched" ); } /// Stripe first, database second. If the re-price fails, the user must be left /// on the cap they are paying for: recording the new cap anyway would hand out /// storage nobody is billed for, and every such failure is silent, because the /// user sees the error and the platform sees nothing. #[tokio::test] async fn a_provider_failure_leaves_the_stored_cap_where_it_was() { let mut h = TestHarness::with_mocks().await; let (user_id, app_id) = subscribed_user(&mut h, "capoutage", 500 * GIB).await; h.mock_stripe .as_ref() .expect("with_mocks provides a payment provider") .faults() .fail_always("update_synckit_app_sub_price", stripe_unavailable); let resp = post_cap(&mut h, 1000 * GIB).await; assert_eq!( resp.status.as_u16(), 503, "the provider is down, so the change did not happen: {}", resp.text ); assert_eq!( caps(&h, user_id, app_id).await, (Some(500 * GIB), None), "no cap is granted that Stripe was not told to bill for" ); } /// Raising takes effect now. The user asking is usually one already blocked by a /// full cap, and Stripe has just been re-priced either way, so making them wait /// for the period boundary would sell them headroom they cannot use. #[tokio::test] async fn raising_the_cap_takes_effect_immediately() { let mut h = TestHarness::with_mocks().await; let (user_id, app_id) = subscribed_user(&mut h, "capraise", 500 * GIB).await; let resp = post_cap(&mut h, 1000 * GIB).await; assert_eq!(resp.status.as_u16(), 200, "raise the cap: {}", resp.text); assert_eq!( caps(&h, user_id, app_id).await, (Some(1000 * GIB), None), "the new cap is live and nothing is left pending" ); assert_eq!(repricings(&h), 1, "and Stripe was re-priced exactly once"); let body: Value = resp.json(); assert_eq!( body["storage_limit_bytes"].as_i64(), Some(1000 * GIB), "the answer reports the cap that is now in force" ); assert!( body["pending_storage_limit_bytes"].is_null(), "and nothing is queued, got {}", body["pending_storage_limit_bytes"] ); } /// Lowering waits for the period boundary. The user paid for the room they /// currently have, so taking it away mid-period is taking back something already /// bought; the renewal webhook promotes the pending cap when Stripe rolls the /// period. #[tokio::test] async fn lowering_the_cap_is_queued_for_the_period_boundary() { let mut h = TestHarness::with_mocks().await; let (user_id, app_id) = subscribed_user(&mut h, "caplower", 1000 * GIB).await; let resp = post_cap(&mut h, 500 * GIB).await; assert_eq!(resp.status.as_u16(), 200, "lower the cap: {}", resp.text); assert_eq!( caps(&h, user_id, app_id).await, (Some(1000 * GIB), Some(500 * GIB)), "the room stays until the period the user paid for ends" ); assert_eq!( repricings(&h), 1, "Stripe is re-priced now, with no proration" ); let body: Value = resp.json(); assert_eq!( body["storage_limit_bytes"].as_i64(), Some(1000 * GIB), "the answer still reports the cap in force" ); assert_eq!( body["pending_storage_limit_bytes"].as_i64(), Some(500 * GIB), "alongside the one that takes over at renewal" ); } /// There is nothing to re-price without a subscription, and asking Stripe to /// change a subscription that does not exist is how a handler ends up acting on /// somebody else's. #[tokio::test] async fn a_cap_change_without_a_subscription_is_refused() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("capnosub", "capnosub@test.com", "Password1!") .await; let (app_id, api_key) = create_internal_app(&h.db, user_id).await; auth_as(&mut h, user_id, app_id, &api_key); let resp = post_cap(&mut h, 1000 * GIB).await; assert_eq!( resp.status.as_u16(), 400, "no subscription, nothing to adjust: {}", resp.text ); assert_eq!(repricings(&h), 0, "and the provider is never called"); } /// The subscription endpoint answers 200 with `active: false` rather than 404 /// when there is no subscription. Clients render a subscribe prompt off that /// shape, so a 404 here would show them an error screen at exactly the moment /// they are being asked to pay. #[tokio::test] async fn an_unsubscribed_user_gets_an_inactive_status_rather_than_a_404() { let mut h = TestHarness::with_mocks().await; let user_id = h .signup("substatus", "substatus@test.com", "Password1!") .await; let (app_id, api_key) = create_internal_app(&h.db, user_id).await; auth_as(&mut h, user_id, app_id, &api_key); let resp = h.client.get("/api/v1/sync/subscription").await; assert_eq!( resp.status.as_u16(), 200, "having no subscription is an answer, not an error: {}", resp.text ); let body: Value = resp.json(); assert_eq!( body["active"].as_bool(), Some(false), "and the answer is that they do not have one" ); } /// The account endpoint answers with an identity, taken from a bearer token, so /// the only question it has is whose. Two subscribed users, two tokens, and each /// must see itself. #[tokio::test] async fn the_account_endpoint_answers_for_the_token_holder_and_nobody_else() { let mut h = TestHarness::with_mocks().await; let first = h.signup("acctone", "acctone@test.com", "Password1!").await; let (app_id, api_key) = create_internal_app(&h.db, first).await; auth_as(&mut h, first, app_id, &api_key); let resp = h.client.get("/api/v1/sync/account").await; assert_eq!(resp.status.as_u16(), 200, "account: {}", resp.text); let body: Value = resp.json(); assert_eq!(body["username"].as_str(), Some("acctone")); assert_eq!(body["email"].as_str(), Some("acctone@test.com")); // A second user of the same app. The app id in the token is identical; only // the user claim differs, which is the claim this endpoint has to read. let second = h.signup("accttwo", "accttwo@test.com", "Password1!").await; auth_as(&mut h, second, app_id, &api_key); let resp = h.client.get("/api/v1/sync/account").await; assert_eq!(resp.status.as_u16(), 200, "second account: {}", resp.text); let body: Value = resp.json(); assert_eq!( body["username"].as_str(), Some("accttwo"), "a token for the second user must not answer with the first user's name" ); assert_eq!(body["email"].as_str(), Some("accttwo@test.com")); } /// Without a token there is no identity to answer with. #[tokio::test] async fn the_account_endpoint_is_closed_to_an_unauthenticated_caller() { let mut h = TestHarness::with_mocks().await; let resp = h.client.get("/api/v1/sync/account").await; assert_eq!( resp.status.as_u16(), 401, "an identity endpoint with no token is unauthorized, got {}", resp.status ); }