fix(core): refuse a squad replacement that would empty a populated squad
CI / Build, lint & test (push) Successful in 3m38s

/squad/replace is a full replacement: it deletes every assignment and
reinserts the supplied slots. Nothing validated that the supplied list was
non-empty, so a caller sending no slots silently wiped the squad and got
200/ok back.

This happened for real. A FIFA 17 client whose in-memory squad had been
destroyed by a bad parse wrote its emptiness back twice; WAL forensics on the
staging DB pin the damage to commit frame 465, squad_players 18 rows -> 0,
logged as route=squad-replace status=200 outcome=ok. The squad is the
authority's state, so mirroring a broken client's model is unrecoverable.

No product flow empties a squad: a full-replacement client sends its complete
slot array, and no caller or test in the tree builds an empty slot list. So an
empty list means the caller's model is broken, and the write is refused with
BadRequest. The check runs inside the transaction, so a concurrent write
cannot slip between the count and the delete, and a newly created squad
counts zero and is unaffected.

The regression test asserts both halves: the empty replacement is rejected,
and the existing assignments survive it. With the guard removed the test fails
with 200 and slots_written 0 - the exact production symptom.
This commit is contained in:
funman300
2026-08-24 19:27:44 +00:00
parent 1df03d4287
commit 9bdc1633a0
2 changed files with 108 additions and 0 deletions
+82
View File
@@ -3016,6 +3016,88 @@ async fn test_squad_ext_replace_read_roundtrip_and_idempotency() {
assert_eq!(other["extension"]["state"], "missing");
}
/// A full replacement that carries no slots MUST NOT empty a populated squad.
///
/// Regression: a FIFA 17 client whose in-memory squad had been destroyed by a
/// bad parse wrote that emptiness back through `/squad/replace`, taking the
/// canonical squad from 18 assignments to 0 while the request logged 200/ok.
/// The squad is the authority's state, so mirroring a broken client's model is
/// unrecoverable data loss.
#[tokio::test]
async fn test_squad_replace_refuses_to_empty_a_populated_squad() {
let app = build_test_app().await;
auth(&app, "SquadWipeGuardUser").await;
let (_, packs) = json_get(&app, "/packs").await;
let pack_id = packs["packs"][0]["pack_id"].as_str().unwrap().to_string();
json_post(
&app,
&format!("/packs/open/{pack_id}"),
serde_json::json!({}),
)
.await;
let (_, coll) = json_get(&app, "/collection").await;
let ids: Vec<String> = coll["collection"]
.as_array()
.unwrap()
.iter()
.take(2)
.map(|c| c["owned_card_id"].as_str().unwrap().to_string())
.collect();
let ext_write = serde_json::json!({
"namespace": "fifa17.squad", "schema_version": 1, "payload": "{\"custom\":\"[1]\"}"
});
let client_reported = serde_json::json!({
"client_reported_chemistry": 52,
"client_reported_rating": 90,
"client_reported_star_rating": 90
});
let populate = serde_json::json!({
"name": "OpenFUT",
"formation": "f442",
"slots": [
{"owned_card_id": ids[0], "slot": 0, "is_captain": true, "is_on_bench": false},
{"owned_card_id": ids[1], "slot": 1, "is_captain": false, "is_on_bench": false},
],
"client_reported": client_reported,
"extension": ext_write,
});
let (s, put) = json_put(&app, "/squad/replace", populate).await;
assert_eq!(s, StatusCode::OK, "{put}");
assert_eq!(put["slots_written"], 2);
// The destructive write: a well-formed replacement that simply carries no
// slots. It must be REFUSED, not applied — this is the exact shape that
// emptied a real squad.
let (s, err) = json_put(
&app,
"/squad/replace",
serde_json::json!({
"name": "OpenFUT",
"formation": "f442",
"slots": [],
"client_reported": client_reported,
"extension": ext_write,
}),
)
.await;
assert_eq!(
s,
StatusCode::BAD_REQUEST,
"an empty replacement must be refused, not applied: {err}"
);
// The squad is untouched — the refusal rolled back, it did not half-apply.
let (s, ext) = json_get(&app, "/squad/ext?namespace=fifa17.squad").await;
assert_eq!(s, StatusCode::OK);
assert_eq!(
ext["players"].as_array().unwrap().len(),
2,
"both assignments survive the refused replacement"
);
}
// ─────────────────────────── economy HTTP boundary ──────────────────────────
#[tokio::test]