test(fifa17): harden SBC retail acceptance
This commit is contained in:
@@ -3,6 +3,18 @@
|
||||
//! collide with the live oracle). `core_url` defaults to Bridge's convention.
|
||||
|
||||
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)]
|
||||
pub struct HostConfig {
|
||||
@@ -44,6 +56,9 @@ pub struct HostConfig {
|
||||
/// `OPENFUT_ACCOUNT_PATH`, then existing `FUT_ACCOUNT_PATH`, then the
|
||||
/// identity store's parent directory + `active_account.json`.
|
||||
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)]
|
||||
@@ -76,9 +91,68 @@ fn required_i64_nonzero(key: &str) -> Result<i64, ConfigError> {
|
||||
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 {
|
||||
pub fn from_env() -> Result<Self, ConfigError> {
|
||||
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")
|
||||
.ok()
|
||||
.filter(|v| !v.is_empty())
|
||||
@@ -89,7 +163,7 @@ impl HostConfig {
|
||||
.or_else(|| env::var("FUT_ACCOUNT_PATH").ok().filter(|v| !v.is_empty()))
|
||||
.unwrap_or_else(|| default_account_path(&identity_store_path));
|
||||
Ok(HostConfig {
|
||||
listen_addr: required("OPENFUT_UTAS_HOST_ADDR")?,
|
||||
listen_addr,
|
||||
python_upstream: required("OPENFUT_UTAS_PYTHON_URL")?,
|
||||
core_url: env::var("OPENFUT_CORE_URL")
|
||||
.unwrap_or_else(|_| "http://127.0.0.1:8080".into()),
|
||||
@@ -102,6 +176,7 @@ impl HostConfig {
|
||||
identity_store_path,
|
||||
clientdata_path,
|
||||
account_path,
|
||||
sbc_post_commit_fault,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -125,3 +200,57 @@ fn default_account_path(identity_store_path: &str) -> String {
|
||||
.to_string_lossy()
|
||||
.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
@@ -83,7 +83,7 @@ use serde_json::{json, Value};
|
||||
|
||||
use account_store::{AccountStore, RenameOutcome};
|
||||
use clientdata_store::ClientDataStore;
|
||||
use config::HostConfig;
|
||||
use config::{HostConfig, SbcPostCommitFault};
|
||||
|
||||
// ───────────────────────────── Route classification ─────────────────────────
|
||||
|
||||
@@ -1792,6 +1792,7 @@ fn error_response(status: u16, code: &str) -> WireResponse {
|
||||
status,
|
||||
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
||||
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 ──────────────────────────────
|
||||
|
||||
/// 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)]
|
||||
pub struct WireResponse {
|
||||
pub status: u16,
|
||||
pub headers: Vec<(String, String)>,
|
||||
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 {
|
||||
@@ -2250,6 +2272,7 @@ fn json_response(body: &Value) -> WireResponse {
|
||||
status: 200,
|
||||
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
||||
body: bytes,
|
||||
transport: ResponseTransport::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2323,6 +2346,7 @@ impl PassClient {
|
||||
status,
|
||||
headers: out,
|
||||
body: bytes,
|
||||
transport: ResponseTransport::Normal,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2445,6 +2469,8 @@ pub struct Server {
|
||||
/// Core supplies the database transaction; this gate closes the cross-store race
|
||||
/// within one host process.
|
||||
economy_gate: Arc<Mutex<()>>,
|
||||
/// Staging-only simulation of losing the successful SBC submit receipt.
|
||||
sbc_post_commit_fault: SbcPostCommitFault,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -2468,6 +2494,7 @@ impl Server {
|
||||
economy: None,
|
||||
clientdata: Arc::new(ClientDataStore::open(ephemeral_clientdata_path())),
|
||||
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 account = Arc::new(AccountStore::open(cfg.account_path.clone()));
|
||||
eprintln!(
|
||||
"utas-host sbc_post_commit_fault={:?}",
|
||||
cfg.sbc_post_commit_fault
|
||||
);
|
||||
Ok(Server::new(
|
||||
core,
|
||||
entities,
|
||||
@@ -2540,7 +2571,8 @@ impl Server {
|
||||
)
|
||||
.with_economy(economy)
|
||||
.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
|
||||
@@ -2575,6 +2607,11 @@ impl Server {
|
||||
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> {
|
||||
let definitions = self.core.list_sbcs()?;
|
||||
let completions = self.core.sbc_completion_counts()?;
|
||||
@@ -2819,7 +2856,7 @@ impl Server {
|
||||
Ok(packs) => packs.len() as i64,
|
||||
Err(error) => return core_sbc_error_response(error),
|
||||
};
|
||||
json_status(
|
||||
let mut response = json_status(
|
||||
200,
|
||||
&fifa17_sbc::submit_body(
|
||||
challenge_id,
|
||||
@@ -2827,7 +2864,9 @@ impl Server {
|
||||
credits,
|
||||
unopened_packs,
|
||||
),
|
||||
)
|
||||
);
|
||||
response.transport = self.sbc_post_commit_fault.into();
|
||||
response
|
||||
}
|
||||
_ => json_status(500, &json!({ "error": "invalid SBC route dispatch" })),
|
||||
}
|
||||
@@ -3183,6 +3222,7 @@ impl Server {
|
||||
"application/json".to_string(),
|
||||
)],
|
||||
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
|
||||
transport: ResponseTransport::Normal,
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -3603,6 +3643,7 @@ impl Server {
|
||||
status: 204,
|
||||
headers: Vec::new(),
|
||||
body: Vec::new(),
|
||||
transport: ResponseTransport::Normal,
|
||||
},
|
||||
"captcha" => json_status(
|
||||
200,
|
||||
@@ -3770,10 +3811,11 @@ impl Server {
|
||||
&req.body,
|
||||
peer_ip.as_deref(),
|
||||
);
|
||||
if write_response(&mut writer, &resp).is_err() {
|
||||
return;
|
||||
}
|
||||
if req.close {
|
||||
let close = match write_response(&mut writer, &resp) {
|
||||
Ok(close) => close,
|
||||
Err(_) => return,
|
||||
};
|
||||
if close || req.close {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -3813,6 +3855,7 @@ fn json_status(status: u16, v: &Value) -> WireResponse {
|
||||
("Content-Length".to_string(), body.len().to_string()),
|
||||
],
|
||||
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));
|
||||
for (k, v) in &resp.headers {
|
||||
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("\r\n");
|
||||
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.flush()
|
||||
w.flush()?;
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -5326,4 +5382,98 @@ mod tests {
|
||||
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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ use crate::economy_store::OwnedItemLookup;
|
||||
use crate::market_store::{now_secs, Listing, MarketError, MarketStore};
|
||||
use crate::pile_store::PileStore;
|
||||
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`).
|
||||
const TRADE_ID_BASE: i64 = 900_000_000;
|
||||
@@ -47,6 +47,7 @@ fn json_body(status: u16, body: &Value) -> WireResponse {
|
||||
status,
|
||||
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
|
||||
body: bytes,
|
||||
transport: ResponseTransport::Normal,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user