#!/usr/bin/env python3 """ISOLATED staging harness for the Core SOLD-settlement path. Exercises the REAL `openfut-core` binary over REAL HTTP (`POST /economy/settle-sale`) against a THROWAWAY SQLite database in a fresh temp directory, so the transfer-market sold path can be validated with no FIFA client, no UTAS host, and no production state. Isolation guarantees (all enforced below, not merely documented): * The database is created by `tempfile.mkdtemp()` and deleted on exit (`--keep` opts out). * The listen port is chosen by binding 127.0.0.1:0 and reading the port back; the PRODUCTION ports 8099 (utas-host), 8199 (oracle) and 18080 (prod Core) are in a hard deny-list and are never bound or contacted. * Nothing under /home/alex/openfut-promotion/state/ (the live DBs) is read or written. * Core runs with an explicit LISTEN_ADDR / DATABASE_URL / DATA_DIR and cwd set to the temp directory, so no relative path can escape into the repo or a live database. Canonical two-party fixture reproduced here: seller 1,000 coins owning `item-x`; buyer 20,000; gross 15,000; fee 750. Expected: buyer 20,000 -> 5,000; seller 1,000 -> 15,250; owner seller -> buyer; exactly ONE row for `item-x`; modelled coins 21,000 -> 20,250 (delta == fee). Usage: python3 scripts/settlement-staging.py [--keep] [--no-build] Exit code 0 iff every assertion passes. """ from __future__ import annotations import argparse import http.client import json import os import shutil import socket import sqlite3 import subprocess import sys import tempfile import time import urllib.error import urllib.request # --- fixed facts about the repo (read from openfut-core/src/{main,config}.rs) ------- # Config::from_env(): LISTEN_ADDR, DATABASE_URL, DATA_DIR, DB_MAX_CONNECTIONS. # Plain `openfut-core` (no subcommand) runs its own migrations, then serves axum. REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) CORE_BIN = os.path.join(REPO_ROOT, "target", "release", "openfut-core") CORE_DATA_DIR = os.path.join(REPO_ROOT, "openfut-core", "data") # Ports owned by the live production test. Never bind, never speak to. FORBIDDEN_PORTS = frozenset({8099, 8199, 18080}) GAME = "fifa17" TS = "2026-01-01T00:00:00Z" SELLER_PROFILE, SELLER_CLUB = "prof-seller", "club-seller" BUYER_PROFILE, BUYER_CLUB = "prof-buyer", "club-buyer" ITEM_ID, CARD_ID = "item-x", "def-x" SELLER_START, BUYER_START = 1_000, 20_000 GROSS, FEE = 15_000, 750 PROCEEDS = GROSS - FEE # `app::build` runs a content preflight that rejects any owned card whose card_id is # not a loaded CardDefinition, so the fixture's definition ships as a one-entry # production content pack (shape: openfut-core/src/models/card.rs::CardDefinition). CARD_PACK = [ { "id": CARD_ID, "name": "Staging Fixture", "overall": 82, "position": "ST", "nation": "Testland", "league": "Staging League", "club": "Fixture FC", "pace": 80, "shooting": 80, "passing": 80, "dribbling": 80, "defending": 40, "physical": 75, "rarity": "gold", "image_path": None, } ] READY_TIMEOUT_S = 30.0 # --- output helpers ----------------------------------------------------------------- def banner(title: str) -> None: print() print("=" * 72) print(f"== {title}") print("=" * 72) class Checks: """Every expectation is printed where it happens AND tallied, so one failure never hides the rest and the RESULT block stays a summary.""" def __init__(self) -> None: self.results: list[tuple[str, bool, str]] = [] def _record(self, label: str, ok: bool, detail: str) -> bool: self.results.append((label, ok, detail)) print(f" [{'PASS' if ok else 'FAIL'}] {label}: {detail}") return ok def expect(self, label: str, actual, expected) -> bool: return self._record( label, actual == expected, f"expected {expected!r}, got {actual!r}" ) def expect_true(self, label: str, ok: bool, detail: str) -> bool: return self._record(label, bool(ok), detail) def report(self) -> bool: failed = [label for label, ok, _ in self.results if not ok] print(f" {len(self.results) - len(failed)}/{len(self.results)} checks passed") for label, ok, detail in self.results: if not ok: print(f" [FAIL] {label}: {detail}") return not failed # --- port / process plumbing -------------------------------------------------------- def pick_free_port() -> int: """Bind 127.0.0.1:0, read the port back, release it. Production ports refused.""" for _ in range(64): with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] if port not in FORBIDDEN_PORTS and port > 1024: return port raise RuntimeError("could not obtain a free non-production port") def build_core() -> None: print("building openfut-core (release)...") subprocess.run( ["cargo", "build", "-p", "openfut-core", "--release"], cwd=REPO_ROOT, check=True, ) def pack_path(workdir: str) -> str: return os.path.join(workdir, "content-pack.json") def write_content_pack(workdir: str) -> str: path = pack_path(workdir) with open(path, "w") as fh: json.dump(CARD_PACK, fh, indent=2) return path def launch_core(db_path: str, port: int, workdir: str, log_path: str, label: str): """Start the real Core binary against the throwaway DB. Core runs its migrations.""" if port in FORBIDDEN_PORTS: raise RuntimeError(f"refusing to bind production port {port}") env = dict(os.environ) env.update( { "LISTEN_ADDR": f"127.0.0.1:{port}", "DATABASE_URL": f"sqlite://{db_path}", "DATA_DIR": CORE_DATA_DIR, "OPENFUT_CONTENT_PACKS": pack_path(workdir), "RUST_LOG": "openfut_core=info", } ) log = open(log_path, "ab", buffering=0) log.write(f"\n---- {label} on 127.0.0.1:{port} ----\n".encode()) proc = subprocess.Popen( [CORE_BIN], cwd=workdir, # temp dir: no relative path can reach a real database env=env, stdout=log, stderr=log, ) proc._log = log # type: ignore[attr-defined] return proc def wait_ready(proc, port: int, log_path: str) -> None: deadline = time.monotonic() + READY_TIMEOUT_S last = "" while time.monotonic() < deadline: if proc.poll() is not None: raise RuntimeError( f"Core exited early with code {proc.returncode}\n{tail(log_path)}" ) try: conn = http.client.HTTPConnection("127.0.0.1", port, timeout=2) conn.request("GET", "/health") resp = conn.getresponse() resp.read() conn.close() if resp.status == 200: return last = f"/health -> HTTP {resp.status}" except OSError as exc: last = f"{type(exc).__name__}: {exc}" time.sleep(0.1) raise RuntimeError( f"Core on 127.0.0.1:{port} not ready after {READY_TIMEOUT_S:.0f}s " f"(last: {last})\n{tail(log_path)}" ) def stop_core(proc) -> None: if proc is None or proc.poll() is not None: return proc.terminate() try: proc.wait(timeout=10) except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=10) finally: log = getattr(proc, "_log", None) if log is not None: log.close() def tail(log_path: str, lines: int = 25) -> str: try: with open(log_path, "r", errors="replace") as fh: body = fh.read().splitlines() except OSError: return "(no core log)" return "--- core log tail ---\n" + "\n".join(body[-lines:]) # --- database ----------------------------------------------------------------------- def connect(db_path: str) -> sqlite3.Connection: conn = sqlite3.connect(db_path, timeout=10) conn.execute("PRAGMA busy_timeout = 10000") return conn def seed(db_path: str) -> None: """Direct-SQL fixture. Column sets match openfut-core/migrations/0001_initial.sql (+ 0016 game_dimension's profiles.game_id). Two profiles share one game_id, which services::profile::create_profile would refuse -- that limit is a service rule, not a schema constraint, and the settle route never resolves the active profile when both club ids are named explicitly. """ conn = connect(db_path) try: with conn: conn.executemany( "INSERT INTO profiles (id, username, created_at, updated_at, game_id) " "VALUES (?, ?, ?, ?, ?)", [ (SELLER_PROFILE, "seller", TS, TS, GAME), (BUYER_PROFILE, "buyer", TS, TS, GAME), ], ) conn.executemany( "INSERT INTO clubs (id, profile_id, name, coins, created_at, updated_at) " "VALUES (?, ?, ?, ?, ?, ?)", [ (SELLER_CLUB, SELLER_PROFILE, "Seller FC", SELLER_START, TS, TS), (BUYER_CLUB, BUYER_PROFILE, "Buyer FC", BUYER_START, TS, TS), ], ) conn.execute( "INSERT INTO owned_cards (id, club_id, card_id, is_loan, acquired_at) " "VALUES (?, ?, ?, 0, ?)", (ITEM_ID, SELLER_CLUB, CARD_ID, TS), ) finally: conn.close() def snapshot(db_path: str) -> dict: conn = connect(db_path) try: cur = conn.cursor() seller = cur.execute( "SELECT coins FROM clubs WHERE id = ?", (SELLER_CLUB,) ).fetchone()[0] buyer = cur.execute( "SELECT coins FROM clubs WHERE id = ?", (BUYER_CLUB,) ).fetchone()[0] owners = [ row[0] for row in cur.execute( "SELECT club_id FROM owned_cards WHERE id = ?", (ITEM_ID,) ) ] total = cur.execute("SELECT COALESCE(SUM(coins), 0) FROM clubs").fetchone()[0] return { "seller_coins": seller, "buyer_coins": buyer, "owner": owners[0] if owners else None, "item_rows": len(owners), "total_coins": total, } finally: conn.close() def print_snapshot(title: str, snap: dict, extra: dict | None = None) -> None: banner(title) print(f" seller club {SELLER_CLUB!r:>14} coins : {snap['seller_coins']:>8,}") print(f" buyer club {BUYER_CLUB!r:>14} coins : {snap['buyer_coins']:>8,}") print(f" owner of {ITEM_ID!r:>17} : {snap['owner']}") print(f" rows in owned_cards for {ITEM_ID!r} : {snap['item_rows']}") print(f" total modelled coins (SUM clubs) : {snap['total_coins']:>8,}") for key, value in (extra or {}).items(): print(f" {key:<32} : {value}") # --- HTTP --------------------------------------------------------------------------- def post_settle(port: int, body: dict) -> tuple[int, str]: if port in FORBIDDEN_PORTS: raise RuntimeError(f"refusing to POST to production port {port}") req = urllib.request.Request( f"http://127.0.0.1:{port}/economy/settle-sale", data=json.dumps(body).encode(), headers={"Content-Type": "application/json", "X-OpenFUT-Game": GAME}, method="POST", ) try: with urllib.request.urlopen(req, timeout=15) as resp: return resp.status, resp.read().decode(errors="replace") except urllib.error.HTTPError as exc: # 4xx/5xx carry the rejection body return exc.code, exc.read().decode(errors="replace") def show_response(status: int, body: str) -> dict | None: print(f" HTTP {status}") try: parsed = json.loads(body) except json.JSONDecodeError: print(f" body (not JSON): {body!r}") return None print(" body:") for line in json.dumps(parsed, indent=2, sort_keys=True).splitlines(): print(f" {line}") return parsed # --- the run ------------------------------------------------------------------------ def run(port: int, db_path: str, workdir: str, log_path: str, checks: Checks) -> None: settle_body = { "item_id": ITEM_ID, "gross": GROSS, "fee": FEE, "seller_club_id": SELLER_CLUB, "buyer_club_id": BUYER_CLUB, } print(f" content pack : {write_content_pack(workdir)} (defines {CARD_ID})") banner("MIGRATE (real Core creates the throwaway schema)") # Core owns its schema (sqlx migrations, embedded at compile time), so the # fixture cannot be seeded into an empty file. Start Core once purely to # migrate, stop it, THEN seed: the external writer and Core's WAL pool never # coexist, which is the safest ordering. migrator = launch_core(db_path, port, workdir, log_path, "migrate pass") try: wait_ready(migrator, port, log_path) print(f" migrations applied; Core answered /health on 127.0.0.1:{port}") finally: stop_core(migrator) print(" migrate pass stopped") banner("SEED (canonical two-party fixture, direct SQL)") seed(db_path) print(f" {SELLER_CLUB}: {SELLER_START:,} coins, owns {ITEM_ID} ({CARD_ID})") print(f" {BUYER_CLUB}: {BUYER_START:,} coins") banner(f"LAUNCH Core on 127.0.0.1:{port} (db {db_path})") server = launch_core(db_path, port, workdir, log_path, "serve pass") try: wait_ready(server, port, log_path) print(f" ready: GET /health -> 200 (pid {server.pid})") before = snapshot(db_path) print_snapshot("BEFORE", before) banner("PURCHASE POST /economy/settle-sale") print(f" request: {json.dumps(settle_body)}") status, body = post_settle(port, settle_body) receipt = show_response(status, body) after = snapshot(db_path) print_snapshot( "AFTER", after, { "fee withheld and destroyed": f"{FEE:,}", f"DUPLICATE COUNT for {ITEM_ID!r}": after["item_rows"], "coins destroyed (before-after)": f"{before['total_coins'] - after['total_coins']:,}", }, ) banner("EXPECTATIONS") checks.expect("purchase HTTP status", status, 200) checks.expect("buyer coins", after["buyer_coins"], BUYER_START - GROSS) checks.expect("seller coins", after["seller_coins"], SELLER_START + PROCEEDS) checks.expect("item owner is buyer club", after["owner"], BUYER_CLUB) checks.expect("duplicate count == 1", after["item_rows"], 1) checks.expect( "total coins before", before["total_coins"], SELLER_START + BUYER_START ) checks.expect( "total coins after", after["total_coins"], SELLER_START + BUYER_START - FEE, ) buyer_debit = before["buyer_coins"] - after["buyer_coins"] seller_credit = after["seller_coins"] - before["seller_coins"] checks.expect_true( "conservation buyer_debit == seller_credit + fee", buyer_debit == seller_credit + FEE, f"{buyer_debit:,} == {seller_credit:,} + {FEE:,}", ) if receipt is None: checks.expect_true("receipt is JSON", False, "response body was not JSON") else: checks.expect("receipt.item_id", receipt.get("item_id"), ITEM_ID) checks.expect("receipt.card_id", receipt.get("card_id"), CARD_ID) checks.expect( "receipt.seller_club_id", receipt.get("seller_club_id"), SELLER_CLUB ) checks.expect( "receipt.buyer_club_id", receipt.get("buyer_club_id"), BUYER_CLUB ) checks.expect("receipt.gross", receipt.get("gross"), GROSS) checks.expect("receipt.fee", receipt.get("fee"), FEE) checks.expect("receipt.proceeds", receipt.get("proceeds"), PROCEEDS) checks.expect( "receipt.seller_balance", receipt.get("seller_balance"), after["seller_coins"], ) checks.expect( "receipt.buyer_balance", receipt.get("buyer_balance"), after["buyer_coins"], ) checks.expect( "receipt.squad_slots_freed", receipt.get("squad_slots_freed"), 0 ) banner("RETRY (identical request must be rejected, nothing may move)") retry_status, retry_body = post_settle(port, settle_body) show_response(retry_status, retry_body) replay = snapshot(db_path) print_snapshot("AFTER RETRY", replay) checks.expect_true( "retry rejected (4xx)", 400 <= retry_status < 500, f"HTTP {retry_status}", ) checks.expect("retry left buyer coins", replay["buyer_coins"], after["buyer_coins"]) checks.expect( "retry left seller coins", replay["seller_coins"], after["seller_coins"] ) checks.expect("retry left owner", replay["owner"], after["owner"]) checks.expect("retry left total coins", replay["total_coins"], after["total_coins"]) checks.expect("retry left one row", replay["item_rows"], 1) # The identical retry above is refused by the AFFORDABILITY guard, because # settle_sale debits before it touches ownership and the buyer no longer holds # 15,000. That alone never exercises the ownership CAS that is the actual # replay guard, so replay the same item at a price the buyer CAN afford: the # only thing left to stop it is "item not owned by the named seller". banner("REPLAY GUARD (affordable re-settle must still fail on ownership)") cheap = dict(settle_body, gross=100, fee=5) print(f" request: {json.dumps(cheap)}") guard_status, guard_body = post_settle(port, cheap) show_response(guard_status, guard_body) guarded = snapshot(db_path) print_snapshot("AFTER REPLAY GUARD", guarded) checks.expect("replay guard rejects with 404", guard_status, 404) checks.expect_true( "replay guard cites ownership", "not owned" in guard_body, f"body: {guard_body}", ) checks.expect( "replay guard left buyer coins", guarded["buyer_coins"], after["buyer_coins"] ) checks.expect( "replay guard left seller coins", guarded["seller_coins"], after["seller_coins"], ) checks.expect("replay guard left owner", guarded["owner"], after["owner"]) checks.expect( "replay guard left total coins", guarded["total_coins"], after["total_coins"], ) checks.expect("replay guard left one row", guarded["item_rows"], 1) finally: stop_core(server) print("\n Core stopped") def main() -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument( "--keep", action="store_true", help="keep the temp dir (and its DB + core log)" ) parser.add_argument( "--no-build", action="store_true", help="use target/release/openfut-core as-is" ) args = parser.parse_args() if not args.no_build: build_core() if not os.path.isfile(CORE_BIN): print(f"core binary not found: {CORE_BIN}", file=sys.stderr) return 2 port = pick_free_port() workdir = tempfile.mkdtemp(prefix="openfut-settlement-staging-") db_path = os.path.join(workdir, "staging.db") log_path = os.path.join(workdir, "core.log") banner("SETTLEMENT STAGING HARNESS (isolated; production untouched)") print(f" temp dir : {workdir}") print(f" throwaway db : {db_path}") print(f" core binary : {CORE_BIN}") print(f" port : {port} (production {sorted(FORBIDDEN_PORTS)} never bound)") checks = Checks() try: run(port, db_path, workdir, log_path, checks) except Exception as exc: # report, then still clean up banner("HARNESS ERROR") print(f" {type(exc).__name__}: {exc}") checks.expect_true("harness completed", False, f"{type(exc).__name__}: {exc}") finally: if args.keep: print(f"\n --keep: leaving {workdir} in place") else: shutil.rmtree(workdir, ignore_errors=True) print(f"\n removed {workdir}") banner("RESULT") ok = checks.report() print() print(f" {'ALL CHECKS PASSED' if ok else 'FAILURES PRESENT'}") return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())