From 76512f6048ae6ffc92c5377a1292371d7183fb7b Mon Sep 17 00:00:00 2001 From: OpenFUT Agent Date: Thu, 13 Aug 2026 22:39:45 +0000 Subject: [PATCH] test(fifa17): prove post-barrier economy authority (never-both / no-fallback / stale-reader) barrier_never_both_no_fallback_and_stale_reader drives the REAL post-barrier handle_with_ip against a live in-process Core + a mock Python upstream that counts every request and answers with a coins=111 marker: - STALE READER: credits + userMassInfo show the Core balance, never 111 (userMassInfo proxies the Python envelope but the Rust economy overlay wins). - NEVER BOTH (Core up): every pure economy route returns a Rust body (no __python__ marker) and the Python proxy call-count stays 0. - NO FALLBACK: a server pointed at a dead Core port (built without probing Core: empty catalog + empty pool) fails closed (credits/match -> 503) and STILL never proxies to Python (call-count unchanged). Also parametrizes build_econ_server's pass URL so the mock upstream can be injected. host 71 lib + 4 integration + differential + concurrency + failure + 24 host_test all green; clippy -D warnings + fmt clean. --- .../tests/economy_integration.rs | 259 +++++++++++++++++- 1 file changed, 257 insertions(+), 2 deletions(-) diff --git a/openfut-utas-host/tests/economy_integration.rs b/openfut-utas-host/tests/economy_integration.rs index 1d06449..2693373 100644 --- a/openfut-utas-host/tests/economy_integration.rs +++ b/openfut-utas-host/tests/economy_integration.rs @@ -274,6 +274,7 @@ struct SeqResult { fn build_econ_server( base: &str, dir: &std::path::Path, + pass_url: &str, ) -> (Server, HttpCoreClient, Arc, i64) { let probe = HttpCoreClient::new(base, "fifa17"); let owned = probe.all_owned().expect("core collection"); @@ -334,7 +335,7 @@ fn build_econ_server( core, entities, resolver.clone(), - Arc::new(PassClient::new("http://127.0.0.1:9")), + Arc::new(PassClient::new(pass_url)), 33068179, ) .with_economy(services); @@ -349,7 +350,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult { wait_ready(base); // Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one // owned instance per definition. No /auth/local — the profile already exists. - let (server, client, resolver, sample_resource) = build_econ_server(base, dir); + let (server, client, resolver, sample_resource) = + build_econ_server(base, dir, "http://127.0.0.1:9"); let start = client.balance().unwrap(); assert!(start >= 5000, "seeded dev balance present ({start})"); @@ -889,3 +891,256 @@ async fn from_config_constructs_and_serves_economy() { std::fs::remove_dir_all(&dir).ok(); } + +// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ──── +// stale reader), through the REAL handle_with_ip dispatch ────── + +/// A mock Python UTAS upstream that COUNTS every request it receives and always +/// answers with a distinctive marker body carrying coins=111. If an economy +/// route ever reaches Python, this counter moves and/or the marker leaks. +struct MockPython { + calls: Arc, + url: String, +} + +fn start_mock_python() -> MockPython { + use std::io::{Read, Write}; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let c2 = calls.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut s) = stream else { continue }; + c2.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let mut buf = [0u8; 8192]; + let _ = s.read(&mut buf); + let body = br#"{"__python__":true,"credits":111,"currencies":[{"name":"coins","funds":111,"finalFunds":111}],"userInfo":{"currencies":[{"name":"coins","funds":111,"finalFunds":111}]},"purchase":[]}"#; + let head = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _ = s.write_all(head.as_bytes()); + let _ = s.write_all(body); + } + }); + MockPython { + calls, + url: format!("http://{addr}"), + } +} + +/// The pure economy routes (userMassInfo excluded — it is the documented hybrid +/// that proxies the Python envelope but Rust-overlays the economy fields). +fn pure_economy_routes() -> Vec<(&'static str, String, Vec)> { + vec![ + ("GET", "/ut/game/fifa17/user/credits".into(), b"".to_vec()), + ( + "GET", + "/ut/game/fifa17/store/purchasegroup".into(), + b"".to_vec(), + ), + ( + "PUT", + "/ut/game/fifa17/store/transaction".into(), + br#"{"packId":1}"#.to_vec(), + ), + ( + "POST", + "/ut/game/fifa17/purchased".into(), + br#"{"packId":70}"#.to_vec(), + ), + ("GET", "/ut/game/fifa17/purchased".into(), b"".to_vec()), + ( + "DELETE", + "/ut/game/fifa17/item/100000001".into(), + b"".to_vec(), + ), + ( + "POST", + "/ut/delete/game/fifa17/item".into(), + br#"{"itemData":[{"id":100000001}]}"#.to_vec(), + ), + ( + "PUT", + "/ut/game/fifa17/item".into(), + br#"{"itemData":[{"id":100000001,"pile":"trade"}]}"#.to_vec(), + ), + ( + "POST", + "/ut/delete/game/fifa17/match".into(), + br#"{"endReason":"WIN"}"#.to_vec(), + ), + ( + "POST", + "/ut/game/fifa17/auctionhouse".into(), + br#"{"itemData":{"id":555,"resourceId":20000},"buyNowPrice":1000,"startingBid":500}"# + .to_vec(), + ), + ("GET", "/ut/game/fifa17/tradePile".into(), b"".to_vec()), + ( + "POST", + "/ut/game/fifa17/trade/900000001".into(), + b"{}".to_vec(), + ), + ( + "DELETE", + "/ut/delete/game/fifa17/trade/900000001".into(), + b"".to_vec(), + ), + ] +} + +fn barrier_checks(base: &str, dir: &std::path::Path, mock: &MockPython) { + wait_ready(base); + let (server, client, _r, _sample) = build_econ_server(base, dir, &mock.url); + let core_coins = client.balance().unwrap(); + assert_ne!( + core_coins, 111, + "Core must diverge from the Python marker (111)" + ); + + // ── STALE READER: readers show Core values, never the Python 111 ── + let cr = server.handle_with_ip("GET", "/ut/game/fifa17/user/credits", &[], b"", None); + let crv: Value = serde_json::from_slice(&cr.body).unwrap(); + assert!( + crv.get("__python__").is_none(), + "credits is Rust, not the Python body" + ); + assert_eq!( + crv["currencies"][0]["funds"], core_coins, + "credits coins = Core, not 111" + ); + // userMassInfo is the hybrid: Python envelope proxied, economy Rust-overlaid. + let mi = server.handle_with_ip("GET", "/ut/game/fifa17/userMassInfo", &[], b"", None); + let miv: Value = serde_json::from_slice(&mi.body).unwrap(); + assert_eq!( + miv["userInfo"]["currencies"][0]["funds"], core_coins, + "userMassInfo coins overlaid to Core (stale Python 111 not visible)" + ); + + // ── NEVER BOTH (Core up): pure economy routes reach Rust, never Python ── + let before = mock.calls.load(std::sync::atomic::Ordering::SeqCst); + for (m, p, b) in pure_economy_routes() { + let r = server.handle_with_ip(m, &p, &[], &b, None); + assert!( + !r.body.windows(10).any(|w| w == b"__python__"), + "{m} {p} must be Rust-owned (no Python marker in body)" + ); + } + assert_eq!( + mock.calls.load(std::sync::atomic::Ordering::SeqCst), + before, + "NEVER BOTH: no pure economy route reached the Python proxy" + ); + + // ── NO FALLBACK: a server pointed at a DEAD Core still fails closed and + // never proxies to Python. Built without probing Core (empty catalog + + // empty pool), so no live Core is needed to construct it. ── + let dead_dir = dir.join("dead"); + std::fs::create_dir_all(&dead_dir).unwrap(); + let dead = build_dead_core_server(&dead_dir, &mock.url); + let before_down = mock.calls.load(std::sync::atomic::Ordering::SeqCst); + let credits_down = dead.handle_with_ip("GET", "/ut/game/fifa17/user/credits", &[], b"", None); + assert_eq!( + credits_down.status, 503, + "credits fails closed against a dead Core" + ); + let match_down = dead.handle_with_ip( + "POST", + "/ut/delete/game/fifa17/match", + &[], + br#"{"endReason":"WIN"}"#, + None, + ); + assert_eq!( + match_down.status, 503, + "match fails closed against a dead Core" + ); + for (m, p, b) in pure_economy_routes() { + let _ = dead.handle_with_ip(m, &p, &[], &b, None); + } + assert_eq!( + mock.calls.load(std::sync::atomic::Ordering::SeqCst), + before_down, + "NO FALLBACK: economy routes never proxy to Python even against a dead Core" + ); +} + +/// A `Server` whose Core (read + economy) points at a definitely-dead loopback +/// port, wired WITHOUT probing Core: an empty catalog + empty content pool. Used +/// to prove economy routes fail closed (503) and never fall back to Python. +fn build_dead_core_server(dir: &std::path::Path, pass_url: &str) -> Server { + // A closed loopback port: bind then drop, so connects are refused. + let dead_addr = { + let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap() + }; + let dead_url = format!("http://{dead_addr}"); + let catalog = + Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa17","cards":{}}"#) + .unwrap(); + let store = JsonIdentityStore::open(dir.join("identity.json").to_str().unwrap()).unwrap(); + let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store))); + let entities = Arc::new(Fifa17Entities::from_maps( + HashMap::new(), + HashMap::new(), + HashMap::new(), + )); + let core: Arc = Arc::new(HttpCoreClient::new(dead_url.clone(), "fifa17")); + let bridge = Arc::new(AsyncBridge::new().unwrap()); + let mp = dir.join("market.db").to_string_lossy().into_owned(); + let market = Arc::new( + bridge + .block_on(async move { MarketStore::open(&mp).await }) + .unwrap(), + ); + let pp = dir.join("pile.db").to_string_lossy().into_owned(); + let piles = Arc::new( + bridge + .block_on(async move { PileStore::open(&pp).await }) + .unwrap(), + ); + let econ: Arc = Arc::new(HttpCoreClient::new(dead_url, "fifa17")); + let services = Arc::new(EconomyServices { + econ, + market, + piles, + bridge, + pool: Arc::new(Vec::new()), + }); + Server::new( + core, + entities, + resolver, + Arc::new(PassClient::new(pass_url)), + 33_068_179, + ) + .with_economy(services) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn barrier_never_both_no_fallback_and_stale_reader() { + let dir = std::env::temp_dir().join(format!( + "openfut-econ-barrier-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&dir).unwrap(); + let db_url = format!("sqlite://{}/econ.db", dir.display()); + let (h, base) = start_core_seeded(&db_url, true).await; + let mock = start_mock_python(); + let (b, d) = (base.clone(), dir.clone()); + tokio::task::spawn_blocking(move || { + std::thread::spawn(move || barrier_checks(&b, &d, &mock)) + .join() + .expect("barrier checks thread") + }) + .await + .expect("barrier phase"); + h.abort(); + std::fs::remove_dir_all(&dir).ok(); +}