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.
This commit is contained in:
OpenFUT Agent
2026-08-13 22:39:45 +00:00
parent 93a46d4de7
commit 76512f6048
+257 -2
View File
@@ -274,6 +274,7 @@ struct SeqResult {
fn build_econ_server(
base: &str,
dir: &std::path::Path,
pass_url: &str,
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, 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<std::sync::atomic::AtomicUsize>,
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<u8>)> {
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<dyn CoreAccess> = 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<dyn CoreEconomy> = 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();
}