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
+161 -11
View File
@@ -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)
);
}
}