max / makenotwork
5 files changed,
+936 insertions,
-1 deletion
| @@ -303,7 +303,7 @@ pub async fn cancel_stale_rotation( | |||
| 303 | 303 | r#" | |
| 304 | 304 | DELETE FROM sync_key_rotations | |
| 305 | 305 | WHERE app_id = $1 AND user_id = $2 | |
| 306 | - | AND updated_at < NOW() - make_interval(hours => $3) | |
| 306 | + | AND updated_at < NOW() - make_interval(hours => $3::int) | |
| 307 | 307 | "#, | |
| 308 | 308 | ) | |
| 309 | 309 | .bind(app_id) |
| @@ -0,0 +1,359 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::creator_tiers` — the money/quota module. | |
| 2 | + | //! | |
| 3 | + | //! Audit Run 16 flagged the creator-tier DB layer as the Testing cold spot: the | |
| 4 | + | //! 280-line in-file test block exercises only the pure `label`/`price_cents` | |
| 5 | + | //! table, while the storage-cap CAS and the subscription lifecycle — the parts | |
| 6 | + | //! that actually enforce money and quota — were asserted only indirectly through | |
| 7 | + | //! HTTP upload flows ("money/race modules lack in-file `#[sqlx::test]`"). These | |
| 8 | + | //! call the `db::creator_tiers` functions directly against real Postgres so the | |
| 9 | + | //! invariants live where they belong: | |
| 10 | + | //! | |
| 11 | + | //! 1. `try_increment_storage` is a real compare-and-set — it never lets | |
| 12 | + | //! concurrent uploads push a creator over their cap (the race a plain | |
| 13 | + | //! read-then-write would lose). | |
| 14 | + | //! 2. `try_replace_storage_on` swaps sizes atomically and clamps the decrement. | |
| 15 | + | //! 3. The subscription lifecycle (create / duplicate-webhook / cancel / | |
| 16 | + | //! resubscribe) and the 30-day grace window resolve as documented. | |
| 17 | + | ||
| 18 | + | use crate::harness::db::TestDb; | |
| 19 | + | use makenotwork::db::{self, CreatorTier, UserId}; | |
| 20 | + | ||
| 21 | + | const MB: i64 = 1024 * 1024; | |
| 22 | + | ||
| 23 | + | /// Seed a minimal verified user (rows FK `users(id)`). | |
| 24 | + | async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId { | |
| 25 | + | let hash = makenotwork::auth::hash_password("password123").expect("hash"); | |
| 26 | + | sqlx::query_scalar::<_, UserId>( | |
| 27 | + | "INSERT INTO users (username, email, password_hash, email_verified) | |
| 28 | + | VALUES ($1, $2, $3, true) RETURNING id", | |
| 29 | + | ) | |
| 30 | + | .bind(username) | |
| 31 | + | .bind(format!("{username}@test.com")) | |
| 32 | + | .bind(&hash) | |
| 33 | + | .fetch_one(pool) | |
| 34 | + | .await | |
| 35 | + | .expect("seed user") | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | async fn storage_used(pool: &sqlx::PgPool, user: UserId) -> i64 { | |
| 39 | + | db::creator_tiers::get_storage_used(pool, user).await.unwrap() | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // ── try_increment_storage — the cap-checked CAS ────────────────────────────── | |
| 43 | + | ||
| 44 | + | #[tokio::test] | |
| 45 | + | async fn increment_within_cap_advances_the_counter() { | |
| 46 | + | let db = TestDb::new().await; | |
| 47 | + | let user = seed_user(&db.pool, "ct_inc_ok").await; | |
| 48 | + | ||
| 49 | + | db::creator_tiers::try_increment_storage(&db.pool, user, 10 * MB, 100 * MB) | |
| 50 | + | .await | |
| 51 | + | .expect("increment under cap must succeed"); | |
| 52 | + | assert_eq!(storage_used(&db.pool, user).await, 10 * MB); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | #[tokio::test] | |
| 56 | + | async fn increment_to_exact_cap_succeeds_then_one_more_byte_fails() { | |
| 57 | + | let db = TestDb::new().await; | |
| 58 | + | let user = seed_user(&db.pool, "ct_inc_boundary").await; | |
| 59 | + | let cap = 50 * MB; | |
| 60 | + | ||
| 61 | + | // Filling to exactly the cap is allowed (the check is `<= cap`). | |
| 62 | + | db::creator_tiers::try_increment_storage(&db.pool, user, cap, cap) | |
| 63 | + | .await | |
| 64 | + | .expect("filling to exactly the cap must succeed"); | |
| 65 | + | assert_eq!(storage_used(&db.pool, user).await, cap); | |
| 66 | + | ||
| 67 | + | // One more byte crosses the cap and is rejected, leaving the counter intact. | |
| 68 | + | let err = db::creator_tiers::try_increment_storage(&db.pool, user, 1, cap) | |
| 69 | + | .await | |
| 70 | + | .expect_err("crossing the cap must be rejected"); | |
| 71 | + | assert!( | |
| 72 | + | matches!(err, makenotwork::error::AppError::BadRequest(_)), | |
| 73 | + | "cap rejection surfaces as a user-facing BadRequest, got {err:?}" | |
| 74 | + | ); | |
| 75 | + | assert_eq!( | |
| 76 | + | storage_used(&db.pool, user).await, | |
| 77 | + | cap, | |
| 78 | + | "a rejected increment must not move the counter" | |
| 79 | + | ); | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// The invariant a plain read-then-write would break: many uploads confirm at | |
| 83 | + | /// once, each incrementing the shared counter. The cap-checked UPDATE is a CAS, | |
| 84 | + | /// so the sum of the winners can never exceed the cap — no over-sell. | |
| 85 | + | #[tokio::test] | |
| 86 | + | async fn concurrent_increments_never_exceed_the_cap() { | |
| 87 | + | let db = TestDb::new().await; | |
| 88 | + | let user = seed_user(&db.pool, "ct_inc_race").await; | |
| 89 | + | ||
| 90 | + | let chunk = 10 * MB; | |
| 91 | + | let cap = 5 * chunk; // exactly five chunks fit | |
| 92 | + | let racers = 20; | |
| 93 | + | ||
| 94 | + | let mut handles = Vec::new(); | |
| 95 | + | for _ in 0..racers { | |
| 96 | + | let pool = db.pool.clone(); | |
| 97 | + | handles.push(tokio::spawn(async move { | |
| 98 | + | db::creator_tiers::try_increment_storage(&pool, user, chunk, cap).await.is_ok() | |
| 99 | + | })); | |
| 100 | + | } | |
| 101 | + | let mut winners = 0; | |
| 102 | + | for h in handles { | |
| 103 | + | if h.await.expect("task panicked") { | |
| 104 | + | winners += 1; | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | assert_eq!(winners, 5, "exactly cap/chunk increments may win the CAS"); | |
| 109 | + | let final_used = storage_used(&db.pool, user).await; | |
| 110 | + | assert_eq!(final_used, cap, "the counter lands exactly on the cap"); | |
| 111 | + | assert!(final_used <= cap, "the CAS must never over-sell storage"); | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | // ── try_replace_storage_on / try_apply_storage_on — atomic swap + clamps ───── | |
| 115 | + | ||
| 116 | + | #[tokio::test] | |
| 117 | + | async fn replace_storage_swaps_old_for_new_atomically() { | |
| 118 | + | let db = TestDb::new().await; | |
| 119 | + | let user = seed_user(&db.pool, "ct_replace").await; | |
| 120 | + | let cap = 100 * MB; | |
| 121 | + | ||
| 122 | + | db::creator_tiers::try_increment_storage(&db.pool, user, 30 * MB, cap).await.unwrap(); | |
| 123 | + | ||
| 124 | + | let mut conn = db.pool.acquire().await.unwrap(); | |
| 125 | + | // Swap the 30MB file for a 20MB one: net -10MB in a single statement. | |
| 126 | + | db::creator_tiers::try_replace_storage_on(&mut conn, user, 30 * MB, 20 * MB, cap) | |
| 127 | + | .await | |
| 128 | + | .expect("replace under cap must succeed"); | |
| 129 | + | drop(conn); | |
| 130 | + | ||
| 131 | + | assert_eq!(storage_used(&db.pool, user).await, 20 * MB); | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[tokio::test] | |
| 135 | + | async fn replace_storage_over_cap_is_rejected_without_drift() { | |
| 136 | + | let db = TestDb::new().await; | |
| 137 | + | let user = seed_user(&db.pool, "ct_replace_cap").await; | |
| 138 | + | let cap = 50 * MB; | |
| 139 | + | ||
| 140 | + | db::creator_tiers::try_increment_storage(&db.pool, user, 40 * MB, cap).await.unwrap(); | |
| 141 | + | ||
| 142 | + | let mut conn = db.pool.acquire().await.unwrap(); | |
| 143 | + | // Replacing the 40MB file with a 60MB one would land at 60MB > cap: rejected. | |
| 144 | + | let err = db::creator_tiers::try_replace_storage_on(&mut conn, user, 40 * MB, 60 * MB, cap) | |
| 145 | + | .await | |
| 146 | + | .expect_err("replace that overflows the cap must be rejected"); | |
| 147 | + | assert!(matches!(err, makenotwork::error::AppError::BadRequest(_))); | |
| 148 | + | drop(conn); | |
| 149 | + | ||
| 150 | + | assert_eq!( | |
| 151 | + | storage_used(&db.pool, user).await, | |
| 152 | + | 40 * MB, | |
| 153 | + | "a rejected replace must leave the counter untouched (no drift)" | |
| 154 | + | ); | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[tokio::test] | |
| 158 | + | async fn replace_storage_clamps_the_decrement_at_zero() { | |
| 159 | + | let db = TestDb::new().await; | |
| 160 | + | let user = seed_user(&db.pool, "ct_replace_clamp").await; | |
| 161 | + | let cap = 100 * MB; | |
| 162 | + | ||
| 163 | + | // Counter is 10MB but we claim to remove a 999MB "old" file: the | |
| 164 | + | // GREATEST(0, ...) clamp prevents a negative counter, then adds the new 5MB. | |
| 165 | + | db::creator_tiers::try_increment_storage(&db.pool, user, 10 * MB, cap).await.unwrap(); | |
| 166 | + | let mut conn = db.pool.acquire().await.unwrap(); | |
| 167 | + | db::creator_tiers::try_replace_storage_on(&mut conn, user, 999 * MB, 5 * MB, cap) | |
| 168 | + | .await | |
| 169 | + | .expect("clamped replace succeeds"); | |
| 170 | + | drop(conn); | |
| 171 | + | ||
| 172 | + | assert_eq!( | |
| 173 | + | storage_used(&db.pool, user).await, | |
| 174 | + | 5 * MB, | |
| 175 | + | "the decrement clamps to zero, so the result is just the new size" | |
| 176 | + | ); | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | #[tokio::test] | |
| 180 | + | async fn apply_storage_dispatches_increment_vs_replace() { | |
| 181 | + | let db = TestDb::new().await; | |
| 182 | + | let user = seed_user(&db.pool, "ct_apply").await; | |
| 183 | + | let cap = 100 * MB; | |
| 184 | + | ||
| 185 | + | // None => fresh increment. | |
| 186 | + | let mut conn = db.pool.acquire().await.unwrap(); | |
| 187 | + | db::creator_tiers::try_apply_storage_on(&mut conn, user, None, 15 * MB, cap) | |
| 188 | + | .await | |
| 189 | + | .unwrap(); | |
| 190 | + | // Some(old) => replace. | |
| 191 | + | db::creator_tiers::try_apply_storage_on(&mut conn, user, Some(15 * MB), 25 * MB, cap) | |
| 192 | + | .await | |
| 193 | + | .unwrap(); | |
| 194 | + | drop(conn); | |
| 195 | + | ||
| 196 | + | assert_eq!(storage_used(&db.pool, user).await, 25 * MB); | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | #[tokio::test] | |
| 200 | + | async fn decrement_storage_clamps_at_zero() { | |
| 201 | + | let db = TestDb::new().await; | |
| 202 | + | let user = seed_user(&db.pool, "ct_dec").await; | |
| 203 | + | ||
| 204 | + | db::creator_tiers::try_increment_storage(&db.pool, user, 5 * MB, 100 * MB).await.unwrap(); | |
| 205 | + | // Over-decrement (double-delete, stale size) must floor at zero, not wrap negative. | |
| 206 | + | db::creator_tiers::decrement_storage_used(&db.pool, user, 999 * MB).await.unwrap(); | |
| 207 | + | ||
| 208 | + | assert_eq!(storage_used(&db.pool, user).await, 0); | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | // ── subscription lifecycle ─────────────────────────────────────────────────── | |
| 212 | + | ||
| 213 | + | #[tokio::test] | |
| 214 | + | async fn create_then_read_active_tier() { | |
| 215 | + | let db = TestDb::new().await; | |
| 216 | + | let user = seed_user(&db.pool, "ct_sub_create").await; | |
| 217 | + | ||
| 218 | + | assert!( | |
| 219 | + | db::creator_tiers::get_active_creator_tier(&db.pool, user).await.unwrap().is_none(), | |
| 220 | + | "no subscription => no active tier" | |
| 221 | + | ); | |
| 222 | + | ||
| 223 | + | let created = db::creator_tiers::create_creator_subscription( | |
| 224 | + | &db.pool, user, "sub_ct_1", "cus_ct_1", CreatorTier::BigFiles, | |
| 225 | + | ) | |
| 226 | + | .await | |
| 227 | + | .unwrap(); | |
| 228 | + | assert!(created.is_some(), "a fresh insert returns the new row"); | |
| 229 | + | ||
| 230 | + | assert_eq!( | |
| 231 | + | db::creator_tiers::get_active_creator_tier(&db.pool, user).await.unwrap(), | |
| 232 | + | Some(CreatorTier::BigFiles), | |
| 233 | + | ); | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | #[tokio::test] | |
| 237 | + | async fn duplicate_webhook_insert_is_a_noop() { | |
| 238 | + | let db = TestDb::new().await; | |
| 239 | + | let user = seed_user(&db.pool, "ct_sub_dup").await; | |
| 240 | + | ||
| 241 | + | db::creator_tiers::create_creator_subscription( | |
| 242 | + | &db.pool, user, "sub_dup", "cus_dup", CreatorTier::SmallFiles, | |
| 243 | + | ) | |
| 244 | + | .await | |
| 245 | + | .unwrap(); | |
| 246 | + | ||
| 247 | + | // Same subscription id, already active => the guarded ON CONFLICT is a no-op | |
| 248 | + | // and RETURNING yields no row. This is what makes a Stripe redelivery safe. | |
| 249 | + | let again = db::creator_tiers::create_creator_subscription( | |
| 250 | + | &db.pool, user, "sub_dup", "cus_dup", CreatorTier::SmallFiles, | |
| 251 | + | ) | |
| 252 | + | .await | |
| 253 | + | .unwrap(); | |
| 254 | + | assert!(again.is_none(), "a duplicate active-webhook insert returns None"); | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | #[tokio::test] | |
| 258 | + | async fn cancel_clears_active_tier_and_resubscribe_reactivates() { | |
| 259 | + | let db = TestDb::new().await; | |
| 260 | + | let user = seed_user(&db.pool, "ct_sub_cycle").await; | |
| 261 | + | ||
| 262 | + | db::creator_tiers::create_creator_subscription( | |
| 263 | + | &db.pool, user, "sub_cycle_a", "cus_cycle", CreatorTier::Everything, | |
| 264 | + | ) | |
| 265 | + | .await | |
| 266 | + | .unwrap(); | |
| 267 | + | ||
| 268 | + | let canceled = db::creator_tiers::cancel_creator_sub(&db.pool, "sub_cycle_a").await.unwrap(); | |
| 269 | + | assert!(canceled.is_some(), "cancel returns the updated row"); | |
| 270 | + | assert!( | |
| 271 | + | db::creator_tiers::get_active_creator_tier(&db.pool, user).await.unwrap().is_none(), | |
| 272 | + | "a canceled subscription is no longer the active tier" | |
| 273 | + | ); | |
| 274 | + | ||
| 275 | + | // Re-subscribing with a new Stripe subscription id reactivates the row. | |
| 276 | + | let re = db::creator_tiers::create_creator_subscription( | |
| 277 | + | &db.pool, user, "sub_cycle_b", "cus_cycle", CreatorTier::Basic, | |
| 278 | + | ) | |
| 279 | + | .await | |
| 280 | + | .unwrap(); | |
| 281 | + | assert!(re.is_some(), "a new subscription id after cancel reactivates"); | |
| 282 | + | assert_eq!( | |
| 283 | + | db::creator_tiers::get_active_creator_tier(&db.pool, user).await.unwrap(), | |
| 284 | + | Some(CreatorTier::Basic), | |
| 285 | + | "reactivation adopts the new tier", | |
| 286 | + | ); | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | #[tokio::test] | |
| 290 | + | async fn count_active_paying_counts_only_active_subscriptions() { | |
| 291 | + | let db = TestDb::new().await; | |
| 292 | + | let a = seed_user(&db.pool, "ct_count_a").await; | |
| 293 | + | let b = seed_user(&db.pool, "ct_count_b").await; | |
| 294 | + | ||
| 295 | + | db::creator_tiers::create_creator_subscription(&db.pool, a, "sub_ca", "cus_ca", CreatorTier::Basic) | |
| 296 | + | .await | |
| 297 | + | .unwrap(); | |
| 298 | + | db::creator_tiers::create_creator_subscription(&db.pool, b, "sub_cb", "cus_cb", CreatorTier::Basic) | |
| 299 | + | .await | |
| 300 | + | .unwrap(); | |
| 301 | + | assert_eq!(db::creator_tiers::count_active_paying(&db.pool).await.unwrap(), 2); | |
| 302 | + | ||
| 303 | + | // Cancelling one drops the strict paying count. | |
| 304 | + | db::creator_tiers::cancel_creator_sub(&db.pool, "sub_cb").await.unwrap(); | |
| 305 | + | assert_eq!(db::creator_tiers::count_active_paying(&db.pool).await.unwrap(), 1); | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | // ── 30-day cancellation grace window ───────────────────────────────────────── | |
| 309 | + | ||
| 310 | + | #[tokio::test] | |
| 311 | + | async fn grace_window_opens_on_cancel_and_closes_after_thirty_days() { | |
| 312 | + | let db = TestDb::new().await; | |
| 313 | + | let user = seed_user(&db.pool, "ct_grace").await; | |
| 314 | + | ||
| 315 | + | db::creator_tiers::create_creator_subscription( | |
| 316 | + | &db.pool, user, "sub_grace", "cus_grace", CreatorTier::SmallFiles, | |
| 317 | + | ) | |
| 318 | + | .await | |
| 319 | + | .unwrap(); | |
| 320 | + | db::creator_tiers::cancel_creator_sub(&db.pool, "sub_grace").await.unwrap(); | |
| 321 | + | ||
| 322 | + | // Just-canceled => inside the grace window. | |
| 323 | + | assert!(db::creator_tiers::is_in_grace_period(&db.pool, user).await.unwrap()); | |
| 324 | + | ||
| 325 | + | // Backdate the cancellation past 30 days => grace closed. | |
| 326 | + | sqlx::query("UPDATE creator_subscriptions SET canceled_at = NOW() - INTERVAL '31 days' WHERE user_id = $1") | |
| 327 | + | .bind(user) | |
| 328 | + | .execute(&db.pool) | |
| 329 | + | .await | |
| 330 | + | .unwrap(); | |
| 331 | + | assert!(!db::creator_tiers::is_in_grace_period(&db.pool, user).await.unwrap()); | |
| 332 | + | } | |
| 333 | + | ||
| 334 | + | #[tokio::test] | |
| 335 | + | async fn expired_grace_creators_surface_once_then_mark_enforced() { | |
| 336 | + | let db = TestDb::new().await; | |
| 337 | + | let user = seed_user(&db.pool, "ct_grace_sweep").await; | |
| 338 | + | ||
| 339 | + | db::creator_tiers::create_creator_subscription( | |
| 340 | + | &db.pool, user, "sub_sweep", "cus_sweep", CreatorTier::Basic, | |
| 341 | + | ) | |
| 342 | + | .await | |
| 343 | + | .unwrap(); | |
| 344 | + | db::creator_tiers::cancel_creator_sub(&db.pool, "sub_sweep").await.unwrap(); | |
| 345 | + | // Age the cancellation past the 30-day grace so the sweep picks it up. | |
| 346 | + | sqlx::query("UPDATE creator_subscriptions SET canceled_at = NOW() - INTERVAL '31 days' WHERE user_id = $1") | |
| 347 | + | .bind(user) | |
| 348 | + | .execute(&db.pool) | |
| 349 | + | .await | |
| 350 | + | .unwrap(); | |
| 351 | + | ||
| 352 | + | let expired = db::creator_tiers::get_expired_grace_creators(&db.pool).await.unwrap(); | |
| 353 | + | assert!(expired.contains(&user), "an over-grace creator surfaces for enforcement"); | |
| 354 | + | ||
| 355 | + | // Stamping grace_enforced_at removes it from the next sweep (idempotent tick). | |
| 356 | + | db::creator_tiers::mark_grace_enforced_batch(&db.pool, &[user]).await.unwrap(); | |
| 357 | + | let after = db::creator_tiers::get_expired_grace_creators(&db.pool).await.unwrap(); | |
| 358 | + | assert!(!after.contains(&user), "an enforced creator is not swept twice"); | |
| 359 | + | } |
| @@ -0,0 +1,281 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::synckit::rotation` — the SyncKit encryption | |
| 2 | + | //! key-rotation state machine. | |
| 3 | + | //! | |
| 4 | + | //! Audit Run 16 flagged `rotation` as a Concurrency/Testing cold spot (B+), and | |
| 5 | + | //! Phase 3 of the Run 16 remediation made `begin_key_rotation` transactional | |
| 6 | + | //! (`SELECT ... FOR UPDATE` on the `sync_keys` row) precisely to close a | |
| 7 | + | //! check-then-insert race where two devices both observe "no rotation" and both | |
| 8 | + | //! INSERT. These tests pin that fix and the rest of the begin/complete/cancel | |
| 9 | + | //! lifecycle at the DB layer, calling the `db::synckit::rotation` functions | |
| 10 | + | //! directly against real Postgres. | |
| 11 | + | ||
| 12 | + | use crate::harness::db::TestDb; | |
| 13 | + | use makenotwork::db::synckit; | |
| 14 | + | use makenotwork::db::{SyncAppId, SyncDeviceId, UserId}; | |
| 15 | + | ||
| 16 | + | /// Seed a verified user. | |
| 17 | + | async fn seed_user(pool: &sqlx::PgPool, username: &str) -> UserId { | |
| 18 | + | let hash = makenotwork::auth::hash_password("password123").expect("hash"); | |
| 19 | + | sqlx::query_scalar::<_, UserId>( | |
| 20 | + | "INSERT INTO users (username, email, password_hash, email_verified) | |
| 21 | + | VALUES ($1, $2, $3, true) RETURNING id", | |
| 22 | + | ) | |
| 23 | + | .bind(username) | |
| 24 | + | .bind(format!("{username}@test.com")) | |
| 25 | + | .bind(&hash) | |
| 26 | + | .fetch_one(pool) | |
| 27 | + | .await | |
| 28 | + | .expect("seed user") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | /// Seed a sync app owned by `user`. | |
| 32 | + | async fn seed_app(pool: &sqlx::PgPool, user: UserId, name: &str) -> SyncAppId { | |
| 33 | + | sqlx::query_scalar::<_, SyncAppId>( | |
| 34 | + | "INSERT INTO sync_apps (creator_id, name, api_key_hash, api_key_prefix) | |
| 35 | + | VALUES ($1, $2, $3, $4) RETURNING id", | |
| 36 | + | ) | |
| 37 | + | .bind(user) | |
| 38 | + | .bind(name) | |
| 39 | + | .bind(format!("hash_{name}")) | |
| 40 | + | .bind(&name[..name.len().min(8)]) | |
| 41 | + | .fetch_one(pool) | |
| 42 | + | .await | |
| 43 | + | .expect("seed sync app") | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | /// Seed the app's sync_keys row (key_version=1, key_id=1 by default). | |
| 47 | + | async fn seed_key(pool: &sqlx::PgPool, app: SyncAppId, user: UserId) { | |
| 48 | + | sqlx::query("INSERT INTO sync_keys (app_id, user_id, encrypted_key) VALUES ($1, $2, 'enc_v1')") | |
| 49 | + | .bind(app) | |
| 50 | + | .bind(user) | |
| 51 | + | .execute(pool) | |
| 52 | + | .await | |
| 53 | + | .expect("seed sync key"); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /// Seed a device row (`sync_key_rotations.device_id` FKs `sync_devices`). | |
| 57 | + | async fn seed_device(pool: &sqlx::PgPool, app: SyncAppId, user: UserId, name: &str) -> SyncDeviceId { | |
| 58 | + | sqlx::query_scalar::<_, SyncDeviceId>( | |
| 59 | + | "INSERT INTO sync_devices (app_id, user_id, device_name, platform) | |
| 60 | + | VALUES ($1, $2, $3, 'macos') RETURNING id", | |
| 61 | + | ) | |
| 62 | + | .bind(app) | |
| 63 | + | .bind(user) | |
| 64 | + | .bind(name) | |
| 65 | + | .fetch_one(pool) | |
| 66 | + | .await | |
| 67 | + | .expect("seed device") | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | async fn rotation_row_count(pool: &sqlx::PgPool, app: SyncAppId) -> i64 { | |
| 71 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sync_key_rotations WHERE app_id = $1") | |
| 72 | + | .bind(app) | |
| 73 | + | .fetch_one(pool) | |
| 74 | + | .await | |
| 75 | + | .expect("count rotations") | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // ── begin_key_rotation preconditions ───────────────────────────────────────── | |
| 79 | + | ||
| 80 | + | #[tokio::test] | |
| 81 | + | async fn begin_rotation_creates_a_row_at_matching_version() { | |
| 82 | + | let db = TestDb::new().await; | |
| 83 | + | let user = seed_user(&db.pool, "rot_begin").await; | |
| 84 | + | let app = seed_app(&db.pool, user, "rotbegin").await; | |
| 85 | + | seed_key(&db.pool, app, user).await; | |
| 86 | + | let device = seed_device(&db.pool, app, user, "dev").await; | |
| 87 | + | ||
| 88 | + | let out = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1) | |
| 89 | + | .await | |
| 90 | + | .unwrap(); | |
| 91 | + | let created = out.expect("matching version begins a rotation"); | |
| 92 | + | // Fresh key was key_id=1, so the rotation targets key_id=2. | |
| 93 | + | assert_eq!(created.new_key_id, 2); | |
| 94 | + | assert_eq!(created.device_id, device); | |
| 95 | + | assert_eq!(rotation_row_count(&db.pool, app).await, 1); | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | #[tokio::test] | |
| 99 | + | async fn begin_rotation_rejects_version_mismatch() { | |
| 100 | + | let db = TestDb::new().await; | |
| 101 | + | let user = seed_user(&db.pool, "rot_ver").await; | |
| 102 | + | let app = seed_app(&db.pool, user, "rotver").await; | |
| 103 | + | seed_key(&db.pool, app, user).await; | |
| 104 | + | ||
| 105 | + | // Client believes the key is at version 99; server is at 1 -> caller 409s. | |
| 106 | + | let out = synckit::begin_key_rotation(&db.pool, app, user, SyncDeviceId::new(), "enc_v2", 99) | |
| 107 | + | .await | |
| 108 | + | .unwrap(); | |
| 109 | + | assert_eq!(out.err(), Some("key version mismatch")); | |
| 110 | + | assert_eq!(rotation_row_count(&db.pool, app).await, 0, "a mismatch inserts nothing"); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[tokio::test] | |
| 114 | + | async fn begin_rotation_errors_when_no_key_exists() { | |
| 115 | + | let db = TestDb::new().await; | |
| 116 | + | let user = seed_user(&db.pool, "rot_nokey").await; | |
| 117 | + | let app = seed_app(&db.pool, user, "rotnokey").await; | |
| 118 | + | // Deliberately no seed_key. | |
| 119 | + | ||
| 120 | + | let out = synckit::begin_key_rotation(&db.pool, app, user, SyncDeviceId::new(), "enc_v2", 1) | |
| 121 | + | .await | |
| 122 | + | .unwrap(); | |
| 123 | + | assert_eq!(out.err(), Some("no encryption key exists")); | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | #[tokio::test] | |
| 127 | + | async fn begin_rotation_is_resumable_by_the_same_device() { | |
| 128 | + | let db = TestDb::new().await; | |
| 129 | + | let user = seed_user(&db.pool, "rot_resume").await; | |
| 130 | + | let app = seed_app(&db.pool, user, "rotresume").await; | |
| 131 | + | seed_key(&db.pool, app, user).await; | |
| 132 | + | let device = seed_device(&db.pool, app, user, "dev").await; | |
| 133 | + | ||
| 134 | + | let first = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1) | |
| 135 | + | .await | |
| 136 | + | .unwrap() | |
| 137 | + | .expect("first begin"); | |
| 138 | + | // The same device re-issuing begin resumes the identical rotation, not a new one. | |
| 139 | + | let resumed = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1) | |
| 140 | + | .await | |
| 141 | + | .unwrap() | |
| 142 | + | .expect("resume"); | |
| 143 | + | assert_eq!(resumed.id, first.id, "the same device resumes its own rotation"); | |
| 144 | + | assert_eq!(rotation_row_count(&db.pool, app).await, 1, "resume must not create a second row"); | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | #[tokio::test] | |
| 148 | + | async fn begin_rotation_blocks_a_second_device() { | |
| 149 | + | let db = TestDb::new().await; | |
| 150 | + | let user = seed_user(&db.pool, "rot_block").await; | |
| 151 | + | let app = seed_app(&db.pool, user, "rotblock").await; | |
| 152 | + | seed_key(&db.pool, app, user).await; | |
| 153 | + | let dev_a = seed_device(&db.pool, app, user, "dev-a").await; | |
| 154 | + | let dev_b = seed_device(&db.pool, app, user, "dev-b").await; | |
| 155 | + | ||
| 156 | + | synckit::begin_key_rotation(&db.pool, app, user, dev_a, "enc_v2", 1) | |
| 157 | + | .await | |
| 158 | + | .unwrap() | |
| 159 | + | .expect("first device begins"); | |
| 160 | + | let second = synckit::begin_key_rotation(&db.pool, app, user, dev_b, "enc_v2", 1) | |
| 161 | + | .await | |
| 162 | + | .unwrap(); | |
| 163 | + | assert_eq!(second.err(), Some("rotation already in progress by another device")); | |
| 164 | + | assert_eq!(rotation_row_count(&db.pool, app).await, 1); | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | /// The Phase 3 remediation's raison d'être: two devices racing `begin` for the | |
| 168 | + | /// same (app, user). The `FOR UPDATE` on the sync_keys row serializes the | |
| 169 | + | /// check-then-insert, so exactly one wins and exactly one rotation row exists — | |
| 170 | + | /// never two rotations, never a unique-constraint 500. | |
| 171 | + | #[tokio::test] | |
| 172 | + | async fn concurrent_begins_yield_exactly_one_rotation() { | |
| 173 | + | let db = TestDb::new().await; | |
| 174 | + | let user = seed_user(&db.pool, "rot_race").await; | |
| 175 | + | let app = seed_app(&db.pool, user, "rotrace").await; | |
| 176 | + | seed_key(&db.pool, app, user).await; | |
| 177 | + | ||
| 178 | + | let mut handles = Vec::new(); | |
| 179 | + | for i in 0..8 { | |
| 180 | + | let pool = db.pool.clone(); | |
| 181 | + | let device = seed_device(&db.pool, app, user, &format!("dev-{i}")).await; | |
| 182 | + | handles.push(tokio::spawn(async move { | |
| 183 | + | synckit::begin_key_rotation(&pool, app, user, device, "enc_v2", 1).await | |
| 184 | + | })); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | let mut winners = 0; | |
| 188 | + | for h in handles { | |
| 189 | + | // No task may error out (a lost race resolves to Ok(Err(..)), not Err). | |
| 190 | + | let out = h.await.expect("task panicked").expect("begin must not hard-error under contention"); | |
| 191 | + | if out.is_ok() { | |
| 192 | + | winners += 1; | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | assert_eq!(winners, 1, "exactly one racing device may open the rotation"); | |
| 197 | + | assert_eq!( | |
| 198 | + | rotation_row_count(&db.pool, app).await, | |
| 199 | + | 1, | |
| 200 | + | "the FOR UPDATE serialization must leave exactly one rotation row" | |
| 201 | + | ); | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // ── complete + cancel ──────────────────────────────────────────────────────── | |
| 205 | + | ||
| 206 | + | #[tokio::test] | |
| 207 | + | async fn complete_rotation_with_no_entries_swaps_the_key() { | |
| 208 | + | let db = TestDb::new().await; | |
| 209 | + | let user = seed_user(&db.pool, "rot_complete").await; | |
| 210 | + | let app = seed_app(&db.pool, user, "rotcomp").await; | |
| 211 | + | seed_key(&db.pool, app, user).await; | |
| 212 | + | let device = seed_device(&db.pool, app, user, "dev").await; | |
| 213 | + | ||
| 214 | + | let started = synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1) | |
| 215 | + | .await | |
| 216 | + | .unwrap() | |
| 217 | + | .expect("begin"); | |
| 218 | + | ||
| 219 | + | // No sync_log entries exist, so nothing needs re-encryption: complete succeeds. | |
| 220 | + | let done = synckit::complete_key_rotation(&db.pool, app, user, started.id) | |
| 221 | + | .await | |
| 222 | + | .unwrap(); | |
| 223 | + | assert_eq!(done, Ok(started.new_key_id), "complete returns the new key id"); | |
| 224 | + | ||
| 225 | + | // The rotation is consumed and the live key advanced. | |
| 226 | + | assert_eq!(rotation_row_count(&db.pool, app).await, 0, "the rotation row is deleted"); | |
| 227 | + | let (version, key_id, enc): (i32, i32, String) = | |
| 228 | + | sqlx::query_as("SELECT key_version, key_id, encrypted_key FROM sync_keys WHERE app_id = $1 AND user_id = $2") | |
| 229 | + | .bind(app) | |
| 230 | + | .bind(user) | |
| 231 | + | .fetch_one(&db.pool) | |
| 232 | + | .await | |
| 233 | + | .unwrap(); | |
| 234 | + | assert_eq!(version, 2, "key_version is bumped on completion"); | |
| 235 | + | assert_eq!(key_id, started.new_key_id, "the new key id is now the active one"); | |
| 236 | + | assert_eq!(enc, "enc_v2", "the new encrypted key is now live"); | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | #[tokio::test] | |
| 240 | + | async fn complete_rotation_is_noop_for_unknown_rotation() { | |
| 241 | + | let db = TestDb::new().await; | |
| 242 | + | let user = seed_user(&db.pool, "rot_unknown").await; | |
| 243 | + | let app = seed_app(&db.pool, user, "rotunkn").await; | |
| 244 | + | seed_key(&db.pool, app, user).await; | |
| 245 | + | ||
| 246 | + | // A random rotation id that was never started -> Err(0), no key change. | |
| 247 | + | let out = synckit::complete_key_rotation(&db.pool, app, user, uuid::Uuid::new_v4()) | |
| 248 | + | .await | |
| 249 | + | .unwrap(); | |
| 250 | + | assert_eq!(out, Err(0)); | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | #[tokio::test] | |
| 254 | + | async fn cancel_stale_rotation_respects_the_age_threshold() { | |
| 255 | + | let db = TestDb::new().await; | |
| 256 | + | let user = seed_user(&db.pool, "rot_stale").await; | |
| 257 | + | let app = seed_app(&db.pool, user, "rotstale").await; | |
| 258 | + | seed_key(&db.pool, app, user).await; | |
| 259 | + | let device = seed_device(&db.pool, app, user, "dev").await; | |
| 260 | + | ||
| 261 | + | synckit::begin_key_rotation(&db.pool, app, user, device, "enc_v2", 1) | |
| 262 | + | .await | |
| 263 | + | .unwrap() | |
| 264 | + | .expect("begin"); | |
| 265 | + | ||
| 266 | + | // A fresh rotation is not stale, so a 24h cancel is a no-op. | |
| 267 | + | assert!( | |
| 268 | + | !synckit::cancel_stale_rotation(&db.pool, app, user, 24).await.unwrap(), | |
| 269 | + | "a fresh rotation must not be cancelled as stale" | |
| 270 | + | ); | |
| 271 | + | assert_eq!(rotation_row_count(&db.pool, app).await, 1); | |
| 272 | + | ||
| 273 | + | // Age it past the threshold -> the stale sweep removes it, unblocking new begins. | |
| 274 | + | sqlx::query("UPDATE sync_key_rotations SET updated_at = NOW() - INTERVAL '48 hours' WHERE app_id = $1") | |
| 275 | + | .bind(app) | |
| 276 | + | .execute(&db.pool) | |
| 277 | + | .await | |
| 278 | + | .unwrap(); | |
| 279 | + | assert!(synckit::cancel_stale_rotation(&db.pool, app, user, 24).await.unwrap()); | |
| 280 | + | assert_eq!(rotation_row_count(&db.pool, app).await, 0); | |
| 281 | + | } |
| @@ -0,0 +1,292 @@ | |||
| 1 | + | //! DB-layer contract tests for `db::webhook_events` — the webhook dedup + | |
| 2 | + | //! retry-queue module. | |
| 3 | + | //! | |
| 4 | + | //! Audit Run 16 flagged `webhook_events` as a Testing/Concurrency cold spot | |
| 5 | + | //! (type B+): the dedup marker and the retry-claim query are exercised only | |
| 6 | + | //! indirectly through the Stripe webhook flow, with "no two-concurrent- | |
| 7 | + | //! deliveries test." These call the `db::webhook_events` functions directly so | |
| 8 | + | //! the two race-sensitive invariants live where they belong: | |
| 9 | + | //! | |
| 10 | + | //! 1. `mark_event_processed` is idempotent under concurrent duplicate delivery | |
| 11 | + | //! (Stripe at-least-once) — N racing marks of one event leave exactly one row. | |
| 12 | + | //! 2. `get_retryable_events` claims-and-defers so an overlapping scheduler tick | |
| 13 | + | //! (or a second replica) can't re-claim the same due events within the window. | |
| 14 | + | //! | |
| 15 | + | //! Plus the backoff/dead-letter progression and the retention prune. | |
| 16 | + | ||
| 17 | + | use crate::harness::db::TestDb; | |
| 18 | + | use makenotwork::db::webhook_events; | |
| 19 | + | ||
| 20 | + | /// Count rows in `processed_webhook_events` (dedup markers). | |
| 21 | + | async fn processed_count(pool: &sqlx::PgPool) -> i64 { | |
| 22 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM processed_webhook_events") | |
| 23 | + | .fetch_one(pool) | |
| 24 | + | .await | |
| 25 | + | .expect("count processed") | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | /// Insert a failed event and return its id, backdating `next_retry_at` into the | |
| 29 | + | /// past so it is immediately due (the column defaults to NOW()+60s). | |
| 30 | + | async fn seed_due_event(pool: &sqlx::PgPool, event_type: &str) -> uuid::Uuid { | |
| 31 | + | webhook_events::insert_failed_event(pool, "stripe", event_type, "{}", None, "boom") | |
| 32 | + | .await | |
| 33 | + | .expect("insert failed event"); | |
| 34 | + | sqlx::query_scalar::<_, uuid::Uuid>( | |
| 35 | + | "UPDATE webhook_events SET next_retry_at = NOW() - INTERVAL '1 minute' \ | |
| 36 | + | WHERE event_type = $1 RETURNING id", | |
| 37 | + | ) | |
| 38 | + | .bind(event_type) | |
| 39 | + | .fetch_one(pool) | |
| 40 | + | .await | |
| 41 | + | .expect("backdate next_retry_at") | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // ── dedup marker (`processed_webhook_events`) ──────────────────────────────── | |
| 45 | + | ||
| 46 | + | #[tokio::test] | |
| 47 | + | async fn is_event_processed_false_before_mark_true_after() { | |
| 48 | + | let db = TestDb::new().await; | |
| 49 | + | let id = "evt_dedup_basic"; | |
| 50 | + | ||
| 51 | + | assert!( | |
| 52 | + | !webhook_events::is_event_processed(&db.pool, id).await.unwrap(), | |
| 53 | + | "an unseen event must not read as processed" | |
| 54 | + | ); | |
| 55 | + | ||
| 56 | + | webhook_events::mark_event_processed(&db.pool, id).await.unwrap(); | |
| 57 | + | ||
| 58 | + | assert!( | |
| 59 | + | webhook_events::is_event_processed(&db.pool, id).await.unwrap(), | |
| 60 | + | "a marked event must read as processed" | |
| 61 | + | ); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | #[tokio::test] | |
| 65 | + | async fn mark_event_processed_is_idempotent() { | |
| 66 | + | let db = TestDb::new().await; | |
| 67 | + | let id = "evt_dedup_idem"; | |
| 68 | + | ||
| 69 | + | // Serial re-marks (redelivery) must not error and must not duplicate the row. | |
| 70 | + | webhook_events::mark_event_processed(&db.pool, id).await.unwrap(); | |
| 71 | + | webhook_events::mark_event_processed(&db.pool, id).await.unwrap(); | |
| 72 | + | webhook_events::mark_event_processed(&db.pool, id).await.unwrap(); | |
| 73 | + | ||
| 74 | + | assert_eq!(processed_count(&db.pool).await, 1, "re-marking must be a no-op"); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | /// The core concurrency invariant: Stripe delivers at-least-once, so two | |
| 78 | + | /// deliveries of the same event can race the post-handler mark. `ON CONFLICT DO | |
| 79 | + | /// NOTHING` must collapse them to exactly one dedup row — never a duplicate-key | |
| 80 | + | /// error, never two rows. | |
| 81 | + | #[tokio::test] | |
| 82 | + | async fn mark_event_processed_concurrent_duplicates_leave_one_row() { | |
| 83 | + | let db = TestDb::new().await; | |
| 84 | + | let id = "evt_dedup_race"; | |
| 85 | + | ||
| 86 | + | let mut handles = Vec::new(); | |
| 87 | + | for _ in 0..24 { | |
| 88 | + | let pool = db.pool.clone(); | |
| 89 | + | let id = id.to_string(); | |
| 90 | + | handles.push(tokio::spawn(async move { | |
| 91 | + | webhook_events::mark_event_processed(&pool, &id).await | |
| 92 | + | })); | |
| 93 | + | } | |
| 94 | + | for h in handles { | |
| 95 | + | // No task may observe a unique-violation — the ON CONFLICT swallows it. | |
| 96 | + | h.await.expect("task panicked").expect("mark must not error under contention"); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | assert_eq!( | |
| 100 | + | processed_count(&db.pool).await, | |
| 101 | + | 1, | |
| 102 | + | "concurrent duplicate deliveries must yield exactly one dedup row" | |
| 103 | + | ); | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | #[tokio::test] | |
| 107 | + | async fn prune_processed_events_respects_retention_boundary() { | |
| 108 | + | let db = TestDb::new().await; | |
| 109 | + | ||
| 110 | + | webhook_events::mark_event_processed(&db.pool, "evt_old").await.unwrap(); | |
| 111 | + | webhook_events::mark_event_processed(&db.pool, "evt_fresh").await.unwrap(); | |
| 112 | + | // Age the old marker past a 30-day retention; leave the fresh one at NOW(). | |
| 113 | + | sqlx::query("UPDATE processed_webhook_events SET processed_at = NOW() - INTERVAL '40 days' WHERE event_id = 'evt_old'") | |
| 114 | + | .execute(&db.pool) | |
| 115 | + | .await | |
| 116 | + | .unwrap(); | |
| 117 | + | ||
| 118 | + | let deleted = webhook_events::prune_processed_events(&db.pool, 30).await.unwrap(); | |
| 119 | + | assert_eq!(deleted, 1, "exactly the aged marker is pruned"); | |
| 120 | + | ||
| 121 | + | assert!(!webhook_events::is_event_processed(&db.pool, "evt_old").await.unwrap()); | |
| 122 | + | assert!( | |
| 123 | + | webhook_events::is_event_processed(&db.pool, "evt_fresh").await.unwrap(), | |
| 124 | + | "a within-retention marker must survive the prune" | |
| 125 | + | ); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ── retry queue (`webhook_events`) claim-and-defer ─────────────────────────── | |
| 129 | + | ||
| 130 | + | /// A due event is claimed once, and the same immediate tick can't re-claim it: | |
| 131 | + | /// `get_retryable_events` pushes `next_retry_at` out by 2 minutes as it selects, | |
| 132 | + | /// so an overlapping scheduler tick (or a second replica) gets nothing. This is | |
| 133 | + | /// the "two-concurrent-deliveries" guard the audit noted was untested. | |
| 134 | + | #[tokio::test] | |
| 135 | + | async fn get_retryable_events_claims_and_defers() { | |
| 136 | + | let db = TestDb::new().await; | |
| 137 | + | seed_due_event(&db.pool, "claim.a").await; | |
| 138 | + | seed_due_event(&db.pool, "claim.b").await; | |
| 139 | + | ||
| 140 | + | let first = webhook_events::get_retryable_events(&db.pool).await.unwrap(); | |
| 141 | + | assert_eq!(first.len(), 2, "both due events are claimed on the first tick"); | |
| 142 | + | ||
| 143 | + | let second = webhook_events::get_retryable_events(&db.pool).await.unwrap(); | |
| 144 | + | assert!( | |
| 145 | + | second.is_empty(), | |
| 146 | + | "a claimed event's next_retry_at is deferred, so an immediate re-tick claims nothing" | |
| 147 | + | ); | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | /// Two overlapping ticks racing the same due events must partition them — never | |
| 151 | + | /// hand the same event to both callers (would double-run the handler). | |
| 152 | + | #[tokio::test] | |
| 153 | + | async fn get_retryable_events_concurrent_ticks_do_not_double_claim() { | |
| 154 | + | let db = TestDb::new().await; | |
| 155 | + | for i in 0..6 { | |
| 156 | + | seed_due_event(&db.pool, &format!("race.claim.{i}")).await; | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | let p1 = db.pool.clone(); | |
| 160 | + | let p2 = db.pool.clone(); | |
| 161 | + | let (a, b) = tokio::join!( | |
| 162 | + | tokio::spawn(async move { webhook_events::get_retryable_events(&p1).await }), | |
| 163 | + | tokio::spawn(async move { webhook_events::get_retryable_events(&p2).await }), | |
| 164 | + | ); | |
| 165 | + | let a = a.unwrap().unwrap(); | |
| 166 | + | let b = b.unwrap().unwrap(); | |
| 167 | + | ||
| 168 | + | let mut ids: Vec<_> = a.iter().chain(b.iter()).map(|e| e.id).collect(); | |
| 169 | + | let total = ids.len(); | |
| 170 | + | ids.sort(); | |
| 171 | + | ids.dedup(); | |
| 172 | + | assert_eq!( | |
| 173 | + | ids.len(), | |
| 174 | + | total, | |
| 175 | + | "no event id may be claimed by both concurrent ticks" | |
| 176 | + | ); | |
| 177 | + | assert_eq!(ids.len(), 6, "every due event is claimed exactly once across the two ticks"); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[tokio::test] | |
| 181 | + | async fn get_retryable_events_excludes_future_and_exhausted() { | |
| 182 | + | let db = TestDb::new().await; | |
| 183 | + | ||
| 184 | + | // Due now — eligible. | |
| 185 | + | seed_due_event(&db.pool, "elig.due").await; | |
| 186 | + | // Scheduled in the future — not yet eligible. | |
| 187 | + | webhook_events::insert_failed_event(&db.pool, "stripe", "elig.future", "{}", None, "e") | |
| 188 | + | .await | |
| 189 | + | .unwrap(); | |
| 190 | + | // Due but retry-exhausted (attempts >= 5) — must be skipped so it can't storm. | |
| 191 | + | let exhausted = seed_due_event(&db.pool, "elig.exhausted").await; | |
| 192 | + | sqlx::query("UPDATE webhook_events SET attempts = 5 WHERE id = $1") | |
| 193 | + | .bind(exhausted) | |
| 194 | + | .execute(&db.pool) | |
| 195 | + | .await | |
| 196 | + | .unwrap(); | |
| 197 | + | ||
| 198 | + | let due = webhook_events::get_retryable_events(&db.pool).await.unwrap(); | |
| 199 | + | let types: Vec<_> = due.iter().map(|e| e.event_type.as_str()).collect(); | |
| 200 | + | assert_eq!(types, ["elig.due"], "only the due, non-exhausted event is returned"); | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // ── backoff + dead-letter progression ──────────────────────────────────────── | |
| 204 | + | ||
| 205 | + | #[tokio::test] | |
| 206 | + | async fn schedule_retry_sets_retrying_and_backs_off() { | |
| 207 | + | let db = TestDb::new().await; | |
| 208 | + | let id = seed_due_event(&db.pool, "backoff.evt").await; | |
| 209 | + | ||
| 210 | + | // First failed retry: attempt count 1, status -> retrying, deferred forward. | |
| 211 | + | webhook_events::schedule_retry(&db.pool, id, 1, "still failing").await.unwrap(); | |
| 212 | + | ||
| 213 | + | let (status, attempts, deferred): (String, i32, bool) = sqlx::query_as( | |
| 214 | + | "SELECT status, attempts, next_retry_at > NOW() FROM webhook_events WHERE id = $1", | |
| 215 | + | ) | |
| 216 | + | .bind(id) | |
| 217 | + | .fetch_one(&db.pool) | |
| 218 | + | .await | |
| 219 | + | .unwrap(); | |
| 220 | + | assert_eq!(status, "retrying"); | |
| 221 | + | assert_eq!(attempts, 1); | |
| 222 | + | assert!(deferred, "next_retry_at is pushed into the future by the backoff"); | |
| 223 | + | ||
| 224 | + | // Immediately after scheduling, it is not due, so a tick skips it. | |
| 225 | + | assert!( | |
| 226 | + | webhook_events::get_retryable_events(&db.pool).await.unwrap().is_empty(), | |
| 227 | + | "a just-rescheduled event is not yet due" | |
| 228 | + | ); | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | #[tokio::test] | |
| 232 | + | async fn schedule_retry_dead_letters_at_max_attempts() { | |
| 233 | + | let db = TestDb::new().await; | |
| 234 | + | let id = seed_due_event(&db.pool, "dead.evt").await; | |
| 235 | + | ||
| 236 | + | // Reaching the max attempts marks the event dead — off the retry queue, | |
| 237 | + | // onto the operator dead-letter list. | |
| 238 | + | webhook_events::schedule_retry(&db.pool, id, 5, "exhausted").await.unwrap(); | |
| 239 | + | ||
| 240 | + | let status: String = sqlx::query_scalar("SELECT status FROM webhook_events WHERE id = $1") | |
| 241 | + | .bind(id) | |
| 242 | + | .fetch_one(&db.pool) | |
| 243 | + | .await | |
| 244 | + | .unwrap(); | |
| 245 | + | assert_eq!(status, "dead"); | |
| 246 | + | ||
| 247 | + | assert!( | |
| 248 | + | webhook_events::get_retryable_events(&db.pool).await.unwrap().is_empty(), | |
| 249 | + | "a dead event must never be re-claimed for retry" | |
| 250 | + | ); | |
| 251 | + | let dead = webhook_events::get_dead_events(&db.pool).await.unwrap(); | |
| 252 | + | assert!(dead.iter().any(|e| e.id == id), "a dead event surfaces on the operator list"); | |
| 253 | + | } | |
| 254 | + | ||
| 255 | + | #[tokio::test] | |
| 256 | + | async fn retry_dead_event_resurrects_only_dead_rows() { | |
| 257 | + | let db = TestDb::new().await; | |
| 258 | + | ||
| 259 | + | // A live (failed) event is not a dead-letter, so resetting it is a no-op. | |
| 260 | + | let live = seed_due_event(&db.pool, "resurrect.live").await; | |
| 261 | + | assert!( | |
| 262 | + | !webhook_events::retry_dead_event(&db.pool, live).await.unwrap(), | |
| 263 | + | "retry_dead_event must only act on status = 'dead'" | |
| 264 | + | ); | |
| 265 | + | ||
| 266 | + | // A genuinely dead event flips back to 'failed' and becomes claimable again. | |
| 267 | + | let dead = seed_due_event(&db.pool, "resurrect.dead").await; | |
| 268 | + | webhook_events::schedule_retry(&db.pool, dead, 5, "exhausted").await.unwrap(); | |
| 269 | + | assert!(webhook_events::retry_dead_event(&db.pool, dead).await.unwrap()); | |
| 270 | + | ||
| 271 | + | let status: String = sqlx::query_scalar("SELECT status FROM webhook_events WHERE id = $1") | |
| 272 | + | .bind(dead) | |
| 273 | + | .fetch_one(&db.pool) | |
| 274 | + | .await | |
| 275 | + | .unwrap(); | |
| 276 | + | assert_eq!(status, "failed", "a resurrected event returns to the retry queue"); | |
| 277 | + | } | |
| 278 | + | ||
| 279 | + | #[tokio::test] | |
| 280 | + | async fn mark_processed_removes_event_from_queue() { | |
| 281 | + | let db = TestDb::new().await; | |
| 282 | + | let id = seed_due_event(&db.pool, "done.evt").await; | |
| 283 | + | ||
| 284 | + | webhook_events::mark_processed(&db.pool, id).await.unwrap(); | |
| 285 | + | ||
| 286 | + | let remaining: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM webhook_events WHERE id = $1") | |
| 287 | + | .bind(id) | |
| 288 | + | .fetch_one(&db.pool) | |
| 289 | + | .await | |
| 290 | + | .unwrap(); | |
| 291 | + | assert_eq!(remaining, 0, "a successfully processed event is deleted from the queue"); | |
| 292 | + | } |
| @@ -98,4 +98,7 @@ mod cart; | |||
| 98 | 98 | mod bundles; | |
| 99 | 99 | mod idempotency; | |
| 100 | 100 | mod db_payments_layer; | |
| 101 | + | mod db_creator_tiers_layer; | |
| 102 | + | mod db_synckit_rotation; | |
| 103 | + | mod db_webhook_events; | |
| 101 | 104 | mod security_idor_moderation; |