Compare commits
10 Commits
6926bb9528
...
5020137050
| Author | SHA1 | Date | |
|---|---|---|---|
| 5020137050 | |||
| cf05ab2a9e | |||
| a6416a3f1d | |||
| 47ced228de | |||
| b8beeba98d | |||
| df6994c957 | |||
| d74e86c065 | |||
| 76512f6048 | |||
| 93a46d4de7 | |||
| 3ba24a0faf |
Generated
+3
@@ -3219,10 +3219,13 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"openfut-adapter-fifa17",
|
"openfut-adapter-fifa17",
|
||||||
|
"openfut-core",
|
||||||
"openfut-identity",
|
"openfut-identity",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sqlx",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,6 @@ openfut-identity = { path = "../openfut-identity" }
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
openfut-core = { path = "../openfut-core" }
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
|
||||||
|
sqlx = { version = "0.7", features = ["sqlite", "runtime-tokio-rustls"] }
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
//! Durable end-to-end proof of the FIFA17 real-profile import against a
|
||||||
|
//! DISPOSABLE, temp-FILE Core SQLite DB (not `:memory:`, so a restart is real).
|
||||||
|
//!
|
||||||
|
//! This drives the REAL components exactly as the production dispatch does:
|
||||||
|
//! * `openfut_import_fifa17::analyze` → the read-only [`Report`] (dry-run);
|
||||||
|
//! * `openfut_import_fifa17::emit_content` → the production content pack;
|
||||||
|
//! * `openfut_import_fifa17::apply::plan_apply` → the generic Core request +
|
||||||
|
//! the deterministic identity mappings + the allocation watermark;
|
||||||
|
//! * the real orchestration GATES (`gate_staging`, `local_core_preflight`,
|
||||||
|
//! `identity_dry_preflight`, `seed_identity`, `post_validate_identity`) and a
|
||||||
|
//! real `openfut_identity::JsonIdentityStore`;
|
||||||
|
//! * `openfut_core::services::import::apply_profile_import` inside a single
|
||||||
|
//! Core SQLite transaction on a temp-file pool.
|
||||||
|
//!
|
||||||
|
//! The ONLY place this test diverges from `apply::apply` is the Core boundary:
|
||||||
|
//! `apply::apply` writes the request JSON and spawns the `openfut-core` binary
|
||||||
|
//! (`import <req.json>`); here we serialize the SAME `GenericImportRequest` to
|
||||||
|
//! JSON and deserialize it into Core's `ProfileImportRequest` (the two types
|
||||||
|
//! share their field names by contract), then call `apply_profile_import`
|
||||||
|
//! in-process against the temp-file pool. That collapses the process boundary
|
||||||
|
//! without reimplementing any importer or Core logic, and lets the test drop &
|
||||||
|
//! reopen the DB file to prove durability.
|
||||||
|
//!
|
||||||
|
//! Fingerprint mechanism under test: `openfut_import_fifa17::fingerprint` (a
|
||||||
|
//! dependency-free FNV-1a-64 hex digest of the source snapshot bytes) is carried
|
||||||
|
//! verbatim into `ProfileImportRequest::source_fingerprint`, which Core persists
|
||||||
|
//! as `profiles.import_fingerprint` and uses as the per-game rerun-identity key:
|
||||||
|
//! identical fingerprint on an already-imported game → idempotent no-op; a
|
||||||
|
//! DIFFERENT fingerprint for the same game → hard failure (never a silent merge).
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
|
use openfut_adapter_fifa17::fut::catalog::Fifa17WireItemIdPolicy;
|
||||||
|
use openfut_core::db::{init_pool, run_migrations, Pool};
|
||||||
|
use openfut_core::services::card_db::CardDb;
|
||||||
|
use openfut_core::services::import::{apply_profile_import, ImportOutcome, ProfileImportRequest};
|
||||||
|
use openfut_identity::{ExternalIdentityStore, JsonIdentityStore};
|
||||||
|
|
||||||
|
use openfut_import_fifa17::apply::{
|
||||||
|
content_card_ids, gate_staging, identity_dry_preflight, local_core_preflight, owned_item_id,
|
||||||
|
plan_apply, post_validate_identity, seed_identity, ApplyPlan,
|
||||||
|
};
|
||||||
|
use openfut_import_fifa17::model::Profile;
|
||||||
|
use openfut_import_fifa17::{analyze, emit_content, fingerprint, Entities, Report, Roster};
|
||||||
|
|
||||||
|
const PERSONA: i64 = 33068179;
|
||||||
|
|
||||||
|
// ---- sanitized in-test fixture (NOT production/live data) ----
|
||||||
|
|
||||||
|
fn roster() -> Roster {
|
||||||
|
Roster::from_json_str(
|
||||||
|
r#"[
|
||||||
|
{"id":20801,"first":"Cristiano","last":"Ronaldo","common":""},
|
||||||
|
{"id":176580,"first":"Luis","last":"Suárez","common":""},
|
||||||
|
{"id":158023,"first":"Lionel","last":"Messi","common":""}
|
||||||
|
]"#,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entities() -> Entities {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
Entities::from_maps(
|
||||||
|
BTreeMap::from([(53, "LaLiga".to_string())]),
|
||||||
|
BTreeMap::from([(38, "Portugal".to_string())]),
|
||||||
|
BTreeMap::from([(243, "Real Madrid".to_string())]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A resolvable base player card (nation 38 / team 243 / league 53 all resolve).
|
||||||
|
fn player(id: i64, resource: i64, asset: i64, rating: i64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"id":{id},"resourceId":{resource},"assetId":{asset},"itemType":"player",
|
||||||
|
"rareflag":1,"rating":{rating},"preferredPosition":"ST","nation":38,
|
||||||
|
"teamid":243,"leagueId":53,"attributeList":[
|
||||||
|
{{"index":0,"value":90}},{{"index":1,"value":91}},{{"index":2,"value":82}},
|
||||||
|
{{"index":3,"value":88}},{{"index":4,"value":30}},{{"index":5,"value":78}}]}}"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQUAD_F433: &str = r#"[{"formation":"f433","squadName":"OpenFUT","captain":100000001,
|
||||||
|
"squadType":"REGULAR_SQUAD","custom":"[0,0,0]",
|
||||||
|
"players":[{"index":0,"itemData":{"id":100000001},"kitNumber":7},
|
||||||
|
{"index":1,"itemData":{"id":100000002},"kitNumber":9}],
|
||||||
|
"kicktakers":[{"index":0,"id":100000001}],"manager":[{"id":100000427}]}]"#;
|
||||||
|
|
||||||
|
/// The three owned player instances (wire ids 100000001..100000003).
|
||||||
|
fn items() -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
player(100000001, 20801, 20801, 94), // fifa17_20801 (Ronaldo)
|
||||||
|
player(100000002, 176580, 176580, 86), // fifa17_176580 (Suárez)
|
||||||
|
player(100000003, 158023, 158023, 93), // fifa17_158023 (Messi)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the source profile JSON with the given coins (varying coins is the
|
||||||
|
/// "meaningful change" that flips the fingerprint for STEP E).
|
||||||
|
fn profile_json(coins: i64) -> String {
|
||||||
|
format!(
|
||||||
|
r#"{{"personaId":{PERSONA},"personaName":"CAGE","clubName":"OpenFUT","clubAbbr":"OFC",
|
||||||
|
"coins":{coins},"nextItemId":100000600,
|
||||||
|
"items":[{}],"squads":{SQUAD_F433},"unopenedPackIds":[70,70,101]}}"#,
|
||||||
|
items().join(",")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Analyze one snapshot into (report, raw Value, fingerprint), the read-only
|
||||||
|
/// dry-run the production dispatch performs before any write.
|
||||||
|
fn dry_run(coins: i64) -> (Report, serde_json::Value, String) {
|
||||||
|
let json = profile_json(coins);
|
||||||
|
let prof = Profile::from_json_str(&json).unwrap();
|
||||||
|
let report = analyze(&prof, &roster(), &entities(), &BTreeSet::new());
|
||||||
|
let raw: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
|
let fp = fingerprint(json.as_bytes());
|
||||||
|
(report, raw, fp)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize the importer's generic request and deserialize it into Core's
|
||||||
|
/// request — the exact JSON contract `apply::apply` hands the Core binary.
|
||||||
|
fn to_core_request(plan: &ApplyPlan) -> ProfileImportRequest {
|
||||||
|
let bytes = serde_json::to_vec(&plan.request).expect("serialize generic request");
|
||||||
|
serde_json::from_slice(&bytes).expect("deserialize into Core ProfileImportRequest")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn count(pool: &Pool, table: &str) -> i64 {
|
||||||
|
sqlx::query_scalar(&format!("SELECT COUNT(*) FROM {table}"))
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn coins(pool: &Pool) -> i64 {
|
||||||
|
sqlx::query_scalar("SELECT coins FROM clubs")
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn owned_card_ids(pool: &Pool) -> BTreeSet<String> {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT card_id FROM owned_cards")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn owned_item_ids(pool: &Pool) -> BTreeSet<String> {
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT id FROM owned_cards")
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
const CORE_TABLES: &[&str] = &[
|
||||||
|
"profiles",
|
||||||
|
"clubs",
|
||||||
|
"owned_cards",
|
||||||
|
"packs",
|
||||||
|
"squads",
|
||||||
|
"squad_players",
|
||||||
|
"game_entity_ext",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn durable_import_dryrun_apply_restart_idempotent_conflict() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("core.sqlite");
|
||||||
|
let db_url = format!("sqlite://{}", db_path.display());
|
||||||
|
let identity_store_path = dir.path().join("identity.json");
|
||||||
|
|
||||||
|
// Empty base catalog dir → CardDb has ONLY the emitted production pack.
|
||||||
|
let data_dir = dir.path().join("data");
|
||||||
|
std::fs::create_dir_all(&data_dir).unwrap();
|
||||||
|
|
||||||
|
// The expected import shape, derived from the fixture.
|
||||||
|
let expected_coins = 750_000_i64;
|
||||||
|
let expected_owned = 3_usize;
|
||||||
|
let expected_packs = 3_usize; // unopenedPackIds [70,70,101]
|
||||||
|
let expected_squad_slots = 2_usize;
|
||||||
|
let expected_card_ids: BTreeSet<String> = ["fifa17_20801", "fifa17_176580", "fifa17_158023"]
|
||||||
|
.iter()
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.collect();
|
||||||
|
let expected_wires = [100000001_i64, 100000002, 100000003];
|
||||||
|
let expected_owned_ids: BTreeSet<String> = expected_wires
|
||||||
|
.iter()
|
||||||
|
.map(|&w| owned_item_id(PERSONA, w))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let (g, k) = (
|
||||||
|
Fifa17WireItemIdPolicy::GAME,
|
||||||
|
Fifa17WireItemIdPolicy::OWNED_ITEM_KIND,
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================ STEP A — DRY-RUN ============================
|
||||||
|
let (report, raw, fp1) = dry_run(expected_coins);
|
||||||
|
|
||||||
|
// The report exposes the migration target + inventory + entitlements + the
|
||||||
|
// provenance fingerprint, WITHOUT touching any store.
|
||||||
|
assert_eq!(report.game, "fifa17");
|
||||||
|
assert_eq!(report.persona_id, PERSONA);
|
||||||
|
assert_eq!(report.persona_name, "CAGE");
|
||||||
|
assert_eq!(report.coins, expected_coins);
|
||||||
|
assert_eq!(report.counts.player_cards, expected_owned);
|
||||||
|
assert!(report.counts.balances(), "item accounting must balance");
|
||||||
|
assert_eq!(report.unopened_pack_ids, [70, 70, 101]);
|
||||||
|
assert!(!report.has_blockers(), "clean fixture has no blockers");
|
||||||
|
assert_eq!(fp1.len(), 16, "FNV-1a-64 fingerprint is 16 hex chars");
|
||||||
|
assert!(fp1.chars().all(|c| c.is_ascii_hexdigit()));
|
||||||
|
|
||||||
|
// Plan the apply (still no writes) and emit the production content pack.
|
||||||
|
let plan = plan_apply(&report, &raw, &fp1).expect("plan apply");
|
||||||
|
assert_eq!(plan.source_fingerprint, fp1);
|
||||||
|
assert_eq!(plan.request.owned.len(), expected_owned);
|
||||||
|
assert_eq!(plan.mappings.len(), expected_owned);
|
||||||
|
assert_eq!(plan.watermark, 100000600);
|
||||||
|
assert_eq!(plan.request.entitlements.len(), expected_packs);
|
||||||
|
|
||||||
|
let emit = emit_content(&report, dir.path(), &fp1).expect("emit content");
|
||||||
|
let pack_ids = content_card_ids(&emit.content_pack).expect("read emitted pack");
|
||||||
|
assert_eq!(pack_ids, expected_card_ids, "emitted pack card ids");
|
||||||
|
|
||||||
|
// The real orchestration gates run against the plan (read-only).
|
||||||
|
assert!(
|
||||||
|
!gate_staging(&plan, false).expect("staging gate"),
|
||||||
|
"zero deferred instances → production-complete, no staging flag"
|
||||||
|
);
|
||||||
|
local_core_preflight(&plan, &pack_ids).expect("local core preflight");
|
||||||
|
|
||||||
|
// Open the disposable temp-FILE Core pool + migrations, and PROVE the
|
||||||
|
// dry-run above mutated nothing: every Core table is empty.
|
||||||
|
let pool = init_pool(&db_url, 1).await.expect("init core pool");
|
||||||
|
run_migrations(&pool).await.expect("migrations");
|
||||||
|
let store = JsonIdentityStore::open(&identity_store_path).expect("open identity store");
|
||||||
|
identity_dry_preflight(&store, &plan).expect("identity dry preflight");
|
||||||
|
for t in CORE_TABLES {
|
||||||
|
assert_eq!(count(&pool, t).await, 0, "dry-run left `{t}` unmutated");
|
||||||
|
}
|
||||||
|
// The dry identity preflight also seeded nothing.
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.external_for(g, k, &owned_item_id(PERSONA, 100000001))
|
||||||
|
.unwrap(),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================ STEP B — APPLY =============================
|
||||||
|
// Idempotently seed the identity mappings + watermark, then run the ONE
|
||||||
|
// generic Core import transaction (via the real JSON contract).
|
||||||
|
seed_identity(&store, &plan).expect("seed identity");
|
||||||
|
let req = to_core_request(&plan);
|
||||||
|
let card_db = {
|
||||||
|
let mut db = CardDb::load(data_dir.to_str().unwrap()).expect("load empty base catalog");
|
||||||
|
db.load_pack(&emit.content_pack)
|
||||||
|
.expect("load production pack");
|
||||||
|
db
|
||||||
|
};
|
||||||
|
let outcome = apply_profile_import(&pool, &card_db, &req)
|
||||||
|
.await
|
||||||
|
.expect("core import");
|
||||||
|
assert_eq!(
|
||||||
|
outcome,
|
||||||
|
ImportOutcome::Imported {
|
||||||
|
owned: expected_owned,
|
||||||
|
squad_slots: expected_squad_slots
|
||||||
|
}
|
||||||
|
);
|
||||||
|
post_validate_identity(&store, &plan).expect("post-validate identity");
|
||||||
|
|
||||||
|
// Exact durable Core state.
|
||||||
|
assert_eq!(count(&pool, "profiles").await, 1);
|
||||||
|
assert_eq!(count(&pool, "clubs").await, 1);
|
||||||
|
assert_eq!(coins(&pool).await, expected_coins, "exact coins");
|
||||||
|
assert_eq!(count(&pool, "owned_cards").await, expected_owned as i64);
|
||||||
|
assert_eq!(count(&pool, "packs").await, expected_packs as i64);
|
||||||
|
let unopened: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM packs WHERE opened = 0")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(unopened, expected_packs as i64, "unopened entitlements");
|
||||||
|
assert_eq!(
|
||||||
|
count(&pool, "squad_players").await,
|
||||||
|
expected_squad_slots as i64
|
||||||
|
);
|
||||||
|
|
||||||
|
// Core content identities + the deterministic opaque OwnedItemIds.
|
||||||
|
assert_eq!(owned_card_ids(&pool).await, expected_card_ids);
|
||||||
|
assert_eq!(
|
||||||
|
owned_item_ids(&pool).await,
|
||||||
|
expected_owned_ids,
|
||||||
|
"Core owned_cards.id == deterministic owned_item_id(persona, wire)"
|
||||||
|
);
|
||||||
|
// The stored provenance/rerun key IS the source fingerprint.
|
||||||
|
let stored_fp: String = sqlx::query_scalar("SELECT import_fingerprint FROM profiles")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored_fp, fp1);
|
||||||
|
|
||||||
|
// Identity store: every preserved wire id resolves both directions, and the
|
||||||
|
// watermark is the source allocation floor.
|
||||||
|
for &w in &expected_wires {
|
||||||
|
let core_id = owned_item_id(PERSONA, w);
|
||||||
|
assert_eq!(store.external_for(g, k, &core_id).unwrap(), Some(w));
|
||||||
|
assert_eq!(store.core_for(g, k, w).unwrap(), Some(core_id));
|
||||||
|
}
|
||||||
|
assert_eq!(store.watermark_for(g, k), Some(100000600));
|
||||||
|
|
||||||
|
// ============================ STEP C — RESTART ===========================
|
||||||
|
// Drop the pool, reopen the SAME db file, and re-read identical state.
|
||||||
|
pool.close().await;
|
||||||
|
drop(pool);
|
||||||
|
let pool = init_pool(&db_url, 1).await.expect("reopen core pool");
|
||||||
|
run_migrations(&pool).await.expect("migrations idempotent");
|
||||||
|
assert_eq!(count(&pool, "profiles").await, 1);
|
||||||
|
assert_eq!(coins(&pool).await, expected_coins, "coins survive restart");
|
||||||
|
assert_eq!(count(&pool, "owned_cards").await, expected_owned as i64);
|
||||||
|
assert_eq!(count(&pool, "packs").await, expected_packs as i64);
|
||||||
|
assert_eq!(owned_card_ids(&pool).await, expected_card_ids);
|
||||||
|
assert_eq!(owned_item_ids(&pool).await, expected_owned_ids);
|
||||||
|
|
||||||
|
// Identity store also durable across a reopen.
|
||||||
|
let store = JsonIdentityStore::open(&identity_store_path).expect("reopen identity store");
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.external_for(g, k, &owned_item_id(PERSONA, 100000001))
|
||||||
|
.unwrap(),
|
||||||
|
Some(100000001)
|
||||||
|
);
|
||||||
|
|
||||||
|
// ======================= STEP D — IDEMPOTENT RE-APPLY ====================
|
||||||
|
// Same source (same fingerprint) → recognized as already imported, no dupes.
|
||||||
|
identity_dry_preflight(&store, &plan).expect("re-run identity dry preflight");
|
||||||
|
seed_identity(&store, &plan).expect("re-run identity seed is idempotent");
|
||||||
|
let req_again = to_core_request(&plan);
|
||||||
|
let outcome = apply_profile_import(&pool, &card_db, &req_again)
|
||||||
|
.await
|
||||||
|
.expect("re-apply");
|
||||||
|
assert_eq!(outcome, ImportOutcome::AlreadyImported);
|
||||||
|
assert_eq!(count(&pool, "profiles").await, 1, "no duplicate profile");
|
||||||
|
assert_eq!(coins(&pool).await, expected_coins, "coins NOT doubled");
|
||||||
|
assert_eq!(
|
||||||
|
count(&pool, "owned_cards").await,
|
||||||
|
expected_owned as i64,
|
||||||
|
"no duplicate owned cards"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
count(&pool, "packs").await,
|
||||||
|
expected_packs as i64,
|
||||||
|
"no duplicate entitlements"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============================ STEP E — CONFLICT ==========================
|
||||||
|
// A MEANINGFUL change (different coins → different snapshot fingerprint) for
|
||||||
|
// the SAME target (game fifa17) must FAIL CLOSED — never a silent merge.
|
||||||
|
let (report2, raw2, fp2) = dry_run(expected_coins + 1);
|
||||||
|
assert_ne!(fp2, fp1, "meaningful change flips the fingerprint");
|
||||||
|
let plan2 = plan_apply(&report2, &raw2, &fp2).expect("plan conflicting apply");
|
||||||
|
let req_conflict = to_core_request(&plan2);
|
||||||
|
let err = apply_profile_import(&pool, &card_db, &req_conflict)
|
||||||
|
.await
|
||||||
|
.expect_err("different fingerprint on imported game must fail closed");
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("different source"),
|
||||||
|
"expected explicit conflict, got: {msg}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The failed conflicting import changed nothing.
|
||||||
|
assert_eq!(count(&pool, "profiles").await, 1);
|
||||||
|
assert_eq!(
|
||||||
|
coins(&pool).await,
|
||||||
|
expected_coins,
|
||||||
|
"conflict left coins intact"
|
||||||
|
);
|
||||||
|
assert_eq!(count(&pool, "owned_cards").await, expected_owned as i64);
|
||||||
|
let stored_fp: String = sqlx::query_scalar("SELECT import_fingerprint FROM profiles")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored_fp, fp1, "original fingerprint unchanged");
|
||||||
|
|
||||||
|
pool.close().await;
|
||||||
|
}
|
||||||
@@ -12,6 +12,91 @@ entitlements (`unopenedPackIds`). `points` has **no** writer (read-only). EASFC
|
|||||||
Legend: **R** = Rust/Core authoritative, **P** = Python proxied (oracle).
|
Legend: **R** = Rust/Core authoritative, **P** = Python proxied (oracle).
|
||||||
Evidence lines refer to `fifa17-recon/tools/{utas_server.py,fut_store.py}`.
|
Evidence lines refer to `fifa17-recon/tools/{utas_server.py,fut_store.py}`.
|
||||||
|
|
||||||
|
|
||||||
|
## Accepted URL prefixes (v1 + v2) — S2 fix
|
||||||
|
|
||||||
|
The retail FIFA 17 client issues the **Store family** under a `/ut/v2/game/<sku>/`
|
||||||
|
prefix (live-observed `PUT /ut/v2/game/fifa17/store/transaction/0`), while other
|
||||||
|
routes use `/ut/game/<sku>/`. `classify_economy` normalizes BOTH prefixes to the
|
||||||
|
same tail (`ut_tail`), so economy ownership is prefix-agnostic. This closes the
|
||||||
|
S2 live-staging defect where the v2 Store BUY escaped to Python.
|
||||||
|
|
||||||
|
| Route | Accepted method + path shapes (both prefixes) | Economy route |
|
||||||
|
|---|---|---|
|
||||||
|
| Credits | `GET (/ut/game\|/ut/v2/game)/<sku>/user/credits` | `Credits` |
|
||||||
|
| Store catalogue | `GET …/store/purchasegroup[/…]` | `PurchaseGroup` |
|
||||||
|
| **Store BUY** | `PUT …/store/transaction` **and** `PUT …/store/transaction/<txn-id>` (numeric, e.g. `…/store/transaction/0`) | `StoreBuy` |
|
||||||
|
| **Pack open** | `POST …/purchased` **and** `POST …/purchased/items` | `PackOpen` |
|
||||||
|
| **Pack reveal** | `GET …/purchased` **and** `GET …/purchased/items` | `PackReveal` |
|
||||||
|
| Quick-sell (path) | `DELETE …/item/<digits>` | `QuickSellPath` |
|
||||||
|
| Quick-sell (body) | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/item` | `QuickSellBody` |
|
||||||
|
| Move | `PUT …/item` | `MoveItems` |
|
||||||
|
| Match end | `POST (/ut/delete/game\|/ut/v2/delete/game)/<sku>/match` | `MatchEnd` |
|
||||||
|
| Market list | `POST …/auctionhouse` \| `…/transfermarket` | `MarketList` |
|
||||||
|
| Market query | `GET …/tradePile` **and** `…/tradePile/counts` (CASE-INSENSITIVE: `tradepile` too) | `MarketQuery` |
|
||||||
|
| Market buy | `…/trade/<id>` | `MarketBuy` |
|
||||||
|
| Market cancel | `DELETE (/ut/delete/game\|/ut/v2/delete/game)/<sku>/trade/<id>` | `MarketCancel` |
|
||||||
|
|
||||||
|
`store/transaction` matching is BOUNDED to a single all-digit id segment — it
|
||||||
|
never absorbs `store/transactions`, `store/transactionfoo`, or
|
||||||
|
`store/transaction/<id>/extra` (those proxy to Python as non-economy). Audit
|
||||||
|
basis: Python route table `utas_server.py:1420` matches the store family
|
||||||
|
"regardless of /ut/game vs /ut/v2/game prefix"; all other economy routes are
|
||||||
|
`G = /ut/game/[^/]+`-prefixed (v1-only) and the retail client uses v1 for them.
|
||||||
|
|
||||||
|
### Round-2 fix (retail `/purchased/items` + `tradePile` case) — candidate supersedes `47ced22`
|
||||||
|
|
||||||
|
Live re-stage of `47ced22` showed the CONFIRMED retail Store BUY uses
|
||||||
|
`POST /ut/game/fifa17/purchased/items` (reveal `GET …/purchased/items`), which the
|
||||||
|
exact-tail `purchased` match missed → Python (Core coins unchanged = no debit). Fix:
|
||||||
|
`is_purchased_tail` accepts `purchased` AND `purchased/items` (bounded: rejects
|
||||||
|
`purchasedfoo`, `purchased/items/extra`); `is_tradepile_tail` matches the `tradepile`
|
||||||
|
family case-insensitively (`tradePile`, `tradepile`, `…/counts`) since the FUT hub tile
|
||||||
|
polls lowercase while the screen uses camelCase (oracle routes both via `re.I`). Audit
|
||||||
|
basis: oracle `utas_server.py:1428` bare `/purchased` regex matches `/purchased/items`;
|
||||||
|
`:1539-1540` `tradePile`/`tradePile/counts` are `re.I`. The full contract is the
|
||||||
|
machine-auditable `retail_route_matrix` unit test + the `pure_economy_routes` dispatch
|
||||||
|
matrix (NEVER-BOTH / no-fallback) + `retail_purchased_items_buy_debits_core_through_dispatch`.
|
||||||
|
|
||||||
|
## E1 — CUTOVER READY (barrier `93a46d4`, superproject source-ready; NOT deployed)
|
||||||
|
|
||||||
|
The economy authority barrier is committed: `Server::handle_with_ip` dispatches
|
||||||
|
every economy route to Rust/Core (`try_handle_economy`) BEFORE `classify()`, and
|
||||||
|
`from_config` (`43917a0`) attaches the economy services in production. Final
|
||||||
|
per-route authority (all economy routes owner = Rust, Python proxy = NO):
|
||||||
|
|
||||||
|
| Route (method) | Owner | coins R/W | inv R/W | ent R/W | pile R/W | listing R/W | reveal | Py proxy | Rust handler / Core primitive |
|
||||||
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
|
| `/user/credits` (GET) | R | R/- | - | R/- | - | - | - | NO | `handle_credits` (`balance`+ent count) |
|
||||||
|
| `/userMassInfo` (GET) | R (hybrid) | R/- | R/- | R/- | - | - | - | envelope only | `overlay_massinfo_economy`+squad (Py non-economy envelope only) |
|
||||||
|
| `/store/purchasegroup` (GET) | R | R/- | - | R/- | - | - | - | NO | `handle_purchasegroup` full-gen + `StoreMode` |
|
||||||
|
| `/store/transaction` (PUT) | R | -/R | -/R | - | -/R | - | R | NO | `handle_store_buy` → `purchase_items` |
|
||||||
|
| `/purchased` (POST) | R | -/R | -/R | -/R | -/R | - | R | NO | `handle_pack_open` → `redeem_entitlement`/mint |
|
||||||
|
| `/purchased` (GET) | R | - | R/- | - | R/- | - | R | NO | `shape_purchased_reveal` (pile+inventory) |
|
||||||
|
| `/item/<id>` (DELETE) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_path` → `sell_item` |
|
||||||
|
| `/ut/delete/…/item` (POST) | R | -/R | -/R | - | - | - | - | NO | `handle_quick_sell_body` → `sell_item` |
|
||||||
|
| `/item` (PUT) | R | - | R/- | - | -/R | - | - | NO | `handle_move_items` (PileStore) |
|
||||||
|
| `/ut/delete/…/match` (POST) | R | -/R | - | - | - | - | - | NO | `handle_match_end` → `grant_reward` |
|
||||||
|
| `/auctionhouse`,`/transfermarket` | R | R/- | - | - | - | -/R | - | NO | `handle_market_list` (MarketStore) |
|
||||||
|
| `/tradePile` (GET) | R | R/- | - | - | - | R/- | - | NO | `handle_market_query` |
|
||||||
|
| `/trade/<id>` (POST/PUT/GET) | R | R/R | -/R | - | - | R/R | - | NO | `handle_market_buy` → `purchase_item` |
|
||||||
|
| `/ut/delete/…/trade/<id>` (DELETE) | R | - | - | - | - | -/R | - | NO | `handle_market_cancel` |
|
||||||
|
|
||||||
|
`/ut/auth`, `/openfut/fifa17/capability`, `/club`, `/squad/*` are NOT economy
|
||||||
|
routes (classify_economy → None) and are unchanged. `userMassInfo` is the one
|
||||||
|
intentional hybrid: Python supplies the non-economy envelope, Rust overlays the
|
||||||
|
squad AND the economy fields — no Python economy value is authoritative/visible.
|
||||||
|
|
||||||
|
**Proofs (all green, source-ready):** differential 15 PARITY + 1
|
||||||
|
DIFFERENT-BY-DESIGN (market second-buy: Rust single-debit ledger vs oracle
|
||||||
|
stateless re-debit; compat NONE); host concurrency 8 races × 50 iters; failure
|
||||||
|
injection 10 cases incl. complete-sale-after-commit = SAFE (listing left
|
||||||
|
`reserved`, not buyable; one debit + one mint; no E3); importer
|
||||||
|
dry-run/apply/restart/idempotency/conflict; `from_config` E2E + restart;
|
||||||
|
NEVER-BOTH (economy routes → Rust, Python proxy count 0); no-fallback (dead Core
|
||||||
|
→ 503, proxy count 0); stale-reader (Core values only, Python 111 never visible).
|
||||||
|
Python source byte-unchanged; oracle suite 32/32 green.
|
||||||
|
|
||||||
## Writer routes (mutate cluster state)
|
## Writer routes (mutate cluster state)
|
||||||
|
|
||||||
| Route | Method | Python handler | Writes | Current | Target | Core primitive |
|
| Route | Method | Python handler | Writes | Current | Target | Core primitive |
|
||||||
|
|||||||
+317
-12
@@ -139,11 +139,17 @@ pub fn classify(method: &str, path: &str) -> Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tail after `/ut/game/<title>/` (non-empty title), or `None`.
|
/// The tail after `/ut/game/<sku>/` or `/ut/v2/game/<sku>/` (non-empty sku), or
|
||||||
|
/// `None`. Retail FIFA 17 issues the Store family (`store/*`, `purchased`) under
|
||||||
|
/// the `/ut/v2/game/<sku>/` prefix while other routes use `/ut/game/<sku>/`; both
|
||||||
|
/// normalize to the same tail so economy classification is prefix-agnostic. The
|
||||||
|
/// `sku` segment is generic (never hard-coded to `fifa17`).
|
||||||
fn ut_tail(path: &str) -> Option<&str> {
|
fn ut_tail(path: &str) -> Option<&str> {
|
||||||
let rest = path.strip_prefix("/ut/game/")?;
|
let rest = path
|
||||||
let (title, tail) = rest.split_once('/')?;
|
.strip_prefix("/ut/game/")
|
||||||
if title.is_empty() {
|
.or_else(|| path.strip_prefix("/ut/v2/game/"))?;
|
||||||
|
let (sku, tail) = rest.split_once('/')?;
|
||||||
|
if sku.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
Some(tail)
|
Some(tail)
|
||||||
@@ -209,6 +215,44 @@ fn is_item_id_tail(tail: &str) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `store/transaction` or `store/transaction/<digits>` — the Store BUY create
|
||||||
|
/// step. Retail sends a trailing numeric transaction id (observed live:
|
||||||
|
/// `store/transaction/0`). Mirrors the Python oracle's bare `/store/transaction`
|
||||||
|
/// route, but bounded to a single all-digit id segment so it never absorbs
|
||||||
|
/// `store/transactions`, `store/transactionfoo`, or `store/transaction/0/extra`.
|
||||||
|
fn is_store_transaction_tail(tail: &str) -> bool {
|
||||||
|
match tail.strip_prefix("store/transaction") {
|
||||||
|
Some("") => true,
|
||||||
|
Some(rest) => match rest.strip_prefix('/') {
|
||||||
|
Some(id) => !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()),
|
||||||
|
None => false,
|
||||||
|
},
|
||||||
|
None => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `purchased` or `purchased/items` — pack OPEN (POST) / reveal (GET). Retail
|
||||||
|
/// sends the `/items` sub-path (FutPurchaseItemsServerResponse); the Python
|
||||||
|
/// oracle's bare `/purchased` regex matches both. Bounded to exactly these two
|
||||||
|
/// tails (rejects `purchasedfoo`, `purchased/items/extra`).
|
||||||
|
fn is_purchased_tail(tail: &str) -> bool {
|
||||||
|
tail == "purchased" || tail == "purchased/items"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `tradePile` or `tradePile/counts` — the user's own listings (query) + the
|
||||||
|
/// listing-count tile. CASE-INSENSITIVE: the FUT hub tile polls lowercase
|
||||||
|
/// `tradepile`/`tradepile/counts` while the screen uses camelCase `tradePile`
|
||||||
|
/// (the oracle routes both via `re.I`). Bounded to the `tradepile` family
|
||||||
|
/// (base tail or a `tradepile/<sub>` path); allocation-free.
|
||||||
|
fn is_tradepile_tail(tail: &str) -> bool {
|
||||||
|
const BASE: &str = "tradePile";
|
||||||
|
match tail.len() {
|
||||||
|
9 => tail.eq_ignore_ascii_case(BASE),
|
||||||
|
n if n > 9 => tail.as_bytes()[9] == b'/' && tail[..9].eq_ignore_ascii_case(BASE),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Classify a FIFA17 economy route from method + path, mirroring the Python
|
/// Classify a FIFA17 economy route from method + path, mirroring the Python
|
||||||
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
/// oracle's route table (`utas_server.py` §1418-1553). Returns `None` for any
|
||||||
/// non-economy path. Path is already query-stripped by the caller.
|
/// non-economy path. Path is already query-stripped by the caller.
|
||||||
@@ -219,7 +263,10 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
|
|||||||
let delete = method.eq_ignore_ascii_case("DELETE");
|
let delete = method.eq_ignore_ascii_case("DELETE");
|
||||||
|
|
||||||
// The `/ut/delete/game/<sku>/…` family is NOT `/ut/game/…`-prefixed.
|
// The `/ut/delete/game/<sku>/…` family is NOT `/ut/game/…`-prefixed.
|
||||||
if let Some(rest) = path.strip_prefix("/ut/delete/game/") {
|
if let Some(rest) = path
|
||||||
|
.strip_prefix("/ut/delete/game/")
|
||||||
|
.or_else(|| path.strip_prefix("/ut/v2/delete/game/"))
|
||||||
|
{
|
||||||
if let Some((_sku, tail)) = rest.split_once('/') {
|
if let Some((_sku, tail)) = rest.split_once('/') {
|
||||||
if tail == "item" && post {
|
if tail == "item" && post {
|
||||||
return Some(EconomyRoute::QuickSellBody);
|
return Some(EconomyRoute::QuickSellBody);
|
||||||
@@ -237,13 +284,13 @@ pub fn classify_economy(method: &str, path: &str) -> Option<EconomyRoute> {
|
|||||||
match ut_tail(path) {
|
match ut_tail(path) {
|
||||||
Some("user/credits") if get => Some(EconomyRoute::Credits),
|
Some("user/credits") if get => Some(EconomyRoute::Credits),
|
||||||
Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
|
Some(t) if get && t.starts_with("store/purchasegroup") => Some(EconomyRoute::PurchaseGroup),
|
||||||
Some("store/transaction") if put => Some(EconomyRoute::StoreBuy),
|
Some(t) if put && is_store_transaction_tail(t) => Some(EconomyRoute::StoreBuy),
|
||||||
Some("purchased") if post => Some(EconomyRoute::PackOpen),
|
Some(t) if post && is_purchased_tail(t) => Some(EconomyRoute::PackOpen),
|
||||||
Some("purchased") if get => Some(EconomyRoute::PackReveal),
|
Some(t) if get && is_purchased_tail(t) => Some(EconomyRoute::PackReveal),
|
||||||
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
|
Some(t) if delete && is_item_id_tail(t) => Some(EconomyRoute::QuickSellPath),
|
||||||
Some("item") if put => Some(EconomyRoute::MoveItems),
|
Some("item") if put => Some(EconomyRoute::MoveItems),
|
||||||
Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList),
|
Some(t) if (t == "auctionhouse" || t == "transfermarket") => Some(EconomyRoute::MarketList),
|
||||||
Some("tradePile") if get => Some(EconomyRoute::MarketQuery),
|
Some(t) if get && is_tradepile_tail(t) => Some(EconomyRoute::MarketQuery),
|
||||||
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
|
Some(t) if t.starts_with("trade") => Some(EconomyRoute::MarketBuy),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -2084,6 +2131,21 @@ impl Server {
|
|||||||
client_ip: Option<&str>,
|
client_ip: Option<&str>,
|
||||||
) -> WireResponse {
|
) -> WireResponse {
|
||||||
let path = target.split('?').next().unwrap_or(target);
|
let path = target.split('?').next().unwrap_or(target);
|
||||||
|
// ─────────────────────── Economy authority barrier ───────────────────
|
||||||
|
// Every economy-touching route is owned by Rust/Core. Classified and
|
||||||
|
// dispatched HERE, before `classify()`, so a migrated route can NEVER also
|
||||||
|
// reach the Python passthrough (NEVER BOTH). When the economy services are
|
||||||
|
// wired (production `from_config`), an economy route ALWAYS returns `Some`
|
||||||
|
// — fail-closed (503) on any Core error — so there is no Python economy
|
||||||
|
// fallback. `None` means "not an economy route" (or no economy wired, i.e.
|
||||||
|
// a bare test server), which falls through to the classifier below.
|
||||||
|
if let Some(resp) = self.try_handle_economy(method, target, headers, body, client_ip) {
|
||||||
|
eprintln!(
|
||||||
|
"utas-host owner=RUST route=economy method={} path={} status={}",
|
||||||
|
method, path, resp.status
|
||||||
|
);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
match classify(method, path) {
|
match classify(method, path) {
|
||||||
Route::Club => {
|
Route::Club => {
|
||||||
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
|
||||||
@@ -2128,11 +2190,31 @@ impl Server {
|
|||||||
}
|
}
|
||||||
Route::UserMassInfo => {
|
Route::UserMassInfo => {
|
||||||
let deps = self.squad_deps();
|
let deps = self.squad_deps();
|
||||||
let (resp, log) =
|
let (mut resp, log) =
|
||||||
handle_user_mass_info(method, target, headers, body, &deps, self.pass.as_ref());
|
handle_user_mass_info(method, target, headers, body, &deps, self.pass.as_ref());
|
||||||
|
// Overlay the authoritative Core economy (coins + unopened-pack
|
||||||
|
// count) so NO stale Python economy value is visible post-barrier.
|
||||||
|
// userMassInfo remains a hybrid by design: Python supplies the
|
||||||
|
// non-economy envelope; Rust owns the squad AND the economy fields.
|
||||||
|
let mut econ_overlaid = false;
|
||||||
|
if let Some(svc) = &self.economy {
|
||||||
|
if (200..300).contains(&resp.status) {
|
||||||
|
if let (Ok(coins), Ok(ents)) = (svc.econ.balance(), svc.econ.entitlements())
|
||||||
|
{
|
||||||
|
if let Ok(mut root) = serde_json::from_slice::<Value>(&resp.body) {
|
||||||
|
if overlay_massinfo_economy(&mut root, coins, ents.len()) {
|
||||||
|
if let Ok(nb) = serde_json::to_vec(&root) {
|
||||||
|
set_json_body(&mut resp, nb);
|
||||||
|
econ_overlaid = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"utas-host owner=RUST_OVERLAY route=userMassInfo status={} squad_outcome={} detail=[{}]",
|
"utas-host owner=RUST_OVERLAY route=userMassInfo status={} squad_outcome={} econ_overlaid={} detail=[{}]",
|
||||||
resp.status, log.outcome, log.detail
|
resp.status, log.outcome, econ_overlaid, log.detail
|
||||||
);
|
);
|
||||||
resp
|
resp
|
||||||
}
|
}
|
||||||
@@ -3053,4 +3135,227 @@ mod tests {
|
|||||||
let mut other = serde_json::json!({"other": 1});
|
let mut other = serde_json::json!({"other": 1});
|
||||||
assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0);
|
assert_eq!(overlay_empty_mypacks(&mut other, StoreMode::CleanV1), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ut_tail_normalizes_v1_and_v2() {
|
||||||
|
for (path, want) in [
|
||||||
|
(
|
||||||
|
"/ut/game/fifa17/store/purchasegroup",
|
||||||
|
Some("store/purchasegroup"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/ut/v2/game/fifa17/store/purchasegroup",
|
||||||
|
Some("store/purchasegroup"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/ut/game/fifa17/store/transaction",
|
||||||
|
Some("store/transaction"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/ut/v2/game/fifa17/store/transaction/0",
|
||||||
|
Some("store/transaction/0"),
|
||||||
|
),
|
||||||
|
("/ut/game/fifa17/purchased", Some("purchased")),
|
||||||
|
("/ut/v2/game/fifa17/purchased", Some("purchased")),
|
||||||
|
("/ut/game/fifa17/user/credits", Some("user/credits")),
|
||||||
|
// generic sku — helper is not fifa17-string-specific.
|
||||||
|
(
|
||||||
|
"/ut/game/fifa23/store/transaction/7",
|
||||||
|
Some("store/transaction/7"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"/ut/v2/game/fifa23/store/purchasegroup",
|
||||||
|
Some("store/purchasegroup"),
|
||||||
|
),
|
||||||
|
// negatives.
|
||||||
|
("/ut/auth", None),
|
||||||
|
("/openfut/account/sync", None),
|
||||||
|
("/ut/game/", None),
|
||||||
|
("/ut/game/fifa17", None),
|
||||||
|
("/ut/v2/game/fifa17", None),
|
||||||
|
("/ut/v2/other/thing", None),
|
||||||
|
("/ut/delete/game/fifa17/item", None),
|
||||||
|
] {
|
||||||
|
assert_eq!(ut_tail(path), want, "ut_tail({path})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_store_transaction_tail_is_bounded() {
|
||||||
|
assert!(is_store_transaction_tail("store/transaction"));
|
||||||
|
assert!(is_store_transaction_tail("store/transaction/0"));
|
||||||
|
assert!(is_store_transaction_tail("store/transaction/123"));
|
||||||
|
assert!(!is_store_transaction_tail("store/transactions"));
|
||||||
|
assert!(!is_store_transaction_tail("store/transactionfoo"));
|
||||||
|
assert!(!is_store_transaction_tail("store/transaction/0/extra"));
|
||||||
|
assert!(!is_store_transaction_tail("store/transaction/"));
|
||||||
|
assert!(!is_store_transaction_tail("store/transaction/abc"));
|
||||||
|
assert!(!is_store_transaction_tail("store/purchasegroup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classify_economy_covers_retail_v2_store_family() {
|
||||||
|
use EconomyRoute::*;
|
||||||
|
// The exact live-failure shape now classifies as Rust StoreBuy.
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0"),
|
||||||
|
Some(StoreBuy)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("PUT", "/ut/game/fifa17/store/transaction"),
|
||||||
|
Some(StoreBuy)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/123"),
|
||||||
|
Some(StoreBuy)
|
||||||
|
);
|
||||||
|
// purchasegroup + purchased under both prefixes.
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("GET", "/ut/v2/game/fifa17/store/purchasegroup"),
|
||||||
|
Some(PurchaseGroup)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("GET", "/ut/game/fifa17/store/purchasegroup/all"),
|
||||||
|
Some(PurchaseGroup)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("POST", "/ut/v2/game/fifa17/purchased"),
|
||||||
|
Some(PackOpen)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("GET", "/ut/v2/game/fifa17/purchased"),
|
||||||
|
Some(PackReveal)
|
||||||
|
);
|
||||||
|
// v1 non-store economy routes still classify (regression).
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("GET", "/ut/game/fifa17/user/credits"),
|
||||||
|
Some(Credits)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("PUT", "/ut/game/fifa17/item"),
|
||||||
|
Some(MoveItems)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("DELETE", "/ut/game/fifa17/item/100000001"),
|
||||||
|
Some(QuickSellPath)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("GET", "/ut/game/fifa17/tradePile"),
|
||||||
|
Some(MarketQuery)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("POST", "/ut/delete/game/fifa17/item"),
|
||||||
|
Some(QuickSellBody)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("POST", "/ut/delete/game/fifa17/match"),
|
||||||
|
Some(MatchEnd)
|
||||||
|
);
|
||||||
|
// delete family under v2 prefix too (defense-in-depth symmetry).
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("POST", "/ut/v2/delete/game/fifa17/item"),
|
||||||
|
Some(QuickSellBody)
|
||||||
|
);
|
||||||
|
// negatives: non-economy stays None (proxied to Python).
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("PUT", "/ut/v2/game/fifa17/store/transaction/0/extra"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy("PUT", "/ut/game/fifa17/store/transactions"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(classify_economy("GET", "/ut/game/fifa17/hub"), None);
|
||||||
|
assert_eq!(classify_economy("GET", "/ut/v2/game/fifa17/store"), None);
|
||||||
|
assert_eq!(classify_economy("POST", "/ut/auth"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RETAIL_ROUTE_MATRIX — the audited retail economy route contract. Every
|
||||||
|
/// economy row MUST classify to its Rust route (Python proxy forbidden);
|
||||||
|
/// every negative near-miss MUST stay `None` (proxied). Permanent gate against
|
||||||
|
/// "Python knows route X, Rust forgot route X".
|
||||||
|
#[test]
|
||||||
|
fn retail_route_matrix() {
|
||||||
|
use EconomyRoute::*;
|
||||||
|
let matrix: &[(&str, &str, Option<EconomyRoute>)] = &[
|
||||||
|
// credits
|
||||||
|
("GET", "/ut/game/fifa17/user/credits", Some(Credits)),
|
||||||
|
// purchasegroup (v1 + v2 + /all)
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fifa17/store/purchasegroup",
|
||||||
|
Some(PurchaseGroup),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fifa17/store/purchasegroup/all",
|
||||||
|
Some(PurchaseGroup),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/v2/game/fifa17/store/purchasegroup/all",
|
||||||
|
Some(PurchaseGroup),
|
||||||
|
),
|
||||||
|
// store transaction (v2 + trailing id) — round-1 fix
|
||||||
|
("PUT", "/ut/game/fifa17/store/transaction", Some(StoreBuy)),
|
||||||
|
(
|
||||||
|
"PUT",
|
||||||
|
"/ut/v2/game/fifa17/store/transaction/0",
|
||||||
|
Some(StoreBuy),
|
||||||
|
),
|
||||||
|
// purchased + purchased/items — round-2 fix (POST open, GET reveal)
|
||||||
|
("POST", "/ut/game/fifa17/purchased", Some(PackOpen)),
|
||||||
|
("POST", "/ut/game/fifa17/purchased/items", Some(PackOpen)),
|
||||||
|
("POST", "/ut/v2/game/fifa17/purchased/items", Some(PackOpen)),
|
||||||
|
("GET", "/ut/game/fifa17/purchased", Some(PackReveal)),
|
||||||
|
("GET", "/ut/game/fifa17/purchased/items", Some(PackReveal)),
|
||||||
|
// move
|
||||||
|
("PUT", "/ut/game/fifa17/item", Some(MoveItems)),
|
||||||
|
// quick-sell (path + body)
|
||||||
|
(
|
||||||
|
"DELETE",
|
||||||
|
"/ut/game/fifa17/item/100000001",
|
||||||
|
Some(QuickSellPath),
|
||||||
|
),
|
||||||
|
("POST", "/ut/delete/game/fifa17/item", Some(QuickSellBody)),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/v2/delete/game/fifa17/item",
|
||||||
|
Some(QuickSellBody),
|
||||||
|
),
|
||||||
|
// match end
|
||||||
|
("POST", "/ut/delete/game/fifa17/match", Some(MatchEnd)),
|
||||||
|
// market list / query (case-insensitive tradePile + counts) / buy / cancel
|
||||||
|
("POST", "/ut/game/fifa17/auctionhouse", Some(MarketList)),
|
||||||
|
("POST", "/ut/game/fifa17/transfermarket", Some(MarketList)),
|
||||||
|
("GET", "/ut/game/fifa17/tradePile", Some(MarketQuery)),
|
||||||
|
("GET", "/ut/game/fifa17/tradepile", Some(MarketQuery)),
|
||||||
|
("GET", "/ut/game/fifa17/tradePile/counts", Some(MarketQuery)),
|
||||||
|
("GET", "/ut/game/fifa17/tradepile/counts", Some(MarketQuery)),
|
||||||
|
("POST", "/ut/game/fifa17/trade/900000001", Some(MarketBuy)),
|
||||||
|
(
|
||||||
|
"DELETE",
|
||||||
|
"/ut/delete/game/fifa17/trade/900000001",
|
||||||
|
Some(MarketCancel),
|
||||||
|
),
|
||||||
|
// ── negatives: must stay None (proxied to Python) ──
|
||||||
|
("GET", "/ut/game/fifa17/store", None),
|
||||||
|
("GET", "/ut/game/fifa17/store/", None),
|
||||||
|
("PUT", "/ut/game/fifa17/store/transactions", None),
|
||||||
|
("PUT", "/ut/game/fifa17/store/transaction/0/extra", None),
|
||||||
|
("POST", "/ut/game/fifa17/purchasedfoo", None),
|
||||||
|
("POST", "/ut/game/fifa17/purchased/items/extra", None),
|
||||||
|
("GET", "/ut/game/fifa17/hub", None),
|
||||||
|
("GET", "/ut/game/fifa17/marketdata", None),
|
||||||
|
("POST", "/ut/auth", None),
|
||||||
|
("GET", "/ut/game/fifa17/watchList", None),
|
||||||
|
];
|
||||||
|
for (m, p, want) in matrix {
|
||||||
|
assert_eq!(
|
||||||
|
classify_economy(m, p),
|
||||||
|
*want,
|
||||||
|
"RETAIL_ROUTE_MATRIX: {m} {p}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -274,6 +274,7 @@ struct SeqResult {
|
|||||||
fn build_econ_server(
|
fn build_econ_server(
|
||||||
base: &str,
|
base: &str,
|
||||||
dir: &std::path::Path,
|
dir: &std::path::Path,
|
||||||
|
pass_url: &str,
|
||||||
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
|
) -> (Server, HttpCoreClient, Arc<Fifa17IdentityResolver>, i64) {
|
||||||
let probe = HttpCoreClient::new(base, "fifa17");
|
let probe = HttpCoreClient::new(base, "fifa17");
|
||||||
let owned = probe.all_owned().expect("core collection");
|
let owned = probe.all_owned().expect("core collection");
|
||||||
@@ -334,7 +335,7 @@ fn build_econ_server(
|
|||||||
core,
|
core,
|
||||||
entities,
|
entities,
|
||||||
resolver.clone(),
|
resolver.clone(),
|
||||||
Arc::new(PassClient::new("http://127.0.0.1:9")),
|
Arc::new(PassClient::new(pass_url)),
|
||||||
33068179,
|
33068179,
|
||||||
)
|
)
|
||||||
.with_economy(services);
|
.with_economy(services);
|
||||||
@@ -349,7 +350,8 @@ fn economy_sequence(base: &str, dir: &std::path::Path) -> SeqResult {
|
|||||||
wait_ready(base);
|
wait_ready(base);
|
||||||
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
// Core is seeded (start_core_seeded): a fifa17 profile with 100k coins + one
|
||||||
// owned instance per definition. No /auth/local — the profile already exists.
|
// owned instance per definition. No /auth/local — the profile already exists.
|
||||||
let (server, client, resolver, sample_resource) = build_econ_server(base, dir);
|
let (server, client, resolver, sample_resource) =
|
||||||
|
build_econ_server(base, dir, "http://127.0.0.1:9");
|
||||||
let start = client.balance().unwrap();
|
let start = client.balance().unwrap();
|
||||||
assert!(start >= 5000, "seeded dev balance present ({start})");
|
assert!(start >= 5000, "seeded dev balance present ({start})");
|
||||||
|
|
||||||
@@ -889,3 +891,527 @@ async fn from_config_constructs_and_serves_economy() {
|
|||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────── Post-barrier authority proofs (NEVER BOTH / no fallback / ────
|
||||||
|
// stale reader), through the REAL handle_with_ip dispatch ──────
|
||||||
|
|
||||||
|
/// A mock Python UTAS upstream that COUNTS every request it receives and always
|
||||||
|
/// answers with a distinctive marker body carrying coins=111. If an economy
|
||||||
|
/// route ever reaches Python, this counter moves and/or the marker leaks.
|
||||||
|
struct MockPython {
|
||||||
|
calls: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn start_mock_python() -> MockPython {
|
||||||
|
use std::io::{Read, Write};
|
||||||
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
let addr = listener.local_addr().unwrap();
|
||||||
|
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
let c2 = calls.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for stream in listener.incoming() {
|
||||||
|
let Ok(mut s) = stream else { continue };
|
||||||
|
c2.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let mut buf = [0u8; 8192];
|
||||||
|
let _ = s.read(&mut buf);
|
||||||
|
let body = br#"{"__python__":true,"credits":111,"currencies":[{"name":"coins","funds":111,"finalFunds":111}],"userInfo":{"currencies":[{"name":"coins","funds":111,"finalFunds":111}]},"purchase":[]}"#;
|
||||||
|
let head = format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
|
body.len()
|
||||||
|
);
|
||||||
|
let _ = s.write_all(head.as_bytes());
|
||||||
|
let _ = s.write_all(body);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
MockPython {
|
||||||
|
calls,
|
||||||
|
url: format!("http://{addr}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The pure economy routes (userMassInfo excluded — it is the documented hybrid
|
||||||
|
/// that proxies the Python envelope but Rust-overlays the economy fields).
|
||||||
|
fn pure_economy_routes() -> Vec<(&'static str, String, Vec<u8>)> {
|
||||||
|
vec![
|
||||||
|
("GET", "/ut/game/fifa17/user/credits".into(), b"".to_vec()),
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fifa17/store/purchasegroup".into(),
|
||||||
|
b"".to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PUT",
|
||||||
|
"/ut/game/fifa17/store/transaction".into(),
|
||||||
|
br#"{"packId":1}"#.to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fifa17/purchased".into(),
|
||||||
|
br#"{"packId":70}"#.to_vec(),
|
||||||
|
),
|
||||||
|
("GET", "/ut/game/fifa17/purchased".into(), b"".to_vec()),
|
||||||
|
(
|
||||||
|
"DELETE",
|
||||||
|
"/ut/game/fifa17/item/100000001".into(),
|
||||||
|
b"".to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/delete/game/fifa17/item".into(),
|
||||||
|
br#"{"itemData":[{"id":100000001}]}"#.to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"PUT",
|
||||||
|
"/ut/game/fifa17/item".into(),
|
||||||
|
br#"{"itemData":[{"id":100000001,"pile":"trade"}]}"#.to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/delete/game/fifa17/match".into(),
|
||||||
|
br#"{"endReason":"WIN"}"#.to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fifa17/auctionhouse".into(),
|
||||||
|
br#"{"itemData":{"id":555,"resourceId":20000},"buyNowPrice":1000,"startingBid":500}"#
|
||||||
|
.to_vec(),
|
||||||
|
),
|
||||||
|
("GET", "/ut/game/fifa17/tradePile".into(), b"".to_vec()),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fifa17/trade/900000001".into(),
|
||||||
|
b"{}".to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"DELETE",
|
||||||
|
"/ut/delete/game/fifa17/trade/900000001".into(),
|
||||||
|
b"".to_vec(),
|
||||||
|
),
|
||||||
|
// ── Retail v2 Store family (the S2 live-failure shapes). These MUST be
|
||||||
|
// Rust-owned exactly like their v1 forms. ──
|
||||||
|
(
|
||||||
|
"PUT",
|
||||||
|
"/ut/v2/game/fifa17/store/transaction/0".into(),
|
||||||
|
br#"{"packId":1}"#.to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/v2/game/fifa17/store/purchasegroup".into(),
|
||||||
|
b"".to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/v2/game/fifa17/purchased".into(),
|
||||||
|
br#"{"packId":70}"#.to_vec(),
|
||||||
|
),
|
||||||
|
("GET", "/ut/v2/game/fifa17/purchased".into(), b"".to_vec()),
|
||||||
|
// ── Round-2 retail shapes: the confirmed BUY uses POST /purchased/items,
|
||||||
|
// reveal GET /purchased/items; hub tile polls lowercase tradepile + /counts. ──
|
||||||
|
(
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fifa17/purchased/items".into(),
|
||||||
|
br#"{"packId":1}"#.to_vec(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fifa17/purchased/items".into(),
|
||||||
|
b"".to_vec(),
|
||||||
|
),
|
||||||
|
("GET", "/ut/game/fifa17/tradepile".into(), b"".to_vec()),
|
||||||
|
(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fifa17/tradePile/counts".into(),
|
||||||
|
b"".to_vec(),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn barrier_checks(base: &str, dir: &std::path::Path, mock: &MockPython) {
|
||||||
|
wait_ready(base);
|
||||||
|
let (server, client, _r, _sample) = build_econ_server(base, dir, &mock.url);
|
||||||
|
let core_coins = client.balance().unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
core_coins, 111,
|
||||||
|
"Core must diverge from the Python marker (111)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── STALE READER: readers show Core values, never the Python 111 ──
|
||||||
|
let cr = server.handle_with_ip("GET", "/ut/game/fifa17/user/credits", &[], b"", None);
|
||||||
|
let crv: Value = serde_json::from_slice(&cr.body).unwrap();
|
||||||
|
assert!(
|
||||||
|
crv.get("__python__").is_none(),
|
||||||
|
"credits is Rust, not the Python body"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
crv["currencies"][0]["funds"], core_coins,
|
||||||
|
"credits coins = Core, not 111"
|
||||||
|
);
|
||||||
|
// userMassInfo is the hybrid: Python envelope proxied, economy Rust-overlaid.
|
||||||
|
let mi = server.handle_with_ip("GET", "/ut/game/fifa17/userMassInfo", &[], b"", None);
|
||||||
|
let miv: Value = serde_json::from_slice(&mi.body).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
miv["userInfo"]["currencies"][0]["funds"], core_coins,
|
||||||
|
"userMassInfo coins overlaid to Core (stale Python 111 not visible)"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── PART 7 REPRO: the exact S2 live-failure shape (retail v2 Store BUY,
|
||||||
|
// `PUT /ut/v2/game/fifa17/store/transaction/0`) is now Rust-owned — it
|
||||||
|
// debits Core and returns a `createPackResponse`, NOT the Python
|
||||||
|
// `{"state":"TRANSACTIONCANCEL"}` no-op the rejected candidate produced. ──
|
||||||
|
let before_buy = client.balance().unwrap();
|
||||||
|
let calls_before_buy = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let buy = server.handle_with_ip(
|
||||||
|
"PUT",
|
||||||
|
"/ut/v2/game/fifa17/store/transaction/0",
|
||||||
|
&[],
|
||||||
|
br#"{"packId":1}"#,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(buy.status, 200, "v2 Store BUY handled by Rust (200)");
|
||||||
|
let buyv: Value = serde_json::from_slice(&buy.body).unwrap();
|
||||||
|
assert!(
|
||||||
|
buyv.get("createPackResponse").is_some(),
|
||||||
|
"v2 Store BUY returns a Rust createPackResponse, not the Python no-op: {buyv}"
|
||||||
|
);
|
||||||
|
assert_ne!(
|
||||||
|
buyv.get("state").and_then(|s| s.as_str()),
|
||||||
|
Some("TRANSACTIONCANCEL"),
|
||||||
|
"v2 Store BUY must NOT be the Python TRANSACTIONCANCEL fallback"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
calls_before_buy,
|
||||||
|
"v2 Store BUY never reached the Python proxy"
|
||||||
|
);
|
||||||
|
let after_buy = client.balance().unwrap();
|
||||||
|
assert!(
|
||||||
|
after_buy < before_buy,
|
||||||
|
"v2 Store BUY debited Core coins ({before_buy} -> {after_buy})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── NEVER BOTH (Core up): pure economy routes reach Rust, never Python ──
|
||||||
|
let before = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
for (m, p, b) in pure_economy_routes() {
|
||||||
|
let r = server.handle_with_ip(m, &p, &[], &b, None);
|
||||||
|
assert!(
|
||||||
|
!r.body.windows(10).any(|w| w == b"__python__"),
|
||||||
|
"{m} {p} must be Rust-owned (no Python marker in body)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
before,
|
||||||
|
"NEVER BOTH: no pure economy route reached the Python proxy"
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── NO FALLBACK: a server pointed at a DEAD Core still fails closed and
|
||||||
|
// never proxies to Python. Built without probing Core (empty catalog +
|
||||||
|
// empty pool), so no live Core is needed to construct it. ──
|
||||||
|
let dead_dir = dir.join("dead");
|
||||||
|
std::fs::create_dir_all(&dead_dir).unwrap();
|
||||||
|
let dead = build_dead_core_server(&dead_dir, &mock.url);
|
||||||
|
let before_down = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let credits_down = dead.handle_with_ip("GET", "/ut/game/fifa17/user/credits", &[], b"", None);
|
||||||
|
assert_eq!(
|
||||||
|
credits_down.status, 503,
|
||||||
|
"credits fails closed against a dead Core"
|
||||||
|
);
|
||||||
|
let match_down = dead.handle_with_ip(
|
||||||
|
"POST",
|
||||||
|
"/ut/delete/game/fifa17/match",
|
||||||
|
&[],
|
||||||
|
br#"{"endReason":"WIN"}"#,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
match_down.status, 503,
|
||||||
|
"match fails closed against a dead Core"
|
||||||
|
);
|
||||||
|
for (m, p, b) in pure_economy_routes() {
|
||||||
|
let _ = dead.handle_with_ip(m, &p, &[], &b, None);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
before_down,
|
||||||
|
"NO FALLBACK: economy routes never proxy to Python even against a dead Core"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `Server` whose Core (read + economy) points at a definitely-dead loopback
|
||||||
|
/// port, wired WITHOUT probing Core: an empty catalog + empty content pool. Used
|
||||||
|
/// to prove economy routes fail closed (503) and never fall back to Python.
|
||||||
|
fn build_dead_core_server(dir: &std::path::Path, pass_url: &str) -> Server {
|
||||||
|
// A closed loopback port: bind then drop, so connects are refused.
|
||||||
|
let dead_addr = {
|
||||||
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
|
l.local_addr().unwrap()
|
||||||
|
};
|
||||||
|
let dead_url = format!("http://{dead_addr}");
|
||||||
|
let catalog =
|
||||||
|
Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa17","cards":{}}"#)
|
||||||
|
.unwrap();
|
||||||
|
let store = JsonIdentityStore::open(dir.join("identity.json").to_str().unwrap()).unwrap();
|
||||||
|
let resolver = Arc::new(Fifa17IdentityResolver::new(catalog, Arc::new(store)));
|
||||||
|
let entities = Arc::new(Fifa17Entities::from_maps(
|
||||||
|
HashMap::new(),
|
||||||
|
HashMap::new(),
|
||||||
|
HashMap::new(),
|
||||||
|
));
|
||||||
|
let core: Arc<dyn CoreAccess> = Arc::new(HttpCoreClient::new(dead_url.clone(), "fifa17"));
|
||||||
|
let bridge = Arc::new(AsyncBridge::new().unwrap());
|
||||||
|
let mp = dir.join("market.db").to_string_lossy().into_owned();
|
||||||
|
let market = Arc::new(
|
||||||
|
bridge
|
||||||
|
.block_on(async move { MarketStore::open(&mp).await })
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let pp = dir.join("pile.db").to_string_lossy().into_owned();
|
||||||
|
let piles = Arc::new(
|
||||||
|
bridge
|
||||||
|
.block_on(async move { PileStore::open(&pp).await })
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
let econ: Arc<dyn CoreEconomy> = Arc::new(HttpCoreClient::new(dead_url, "fifa17"));
|
||||||
|
let services = Arc::new(EconomyServices {
|
||||||
|
econ,
|
||||||
|
market,
|
||||||
|
piles,
|
||||||
|
bridge,
|
||||||
|
pool: Arc::new(Vec::new()),
|
||||||
|
});
|
||||||
|
Server::new(
|
||||||
|
core,
|
||||||
|
entities,
|
||||||
|
resolver,
|
||||||
|
Arc::new(PassClient::new(pass_url)),
|
||||||
|
33_068_179,
|
||||||
|
)
|
||||||
|
.with_economy(services)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn barrier_never_both_no_fallback_and_stale_reader() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"openfut-econ-barrier-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db_url = format!("sqlite://{}/econ.db", dir.display());
|
||||||
|
let (h, base) = start_core_seeded(&db_url, true).await;
|
||||||
|
let mock = start_mock_python();
|
||||||
|
let (b, d) = (base.clone(), dir.clone());
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
std::thread::spawn(move || barrier_checks(&b, &d, &mock))
|
||||||
|
.join()
|
||||||
|
.expect("barrier checks thread")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("barrier phase");
|
||||||
|
h.abort();
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Retail v2 Store E2E + v1/v2 route equivalence ───────────────
|
||||||
|
//
|
||||||
|
// Proves the S2 fix end-to-end through the REAL dispatch: the Store flow driven
|
||||||
|
// over the retail `/ut/v2/game/<sku>/…` paths is Rust-owned (Python proxy count
|
||||||
|
// 0), mutates Core, and behaves IDENTICALLY to the v1 paths for the same op.
|
||||||
|
|
||||||
|
fn v2_store_flow(base: &str, dir: &std::path::Path) {
|
||||||
|
let mock = start_mock_python();
|
||||||
|
let (server, client, _r, _s) = build_econ_server(base, dir, &mock.url);
|
||||||
|
let calls0 = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
|
// GET purchasegroup via v2 → Rust catalogue (non-empty).
|
||||||
|
let pg = server.handle_with_ip(
|
||||||
|
"GET",
|
||||||
|
"/ut/v2/game/fifa17/store/purchasegroup",
|
||||||
|
&[],
|
||||||
|
b"",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(pg.status, 200, "v2 purchasegroup handled by Rust");
|
||||||
|
let pgv: Value = serde_json::from_slice(&pg.body).unwrap();
|
||||||
|
assert!(
|
||||||
|
pgv.get("purchase")
|
||||||
|
.and_then(|p| p.as_array())
|
||||||
|
.is_some_and(|a| !a.is_empty()),
|
||||||
|
"v2 purchasegroup returns a Rust catalogue: {pgv}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same pack (id 1) via v1 then v2 → IDENTICAL debit + item count (Part 6).
|
||||||
|
let bal0 = client.balance().unwrap();
|
||||||
|
let v1 = server.handle_with_ip(
|
||||||
|
"PUT",
|
||||||
|
"/ut/game/fifa17/store/transaction",
|
||||||
|
&[],
|
||||||
|
br#"{"packId":1}"#,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(v1.status, 200);
|
||||||
|
let bal1 = client.balance().unwrap();
|
||||||
|
let v1v: Value = serde_json::from_slice(&v1.body).unwrap();
|
||||||
|
let v1_items = v1v["createPackResponse"]["itemList"]
|
||||||
|
.as_array()
|
||||||
|
.map_or(0, |a| a.len());
|
||||||
|
let v1_debit = bal0 - bal1;
|
||||||
|
|
||||||
|
let v2 = server.handle_with_ip(
|
||||||
|
"PUT",
|
||||||
|
"/ut/v2/game/fifa17/store/transaction/0",
|
||||||
|
&[],
|
||||||
|
br#"{"packId":1}"#,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(v2.status, 200);
|
||||||
|
let bal2 = client.balance().unwrap();
|
||||||
|
let v2v: Value = serde_json::from_slice(&v2.body).unwrap();
|
||||||
|
let v2_items = v2v["createPackResponse"]["itemList"]
|
||||||
|
.as_array()
|
||||||
|
.map_or(0, |a| a.len());
|
||||||
|
let v2_debit = bal1 - bal2;
|
||||||
|
|
||||||
|
assert!(v1_items > 0, "v1 BUY minted items");
|
||||||
|
assert_eq!(v1_items, v2_items, "v1/v2 BUY yield identical item counts");
|
||||||
|
assert_eq!(
|
||||||
|
v1_debit, v2_debit,
|
||||||
|
"v1/v2 BUY debit identically ({v1_debit} vs {v2_debit})"
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET purchased via v2 → Rust reveal shape; the just-bought items are in the pile.
|
||||||
|
let reveal = server.handle_with_ip("GET", "/ut/v2/game/fifa17/purchased", &[], b"", None);
|
||||||
|
assert_eq!(reveal.status, 200, "v2 GET /purchased handled by Rust");
|
||||||
|
let rv: Value = serde_json::from_slice(&reveal.body).unwrap();
|
||||||
|
assert!(
|
||||||
|
rv.get("itemData").and_then(|d| d.as_array()).is_some(),
|
||||||
|
"v2 reveal is a Rust itemData array: {rv}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// NEVER any Python proxy for the whole v2 Store flow.
|
||||||
|
assert_eq!(
|
||||||
|
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
calls0,
|
||||||
|
"v2 Store flow never reached the Python proxy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn retail_v2_store_flow_matches_v1_through_dispatch() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"openfut-econ-v2-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db_url = format!("sqlite://{}/econ.db", dir.display());
|
||||||
|
let (h, base) = start_core_seeded(&db_url, true).await;
|
||||||
|
let (b, d) = (base.clone(), dir.clone());
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
std::thread::spawn(move || v2_store_flow(&b, &d))
|
||||||
|
.join()
|
||||||
|
.expect("v2 store flow thread")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("v2 store phase");
|
||||||
|
h.abort();
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────── Retail /purchased/items BUY sequence (round-2 S2 regression) ─
|
||||||
|
//
|
||||||
|
// The rejected candidate 47ced22 sent the confirmed retail Store BUY
|
||||||
|
// (POST /ut/game/fifa17/purchased/items) to Python and left Core coins unchanged.
|
||||||
|
// This replays the exact live sequence through real dispatch and asserts the BUY
|
||||||
|
// debits Core, the reveal shows the minted items, and Python proxy count is 0.
|
||||||
|
|
||||||
|
fn retail_purchased_items_flow(base: &str, dir: &std::path::Path) {
|
||||||
|
let mock = start_mock_python();
|
||||||
|
let (server, client, _r, _s) = build_econ_server(base, dir, &mock.url);
|
||||||
|
let calls0 = mock.calls.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
|
||||||
|
// Store screen catalogue (v1 /all) — Rust.
|
||||||
|
let pg = server.handle_with_ip(
|
||||||
|
"GET",
|
||||||
|
"/ut/game/fifa17/store/purchasegroup/all",
|
||||||
|
&[],
|
||||||
|
b"",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(pg.status, 200, "purchasegroup/all Rust-owned");
|
||||||
|
|
||||||
|
// THE CONFIRMED RETAIL BUY: POST /ut/game/fifa17/purchased/items must debit Core.
|
||||||
|
let bal0 = client.balance().unwrap();
|
||||||
|
let buy = server.handle_with_ip(
|
||||||
|
"POST",
|
||||||
|
"/ut/game/fifa17/purchased/items",
|
||||||
|
&[],
|
||||||
|
br#"{"packId":1}"#,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert_eq!(buy.status, 200, "purchased/items BUY handled by Rust (200)");
|
||||||
|
assert!(
|
||||||
|
!buy.body.windows(10).any(|w| w == b"__python__"),
|
||||||
|
"purchased/items BUY is Rust-owned (no Python marker)"
|
||||||
|
);
|
||||||
|
let bal1 = client.balance().unwrap();
|
||||||
|
assert!(
|
||||||
|
bal1 < bal0,
|
||||||
|
"purchased/items BUY debited Core ({bal0} -> {bal1}) — the round-2 S2 was NO debit"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reveal poll: GET /ut/game/fifa17/purchased/items — Rust, shows the minted items.
|
||||||
|
let reveal = server.handle_with_ip("GET", "/ut/game/fifa17/purchased/items", &[], b"", None);
|
||||||
|
assert_eq!(reveal.status, 200, "purchased/items reveal Rust-owned");
|
||||||
|
let rv: Value = serde_json::from_slice(&reveal.body).unwrap();
|
||||||
|
let revealed = rv["itemData"].as_array().map_or(0, |a| a.len());
|
||||||
|
assert!(revealed > 0, "reveal shows the freshly-minted items: {rv}");
|
||||||
|
|
||||||
|
// Repeat reveal is idempotent (no re-grant, no extra debit).
|
||||||
|
let bal2 = client.balance().unwrap();
|
||||||
|
let _ = server.handle_with_ip("GET", "/ut/game/fifa17/purchased/items", &[], b"", None);
|
||||||
|
assert_eq!(
|
||||||
|
client.balance().unwrap(),
|
||||||
|
bal2,
|
||||||
|
"repeat reveal does not mutate coins"
|
||||||
|
);
|
||||||
|
|
||||||
|
// NEVER any Python proxy across the whole /purchased/items sequence.
|
||||||
|
assert_eq!(
|
||||||
|
mock.calls.load(std::sync::atomic::Ordering::SeqCst),
|
||||||
|
calls0,
|
||||||
|
"retail /purchased/items sequence never reached the Python proxy"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||||
|
async fn retail_purchased_items_buy_debits_core_through_dispatch() {
|
||||||
|
let dir = std::env::temp_dir().join(format!(
|
||||||
|
"openfut-econ-pi-{}-{}",
|
||||||
|
std::process::id(),
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let db_url = format!("sqlite://{}/econ.db", dir.display());
|
||||||
|
let (h, base) = start_core_seeded(&db_url, true).await;
|
||||||
|
let (b, d) = (base.clone(), dir.clone());
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
std::thread::spawn(move || retail_purchased_items_flow(&b, &d))
|
||||||
|
.join()
|
||||||
|
.expect("purchased/items flow thread")
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("purchased/items phase");
|
||||||
|
h.abort();
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user