feat(utas-host): FIFA17 squad authority — PUT, /squad/list, userMassInfo overlay
Extend the UTAS migration host to own the squad slice, reusing the exact
production identity path (Fifa17CardCatalog + ExternalIdentityStore) that
/club uses, so /club, /squad/list, userMassInfo and PUT all agree on
wire<->owned identity.
Routing (classified ONCE, no try-Rust-then-Python):
PUT …/squad/<n> -> Rust (numeric id; …/squad/active stays Python)
GET …/squad/list -> Rust
GET …/userMassInfo-> Python proxy, ONLY .squad overlaid
everything else -> Python verbatim
CoreAccess gains read_squad_ext / replace_squad / all_owned (HTTP to the new
Core /squad/ext + /squad/replace routes).
PUT pipeline: parse -> build_squad_write (reverse-resolve every wire id;
refuse unresolved/duplicate) -> AUTHORIZE every resolved owned item against
the active club (identity resolution is NOT authorization; a valid wire id
owned by another profile is rejected before any mutation) -> Core atomic
replace+extension -> exactly {"id":0}. No Python fallback on failure; no
host-side second extension store; no fingerprint recomputation.
Read path assembles ONE projection input (one read_squad_ext + one batch
all_owned; no per-slot lookup) and runs the single adapter projector. Both
/squad/list and userMassInfo.squad derive from it. Fresh projects; Stale is
never applied; Missing is never fabricated — both are prominent integrity
failures, never served from Python (no split authority). The overlay
replaces only .squad and preserves userInfo/settings/userData/
pileSizeClientData, fixing Content-Length.
Tests: routing, full-replacement + exact ack, unknown/foreign/duplicate item
rejection with Core unchanged, idempotent repeat PUT, coupled read-after-
write (list + userMassInfo agree, real resourceIds + stable wire ids),
overlay field preservation, bounded no-N+1 reads, Stale/Missing integrity.
This commit is contained in:
@@ -13,8 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso
|
||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||
use openfut_identity::JsonIdentityStore;
|
||||
use openfut_utas_host::{
|
||||
classify, read_request, CoreAccess, CoreError, CorePage, Fifa17IdentityResolver,
|
||||
HttpCoreClient, PassClient, Route, Server,
|
||||
classify, handle_put_squad, handle_squad_list, handle_user_mass_info, read_request,
|
||||
CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest,
|
||||
CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient,
|
||||
PassClient, Route, Server, SquadDeps,
|
||||
};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::Value;
|
||||
@@ -24,49 +26,58 @@ use serde_json::Value;
|
||||
/// (method, target, body) recorded by the mock upstream.
|
||||
type Recorded = Arc<Mutex<Vec<(String, String, Vec<u8>)>>>;
|
||||
|
||||
/// A recorded squad replacement (the fields a host test asserts on).
|
||||
#[derive(Clone)]
|
||||
struct StoredReplace {
|
||||
formation: Option<String>,
|
||||
slots: Vec<(String, i64, bool, bool)>, // owned_card_id, index, is_captain, is_on_bench
|
||||
ext_payload: String,
|
||||
chemistry: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct FakeCore {
|
||||
items: Vec<CoreOwnedItem>,
|
||||
total: i64,
|
||||
calls: AtomicUsize,
|
||||
read_calls: AtomicUsize,
|
||||
replace_calls: AtomicUsize,
|
||||
last_params: Mutex<Vec<(String, String)>>,
|
||||
/// The stored squad + extension returned by `read_squad_ext`. A successful
|
||||
/// `replace_squad` overwrites it (Fresh), enabling coupled read-after-write.
|
||||
squad: Mutex<Option<CoreSquadRead>>,
|
||||
replaced: Mutex<Vec<StoredReplace>>,
|
||||
panic_if_called: bool,
|
||||
return_err: bool,
|
||||
}
|
||||
|
||||
impl FakeCore {
|
||||
fn new(items: Vec<CoreOwnedItem>, total: i64) -> Self {
|
||||
FakeCore {
|
||||
items,
|
||||
total,
|
||||
calls: AtomicUsize::new(0),
|
||||
last_params: Mutex::new(vec![]),
|
||||
panic_if_called: false,
|
||||
return_err: false,
|
||||
}
|
||||
FakeCore { items, total, ..Default::default() }
|
||||
}
|
||||
fn forbidden() -> Self {
|
||||
FakeCore {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
calls: AtomicUsize::new(0),
|
||||
last_params: Mutex::new(vec![]),
|
||||
panic_if_called: true,
|
||||
return_err: false,
|
||||
}
|
||||
FakeCore { panic_if_called: true, ..Default::default() }
|
||||
}
|
||||
fn erroring() -> Self {
|
||||
FakeCore {
|
||||
items: vec![],
|
||||
total: 0,
|
||||
calls: AtomicUsize::new(0),
|
||||
last_params: Mutex::new(vec![]),
|
||||
panic_if_called: false,
|
||||
return_err: true,
|
||||
}
|
||||
FakeCore { return_err: true, ..Default::default() }
|
||||
}
|
||||
/// Preset the stored squad read (for read-path tests without a prior PUT).
|
||||
fn with_squad(self, read: CoreSquadRead) -> Self {
|
||||
*self.squad.lock() = Some(read);
|
||||
self
|
||||
}
|
||||
fn calls(&self) -> usize {
|
||||
self.calls.load(Ordering::SeqCst)
|
||||
}
|
||||
fn read_calls(&self) -> usize {
|
||||
self.read_calls.load(Ordering::SeqCst)
|
||||
}
|
||||
fn replace_calls(&self) -> usize {
|
||||
self.replace_calls.load(Ordering::SeqCst)
|
||||
}
|
||||
fn replaced(&self) -> Vec<StoredReplace> {
|
||||
self.replaced.lock().clone()
|
||||
}
|
||||
fn last(&self) -> Vec<(String, String)> {
|
||||
self.last_params.lock().clone()
|
||||
}
|
||||
@@ -74,10 +85,7 @@ impl FakeCore {
|
||||
|
||||
impl CoreAccess for FakeCore {
|
||||
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
|
||||
assert!(
|
||||
!self.panic_if_called,
|
||||
"Core must NOT be called on this path"
|
||||
);
|
||||
assert!(!self.panic_if_called, "Core must NOT be called on this path");
|
||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
@@ -86,9 +94,64 @@ impl CoreAccess for FakeCore {
|
||||
.iter()
|
||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||
.collect();
|
||||
Ok(CorePage {
|
||||
items: self.items.clone(),
|
||||
total: self.total,
|
||||
Ok(CorePage { items: self.items.clone(), total: self.total })
|
||||
}
|
||||
|
||||
fn read_squad_ext(&self, _namespace: &str) -> Result<CoreSquadRead, CoreError> {
|
||||
assert!(!self.panic_if_called, "Core must NOT be called on this path");
|
||||
self.read_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
self.squad.lock().clone().ok_or(CoreError::Status(404))
|
||||
}
|
||||
|
||||
fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> {
|
||||
assert!(!self.panic_if_called, "Core must NOT be called on this path");
|
||||
self.replace_calls.fetch_add(1, Ordering::SeqCst);
|
||||
if self.return_err {
|
||||
return Err(CoreError::Status(500));
|
||||
}
|
||||
self.replaced.lock().push(StoredReplace {
|
||||
formation: req.formation.clone(),
|
||||
slots: req
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| (s.owned_card_id.clone(), s.index, s.is_captain, s.is_on_bench))
|
||||
.collect(),
|
||||
ext_payload: req.ext_payload.clone(),
|
||||
chemistry: req.client_reported.chemistry,
|
||||
});
|
||||
// Reflect the committed state so a subsequent read is Fresh (deterministic
|
||||
// fingerprint by construction — the fake owns both sides).
|
||||
let slots = req
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| CoreSquadSlot {
|
||||
owned_card_id: s.owned_card_id.clone(),
|
||||
index: s.index,
|
||||
is_captain: s.is_captain,
|
||||
is_on_bench: s.is_on_bench,
|
||||
})
|
||||
.collect();
|
||||
let fingerprint = format!(
|
||||
"fp-{}-{}",
|
||||
req.formation.clone().unwrap_or_default(),
|
||||
req.slots.len()
|
||||
);
|
||||
*self.squad.lock() = Some(CoreSquadRead {
|
||||
name: req.name.clone().unwrap_or_default(),
|
||||
formation: req.formation.clone().unwrap_or_default(),
|
||||
slots,
|
||||
ext: CoreExtState::Fresh {
|
||||
schema_version: req.ext_schema_version,
|
||||
payload: req.ext_payload.clone(),
|
||||
},
|
||||
});
|
||||
Ok(CoreReplaceResult {
|
||||
squad_id: "sq-1".into(),
|
||||
canonical_fingerprint: fingerprint,
|
||||
slots_written: req.slots.len(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -177,12 +240,11 @@ fn build_server(
|
||||
);
|
||||
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> =
|
||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||
Server::new(
|
||||
core,
|
||||
Arc::new(entities()),
|
||||
assets,
|
||||
resolver,
|
||||
Arc::new(PassClient::new(upstream)),
|
||||
)
|
||||
}
|
||||
@@ -536,8 +598,7 @@ fn club_end_to_end_through_real_resolver_and_sends_game_header() {
|
||||
)
|
||||
.unwrap();
|
||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||
let resolver: Arc<dyn ItemIdentityResolver + Send + Sync> =
|
||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||
let server = Server::new(
|
||||
Arc::new(HttpCoreClient::new(core_url, "fifa17")),
|
||||
Arc::new(entities()),
|
||||
@@ -572,3 +633,396 @@ fn club_end_to_end_through_real_resolver_and_sends_game_header() {
|
||||
"X-OpenFUT-Game: fifa17 sent to Core: {got:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ── Squad host: routing, PUT pipeline, authorization, coupled read-after-write ─
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
/// Build the production resolver over a card→asset catalog and PRE-ALLOCATE a
|
||||
/// wire id for each owned item (as `/club` would before FIFA ever references
|
||||
/// them). Returns the resolver + the wire id assigned to each owned_card_id.
|
||||
fn resolver_with_wires(
|
||||
items: &[CoreOwnedItem],
|
||||
assets: &[(&str, u32)],
|
||||
) -> (Fifa17IdentityResolver, HashMap<String, i64>) {
|
||||
let entries: Vec<String> = assets
|
||||
.iter()
|
||||
.map(|(id, a)| format!("\"{id}\":{{\"asset_id\":{a}}}"))
|
||||
.collect();
|
||||
let doc = format!(
|
||||
"{{\"schema_version\":1,\"game\":\"fifa17\",\"cards\":{{{}}}}}",
|
||||
entries.join(",")
|
||||
);
|
||||
let catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||
let resolver = Fifa17IdentityResolver::new(catalog, Arc::new(store));
|
||||
let mut wires = HashMap::new();
|
||||
for it in items {
|
||||
if let Some(id) = resolver.resolve(it) {
|
||||
wires.insert(it.owned_card_id.clone(), id.item_id as i64);
|
||||
}
|
||||
}
|
||||
(resolver, wires)
|
||||
}
|
||||
|
||||
/// A FIFA squad-save body. `players` = (index, wire id, kit number).
|
||||
fn put_body(formation: &str, captain: i64, players: &[(i64, i64, i64)], custom: &str) -> Vec<u8> {
|
||||
let ps: Vec<Value> = players
|
||||
.iter()
|
||||
.map(|(i, w, k)| json!({"index": i, "itemData": {"id": w, "dream": false}, "kitNumber": k}))
|
||||
.collect();
|
||||
json!({
|
||||
"id": 0,
|
||||
"formation": formation,
|
||||
"squadName": "OpenFUT",
|
||||
"squadType": "REGULAR_SQUAD",
|
||||
"chemistry": 52,
|
||||
"rating": 90,
|
||||
"starRating": 90,
|
||||
"captain": captain,
|
||||
"custom": custom,
|
||||
"manager": [{"id": 100000427, "dream": false}],
|
||||
"players": ps,
|
||||
"kicktakers": [{"index": 0, "id": captain, "dream": false}],
|
||||
})
|
||||
.to_string()
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
fn gk() -> CoreOwnedItem {
|
||||
item("oc-a", "card_a", 87, "GK", "Argentina", "Premier League", "Chelsea")
|
||||
}
|
||||
fn st() -> CoreOwnedItem {
|
||||
item("oc-b", "card_b", 90, "ST", "Argentina", "Premier League", "Chelsea")
|
||||
}
|
||||
const ASSETS: &[(&str, u32)] = &[("card_a", 20801), ("card_b", 158023)];
|
||||
|
||||
/// A mock Python returning a fixed userMassInfo JSON (userInfo/settings/... +
|
||||
/// a Python-authored squad the overlay must replace).
|
||||
fn spawn_mock_python_json(body: Value) -> (String, Recorded) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let rec = Arc::new(Mutex::new(Vec::new()));
|
||||
let rec2 = rec.clone();
|
||||
let text = body.to_string();
|
||||
std::thread::spawn(move || {
|
||||
for stream in listener.incoming() {
|
||||
let mut s = match stream {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let mut r = BufReader::new(s.try_clone().unwrap());
|
||||
if let Ok(Some(req)) = read_request(&mut r) {
|
||||
rec2.lock().push((req.method.clone(), req.target.clone(), req.body.clone()));
|
||||
let head = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||
text.len()
|
||||
);
|
||||
let _ = s.write_all(head.as_bytes());
|
||||
let _ = s.write_all(text.as_bytes());
|
||||
}
|
||||
}
|
||||
});
|
||||
(format!("http://{addr}"), rec)
|
||||
}
|
||||
|
||||
// ── Classification ──────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn classify_squad_and_usermassinfo_routes() {
|
||||
assert_eq!(classify("PUT", "/ut/game/fifa17/squad/0"), Route::SquadReplace);
|
||||
assert_eq!(classify("PUT", "/ut/game/fifa17/squad/3"), Route::SquadReplace);
|
||||
assert_eq!(classify("GET", "/ut/game/fifa17/squad/list"), Route::SquadList);
|
||||
assert_eq!(classify("GET", "/ut/game/fifa17/userMassInfo"), Route::UserMassInfo);
|
||||
// /squad/active stays with Python (not numeric); GET squad/<n> is NOT a Rust
|
||||
// read route; a squad PUT is never a GET.
|
||||
assert_eq!(classify("PUT", "/ut/game/fifa17/squad/active"), Route::Passthrough);
|
||||
assert_eq!(classify("GET", "/ut/game/fifa17/squad/active"), Route::Passthrough);
|
||||
assert_eq!(classify("GET", "/ut/game/fifa17/squad/0"), Route::Passthrough);
|
||||
assert_eq!(classify("PUT", "/ut/game/fifa17/squad/list"), Route::Passthrough);
|
||||
assert_eq!(classify("GET", "/ut/game/fifa17/club"), Route::Club);
|
||||
}
|
||||
|
||||
// ── PUT pipeline ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn put_full_replacement_commits_canonical_and_extension_and_acks_id0() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]");
|
||||
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 200, "{log:?}");
|
||||
assert_eq!(resp.body, br#"{"id":0}"#, "exact save ack");
|
||||
|
||||
let recs = core.replaced();
|
||||
assert_eq!(recs.len(), 1);
|
||||
let r = &recs[0];
|
||||
assert_eq!(r.formation.as_deref(), Some("f442"));
|
||||
// Canonical slots carry Core owned ids, never FIFA wire integers.
|
||||
let mut owned: Vec<&str> = r.slots.iter().map(|(o, ..)| o.as_str()).collect();
|
||||
owned.sort();
|
||||
assert_eq!(owned, vec!["oc-a", "oc-b"]);
|
||||
assert!(r.slots.iter().all(|(o, ..)| o.starts_with("oc-")));
|
||||
// Captain is the semantic owned item (oc-a), flagged in canonical.
|
||||
assert!(r.slots.iter().any(|(o, _, cap, _)| o == "oc-a" && *cap));
|
||||
// Extension payload round-trips the opaque custom + kit-by-owned-id.
|
||||
assert!(r.ext_payload.contains("[1,2,3]"), "custom in ext payload");
|
||||
assert_eq!(r.chemistry, Some(52), "client-reported shadow carried");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_rejects_wire_id_owned_by_another_profile_core_unchanged() {
|
||||
// oc-b resolves (identity) but is NOT in the active club's owned set.
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(vec![gk()], 1); // only oc-a owned
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]");
|
||||
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 403, "resolvable != authorized");
|
||||
assert_eq!(log.outcome, "unauthorized_item");
|
||||
assert_eq!(core.replace_calls(), 0, "Core squad left unchanged");
|
||||
assert!(core.replaced().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_rejects_unknown_wire_id() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
// 999_999_999 was never allocated → unresolvable.
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, 999_999_999, 9)], "[]");
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
assert_eq!(log.outcome, "unresolved_wire_ids");
|
||||
assert_eq!(core.replace_calls(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_rejects_duplicate_owned_item() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-a"], 9)], "[]");
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 400);
|
||||
assert_eq!(log.outcome, "duplicate_owned_item");
|
||||
assert_eq!(core.replace_calls(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn put_core_failure_returns_error_never_python() {
|
||||
// handle_put_squad has no PassClient at all — a Core failure cannot fall back.
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::erroring();
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1)], "[]");
|
||||
let (resp, log) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(resp.status, 502);
|
||||
assert_eq!(log.outcome, "core_error");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_identical_put_is_idempotent() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]");
|
||||
let (r1, _) = handle_put_squad(&body, &deps);
|
||||
let (r2, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(r1.body, br#"{"id":0}"#);
|
||||
assert_eq!(r2.body, br#"{"id":0}"#);
|
||||
let recs = core.replaced();
|
||||
assert_eq!(recs.len(), 2);
|
||||
assert_eq!(recs[0].slots, recs[1].slots, "canonical converges");
|
||||
assert_eq!(recs[0].formation, recs[1].formation);
|
||||
assert_eq!(recs[0].ext_payload, recs[1].ext_payload, "extension converges");
|
||||
}
|
||||
|
||||
// ── Coupled read-after-write ─────────────────────────────────────────────────
|
||||
|
||||
/// PUT, then both reads (list + userMassInfo overlay) reflect the same committed
|
||||
/// squad, projected via the shared identity path (real resourceIds + wire ids).
|
||||
#[test]
|
||||
fn coupled_read_after_write_list_and_usermassinfo_agree() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[1,2,3]");
|
||||
let (put, _) = handle_put_squad(&body, &deps);
|
||||
assert_eq!(put.status, 200);
|
||||
|
||||
// GET /squad/list
|
||||
let (list_resp, list_log) = handle_squad_list(&deps);
|
||||
assert_eq!(list_log.outcome, "ok");
|
||||
let list: Value = serde_json::from_slice(&list_resp.body).unwrap();
|
||||
let entry = &list["squad"][0];
|
||||
assert_eq!(entry["formation"], "f442");
|
||||
assert_eq!(entry["id"], 0);
|
||||
assert_eq!(entry["squadType"], "REGULAR_SQUAD");
|
||||
|
||||
// userMassInfo overlay against a mock Python
|
||||
let py_body = json!({
|
||||
"userInfo": {"personaId": 42, "clubName": "OpenFUT"},
|
||||
"settings": {"a": 1},
|
||||
"userData": {"b": 2},
|
||||
"pileSizeClientData": {"c": 3},
|
||||
"squad": {"id": 0, "personaId": 42, "players": [{"index": 0, "itemData": {"id": 777}, "kitNumber": 99}]},
|
||||
});
|
||||
let (py_url, _rec) = spawn_mock_python_json(py_body);
|
||||
let pass = PassClient::new(&py_url);
|
||||
let (umi_resp, umi_log) =
|
||||
handle_user_mass_info("GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass);
|
||||
assert_eq!(umi_log.outcome, "ok");
|
||||
let umi: Value = serde_json::from_slice(&umi_resp.body).unwrap();
|
||||
|
||||
// Unrelated fields preserved verbatim.
|
||||
assert_eq!(umi["userInfo"]["personaId"], 42);
|
||||
assert_eq!(umi["settings"]["a"], 1);
|
||||
assert_eq!(umi["userData"]["b"], 2);
|
||||
assert_eq!(umi["pileSizeClientData"]["c"], 3);
|
||||
|
||||
// .squad replaced by the Rust projection: persona preserved, envelope added.
|
||||
let sq = &umi["squad"];
|
||||
assert_eq!(sq["personaId"], 42, "persona preserved from Python");
|
||||
assert_eq!(sq["formation"], "f442");
|
||||
assert_eq!(sq["captain"], w["oc-a"], "captain is the wire id, not resourceId");
|
||||
let occ: Vec<&Value> = sq["players"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|p| p["itemData"]["id"].as_i64().unwrap() != 0)
|
||||
.collect();
|
||||
assert_eq!(occ.len(), 2, "the two committed players, not Python's 777");
|
||||
// DERIVED identity: wire id + real resourceId from the production catalog.
|
||||
let p0 = occ.iter().find(|p| p["index"] == 0).unwrap();
|
||||
assert_eq!(p0["itemData"]["id"], w["oc-a"]);
|
||||
assert_eq!(p0["itemData"]["resourceId"], 20801);
|
||||
assert_eq!(p0["kitNumber"], 1);
|
||||
let p1 = occ.iter().find(|p| p["index"] == 1).unwrap();
|
||||
assert_eq!(p1["itemData"]["resourceId"], 158023);
|
||||
assert_eq!(p1["kitNumber"], 9);
|
||||
assert!(umi["squad"]["players"].as_array().unwrap().iter().all(|p| p["itemData"]["id"] != 777),
|
||||
"no Python squad content survives");
|
||||
|
||||
// Content-Length matches the rewritten body.
|
||||
let cl = umi_resp
|
||||
.headers
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case("content-length"))
|
||||
.map(|(_, v)| v.parse::<usize>().unwrap());
|
||||
assert_eq!(cl, Some(umi_resp.body.len()), "Content-Length fixed after overlay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_path_is_bounded_no_per_slot_lookup() {
|
||||
let items = vec![gk(), st()];
|
||||
let (resolver, w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 2);
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let body = put_body("f442", w["oc-a"], &[(0, w["oc-a"], 1), (1, w["oc-b"], 9)], "[]");
|
||||
handle_put_squad(&body, &deps);
|
||||
let reads_before = core.read_calls();
|
||||
let owned_before = core.calls();
|
||||
let (_r, log) = handle_squad_list(&deps);
|
||||
assert_eq!(log.outcome, "ok");
|
||||
assert_eq!(core.read_calls() - reads_before, 1, "exactly one squad read");
|
||||
assert_eq!(core.calls() - owned_before, 1, "exactly one batch owned fetch (no per-slot)");
|
||||
}
|
||||
|
||||
// ── Stale / Missing integrity ────────────────────────────────────────────────
|
||||
|
||||
fn read_with_ext(ext: CoreExtState) -> CoreSquadRead {
|
||||
CoreSquadRead {
|
||||
name: "OpenFUT".into(),
|
||||
formation: "f442".into(),
|
||||
slots: vec![CoreSquadSlot {
|
||||
owned_card_id: "oc-a".into(),
|
||||
index: 0,
|
||||
is_captain: true,
|
||||
is_on_bench: false,
|
||||
}],
|
||||
ext,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_extension_is_not_applied_on_reads() {
|
||||
let items = vec![gk()];
|
||||
let (resolver, _w) = resolver_with_wires(&items, ASSETS);
|
||||
let core = FakeCore::new(items.clone(), 1)
|
||||
.with_squad(read_with_ext(CoreExtState::Stale { schema_version: 1, payload: "{}".into() }));
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let (resp, log) = handle_squad_list(&deps);
|
||||
assert_eq!(log.outcome, "stale_integrity");
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(v["squad"].as_array().unwrap().len(), 0, "stale never projected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_extension_is_explicit_on_reads() {
|
||||
let items = vec![gk()];
|
||||
let (resolver, _w) = resolver_with_wires(&items, ASSETS);
|
||||
let core =
|
||||
FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing));
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let (resp, log) = handle_squad_list(&deps);
|
||||
assert_eq!(log.outcome, "missing_integrity");
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(v["squad"].as_array().unwrap().len(), 0, "missing never fabricated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usermassinfo_never_serves_python_squad_on_integrity_failure() {
|
||||
let items = vec![gk()];
|
||||
let (resolver, _w) = resolver_with_wires(&items, ASSETS);
|
||||
let core =
|
||||
FakeCore::new(items.clone(), 1).with_squad(read_with_ext(CoreExtState::Missing));
|
||||
let ent = entities();
|
||||
let deps = SquadDeps { core: &core, resolver: &resolver, entities: &ent };
|
||||
let py_body = json!({
|
||||
"userInfo": {"personaId": 7},
|
||||
"squad": {"id": 0, "players": [{"index": 0, "itemData": {"id": 777}, "kitNumber": 5}]},
|
||||
});
|
||||
let (py_url, _rec) = spawn_mock_python_json(py_body);
|
||||
let pass = PassClient::new(&py_url);
|
||||
let (resp, log) =
|
||||
handle_user_mass_info("GET", "/ut/game/fifa17/userMassInfo", &[], b"", &deps, &pass);
|
||||
assert_eq!(log.outcome, "missing_integrity");
|
||||
let v: Value = serde_json::from_slice(&resp.body).unwrap();
|
||||
assert_eq!(v["userInfo"]["personaId"], 7, "unrelated fields still Python");
|
||||
// Rust owns squad: Python's squad (id 777) must NOT survive.
|
||||
assert!(v["squad"]["players"].as_array().unwrap().is_empty());
|
||||
assert_eq!(v["squad"]["personaId"], 7, "persona preserved, squad emptied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_route_still_reaches_python() {
|
||||
let core = Arc::new(FakeCore::forbidden());
|
||||
let (py_url, rec) = spawn_mock_python();
|
||||
let server = build_server(core, &py_url, None);
|
||||
let resp = server.handle("POST", "/ut/game/fifa17/packs/purchase", &[], b"{}");
|
||||
assert_eq!(resp.status, 200);
|
||||
assert!(resp.headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("x-from-python")));
|
||||
assert_eq!(rec.lock().len(), 1, "reached Python");
|
||||
}
|
||||
Reference in New Issue
Block a user