0138a39f3e
Implement the LSX (EA App bootstrap, TCP :3216→:3217) server in the bridge
and clear the loading-screen crash gate. FIFA 23 now completes the full LSX
handshake + session against OpenFUT and advances to the online/Blaze phase
("<persona> is connecting to the EA Servers…") — verified live, not a cached
session (the displayed persona "fun" comes from our GetProfile).
Root cause of the invariant loading-screen crash: get_config() omitted the
PROGRESSIVE_INSTALLATION and PROGRESSIVE_INSTALLATION_EVENT services (Name="PI").
FIFA builds its service registry from GetConfig and resolves that handle during
online-init; the missing service left a null handle it dereferenced at
FIFA23.exe+0x133dfc5 (mov rax,[r13+0x18], r13=NULL) ~2s after LSX — identical
across every prior run. Found via FIFA's own CrashReport_*.json (stable RIP) +
diffing anadius64.dll's service-registration table against ours.
Also in this change:
- process_frame(): handle FIFA's pipelined (batched) LSX messages per read,
eliminating the ~15s handshake hang.
- Real anadius values (RE'd from anadius64.dll + anadius.cfg): PersonaId/UserId/
persona, locale list, GetGameInfo fixed `GameInfo="<value>"` attribute,
GetAllGameInfo 14-attr schema, GetSetting `Setting=` attribute.
- IsProgressiveInstallationAvailable served from sender="PI".
- docs/connection-gate-findings.md: full RE trail; relocate stale openfut-hook
copy under docs/ (canonical hook lives in openfut-launcher).
Next gate: Blaze/online (M2), ridden over ProtoSSL in-process.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
280 lines
10 KiB
Rust
280 lines
10 KiB
Rust
use axum::{
|
|
body::Body,
|
|
http::{Request, StatusCode},
|
|
};
|
|
use openfut_bridge::{
|
|
capture::CapturedRequest,
|
|
config::Config,
|
|
mapper::{map_to_core, placeholder_response},
|
|
proxy::ProxyState,
|
|
routes,
|
|
};
|
|
use axum::routing::{any, delete, get, post};
|
|
use tower::ServiceExt;
|
|
|
|
// ── Unit tests ────────────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_known_endpoint_maps_to_core() {
|
|
let mapping = map_to_core("POST", "/ut/auth");
|
|
assert!(mapping.is_some());
|
|
let m = mapping.unwrap();
|
|
assert_eq!(m.core_path, "/auth/local");
|
|
}
|
|
|
|
#[test]
|
|
fn test_unknown_endpoint_returns_none() {
|
|
let mapping = map_to_core("GET", "/ut/game/fut/some/unknown/path");
|
|
assert!(mapping.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_capture_serializes_cleanly() {
|
|
let capture = CapturedRequest::new(
|
|
"GET",
|
|
"/ut/game/fut/user/settings",
|
|
None,
|
|
vec![("user-agent".into(), "FIFA23/1.0".into())],
|
|
None,
|
|
);
|
|
|
|
let json = serde_json::to_string(&capture).expect("serialize");
|
|
let back: CapturedRequest = serde_json::from_str(&json).expect("deserialize");
|
|
assert_eq!(back.path, "/ut/game/fut/user/settings");
|
|
assert_eq!(back.method, "GET");
|
|
assert!(back.mapped_to_core.is_none());
|
|
assert!(back.response_status.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_capture_with_response() {
|
|
let capture = CapturedRequest::new(
|
|
"POST",
|
|
"/ut/auth",
|
|
None,
|
|
vec![],
|
|
Some(r#"{"token":"abc"}"#.into()),
|
|
)
|
|
.with_response(200, Some(r#"{"status":"ok"}"#.into()));
|
|
|
|
assert_eq!(capture.response_status, Some(200));
|
|
assert!(capture.response_body.is_some());
|
|
}
|
|
|
|
// ── #24 Placeholder response format ──────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn test_placeholder_response_has_required_fields() {
|
|
let resp = placeholder_response("GET", "/ut/game/fut/unknown");
|
|
assert_eq!(resp["status"], "ok");
|
|
assert!(resp["openfut_note"].is_string());
|
|
assert_eq!(resp["method"], "GET");
|
|
assert_eq!(resp["path"], "/ut/game/fut/unknown");
|
|
}
|
|
|
|
#[test]
|
|
fn test_placeholder_response_for_post() {
|
|
let resp = placeholder_response("POST", "/ut/auth/fifa");
|
|
assert_eq!(resp["status"], "ok");
|
|
assert_eq!(resp["method"], "POST");
|
|
}
|
|
|
|
// ── #25 Full HTTP integration test against bridge ────────────────────────────
|
|
|
|
fn build_test_app() -> axum::Router {
|
|
let cfg = Config {
|
|
listen_addr: "127.0.0.1:0".into(),
|
|
core_url: "http://127.0.0.1:9999".into(), // won't be reached in placeholder mode
|
|
captures_dir: std::env::temp_dir()
|
|
.join(format!("openfut-bridge-test-{}", uuid::Uuid::new_v4()))
|
|
.to_string_lossy()
|
|
.into_owned(),
|
|
placeholder_mode: true,
|
|
tls_enabled: false,
|
|
};
|
|
let state = ProxyState::new(cfg);
|
|
|
|
axum::Router::new()
|
|
.route("/_bridge/health", get(routes::health::get_health))
|
|
.route("/_bridge/dashboard", get(routes::health::get_dashboard))
|
|
.route("/_bridge/captures", get(routes::admin::get_captures))
|
|
.route("/_bridge/captures", delete(routes::admin::delete_captures))
|
|
.route("/_bridge/unknown", get(routes::admin::get_unknown_endpoints))
|
|
.route("/_bridge/status", get(routes::admin::get_endpoint_status))
|
|
.route("/_bridge/captures/:id/replay", post(routes::admin::post_replay_capture))
|
|
.fallback(any(openfut_bridge::proxy::catch_all_handler))
|
|
.with_state(state)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_health_endpoint() {
|
|
let app = build_test_app();
|
|
let resp = app
|
|
.oneshot(Request::builder().uri("/_bridge/health").body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(json["status"], "ok");
|
|
assert_eq!(json["service"], "openfut-bridge");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_placeholder_mode_returns_ok() {
|
|
let app = build_test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("GET")
|
|
.uri("/ut/game/fut/completely/unknown/endpoint")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert_eq!(json["status"], "ok");
|
|
assert!(json["openfut_note"].is_string());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_captures_endpoint_returns_list() {
|
|
let app = build_test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/_bridge/captures")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert!(json["captures"].is_array());
|
|
assert!(json["total"].is_number());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_delete_captures() {
|
|
let app = build_test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.method("DELETE")
|
|
.uri("/_bridge/captures")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert!(json["deleted"].is_number());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_bridge_status_endpoint() {
|
|
let app = build_test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/_bridge/status")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
|
assert!(json["endpoints"].is_array());
|
|
}
|
|
|
|
// ── Phase 13 — Dashboard ──────────────────────────────────────────────────────
|
|
|
|
#[tokio::test]
|
|
async fn test_dashboard_returns_html() {
|
|
let app = build_test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/_bridge/dashboard")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), StatusCode::OK);
|
|
let ct = resp.headers().get("content-type").unwrap().to_str().unwrap();
|
|
assert!(ct.contains("text/html"), "expected text/html, got {ct}");
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
|
assert!(html.contains("OpenFUT Dashboard"), "title missing");
|
|
assert!(html.contains("const CORE ="), "core URL injection missing");
|
|
// Placeholder URL injected in test mode
|
|
assert!(html.contains("127.0.0.1:9999"), "core URL not injected");
|
|
assert!(!html.contains("{{CORE_URL}}"), "template placeholder was not replaced");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_dashboard_contains_key_sections() {
|
|
let app = build_test_app();
|
|
let resp = app
|
|
.oneshot(
|
|
Request::builder()
|
|
.uri("/_bridge/dashboard")
|
|
.body(Body::empty())
|
|
.unwrap(),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let body = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap();
|
|
let html = String::from_utf8(body.to_vec()).unwrap();
|
|
// Verify all major tab sections are present
|
|
assert!(html.contains("tab-club"), "club tab missing");
|
|
assert!(html.contains("tab-collection"), "collection tab missing");
|
|
assert!(html.contains("tab-packs"), "packs tab missing");
|
|
assert!(html.contains("tab-objectives"), "objectives tab missing");
|
|
assert!(html.contains("tab-division"), "division tab missing");
|
|
assert!(html.contains("tab-champs"), "fut champs tab missing");
|
|
assert!(html.contains("tab-squad"), "squad tab missing");
|
|
assert!(html.contains("tab-draft"), "draft tab missing");
|
|
assert!(html.contains("tab-market"), "market tab missing");
|
|
assert!(html.contains("tab-events"), "events tab missing");
|
|
assert!(html.contains("tab-matches"), "matches tab missing");
|
|
assert!(html.contains("tab-store"), "pack store tab missing");
|
|
assert!(html.contains("tab-sbc"), "sbc tab missing");
|
|
assert!(html.contains("tab-statistics"), "statistics tab missing");
|
|
assert!(html.contains("tab-catalog"), "card catalog tab missing");
|
|
assert!(html.contains("tab-settings"), "settings tab missing");
|
|
assert!(html.contains("tab-notifications"), "notifications tab missing");
|
|
assert!(html.contains("tab-achievements"), "achievements tab missing");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tls_cert_generation() {
|
|
let result = openfut_bridge::tls::generate_self_signed_cert();
|
|
assert!(result.is_ok(), "cert generation failed: {:?}", result.err());
|
|
let (cert_pem, key_pem) = result.unwrap();
|
|
assert!(!cert_pem.is_empty());
|
|
assert!(!key_pem.is_empty());
|
|
// Verify the PEM blocks are well-formed
|
|
let cert_str = String::from_utf8(cert_pem).unwrap();
|
|
let key_str = String::from_utf8(key_pem).unwrap();
|
|
assert!(cert_str.contains("BEGIN CERTIFICATE"));
|
|
assert!(key_str.contains("PRIVATE KEY"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tls_acceptor_construction() {
|
|
let (cert_pem, key_pem) = openfut_bridge::tls::generate_self_signed_cert().unwrap();
|
|
let acceptor = openfut_bridge::tls::make_tls_acceptor(&cert_pem, &key_pem);
|
|
assert!(acceptor.is_ok(), "acceptor construction failed: {:?}", acceptor.err());
|
|
}
|