Files
OpenFUT/openfut-adapter-fifa17/tests/oracle_parity.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

403 lines
15 KiB
Rust

//! Differential tests: the Rust adapter against the Python Blaze responder.
//!
//! `openfut-protocol-blaze` proves the *codec* matches. This proves the layer
//! that decides **what to say**: for each inbound frame, the exact frames that
//! go back and their order.
//!
//! Every vector in `fixtures/blaze_transactions.jsonl` was produced by calling
//! the real `blaze_responder_v3b.dispatch()`. Transactions replay in file order
//! against a shared session per connection, so ordering-dependent behaviour is
//! exercised rather than assumed — preAuth captures the locale that later ALOC
//! fields echo, and login sets the auth code getAuthToken returns afterwards.
//!
//! Comparison is byte-for-byte, including frame count and order. A missing
//! post-login notification or a reply where the oracle stays silent is a
//! failure here, which is the whole point.
//!
//! Regenerate after any oracle change: python3 fixtures/generate.py
use std::collections::HashMap;
use openfut_adapter_fifa17::blaze::{Adapter, AdapterConfig, Endpoints, Identity, Session};
use openfut_protocol_blaze::fire2::Frame;
use openfut_protocol_blaze::heat2;
use serde_json::Value as J;
fn records() -> Vec<J> {
let path = format!(
"{}/fixtures/blaze_transactions.jsonl",
env!("CARGO_MANIFEST_DIR")
);
let text = std::fs::read_to_string(&path)
.unwrap_or_else(|e| panic!("cannot read {path}: {e}\nrun: python3 fixtures/generate.py"));
text.lines()
.filter(|l| !l.trim().is_empty())
.map(|l| serde_json::from_str(l).expect("fixture line is valid JSON"))
.collect()
}
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
.collect()
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
fn st(j: &J, k: &str) -> String {
j[k].as_str()
.unwrap_or_else(|| panic!("{k} is a string"))
.to_string()
}
fn n(j: &J, k: &str) -> i64 {
j[k].as_i64().unwrap_or_else(|| panic!("{k} is a number"))
}
/// Rebuild the exact configuration the fixtures were generated under.
///
/// The generator uses deliberately non-loopback addresses, so an adapter that
/// hardcoded one instead of reading its config fails loudly here rather than
/// coincidentally matching a default.
fn config_from(record: &J) -> (AdapterConfig, i64) {
let id = &record["identity"];
let identity = Identity {
persona_id: n(id, "persona_id"),
persona_name: st(id, "persona_name"),
user_id: n(id, "user_id"),
ext_id: n(id, "ext_id"),
email: st(id, "email"),
namespace: st(id, "namespace"),
client_platform: n(id, "client_platform"),
persona_status: n(id, "persona_status"),
user_session_type: n(id, "user_session_type"),
account_locale: n(id, "account_locale_int"),
locale: st(id, "locale"),
content_id: st(id, "content_id"),
entitlement_tag: st(id, "entitlement_tag"),
entitlement_group: st(id, "entitlement_group"),
title_id: st(id, "title_id"),
client_id: st(id, "client_id"),
platform: st(id, "platform"),
};
let endpoints = Endpoints {
advertise: st(record, "advertise"),
bind: st(record, "bind"),
pow_content_host: st(record, "pow_content_host"),
pow_host: st(record, "pow_host"),
..Endpoints::loopback()
};
let cfg = AdapterConfig {
identity,
endpoints,
server_version: st(&record["identity"], "server_version"),
};
(cfg, n(record, "now"))
}
struct Replay {
adapter: Adapter,
now: i64,
sessions: HashMap<String, Session>,
records: Vec<J>,
}
fn setup() -> Replay {
let records = records();
let cfg_rec = records
.iter()
.find(|r| r["kind"] == "config")
.expect("fixture carries a config record")
.clone();
let (cfg, now) = config_from(&cfg_rec);
let mut sessions = HashMap::new();
for r in &records {
if r["kind"] == "session" {
// The session key is injected, not generated: it appears verbatim
// in three responses, so a self-minted one could never match.
sessions.insert(
st(r, "id"),
Session::new(st(r, "session_key"), n(r, "account_locale")),
);
}
}
Replay {
adapter: Adapter::new(cfg),
now,
sessions,
records,
}
}
/// The headline test: replay every transaction and require identical frames.
#[test]
fn dispatch_matches_python_oracle_byte_for_byte() {
let mut rp = setup();
let records = rp.records.clone();
let mut checked = 0usize;
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let name = st(rec, "name");
let sid = st(rec, "session");
let request = unhex(&st(rec, "request_hex"));
let expected: Vec<String> = rec["responses"]
.as_array()
.expect("responses array")
.iter()
.map(|f| f.as_str().unwrap().to_string())
.collect();
let (frame, used) = Frame::parse(&request)
.unwrap_or_else(|e| panic!("{name}: fixture request does not parse: {e}"));
assert_eq!(used, request.len(), "{name}: trailing bytes in request");
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload)
.unwrap_or_else(|e| panic!("{name}: request body is not valid TDF: {e}"))
};
let session = rp.sessions.get_mut(&sid).expect("session declared");
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
assert_eq!(
out.len(),
expected.len(),
"\n{name}: produced {} frame(s), oracle produced {}",
out.len(),
expected.len()
);
for (idx, (got, want)) in out.iter().zip(expected.iter()).enumerate() {
let got_hex = hex(&got.encode());
if &got_hex != want {
// Narrow the failure to header vs body before dumping bytes.
let want_bytes = unhex(want);
let got_bytes = got.encode();
assert_eq!(
hex(&got_bytes[..16.min(got_bytes.len())]),
hex(&want_bytes[..16.min(want_bytes.len())]),
"\n{name} frame {idx}: HEADER differs"
);
panic!(
"\n{name} frame {idx}: BODY differs\n got {} bytes\n want {} bytes",
got_bytes.len().saturating_sub(16),
want_bytes.len().saturating_sub(16)
);
}
}
checked += 1;
}
assert!(
checked >= 40,
"expected the full script, replayed {checked}"
);
}
/// Frame counts and ordering are part of the contract, so assert them
/// separately from bytes — a rewrite that answered correctly but dropped a
/// notification would otherwise fail with an unhelpful byte diff.
#[test]
fn frame_counts_and_ordering_match() {
let mut rp = setup();
let records = rp.records.clone();
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let name = st(rec, "name");
let request = unhex(&st(rec, "request_hex"));
let expected = rec["responses"].as_array().unwrap();
let (frame, _) = Frame::parse(&request).unwrap();
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload).unwrap()
};
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
assert_eq!(out.len(), expected.len(), "{name}: frame count");
for (got, want_hex) in out.iter().zip(expected.iter()) {
let want = Frame::parse(&unhex(want_hex.as_str().unwrap())).unwrap().0;
assert_eq!(
got.header.component, want.header.component,
"{name}: component"
);
assert_eq!(got.header.command, want.header.command, "{name}: command");
assert_eq!(got.header.msg_type, want.header.msg_type, "{name}: msgType");
assert_eq!(got.header.msg_num, want.header.msg_num, "{name}: msgNum");
assert_eq!(
got.header.user_index, want.header.user_index,
"{name}: userIndex"
);
}
}
}
/// The login burst is the sequence most likely to be silently wrong, so pin it
/// explicitly rather than relying on it being buried in the byte comparison.
#[test]
fn login_emits_reply_then_exactly_three_pushes() {
let rp = setup();
let login = rp
.records
.iter()
.find(|r| r["kind"] == "tx" && r["name"] == "login")
.expect("login transaction present");
let frames: Vec<Frame> = login["responses"]
.as_array()
.unwrap()
.iter()
.map(|h| Frame::parse(&unhex(h.as_str().unwrap())).unwrap().0)
.collect();
assert_eq!(frames.len(), 4, "reply + three UserSessions pushes");
assert_eq!(
frames[0].header.msg_type,
openfut_protocol_blaze::fire2::MsgType::Reply
);
let notify_ids: Vec<u16> = frames[1..].iter().map(|f| f.header.command).collect();
assert_eq!(notify_ids, vec![0x0008, 0x0001, 0x0002]);
}
/// The generator uses non-loopback addresses, so any loopback literal left in a
/// response means the adapter hardcoded something it should have read from
/// config — the exact regression the client/server split was meant to prevent.
///
/// Two keys are genuine literals in the oracle, not substitution failures.
/// Both are OAuth redirect targets the client never actually dials (the flow is
/// forged), so the loopback is inert; they are allowlisted by key rather than
/// by pattern so a third one cannot slip in unnoticed.
const ALLOWED_LOOPBACK_KEYS: [&str; 2] = ["identityRedirectUri", "redirect_uri"];
#[test]
fn no_response_hardcodes_a_loopback_address() {
let mut rp = setup();
let records = rp.records.clone();
let advertise = "198.51.100.7";
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let name = st(rec, "name");
let request = unhex(&st(rec, "request_hex"));
let (frame, _) = Frame::parse(&request).unwrap();
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload).unwrap()
};
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
let out = rp.adapter.dispatch(&frame.header, &body, session, rp.now);
for f in &out {
let text = String::from_utf8_lossy(&f.payload);
for (at, _) in text.match_indices("127.0.0.1") {
// TDF strings are length-prefixed and NUL-terminated, so the
// owning key sits shortly before the value. Look back far
// enough to name it, and require it to be allowlisted.
let start = at.saturating_sub(80);
let context = &text[start..text.len().min(at + 64)];
assert!(
ALLOWED_LOOPBACK_KEYS.iter().any(|k| context.contains(k)),
"\n{name}: unexpected loopback literal, context {context:?}"
);
}
}
// The advertised address must actually appear somewhere in the config
// responses, or substitution silently did nothing.
if name.starts_with("fetch_config") || name == "preauth" {
let text = String::from_utf8_lossy(&out[0].payload);
assert!(
text.contains(advertise),
"{name}: advertised address missing from the config payload"
);
}
}
}
/// Session state must survive across RPCs on one connection, and must NOT leak
/// between connections.
#[test]
fn session_state_is_per_connection() {
let mut rp = setup();
let records = rp.records.clone();
for rec in records.iter().filter(|r| r["kind"] == "tx") {
let request = unhex(&st(rec, "request_hex"));
let (frame, _) = Frame::parse(&request).unwrap();
let body = if frame.payload.is_empty() {
heat2::Struct::new()
} else {
heat2::decode(&frame.payload).unwrap()
};
let session = rp.sessions.get_mut(&st(rec, "session")).unwrap();
rp.adapter.dispatch(&frame.header, &body, session, rp.now);
}
// "main" logged in with an auth code and an enUS preAuth.
let main = &rp.sessions["main"];
assert!(main.logged_in);
assert_eq!(main.auth_code, "OPENFUT-TEST-AUTHCODE");
assert_eq!(main.account_locale, 0x656E_5553);
// "locale" ran a deDE preAuth and a login carrying no AUTH member.
let loc = &rp.sessions["locale"];
assert_eq!(loc.account_locale, 0x6465_4445, "deDE locale captured");
assert_eq!(loc.service_name, "fifa-2017-pc-de");
assert!(loc.auth_code.is_empty());
// "fallbacks" never logged in.
assert!(!rp.sessions["fallbacks"].logged_in);
}
// ────────────────────────────── redirector ──────────────────────────────
//
// The first hop. A different protocol from Blaze — HTTPS with an XML body —
// but the same rule: byte-for-byte against the oracle.
#[test]
fn redirect_response_matches_python_oracle_byte_for_byte() {
use openfut_adapter_fifa17::redirector;
let path = format!("{}/fixtures/redirector.json", env!("CARGO_MANIFEST_DIR"));
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read {path}: {e}"));
let table: serde_json::Map<String, J> = serde_json::from_str(&text).expect("valid JSON");
assert!(table.len() >= 3, "expected several advertised addresses");
for (advertise, want_hex) in &table {
let mut cfg = AdapterConfig::loopback();
cfg.endpoints.advertise = advertise.clone();
let got = redirector::redirect_response(&cfg);
assert_eq!(
hex(&got),
want_hex.as_str().unwrap(),
"\nredirector response differs for advertise={advertise}"
);
}
}
/// The advertised Blaze endpoint must follow config, and the fixtures use
/// deliberately different addresses so a hardcoded one cannot pass.
#[test]
fn redirect_response_is_configurable_not_baked() {
use openfut_adapter_fifa17::redirector;
let mut a = AdapterConfig::loopback();
a.endpoints.advertise = "10.0.0.5".into();
let mut b = AdapterConfig::loopback();
b.endpoints.advertise = "10.0.0.6".into();
assert_ne!(
redirector::redirect_response(&a),
redirector::redirect_response(&b),
"the advertised address must reach the wire"
);
}