diff --git a/openfut-utas-host/mutation-battery.sh b/openfut-utas-host/mutation-battery.sh new file mode 100755 index 0000000..172b757 --- /dev/null +++ b/openfut-utas-host/mutation-battery.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# FIFA 17 squad HOST mutation battery. +# +# Each mutation injects a specific wrong behaviour into the committed source +# (host, or the adapter it composes), runs the ONE host test that defends the +# invariant, and requires that test to FAIL (mutant killed). Source is reverted +# via `git checkout` after every mutation. A SURVIVOR fails the battery. +# +# Run from the host crate root: bash mutation-battery.sh +set -u +cd "$(dirname "$0")" + +H=src/lib.rs +PROJ=../openfut-adapter-fifa17/src/fut/squad_projection.rs +EXT=../openfut-adapter-fifa17/src/fut/squad_ext.rs +ITEM=../openfut-adapter-fifa17/src/fut/item.rs + +PASS=0 +FAIL=0 +declare -a SURVIVORS=() + +mutate() { + OLD="$2" NEW="$3" perl -0777 -pi -e \ + 's/\Q$ENV{OLD}\E/$ENV{NEW}/g or die "PATTERN NOT FOUND in '"$1"'\n"' "$1" +} + +kill_test() { + local n="$1" label="$2" filter="$3" file="$4" old="$5" new="$6" + git checkout -- "$file" + if ! mutate "$file" "$old" "$new"; then + echo " [#$n] SETUP ERROR — $label"; FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label (setup)"); git checkout -- "$file"; return + fi + if cargo test --quiet --test host_test "$filter" >/dev/null 2>&1; then + echo " [#$n] SURVIVED — $label"; FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label") + else + echo " [#$n] killed — $label"; PASS=$((PASS+1)) + fi + git checkout -- "$file" +} + +echo "== FIFA17 squad host mutation battery ==" + +kill_test 1 "resolvable wire id skips authorization" \ + put_rejects_wire_id_owned_by_another_profile_core_unchanged "$H" \ + 'if !owned_set.contains(&slot.owned_card_id) {' 'if false {' + +kill_test 2 "authorization loop never runs (wrong-profile accepted)" \ + put_rejects_wire_id_owned_by_another_profile_core_unchanged "$H" \ + 'for slot in &build.canonical.slots {' 'for slot in build.canonical.slots.iter().take(0) {' + +kill_test 3 "PUT/core failure masked as success (not failed loudly)" \ + put_core_failure_returns_error_never_python "$H" \ + ' error_response(502, "core_error"),' ' json_response(&save_ack(put.id)),' + +kill_test 4 "PUT forwarded as a partial slot diff" \ + put_full_replacement_commits_canonical_and_extension_and_acks_id0 "$H" \ + '.iter() + .map(|s| CoreSquadSlot {' '.iter() + .take(1) + .map(|s| CoreSquadSlot {' + +kill_test 5 "extension dropped from the atomic write" \ + put_full_replacement_commits_canonical_and_extension_and_acks_id0 "$H" \ + 'ext_payload: build.extension.to_payload(),' 'ext_payload: String::new(),' + +kill_test 6 "stale extension accepted and projected" \ + stale_extension_is_not_applied_on_reads "$H" \ + ' CoreExtState::Stale { .. } => return HostProjection::Stale,' \ + ' CoreExtState::Stale { schema_version, payload } => match Fifa17SquadExtensionV1::from_payload(*schema_version, payload) { Ok(e) => e, Err(_) => return HostProjection::Missing },' + +kill_test 7 "missing extension fabricates defaults" \ + missing_extension_is_explicit_on_reads "$H" \ + ' CoreExtState::Missing => return HostProjection::Missing,' \ + ' CoreExtState::Missing => Fifa17SquadExtensionV1::from_payload(1, "{}").unwrap(),' + +kill_test 8 "overlay clobbers userInfo" \ + coupled_read_after_write_list_and_usermassinfo_agree "$H" \ + ' root["squad"] = squad_val;' ' root["squad"] = squad_val; root["userInfo"] = serde_json::json!({});' + +kill_test 9 "overlay clobbers settings" \ + coupled_read_after_write_list_and_usermassinfo_agree "$H" \ + ' root["squad"] = squad_val;' ' root["squad"] = squad_val; root["settings"] = serde_json::json!({});' + +kill_test 10 "overlay clobbers pileSizeClientData" \ + coupled_read_after_write_list_and_usermassinfo_agree "$H" \ + ' root["squad"] = squad_val;' ' root["squad"] = squad_val; root["pileSizeClientData"] = serde_json::json!({});' + +kill_test 11 "/squad/list uses separate shaping logic" \ + coupled_read_after_write_list_and_usermassinfo_agree "$H" \ + 'json_response(&squad_list(&v)),' 'json_response(&serde_json::json!({ "squad": [{ "formation": "WRONG" }] })),' + +kill_test 12 "captain emitted using resourceId (adapter projector)" \ + coupled_read_after_write_list_and_usermassinfo_agree "$PROJ" \ + 'captain_wire = id.item_id as i64;' 'captain_wire = id.asset_id as i64;' + +kill_test 13 "kitNumber keyed by slot index (adapter extension)" \ + coupled_read_after_write_list_and_usermassinfo_agree "$EXT" \ + '(s.owned_card_id.clone(), s.kit_number)' '(s.index.to_string(), s.kit_number)' + +kill_test 14 "client-reported evaluation dropped/mishandled" \ + put_full_replacement_commits_canonical_and_extension_and_acks_id0 "$H" \ + 'chemistry: build.extension.client_reported.chemistry,' 'chemistry: None,' + +kill_test 15 "host trusts/acts on the Core fingerprint" \ + put_full_replacement_commits_canonical_and_extension_and_acks_id0 "$H" \ + ' Ok(_) => ( + json_response(&save_ack(put.id)),' \ + ' Ok(r) => ( + if r.canonical_fingerprint.is_empty() { json_response(&save_ack(put.id)) } else { error_response(409, "host_trusts_fp") },' + +kill_test 16 "per-slot Core lookup introduced (N+1)" \ + read_path_is_bounded_no_per_slot_lookup "$H" \ + ' let owned = match deps.core.all_owned() {' \ + ' for _ in 0..2 { let _ = deps.core.all_owned(); } + let owned = match deps.core.all_owned() {' + +kill_test 17 "/squad/active classified to Rust" \ + classify_squad_and_usermassinfo_routes "$H" \ + ' Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),' \ + ' Some(id) => !id.is_empty(),' + +kill_test 18 "duplicate definition collapses owned instances (adapter shaper)" \ + duplicate_definition_instances_stay_distinct_through_host "$ITEM" \ + '"id": id.item_id,' '"id": id.asset_id,' + +kill_test 19 "Content-Length left stale after overlay" \ + coupled_read_after_write_list_and_usermassinfo_agree "$H" \ + 'resp.headers.push(("Content-Length".to_string(), body.len().to_string()));' \ + 'resp.headers.push(("Content-Length".to_string(), "0".to_string()));' + +kill_test 20 "Python squad silently used after Rust projection failure" \ + usermassinfo_never_serves_python_squad_on_integrity_failure "$H" \ + 'empty_squad_overlay(persona)' 'root["squad"].clone()' + +echo "== mutants killed: $PASS / $((PASS+FAIL)) ==" +if [ "$FAIL" -ne 0 ]; then + printf 'SURVIVORS:\n'; printf ' - %s\n' "${SURVIVORS[@]}" + exit 1 +fi +echo "all mutants killed" diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 714d6b2..8ce1488 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -1025,4 +1025,42 @@ fn unrelated_route_still_reaches_python() { assert_eq!(resp.status, 200); assert!(resp.headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("x-from-python"))); assert_eq!(rec.lock().len(), 1, "reached Python"); +} + +#[test] +fn duplicate_definition_instances_stay_distinct_through_host() { + // Two owned copies of ONE definition (card_a): distinct owned ids and wire + // ids, one shared resourceId — must not collapse anywhere in the host. + let a = item("oc-a", "card_a", 87, "GK", "Argentina", "Premier League", "Chelsea"); + let c = item("oc-c", "card_a", 87, "ST", "Argentina", "Premier League", "Chelsea"); + let items = vec![a, c]; + let (resolver, w) = resolver_with_wires(&items, &[("card_a", 20801)]); + assert_ne!(w["oc-a"], w["oc-c"], "two copies get distinct wire ids"); + let core = FakeCore::new(items.clone(), 2); + let ent = entities(); + let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent }; + let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-c"], 7)], "[]"); + let (put, _) = handle_put_squad(&body, &deps); + assert_eq!(put.status, 200); + + let py = json!({"userInfo": {"personaId": 1}, "squad": {"players": []}}); + let (url, _r) = spawn_mock_python_json(py); + let pass = PassClient::new(&url); + let (umi, _) = + handle_user_mass_info("GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass); + let v: Value = serde_json::from_slice(&umi.body).unwrap(); + let occ: Vec<&Value> = v["squad"]["players"] + .as_array() + .unwrap() + .iter() + .filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0) + .collect(); + assert_eq!(occ.len(), 2); + assert_ne!( + occ[0]["itemData"]["id"], occ[1]["itemData"]["id"], + "two copies of one definition keep distinct wire ids" + ); + for p in &occ { + assert_eq!(p["itemData"]["resourceId"], 20801, "shared asset id"); + } } \ No newline at end of file