//! DB-layer contract tests for `db::synckit::blobs`, the SyncKit blob rows and //! the storage accounting hung off them. //! //! `db_synckit_layer` touches two blob cases in passing (owner scoping and the //! internal re-confirm). What had no test anywhere is the arithmetic those rows //! feed, which is where being wrong costs a developer money or hands a user //! free storage: //! //! - `storage_used_bytes` sums the authoritative rows rather than a counter, //! - `confirm_internal_blob` compares `used + size` against the subscription //! cap with `>`, so a blob landing exactly on the cap is allowed and one //! byte more is refused, and a NULL cap fails closed at zero, //! - `confirm_developer_blob` enforces the bulk app-wide cap, the per-key cap, //! and the defensive app aggregate that catches a key which is itself empty, //! - both confirm paths are idempotent under redelivery: a repeated confirm //! of the same `(app, user, hash)` returns `AlreadyStored` and moves no //! counter, because an upload retry must not eat the cap twice, //! - `delete_sync_blob` is the only shrink path: it refunds the app-wide and //! per-key counters by exactly the blob's size, dead-letters the S3 object //! in the same transaction, and a second delete is a `NotFound` no-op that //! refunds nothing a second time, //! - `count_sync_devices` counts one `(app, user)` pair and no other. //! //! Delete this file and the caps stop being tested at their boundaries: an //! off-by-one in either direction, a double refund on a repeated delete, and a //! redelivered confirm charging twice all pass silently. use crate::harness::db::TestDb; use crate::harness::seed_user; use makenotwork::db::synckit; use makenotwork::db::synckit::{BlobConfirm, BlobDelete}; use makenotwork::db::{SyncAppId, SyncEnforcementMode, UserId}; /// One gibibyte. Written out rather than read back from /// `synckit_billing::storage_cap_bytes`, so the boundary cases below compare /// against an independent number instead of re-deriving the one under test. const GIB: i64 = 1024 * 1024 * 1024; /// Seed a sync app owned by `user`, with the `sync_app_usage_current` row the /// developer-billing path locks `FOR UPDATE`. async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None) .await .expect("seed sync app") .id } /// Mark an app first-party, so the end-user subscription model applies. async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) { sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1") .bind(app) .execute(pool) .await .expect("mark internal"); } /// Give `user` an active end-user subscription on `app` capped at `limit_bytes`. async fn seed_subscription( pool: &sqlx::PgPool, user: UserId, app: SyncAppId, sub_id: &str, limit_bytes: Option, ) { sqlx::query( "INSERT INTO app_sync_subscriptions (user_id, app_id, stripe_subscription_id, stripe_customer_id, tier, status, storage_limit_bytes) VALUES ($1, $2, $3, 'cus_test', 'monthly', 'active', $4)", ) .bind(user) .bind(app) .bind(sub_id) .bind(limit_bytes) .execute(pool) .await .expect("seed subscription"); } /// The app-wide running counter the developer-billing path maintains. async fn app_bytes(pool: &sqlx::PgPool, app: SyncAppId) -> i64 { sqlx::query_scalar::<_, i64>( "SELECT bytes_stored FROM sync_app_usage_current WHERE app_id = $1", ) .bind(app) .fetch_one(pool) .await .expect("read app bytes_stored") } /// The per-key running counter, or `None` when the key has no row yet. async fn key_bytes(pool: &sqlx::PgPool, app: SyncAppId, key: &str) -> Option { sqlx::query_scalar::<_, i64>( "SELECT bytes_stored FROM sync_key_usage_current WHERE app_id = $1 AND key = $2", ) .bind(app) .bind(key) .fetch_optional(pool) .await .expect("read key bytes_stored") } /// Count of blob rows for one `(app, user)`. async fn blob_rows(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) -> i64 { sqlx::query_scalar::<_, i64>( "SELECT COUNT(*) FROM sync_blobs WHERE app_id = $1 AND user_id = $2", ) .bind(app) .bind(user) .fetch_one(pool) .await .expect("count blob rows") } /// Every dead-lettered S3 key queued so far, with its bucket and source. async fn pending_deletions(pool: &sqlx::PgPool) -> Vec<(String, String, String)> { sqlx::query_as::<_, (String, String, String)>( "SELECT s3_key, bucket, source FROM pending_s3_deletions ORDER BY s3_key", ) .fetch_all(pool) .await .expect("read pending deletions") } /// Confirm a blob on a bulk-billed developer app with the given GB cap. #[allow(clippy::too_many_arguments)] async fn confirm_bulk( pool: &sqlx::PgPool, app: SyncAppId, user: UserId, hash: &str, size: i64, key: &str, gb_cap: i32, ) -> BlobConfirm { synckit::confirm_developer_blob( pool, app, user, hash, size, &format!("s3/{hash}"), key, SyncEnforcementMode::Bulk, Some(gb_cap), None, None, ) .await .expect("bulk confirm") } /// Confirm a blob on a per-key-billed developer app: `key_cap` keys, `gb_per_key` /// GB each. #[allow(clippy::too_many_arguments)] async fn confirm_per_key( pool: &sqlx::PgPool, app: SyncAppId, user: UserId, hash: &str, size: i64, key: &str, key_cap: i32, gb_per_key: i32, ) -> BlobConfirm { synckit::confirm_developer_blob( pool, app, user, hash, size, &format!("s3/{hash}"), key, SyncEnforcementMode::PerKey, None, Some(key_cap), Some(gb_per_key), ) .await .expect("per-key confirm") } // ── storage_used_bytes ────────────────────────────────────────────────────── /// Three distinct, non-round sizes, so a sum is distinguishable from a count, a /// max, or the size of the first row. #[tokio::test] async fn storage_used_bytes_sums_every_blob_of_one_user_and_no_one_elses() { let db = TestDb::new().await; let alice = seed_user(&db.pool, "skblobs_sum_alice").await; let bob = seed_user(&db.pool, "skblobs_sum_bob").await; let app = seed_app(&db.pool, alice, "blobsum").await; make_internal(&db.pool, app).await; seed_subscription(&db.pool, alice, app, "sub_sum_alice", Some(100_000)).await; seed_subscription(&db.pool, bob, app, "sub_sum_bob", Some(100_000)).await; for (hash, size) in [("h-a", 3000), ("h-b", 700), ("h-c", 41)] { let out = synckit::confirm_internal_blob(&db.pool, app, alice, hash, size, "s3/a", "default") .await .expect("confirm"); assert_eq!(out, BlobConfirm::Stored, "seeding {hash} must store"); } let out = synckit::confirm_internal_blob(&db.pool, app, bob, "h-d", 500, "s3/d", "default") .await .expect("confirm"); assert_eq!(out, BlobConfirm::Stored, "bob's own blob stores"); assert_eq!( synckit::storage_used_bytes(&db.pool, app, alice) .await .expect("alice usage"), 3741, "3000 + 700 + 41: the sum of the rows, not their count or their max" ); assert_eq!( synckit::storage_used_bytes(&db.pool, app, bob) .await .expect("bob usage"), 500, "bob is charged for his row alone" ); } // ── confirm_internal_blob: the end-user cap ───────────────────────────────── /// The gate is `used + size > limit`, so exactly-on-the-cap is allowed and one /// byte past it is refused. Both sides are asserted: a test that only checked /// the refusal could not tell `>` from `>=`. #[tokio::test] async fn an_internal_blob_landing_exactly_on_the_cap_is_stored_and_one_byte_more_is_refused() { let db = TestDb::new().await; let user = seed_user(&db.pool, "skblobs_cap").await; let app = seed_app(&db.pool, user, "blobcap").await; make_internal(&db.pool, app).await; seed_subscription(&db.pool, user, app, "sub_cap", Some(5000)).await; let first = synckit::confirm_internal_blob(&db.pool, app, user, "h-1", 3000, "s3/1", "default") .await .expect("first confirm"); assert_eq!(first, BlobConfirm::Stored, "3000 of a 5000 cap fits"); // 3000 + 2000 == 5000: the boundary itself, which must be allowed. let exact = synckit::confirm_internal_blob(&db.pool, app, user, "h-2", 2000, "s3/2", "default") .await .expect("boundary confirm"); assert_eq!( exact, BlobConfirm::Stored, "a blob that fills the cap exactly is within it" ); // One byte past it is not. let over = synckit::confirm_internal_blob(&db.pool, app, user, "h-3", 1, "s3/3", "default") .await .expect("over-cap confirm"); assert_eq!( over, BlobConfirm::QuotaExceeded { dimension: "storage", used: 5000, limit: 5000, key: None, }, "one byte past a full cap is refused, and the reason names the real numbers" ); assert!( synckit::get_sync_blob_by_hash(&db.pool, app, user, "h-3") .await .expect("lookup") .is_none(), "a refused confirm writes no row" ); assert_eq!( synckit::storage_used_bytes(&db.pool, app, user) .await .expect("usage"), 5000, "and charges nothing" ); } /// A NULL `storage_limit_bytes` becomes a zero cap (`unwrap_or(0)`), so the path /// fails closed rather than treating "no cap recorded" as unlimited. #[tokio::test] async fn an_internal_blob_is_refused_when_the_subscription_records_no_cap() { let db = TestDb::new().await; let user = seed_user(&db.pool, "skblobs_nullcap").await; let app = seed_app(&db.pool, user, "blobnullcap").await; make_internal(&db.pool, app).await; seed_subscription(&db.pool, user, app, "sub_nullcap", None).await; let out = synckit::confirm_internal_blob(&db.pool, app, user, "h-n", 4096, "s3/n", "default") .await .expect("confirm"); assert_eq!( out, BlobConfirm::QuotaExceeded { dimension: "storage", used: 0, limit: 0, key: None, }, "a missing cap is zero, not infinity" ); assert_eq!( blob_rows(&db.pool, app, user).await, 0, "nothing was written" ); } /// Paid-only: no subscription row at all, and a row that is not `active`, are /// both `NoSubscription`, and neither writes. #[tokio::test] async fn an_internal_blob_needs_an_active_subscription_to_be_stored() { let db = TestDb::new().await; let user = seed_user(&db.pool, "skblobs_nosub").await; let app = seed_app(&db.pool, user, "blobnosub").await; make_internal(&db.pool, app).await; let missing = synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default") .await .expect("confirm without a subscription"); assert_eq!( missing, BlobConfirm::NoSubscription, "no subscription row means no write" ); seed_subscription(&db.pool, user, app, "sub_nosub", Some(100_000)).await; synckit::update_app_sync_subscription_status(&db.pool, "sub_nosub", "canceled", None) .await .expect("cancel"); let canceled = synckit::confirm_internal_blob(&db.pool, app, user, "h-m", 2048, "s3/m", "default") .await .expect("confirm on a canceled subscription"); assert_eq!( canceled, BlobConfirm::NoSubscription, "a canceled subscription closes writes even though the row exists" ); assert_eq!( blob_rows(&db.pool, app, user).await, 0, "neither refusal wrote a row" ); } // ── confirm_developer_blob: bulk mode ─────────────────────────────────────── /// Bulk mode caps the app as a whole. Same boundary pair as the internal path, /// against a cap expressed in GB, plus the two counters the path maintains. #[tokio::test] async fn bulk_mode_fills_the_app_cap_exactly_then_refuses_the_next_byte() { let db = TestDb::new().await; let dev = seed_user(&db.pool, "skblobs_bulk_dev").await; let user = seed_user(&db.pool, "skblobs_bulk_user").await; let app = seed_app(&db.pool, dev, "blobbulk").await; let cap = 2 * GIB; // Two blobs on two different keys, so the app-wide counter is visibly the // sum of the keys rather than either one of them. let first = confirm_bulk(&db.pool, app, user, "b-1", cap - 500, "alpha", 2).await; assert_eq!(first, BlobConfirm::Stored, "the first blob fits under 2 GB"); let exact = confirm_bulk(&db.pool, app, user, "b-2", 500, "beta", 2).await; assert_eq!( exact, BlobConfirm::Stored, "the blob that fills the cap exactly is within it" ); let over = confirm_bulk(&db.pool, app, user, "b-3", 1, "beta", 2).await; assert_eq!( over, BlobConfirm::QuotaExceeded { dimension: "storage", used: cap, limit: cap, key: None, }, "bulk mode reports the app dimension with no key attached" ); assert_eq!( app_bytes(&db.pool, app).await, cap, "the app counter is the sum of both blobs" ); assert_eq!( key_bytes(&db.pool, app, "alpha").await, Some(cap - 500), "each key is charged its own blob" ); assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(500)); assert_eq!( blob_rows(&db.pool, app, user).await, 2, "the refused confirm wrote no third row" ); } /// Stripe-style redelivery: the same confirm arriving twice must be a no-op the /// second time, not a second charge against the cap. #[tokio::test] async fn a_redelivered_developer_confirm_is_a_no_op_and_charges_nothing_twice() { let db = TestDb::new().await; let dev = seed_user(&db.pool, "skblobs_replay_dev").await; let user = seed_user(&db.pool, "skblobs_replay_user").await; let app = seed_app(&db.pool, dev, "blobreplay").await; let first = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await; assert_eq!(first, BlobConfirm::Stored); // The identical delivery again, and a third that claims a different size and // key: the hash is the identity, so both are refused a second charge. let second = confirm_bulk(&db.pool, app, user, "r-1", 7000, "alpha", 10).await; assert_eq!( second, BlobConfirm::AlreadyStored, "a redelivery of the same (app, user, hash) is idempotent" ); let third = confirm_bulk(&db.pool, app, user, "r-1", 3300, "beta", 10).await; assert_eq!( third, BlobConfirm::AlreadyStored, "the hash decides, so a redelivery carrying different metadata is still a no-op" ); assert_eq!( app_bytes(&db.pool, app).await, 7000, "the app counter moved once, not three times" ); assert_eq!( key_bytes(&db.pool, app, "alpha").await, Some(7000), "and the key counter with it" ); assert_eq!( key_bytes(&db.pool, app, "beta").await, None, "the redelivery's other key was never charged" ); assert_eq!(blob_rows(&db.pool, app, user).await, 1); } // ── confirm_developer_blob: per-key mode ──────────────────────────────────── /// Per-key mode caps each key at `gb_per_key`. A key that fills its own cap is /// refused while a different key keeps working, which is the whole point of the /// mode: one full key must not degrade the app. #[tokio::test] async fn per_key_mode_refuses_the_key_that_is_full_and_leaves_the_others_writable() { let db = TestDb::new().await; let dev = seed_user(&db.pool, "skblobs_pk_dev").await; let user = seed_user(&db.pool, "skblobs_pk_user").await; let app = seed_app(&db.pool, dev, "blobperkey").await; // 3 keys x 1 GB: per-key limit 1 GiB, app aggregate 3 GiB. let (key_cap, gb) = (3, 1); let first = confirm_per_key(&db.pool, app, user, "p-1", GIB - 700, "alpha", key_cap, gb).await; assert_eq!(first, BlobConfirm::Stored, "under alpha's 1 GB"); let exact = confirm_per_key(&db.pool, app, user, "p-2", 700, "alpha", key_cap, gb).await; assert_eq!( exact, BlobConfirm::Stored, "a blob filling the key cap exactly is within it" ); let over = confirm_per_key(&db.pool, app, user, "p-3", 1, "alpha", key_cap, gb).await; assert_eq!( over, BlobConfirm::QuotaExceeded { dimension: "storage_per_key", used: GIB, limit: GIB, key: Some("alpha".to_string()), }, "the refusal names the key it applies to, so the caller can tell the developer which one" ); // The app aggregate still has 2 GiB free, so another key writes fine. let other = confirm_per_key(&db.pool, app, user, "p-4", 4096, "beta", key_cap, gb).await; assert_eq!( other, BlobConfirm::Stored, "a full key must not close the whole app" ); assert_eq!(key_bytes(&db.pool, app, "alpha").await, Some(GIB)); assert_eq!(key_bytes(&db.pool, app, "beta").await, Some(4096)); assert_eq!( app_bytes(&db.pool, app).await, GIB + 4096, "the app counter is the sum across keys" ); } /// The defensive app-aggregate ceiling: a key with nothing stored still cannot /// write once the app total is at `key_cap * gb_per_key`. Asserted on an empty /// key so it cannot be confused with the per-key check, which would pass here. #[tokio::test] async fn per_key_mode_stops_an_empty_key_once_the_app_aggregate_is_full() { let db = TestDb::new().await; let dev = seed_user(&db.pool, "skblobs_agg_dev").await; let user = seed_user(&db.pool, "skblobs_agg_user").await; let app = seed_app(&db.pool, dev, "blobaggregate").await; // 2 keys x 1 GB: app aggregate 2 GiB. let (key_cap, gb) = (2, 1); for (hash, key) in [("a-1", "alpha"), ("a-2", "beta")] { let out = confirm_per_key(&db.pool, app, user, hash, GIB, key, key_cap, gb).await; assert_eq!(out, BlobConfirm::Stored, "{key} fills its own 1 GB exactly"); } assert_eq!(app_bytes(&db.pool, app).await, 2 * GIB); // gamma has stored nothing, so the per-key check (0 + 1 <= 1 GiB) passes and // only the aggregate ceiling can refuse this. let refused = confirm_per_key(&db.pool, app, user, "a-3", 1, "gamma", key_cap, gb).await; assert_eq!( refused, BlobConfirm::QuotaExceeded { dimension: "storage", used: 2 * GIB, limit: 2 * GIB, key: None, }, "the aggregate ceiling refuses an empty key, reporting the app dimension" ); assert_eq!( key_bytes(&db.pool, app, "gamma").await, None, "and left no counter row behind for it" ); assert_eq!(blob_rows(&db.pool, app, user).await, 2); } // ── delete_sync_blob ──────────────────────────────────────────────────────── /// Delete refunds both counters by exactly the deleted blob's size, dead-letters /// its object, and a repeat delete refunds nothing further. The two blobs carry /// different sizes so a refund of the wrong one is visible. #[tokio::test] async fn deleting_a_blob_refunds_its_own_size_once_and_dead_letters_its_object() { let db = TestDb::new().await; let dev = seed_user(&db.pool, "skblobs_del_dev").await; let user = seed_user(&db.pool, "skblobs_del_user").await; let app = seed_app(&db.pool, dev, "blobdelete").await; confirm_bulk(&db.pool, app, user, "d-1", 7000, "alpha", 10).await; confirm_bulk(&db.pool, app, user, "d-2", 3300, "alpha", 10).await; assert_eq!(app_bytes(&db.pool, app).await, 10_300); let deleted = synckit::delete_sync_blob(&db.pool, app, user, "d-1") .await .expect("delete"); assert_eq!( deleted, BlobDelete::Deleted { size_bytes: 7000 }, "the delete reports the size it freed" ); assert_eq!( app_bytes(&db.pool, app).await, 3300, "the app counter is reduced by 7000, not zeroed and not reduced by the other blob" ); assert_eq!( key_bytes(&db.pool, app, "alpha").await, Some(3300), "the key counter tracks it" ); let queued = pending_deletions(&db.pool).await; assert_eq!( queued, vec![( "s3/d-1".to_string(), "synckit".to_string(), "synckit_blob_delete".to_string() )], "the object is dead-lettered into the synckit bucket by the same transaction" ); // Deleting again must not refund a second time. let again = synckit::delete_sync_blob(&db.pool, app, user, "d-1") .await .expect("second delete"); assert_eq!( again, BlobDelete::NotFound, "a repeat delete finds nothing to free" ); assert_eq!( app_bytes(&db.pool, app).await, 3300, "and refunds nothing a second time" ); assert_eq!(key_bytes(&db.pool, app, "alpha").await, Some(3300)); assert_eq!( pending_deletions(&db.pool).await.len(), 1, "and enqueues no second deletion" ); } /// For an internal app the blob rows are the accounting, so a delete is what /// frees the quota. The app counter must stay at zero rather than going /// negative on the refund UPDATE that matches an untouched row. #[tokio::test] async fn deleting_an_internal_blob_frees_the_cap_it_was_holding() { let db = TestDb::new().await; let user = seed_user(&db.pool, "skblobs_intdel").await; let app = seed_app(&db.pool, user, "blobintdel").await; make_internal(&db.pool, app).await; seed_subscription(&db.pool, user, app, "sub_intdel", Some(5000)).await; synckit::confirm_internal_blob(&db.pool, app, user, "i-1", 3000, "s3/i-1", "default") .await .expect("store the first blob"); // 3000 + 2500 = 5500 over a 5000 cap. let blocked = synckit::confirm_internal_blob(&db.pool, app, user, "i-2", 2500, "s3/i-2", "default") .await .expect("confirm over cap"); assert_eq!( blocked, BlobConfirm::QuotaExceeded { dimension: "storage", used: 3000, limit: 5000, key: None, }, "the second blob does not fit alongside the first" ); let freed = synckit::delete_sync_blob(&db.pool, app, user, "i-1") .await .expect("delete"); assert_eq!(freed, BlobDelete::Deleted { size_bytes: 3000 }); assert_eq!( synckit::storage_used_bytes(&db.pool, app, user) .await .expect("usage"), 0, "usage is summed from the rows, so removing the row is the whole refund" ); assert_eq!( app_bytes(&db.pool, app).await, 0, "an internal app keeps no counter, and the refund floors at zero rather than going negative" ); let retried = synckit::confirm_internal_blob(&db.pool, app, user, "i-2", 2500, "s3/i-2", "default") .await .expect("retry after the delete"); assert_eq!( retried, BlobConfirm::Stored, "the freed cap is immediately usable, without waiting on the drift job" ); } // ── count_sync_devices ────────────────────────────────────────────────────── /// Counts one `(app, user)` pair. Three devices for one user against two for /// another, in two apps, so a query missing either scope gives a different /// number than the right one. #[tokio::test] async fn count_sync_devices_counts_one_user_in_one_app() { let db = TestDb::new().await; let alice = seed_user(&db.pool, "skblobs_dev_alice").await; let bob = seed_user(&db.pool, "skblobs_dev_bob").await; let app_one = seed_app(&db.pool, alice, "devcountone").await; let app_two = seed_app(&db.pool, alice, "devcounttwo").await; for name in ["laptop", "desktop", "phone"] { synckit::upsert_sync_device( &db.pool, app_one, alice, name, makenotwork::db::SyncPlatform::Macos, None, ) .await .expect("seed alice device"); } for name in ["tablet", "watch"] { synckit::upsert_sync_device( &db.pool, app_one, bob, name, makenotwork::db::SyncPlatform::Macos, None, ) .await .expect("seed bob device"); } synckit::upsert_sync_device( &db.pool, app_two, alice, "laptop", makenotwork::db::SyncPlatform::Macos, None, ) .await .expect("seed alice device in the second app"); assert_eq!( synckit::count_sync_devices(&db.pool, app_one, alice) .await .expect("count"), 3, "alice's three devices in the first app, not bob's and not her other app's" ); assert_eq!( synckit::count_sync_devices(&db.pool, app_one, bob) .await .expect("count"), 2 ); assert_eq!( synckit::count_sync_devices(&db.pool, app_two, alice) .await .expect("count"), 1 ); assert_eq!( synckit::count_sync_devices(&db.pool, app_two, bob) .await .expect("count"), 0, "a user with no device in an app counts zero" ); }