fix(matches): close the second, unguarded match-economy authority
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:
funman300
2026-08-21 04:47:57 +00:00
parent f0550e2ae1
commit bae0a2bdaa
8 changed files with 466 additions and 324 deletions
+213 -38
View File
@@ -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"