fix(economy): close SBC dup-card exploit + non-atomic economy races
Correctness fixes from docs/CORE_CORRECTNESS_ISSUES.md: - Issue 3 (HIGH, exploit): submit_sbc dedups owned_card_ids (HashSet) and bounds the list (MAX_SBC_CARDS=30) before resolution. A repeated id resolved the same card N times, passed validation, and granted the reward while only one card was consumed -> any SBC satisfiable with one duplicated card = free reward. Now rejected with BadRequest. Regression test added. - Issue 2 (HIGH): TOCTOU economy mutations closed with single-statement compare-and-swap (no transaction plumbing): club::spend_coins conditional debit (WHERE coins >= ?) + rows_affected, also rejects negative amounts; pack::open_pack claims the pack before minting; market::buy_listing claims the listing before charging and releases on debit failure; market::sell_card guards the DELETE with owner + rows_affected; checkin::claim uses a conditional INSERT ... WHERE NOT EXISTS (today) before paying out. - Issue 4 (LOW): season.rs .expect() on missing rows -> graceful AppError; checkin index (streak-1) % 7 -> .rem_euclid(7) (guards negative index panic). - Issue 1 (LOW): migration 0019 adds sbc_submissions.club_id + backfill; submit_sbc binds it so the MY CLUB milestone query stops silently reading 0. Core suite 179 green + clippy clean.
This commit is contained in:
+10
-4
@@ -1,6 +1,6 @@
|
||||
use crate::{
|
||||
db::Pool,
|
||||
error::AppResult,
|
||||
error::{AppError, AppResult},
|
||||
models::season::{Season, SeasonEndSummary, SeasonHistoryEntry, SeasonResult},
|
||||
services::{club, pack},
|
||||
};
|
||||
@@ -20,7 +20,9 @@ pub async fn get_or_create(pool: &Pool, profile_id: &str) -> AppResult<Season> {
|
||||
.bind(&now)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(fetch(pool, profile_id).await?.expect("just inserted"))
|
||||
fetch(pool, profile_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing immediately after insert")))
|
||||
}
|
||||
|
||||
async fn fetch(pool: &Pool, profile_id: &str) -> AppResult<Option<Season>> {
|
||||
@@ -66,7 +68,9 @@ pub async fn record_match(
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let season = fetch(pool, profile_id).await?.expect("season must exist");
|
||||
let season = fetch(pool, profile_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after record_match update")))?;
|
||||
|
||||
if !season.is_complete() {
|
||||
return Ok((season, None));
|
||||
@@ -141,7 +145,9 @@ pub async fn record_match(
|
||||
pack_awarded: pack_id.map(String::from),
|
||||
};
|
||||
|
||||
let updated = fetch(pool, profile_id).await?.expect("season must exist");
|
||||
let updated = fetch(pool, profile_id)
|
||||
.await?
|
||||
.ok_or_else(|| AppError::Internal(anyhow::anyhow!("season row missing after season rollover")))?;
|
||||
Ok((updated, Some(summary)))
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user