From 85761390a81d26dbae8801712fbc16aa9f267a21 Mon Sep 17 00:00:00 2001 From: funman300 Date: Wed, 12 Aug 2026 03:04:24 +0000 Subject: [PATCH] =?UTF-8?q?feat(utas-host):=20FIFA17=20squad=20authority?= =?UTF-8?q?=20=E2=80=94=20PUT,=20/squad/list,=20userMassInfo=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ -> 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. --- openfut-utas-host/src/lib.rs | 608 ++++++++++++++++++++++++++- openfut-utas-host/tests/host_test.rs | 530 +++++++++++++++++++++-- 2 files changed, 1080 insertions(+), 58 deletions(-) diff --git a/openfut-utas-host/src/lib.rs b/openfut-utas-host/src/lib.rs index d2b8960..b55cb20 100644 --- a/openfut-utas-host/src/lib.rs +++ b/openfut-utas-host/src/lib.rs @@ -46,6 +46,14 @@ use openfut_adapter_fifa17::fut::club_response::{ }; use openfut_adapter_fifa17::fut::entities::Fifa17Entities; use openfut_adapter_fifa17::fut::owned_query::{map_to_core, parse_club_query, MapError}; +use openfut_adapter_fifa17::fut::squad::{parse_squad_put, save_ack, SquadWireResolver}; +use openfut_adapter_fifa17::fut::squad_ext::{ + build_squad_write, Fifa17SquadExtensionV1, SquadBuildError, EXT_NAMESPACE, EXT_SCHEMA_VERSION, +}; +use openfut_adapter_fifa17::fut::squad_projection::{ + project_squad, squad_list, user_mass_info_squad, ProjectionSlot, SquadExtInput, + SquadProjection, SquadProjectionInput, +}; use openfut_identity::ExternalIdentityStore; use serde_json::{json, Value}; @@ -56,30 +64,62 @@ use config::HostConfig; /// The route decision, taken once, before execution. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Route { - /// The owned-player search, served from Core. + /// `GET …/club` — the owned-player search, served from Core. Club, + /// `PUT …/squad/` — full squad replacement, committed to Core. + SquadReplace, + /// `GET …/squad/list` — the squad summary, projected from Core. + SquadList, + /// `GET …/userMassInfo` — proxied to Python, with only `.squad` overlaid. + UserMassInfo, /// Anything else — proxied verbatim to the Python oracle. Passthrough, } -/// Classify a request. ONLY `GET /ut/game//club` (exact) is owned by Rust. -/// `/club/stats/*`, `/clubUser`, mutations, auth, packs, market, squad, etc. all -/// fall through to Python. +/// Classify a request ONCE, before execution. Rust owns exactly: +/// * `GET …/club` +/// * `PUT …/squad/<n>` (numeric id; `…/squad/active` is NOT numeric → Python) +/// * `GET …/squad/list` +/// * `GET …/userMassInfo` (proxied, `.squad` overlaid) +/// +/// Everything else — `GET …/squad/<n>`, `…/squad/active`, `/clubUser`, auth, +/// packs, market, other mutations — falls through to Python. There is no +/// "try Rust then Python", so a squad mutation can never be double-applied. pub fn classify(method: &str, path: &str) -> Route { - if method.eq_ignore_ascii_case("GET") && is_exact_club_path(path) { - Route::Club + let get = method.eq_ignore_ascii_case("GET"); + let put = method.eq_ignore_ascii_case("PUT"); + if get && is_exact_club_path(path) { + return Route::Club; + } + match ut_tail(path) { + Some("squad/list") if get => Route::SquadList, + Some("userMassInfo") if get => Route::UserMassInfo, + Some(tail) if put && is_numeric_squad_tail(tail) => Route::SquadReplace, + _ => Route::Passthrough, + } +} + +/// The tail after `/ut/game/<title>/` (non-empty title), or `None`. +fn ut_tail(path: &str) -> Option<&str> { + let rest = path.strip_prefix("/ut/game/")?; + let (title, tail) = rest.split_once('/')?; + if title.is_empty() { + None } else { - Route::Passthrough + Some(tail) } } fn is_exact_club_path(path: &str) -> bool { - // /ut/game/<seg>/club with nothing after and a non-empty title segment. - match path.strip_prefix("/ut/game/") { - Some(rest) => match rest.split_once('/') { - Some((title, tail)) => !title.is_empty() && tail == "club", - None => false, - }, + ut_tail(path) == Some("club") +} + +/// `squad/<digits>` — the active/full squad save. `squad/active` (non-numeric) is +/// deliberately excluded so the multi-squad flow stays with Python until there is +/// retail evidence for it. +fn is_numeric_squad_tail(tail: &str) -> bool { + match tail.strip_prefix("squad/") { + Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()), None => false, } } @@ -110,11 +150,83 @@ pub struct CorePage { pub total: i64, } +/// One canonical squad slot as read back from Core (game-independent). +#[derive(Debug, Clone)] +pub struct CoreSquadSlot { + pub owned_card_id: String, + pub index: i64, + pub is_captain: bool, + pub is_on_bench: bool, +} + +/// Freshness of the stored opaque extension vs the current canonical squad, as +/// Core reports it. Carries the stored payload for Fresh/Stale (never applied +/// when Stale — the host decides policy). +#[derive(Debug, Clone)] +pub enum CoreExtState { + Fresh { schema_version: i64, payload: String }, + Stale { schema_version: i64, payload: String }, + Missing, +} + +/// The active squad, its canonical slots, and its opaque extension state — the +/// result of Core's `GET /squad/ext`. +#[derive(Debug, Clone)] +pub struct CoreSquadRead { + pub name: String, + /// FIFA formation token, verbatim (Core stores it opaquely). + pub formation: String, + pub slots: Vec<CoreSquadSlot>, + pub ext: CoreExtState, +} + +/// A canonical + extension squad replacement the host asks Core to commit +/// atomically (`PUT /squad/replace`). +pub struct CoreReplaceRequest { + pub name: Option<String>, + pub formation: Option<String>, + pub slots: Vec<CoreSquadSlot>, + pub client_reported: CoreClientEval, + pub ext_namespace: String, + pub ext_schema_version: i64, + pub ext_payload: String, +} + +/// Client-reported shadow evaluation carried through to Core (never Core's +/// authoritative evaluation). +#[derive(Debug, Clone, Default)] +pub struct CoreClientEval { + pub chemistry: Option<i64>, + pub rating: Option<i64>, + pub star_rating: Option<i64>, +} + +/// Outcome of a committed replacement. +#[derive(Debug, Clone)] +pub struct CoreReplaceResult { + pub squad_id: String, + pub canonical_fingerprint: String, + pub slots_written: usize, +} + /// How the host reaches Core. The adapter never sees this — the host owns the /// transport, mirroring the architecture rule. Tests inject a fake. pub trait CoreAccess: Send + Sync { /// Query the owned inventory with semantic `/collection` query params. fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError>; + + /// Every owned item for the active club, in one call (no pagination) — used + /// to assemble a whole squad projection and to authorize squad writes. The + /// default delegates to an unfiltered `query_owned`. + fn all_owned(&self) -> Result<Vec<CoreOwnedItem>, CoreError> { + Ok(self.query_owned(&[])?.items) + } + + /// Read the active squad + its opaque extension for `namespace`. + fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError>; + + /// Replace the active squad's canonical slots + opaque extension atomically. + fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError>; } /// Default HTTP implementation: `GET {core_url}/collection?…` (plain HTTP JSON, @@ -155,6 +267,118 @@ impl CoreAccess for HttpCoreClient { let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; parse_core_page(&v) } + fn read_squad_ext(&self, namespace: &str) -> Result<CoreSquadRead, CoreError> { + let url = format!("{}/squad/ext", self.base_url); + let resp = self + .client + .get(&url) + .header("X-OpenFUT-Game", &self.game) + .query(&[("namespace", namespace)]) + .send() + .map_err(|e| CoreError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; + parse_core_squad_read(&v) + } + + fn replace_squad(&self, req: &CoreReplaceRequest) -> Result<CoreReplaceResult, CoreError> { + let url = format!("{}/squad/replace", self.base_url); + let resp = self + .client + .put(&url) + .header("X-OpenFUT-Game", &self.game) + .json(&replace_request_body(req)) + .send() + .map_err(|e| CoreError::Http(e.to_string()))?; + let status = resp.status().as_u16(); + if !(200..300).contains(&status) { + return Err(CoreError::Status(status)); + } + let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?; + Ok(CoreReplaceResult { + squad_id: v.get("squad_id").and_then(|x| x.as_str()).unwrap_or("").to_string(), + canonical_fingerprint: v + .get("canonical_fingerprint") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + slots_written: v.get("slots_written").and_then(|x| x.as_u64()).unwrap_or(0) as usize, + }) + } +} + +/// Serialize a [`CoreReplaceRequest`] into the `PUT /squad/replace` JSON body. +pub fn replace_request_body(req: &CoreReplaceRequest) -> Value { + let slots: Vec<Value> = req + .slots + .iter() + .map(|s| { + json!({ + "owned_card_id": s.owned_card_id, + "slot": s.index, + "is_captain": s.is_captain, + "is_on_bench": s.is_on_bench, + }) + }) + .collect(); + json!({ + "name": req.name, + "formation": req.formation, + "slots": slots, + "client_reported": { + "client_reported_chemistry": req.client_reported.chemistry, + "client_reported_rating": req.client_reported.rating, + "client_reported_star_rating": req.client_reported.star_rating, + }, + "extension": { + "namespace": req.ext_namespace, + "schema_version": req.ext_schema_version, + "payload": req.ext_payload, + }, + }) +} + +/// Parse Core's `GET /squad/ext` response into a [`CoreSquadRead`]. +pub fn parse_core_squad_read(v: &Value) -> Result<CoreSquadRead, CoreError> { + let squad = v.get("squad").ok_or_else(|| CoreError::Parse("missing `squad`".into()))?; + let name = squad.get("name").and_then(|x| x.as_str()).unwrap_or("").to_string(); + let formation = squad + .get("formation") + .and_then(|x| x.as_str()) + .ok_or_else(|| CoreError::Parse("missing squad.formation".into()))? + .to_string(); + let players = v + .get("players") + .and_then(|p| p.as_array()) + .ok_or_else(|| CoreError::Parse("missing `players`".into()))?; + let slots = players + .iter() + .filter_map(|p| { + Some(CoreSquadSlot { + owned_card_id: p.get("owned_card_id")?.as_str()?.to_string(), + index: p.get("position_index")?.as_i64()?, + is_captain: p.get("is_captain").and_then(|x| x.as_bool()).unwrap_or(false), + is_on_bench: p.get("is_on_bench").and_then(|x| x.as_bool()).unwrap_or(false), + }) + }) + .collect(); + let ext_v = v.get("extension").ok_or_else(|| CoreError::Parse("missing `extension`".into()))?; + let ext = match ext_v.get("state").and_then(|x| x.as_str()) { + Some("fresh") => CoreExtState::Fresh { + schema_version: ext_v.get("schema_version").and_then(|x| x.as_i64()).unwrap_or(0), + payload: ext_v.get("payload").and_then(|x| x.as_str()).unwrap_or("").to_string(), + }, + Some("stale") => CoreExtState::Stale { + schema_version: ext_v.get("schema_version").and_then(|x| x.as_i64()).unwrap_or(0), + payload: ext_v.get("payload").and_then(|x| x.as_str()).unwrap_or("").to_string(), + }, + Some("missing") => CoreExtState::Missing, + other => return Err(CoreError::Parse(format!("unknown extension state {other:?}"))), + }; + Ok(CoreSquadRead { name, formation, slots, ext }) } /// Parse Core's `/collection` response `{ "collection": [...], "total": n }` into @@ -279,6 +503,16 @@ impl ItemIdentityResolver for Fifa17IdentityResolver { } } +/// The same production resolver reverses a wire id to a Core owned-instance id +/// for the squad PUT path — reusing the identity store, so `/club`, `/squad`, +/// and PUT all agree on wire↔owned. Identity ONLY; ownership is authorized +/// separately (a resolvable id is not proof of ownership). +impl SquadWireResolver for Fifa17IdentityResolver { + fn owned_id_for_wire(&self, wire: i64) -> Option<String> { + Fifa17IdentityResolver::owned_id_for_wire(self, wire) + } +} + // ───────────────────────────── /club handler ──────────────────────────────── /// Safe, structured summary of a handled `/club` request (no auth/session/device @@ -379,6 +613,301 @@ fn summarize(pairs: &[(&str, String)]) -> String { .join(",") } +// ───────────────────────────── Squad handlers ─────────────────────────────── + +/// FIFA wire id of the single active squad (`…/squad/0`). +pub const ACTIVE_SQUAD_WIRE_ID: i64 = 0; + +/// Dependencies for the Rust squad paths. `resolver` is the SAME production +/// identity resolver `/club` uses (forward shape + reverse wire→owned), so every +/// route agrees on wire↔owned identity. +pub struct SquadDeps<'a> { + pub core: &'a dyn CoreAccess, + pub resolver: &'a Fifa17IdentityResolver, + pub entities: &'a Fifa17Entities, +} + +/// Secret-free structured log line for a handled squad request. +#[derive(Debug, Clone)] +pub struct SquadLog { + pub outcome: &'static str, + pub detail: String, +} + +/// The active squad projected from Core, with the freshness policy applied. +enum HostProjection { + /// Fresh: the projected FIFA squad object (before any endpoint envelope). + Squad(Value), + /// Stored extension is stale vs the canonical squad — NEVER applied. + Stale, + /// No extension stored — nothing fabricated. + Missing, + /// Core unreachable / response unreadable / projection failed. + Error(String), +} + +/// Assemble the projection input from Core in a BOUNDED number of calls — one +/// `read_squad_ext` + one batch `all_owned`, never per slot — then project. The +/// Fresh/Stale/Missing policy is decided HERE, not buried in a default. +fn project_active_squad(deps: &SquadDeps<'_>) -> HostProjection { + let read = match deps.core.read_squad_ext(EXT_NAMESPACE) { + Ok(r) => r, + Err(e) => return HostProjection::Error(e.to_string()), + }; + let ext = match &read.ext { + CoreExtState::Fresh { schema_version, payload } => { + match Fifa17SquadExtensionV1::from_payload(*schema_version, payload) { + Ok(e) => e, + // Fresh but the payload does not parse as our schema: corruption, + // never coerced into a fabricated squad. + Err(e) => return HostProjection::Error(format!("fresh extension unreadable: {e}")), + } + } + CoreExtState::Stale { .. } => return HostProjection::Stale, + CoreExtState::Missing => return HostProjection::Missing, + }; + let owned = match deps.core.all_owned() { + Ok(v) => v, + Err(e) => return HostProjection::Error(e.to_string()), + }; + let owned_by_id: std::collections::HashMap<String, CoreOwnedItem> = + owned.into_iter().map(|i| (i.owned_card_id.clone(), i)).collect(); + let slots: Vec<ProjectionSlot> = read + .slots + .iter() + .map(|s| ProjectionSlot { + 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 input = SquadProjectionInput { + fifa_squad_id: ACTIVE_SQUAD_WIRE_ID, + name: read.name, + formation: read.formation, + slots, + ext: SquadExtInput::Fresh(ext), + owned: &owned_by_id, + }; + match project_squad(&input, deps.resolver, deps.entities) { + Ok(SquadProjection::Projected(v)) => HostProjection::Squad(v), + Ok(SquadProjection::Stale) => HostProjection::Stale, + Ok(SquadProjection::Missing) => HostProjection::Missing, + Err(e) => HostProjection::Error(e.to_string()), + } +} + +/// A small JSON error body (UTAS mutations that cannot be honoured fail loudly — +/// they are NEVER retried against Python, which would risk a double mutation). +fn error_response(status: u16, code: &str) -> WireResponse { + WireResponse { + status, + headers: vec![("Content-Type".to_string(), "application/json".to_string())], + body: format!("{{\"error\":\"{code}\"}}").into_bytes(), + } +} + +/// `PUT …/squad/<n>` — parse the full replacement, reverse-resolve every wire id, +/// AUTHORIZE every resolved item against the active club, then commit the +/// canonical squad + FIFA extension to Core in one transaction. On any failure +/// it returns an error and NEVER falls back to Python (no double mutation). +pub fn handle_put_squad(body: &[u8], deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { + let put = match parse_squad_put(body) { + Ok(p) => p, + Err(e) => { + return ( + error_response(400, "parse_error"), + SquadLog { outcome: "parse_error", detail: e.to_string() }, + ) + } + }; + // Reverse-resolve wire→owned and shape canonical + extension. Refuses on an + // unresolved wire id or the same owned item placed twice. + let build = match build_squad_write(&put, deps.resolver) { + Ok(b) => b, + Err(SquadBuildError::UnresolvedWireIds(ids)) => { + return ( + error_response(400, "unresolved_wire_ids"), + SquadLog { outcome: "unresolved_wire_ids", detail: format!("{ids:?}") }, + ) + } + Err(SquadBuildError::DuplicateOwnedItem(id)) => { + return ( + error_response(400, "duplicate_owned_item"), + SquadLog { outcome: "duplicate_owned_item", detail: id }, + ) + } + }; + // AUTHORIZATION — identity resolution is NOT authorization. Every resolved + // owned item MUST belong to the active club; a globally-valid wire id that + // belongs to another profile is rejected BEFORE any canonical mutation. + let owned_set: std::collections::HashSet<String> = match deps.core.all_owned() { + Ok(v) => v.into_iter().map(|i| i.owned_card_id).collect(), + Err(e) => { + return ( + error_response(502, "core_error"), + SquadLog { outcome: "core_error", detail: e.to_string() }, + ) + } + }; + for slot in &build.canonical.slots { + if !owned_set.contains(&slot.owned_card_id) { + return ( + error_response(403, "not_owned"), + SquadLog { outcome: "unauthorized_item", detail: slot.owned_card_id.clone() }, + ); + } + } + // Commit canonical + extension atomically. No Python fallback on failure. + let req = CoreReplaceRequest { + name: build.canonical.name.clone(), + formation: build.canonical.formation.clone(), + slots: build + .canonical + .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(), + client_reported: CoreClientEval { + chemistry: build.extension.client_reported.chemistry, + rating: build.extension.client_reported.rating, + star_rating: build.extension.client_reported.star_rating, + }, + ext_namespace: EXT_NAMESPACE.to_string(), + ext_schema_version: EXT_SCHEMA_VERSION, + ext_payload: build.extension.to_payload(), + }; + match deps.core.replace_squad(&req) { + Ok(_) => ( + json_response(&save_ack(put.id)), + SquadLog { outcome: "ok", detail: String::new() }, + ), + Err(e) => ( + error_response(502, "core_error"), + SquadLog { outcome: "core_error", detail: e.to_string() }, + ), + } +} + +/// `GET …/squad/list` — served from Core + the ONE projector. Stale/Missing are +/// integrity failures for the migrated dev profile: logged prominently, degraded +/// to an empty list, NEVER served from Python and NEVER projected from stale ext. +pub fn handle_squad_list(deps: &SquadDeps<'_>) -> (WireResponse, SquadLog) { + match project_active_squad(deps) { + HostProjection::Squad(v) => ( + json_response(&squad_list(&v)), + SquadLog { outcome: "ok", detail: String::new() }, + ), + HostProjection::Stale => ( + json_response(&json!({ "squad": [] })), + SquadLog { outcome: "stale_integrity", detail: "stale extension not applied".into() }, + ), + HostProjection::Missing => ( + json_response(&json!({ "squad": [] })), + SquadLog { outcome: "missing_integrity", detail: "no extension stored".into() }, + ), + HostProjection::Error(e) => ( + json_response(&json!({ "squad": [] })), + SquadLog { outcome: "core_error", detail: e }, + ), + } +} + +/// An explicit empty active squad used only when Rust squad authority cannot +/// produce a Fresh projection during a userMassInfo overlay. It is NOT Python's +/// squad (that would reintroduce split authority) and NOT fabricated extension +/// state — it is an honest "no squad available", surfaced with an ERROR log. +fn empty_squad_overlay(persona_id: i64) -> Value { + json!({ + "id": ACTIVE_SQUAD_WIRE_ID, + "personaId": persona_id, + "changed": 0, + "actives": [], + "players": [], + }) +} + +/// Replace `resp`'s body with `body`, fixing framing headers (Content-Length, +/// Content-Type; drops any stale length/transfer-encoding). +fn set_json_body(resp: &mut WireResponse, body: Vec<u8>) { + resp.headers.retain(|(k, _)| { + !k.eq_ignore_ascii_case("content-length") + && !k.eq_ignore_ascii_case("content-type") + && !k.eq_ignore_ascii_case("transfer-encoding") + }); + resp.headers.push(("Content-Type".to_string(), "application/json".to_string())); + resp.headers.push(("Content-Length".to_string(), body.len().to_string())); + resp.body = body; +} + +/// `GET …/userMassInfo` — proxy the request to Python verbatim, then overlay ONLY +/// `.squad` with the Rust/Core projection. Every unrelated field (`userInfo`, +/// `settings`, `userData`, `pileSizeClientData`, …) is preserved exactly. +pub fn handle_user_mass_info( + method: &str, + target: &str, + headers: &[(String, String)], + body: &[u8], + deps: &SquadDeps<'_>, + pass: &PassClient, +) -> (WireResponse, SquadLog) { + let mut resp = match pass.forward(method, target, headers, body) { + Ok(r) => r, + Err(e) => { + return ( + error_response(502, "upstream_unavailable"), + SquadLog { outcome: "python_unreachable", detail: e.to_string() }, + ) + } + }; + // Only a successful JSON object carrying `.squad` is overlaid; anything else + // is returned verbatim (we never invent a squad into an unrelated response). + if !(200..300).contains(&resp.status) { + return (resp, SquadLog { outcome: "python_non_2xx_passthrough", detail: String::new() }); + } + let mut root: Value = match serde_json::from_slice::<Value>(&resp.body) { + Ok(v) if v.is_object() => v, + _ => return (resp, SquadLog { outcome: "python_body_unusable_passthrough", detail: String::new() }), + }; + if root.get("squad").is_none() { + return (resp, SquadLog { outcome: "python_no_squad_passthrough", detail: String::new() }); + } + // Preserve the client's persona from Python's own response. + let persona = root["squad"] + .get("personaId") + .and_then(|x| x.as_i64()) + .or_else(|| root.get("userInfo").and_then(|u| u.get("personaId")).and_then(|x| x.as_i64())) + .unwrap_or(0); + let (squad_val, log) = match project_active_squad(deps) { + HostProjection::Squad(v) => ( + user_mass_info_squad(v, persona), + SquadLog { outcome: "ok", detail: String::new() }, + ), + HostProjection::Stale => ( + empty_squad_overlay(persona), + SquadLog { outcome: "stale_integrity", detail: "stale extension not applied".into() }, + ), + HostProjection::Missing => ( + empty_squad_overlay(persona), + SquadLog { outcome: "missing_integrity", detail: "no extension stored".into() }, + ), + HostProjection::Error(e) => ( + empty_squad_overlay(persona), + SquadLog { outcome: "core_error", detail: e }, + ), + }; + root["squad"] = squad_val; + let new_body = serde_json::to_vec(&root).unwrap_or_else(|_| resp.body.clone()); + set_json_body(&mut resp, new_body); + (resp, log) +} + // ───────────────────────────── HTTP wire types ────────────────────────────── /// A response ready to write: status, headers, body. @@ -479,7 +1008,9 @@ impl PassClient { pub struct Server { core: Arc<dyn CoreAccess>, entities: Arc<Fifa17Entities>, - assets: Arc<dyn ItemIdentityResolver + Send + Sync>, + /// The single production identity resolver, shared by `/club`, `/squad/*` + /// and the userMassInfo overlay — one wire↔owned identity everywhere. + resolver: Arc<Fifa17IdentityResolver>, pass: Arc<PassClient>, } @@ -488,13 +1019,13 @@ impl Server { pub fn new( core: Arc<dyn CoreAccess>, entities: Arc<Fifa17Entities>, - assets: Arc<dyn ItemIdentityResolver + Send + Sync>, + resolver: Arc<Fifa17IdentityResolver>, pass: Arc<PassClient>, ) -> Self { Server { core, entities, - assets, + resolver, pass, } } @@ -510,19 +1041,28 @@ impl Server { .map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?; let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path) .map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?; - 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))); Ok(Server { core: Arc::new(HttpCoreClient::new( cfg.core_url.clone(), Fifa17WireItemIdPolicy::GAME, )), entities: Arc::new(entities), - assets, + resolver, pass: Arc::new(PassClient::new(cfg.python_upstream.clone())), }) } + /// Assemble the shared squad dependencies (Core access + the one production + /// resolver + entity tables). + fn squad_deps(&self) -> SquadDeps<'_> { + SquadDeps { + core: self.core.as_ref(), + resolver: self.resolver.as_ref(), + entities: self.entities.as_ref(), + } + } + /// Route one request to a response. Classification happens here, once, /// before either branch runs. pub fn handle( @@ -539,7 +1079,7 @@ impl Server { let deps = ClubDeps { core: self.core.as_ref(), entities: self.entities.as_ref(), - assets: self.assets.as_ref(), + assets: self.resolver.as_ref(), }; let (resp, log) = handle_club(query, &deps); eprintln!( @@ -548,6 +1088,34 @@ impl Server { ); resp } + Route::SquadReplace => { + let deps = self.squad_deps(); + let (resp, log) = handle_put_squad(body, &deps); + eprintln!( + "utas-host owner=RUST route=squad-replace status={} outcome={} detail=[{}]", + resp.status, log.outcome, log.detail + ); + resp + } + Route::SquadList => { + let deps = self.squad_deps(); + let (resp, log) = handle_squad_list(&deps); + eprintln!( + "utas-host owner=RUST route=squad-list status={} outcome={} detail=[{}]", + resp.status, log.outcome, log.detail + ); + resp + } + Route::UserMassInfo => { + let deps = self.squad_deps(); + let (resp, log) = + handle_user_mass_info(method, target, headers, body, &deps, self.pass.as_ref()); + eprintln!( + "utas-host owner=RUST_OVERLAY route=userMassInfo status={} squad_outcome={} detail=[{}]", + resp.status, log.outcome, log.detail + ); + resp + } Route::Passthrough => { let resp = match self.pass.forward(method, target, headers, body) { Ok(r) => r, diff --git a/openfut-utas-host/tests/host_test.rs b/openfut-utas-host/tests/host_test.rs index 50fc623..714d6b2 100644 --- a/openfut-utas-host/tests/host_test.rs +++ b/openfut-utas-host/tests/host_test.rs @@ -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"); +} \ No newline at end of file