test(fifa17): harden SBC retail acceptance

This commit is contained in:
funman300
2026-08-18 19:01:33 +00:00
parent f9740f640d
commit e8d1c1ddac
8 changed files with 650 additions and 25 deletions
+8
View File
@@ -250,5 +250,13 @@ mod tests {
parse_wire_item_ids(br#"{"challengeId":101}"#), parse_wire_item_ids(br#"{"challengeId":101}"#),
Err(SbcWireError::MissingSquad) Err(SbcWireError::MissingSquad)
); );
assert!(matches!(
parse_wire_item_ids(br#"{"squad":"not-an-array"}"#),
Err(SbcWireError::MissingSquad)
));
assert!(matches!(
parse_wire_item_ids(br#"{"squad":["#),
Err(SbcWireError::Json(_))
));
} }
} }
+130 -1
View File
@@ -3,6 +3,18 @@
//! collide with the live oracle). `core_url` defaults to Bridge's convention. //! collide with the live oracle). `core_url` defaults to Bridge's convention.
use std::env; use std::env;
const SBC_FAULT_ACK: &str = "staging-only-sbc-receipt-loss";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SbcPostCommitFault {
#[default]
Off,
Drop,
Malformed,
Delay {
millis: u64,
},
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct HostConfig { pub struct HostConfig {
@@ -44,6 +56,9 @@ pub struct HostConfig {
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the /// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
/// identity store's parent directory + `active_account.json`. /// identity store's parent directory + `active_account.json`.
pub account_path: String, pub account_path: String,
/// Disabled by default. Non-off values require three explicit staging guards;
/// see [`parse_sbc_post_commit_fault`].
pub sbc_post_commit_fault: SbcPostCommitFault,
} }
#[derive(Debug)] #[derive(Debug)]
@@ -76,9 +91,68 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
Ok(val) Ok(val)
} }
fn parse_sbc_post_commit_fault(
raw: Option<&str>,
environment: Option<&str>,
ack: Option<&str>,
listen_addr: &str,
) -> Result<SbcPostCommitFault, ConfigError> {
let mode = match raw.filter(|value| !value.is_empty()).unwrap_or("off") {
"off" => return Ok(SbcPostCommitFault::Off),
"drop" => SbcPostCommitFault::Drop,
"malformed" => SbcPostCommitFault::Malformed,
value if value.starts_with("delay:") => {
let millis = value["delay:".len()..].parse::<u64>().map_err(|_| {
ConfigError(
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be delay:<milliseconds>"
.into(),
)
})?;
if !(1..=30_000).contains(&millis) {
return Err(ConfigError(
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT delay must be 1..=30000 ms".into(),
));
}
SbcPostCommitFault::Delay { millis }
}
value => {
return Err(ConfigError(format!(
"OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT must be off, drop, malformed, or delay:<milliseconds>; got {value:?}"
)));
}
};
if environment != Some("staging") {
return Err(ConfigError(
"SBC post-commit faults require OPENFUT_ENVIRONMENT=staging".into(),
));
}
if ack != Some(SBC_FAULT_ACK) {
return Err(ConfigError(format!(
"SBC post-commit faults require OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK={SBC_FAULT_ACK}"
)));
}
if listen_addr.rsplit_once(':').map(|(_, port)| port) == Some("8099") {
return Err(ConfigError(
"SBC post-commit faults refuse the production UTAS port 8099".into(),
));
}
Ok(mode)
}
impl HostConfig { impl HostConfig {
pub fn from_env() -> Result<Self, ConfigError> { pub fn from_env() -> Result<Self, ConfigError> {
let identity_store_path = required("OPENFUT_IDENTITY_STORE")?; let identity_store_path = required("OPENFUT_IDENTITY_STORE")?;
let listen_addr = required("OPENFUT_UTAS_HOST_ADDR")?;
let sbc_post_commit_fault = parse_sbc_post_commit_fault(
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT")
.ok()
.as_deref(),
env::var("OPENFUT_ENVIRONMENT").ok().as_deref(),
env::var("OPENFUT_FIFA17_SBC_POST_COMMIT_FAULT_ACK")
.ok()
.as_deref(),
&listen_addr,
)?;
let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB") let clientdata_path = env::var("OPENFUT_CLIENTDATA_DB")
.ok() .ok()
.filter(|v| !v.is_empty()) .filter(|v| !v.is_empty())
@@ -89,7 +163,7 @@ impl HostConfig {
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty())) .or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
.unwrap_or_else(|| default_account_path(&identity_store_path)); .unwrap_or_else(|| default_account_path(&identity_store_path));
Ok(HostConfig { Ok(HostConfig {
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?, listen_addr,
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?, python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
core_url: env::var("OPENFUT_CORE_URL") core_url: env::var("OPENFUT_CORE_URL")
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()), .unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
@@ -102,6 +176,7 @@ impl HostConfig {
identity_store_path, identity_store_path,
clientdata_path, clientdata_path,
account_path, account_path,
sbc_post_commit_fault,
}) })
} }
} }
@@ -125,3 +200,57 @@ fn default_account_path(identity_store_path: &str) -> String {
.to_string_lossy() .to_string_lossy()
.into_owned() .into_owned()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sbc_post_commit_faults_require_all_staging_guards() {
assert_eq!(
parse_sbc_post_commit_fault(None, None, None, "0.0.0.0:8099").unwrap(),
SbcPostCommitFault::Off
);
assert!(parse_sbc_post_commit_fault(
Some("drop"),
None,
Some(SBC_FAULT_ACK),
"127.0.0.1:18199"
)
.is_err());
assert!(parse_sbc_post_commit_fault(
Some("drop"),
Some("staging"),
None,
"127.0.0.1:18199"
)
.is_err());
assert!(parse_sbc_post_commit_fault(
Some("drop"),
Some("staging"),
Some(SBC_FAULT_ACK),
"0.0.0.0:8099"
)
.is_err());
assert_eq!(
parse_sbc_post_commit_fault(
Some("drop"),
Some("staging"),
Some(SBC_FAULT_ACK),
"0.0.0.0:18199"
)
.unwrap(),
SbcPostCommitFault::Drop
);
assert_eq!(
parse_sbc_post_commit_fault(
Some("delay:25"),
Some("staging"),
Some(SBC_FAULT_ACK),
"0.0.0.0:18199"
)
.unwrap(),
SbcPostCommitFault::Delay { millis: 25 }
);
}
}
+161 -11
View File
@@ -83,7 +83,7 @@ use serde_json::{json, Value};
use account_store::{AccountStore, RenameOutcome}; use account_store::{AccountStore, RenameOutcome};
use clientdata_store::ClientDataStore; use clientdata_store::ClientDataStore;
use config::HostConfig; use config::{HostConfig, SbcPostCommitFault};
// ───────────────────────────── Route classification ───────────────────────── // ───────────────────────────── Route classification ─────────────────────────
@@ -1792,6 +1792,7 @@ fn error_response(status: u16, code: &str) -> WireResponse {
status, status,
headers: vec![("Content-Type".to_string(), "application/json".to_string())], headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: format!("{{\"error\":\"{code}\"}}").into_bytes(), body: format!("{{\"error\":\"{code}\"}}").into_bytes(),
transport: ResponseTransport::Normal,
} }
} }
@@ -2236,12 +2237,33 @@ pub fn handle_match_end(econ: &dyn CoreEconomy, body: &[u8]) -> WireResponse {
// ───────────────────────────── HTTP wire types ────────────────────────────── // ───────────────────────────── HTTP wire types ──────────────────────────────
/// A response ready to write: status, headers, body. /// A response ready to write: status, headers, body, and an internal
/// staging-only transport directive.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct WireResponse { pub struct WireResponse {
pub status: u16, pub status: u16,
pub headers: Vec<(String, String)>, pub headers: Vec<(String, String)>,
pub body: Vec<u8>, pub body: Vec<u8>,
transport: ResponseTransport,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ResponseTransport {
Normal,
Drop,
Malformed,
Delay { millis: u64 },
}
impl From<SbcPostCommitFault> for ResponseTransport {
fn from(value: SbcPostCommitFault) -> Self {
match value {
SbcPostCommitFault::Off => Self::Normal,
SbcPostCommitFault::Drop => Self::Drop,
SbcPostCommitFault::Malformed => Self::Malformed,
SbcPostCommitFault::Delay { millis } => Self::Delay { millis },
}
}
} }
fn json_response(body: &Value) -> WireResponse { fn json_response(body: &Value) -> WireResponse {
@@ -2250,6 +2272,7 @@ fn json_response(body: &Value) -> WireResponse {
status: 200, status: 200,
headers: vec![("Content-Type".to_string(), "application/json".to_string())], headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: bytes, body: bytes,
transport: ResponseTransport::Normal,
} }
} }
@@ -2323,6 +2346,7 @@ impl PassClient {
status, status,
headers: out, headers: out,
body: bytes, body: bytes,
transport: ResponseTransport::Normal,
}) })
} }
} }
@@ -2445,6 +2469,8 @@ pub struct Server {
/// Core supplies the database transaction; this gate closes the cross-store race /// Core supplies the database transaction; this gate closes the cross-store race
/// within one host process. /// within one host process.
economy_gate: Arc<Mutex<()>>, economy_gate: Arc<Mutex<()>>,
/// Staging-only simulation of losing the successful SBC submit receipt.
sbc_post_commit_fault: SbcPostCommitFault,
} }
impl Server { impl Server {
@@ -2468,6 +2494,7 @@ impl Server {
economy: None, economy: None,
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())), clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
economy_gate: Arc::new(Mutex::new(())), economy_gate: Arc::new(Mutex::new(())),
sbc_post_commit_fault: SbcPostCommitFault::Off,
} }
} }
@@ -2531,6 +2558,10 @@ impl Server {
let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone())); let clientdata = Arc::new(ClientDataStore::open(cfg.clientdata_path.clone()));
let account = Arc::new(AccountStore::open(cfg.account_path.clone())); let account = Arc::new(AccountStore::open(cfg.account_path.clone()));
eprintln!(
"utas-host sbc_post_commit_fault={:?}",
cfg.sbc_post_commit_fault
);
Ok(Server::new( Ok(Server::new(
core, core,
entities, entities,
@@ -2540,7 +2571,8 @@ impl Server {
) )
.with_economy(economy) .with_economy(economy)
.with_clientdata(clientdata) .with_clientdata(clientdata)
.with_account(account)) .with_account(account)
.with_sbc_post_commit_fault(cfg.sbc_post_commit_fault))
} }
/// Assemble the shared squad dependencies (Core access + the one production /// Assemble the shared squad dependencies (Core access + the one production
@@ -2575,6 +2607,11 @@ impl Server {
self self
} }
fn with_sbc_post_commit_fault(mut self, fault: SbcPostCommitFault) -> Self {
self.sbc_post_commit_fault = fault;
self
}
fn sbc_views(&self) -> Result<Vec<fifa17_sbc::ChallengeView>, CoreError> { fn sbc_views(&self) -> Result<Vec<fifa17_sbc::ChallengeView>, CoreError> {
let definitions = self.core.list_sbcs()?; let definitions = self.core.list_sbcs()?;
let completions = self.core.sbc_completion_counts()?; let completions = self.core.sbc_completion_counts()?;
@@ -2819,7 +2856,7 @@ impl Server {
Ok(packs) => packs.len() as i64, Ok(packs) => packs.len() as i64,
Err(error) => return core_sbc_error_response(error), Err(error) => return core_sbc_error_response(error),
}; };
json_status( let mut response = json_status(
200, 200,
&fifa17_sbc::submit_body( &fifa17_sbc::submit_body(
challenge_id, challenge_id,
@@ -2827,7 +2864,9 @@ impl Server {
credits, credits,
unopened_packs, unopened_packs,
), ),
) );
response.transport = self.sbc_post_commit_fault.into();
response
} }
_ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })), _ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })),
} }
@@ -3183,6 +3222,7 @@ impl Server {
"application/json".to_string(), "application/json".to_string(),
)], )],
body: br#"{"error":"upstream unavailable"}"#.to_vec(), body: br#"{"error":"upstream unavailable"}"#.to_vec(),
transport: ResponseTransport::Normal,
} }
} }
}; };
@@ -3603,6 +3643,7 @@ impl Server {
status: 204, status: 204,
headers: Vec::new(), headers: Vec::new(),
body: Vec::new(), body: Vec::new(),
transport: ResponseTransport::Normal,
}, },
"captcha" => json_status( "captcha" => json_status(
200, 200,
@@ -3770,10 +3811,11 @@ impl Server {
&req.body, &req.body,
peer_ip.as_deref(), peer_ip.as_deref(),
); );
if write_response(&mut writer, &resp).is_err() { let close = match write_response(&mut writer, &resp) {
return; Ok(close) => close,
} Err(_) => return,
if req.close { };
if close || req.close {
return; return;
} }
} }
@@ -3813,6 +3855,7 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
("Content-Length".to_string(), body.len().to_string()), ("Content-Length".to_string(), body.len().to_string()),
], ],
body, body,
transport: ResponseTransport::Normal,
} }
} }
@@ -3981,7 +4024,14 @@ fn reason(status: u16) -> &'static str {
} }
} }
fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<()> { fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<bool> {
match resp.transport {
ResponseTransport::Drop => return Ok(true),
ResponseTransport::Delay { millis } => {
std::thread::sleep(std::time::Duration::from_millis(millis));
}
ResponseTransport::Normal | ResponseTransport::Malformed => {}
}
let mut head = format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status)); let mut head = format!("HTTP/1.1 {} {}\r\n", resp.status, reason(resp.status));
for (k, v) in &resp.headers { for (k, v) in &resp.headers {
if is_hop_by_hop(k) { if is_hop_by_hop(k) {
@@ -3992,8 +4042,14 @@ fn write_response<W: Write>(w: &mut W, resp: &WireResponse) -> std::io::Result<(
head.push_str(&format!("Content-Length: {}\r\n", resp.body.len())); head.push_str(&format!("Content-Length: {}\r\n", resp.body.len()));
head.push_str("\r\n"); head.push_str("\r\n");
w.write_all(head.as_bytes())?; w.write_all(head.as_bytes())?;
if resp.transport == ResponseTransport::Malformed {
w.write_all(b"{")?;
w.flush()?;
return Ok(true);
}
w.write_all(&resp.body)?; w.write_all(&resp.body)?;
w.flush() w.flush()?;
Ok(false)
} }
#[cfg(test)] #[cfg(test)]
@@ -5326,4 +5382,98 @@ mod tests {
Some(1) Some(1)
); );
} }
fn submit_sbc_with_fault(fault: SbcPostCommitFault) -> (WireResponse, Arc<FakeSbcCore>) {
let (server, core, _services, _resolver, wires) = sbc_test_server();
let server = server.with_sbc_post_commit_fault(fault);
let save_body = serde_json::to_vec(&json!({
"squad": [
{ "index": 0, "itemData": { "id": wires[0] } },
{ "index": 1, "itemData": { "id": wires[1] } }
]
}))
.unwrap();
assert_eq!(
server
.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad",
&[],
&save_body,
)
.status,
200
);
let response = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/101", &[], br#"{}"#);
(response, core)
}
#[test]
fn sbc_post_commit_fault_modes_lose_only_the_receipt() {
let (normal_response, normal_core) = submit_sbc_with_fault(SbcPostCommitFault::Off);
let mut normal = Vec::new();
assert!(!write_response(&mut normal, &normal_response).unwrap());
assert!(normal.ends_with(&normal_response.body));
assert_eq!(
normal_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
let (drop_response, drop_core) = submit_sbc_with_fault(SbcPostCommitFault::Drop);
assert_eq!(drop_response.status, 200);
assert_eq!(
drop_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1),
"Core commit precedes the simulated connection drop"
);
let mut dropped = Vec::new();
assert!(write_response(&mut dropped, &drop_response).unwrap());
assert!(dropped.is_empty(), "drop mode writes no receipt bytes");
let (malformed_response, malformed_core) =
submit_sbc_with_fault(SbcPostCommitFault::Malformed);
assert_eq!(
malformed_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
let mut malformed = Vec::new();
assert!(write_response(&mut malformed, &malformed_response).unwrap());
assert!(malformed.ends_with(b"{"));
assert!(
malformed_response.body.len() > 1,
"advertised body is deliberately truncated"
);
let (delayed_response, delayed_core) =
submit_sbc_with_fault(SbcPostCommitFault::Delay { millis: 20 });
let started = Instant::now();
let mut delayed = Vec::new();
assert!(!write_response(&mut delayed, &delayed_response).unwrap());
assert!(started.elapsed() >= std::time::Duration::from_millis(20));
assert!(delayed.ends_with(&delayed_response.body));
assert_eq!(
delayed_core
.completions
.lock()
.unwrap()
.get("sbc_bronze_upgrade")
.copied(),
Some(1)
);
}
} }
+2 -1
View File
@@ -36,7 +36,7 @@ use crate::economy_store::OwnedItemLookup;
use crate::market_store::{now_secs, Listing, MarketError, MarketStore}; use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
use crate::pile_store::PileStore; use crate::pile_store::PileStore;
use crate::sold_experiment::{CountMode, SoldExperiment}; use crate::sold_experiment::{CountMode, SoldExperiment};
use crate::{CoreEconomy, CoreError, WireResponse}; use crate::{CoreEconomy, CoreError, ResponseTransport, WireResponse};
/// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`). /// FIFA trade-id numbering base (mirrors the oracle's `_TRADE_ID_BASE`).
const TRADE_ID_BASE: i64 = 900_000_000; const TRADE_ID_BASE: i64 = 900_000_000;
@@ -47,6 +47,7 @@ fn json_body(status: u16, body: &Value) -> WireResponse {
status, status,
headers: vec![("Content-Type".to_string(), "application/json".to_string())], headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: bytes, body: bytes,
transport: ResponseTransport::Normal,
} }
} }
@@ -95,6 +95,7 @@
//! killed on guard drop. Never touches the production Core DB/ports or `.105`. //! killed on guard drop. Never touches the production Core DB/ports or `.105`.
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
use openfut_adapter_fifa17::fut::club_response::ItemIdentityResolver;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME; use openfut_adapter_fifa17::fut::non_economy::PERSONA_DISPLAY_NAME;
use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID}; use openfut_adapter_fifa17::fut::store_session::{SessionStore, StoreMode, SENTINEL_PACK_ID};
@@ -310,8 +311,12 @@ fn core_post(http: &reqwest::blocking::Client, base: &str, path: &str, body: Val
/// Build a real `Server` with economy authority wired against the seeded Core. /// Build a real `Server` with economy authority wired against the seeded Core.
/// Returns the server, a direct Core client for balance/entitlement assertions, /// Returns the server, a direct Core client for balance/entitlement assertions,
/// and a valid wire `resourceId` (20000) that reverse-maps to a real Core card. /// the resolver used to obtain stable wire instance ids, and a valid wire
fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClient, i64) { /// `resourceId` (20000) that reverse-maps to a real Core card.
fn build_econ_server(
base: &str,
dir: &std::path::Path,
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
let probe = HttpCoreClient::new(base, "fifa17"); let probe = HttpCoreClient::new(base, "fifa17");
let owned = probe.all_owned().expect("core collection"); let owned = probe.all_owned().expect("core collection");
assert!(!owned.is_empty(), "seed must grant a starter collection"); assert!(!owned.is_empty(), "seed must grant a starter collection");
@@ -374,7 +379,7 @@ fn build_econ_server(base: &str, dir: &std::path::Path) -> (Server, HttpCoreClie
PERSONA_ID, PERSONA_ID,
) )
.with_economy(services); .with_economy(services);
(server, probe, 20000) (server, probe, resolver, 20000)
} }
// ─────────────────────────── differential comparison ──────────────────────── // ─────────────────────────── differential comparison ────────────────────────
@@ -433,7 +438,7 @@ fn json_shape(value: &Value) -> Value {
fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
wait_ready(core_base); wait_ready(core_base);
let http = reqwest::blocking::Client::new(); let http = reqwest::blocking::Client::new();
let (server, client, _sample_resource) = build_econ_server(core_base, dir); let (server, client, _resolver, _sample_resource) = build_econ_server(core_base, dir);
// ── Fixture alignment: both sides own exactly one pack-70 entitlement. ── // ── Fixture alignment: both sides own exactly one pack-70 entitlement. ──
// Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement // Oracle: fresh profile already owns pack 70. Core: grant the "70" entitlement
@@ -1264,13 +1269,14 @@ fn run_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
/// Python acknowledges any body without consuming cards; Rust rejects an empty squad. /// Python acknowledges any body without consuming cards; Rust rejects an empty squad.
fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) { fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path) {
wait_ready(core_base); wait_ready(core_base);
let (server, _client, _sample_resource) = build_econ_server(core_base, dir); let (server, client, resolver, _sample_resource) = build_econ_server(core_base, dir);
let cases = [ let cases = [
("GET", "/ut/game/fifa17/sbs/sets", None), ("GET", "/ut/game/fifa17/sbs/sets", None),
("GET", "/ut/game/fifa17/sbs/setId/1/challenges", None), ("GET", "/ut/game/fifa17/sbs/setId/1/challenges", None),
("GET", "/ut/game/fifa17/sbs/setId/2/challenges", None), ("GET", "/ut/game/fifa17/sbs/setId/2/challenges", None),
("GET", "/ut/game/fifa17/sbs/setId/999/challenges", None), ("GET", "/ut/game/fifa17/sbs/setId/999/challenges", None),
("POST", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))), ("POST", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
("PUT", "/ut/game/fifa17/sbs/sets/tag", Some(json!({}))),
("POST", "/ut/game/fifa17/sbs/challenge/101", None), ("POST", "/ut/game/fifa17/sbs/challenge/101", None),
("GET", "/ut/game/fifa17/sbs/challenge/101/squad", None), ("GET", "/ut/game/fifa17/sbs/challenge/101/squad", None),
( (
@@ -1380,6 +1386,77 @@ fn run_sbc_differential(core_base: &str, oracle: &Oracle, dir: &std::path::Path)
rust_submit.0, 400, rust_submit.0, 400,
"Rust must validate instead of copying the oracle's no-op acceptance" "Rust must validate instead of copying the oracle's no-op acceptance"
); );
let owned = client.all_owned().expect("SBC differential inventory");
let mut selected = Vec::new();
for nation in ["Argentina", "Brazil"] {
selected.push(
owned
.iter()
.find(|item| item.rating >= 70 && item.nation == nation)
.unwrap_or_else(|| panic!("missing {nation} SBC fixture")),
);
}
for item in &owned {
if selected.len() == 11 {
break;
}
if item.rating >= 70
&& !selected
.iter()
.any(|existing| existing.owned_card_id == item.owned_card_id)
{
selected.push(item);
}
}
assert_eq!(selected.len(), 11);
let successful = json!({
"squad": selected
.iter()
.enumerate()
.map(|(index, item)| json!({
"index": index,
"itemData": {
"id": i64::from(
resolver.resolve(item).expect("SBC wire identity").item_id
)
}
}))
.collect::<Vec<_>>()
});
let before_balance = client.balance().unwrap();
let before_packs = client.entitlements().unwrap().len();
let oracle_success = oracle.req(
"POST",
"/ut/game/fifa17/sbs/challenge/201",
Some(successful.clone()),
None,
);
let rust_success = rust(
&server,
"POST",
"/ut/game/fifa17/sbs/challenge/201",
&serde_json::to_vec(&successful).unwrap(),
None,
);
assert_eq!(oracle_success.0, 200);
assert_eq!(rust_success.0, 200);
assert_eq!(
json_shape(&rust_success.1),
json_shape(&oracle_success.1),
"successful Rust submit preserves the oracle response class"
);
assert_eq!(rust_success.1["challengeId"], 201);
assert_eq!(rust_success.1["setId"], 2);
assert!(
client.balance().unwrap() > before_balance,
"Core applies challenge and achievement coin rewards"
);
assert_eq!(
client.entitlements().unwrap().len(),
before_packs + 1,
"Core grants one Hybrid Nations pack"
);
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 4)] #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+237 -1
View File
@@ -12,7 +12,7 @@
//! touches the production Core DB, production ports/containers, or `.105`. //! touches the production Core DB, production ports/containers, or `.105`.
use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog; use openfut_adapter_fifa17::fut::catalog::Fifa17CardCatalog;
use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::entities::{Fifa17Entities, ReverseEntityResolver};
use openfut_adapter_fifa17::fut::store_session::StoreMode; use openfut_adapter_fifa17::fut::store_session::StoreMode;
use openfut_identity::JsonIdentityStore; use openfut_identity::JsonIdentityStore;
use openfut_utas_host::async_bridge::AsyncBridge; use openfut_utas_host::async_bridge::AsyncBridge;
@@ -769,6 +769,7 @@ fn from_config(base: &str, dir: &std::path::Path) -> openfut_utas_host::config::
.join("active_account.json") .join("active_account.json")
.to_string_lossy() .to_string_lossy()
.into_owned(), .into_owned(),
sbc_post_commit_fault: openfut_utas_host::config::SbcPostCommitFault::Off,
} }
} }
@@ -908,6 +909,212 @@ async fn from_config_constructs_and_serves_economy() {
std::fs::remove_dir_all(&dir).ok(); std::fs::remove_dir_all(&dir).ok();
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn sbc_survives_complete_core_and_host_restart() {
let dir = std::env::temp_dir().join(format!(
"openfut-sbc-restart-{}-{}",
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://{}/sbc.db", dir.display());
let (h1, base1) = start_core_seeded(&db_url, true).await;
let first_base = base1.clone();
let first_dir = dir.clone();
let (mut cfg, selected_core_ids, selected_wire_ids) = tokio::task::spawn_blocking(move || {
let cfg = from_config(&first_base, &first_dir);
let server = Server::from_config(&cfg).expect("first complete host");
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
assert_eq!(club.status, 200);
let club: Value = serde_json::from_slice(&club.body).unwrap();
let items = club["itemData"].as_array().expect("club itemData");
let entities =
Fifa17Entities::from_tables_dir(std::path::Path::new(&cfg.tables_dir)).unwrap();
let argentina = i64::from(entities.nation_id("Argentina").unwrap());
let brazil = i64::from(entities.nation_id("Brazil").unwrap());
let mut selected = Vec::new();
for nation in [argentina, brazil] {
selected.push(
items
.iter()
.find(|item| {
item["rating"].as_i64().unwrap_or_default() >= 70
&& item["nation"].as_i64() == Some(nation)
})
.expect("required hybrid nation")
.clone(),
);
}
for item in items {
if selected.len() == 11 {
break;
}
if item["rating"].as_i64().unwrap_or_default() >= 70
&& !selected.iter().any(|existing| existing["id"] == item["id"])
{
selected.push(item.clone());
}
}
assert_eq!(selected.len(), 11, "deterministic Hybrid Nations squad");
let selected_wire_ids: Vec<i64> = selected
.iter()
.map(|item| item["id"].as_i64().expect("wire id"))
.collect();
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
.expect("catalog reload");
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("identity reload");
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
let selected_core_ids: Vec<String> = selected_wire_ids
.iter()
.map(|wire| {
resolver
.owned_id_for_wire(*wire)
.expect("wire mapping persisted")
})
.collect();
let squad = json!({
"squad": selected_wire_ids
.iter()
.enumerate()
.map(|(index, id)| json!({
"index": index,
"itemData": { "id": id },
"kitNumber": 0
}))
.collect::<Vec<_>>()
});
let squad_bytes = serde_json::to_vec(&squad).unwrap();
assert_eq!(
server
.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/201/squad",
&[],
&squad_bytes,
)
.status,
200
);
let submitted = server.handle("PUT", "/ut/game/fifa17/sbs/challenge/201", &[], br#"{}"#);
assert_eq!(submitted.status, 200);
let submitted: Value = serde_json::from_slice(&submitted.body).unwrap();
assert_eq!(submitted["challengeId"], 201);
assert_eq!(submitted["credits"], 102_750);
assert_eq!(submitted["recoveredPacks"], 1);
(cfg, selected_core_ids, selected_wire_ids)
})
.await
.expect("initial complete-host phase");
h1.abort();
let _ = h1.await;
let (h2, base2) = start_core_seeded(&db_url, false).await;
cfg.core_url = base2.clone();
let selected_core_ids_check = selected_core_ids.clone();
let selected_wire_ids_check = selected_wire_ids.clone();
tokio::task::spawn_blocking(move || {
let server = Server::from_config(&cfg).expect("restarted complete host");
let client = HttpCoreClient::new(&base2, "fifa17");
assert_eq!(client.balance().unwrap(), 102_750);
assert_eq!(client.entitlements().unwrap().len(), 1);
assert_eq!(
client
.sbc_completion_counts()
.unwrap()
.get("sbc_hybrid_nations")
.copied(),
Some(1)
);
let owned = client.all_owned().unwrap();
assert!(selected_core_ids_check
.iter()
.all(|id| { owned.iter().all(|item| item.owned_card_id != *id) }));
let club = server.handle("GET", "/ut/game/fifa17/club?count=200", &[], b"");
let club: Value = serde_json::from_slice(&club.body).unwrap();
assert!(selected_wire_ids_check.iter().all(|id| {
club["itemData"]
.as_array()
.unwrap()
.iter()
.all(|item| item["id"].as_i64() != Some(*id))
}));
let saved = server.handle("GET", "/ut/game/fifa17/sbs/challenge/201/squad", &[], b"");
let saved: Value = serde_json::from_slice(&saved.body).unwrap();
assert!(saved["squad"].as_array().unwrap().is_empty());
let purchased = server.handle("GET", "/ut/v2/game/fifa17/purchased/items", &[], b"");
let purchased: Value = serde_json::from_slice(&purchased.body).unwrap();
assert!(purchased["itemData"].as_array().unwrap().is_empty());
for path in ["/ut/game/fifa17/tradePile", "/ut/game/fifa17/watchList"] {
let pile = server.handle("GET", path, &[], b"");
let pile: Value = serde_json::from_slice(&pile.body).unwrap();
assert!(pile["auctionInfo"]
.as_array()
.unwrap()
.iter()
.all(|auction| {
selected_wire_ids_check
.iter()
.all(|id| auction["itemData"]["id"].as_i64() != Some(*id))
}));
}
let active = server.handle("GET", "/ut/game/fifa17/squad/active", &[], b"");
let active: Value = serde_json::from_slice(&active.body).unwrap();
assert!(selected_wire_ids_check.iter().all(|id| {
active["players"].as_array().is_none_or(|players| {
players
.iter()
.all(|slot| slot["itemData"]["id"].as_i64() != Some(*id))
})
}));
let replay_body = json!({
"squad": selected_wire_ids_check
.iter()
.map(|id| json!({ "itemData": { "id": id } }))
.collect::<Vec<_>>()
});
assert_eq!(
server
.handle(
"PUT",
"/ut/game/fifa17/sbs/challenge/201",
&[],
&serde_json::to_vec(&replay_body).unwrap(),
)
.status,
404,
"host rejects consumed wire ids before a duplicate effect"
);
assert!(matches!(
client.submit_sbc("sbc_hybrid_nations", &selected_core_ids_check),
Err(openfut_utas_host::CoreError::Status(409))
));
assert_eq!(client.balance().unwrap(), 102_750);
assert_eq!(client.entitlements().unwrap().len(), 1);
let catalog = Fifa17CardCatalog::from_file(std::path::Path::new(&cfg.catalog_path))
.expect("restart catalog");
let store = JsonIdentityStore::open(&cfg.identity_store_path).expect("restart identity");
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
for (wire, core_id) in selected_wire_ids_check.iter().zip(&selected_core_ids_check) {
assert_eq!(
resolver.owned_id_for_wire(*wire).as_deref(),
Some(core_id.as_str())
);
}
})
.await
.expect("restart verification");
h2.abort();
let _ = h2.await;
std::fs::remove_dir_all(&dir).ok();
}
// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ──── // ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
// stale reader), through the REAL handle_with_ip dispatch ────── // stale reader), through the REAL handle_with_ip dispatch ──────
@@ -1040,6 +1247,35 @@ fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
"/ut/game/fifa17/tradePile/counts".into(), "/ut/game/fifa17/tradePile/counts".into(),
b"".to_vec(), b"".to_vec(),
), ),
// ── FIFA 17 SBC family. Reads, tag acknowledgement, durable squad
// saves, challenge start, and submission are all Rust-owned. ──
("GET", "/ut/game/fifa17/sbs/sets".into(), b"".to_vec()),
(
"GET",
"/ut/game/fifa17/sbs/setId/1/challenges".into(),
b"".to_vec(),
),
(
"GET",
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
b"".to_vec(),
),
("PUT", "/ut/game/fifa17/sbs/sets/tag".into(), b"{}".to_vec()),
(
"PUT",
"/ut/game/fifa17/sbs/challenge/101/squad".into(),
br#"{"squad":[]}"#.to_vec(),
),
(
"POST",
"/ut/game/fifa17/sbs/challenge/101".into(),
b"".to_vec(),
),
(
"PUT",
"/ut/game/fifa17/sbs/challenge/101".into(),
br#"{"squad":[]}"#.to_vec(),
),
] ]
} }
+12 -2
View File
@@ -278,7 +278,7 @@ def cmd_parse(args):
os.makedirs(out_dir, exist_ok=True) os.makedirs(out_dir, exist_ok=True)
out_path = os.path.join(out_dir, "transactions.jsonl") out_path = os.path.join(out_dir, "transactions.jsonl")
total, skipped = 0, 0 total, skipped, filtered = 0, 0, 0
with open(out_path, "w") as out: with open(out_path, "w") as out:
for conn_id in sorted(conns): for conn_id in sorted(conns):
c = conns[conn_id] c = conns[conn_id]
@@ -298,6 +298,9 @@ def cmd_parse(args):
m = re.match(r"(\S+)\s+(\S+)\s+(HTTP/\d\.\d)", rl) m = re.match(r"(\S+)\s+(\S+)\s+(HTTP/\d\.\d)", rl)
method, target, ver = (m.group(1), m.group(2), m.group(3)) if m else ("?", rl, "?") method, target, ver = (m.group(1), m.group(2), m.group(3)) if m else ("?", rl, "?")
path, _, query = target.partition("?") path, _, query = target.partition("?")
if args.path_prefix and not path.startswith(args.path_prefix):
filtered += 1
continue
st = re.match(r"HTTP/\d\.\d\s+(\d+)", sl) st = re.match(r"HTTP/\d\.\d\s+(\d+)", sl)
req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1) req_t, req_unix = time_at(c["marks"]["c2s"], rend - 1)
res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0)) res_t, res_unix = time_at(c["marks"]["s2c"], max(send - 1, 0))
@@ -342,9 +345,14 @@ def cmd_parse(args):
total += 1 total += 1
os.chmod(out_path, 0o644) os.chmod(out_path, 0o644)
detail = []
if skipped:
detail.append("%d unpaired messages reported above" % skipped)
if filtered:
detail.append("%d transactions excluded by path prefix" % filtered)
print("wrote %s (%d transactions across %d connections%s)" print("wrote %s (%d transactions across %d connections%s)"
% (out_path, total, len(conns), % (out_path, total, len(conns),
"; %d unpaired messages reported above" % skipped if skipped else "")) "; " + "; ".join(detail) if detail else ""))
return 0 return 0
@@ -587,6 +595,8 @@ def main():
# fixture for the whole response. # fixture for the whole response.
p.add_argument("--max-body", type=int, default=0, p.add_argument("--max-body", type=int, default=0,
help="bytes; 0 = keep every body in full") help="bytes; 0 = keep every body in full")
p.add_argument("--path-prefix", default="",
help="emit only transactions whose request path starts with this prefix")
p.set_defaults(fn=cmd_parse) p.set_defaults(fn=cmd_parse)
s = sub.add_parser("snapshot") s = sub.add_parser("snapshot")
+18 -4
View File
@@ -123,8 +123,8 @@ def main():
body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode() body = json.dumps({"squad": [1, 2, 3], "note": "x" * 300}).encode()
reqs = [ reqs = [
b"GET /ut/game/fifa17/userMassInfo HTTP/1.1\r\nHost: t\r\n\r\n", b"GET /ut/game/fifa17/sbs/sets HTTP/1.1\r\nHost: t\r\n\r\n",
b"POST /ut/game/fifa17/purchased/items HTTP/1.1\r\nHost: t\r\n" b"POST /ut/game/fifa17/sbs/challenge/101/squad HTTP/1.1\r\nHost: t\r\n"
b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body, b"Content-Type: application/json\r\nContent-Length: %d\r\n\r\n" % len(body) + body,
b"GET /chunked?deviceId=DEADBEEFCAFE&keep=yes HTTP/1.1\r\nHost: t\r\n\r\n", b"GET /chunked?deviceId=DEADBEEFCAFE&keep=yes HTTP/1.1\r\nHost: t\r\n\r\n",
b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n", b"GET /ut/game/fifa17/hub HTTP/1.1\r\nHost: t\r\nConnection: close\r\n\r\n",
@@ -180,8 +180,8 @@ def main():
if len(txs) == 4: if len(txs) == 4:
check("methods and paths in order", check("methods and paths in order",
[(t["request"]["method"], t["request"]["path"]) for t in txs] == [(t["request"]["method"], t["request"]["path"]) for t in txs] ==
[("GET", "/ut/game/fifa17/userMassInfo"), [("GET", "/ut/game/fifa17/sbs/sets"),
("POST", "/ut/game/fifa17/purchased/items"), ("POST", "/ut/game/fifa17/sbs/challenge/101/squad"),
("GET", "/chunked"), ("GET", "/chunked"),
("GET", "/ut/game/fifa17/hub")]) ("GET", "/ut/game/fifa17/hub")])
check("request body preserved byte-for-byte", check("request body preserved byte-for-byte",
@@ -210,6 +210,20 @@ def main():
qtx is not None and "keep=yes" in qtx["request"]["query"], qtx is not None and "keep=yes" in qtx["request"]["query"],
qtx["request"]["query"] if qtx else "") qtx["request"]["query"] if qtx else "")
print("== path-scoped fixture ==")
r = subprocess.run(
[sys.executable, TOOL, "parse", "--session", SESSION,
"--path-prefix", "/ut/game/fifa17/sbs/"],
capture_output=True, text=True)
print(" " + r.stdout.strip().replace("\n", "\n "))
filtered = [json.loads(l) for l in
open(os.path.join(SESSION, "sanitized", "transactions.jsonl"))]
check("SBC prefix emits only the two SBC transactions", len(filtered) == 2,
"got %d" % len(filtered))
check("SBC save body remains byte-exact after scoped parse",
len(filtered) == 2 and
base64.b64decode(filtered[1]["request"]["body_b64"]) == body)
srv.shutdown() srv.shutdown()
print() print()
print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails)) print("all checks passed" if not fails else "%d FAILED: %s" % (len(fails), fails))