test(fifa17): smoke-test the apply probe and prove the gate fails closed

Unit tests cover classification and body parsing; they do not prove the running
host behaves. These three scripts exercise the real service, and they found
nothing broken but make the two load-bearing claims checkable:

fifa17-apply-snapshot.py   Core truth around an apply: coins, owned rows, kind
                           histogram, the source stack's copy count, and the
                           target's mutable fields (contract/fitness/playStyle/
                           training/injury). Coins and ownership come from the
                           staging DB, not the wire, so the check cannot be
                           satisfied by a projection bug.

fifa17-apply-probe-smoke.py  Replays the EXACT captured request plus the edges,
                           against the live host, no client needed:
                             1. {"apply":[{"id":100000003}]} -> 200 {"itemData":[]}
                                source=Consumable subtype=201 copies=1,
                                target=fifa17_200389 rating=87
                             2. two targets            -> 400 apply_batch_unsupported
                             3. unknown target wire id -> 200 UNRESOLVED_WIRE_ID
                             4. unowned source         -> 200 NOT_OWNED
                           then re-snapshots: Core identical after all four.

fifa17-apply-gate-off.py   The production-safety claim. With APPLY_PROBE unset
                           the same request must produce the pre-probe
                           behaviour, and does: no apply-probe line, three
                           passthrough lines, 502 into the dead upstream, Core
                           unchanged. Verified by restarting staging without the
                           flag -- an assertion about failing closed is worth
                           nothing unless the closed path is executed.

Nothing here writes to production; snapshot reads the staging DB read-only.
This commit is contained in:
funman300
2026-08-22 00:54:24 +00:00
parent 9f445904a5
commit 8cb2a0f9c6
3 changed files with 158 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""With OPENFUT_FIFA17_APPLY_PROBE unset the apply MUST fall through to the
Python passthrough -- i.e. exactly the behaviour that existed before the probe.
That is the production-safety claim, so prove it rather than assert it."""
import subprocess
import urllib.error
import urllib.request
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
mark = sum(1 for _ in open(LOG))
before = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
req = urllib.request.Request(
"http://127.0.0.1:8299/ut/game/fifa17/item/resource/5001004",
data=b'{"apply":[{"id":100000003}]}',
headers={"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"},
method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
st, body = r.status, r.read().decode()
except urllib.error.HTTPError as e:
st, body = e.code, e.read().decode()
print(" status=%s body=%s" % (st, body))
print("--- log with the gate OFF ---")
lines = list(open(LOG))[mark:]
for line in lines:
if any(k in line for k in ("apply-probe", "passthrough", "PYTHON")):
print(" " + line.rstrip()[:150])
probe_served = any("apply-probe" in line for line in lines)
went_python = any("owner=PYTHON" in line for line in lines)
after = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
print("\n probe served (must be False): %s" % probe_served)
print(" proxied to Python (must be True): %s" % went_python)
print(" Core unchanged: %s" % (before == after))
print("\nRESULT: %s" % ("OK -- gate fails closed"
if (not probe_served and went_python and before == after)
else "FAILED"))
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Smoke-test the consumable-apply probe against the RUNNING staging host.
Replays the exact request the client sent, plus the batch and unknown-target
edges, then proves Core is byte-identical afterwards. No client needed.
"""
import json
import subprocess
import urllib.error
import urllib.request
BASE = "http://127.0.0.1:8299"
PATH = "/ut/game/fifa17/item/resource/5001004"
H = {"X-OpenFUT-Game": "fifa17", "Content-Type": "application/json"}
LOG = "/home/alex/openfut-sold-staging/logs/utas-host.log"
def post(path, payload):
req = urllib.request.Request(
BASE + path, data=payload, headers=H, method="POST")
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, r.read().decode()
except urllib.error.HTTPError as e:
return e.code, e.read().decode()
before = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
mark = sum(1 for _ in open(LOG))
print("=== 1. the EXACT request captured from the client ===")
st, body = post(PATH, b'{"apply":[{"id":100000003}]}')
print(" status=%s body=%s" % (st, body))
ok1 = st == 200 and json.loads(body) == {"itemData": []}
print("\n=== 2. batch: semantics unproven, must be REFUSED not guessed ===")
st2, body2 = post(PATH, b'{"apply":[{"id":100000003},{"id":100000004}]}')
print(" status=%s body=%s" % (st2, body2[:90]))
ok2 = st2 == 400 and "apply_batch_unsupported" in body2
print("\n=== 3. unknown target: observed, never invented ===")
st3, body3 = post(PATH, b'{"apply":[{"id":999999999}]}')
print(" status=%s body=%s" % (st3, body3))
ok3 = st3 == 200
print("\n=== 4. unowned source resource ===")
st4, body4 = post("/ut/game/fifa17/item/resource/1234567", b'{"apply":[{"id":100000003}]}')
print(" status=%s body=%s" % (st4, body4))
ok4 = st4 == 200
print("\n=== host log ===")
for line in list(open(LOG))[mark:]:
if "apply-probe" in line:
print(" " + line.rstrip()[:190])
after = subprocess.run(["python3", "/home/alex/OpenFUT/scripts/fifa17-apply-snapshot.py"],
capture_output=True, text=True).stdout
same = before == after
print("\n=== 5. Core unchanged by all four requests: %s ===" % ("YES" if same else "NO"))
if not same:
for b, a in zip(before.splitlines(), after.splitlines()):
if b != a:
print(" BEFORE %s\n AFTER %s" % (b.strip(), a.strip()))
print("\nRESULT: %s" % ("OK" if all([ok1, ok2, ok3, ok4, same]) else "FAILED"))
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Core snapshot around the consumable-apply probe: coins, ownership, the source
consumable's copies, and the target's mutable state. Run before and after; the
probe must change NOTHING."""
import json
import sys
import urllib.request
H = {"X-OpenFUT-Game": "fifa17"}
SOURCE_RES = 5001004
TARGET_WIRE = 100000003
def get(p):
req = urllib.request.Request("http://127.0.0.1:8299" + p, headers=H)
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())
snap = {}
import sqlite3
con = sqlite3.connect("file:/home/alex/openfut-sold-staging/staging-core.db?mode=ro", uri=True)
snap["coins"] = con.execute("SELECT coins FROM clubs LIMIT 1").fetchone()[0]
snap["owned_rows"] = con.execute("SELECT COUNT(*) FROM owned_cards").fetchone()[0]
snap["by_kind"] = dict(con.execute("SELECT content_kind, COUNT(*) FROM owned_cards GROUP BY 1"))
con.close()
cons = get("/ut/game/fifa17/club/consumables/development")
stacks = cons.get("itemData") or []
snap["development_stacks"] = len(stacks)
snap["development_copies"] = sum(s.get("count", 0) for s in stacks)
snap["source_stack"] = next(
({"count": s.get("count"), "resourceId": s.get("resourceId")}
for s in stacks if s.get("resourceId") == SOURCE_RES), None)
sq = get("/ut/game/fifa17/squad/0")
players = (sq.get("squad") or sq).get("players") or []
for p in players:
it = p.get("itemData") or {}
if it.get("id") == TARGET_WIRE:
snap["target"] = {
"resourceId": it.get("resourceId"), "rating": it.get("rating"),
"contract": it.get("contract"), "fitness": it.get("fitness"),
"injuryType": it.get("injuryType"), "training": it.get("training"),
"playStyle": it.get("playStyle"), "preferredPosition": it.get("preferredPosition"),
}
break
print(json.dumps(snap, indent=2, sort_keys=True))
if len(sys.argv) > 1:
open(sys.argv[1], "w").write(json.dumps(snap, sort_keys=True))