"""Unit tests for the game_servers.py changes made for world persistence (world_persistence_impl.md §7.3/§7.4): per-spawn token validation, and the ``_await_ready`` replacement for the old, fail-open ``_await_port``. No real Godot process is spawned here (that stays a manual/checkpoint integration test, per test_world_join.py's docstring) -- these exercise ``GameServerSupervisor`` against a fake process object standing in for ``subprocess.Popen``. """ import json import pytest from app.config import settings from app.services.game_servers import GameServerSupervisor, _WorldServer class _FakeProc: """Stands in for subprocess.Popen: only .poll() is needed by the code paths under test here.""" def __init__(self, exited: bool = False): self._exited = exited def poll(self): return 0 if self._exited else None def terminate(self): self._exited = True def wait(self, timeout=None): return 0 def kill(self): self._exited = True def _make_srv(tmp_path, token="secret", alive=True) -> _WorldServer: status_path = tmp_path / "status.json" return _WorldServer( world_id="7", port=27000, proc=_FakeProc(exited=not alive), status_path=status_path, token=token, ) class TestValidateToken: def test_unknown_world_is_false(self): sup = GameServerSupervisor() assert sup.validate_token("no-such-world", "anything") is False def test_correct_token_is_true(self, tmp_path): sup = GameServerSupervisor() sup._servers["7"] = _make_srv(tmp_path, token="secret") assert sup.validate_token("7", "secret") is True # world_id is looked up as str(world_id), so an int works too -- # this matters because the FastAPI path param is an int. assert sup.validate_token(7, "secret") is True def test_wrong_token_is_false(self, tmp_path): sup = GameServerSupervisor() sup._servers["7"] = _make_srv(tmp_path, token="secret") assert sup.validate_token("7", "wrong") is False def test_token_scoped_to_its_own_world_only(self, tmp_path): sup = GameServerSupervisor() sup._servers["7"] = _make_srv(tmp_path, token="secret") # Same token, different world_id -- must not authenticate (§7.3: # "a path whose world_id differs from the token's world is 403"). assert sup.validate_token("8", "secret") is False def test_dead_server_token_is_false(self, tmp_path): sup = GameServerSupervisor() sup._servers["7"] = _make_srv(tmp_path, token="secret", alive=False) assert sup.validate_token("7", "secret") is False def test_reaping_invalidates_the_token(self, tmp_path): """§7.3: "dies with its process" -- once removed from _servers (what the reaper does), the same token no longer validates.""" sup = GameServerSupervisor() sup._servers["7"] = _make_srv(tmp_path, token="secret") assert sup.validate_token("7", "secret") is True del sup._servers["7"] assert sup.validate_token("7", "secret") is False class TestAwaitReady: """Replaces the old _await_port, which (a) treated a bound UDP port as readiness even though content loading happens before create_server, and (b) failed open past its deadline with no error at all.""" async def test_raises_timeout_error_when_never_ready(self, tmp_path, monkeypatch): monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3) sup = GameServerSupervisor() srv = _make_srv(tmp_path) # status file never written with pytest.raises(TimeoutError): await sup._await_ready(srv) async def test_returns_once_status_file_reports_ready(self, tmp_path, monkeypatch): monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 5.0) sup = GameServerSupervisor() srv = _make_srv(tmp_path) srv.status_path.write_text(json.dumps({"ready": True, "players": 0})) await sup._await_ready(srv) # must not raise / must not hang async def test_ready_false_is_not_ready(self, tmp_path, monkeypatch): monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 0.3) sup = GameServerSupervisor() srv = _make_srv(tmp_path) srv.status_path.write_text(json.dumps({"ready": False, "players": 0})) with pytest.raises(TimeoutError): await sup._await_ready(srv) async def test_raises_promptly_if_process_already_exited(self, tmp_path, monkeypatch): # A long deadline, but the process is already dead -- must not wait # out the full deadline (this is the "no crash restart mid-wait" # case: don't silently poll a corpse for 20s). monkeypatch.setattr(settings, "GAME_SERVER_READY_TIMEOUT_SECONDS", 20.0) sup = GameServerSupervisor() srv = _make_srv(tmp_path, alive=False) with pytest.raises(TimeoutError): await sup._await_ready(srv) class TestSpawnCommandLine: def test_spawn_includes_api_url_and_server_token(self, monkeypatch, tmp_path): """world_persistence_impl.md §7.2 step 1 / §7.4: `_spawn` must pass --api-url and --server-token on the command line.""" sup = GameServerSupervisor() monkeypatch.setattr(sup, "_free_port", lambda: 27050) captured = {} class _Proc: def poll(self): return None def _fake_popen(cmd, **kwargs): captured["cmd"] = cmd return _Proc() monkeypatch.setattr( "app.services.game_servers.subprocess.Popen", _fake_popen ) srv = sup._spawn("42") cmd = captured["cmd"] assert "--api-url" in cmd assert cmd[cmd.index("--api-url") + 1] == settings.GAME_SERVER_INTERNAL_API_URL assert "--server-token" in cmd token_in_cmd = cmd[cmd.index("--server-token") + 1] assert token_in_cmd == srv.token assert len(srv.token) > 20 # secrets.token_urlsafe(32) -- not a short/guessable value