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:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user