max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+962 insertions,
-0 deletions
| @@ -40,9 +40,11 @@ | |||
| 40 | 40 | mod db_scan_jobs_layer; | |
| 41 | 41 | mod db_scanning_layer; | |
| 42 | 42 | mod db_ssh_keys_layer; | |
| 43 | + | mod db_synckit_accounts_layer; | |
| 43 | 44 | mod db_synckit_billing_layer; | |
| 44 | 45 | mod db_synckit_groups; | |
| 45 | 46 | mod db_synckit_invitations; | |
| 47 | + | mod db_synckit_layer; | |
| 46 | 48 | mod db_synckit_rotation; | |
| 47 | 49 | mod db_transactions_layer; | |
| 48 | 50 | mod db_users_layer; |
| @@ -1,0 +1,598 @@ | |||
| 1 | + | //! DB-layer contract tests for SyncKit devices, apps, end-user subscriptions | |
| 2 | + | //! and the security audit log (`db::synckit::{devices, apps, subscriptions, | |
| 3 | + | //! security}`), which had none of their own. `db_synckit_layer` covers the | |
| 4 | + | //! change log, blobs and keys; see its header for what is deliberately left to | |
| 5 | + | //! the HTTP workflows. | |
| 6 | + | //! | |
| 7 | + | //! `apps` is covered here beyond the API-key hashing its own `#[cfg(test)]` | |
| 8 | + | //! module already pins, and sync-token revocation leaving the website session | |
| 9 | + | //! alone stays in `synckit_security`, which asserts it end to end. | |
| 10 | + | ||
| 11 | + | use crate::harness::db::TestDb; | |
| 12 | + | use crate::harness::seed_user; | |
| 13 | + | ||
| 14 | + | use makenotwork::db::synckit; | |
| 15 | + | use makenotwork::db::synckit::NewAppSyncSubscription; | |
| 16 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, SyncPlatform, UserId}; | |
| 17 | + | ||
| 18 | + | /// Seed a sync app owned by `user`, with its usage row. | |
| 19 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 20 | + | synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None) | |
| 21 | + | .await | |
| 22 | + | .expect("seed sync app") | |
| 23 | + | .id | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /// Mark an app first-party, so the end-user subscription model applies. | |
| 27 | + | async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) { | |
| 28 | + | sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1") | |
| 29 | + | .bind(app) | |
| 30 | + | .execute(pool) | |
| 31 | + | .await | |
| 32 | + | .expect("mark internal"); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | async fn seed_device( | |
| 36 | + | pool: &sqlx::PgPool, | |
| 37 | + | app: SyncAppId, | |
| 38 | + | user: UserId, | |
| 39 | + | name: &str, | |
| 40 | + | ) -> SyncDeviceId { | |
| 41 | + | synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Macos, None) | |
| 42 | + | .await | |
| 43 | + | .expect("seed device") | |
| 44 | + | .id | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Give `user` an active subscription on `app` with a `limit_bytes` cap. | |
| 48 | + | pub(crate) async fn seed_active_subscription( | |
| 49 | + | pool: &sqlx::PgPool, | |
| 50 | + | user: UserId, | |
| 51 | + | app: SyncAppId, | |
| 52 | + | sub_id: &str, | |
| 53 | + | limit_bytes: i64, | |
| 54 | + | ) { | |
| 55 | + | let created = synckit::create_app_sync_subscription( | |
| 56 | + | pool, | |
| 57 | + | &NewAppSyncSubscription { | |
| 58 | + | user_id: user, | |
| 59 | + | app_id: app, | |
| 60 | + | stripe_subscription_id: sub_id, | |
| 61 | + | stripe_customer_id: "cus_test", | |
| 62 | + | interval: "monthly", | |
| 63 | + | storage_limit_bytes: limit_bytes, | |
| 64 | + | }, | |
| 65 | + | ) | |
| 66 | + | .await | |
| 67 | + | .expect("seed subscription"); | |
| 68 | + | assert!(created, "the first insert is a write"); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | // ── devices ───────────────────────────────────────────────────────────────── | |
| 72 | + | ||
| 73 | + | #[tokio::test] | |
| 74 | + | async fn re_registering_a_device_updates_the_row_it_already_has() { | |
| 75 | + | let db = TestDb::new().await; | |
| 76 | + | let user = seed_user(&db.pool, "skdev_upsert").await; | |
| 77 | + | let app = seed_app(&db.pool, user, "devupsert").await; | |
| 78 | + | ||
| 79 | + | let first = synckit::upsert_sync_device( | |
| 80 | + | &db.pool, | |
| 81 | + | app, | |
| 82 | + | user, | |
| 83 | + | "laptop", | |
| 84 | + | SyncPlatform::Macos, | |
| 85 | + | Some("1.2.0"), | |
| 86 | + | ) | |
| 87 | + | .await | |
| 88 | + | .unwrap(); | |
| 89 | + | let again = synckit::upsert_sync_device( | |
| 90 | + | &db.pool, | |
| 91 | + | app, | |
| 92 | + | user, | |
| 93 | + | "laptop", | |
| 94 | + | SyncPlatform::Linux, | |
| 95 | + | Some("1.3.0"), | |
| 96 | + | ) | |
| 97 | + | .await | |
| 98 | + | .unwrap(); | |
| 99 | + | assert_eq!(first.id, again.id, "the same device is not a second device"); | |
| 100 | + | assert_eq!(again.platform, SyncPlatform::Linux); | |
| 101 | + | assert_eq!(again.client_version.as_deref(), Some("1.3.0")); | |
| 102 | + | ||
| 103 | + | // A client that stops sending a User-Agent has not downgraded to unknown. | |
| 104 | + | let quiet = | |
| 105 | + | synckit::upsert_sync_device(&db.pool, app, user, "laptop", SyncPlatform::Linux, None) | |
| 106 | + | .await | |
| 107 | + | .unwrap(); | |
| 108 | + | assert_eq!( | |
| 109 | + | quiet.client_version.as_deref(), | |
| 110 | + | Some("1.3.0"), | |
| 111 | + | "None leaves the recorded version alone rather than blanking it" | |
| 112 | + | ); | |
| 113 | + | assert_eq!( | |
| 114 | + | synckit::count_sync_devices(&db.pool, app, user) | |
| 115 | + | .await | |
| 116 | + | .unwrap(), | |
| 117 | + | 1 | |
| 118 | + | ); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | #[tokio::test] | |
| 122 | + | async fn a_device_answers_only_to_its_own_owner() { | |
| 123 | + | let db = TestDb::new().await; | |
| 124 | + | let alice = seed_user(&db.pool, "skdev_alice").await; | |
| 125 | + | let bob = seed_user(&db.pool, "skdev_bob").await; | |
| 126 | + | let app = seed_app(&db.pool, alice, "devscope").await; | |
| 127 | + | let other_app = seed_app(&db.pool, alice, "devscope2").await; | |
| 128 | + | let device = seed_device(&db.pool, app, alice, "alice-laptop").await; | |
| 129 | + | ||
| 130 | + | assert!( | |
| 131 | + | synckit::sync_device_belongs(&db.pool, device, app, alice) | |
| 132 | + | .await | |
| 133 | + | .unwrap() | |
| 134 | + | ); | |
| 135 | + | assert!( | |
| 136 | + | !synckit::sync_device_belongs(&db.pool, device, app, bob) | |
| 137 | + | .await | |
| 138 | + | .unwrap(), | |
| 139 | + | "a device id from another user's token must not verify" | |
| 140 | + | ); | |
| 141 | + | assert!( | |
| 142 | + | !synckit::sync_device_belongs(&db.pool, device, other_app, alice) | |
| 143 | + | .await | |
| 144 | + | .unwrap(), | |
| 145 | + | "nor one from the same user in another app" | |
| 146 | + | ); | |
| 147 | + | ||
| 148 | + | assert!( | |
| 149 | + | !synckit::delete_sync_device(&db.pool, device, app, bob) | |
| 150 | + | .await | |
| 151 | + | .unwrap(), | |
| 152 | + | "and a non-owner's delete removes nothing" | |
| 153 | + | ); | |
| 154 | + | assert!( | |
| 155 | + | synckit::sync_device_belongs(&db.pool, device, app, alice) | |
| 156 | + | .await | |
| 157 | + | .unwrap() | |
| 158 | + | ); | |
| 159 | + | assert!( | |
| 160 | + | synckit::delete_sync_device(&db.pool, device, app, alice) | |
| 161 | + | .await | |
| 162 | + | .unwrap() | |
| 163 | + | ); | |
| 164 | + | assert!( | |
| 165 | + | !synckit::delete_sync_device(&db.pool, device, app, alice) | |
| 166 | + | .await | |
| 167 | + | .unwrap(), | |
| 168 | + | "deleting it twice reports no second removal" | |
| 169 | + | ); | |
| 170 | + | ||
| 171 | + | let alice_devices = synckit::get_sync_devices(&db.pool, app, bob).await.unwrap(); | |
| 172 | + | assert!(alice_devices.is_empty()); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[tokio::test] | |
| 176 | + | async fn a_pull_cursor_only_ever_moves_forward() { | |
| 177 | + | let db = TestDb::new().await; | |
| 178 | + | let user = seed_user(&db.pool, "skdev_cursor").await; | |
| 179 | + | let app = seed_app(&db.pool, user, "devcursor").await; | |
| 180 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 181 | + | ||
| 182 | + | synckit::touch_and_advance_cursor(&db.pool, device, 40, Some("1.4.0")) | |
| 183 | + | .await | |
| 184 | + | .unwrap(); | |
| 185 | + | // An out-of-order pull response, or a client replaying an old one. | |
| 186 | + | synckit::touch_and_advance_cursor(&db.pool, device, 7, None) | |
| 187 | + | .await | |
| 188 | + | .unwrap(); | |
| 189 | + | ||
| 190 | + | let row: (i64, Option<String>) = | |
| 191 | + | sqlx::query_as("SELECT last_pulled_seq, client_version FROM sync_devices WHERE id = $1") | |
| 192 | + | .bind(device) | |
| 193 | + | .fetch_one(&db.pool) | |
| 194 | + | .await | |
| 195 | + | .unwrap(); | |
| 196 | + | assert_eq!( | |
| 197 | + | row.0, 40, | |
| 198 | + | "a lower cursor must not rewind the device, or compaction would \ | |
| 199 | + | delete entries it has already seen" | |
| 200 | + | ); | |
| 201 | + | assert_eq!( | |
| 202 | + | row.1.as_deref(), | |
| 203 | + | Some("1.4.0"), | |
| 204 | + | "and a version-less pull leaves the recorded version alone" | |
| 205 | + | ); | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // ── apps ──────────────────────────────────────────────────────────────────── | |
| 209 | + | ||
| 210 | + | #[tokio::test] | |
| 211 | + | async fn regenerating_an_api_key_retires_the_old_one() { | |
| 212 | + | let db = TestDb::new().await; | |
| 213 | + | let user = seed_user(&db.pool, "skapp_regen").await; | |
| 214 | + | let app = synckit::create_sync_app(&db.pool, user, "regen", "sk_old_key_value", None, None) | |
| 215 | + | .await | |
| 216 | + | .unwrap(); | |
| 217 | + | ||
| 218 | + | assert_eq!( | |
| 219 | + | synckit::get_sync_app_by_api_key(&db.pool, "sk_old_key_value") | |
| 220 | + | .await | |
| 221 | + | .unwrap() | |
| 222 | + | .map(|a| a.id), | |
| 223 | + | Some(app.id) | |
| 224 | + | ); | |
| 225 | + | ||
| 226 | + | let updated = synckit::regenerate_sync_app_key(&db.pool, app.id, "sk_new_key_value") | |
| 227 | + | .await | |
| 228 | + | .unwrap(); | |
| 229 | + | assert_eq!( | |
| 230 | + | updated.api_key_prefix, "sk_new_k", | |
| 231 | + | "the prefix is the first 8" | |
| 232 | + | ); | |
| 233 | + | assert!( | |
| 234 | + | synckit::get_sync_app_by_api_key(&db.pool, "sk_old_key_value") | |
| 235 | + | .await | |
| 236 | + | .unwrap() | |
| 237 | + | .is_none(), | |
| 238 | + | "rotation is immediate: the old key stops working when this returns" | |
| 239 | + | ); | |
| 240 | + | assert_eq!( | |
| 241 | + | synckit::get_sync_app_by_api_key(&db.pool, "sk_new_key_value") | |
| 242 | + | .await | |
| 243 | + | .unwrap() | |
| 244 | + | .map(|a| a.id), | |
| 245 | + | Some(app.id) | |
| 246 | + | ); | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | #[tokio::test] | |
| 250 | + | async fn the_keys_secret_is_a_separate_credential_from_the_api_key() { | |
| 251 | + | let db = TestDb::new().await; | |
| 252 | + | let user = seed_user(&db.pool, "skapp_secret").await; | |
| 253 | + | let app = synckit::create_sync_app(&db.pool, user, "secret", "sk_app_api_key", None, None) | |
| 254 | + | .await | |
| 255 | + | .unwrap(); | |
| 256 | + | ||
| 257 | + | assert!( | |
| 258 | + | synckit::get_sync_app_by_keys_secret(&db.pool, "sk_app_api_key") | |
| 259 | + | .await | |
| 260 | + | .unwrap() | |
| 261 | + | .is_none(), | |
| 262 | + | "the api key ships in every client binary, so it may not open the \ | |
| 263 | + | server-to-server routes" | |
| 264 | + | ); | |
| 265 | + | ||
| 266 | + | synckit::set_sync_app_keys_secret(&db.pool, app.id, "ks_first_secret") | |
| 267 | + | .await | |
| 268 | + | .unwrap(); | |
| 269 | + | assert_eq!( | |
| 270 | + | synckit::get_sync_app_by_keys_secret(&db.pool, "ks_first_secret") | |
| 271 | + | .await | |
| 272 | + | .unwrap() | |
| 273 | + | .map(|a| a.id), | |
| 274 | + | Some(app.id) | |
| 275 | + | ); | |
| 276 | + | ||
| 277 | + | synckit::set_sync_app_keys_secret(&db.pool, app.id, "ks_second_secret") | |
| 278 | + | .await | |
| 279 | + | .unwrap(); | |
| 280 | + | assert!( | |
| 281 | + | synckit::get_sync_app_by_keys_secret(&db.pool, "ks_first_secret") | |
| 282 | + | .await | |
| 283 | + | .unwrap() | |
| 284 | + | .is_none(), | |
| 285 | + | "rotation is immediate and unversioned" | |
| 286 | + | ); | |
| 287 | + | ||
| 288 | + | sqlx::query("UPDATE sync_apps SET is_active = false WHERE id = $1") | |
| 289 | + | .bind(app.id) | |
| 290 | + | .execute(&db.pool) | |
| 291 | + | .await | |
| 292 | + | .unwrap(); | |
| 293 | + | assert!( | |
| 294 | + | synckit::get_sync_app_by_keys_secret(&db.pool, "ks_second_secret") | |
| 295 | + | .await | |
| 296 | + | .unwrap() | |
| 297 | + | .is_none(), | |
| 298 | + | "a deactivated app authenticates nothing" | |
| 299 | + | ); | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | // ── subscriptions ─────────────────────────────────────────────────────────── | |
| 303 | + | ||
| 304 | + | #[tokio::test] | |
| 305 | + | async fn only_a_first_party_app_gates_writes_on_a_subscription() { | |
| 306 | + | let db = TestDb::new().await; | |
| 307 | + | let user = seed_user(&db.pool, "sksub_gate").await; | |
| 308 | + | let developer_app = seed_app(&db.pool, user, "gatedev").await; | |
| 309 | + | let internal_app = seed_app(&db.pool, user, "gateint").await; | |
| 310 | + | make_internal(&db.pool, internal_app).await; | |
| 311 | + | ||
| 312 | + | assert!( | |
| 313 | + | synckit::internal_write_allowed(&db.pool, developer_app, user) | |
| 314 | + | .await | |
| 315 | + | .unwrap(), | |
| 316 | + | "a developer-billed app's users never hold a subscription of their own" | |
| 317 | + | ); | |
| 318 | + | assert!( | |
| 319 | + | !synckit::internal_write_allowed(&db.pool, internal_app, user) | |
| 320 | + | .await | |
| 321 | + | .unwrap(), | |
| 322 | + | "first-party sync is paid-only" | |
| 323 | + | ); | |
| 324 | + | ||
| 325 | + | seed_active_subscription(&db.pool, user, internal_app, "sub_gate", 1_000).await; | |
| 326 | + | assert!( | |
| 327 | + | synckit::internal_write_allowed(&db.pool, internal_app, user) | |
| 328 | + | .await | |
| 329 | + | .unwrap() | |
| 330 | + | ); | |
| 331 | + | ||
| 332 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_gate", "canceled", None) | |
| 333 | + | .await | |
| 334 | + | .unwrap(); | |
| 335 | + | assert!( | |
| 336 | + | !synckit::internal_write_allowed(&db.pool, internal_app, user) | |
| 337 | + | .await | |
| 338 | + | .unwrap(), | |
| 339 | + | "a canceled subscription closes writes again" | |
| 340 | + | ); | |
| 341 | + | ||
| 342 | + | assert!( | |
| 343 | + | !synckit::internal_write_allowed(&db.pool, SyncAppId::new(), user) | |
| 344 | + | .await | |
| 345 | + | .unwrap(), | |
| 346 | + | "an app that does not exist denies rather than defaults open" | |
| 347 | + | ); | |
| 348 | + | } | |
| 349 | + | ||
| 350 | + | #[tokio::test] | |
| 351 | + | async fn a_canceled_subscription_is_not_revived_by_a_late_webhook() { | |
| 352 | + | let db = TestDb::new().await; | |
| 353 | + | let user = seed_user(&db.pool, "sksub_terminal").await; | |
| 354 | + | let app = seed_app(&db.pool, user, "subterminal").await; | |
| 355 | + | seed_active_subscription(&db.pool, user, app, "sub_terminal", 1_000).await; | |
| 356 | + | ||
| 357 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_terminal", "canceled", None) | |
| 358 | + | .await | |
| 359 | + | .unwrap(); | |
| 360 | + | // An `invoice.paid` that took the long way round arrives after the delete. | |
| 361 | + | synckit::update_app_sync_subscription_status( | |
| 362 | + | &db.pool, | |
| 363 | + | "sub_terminal", | |
| 364 | + | "active", | |
| 365 | + | Some(1_800_000_000), | |
| 366 | + | ) | |
| 367 | + | .await | |
| 368 | + | .unwrap(); | |
| 369 | + | ||
| 370 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 371 | + | .await | |
| 372 | + | .unwrap() | |
| 373 | + | .expect("row still there"); | |
| 374 | + | assert_eq!(sub.status, "canceled", "canceled is terminal here"); | |
| 375 | + | assert!( | |
| 376 | + | sub.current_period_end.is_none(), | |
| 377 | + | "and the refused update stamped no period either" | |
| 378 | + | ); | |
| 379 | + | } | |
| 380 | + | ||
| 381 | + | #[tokio::test] | |
| 382 | + | async fn a_thin_webhook_period_is_dropped_rather_than_stamped() { | |
| 383 | + | let db = TestDb::new().await; | |
| 384 | + | let user = seed_user(&db.pool, "sksub_epoch").await; | |
| 385 | + | let app = seed_app(&db.pool, user, "subepoch").await; | |
| 386 | + | seed_active_subscription(&db.pool, user, app, "sub_epoch", 1_000).await; | |
| 387 | + | ||
| 388 | + | synckit::update_app_sync_subscription_status( | |
| 389 | + | &db.pool, | |
| 390 | + | "sub_epoch", | |
| 391 | + | "active", | |
| 392 | + | Some(1_800_000_000), | |
| 393 | + | ) | |
| 394 | + | .await | |
| 395 | + | .unwrap(); | |
| 396 | + | let good = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 397 | + | .await | |
| 398 | + | .unwrap() | |
| 399 | + | .unwrap() | |
| 400 | + | .current_period_end | |
| 401 | + | .expect("a real period lands"); | |
| 402 | + | ||
| 403 | + | for thin in [None, Some(0), Some(-5)] { | |
| 404 | + | synckit::update_app_sync_subscription_status(&db.pool, "sub_epoch", "active", thin) | |
| 405 | + | .await | |
| 406 | + | .unwrap(); | |
| 407 | + | assert_eq!( | |
| 408 | + | synckit::get_user_app_subscription(&db.pool, user, app) | |
| 409 | + | .await | |
| 410 | + | .unwrap() | |
| 411 | + | .unwrap() | |
| 412 | + | .current_period_end, | |
| 413 | + | Some(good), | |
| 414 | + | "a zero or missing period keeps the live one, never a 1970 stamp" | |
| 415 | + | ); | |
| 416 | + | } | |
| 417 | + | } | |
| 418 | + | ||
| 419 | + | #[tokio::test] | |
| 420 | + | async fn a_queued_cap_change_lands_only_at_the_period_roll() { | |
| 421 | + | let db = TestDb::new().await; | |
| 422 | + | let user = seed_user(&db.pool, "sksub_cap").await; | |
| 423 | + | let app = seed_app(&db.pool, user, "subcap").await; | |
| 424 | + | seed_active_subscription(&db.pool, user, app, "sub_cap", 1_000).await; | |
| 425 | + | ||
| 426 | + | synckit::set_pending_storage_cap(&db.pool, user, app, 500) | |
| 427 | + | .await | |
| 428 | + | .unwrap(); | |
| 429 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 430 | + | .await | |
| 431 | + | .unwrap() | |
| 432 | + | .unwrap(); | |
| 433 | + | assert_eq!( | |
| 434 | + | sub.storage_limit_bytes, | |
| 435 | + | Some(1_000), | |
| 436 | + | "the user paid for this period and keeps it" | |
| 437 | + | ); | |
| 438 | + | assert_eq!(sub.pending_storage_limit_bytes, Some(500)); | |
| 439 | + | ||
| 440 | + | synckit::apply_pending_storage_cap(&db.pool, "sub_cap") | |
| 441 | + | .await | |
| 442 | + | .unwrap(); | |
| 443 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 444 | + | .await | |
| 445 | + | .unwrap() | |
| 446 | + | .unwrap(); | |
| 447 | + | assert_eq!(sub.storage_limit_bytes, Some(500), "the roll promotes it"); | |
| 448 | + | assert_eq!(sub.pending_storage_limit_bytes, None); | |
| 449 | + | ||
| 450 | + | // A second renewal must not re-apply anything. | |
| 451 | + | synckit::apply_pending_storage_cap(&db.pool, "sub_cap") | |
| 452 | + | .await | |
| 453 | + | .unwrap(); | |
| 454 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 455 | + | .await | |
| 456 | + | .unwrap() | |
| 457 | + | .unwrap(); | |
| 458 | + | assert_eq!(sub.storage_limit_bytes, Some(500)); | |
| 459 | + | assert_eq!(sub.pending_storage_limit_bytes, None); | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | #[tokio::test] | |
| 463 | + | async fn raising_the_cap_now_clears_the_queued_change() { | |
| 464 | + | let db = TestDb::new().await; | |
| 465 | + | let user = seed_user(&db.pool, "sksub_now").await; | |
| 466 | + | let app = seed_app(&db.pool, user, "subnow").await; | |
| 467 | + | seed_active_subscription(&db.pool, user, app, "sub_now", 1_000).await; | |
| 468 | + | synckit::set_pending_storage_cap(&db.pool, user, app, 500) | |
| 469 | + | .await | |
| 470 | + | .unwrap(); | |
| 471 | + | ||
| 472 | + | // The user is blocked by a full cap and buys more; the price is re-quoted | |
| 473 | + | // against Stripe with prorations, so the storage arrives with the charge. | |
| 474 | + | synckit::set_storage_cap_now(&db.pool, user, app, 4_000) | |
| 475 | + | .await | |
| 476 | + | .unwrap(); | |
| 477 | + | ||
| 478 | + | let sub = synckit::get_user_app_subscription(&db.pool, user, app) | |
| 479 | + | .await | |
| 480 | + | .unwrap() | |
| 481 | + | .unwrap(); | |
| 482 | + | assert_eq!(sub.storage_limit_bytes, Some(4_000)); | |
| 483 | + | assert_eq!( | |
| 484 | + | sub.pending_storage_limit_bytes, None, | |
| 485 | + | "the queued decrease is gone, not waiting to undo the purchase" | |
| 486 | + | ); | |
| 487 | + | assert_eq!( | |
| 488 | + | synckit::get_subscription_by_stripe_id(&db.pool, "sub_now") | |
| 489 | + | .await | |
| 490 | + | .unwrap(), | |
| 491 | + | Some((user, app)) | |
| 492 | + | ); | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | #[tokio::test] | |
| 496 | + | async fn a_subscription_is_read_back_per_user_and_per_app() { | |
| 497 | + | let db = TestDb::new().await; | |
| 498 | + | let alice = seed_user(&db.pool, "sksub_alice").await; | |
| 499 | + | let bob = seed_user(&db.pool, "sksub_bob").await; | |
| 500 | + | let app = seed_app(&db.pool, alice, "subscope").await; |
Lines truncated
| @@ -1,0 +1,460 @@ | |||
| 1 | + | //! DB-layer contract tests for the SyncKit change log, blobs and keys | |
| 2 | + | //! (`db::synckit::{log, blobs, keys}`), which had none of their own. | |
| 3 | + | //! | |
| 4 | + | //! Its siblings (`db_synckit_rotation`, `db_synckit_groups`, | |
| 5 | + | //! `db_synckit_invitations`) already pin the rotation state machine, the group | |
| 6 | + | //! changelog and the invitation lifecycle at this layer; | |
| 7 | + | //! `db_synckit_accounts_layer` covers devices, apps, subscriptions and the | |
| 8 | + | //! audit log. What was reachable only through the HTTP workflows is the owner | |
| 9 | + | //! scoping every one of these queries carries, the `app_id = $1 AND | |
| 10 | + | //! user_id = $2` pair: a route test authenticates as one user, so it cannot | |
| 11 | + | //! see a query that returns another user's rows. | |
| 12 | + | //! | |
| 13 | + | //! Deliberately not re-asserted here, because it is covered elsewhere: the | |
| 14 | + | //! table and `since` pull filters (`synckit_selective`), the storage quota and | |
| 15 | + | //! blob-delete paths through a real subscription (`synckit_paid_sync`, | |
| 16 | + | //! `synckit_per_key_storage`), and compaction against a freshly registered | |
| 17 | + | //! device (`synckit_paid_sync`). | |
| 18 | + | ||
| 19 | + | use super::db_synckit_accounts_layer::seed_active_subscription; | |
| 20 | + | use crate::harness::db::TestDb; | |
| 21 | + | use crate::harness::seed_user; | |
| 22 | + | ||
| 23 | + | use makenotwork::db::synckit; | |
| 24 | + | use makenotwork::db::synckit::BlobConfirm; | |
| 25 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, SyncPlatform, UserId}; | |
| 26 | + | use uuid::Uuid; | |
| 27 | + | ||
| 28 | + | /// Seed a sync app owned by `user`, with its usage row. | |
| 29 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 30 | + | synckit::create_sync_app(pool, user, name, &format!("key_{name}_padding"), None, None) | |
| 31 | + | .await | |
| 32 | + | .expect("seed sync app") | |
| 33 | + | .id | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Mark an app first-party, so the end-user subscription model applies. | |
| 37 | + | async fn make_internal(pool: &sqlx::PgPool, app: SyncAppId) { | |
| 38 | + | sqlx::query("UPDATE sync_apps SET is_internal = true WHERE id = $1") | |
| 39 | + | .bind(app) | |
| 40 | + | .execute(pool) | |
| 41 | + | .await | |
| 42 | + | .expect("mark internal"); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | async fn seed_device( | |
| 46 | + | pool: &sqlx::PgPool, | |
| 47 | + | app: SyncAppId, | |
| 48 | + | user: UserId, | |
| 49 | + | name: &str, | |
| 50 | + | ) -> SyncDeviceId { | |
| 51 | + | synckit::upsert_sync_device(pool, app, user, name, SyncPlatform::Macos, None) | |
| 52 | + | .await | |
| 53 | + | .expect("seed device") | |
| 54 | + | .id | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | /// One INSERT change tuple in the shape `push_sync_changes` expects. | |
| 58 | + | fn change( | |
| 59 | + | table: &str, | |
| 60 | + | row: &str, | |
| 61 | + | ) -> ( | |
| 62 | + | String, | |
| 63 | + | String, | |
| 64 | + | String, | |
| 65 | + | chrono::DateTime<chrono::Utc>, | |
| 66 | + | Option<serde_json::Value>, | |
| 67 | + | ) { | |
| 68 | + | ( | |
| 69 | + | table.to_string(), | |
| 70 | + | "INSERT".to_string(), | |
| 71 | + | row.to_string(), | |
| 72 | + | chrono::Utc::now(), | |
| 73 | + | Some(serde_json::json!({ "row": row })), | |
| 74 | + | ) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // ── log ───────────────────────────────────────────────────────────────────── | |
| 78 | + | ||
| 79 | + | #[tokio::test] | |
| 80 | + | async fn an_append_keeps_every_entry_and_the_order_it_arrived_in() { | |
| 81 | + | let db = TestDb::new().await; | |
| 82 | + | let user = seed_user(&db.pool, "sklog_append").await; | |
| 83 | + | let app = seed_app(&db.pool, user, "logappend").await; | |
| 84 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 85 | + | ||
| 86 | + | let changes: Vec<_> = (0..5).map(|i| change("tasks", &format!("r{i}"))).collect(); | |
| 87 | + | let cursor = synckit::push_sync_changes(&db.pool, app, user, device, Uuid::new_v4(), &changes) | |
| 88 | + | .await | |
| 89 | + | .unwrap(); | |
| 90 | + | ||
| 91 | + | let entries = synckit::pull_sync_changes(&db.pool, app, user, 0, 100) | |
| 92 | + | .await | |
| 93 | + | .unwrap(); | |
| 94 | + | assert_eq!(entries.len(), 5, "an append drops nothing"); | |
| 95 | + | let rows: Vec<&str> = entries.iter().map(|e| e.row_id.as_str()).collect(); | |
| 96 | + | assert_eq!(rows, ["r0", "r1", "r2", "r3", "r4"], "and reorders nothing"); | |
| 97 | + | assert!( | |
| 98 | + | entries.windows(2).all(|w| w[0].seq < w[1].seq), | |
| 99 | + | "seq is strictly increasing: {:?}", | |
| 100 | + | entries.iter().map(|e| e.seq).collect::<Vec<_>>() | |
| 101 | + | ); | |
| 102 | + | assert_eq!( | |
| 103 | + | cursor, | |
| 104 | + | entries.last().unwrap().seq, | |
| 105 | + | "the returned cursor is the highest seq assigned" | |
| 106 | + | ); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | #[tokio::test] | |
| 110 | + | async fn cursor_paging_returns_each_entry_exactly_once() { | |
| 111 | + | let db = TestDb::new().await; | |
| 112 | + | let user = seed_user(&db.pool, "sklog_page").await; | |
| 113 | + | let app = seed_app(&db.pool, user, "logpage").await; | |
| 114 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 115 | + | ||
| 116 | + | for i in 0..5 { | |
| 117 | + | synckit::push_sync_changes( | |
| 118 | + | &db.pool, | |
| 119 | + | app, | |
| 120 | + | user, | |
| 121 | + | device, | |
| 122 | + | Uuid::new_v4(), | |
| 123 | + | &[change("tasks", &format!("r{i}"))], | |
| 124 | + | ) | |
| 125 | + | .await | |
| 126 | + | .unwrap(); | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | // Walk the log two at a time the way a client does, carrying the last seq | |
| 130 | + | // forward as the next cursor. | |
| 131 | + | let mut seen: Vec<String> = Vec::new(); | |
| 132 | + | let mut cursor = 0i64; | |
| 133 | + | loop { | |
| 134 | + | let page = synckit::pull_sync_changes(&db.pool, app, user, cursor, 2) | |
| 135 | + | .await | |
| 136 | + | .unwrap(); | |
| 137 | + | if page.is_empty() { | |
| 138 | + | break; | |
| 139 | + | } | |
| 140 | + | cursor = page.last().unwrap().seq; | |
| 141 | + | seen.extend(page.into_iter().map(|e| e.row_id)); | |
| 142 | + | assert!(seen.len() <= 5, "paging must terminate: {seen:?}"); | |
| 143 | + | } | |
| 144 | + | assert_eq!( | |
| 145 | + | seen, | |
| 146 | + | ["r0", "r1", "r2", "r3", "r4"], | |
| 147 | + | "every entry once, in order, with no gap at a page boundary" | |
| 148 | + | ); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[tokio::test] | |
| 152 | + | async fn a_replayed_batch_appends_nothing_and_returns_the_same_cursor() { | |
| 153 | + | let db = TestDb::new().await; | |
| 154 | + | let user = seed_user(&db.pool, "sklog_replay").await; | |
| 155 | + | let app = seed_app(&db.pool, user, "logreplay").await; | |
| 156 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 157 | + | ||
| 158 | + | let batch = Uuid::new_v4(); | |
| 159 | + | let changes = [change("tasks", "r0"), change("tasks", "r1")]; | |
| 160 | + | let first = synckit::push_sync_changes(&db.pool, app, user, device, batch, &changes) | |
| 161 | + | .await | |
| 162 | + | .unwrap(); | |
| 163 | + | // The client never saw the response and retried the same batch id. | |
| 164 | + | let second = synckit::push_sync_changes(&db.pool, app, user, device, batch, &changes) | |
| 165 | + | .await | |
| 166 | + | .unwrap(); | |
| 167 | + | ||
| 168 | + | assert_eq!(first, second, "at most once: the same cursor comes back"); | |
| 169 | + | let entries = synckit::pull_sync_changes(&db.pool, app, user, 0, 100) | |
| 170 | + | .await | |
| 171 | + | .unwrap(); | |
| 172 | + | assert_eq!(entries.len(), 2, "the retry inserted nothing: {entries:?}"); | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | #[tokio::test] | |
| 176 | + | async fn a_pull_never_reaches_another_user_or_another_app() { | |
| 177 | + | let db = TestDb::new().await; | |
| 178 | + | let alice = seed_user(&db.pool, "sklog_alice").await; | |
| 179 | + | let bob = seed_user(&db.pool, "sklog_bob").await; | |
| 180 | + | let app = seed_app(&db.pool, alice, "logscope").await; | |
| 181 | + | let other_app = seed_app(&db.pool, alice, "logscope2").await; | |
| 182 | + | let alice_dev = seed_device(&db.pool, app, alice, "alice-laptop").await; | |
| 183 | + | let bob_dev = seed_device(&db.pool, app, bob, "bob-laptop").await; | |
| 184 | + | let alice_other_dev = seed_device(&db.pool, other_app, alice, "alice-phone").await; | |
| 185 | + | ||
| 186 | + | synckit::push_sync_changes( | |
| 187 | + | &db.pool, | |
| 188 | + | app, | |
| 189 | + | alice, | |
| 190 | + | alice_dev, | |
| 191 | + | Uuid::new_v4(), | |
| 192 | + | &[change("tasks", "alice")], | |
| 193 | + | ) | |
| 194 | + | .await | |
| 195 | + | .unwrap(); | |
| 196 | + | synckit::push_sync_changes( | |
| 197 | + | &db.pool, | |
| 198 | + | app, | |
| 199 | + | bob, | |
| 200 | + | bob_dev, | |
| 201 | + | Uuid::new_v4(), | |
| 202 | + | &[change("tasks", "bob")], | |
| 203 | + | ) | |
| 204 | + | .await | |
| 205 | + | .unwrap(); | |
| 206 | + | synckit::push_sync_changes( | |
| 207 | + | &db.pool, | |
| 208 | + | other_app, | |
| 209 | + | alice, | |
| 210 | + | alice_other_dev, | |
| 211 | + | Uuid::new_v4(), | |
| 212 | + | &[change("tasks", "alice-other-app")], | |
| 213 | + | ) | |
| 214 | + | .await | |
| 215 | + | .unwrap(); | |
| 216 | + | ||
| 217 | + | let alice_entries = synckit::pull_sync_changes(&db.pool, app, alice, 0, 100) | |
| 218 | + | .await | |
| 219 | + | .unwrap(); | |
| 220 | + | let rows: Vec<&str> = alice_entries.iter().map(|e| e.row_id.as_str()).collect(); | |
| 221 | + | assert_eq!( | |
| 222 | + | rows, | |
| 223 | + | ["alice"], | |
| 224 | + | "one user's log is one user's, per app: {rows:?}" | |
| 225 | + | ); | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // ── blobs ─────────────────────────────────────────────────────────────────── | |
| 229 | + | ||
| 230 | + | #[tokio::test] | |
| 231 | + | async fn a_blob_is_scoped_to_the_user_who_stored_it() { | |
| 232 | + | let db = TestDb::new().await; | |
| 233 | + | let alice = seed_user(&db.pool, "skblob_alice").await; | |
| 234 | + | let bob = seed_user(&db.pool, "skblob_bob").await; | |
| 235 | + | let app = seed_app(&db.pool, alice, "blobscope").await; | |
| 236 | + | make_internal(&db.pool, app).await; | |
| 237 | + | seed_active_subscription(&db.pool, alice, app, "sub_blob_alice", 1_000_000).await; | |
| 238 | + | seed_active_subscription(&db.pool, bob, app, "sub_blob_bob", 1_000_000).await; | |
| 239 | + | ||
| 240 | + | let stored = | |
| 241 | + | synckit::confirm_internal_blob(&db.pool, app, alice, "hash-a", 400, "s3/alice", "default") | |
| 242 | + | .await | |
| 243 | + | .unwrap(); | |
| 244 | + | assert_eq!(stored, BlobConfirm::Stored); | |
| 245 | + | ||
| 246 | + | assert!( | |
| 247 | + | synckit::get_sync_blob_by_hash(&db.pool, app, alice, "hash-a") | |
| 248 | + | .await | |
| 249 | + | .unwrap() | |
| 250 | + | .is_some() | |
| 251 | + | ); | |
| 252 | + | assert!( | |
| 253 | + | synckit::get_sync_blob_by_hash(&db.pool, app, bob, "hash-a") | |
| 254 | + | .await | |
| 255 | + | .unwrap() | |
| 256 | + | .is_none(), | |
| 257 | + | "the hash is the same bytes, but it is not bob's row" | |
| 258 | + | ); | |
| 259 | + | assert_eq!( | |
| 260 | + | synckit::storage_used_bytes(&db.pool, app, bob) | |
| 261 | + | .await | |
| 262 | + | .unwrap(), | |
| 263 | + | 0, | |
| 264 | + | "and it is not charged to bob's quota" | |
| 265 | + | ); | |
| 266 | + | assert_eq!( | |
| 267 | + | synckit::storage_used_bytes(&db.pool, app, alice) | |
| 268 | + | .await | |
| 269 | + | .unwrap(), | |
| 270 | + | 400 | |
| 271 | + | ); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | #[tokio::test] | |
| 275 | + | async fn re_confirming_the_same_hash_stores_one_row_and_charges_once() { | |
| 276 | + | let db = TestDb::new().await; | |
| 277 | + | let user = seed_user(&db.pool, "skblob_dedup").await; | |
| 278 | + | let app = seed_app(&db.pool, user, "blobdedup").await; | |
| 279 | + | make_internal(&db.pool, app).await; | |
| 280 | + | seed_active_subscription(&db.pool, user, app, "sub_blob_dedup", 1_000).await; | |
| 281 | + | ||
| 282 | + | let first = | |
| 283 | + | synckit::confirm_internal_blob(&db.pool, app, user, "hash-x", 400, "s3/x", "default") | |
| 284 | + | .await | |
| 285 | + | .unwrap(); | |
| 286 | + | let second = | |
| 287 | + | synckit::confirm_internal_blob(&db.pool, app, user, "hash-x", 400, "s3/x", "default") | |
| 288 | + | .await | |
| 289 | + | .unwrap(); | |
| 290 | + | assert_eq!(first, BlobConfirm::Stored); | |
| 291 | + | assert_eq!( | |
| 292 | + | second, | |
| 293 | + | BlobConfirm::AlreadyStored, | |
| 294 | + | "content-addressed: the same hash is the same object" | |
| 295 | + | ); | |
| 296 | + | ||
| 297 | + | assert_eq!( | |
| 298 | + | synckit::storage_used_bytes(&db.pool, app, user) | |
| 299 | + | .await | |
| 300 | + | .unwrap(), | |
| 301 | + | 400, | |
| 302 | + | "a re-upload must not count twice, or a retry would eat the cap" | |
| 303 | + | ); | |
| 304 | + | let rows: i64 = sqlx::query_scalar( | |
| 305 | + | "SELECT COUNT(*) FROM sync_blobs WHERE app_id = $1 AND user_id = $2 AND hash = 'hash-x'", | |
| 306 | + | ) | |
| 307 | + | .bind(app) | |
| 308 | + | .bind(user) | |
| 309 | + | .fetch_one(&db.pool) | |
| 310 | + | .await | |
| 311 | + | .unwrap(); | |
| 312 | + | assert_eq!(rows, 1); | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // ── keys ──────────────────────────────────────────────────────────────────── | |
| 316 | + | ||
| 317 | + | #[tokio::test] | |
| 318 | + | async fn a_key_upsert_takes_only_the_version_the_caller_expected() { | |
| 319 | + | let db = TestDb::new().await; | |
| 320 | + | let user = seed_user(&db.pool, "skkey_occ").await; | |
| 321 | + | let app = seed_app(&db.pool, user, "keyocc").await; | |
| 322 | + | ||
| 323 | + | assert!( | |
| 324 | + | synckit::upsert_sync_key(&db.pool, app, user, "env_v1", 0) | |
| 325 | + | .await | |
| 326 | + | .unwrap(), | |
| 327 | + | "the first key inserts" | |
| 328 | + | ); | |
| 329 | + | let info = synckit::get_sync_key(&db.pool, app, user) | |
| 330 | + | .await | |
| 331 | + | .unwrap() | |
| 332 | + | .expect("key exists"); | |
| 333 | + | assert_eq!(info.encrypted_key, "env_v1"); | |
| 334 | + | assert_eq!(info.key_version, 1); | |
| 335 | + | assert!(info.pending_key.is_none()); | |
| 336 | + | ||
| 337 | + | // A second device that still believes it is at version 0 loses. | |
| 338 | + | assert!( | |
| 339 | + | !synckit::upsert_sync_key(&db.pool, app, user, "env_stale", 0) | |
| 340 | + | .await | |
| 341 | + | .unwrap(), | |
| 342 | + | "a stale expected_version is a conflict, not a write" | |
| 343 | + | ); | |
| 344 | + | assert_eq!( | |
| 345 | + | synckit::get_sync_key(&db.pool, app, user) | |
| 346 | + | .await | |
| 347 | + | .unwrap() | |
| 348 | + | .unwrap() | |
| 349 | + | .encrypted_key, | |
| 350 | + | "env_v1", | |
| 351 | + | "and the losing envelope must not have landed" | |
| 352 | + | ); | |
| 353 | + | ||
| 354 | + | assert!( | |
| 355 | + | synckit::upsert_sync_key(&db.pool, app, user, "env_v2", 1) | |
| 356 | + | .await | |
| 357 | + | .unwrap() | |
| 358 | + | ); | |
| 359 | + | let info = synckit::get_sync_key(&db.pool, app, user) | |
| 360 | + | .await | |
| 361 | + | .unwrap() | |
| 362 | + | .unwrap(); | |
| 363 | + | assert_eq!(info.encrypted_key, "env_v2"); | |
| 364 | + | assert_eq!(info.key_version, 2); | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | #[tokio::test] | |
| 368 | + | async fn a_key_belongs_to_one_user_within_one_app() { | |
| 369 | + | let db = TestDb::new().await; | |
| 370 | + | let alice = seed_user(&db.pool, "skkey_alice").await; | |
| 371 | + | let bob = seed_user(&db.pool, "skkey_bob").await; | |
| 372 | + | let app = seed_app(&db.pool, alice, "keyscope").await; | |
| 373 | + | synckit::upsert_sync_key(&db.pool, app, alice, "alice_env", 0) | |
| 374 | + | .await | |
| 375 | + | .unwrap(); | |
| 376 | + | ||
| 377 | + | assert!( | |
| 378 | + | synckit::get_sync_key(&db.pool, app, bob) | |
| 379 | + | .await | |
| 380 | + | .unwrap() | |
| 381 | + | .is_none(), | |
| 382 | + | "bob has no key here, and must not be handed alice's envelope" | |
| 383 | + | ); | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | #[tokio::test] | |
| 387 | + | async fn pruning_refuses_a_non_positive_horizon() { | |
| 388 | + | let db = TestDb::new().await; | |
| 389 | + | let user = seed_user(&db.pool, "skkey_prune").await; | |
| 390 | + | let app = seed_app(&db.pool, user, "keyprune").await; | |
| 391 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 392 | + | synckit::push_sync_changes( | |
| 393 | + | &db.pool, | |
| 394 | + | app, | |
| 395 | + | user, | |
| 396 | + | device, | |
| 397 | + | Uuid::new_v4(), | |
| 398 | + | &[change("tasks", "r0")], | |
| 399 | + | ) | |
| 400 | + | .await | |
| 401 | + | .unwrap(); | |
| 402 | + | ||
| 403 | + | for horizon in [0, -1] { | |
| 404 | + | assert_eq!( | |
| 405 | + | synckit::prune_sync_log(&db.pool, horizon).await.unwrap(), | |
| 406 | + | 0, | |
| 407 | + | "a zero or negative retention is a mistake, not an instruction to \ | |
| 408 | + | delete the whole log" | |
| 409 | + | ); | |
| 410 | + | } | |
| 411 | + | assert_eq!( | |
| 412 | + | synckit::pull_sync_changes(&db.pool, app, user, 0, 100) | |
| 413 | + | .await | |
| 414 | + | .unwrap() | |
| 415 | + | .len(), | |
| 416 | + | 1 | |
| 417 | + | ); | |
| 418 | + | // A real horizon spares entries inside it. | |
| 419 | + | assert_eq!(synckit::prune_sync_log(&db.pool, 30).await.unwrap(), 0); | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | #[tokio::test] | |
| 423 | + | async fn compaction_holds_back_a_log_no_device_has_pulled() { | |
| 424 | + | let db = TestDb::new().await; | |
| 425 | + | let user = seed_user(&db.pool, "skkey_compact").await; | |
| 426 | + | let app = seed_app(&db.pool, user, "keycompact").await; | |
| 427 | + | let device = seed_device(&db.pool, app, user, "laptop").await; | |
| 428 | + | synckit::push_sync_changes( | |
| 429 | + | &db.pool, | |
| 430 | + | app, | |
| 431 | + | user, | |
| 432 | + | device, | |
| 433 | + | Uuid::new_v4(), | |
| 434 | + | &[change("tasks", "r0")], | |
| 435 | + | ) | |
| 436 | + | .await | |
| 437 | + | .unwrap(); | |
| 438 | + | ||
| 439 | + | assert_eq!( | |
| 440 | + | synckit::compact_sync_log(&db.pool, app, user, 0) | |
| 441 | + | .await | |
| 442 | + | .unwrap(), | |
| 443 | + | 0, | |
| 444 | + | "a zero-day safety margin compacts nothing" | |
| 445 | + | ); | |
| 446 | + | assert_eq!( | |
| 447 | + | synckit::compact_sync_log(&db.pool, app, user, 7) | |
| 448 | + | .await | |
| 449 | + | .unwrap(), | |
| 450 | + | 0, | |
| 451 | + | "the device sits at cursor 0, so nothing is known-pulled" | |
| 452 | + | ); | |
| 453 | + | assert_eq!( | |
| 454 | + | synckit::pull_sync_changes(&db.pool, app, user, 0, 100) | |
| 455 | + | .await | |
| 456 | + | .unwrap() | |
| 457 | + | .len(), | |
| 458 | + | 1 | |
| 459 | + | ); | |
| 460 | + | } |