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:
+588
-20
@@ -46,6 +46,14 @@ use openfut_adapter_fifa17::fut::club_response::{
|
|||||||
};
|
};
|
||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
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::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 openfut_identity::ExternalIdentityStore;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
@@ -56,30 +64,62 @@ use config::HostConfig;
|
|||||||
/// The route decision, taken once, before execution.
|
/// The route decision, taken once, before execution.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
/// The owned-player search, served from Core.
|
/// `GET …/club` — the owned-player search, served from Core.
|
||||||
Club,
|
Club,
|
||||||
|
/// `PUT …/squad/<n>` — 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.
|
/// Anything else — proxied verbatim to the Python oracle.
|
||||||
Passthrough,
|
Passthrough,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Classify a request. ONLY `GET /ut/game/<title>/club` (exact) is owned by Rust.
|
/// Classify a request ONCE, before execution. Rust owns exactly:
|
||||||
/// `/club/stats/*`, `/clubUser`, mutations, auth, packs, market, squad, etc. all
|
/// * `GET …/club`
|
||||||
/// fall through to Python.
|
/// * `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 {
|
pub fn classify(method: &str, path: &str) -> Route {
|
||||||
if method.eq_ignore_ascii_case("GET") && is_exact_club_path(path) {
|
let get = method.eq_ignore_ascii_case("GET");
|
||||||
Route::Club
|
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 {
|
} else {
|
||||||
Route::Passthrough
|
Some(tail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_exact_club_path(path: &str) -> bool {
|
fn is_exact_club_path(path: &str) -> bool {
|
||||||
// /ut/game/<seg>/club with nothing after and a non-empty title segment.
|
ut_tail(path) == Some("club")
|
||||||
match path.strip_prefix("/ut/game/") {
|
}
|
||||||
Some(rest) => match rest.split_once('/') {
|
|
||||||
Some((title, tail)) => !title.is_empty() && tail == "club",
|
/// `squad/<digits>` — the active/full squad save. `squad/active` (non-numeric) is
|
||||||
None => false,
|
/// 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,
|
None => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,11 +150,83 @@ pub struct CorePage {
|
|||||||
pub total: i64,
|
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
|
/// How the host reaches Core. The adapter never sees this — the host owns the
|
||||||
/// transport, mirroring the architecture rule. Tests inject a fake.
|
/// transport, mirroring the architecture rule. Tests inject a fake.
|
||||||
pub trait CoreAccess: Send + Sync {
|
pub trait CoreAccess: Send + Sync {
|
||||||
/// Query the owned inventory with semantic `/collection` query params.
|
/// Query the owned inventory with semantic `/collection` query params.
|
||||||
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError>;
|
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,
|
/// 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()))?;
|
let v: Value = resp.json().map_err(|e| CoreError::Parse(e.to_string()))?;
|
||||||
parse_core_page(&v)
|
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
|
/// 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 ────────────────────────────────
|
// ───────────────────────────── /club handler ────────────────────────────────
|
||||||
|
|
||||||
/// Safe, structured summary of a handled `/club` request (no auth/session/device
|
/// Safe, structured summary of a handled `/club` request (no auth/session/device
|
||||||
@@ -379,6 +613,301 @@ fn summarize(pairs: &[(&str, String)]) -> String {
|
|||||||
.join(",")
|
.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 ──────────────────────────────
|
// ───────────────────────────── HTTP wire types ──────────────────────────────
|
||||||
|
|
||||||
/// A response ready to write: status, headers, body.
|
/// A response ready to write: status, headers, body.
|
||||||
@@ -479,7 +1008,9 @@ impl PassClient {
|
|||||||
pub struct Server {
|
pub struct Server {
|
||||||
core: Arc<dyn CoreAccess>,
|
core: Arc<dyn CoreAccess>,
|
||||||
entities: Arc<Fifa17Entities>,
|
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>,
|
pass: Arc<PassClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -488,13 +1019,13 @@ impl Server {
|
|||||||
pub fn new(
|
pub fn new(
|
||||||
core: Arc<dyn CoreAccess>,
|
core: Arc<dyn CoreAccess>,
|
||||||
entities: Arc<Fifa17Entities>,
|
entities: Arc<Fifa17Entities>,
|
||||||
assets: Arc<dyn ItemIdentityResolver + Send + Sync>,
|
resolver: Arc<Fifa17IdentityResolver>,
|
||||||
pass: Arc<PassClient>,
|
pass: Arc<PassClient>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Server {
|
Server {
|
||||||
core,
|
core,
|
||||||
entities,
|
entities,
|
||||||
assets,
|
resolver,
|
||||||
pass,
|
pass,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -510,19 +1041,28 @@ impl Server {
|
|||||||
.map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?;
|
.map_err(|e| format!("loading card-definition catalog {}: {e}", cfg.catalog_path))?;
|
||||||
let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
|
let store = openfut_identity::JsonIdentityStore::open(&cfg.identity_store_path)
|
||||||
.map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?;
|
.map_err(|e| format!("opening identity store {}: {e}", cfg.identity_store_path))?;
|
||||||
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> =
|
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
|
||||||
Ok(Server {
|
Ok(Server {
|
||||||
core: Arc::new(HttpCoreClient::new(
|
core: Arc::new(HttpCoreClient::new(
|
||||||
cfg.core_url.clone(),
|
cfg.core_url.clone(),
|
||||||
Fifa17WireItemIdPolicy::GAME,
|
Fifa17WireItemIdPolicy::GAME,
|
||||||
)),
|
)),
|
||||||
entities: Arc::new(entities),
|
entities: Arc::new(entities),
|
||||||
assets,
|
resolver,
|
||||||
pass: Arc::new(PassClient::new(cfg.python_upstream.clone())),
|
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,
|
/// Route one request to a response. Classification happens here, once,
|
||||||
/// before either branch runs.
|
/// before either branch runs.
|
||||||
pub fn handle(
|
pub fn handle(
|
||||||
@@ -539,7 +1079,7 @@ impl Server {
|
|||||||
let deps = ClubDeps {
|
let deps = ClubDeps {
|
||||||
core: self.core.as_ref(),
|
core: self.core.as_ref(),
|
||||||
entities: self.entities.as_ref(),
|
entities: self.entities.as_ref(),
|
||||||
assets: self.assets.as_ref(),
|
assets: self.resolver.as_ref(),
|
||||||
};
|
};
|
||||||
let (resp, log) = handle_club(query, &deps);
|
let (resp, log) = handle_club(query, &deps);
|
||||||
eprintln!(
|
eprintln!(
|
||||||
@@ -548,6 +1088,34 @@ impl Server {
|
|||||||
);
|
);
|
||||||
resp
|
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 => {
|
Route::Passthrough => {
|
||||||
let resp = match self.pass.forward(method, target, headers, body) {
|
let resp = match self.pass.forward(method, target, headers, body) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ use openfut_adapter_fifa17::fut::club_response::{CoreOwnedItem, ItemIdentityReso
|
|||||||
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
use openfut_adapter_fifa17::fut::entities::Fifa17Entities;
|
||||||
use openfut_identity::JsonIdentityStore;
|
use openfut_identity::JsonIdentityStore;
|
||||||
use openfut_utas_host::{
|
use openfut_utas_host::{
|
||||||
classify, read_request, CoreAccess, CoreError, CorePage, Fifa17IdentityResolver,
|
classify, handle_put_squad, handle_squad_list, handle_user_mass_info, read_request,
|
||||||
HttpCoreClient, PassClient, Route, Server,
|
CoreAccess, CoreError, CoreExtState, CorePage, CoreReplaceRequest,
|
||||||
|
CoreReplaceResult, CoreSquadRead, CoreSquadSlot, Fifa17IdentityResolver, HttpCoreClient,
|
||||||
|
PassClient, Route, Server, SquadDeps,
|
||||||
};
|
};
|
||||||
use parking_lot::Mutex;
|
use parking_lot::Mutex;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -24,49 +26,58 @@ use serde_json::Value;
|
|||||||
/// (method, target, body) recorded by the mock upstream.
|
/// (method, target, body) recorded by the mock upstream.
|
||||||
type Recorded = Arc<Mutex<Vec<(String, String, Vec<u8>)>>>;
|
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 {
|
struct FakeCore {
|
||||||
items: Vec<CoreOwnedItem>,
|
items: Vec<CoreOwnedItem>,
|
||||||
total: i64,
|
total: i64,
|
||||||
calls: AtomicUsize,
|
calls: AtomicUsize,
|
||||||
|
read_calls: AtomicUsize,
|
||||||
|
replace_calls: AtomicUsize,
|
||||||
last_params: Mutex<Vec<(String, String)>>,
|
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,
|
panic_if_called: bool,
|
||||||
return_err: bool,
|
return_err: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FakeCore {
|
impl FakeCore {
|
||||||
fn new(items: Vec<CoreOwnedItem>, total: i64) -> Self {
|
fn new(items: Vec<CoreOwnedItem>, total: i64) -> Self {
|
||||||
FakeCore {
|
FakeCore { items, total, ..Default::default() }
|
||||||
items,
|
|
||||||
total,
|
|
||||||
calls: AtomicUsize::new(0),
|
|
||||||
last_params: Mutex::new(vec![]),
|
|
||||||
panic_if_called: false,
|
|
||||||
return_err: false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fn forbidden() -> Self {
|
fn forbidden() -> Self {
|
||||||
FakeCore {
|
FakeCore { panic_if_called: true, ..Default::default() }
|
||||||
items: vec![],
|
|
||||||
total: 0,
|
|
||||||
calls: AtomicUsize::new(0),
|
|
||||||
last_params: Mutex::new(vec![]),
|
|
||||||
panic_if_called: true,
|
|
||||||
return_err: false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fn erroring() -> Self {
|
fn erroring() -> Self {
|
||||||
FakeCore {
|
FakeCore { return_err: true, ..Default::default() }
|
||||||
items: vec![],
|
}
|
||||||
total: 0,
|
/// Preset the stored squad read (for read-path tests without a prior PUT).
|
||||||
calls: AtomicUsize::new(0),
|
fn with_squad(self, read: CoreSquadRead) -> Self {
|
||||||
last_params: Mutex::new(vec![]),
|
*self.squad.lock() = Some(read);
|
||||||
panic_if_called: false,
|
self
|
||||||
return_err: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
fn calls(&self) -> usize {
|
fn calls(&self) -> usize {
|
||||||
self.calls.load(Ordering::SeqCst)
|
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)> {
|
fn last(&self) -> Vec<(String, String)> {
|
||||||
self.last_params.lock().clone()
|
self.last_params.lock().clone()
|
||||||
}
|
}
|
||||||
@@ -74,10 +85,7 @@ impl FakeCore {
|
|||||||
|
|
||||||
impl CoreAccess for FakeCore {
|
impl CoreAccess for FakeCore {
|
||||||
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
|
fn query_owned(&self, params: &[(&str, String)]) -> Result<CorePage, CoreError> {
|
||||||
assert!(
|
assert!(!self.panic_if_called, "Core must NOT be called on this path");
|
||||||
!self.panic_if_called,
|
|
||||||
"Core must NOT be called on this path"
|
|
||||||
);
|
|
||||||
self.calls.fetch_add(1, Ordering::SeqCst);
|
self.calls.fetch_add(1, Ordering::SeqCst);
|
||||||
if self.return_err {
|
if self.return_err {
|
||||||
return Err(CoreError::Status(500));
|
return Err(CoreError::Status(500));
|
||||||
@@ -86,9 +94,64 @@ impl CoreAccess for FakeCore {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|(k, v)| (k.to_string(), v.clone()))
|
.map(|(k, v)| (k.to_string(), v.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
Ok(CorePage {
|
Ok(CorePage { items: self.items.clone(), total: self.total })
|
||||||
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 catalog = Fifa17CardCatalog::from_json_str(&doc).unwrap();
|
||||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||||
let assets: Arc<dyn ItemIdentityResolver + Send + Sync> =
|
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
|
||||||
Server::new(
|
Server::new(
|
||||||
core,
|
core,
|
||||||
Arc::new(entities()),
|
Arc::new(entities()),
|
||||||
assets,
|
resolver,
|
||||||
Arc::new(PassClient::new(upstream)),
|
Arc::new(PassClient::new(upstream)),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -536,8 +598,7 @@ fn club_end_to_end_through_real_resolver_and_sends_game_header() {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
let store = JsonIdentityStore::open(unique_store_path()).unwrap();
|
||||||
let resolver: Arc<dyn ItemIdentityResolver + Send + Sync> =
|
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||||
Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
|
||||||
let server = Server::new(
|
let server = Server::new(
|
||||||
Arc::new(HttpCoreClient::new(core_url, "fifa17")),
|
Arc::new(HttpCoreClient::new(core_url, "fifa17")),
|
||||||
Arc::new(entities()),
|
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:?}"
|
"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