makepad/libs/asset/store/tests/http/rooms_http.rs
Arena Agent d8d5ea8b6a Repair integration manifests and port nigig exports onto upstream widget families
Restore missing required asset/flow packages from the pinned nigig revision; retain upstream script isolation, X11 hover, and widget-core fixes. Validated makepad-widgets data feature with Rust 1.97.1.
2026-10-26 11:23:26 +00:00

222 lines
9.9 KiB
Rust

//! Live game rooms over HTTP: the rendezvous two players meet at when they
//! press Play on the same game. Claim, list, heartbeat, retire — and the two
//! situations that make the difference between "we are in the same world"
//! and "we are each alone": a simultaneous press, and a room nobody can dial.
mod common;
use common::*;
use makepad_asset_store::json::Value;
const TTL: i64 = 30_000;
fn setup() -> (TestServer, Client, Client) {
let ts = start_server("rooms");
let token = ts.admin_token();
let mut admin = ts.control(Some(&token));
// Hosting a game you can already download is not a privileged act:
// rooms need a principal this server knows and no capability at all.
let player = principal_with(&mut admin, &[]);
let client = ts.control(Some(&player));
(ts, admin, client)
}
fn claim_body(game: &str, invite: &str, host: &str) -> Value {
jobj(vec![
("game", jstr(game)),
("invite", jstr(invite)),
("host", jstr(host)),
("ttl_ms", Value::Int(TTL)),
])
}
fn rooms_of(client: &mut Client, query: &str) -> Vec<Value> {
let r = client.get(&format!("/v1/rooms{query}"));
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
r.json()
.get("rooms")
.and_then(Value::as_arr)
.expect("rooms array")
.to_vec()
}
fn field(v: &Value, key: &str) -> String {
v.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
}
/// The whole feature in one test: two people press Play on the same game and
/// end up in ONE world, with the second told where the first is.
#[test]
fn two_players_pressing_play_on_one_game_meet_in_one_room() {
let (ts, _admin, mut rik) = setup();
let mut sam = ts.control(None);
let token = ts.admin_token();
sam.set_token(Some(&token));
// Nobody is playing yet.
assert!(rooms_of(&mut rik, "?game=arcade").is_empty());
// Rik presses Play: no room, so he hosts and takes the claim.
let r = rik.post_json("/v1/rooms", &claim_body("arcade", "10.0.0.7:5000:5001#ab", "rik"));
assert_eq!(r.status, 201, "{}", String::from_utf8_lossy(&r.body));
assert_eq!(r.str_field("outcome"), "claimed");
let room = r.json().get("room").cloned().expect("room");
let room_id = field(&room, "room");
let host_token = r.str_field("token");
assert!(room_id.starts_with("room_"), "{room_id}");
// Sam presses Play on the same game one moment later. The server does
// not hand him a second claim — it hands him Rik's address.
let r = sam.post_json("/v1/rooms", &claim_body("arcade", "10.0.0.9:6000:6001#cd", "sam"));
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
assert_eq!(r.str_field("outcome"), "occupied");
let found = r.json().get("room").cloned().expect("room");
assert_eq!(field(&found, "invite"), "10.0.0.7:5000:5001#ab");
assert_eq!(field(&found, "host"), "rik");
assert_eq!(field(&found, "room"), room_id);
// The loser is never given the host's token — it cannot retire the room
// it just joined.
assert!(r.json().get("token").is_none());
// One game, one room, both players' view of it identical.
let listed = rooms_of(&mut sam, "?game=arcade");
assert_eq!(listed.len(), 1);
assert_eq!(field(&listed[0], "room"), room_id);
assert_eq!(rooms_of(&mut rik, "").len(), 1);
// A different game has its own claim, not a queue behind this one.
let r = sam.post_json("/v1/rooms", &claim_body("racer", "10.0.0.9:7000:7001#ef", "sam"));
assert_eq!(r.status, 201);
assert_eq!(rooms_of(&mut rik, "").len(), 2);
assert_eq!(rooms_of(&mut rik, "?game=arcade").len(), 1);
// Rik keeps the room alive, then leaves; the claim frees immediately so
// the next press hosts rather than dialling a machine that has quit.
let beat = jobj(vec![("token", jstr(host_token.clone())), ("ttl_ms", Value::Int(TTL))]);
let r = rik.post_json(&format!("/v1/rooms/{room_id}/heartbeat"), &beat);
assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
let retire = jobj(vec![("token", jstr(host_token))]);
let r = rik.post_json(&format!("/v1/rooms/{room_id}/retire"), &retire);
assert_eq!(r.status, 204);
assert!(rooms_of(&mut sam, "?game=arcade").is_empty());
}
/// A room whose host cannot be reached must not become a wall every later
/// player runs into. The joiner that failed says which room it failed on and
/// becomes the host itself.
#[test]
fn a_room_that_cannot_be_dialled_yields_its_claim_instead_of_trapping_the_joiner() {
let (_ts, _admin, mut player) = setup();
let r = player.post_json("/v1/rooms", &claim_body("arcade", "10.0.0.7:5000:5001#ab", "rik"));
let dead_id = field(&r.json().get("room").cloned().unwrap(), "room");
let dead_token = r.str_field("token");
// Sam reads the room, tries to dial it, gets nothing, and claims it.
let mut body = claim_body("arcade", "10.0.0.9:6000:6001#cd", "sam");
if let Value::Obj(pairs) = &mut body {
pairs.push(("replacing".to_string(), jstr(dead_id.clone())));
}
let r = player.post_json("/v1/rooms", &body);
assert_eq!(r.status, 201, "{}", String::from_utf8_lossy(&r.body));
assert_eq!(r.str_field("outcome"), "claimed");
let listed = rooms_of(&mut player, "?game=arcade");
assert_eq!(listed.len(), 1);
assert_eq!(field(&listed[0], "host"), "sam");
// The displaced host learns the claim moved the next time it says it is
// alive. It is not an error condition — it re-claims, and finds Sam.
let beat = jobj(vec![("token", jstr(dead_token)), ("ttl_ms", Value::Int(TTL))]);
let r = player.post_json(&format!("/v1/rooms/{dead_id}/heartbeat"), &beat);
assert_eq!(r.status, 404, "{}", String::from_utf8_lossy(&r.body));
// Naming a room that is no longer the holder is a stale report, not a
// failed dial of the live host — it must not take a working room down.
let mut stale = claim_body("arcade", "10.0.0.5:8000:8001#99", "kim");
if let Value::Obj(pairs) = &mut stale {
pairs.push(("replacing".to_string(), jstr(dead_id)));
}
let r = player.post_json("/v1/rooms", &stale);
assert_eq!(r.status, 200);
assert_eq!(r.str_field("outcome"), "occupied");
}
/// A room is somebody's running game: only its host may renew or end it, and
/// nobody anonymous may even look at the list.
#[test]
fn rooms_are_authenticated_token_gated_and_shape_checked() {
let (ts, _admin, mut player) = setup();
let mut anon = ts.control(None);
assert_eq!(anon.get("/v1/rooms").status, 401);
assert_eq!(anon.post_json("/v1/rooms", &claim_body("g", "i", "h")).status, 401);
assert_eq!(player.delete("/v1/rooms").status, 405);
let r = player.post_json("/v1/rooms", &claim_body("arcade", "10.0.0.7:5000:5001#ab", "rik"));
let id = field(&r.json().get("room").cloned().unwrap(), "room");
let good = r.str_field("token");
let wrong = jobj(vec![("token", jstr("not-the-token")), ("ttl_ms", Value::Int(TTL))]);
assert_eq!(player.post_json(&format!("/v1/rooms/{id}/heartbeat"), &wrong).status, 403);
assert_eq!(
player
.post_json(
&format!("/v1/rooms/{id}/retire"),
&jobj(vec![("token", jstr("not-the-token"))])
)
.status,
403
);
assert_eq!(rooms_of(&mut player, "").len(), 1);
// Unknown room: gone, not broken. Retiring one twice is not an error —
// a host that leaves and then exits runs both paths.
let beat = jobj(vec![("token", jstr(good.clone())), ("ttl_ms", Value::Int(TTL))]);
assert_eq!(
player.post_json("/v1/rooms/room_00000000deadbeef/heartbeat", &beat).status,
404
);
let retire = jobj(vec![("token", jstr(good.clone()))]);
assert_eq!(player.post_json(&format!("/v1/rooms/{id}/retire"), &retire).status, 204);
assert_eq!(player.post_json(&format!("/v1/rooms/{id}/retire"), &retire).status, 204);
// Shape refusals, all before anything is recorded.
let bad_ttl = jobj(vec![
("game", jstr("g")),
("invite", jstr("i")),
("host", jstr("h")),
("ttl_ms", Value::Int(1)),
]);
assert_eq!(player.post_json("/v1/rooms", &bad_ttl).status, 400);
let missing = jobj(vec![("game", jstr("g")), ("ttl_ms", Value::Int(TTL))]);
assert_eq!(player.post_json("/v1/rooms", &missing).status, 400);
let long_invite = claim_body("g", &"x".repeat(400), "h");
assert_eq!(player.post_json("/v1/rooms", &long_invite).status, 400);
assert_eq!(player.get(&format!("/v1/rooms?game={}", "x".repeat(200))).status, 400);
assert!(rooms_of(&mut player, "").is_empty());
}
/// A host that closes the lid stops advertising by itself: nobody sweeps,
/// nothing is persisted, and the claim frees for whoever presses Play next.
#[test]
fn a_lease_that_is_not_renewed_lapses_on_its_own() {
let (_ts, _admin, mut player) = setup();
let short = jobj(vec![
("game", jstr("arcade")),
("invite", jstr("10.0.0.7:5000:5001#ab")),
("host", jstr("rik")),
// The floor of the lease range: short enough for a test to outlive.
("ttl_ms", Value::Int(5_000)),
]);
let r = player.post_json("/v1/rooms", &short);
assert_eq!(r.status, 201);
assert_eq!(rooms_of(&mut player, "?game=arcade").len(), 1);
// Real wall-clock expiry is what a crashed host produces, and waiting
// five seconds for it in a test buys nothing the registry unit tests do
// not already prove (`a_host_that_vanishes_stops_advertising_within_one
// _lease`, on a hand-advanced clock). What this test pins is the HTTP
// half: the claim is live while the lease is, and retire frees it now.
let retire = jobj(vec![("token", jstr(r.str_field("token")))]);
let id = field(&r.json().get("room").cloned().unwrap(), "room");
assert_eq!(player.post_json(&format!("/v1/rooms/{id}/retire"), &retire).status, 204);
let r = player.post_json("/v1/rooms", &claim_body("arcade", "10.0.0.9:1:2#cd", "sam"));
assert_eq!(r.status, 201);
assert_eq!(r.str_field("outcome"), "claimed");
}