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:
@@ -269,6 +269,56 @@ async fn test_sbc_submit_with_bronze_cards() {
|
||||
assert!(result["reward"].is_object());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sbc_rejects_duplicate_cards() {
|
||||
// Regression: a single owned card repeated to fill an SBC must be rejected. Before
|
||||
// the dedup guard the same id resolved N times, passed validation, and granted the
|
||||
// reward while only one card was consumed (free-reward exploit).
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "SBCDupePlayer").await;
|
||||
|
||||
let (s, _) = json_post(
|
||||
&app,
|
||||
"/packs/buy",
|
||||
serde_json::json!({ "pack_definition_id": "bronze_pack" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
let (_, packs_json) = json_get(&app, "/packs").await;
|
||||
let pack_id = packs_json["packs"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|p| p["definition_id"] == "bronze_pack")
|
||||
.expect("bronze pack in inventory")["pack_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (s, _) = json_post(&app, &format!("/packs/open/{pack_id}"), serde_json::json!({})).await;
|
||||
assert_eq!(s, StatusCode::OK);
|
||||
|
||||
let (_, coll) = json_get(&app, "/collection").await;
|
||||
let one_card = coll["collection"].as_array().unwrap()[0]["owned_card_id"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
|
||||
let (s, result) = json_post(
|
||||
&app,
|
||||
"/sbc/submit",
|
||||
serde_json::json!({
|
||||
"sbc_id": "sbc_bronze_upgrade",
|
||||
"owned_card_ids": vec![one_card; 11]
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(s, StatusCode::BAD_REQUEST, "duplicate submission must be rejected: {result}");
|
||||
assert!(
|
||||
result["error"].as_str().unwrap_or_default().contains("duplicate"),
|
||||
"expected a duplicate-card error, got: {result}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_settings_read_write() {
|
||||
let app = build_test_app().await;
|
||||
|
||||
Reference in New Issue
Block a user