Files
OpenFUT/openfut-adapter-fifa17/src/blaze/responses.rs
T
funman300 f451406058 audit: eliminate deployment-address hardcoding; single typed endpoint config
Mandatory OpenFUT architecture audit. Two real defects found and fixed, plus
the config surface tightened so neither class can recur.

DEFECT 1 -- hidden localhost fallback. The Rust host defaulted POW hosts to
127.0.0.1 while every other URL followed OPENFUT_ADVERTISE, so a remote
deployment would emit loopback POW URLs and fail far from the cause. It also
diverged from the deployed Python entrypoint, which derives them
(POW_HOST="${POW_HOST:-$ADV:8094}"). POW endpoints now derive from the
advertised address; explicit overrides still win.

DEFECT 2 -- Default gave loopback silently. `Endpoints::default()` and
`AdapterConfig::default()` supplied 127.0.0.1, so anything constructing a
config by omission got loopback with no signal. Both `Default` impls are
REMOVED. Loopback is now `Endpoints::loopback()` / `AdapterConfig::loopback()`:
an explicit, greppable decision. Production uses `advertising(host)`.

CONFIGURABILITY. `blaze_port` and `utas_port` are now config, not literals.
The advertised Blaze port is our choice -- the client goes wherever
<serverinstanceinfo> sends it -- and 8099 is the client's own built-in default
but still deployment config. A bad port value is an error, not a silent
fallback to the previous one.

TEST-NET EVERYWHERE. Committed fixtures and tests used the lab's real LAN
address; a test that passes because its constant matches the current lab
proves nothing about relocatability. Redirector fixtures regenerated on
RFC 5737 TEST-NET-1/2/3 plus loopback. Harness scripts no longer default the
client IP to the lab address -- client-state.sh now requires it.

SEVEN REQUIRED TESTS in tests/deployment_config.rs plus host-side coverage:
remote config never silently becomes localhost; missing advertise fails
clearly; bind may differ from advertise; changing the Blaze port changes the
redirect; changing the host updates all 200+ generated URLs with no
stragglers; no helper bypasses central config; mutations are detectable.

MUTATION TESTED, and it found a hole in the audit tests themselves. Hardcoding
utas_base, reverting the POW derivation and re-hardcoding the Blaze port were
all caught. Making the redirector read `bind` instead of `advertise` was NOT:
`advertising()` sets bind == advertise, so the two sources were
indistinguishable. That is the single most likely bypass -- the oracle really
does read bind for nucleusConnect -- so the test now forces bind != advertise
and asserts the bind address never reaches the wire. Re-mutated: caught.

Wire behaviour unchanged: oracle fixtures still current, 153 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-11 02:55:50 +00:00

624 lines
22 KiB
Rust

//! FIFA 17 Blaze response bodies.
//!
//! Every builder here mirrors a `Blaze::*` TDF class reversed from FIFA17.exe's
//! own reflection metadata. Member counts and tags are not guesses, and the
//! comments carry the class addresses so a future reader can re-derive them.
//!
//! Two recurring rules, both learned the hard way:
//!
//! * **An absent member is safe; a wrongly-typed one is fatal.** A member the
//! client does not receive keeps its client-side default. A member encoded
//! with the wrong wire type desynchronises the whole TDF parse. That is why
//! `CGID`, `ADDR`, `CVAR` and `ULST` are omitted rather than guessed — their
//! union/objid encodings are UNVERIFIED.
//! * **Identity must be byte-identical across responses.** `MAIL`/`UID`/`ASRC`
//! in `AccountInfo` must match `LoginResponse.SESS` and `PreAuthResponse.NASP`,
//! and the session key must be the same string in three places.
//!
//! Member order in the source below is the oracle's for readability; the
//! encoder sorts by packed tag, so source order never reaches the wire.
use openfut_protocol_blaze::heat2::{Struct, TypeId, Value};
use super::client_config;
use super::config::AdapterConfig;
use super::ids::ADVERTISED_COMPONENT_IDS;
use super::session::Session;
fn s(v: impl Into<String>) -> Value {
Value::String(v.into())
}
fn i(v: i64) -> Value {
Value::Int(v)
}
/// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF`
/// map<string,string>.
///
/// NOT double-nested. The extra nesting exists only inside `PreAuthResponse`,
/// where `CONF` is itself a `FetchConfigResponse` whose own single member is
/// also called `CONF`. Easy to get wrong.
pub fn fetch_config_response(cfid: &str, cfg: &AdapterConfig) -> Struct {
let entries = client_config::rows_for(cfid, cfg)
.into_iter()
.map(|(k, v)| (Value::String(k), Value::String(v)))
.collect();
Struct::new().with(
"CONF",
Value::Map {
key: TypeId::String,
val: TypeId::String,
entries,
},
)
}
/// `Blaze::QosConfigInfo` — 4 members.
///
/// FIFA 17's descriptor has no `SVID`, unlike Mirror's Edge Catalyst; do not
/// add one back from another title's emulator.
pub fn qos_config(cfg: &AdapterConfig) -> Struct {
Struct::new()
.with(
"BWPS",
Value::Struct(
Struct::new()
.with("PSA", s(&cfg.endpoints.advertise))
.with("PSP", i(cfg.endpoints.qos_port)),
),
)
.with("LNP", i(10))
.with(
"LTPS",
Value::Map {
key: TypeId::String,
val: TypeId::Struct,
entries: vec![],
},
)
.with("TIME", i(5_000_000))
}
/// `Blaze::Util::PreAuthResponse`.
pub fn preauth_response(service_name: &str, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("ASRC", s(&id.title_id))
.with(
"CIDS",
Value::List {
elem: TypeId::Int,
items: ADVERTISED_COMPONENT_IDS.iter().copied().map(i).collect(),
},
)
.with("CLID", s(&id.client_id))
.with(
"CONF",
Value::Struct(fetch_config_response("BlazeSDK", cfg)),
)
.with("ESRC", s(&id.title_id))
.with("INST", s(service_name)) // echo of CDAT.SVCN
.with("MAID", i(0))
.with("MINR", i(0))
.with("NASP", s(&id.namespace))
.with("PILD", s(""))
.with("PLAT", s(&id.platform))
.with("QOSS", Value::Struct(qos_config(cfg)))
.with("RSRC", s(&id.title_id))
.with("SVER", s(&cfg.server_version))
}
/// `Blaze::Util::PingResponse` @0x144875560 — exactly one member.
///
/// v2 also sent `TIME`; that is MEC's field, not FIFA 17's.
pub fn ping_response(now: i64) -> Struct {
Struct::new().with("STIM", i(now))
}
/// `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` — 3 TimeValues,
/// encoded as INT microseconds.
///
/// These must be non-zero. The client computes `delay_ms = (CNP + NTMT) / 1000`
/// and re-arms a resend timer; an empty reply gives delay 0, which lands the job
/// on the scheduler's ready list and produces a ~30/s re-subscribe storm that
/// hangs the FUT loading screen.
pub fn census_subscribe_response() -> Struct {
Struct::new()
.with("CNP", i(30 * 1_000_000))
.with("NTMT", i(90 * 1_000_000))
.with("RTMT", i(300 * 1_000_000))
}
/// `Blaze::Authentication::PersonaDetails` @0x14487cab0 — 6 members.
pub fn persona_details(now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("DSNM", s(&id.persona_name))
.with("LAST", i(now))
.with("PID", i(id.persona_id))
.with("PLAT", i(id.client_platform))
.with("STAS", i(id.persona_status))
.with("XREF", i(id.ext_id))
}
/// `Blaze::Authentication::UserLoginInfo` @0x14487cb00 — 8 members.
///
/// `'1CON'` packs to 0x11, which sorts below `'A'` = 0x21, so it leads.
pub fn user_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("1CON", i(0))
.with("BUID", i(id.user_id)) // must be non-zero
.with("FRST", i(0))
.with("KEY", s(&sess.session_key)) // must be non-empty
.with("LLOG", i(now))
.with("MAIL", s(&id.email))
.with("PDTL", Value::Struct(persona_details(now, cfg)))
.with("UID", i(id.user_id)) // must be non-zero
}
/// `Blaze::Authentication::LoginResponse` @0x14487d170 — exactly 5 members.
///
/// Diverges from both public MEC emulators, which emit `CNTX`, `ERRC` and a
/// top-level `SKEY`. FIFA 17 has none of those: `CNTX`/`ERRC` are the Blaze
/// error-metadata block, and the session key lives at `SESS.KEY`.
pub fn login_response(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("ANON", i(0))
.with("NTOS", i(0)) // 1 would divert to the legal-doc flow
.with("SESS", Value::Struct(user_login_info(sess, now, cfg)))
.with("SPAM", i(1))
.with("UNDR", i(0))
}
/// ISO-8601 UTC, matching the oracle's `%Y-%m-%dT%H:%M:%SZ`.
///
/// Hand-rolled from a Unix timestamp to keep this crate free of a date
/// dependency for one format string. Proleptic Gregorian, no leap seconds —
/// the same calendar `time.gmtime` uses.
fn iso8601_utc(unix: i64) -> String {
let days = unix.div_euclid(86_400);
let secs = unix.rem_euclid(86_400);
let (h, mi, sec) = (secs / 3600, (secs % 3600) / 60, secs % 60);
// Civil-from-days (Howard Hinnant's algorithm), shifted to a March-based year.
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
}
/// `Blaze::Authentication::AccountInfo` @0x14487c810 — exactly 16 members.
///
/// The RPC behind the "Unable to retrieve account information" popup: before it
/// was implemented, the empty-reply fallback produced an AccountInfo with
/// `UID=0`/`CO=""` and the popup appeared one layer later.
///
/// Member tags come from the reflection tag table @0x1448775a0; wire types from
/// each member's subtype descriptor (string subtype 0x144867628 covers ASRC CO
/// DOB DTCR LATH LN MAIL PML; the other eight are int/enum). Enum values:
/// `STAS` = AccountStatus ACTIVE = 1, `STAT` = EmailStatus VERIFIED = 2,
/// `RC` = StatusReason none = 0.
pub fn account_info(now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("AMU", i(0))
.with("ASRC", s(&id.namespace)) // == PreAuthResponse.NASP
.with("CO", s("US"))
.with("DOB", s("1990-01-01T00:00:00Z"))
.with("DTCR", s("2016-09-01T00:00:00Z"))
.with("GOPT", i(0))
.with("LATH", s(iso8601_utc(now)))
.with("LN", s(&id.locale))
.with("MAIL", s(&id.email)) // == LoginResponse.SESS.MAIL
.with("PML", s(""))
.with("RC", i(0))
.with("STAS", i(1))
.with("STAT", i(2))
.with("TPOT", i(0))
.with("UDU", i(0))
.with("UID", i(id.user_id)) // == LoginResponse.SESS.UID
}
/// `Blaze::Authentication::PersonaInfo` @0x14487c7c0 — 7 members.
///
/// `STAS` here is PersonaStatus ACTIVE = 2 (table 0x14487ad20) — a different
/// enum from AccountInfo's `STAS`, which is AccountStatus ACTIVE = 1. `LADT`'s
/// wire type is a best guess (INT timestamp); it is only reachable via
/// getPersona/listPersonas, off the critical getAccount path.
pub fn persona_info(now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("DSNM", s(&id.persona_name))
.with("DTCR", s("2016-09-01T00:00:00Z"))
.with("LADT", i(now))
.with("NSNM", s(&id.namespace))
.with("PID", i(id.persona_id))
.with("STAS", i(2))
.with("STRC", i(0))
}
/// `Blaze::Authentication::GetPersonaResponse` @0x14487d1c0 — PINF + UID.
pub fn get_persona_response(now: i64, cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("PINF", Value::Struct(persona_info(now, cfg)))
.with("UID", i(cfg.identity.user_id))
}
/// `Blaze::Authentication::ListPersonasResponse` @0x14487d210 — one member.
pub fn list_personas_response(now: i64, cfg: &AdapterConfig) -> Struct {
Struct::new().with(
"PINF",
Value::List {
elem: TypeId::Struct,
items: vec![Value::Struct(persona_info(now, cfg))],
},
)
}
/// `Blaze::UserSessionLoginInfo` @0x14486f920 — 16 members.
///
/// A superset of `UserLoginInfo` with the persona fields flattened in rather
/// than nested. `KEY` must be byte-identical to `LoginResponse.SESS.KEY`.
///
/// `CGID` (a connectionGroup ObjectId) is omitted: the OBJID encoding is
/// UNVERIFIED and a wrong one desynchronises the parse, while an absent member
/// simply keeps its default.
pub fn user_session_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("1CON", i(0))
.with("ALOC", i(sess.account_locale)) // echo the client's own locale
.with("BUID", i(id.user_id))
.with("DSNM", s(&id.persona_name))
.with("FRST", i(0))
.with("KEY", s(&sess.session_key)) // same string as LoginResponse
.with("LAST", i(now))
.with("LLOG", i(now))
.with("MAIL", s(&id.email))
.with("NASP", s(&id.namespace))
.with("PID", i(id.persona_id))
.with("PLAT", i(id.client_platform))
.with("UID", i(id.user_id))
.with("USTP", i(id.user_session_type))
.with("XREF", i(id.ext_id))
}
/// `Blaze::Util::NetworkQosData` @0x14486e680 — 5 members. `NATT` 0 = OPEN.
pub fn network_qos_data() -> Struct {
Struct::new()
.with("BWHR", i(0))
.with("DBPS", i(100_000))
.with("NAHR", i(0))
.with("NATT", i(0))
.with("UBPS", i(100_000))
}
/// `Blaze::UserSessionExtendedData` @0x144870390.
///
/// Two FIFA-17-specific deltas from the MEC emulators: FIFA HAS `PSLM`
/// (latencyList), which they lack, and FIFA carries `BPS` as a top-level string
/// whereas they bury it inside the `ADDR` union. Follow FIFA's layout.
///
/// `ADDR`, `CVAR` and `ULST` are omitted — unverified union/objid encodings.
pub fn user_session_extended_data() -> Struct {
Struct::new()
.with("BPS", s("openfut"))
.with("CTY", s("US"))
.with(
"DMAP",
Value::Map {
key: TypeId::Int,
val: TypeId::Int,
entries: vec![],
},
)
.with("HWFG", i(0))
.with("ISP", s("OpenFUT"))
.with(
"PSLM",
Value::List {
elem: TypeId::Int,
items: vec![i(0)],
},
)
.with("QDAT", Value::Struct(network_qos_data()))
.with("TZ", s(""))
.with("UATT", i(0))
}
/// `Blaze::UserSessionExtendedDataUpdate` @0x1448703e0 — 3 members.
pub fn user_session_extended_data_update(cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("DATA", Value::Struct(user_session_extended_data()))
.with("SUBS", i(1))
.with("USID", i(cfg.identity.user_id))
}
/// `Blaze::UserIdentification` @0x14486ebc0 — 9 members.
pub fn user_identification(sess: &Session, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("AID", i(id.user_id))
.with("ALOC", i(sess.account_locale))
.with("EXBB", Value::Blob(vec![]))
.with("EXID", i(id.ext_id))
.with("ID", i(id.user_id))
.with("NAME", s(&id.persona_name))
.with("NASP", s(&id.namespace))
.with("ORIG", i(id.persona_id))
.with("PIDI", i(id.persona_id))
}
/// `Blaze::UserData` @0x1448706b0 — payload of the `UserAdded` push.
/// `FLGS` is a UserDataFlags bitfield; bit 0 = online/authenticated.
pub fn user_data(sess: &Session, cfg: &AdapterConfig) -> Struct {
Struct::new()
.with("EDAT", Value::Struct(user_session_extended_data()))
.with("FLGS", i(3))
.with("USER", Value::Struct(user_identification(sess, cfg)))
}
/// `Blaze::Util::PostAuthResponse` @0x144875810 — TELE, TICK, UROP.
///
/// Telemetry and ticker point at dead local ports on purpose: the client gets a
/// well-formed config and then fails to connect quietly, rather than resolving
/// a real EA hostname.
pub fn post_auth_response(sess: &Session, cfg: &AdapterConfig) -> Struct {
let tele = Struct::new()
.with("ADRS", s(&cfg.endpoints.advertise))
.with("ANON", i(0))
.with("DISA", s(""))
.with("EDCT", i(0))
.with("FILT", s(""))
.with("LOC", i(sess.account_locale))
.with("MINR", i(0))
.with("NOOK", s(""))
.with("PORT", i(cfg.endpoints.telemetry_port))
.with("SDLY", i(15_000))
.with("SESS", s(&sess.session_key)) // same key as login
.with("SKEY", s(""))
.with("SPCT", i(75))
.with("STIM", s(""))
.with("SVNM", s("telemetry-openfut"));
let tick = Struct::new()
.with("ADRS", s(&cfg.endpoints.advertise))
.with("PORT", i(cfg.endpoints.ticker_port))
.with("SKEY", s(""));
let urop = Struct::new()
.with("TMOP", i(0))
.with("UID", i(cfg.identity.user_id));
Struct::new()
.with("TELE", Value::Struct(tele))
.with("TICK", Value::Struct(tick))
.with("UROP", Value::Struct(urop))
}
/// `Blaze::Authentication::Entitlement` @0x14487d490 — 16 members.
///
/// FUT's client-side filter (`onListEntitlements` @0x146f27440) keeps a record
/// only if `GNAM` contains `"FIFA17PCBoxContent"` or `"FIFA16PC"`, `TAG` is
/// non-empty, and `STAT == 1`. A plain `"FIFA17PC"` group matched neither
/// needle and produced an empty store.
///
/// `PRID`/`GNAM`/`TAG` must contain no `'|'` and no `'/'`: the client
/// re-serialises them as `PRID|GNAM|TAG|UCNT/`.
pub fn entitlement(group: &str, tag: &str, eid: i64, cfg: &AdapterConfig) -> Struct {
let id = &cfg.identity;
Struct::new()
.with("DEVI", s(""))
.with("GDAY", s("2016-09-01T00:00:00Z"))
.with("GNAM", s(group))
.with("ID", i(eid))
.with("ISCO", i(0))
.with("PID", i(id.persona_id))
.with("PJID", s(&id.content_id))
.with("PRCA", i(2))
.with("PRID", s(&id.content_id))
.with("STAT", i(1)) // must be 1 or FUT drops it
.with("STRC", i(0))
.with("TAG", s(tag)) // must be non-empty
.with("TDAY", s(""))
.with("TYPE", i(1))
.with("UCNT", i(0))
.with("VER", i(1))
}
/// `Blaze::Authentication::Entitlements` @0x14487d4e0 — single member `NLST`.
///
/// Emits BOTH accepted groups so the entitlement manager's "loaded" flag
/// (`byte[entMgr+0x88]`) flips however the client asks.
pub fn entitlements_response(cfg: &AdapterConfig) -> Struct {
let tag = &cfg.identity.entitlement_tag;
Struct::new().with(
"NLST",
Value::List {
elem: TypeId::Struct,
items: vec![
Value::Struct(entitlement("FIFA17PCBoxContent", tag, 1, cfg)),
Value::Struct(entitlement("FIFA16PC", tag, 2, cfg)),
],
},
)
}
/// `Blaze::Authentication::GetAuthTokenResponse` @0x14487d080 — one member.
pub fn get_auth_token_response(sess: &Session) -> Struct {
Struct::new().with("AUTH", s(sess.auth_token()))
}
/// `Util::userSettingsLoad` response.
///
/// TODO(verify): the response descriptor was never reflected. Both independent
/// clean-room emulators use a single `DATA` string, and an unknown-tag payload
/// is ignored rather than fatal, so an empty `DATA` is the safe minimum — the
/// client falls back to its defaults.
pub fn user_settings_response() -> Struct {
Struct::new().with("DATA", s(""))
}
/// `AssociationLists::getLists` response.
///
/// TODO(verify): FIFA's association-list names are NOT known — do not invent
/// them. An empty list is well-formed and means "this user has no association
/// lists", which is true offline.
pub fn get_lists_response() -> Struct {
Struct::new().with(
"LMAP",
Value::List {
elem: TypeId::Struct,
items: vec![],
},
)
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg() -> AdapterConfig {
AdapterConfig::loopback()
}
#[test]
fn iso8601_matches_known_instants() {
assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
assert_eq!(iso8601_utc(1_754_870_400), "2025-08-11T00:00:00Z");
// A leap day, to exercise the civil-from-days branch.
assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z");
assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z");
}
#[test]
fn login_response_has_exactly_five_members() {
let sess = Session::new("k", 0);
assert_eq!(login_response(&sess, 0, &cfg()).len(), 5);
}
#[test]
fn account_info_has_exactly_sixteen_members() {
assert_eq!(account_info(0, &cfg()).len(), 16);
}
#[test]
fn session_key_appears_identically_in_all_three_places() {
let sess = Session::new("THE-KEY", 0);
let c = cfg();
let login = login_response(&sess, 1, &c);
let in_login = login
.get("SESS")
.and_then(Value::as_struct)
.and_then(|s| s.get("KEY"))
.and_then(Value::as_str)
.unwrap();
let notify = user_session_login_info(&sess, 1, &c);
let in_notify = notify.get("KEY").and_then(Value::as_str).unwrap();
let post = post_auth_response(&sess, &c);
let in_post = post
.get("TELE")
.and_then(Value::as_struct)
.and_then(|s| s.get("SESS"))
.and_then(Value::as_str)
.unwrap();
assert_eq!(in_login, "THE-KEY");
assert_eq!(in_notify, "THE-KEY");
assert_eq!(in_post, "THE-KEY");
}
#[test]
fn identity_is_consistent_between_login_and_account_info() {
let sess = Session::new("k", 0);
let c = cfg();
let acct = account_info(0, &c);
let sess_info = user_login_info(&sess, 0, &c);
assert_eq!(
acct.get("MAIL").and_then(Value::as_str),
sess_info.get("MAIL").and_then(Value::as_str)
);
assert_eq!(
acct.get("UID").and_then(Value::as_int),
sess_info.get("UID").and_then(Value::as_int)
);
assert_eq!(
acct.get("ASRC").and_then(Value::as_str),
Some(c.identity.namespace.as_str())
);
}
#[test]
fn entitlement_groups_match_the_clients_needles() {
let c = cfg();
let list = entitlements_response(&c);
let items = match list.get("NLST") {
Some(Value::List { items, .. }) => items,
_ => panic!("NLST is a list"),
};
assert_eq!(items.len(), 2);
for item in items {
let e = item.as_struct().unwrap();
let gnam = e.get("GNAM").and_then(Value::as_str).unwrap();
assert!(
gnam.contains("FIFA17PCBoxContent") || gnam.contains("FIFA16PC"),
"group {gnam} matches neither client needle"
);
assert_eq!(e.get("STAT").and_then(Value::as_int), Some(1));
assert!(!e.get("TAG").and_then(Value::as_str).unwrap().is_empty());
// The client re-serialises these delimited; a separator would corrupt it.
for tag in ["PRID", "GNAM", "TAG"] {
let v = e.get(tag).and_then(Value::as_str).unwrap();
assert!(
!v.contains('|') && !v.contains('/'),
"{tag} has a separator"
);
}
}
}
#[test]
fn census_periods_are_non_zero() {
// Zero here is the ~30/s storm that hangs the FUT loading screen.
let r = census_subscribe_response();
assert!(r.get("CNP").and_then(Value::as_int).unwrap() > 0);
assert!(r.get("NTMT").and_then(Value::as_int).unwrap() > 0);
}
#[test]
fn preauth_echoes_the_requested_service_name() {
let p = preauth_response("fifa-2017-pc-de", &cfg());
assert_eq!(
p.get("INST").and_then(Value::as_str),
Some("fifa-2017-pc-de")
);
}
#[test]
fn extended_data_omits_the_unverified_members() {
// Absent is safe; a wrong union/objid encoding breaks the whole parse.
let d = user_session_extended_data();
for absent in ["ADDR", "CVAR", "ULST"] {
assert!(d.get(absent).is_none(), "{absent} must stay omitted");
}
assert!(
d.get("PSLM").is_some(),
"PSLM is FIFA-specific and required"
);
}
}