fix(matches): close the second, unguarded match-economy authority
CI / Build, lint & test (push) Successful in 3m15s
CI / Build, lint & test (push) Successful in 3m15s
`POST /matches/result` granted coins, XP, level-ups, statistics, four objective metrics, loan expiry, season progression and achievements across a dozen SEPARATE writes with no transaction and no idempotency key. Every call re-credited the same match, and any mid-way failure half-applied it. It sat beside `/matches/complete`, so nothing stopped one match being paid twice through two different doors. It cannot be made exactly-once in place: that needs a caller-supplied match identity, and this request shape has none. Deriving one from the body would collapse two legitimate matches with the same scoreline into one — the under-credit trap already documented for the `fp:` fallback. So the route fails closed: it rejects with a message naming `/matches/complete`, rather than 404, so a caller learns why. The behaviour it uniquely drove is kept, not deleted. `process_match` was the ONLY caller of loan expiry and Core's season model, so both move into `complete_match`'s transaction behind opt-in `expire_loans` / `advance_season` flags. Both default OFF, which keeps the FIFA 17 retail path byte-identical: FIFA 17 has its own loan and Seasons models, and Core's season END GRANTS coins and a pack — invisible economy on a path that never asked for it. Their pooled implementations are replaced by `expire_loans_tx` and `season::record_match_tx`, so a loan that expires or a season that ends commits with the match that caused it. Notifications (level-up / objective / loan / season) were pooled side effects of the removed path. They now emit from the route AFTER the commit — never inside the transaction, since a failed notification must not roll back a completed match — and only when `applied`, so a replay no longer re-notifies. The pooled path had no replay concept and notified every time. Also fixes a real bug this surfaced: `/auth/reset` never deleted `match_completions`, which carries un-cascaded foreign keys to BOTH `matches` and `profiles`. Any profile that completed a match through the authoritative route — i.e. every FIFA 17 profile after a retail match — failed to reset with a database error. It is now deleted first, and ordering is documented. Tests: the 20 integration call sites move to the authoritative route through one helper that mints a per-call identity (each call IS a distinct match). New coverage for the closed path: it rejects without moving the balance or writing history; Core progression stays off unless opted into; a replay does not duplicate notifications; and a profile that completed matches can still be reset.
This commit is contained in:
+213
-38
@@ -78,6 +78,199 @@ async fn json_post(app: &axum::Router, uri: &str, payload: Value) -> (StatusCode
|
||||
(status, serde_json::from_slice(&body).unwrap())
|
||||
}
|
||||
|
||||
/// Submit ONE match through the authoritative exactly-once route.
|
||||
///
|
||||
/// `POST /matches/result` was removed as an economy path: it had no transaction
|
||||
/// and no idempotency key. `/matches/complete` requires a caller-supplied
|
||||
/// `match_identity`, so every call here mints a fresh one — each call is a
|
||||
/// distinct match, which is what these tests mean. `expire_loans` and
|
||||
/// `advance_season` are opted in to keep the Core-mode behaviour the old route
|
||||
/// used to trigger implicitly.
|
||||
///
|
||||
/// Takes the legacy payload shape and derives the canonical `result` from the
|
||||
/// scoreline, so call sites read the same as the match they describe.
|
||||
async fn post_match(app: &axum::Router, payload: Value) -> (StatusCode, Value) {
|
||||
static NEXT_MATCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
|
||||
let n = NEXT_MATCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
|
||||
let goals_for = payload["goals_for"].as_i64().unwrap_or(0);
|
||||
let goals_against = payload["goals_against"].as_i64().unwrap_or(0);
|
||||
let result = match goals_for.cmp(&goals_against) {
|
||||
std::cmp::Ordering::Greater => "win",
|
||||
std::cmp::Ordering::Equal => "draw",
|
||||
std::cmp::Ordering::Less => "loss",
|
||||
};
|
||||
|
||||
let mut body = payload;
|
||||
body["match_identity"] = serde_json::json!(format!("test-match-{n}"));
|
||||
body["result"] = serde_json::json!(result);
|
||||
body["expire_loans"] = serde_json::json!(true);
|
||||
body["advance_season"] = serde_json::json!(true);
|
||||
json_post(app, "/matches/complete", body).await
|
||||
}
|
||||
|
||||
// ── Legacy match-result path is closed ───────────────────────────────────────
|
||||
|
||||
/// `POST /matches/result` used to be a SECOND economy authority: a dozen writes
|
||||
/// with no transaction and no idempotency key, so it re-credited the same match
|
||||
/// on every call. It must now reject and grant nothing, pointing callers at the
|
||||
/// exactly-once route.
|
||||
#[tokio::test]
|
||||
async fn legacy_match_result_route_rejects_and_grants_nothing() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "LegacyGuard").await;
|
||||
|
||||
let (_, before) = json_get(&app, "/club").await;
|
||||
let coins_before = before["coins"].as_i64().expect("coins");
|
||||
|
||||
let (status, body) = json_post(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST, "{body}");
|
||||
assert!(
|
||||
body["error"]
|
||||
.as_str()
|
||||
.unwrap_or_default()
|
||||
.contains("/matches/complete"),
|
||||
"the rejection must name the route that replaced it: {body}"
|
||||
);
|
||||
|
||||
let (_, after) = json_get(&app, "/club").await;
|
||||
assert_eq!(
|
||||
after["coins"].as_i64().expect("coins"),
|
||||
coins_before,
|
||||
"a rejected legacy submit must not move the balance"
|
||||
);
|
||||
let (_, history) = json_get(&app, "/matches").await;
|
||||
assert!(
|
||||
history["matches"].as_array().is_none_or(|m| m.is_empty()),
|
||||
"a rejected legacy submit must not write match history: {history}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The behaviour the legacy route used to trigger implicitly is still reachable,
|
||||
/// but only when the caller opts in — a game with its own loan and season models
|
||||
/// (FIFA 17) must not have Core's advance behind its back, because Core's
|
||||
/// season end GRANTS coins.
|
||||
#[tokio::test]
|
||||
async fn core_progression_is_opt_in_on_the_authoritative_route() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "OptIn").await;
|
||||
|
||||
// Default: no opt-in fields at all.
|
||||
let (status, body) = json_post(
|
||||
&app,
|
||||
"/matches/complete",
|
||||
serde_json::json!({
|
||||
"match_identity": "opt-in-off", "result": "win",
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
assert_eq!(body["applied"], true);
|
||||
assert_eq!(
|
||||
body["season_end"],
|
||||
serde_json::Value::Null,
|
||||
"Core's season must not advance unless asked"
|
||||
);
|
||||
let (_, division) = json_get(&app, "/division").await;
|
||||
assert_eq!(
|
||||
division["matches_played"], 0,
|
||||
"no opt-in means Core's season model saw no match"
|
||||
);
|
||||
|
||||
// Opting in advances it.
|
||||
let (status, body) = json_post(
|
||||
&app,
|
||||
"/matches/complete",
|
||||
serde_json::json!({
|
||||
"match_identity": "opt-in-on", "result": "win",
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles",
|
||||
"advance_season": true
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let (_, division) = json_get(&app, "/division").await;
|
||||
assert_eq!(division["matches_played"], 1);
|
||||
}
|
||||
|
||||
/// A replay must not re-notify. The pooled path this replaced emitted a fresh
|
||||
/// notification every time it was called, because it had no replay concept.
|
||||
#[tokio::test]
|
||||
async fn replayed_completion_does_not_duplicate_notifications() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "ReplayNotify").await;
|
||||
|
||||
let submit = || {
|
||||
json_post(
|
||||
&app,
|
||||
"/matches/complete",
|
||||
serde_json::json!({
|
||||
"match_identity": "same-match", "result": "win",
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
}),
|
||||
)
|
||||
};
|
||||
|
||||
let (status, first) = submit().await;
|
||||
assert_eq!(status, StatusCode::OK, "{first}");
|
||||
assert_eq!(first["applied"], true);
|
||||
let (_, notifications) = json_get(&app, "/notifications").await;
|
||||
let after_first = notifications["notifications"].as_array().unwrap().len();
|
||||
|
||||
let (status, second) = submit().await;
|
||||
assert_eq!(status, StatusCode::OK, "{second}");
|
||||
assert_eq!(second["applied"], false, "replay must not re-apply");
|
||||
let (_, notifications) = json_get(&app, "/notifications").await;
|
||||
assert_eq!(
|
||||
notifications["notifications"].as_array().unwrap().len(),
|
||||
after_first,
|
||||
"a replay must not emit a second set of notifications"
|
||||
);
|
||||
}
|
||||
|
||||
/// A profile that completed a match through the authoritative route must still
|
||||
/// be resettable: `match_completions` holds un-cascaded foreign keys to both
|
||||
/// `matches` and `profiles`.
|
||||
#[tokio::test]
|
||||
async fn reset_clears_a_profile_that_completed_matches() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "ResetMe").await;
|
||||
|
||||
let (status, body) = json_post(
|
||||
&app,
|
||||
"/matches/complete",
|
||||
serde_json::json!({
|
||||
"match_identity": "reset-match", "result": "win",
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
|
||||
let (status, body) = json_post(
|
||||
&app,
|
||||
"/auth/reset",
|
||||
serde_json::json!({ "confirm": "reset" }),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{body}");
|
||||
let (status, _) = json_get(&app, "/profile").await;
|
||||
assert_eq!(status, StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
// ── Existing tests ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
@@ -125,7 +318,7 @@ async fn test_match_result_awards_coins() {
|
||||
"goals_against": 1,
|
||||
"mode": "squad_battles"
|
||||
});
|
||||
let (status, json) = json_post(&app, "/matches/result", payload).await;
|
||||
let (status, json) = post_match(&app, payload).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(json["match_record"]["outcome"], "win");
|
||||
assert!(json["coins_awarded"].as_i64().unwrap() > 0);
|
||||
@@ -154,7 +347,7 @@ async fn test_match_with_goal_positions_tracks_stats() {
|
||||
"mode": "squad_battles",
|
||||
"goal_positions": ["ST", "ST", "CAM"]
|
||||
});
|
||||
let (status, _) = json_post(&app, "/matches/result", payload).await;
|
||||
let (status, _) = post_match(&app, payload).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
|
||||
let (status, stats) = json_get(&app, "/statistics").await;
|
||||
@@ -424,9 +617,8 @@ async fn test_win_streak_tracking() {
|
||||
auth(&app, "StreakPlayer").await;
|
||||
|
||||
for _ in 0..3 {
|
||||
let (s, _) = json_post(
|
||||
let (s, _) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy",
|
||||
"opponent_name": "Bot",
|
||||
@@ -980,9 +1172,8 @@ async fn test_division_updates_after_wins() {
|
||||
|
||||
// Play 3 wins
|
||||
for _ in 0..3 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1004,9 +1195,8 @@ async fn test_season_ends_after_10_matches_and_promotes() {
|
||||
|
||||
// Win all 10 matches of the season
|
||||
for _ in 0..10 {
|
||||
let (s, result) = json_post(
|
||||
let (s, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1111,9 +1301,8 @@ async fn test_notifications_include_completed_objectives() {
|
||||
auth(&app, "ObjNotifPlayer").await;
|
||||
|
||||
// Play enough matches to complete the "daily_play_1_match" objective
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1136,9 +1325,8 @@ async fn test_match_result_returns_season_info() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "SeasonMatchPlayer").await;
|
||||
|
||||
let (s, result) = json_post(
|
||||
let (s, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 2, "goals_against": 1, "mode": "squad_battles"
|
||||
@@ -1579,9 +1767,8 @@ async fn test_rivals_weekly_reward_claim() {
|
||||
auth(&app, "RivalsClaimPlayer").await;
|
||||
|
||||
// Play a match to create the season row
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1601,9 +1788,8 @@ async fn test_rivals_reward_increments_week_counter() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "RivalsWeekCounter").await;
|
||||
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1730,9 +1916,8 @@ async fn test_match_result_includes_level_ups_on_first_win() {
|
||||
// With enough wins we cross the 500 XP threshold (level 2).
|
||||
let mut level_ups_seen = false;
|
||||
for _ in 0..5 {
|
||||
let (status, json) = json_post(
|
||||
let (status, json) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1791,9 +1976,8 @@ async fn test_level_up_creates_persistent_notification() {
|
||||
|
||||
// Play several wins to guarantee crossing the 500 XP threshold (level 2)
|
||||
for _ in 0..5 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1819,9 +2003,8 @@ async fn test_mark_all_notifications_read() {
|
||||
|
||||
// Generate a notification via level-up
|
||||
for _ in 0..5 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1843,9 +2026,8 @@ async fn test_mark_single_notification_read() {
|
||||
|
||||
// Generate level-up notifications
|
||||
for _ in 0..5 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1901,9 +2083,8 @@ async fn test_first_match_achievement_unlocks() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "FirstMatchAchPlayer").await;
|
||||
|
||||
let (_, result) = json_post(
|
||||
let (_, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1924,9 +2105,8 @@ async fn test_first_win_achievement_unlocks_on_win() {
|
||||
let app = build_test_app().await;
|
||||
auth(&app, "FirstWinAchPlayer").await;
|
||||
|
||||
let (_, result) = json_post(
|
||||
let (_, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 2, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1948,9 +2128,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
|
||||
auth(&app, "NoDupAchPlayer").await;
|
||||
|
||||
// First match — first_match unlocks
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1959,9 +2138,8 @@ async fn test_achievements_not_duplicated_on_second_match() {
|
||||
.await;
|
||||
|
||||
// Second match — first_match must NOT appear again
|
||||
let (_, result) = json_post(
|
||||
let (_, result) = post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -1983,9 +2161,8 @@ async fn test_achievement_grants_coins() {
|
||||
let coins_before = club_before["coins"].as_i64().unwrap_or(0);
|
||||
|
||||
// first_match achievement grants 500 coins on top of match reward
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -2027,9 +2204,8 @@ async fn test_auth_reset_clears_profile() {
|
||||
auth(&app, "ResetPlayer").await;
|
||||
|
||||
// Play a match to generate some state
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 1, "goals_against": 0, "mode": "squad_battles"
|
||||
@@ -2102,9 +2278,8 @@ async fn test_division_history_records_after_season_end() {
|
||||
|
||||
// Win all 10 matches to complete and promote
|
||||
for _ in 0..10 {
|
||||
json_post(
|
||||
post_match(
|
||||
&app,
|
||||
"/matches/result",
|
||||
serde_json::json!({
|
||||
"squad_id": "dummy", "opponent_name": "Bot",
|
||||
"goals_for": 3, "goals_against": 0, "mode": "squad_battles"
|
||||
|
||||
Reference in New Issue
Block a user