Add world content persistence backend (Postgres chunks, internal API, game-server token auth)
Implements docs/architecture/world_persistence_impl.md §9's backend task: chunk/room/revision Postgres schema (migration 0005), a Python port of the world.md §3.6 binary chunk codec with golden-file tests, a WorldStore service (world.md §5.1 minus flush_dirty/rebuild_index), the public world-content router (rooms/chunks CRUD, optimistic concurrency, batch writes) and the internal read-only API for dedicated servers, and game_servers.py changes: a per-spawn bearer token, --api-url/--server-token args, and a real "ready"-field wait replacing _await_port, which bound the UDP port before content existed and failed open (no error) past its deadline. create_world now auto-creates the "main" room. Deviations/findings, not silently decided: - chunk_codec.py implements real IEEE-754 float32 packing rather than mirroring a confirmed bug in scripts/chunk_data_decoder.gd's _read_float32/_write_float32, which do a lossy int<->float cast instead of a bit reinterpretation (verified against Godot 4.6.2 headless) and will corrupt any non-integer position/rotation/scale/bounds value. Not fixed here (client-side, out of scope); flagged for the client task. - world_persistence_impl.md references a world's monotonic content_revision (§6/§7.2/§8.1) but never defines where it's stored. Resolved as MAX(id) over the append-only world_chunk_revisions table, scoped to world_id, rather than adding an undocumented column. - No existing Godot-side golden fixture/test vectors were found to reuse, so tests/fixtures/chunks/*.bin are authored fresh from this implementation (see generate_fixtures.py). Verification: 132/132 backend pytest tests pass against a real ephemeral Postgres (both reusing an already-running instance and cold via conftest.py's own docker-compose bootstrap), including migration upgrade/downgrade/upgrade, codec round-trip + validation-error tests, and full HTTP round trips for PUT/GET/batch/delete/revisions/rollback and the internal API's token auth. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y2FESCaMXeJpC9gSctiEeD
This commit is contained in:
@@ -30,7 +30,16 @@ from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.main import app
|
||||
|
||||
TEST_TABLES = ("avatar_loadouts", "assets", "worlds", "sessions", "refresh_tokens", "users")
|
||||
TEST_TABLES = (
|
||||
"avatar_loadouts", "assets",
|
||||
# world_chunk_revisions has no FK to worlds/world_chunks (world.md §4.7 /
|
||||
# world_persistence_impl.md §4.3, deliberate -- deleting a chunk or world
|
||||
# must not lose the audit trail), so it won't be cascade-truncated via
|
||||
# `worlds` the way world_rooms/world_chunks would be. List all three
|
||||
# explicitly rather than rely on cascade for some of them.
|
||||
"world_chunk_revisions", "world_chunks", "world_rooms",
|
||||
"worlds", "sessions", "refresh_tokens", "users",
|
||||
)
|
||||
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[1]
|
||||
_COMPOSE_PROJECT = "agp-pytest"
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
+105
@@ -0,0 +1,105 @@
|
||||
"""Generates the golden-file chunk fixtures in this directory.
|
||||
|
||||
These pin the world.md §3.6 binary format so the Python codec
|
||||
(app/services/chunk_codec.py) and any future Godot-side test fixture can be
|
||||
checked for drift (world_persistence_impl.md §5.5's "drift control"
|
||||
requirement). No Godot-side golden fixture existed at the time this was
|
||||
written (searched scripts/ and tests/ for AGPC / binary-format test
|
||||
vectors -- none found), so these are authored here, from this
|
||||
implementation, as the first golden files for the format. The client/editor
|
||||
follow-on task should decode these same files with
|
||||
`ChunkDataDecoder.decode_binary()` and assert the same logical values
|
||||
(see the note in chunk_codec.py about a confirmed float32 handling bug in
|
||||
that decoder that will need fixing for full byte parity on non-integer
|
||||
transforms).
|
||||
|
||||
Run with: `python tests/fixtures/chunks/generate_fixtures.py` from `backend/`.
|
||||
Fixtures are checked in; this script is for regenerating them if the format
|
||||
or these examples change, not run as part of the test suite.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[3])) # backend/
|
||||
|
||||
from app.services import chunk_codec as cc
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent
|
||||
|
||||
|
||||
def _empty_chunk() -> cc.ChunkPayload:
|
||||
return cc.ChunkPayload(
|
||||
world_id="7", room_id="main", coord=(0, 0, 0),
|
||||
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
tile_layers=[], objects=[],
|
||||
flags=cc.ContentFlags(),
|
||||
created_at=1_700_000_000, updated_at=1_700_000_000, revision=1, author="fixture-gen",
|
||||
)
|
||||
|
||||
|
||||
def _simple_tile_chunk() -> cc.ChunkPayload:
|
||||
layer = cc.TileLayer(
|
||||
name="ground", layer_id=0, width=4, height=4, depth=1,
|
||||
palette=[
|
||||
cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1),
|
||||
cc.TileDef(id=1, asset_id="tiles/grass", solid=False, flags=1, variants=4),
|
||||
cc.TileDef(id=2, asset_id="tiles/stone", solid=True, flags=2, variants=2),
|
||||
],
|
||||
tiles=[0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0],
|
||||
)
|
||||
return cc.ChunkPayload(
|
||||
world_id="12", room_id="main", coord=(3, -1, 0),
|
||||
bounds=cc.ChunkBounds(min=(96.0, -32.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
tile_layers=[layer], objects=[],
|
||||
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
|
||||
created_at=1_700_000_000, updated_at=1_700_000_500, revision=2, author="fixture-gen",
|
||||
)
|
||||
|
||||
|
||||
def _chunk_with_objects() -> cc.ChunkPayload:
|
||||
layer = cc.TileLayer(
|
||||
name="ground", layer_id=0, width=2, height=2, depth=1,
|
||||
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
|
||||
tiles=[0, 0, 0, 0],
|
||||
)
|
||||
obj1 = cc.WorldObject(
|
||||
id="obj_0001", type="interactable", asset_id="props/chest",
|
||||
pos=(101.5, -110.25, 0.5), rot=(0.0, 0.0, 0.0, 1.0), scale=(1.0, 1.0, 1.0),
|
||||
dynamic=False,
|
||||
properties={"locked": False, "loot_table": "chest_basic"},
|
||||
flags=cc.ContentFlags(violence=0, mature=False, sources=[]),
|
||||
)
|
||||
obj2 = cc.WorldObject(
|
||||
id="obj_0002", type="spawn_point", asset_id="",
|
||||
pos=(96.0, -128.0, 1.0), rot=(0.0, 0.7071, 0.0, 0.7071), scale=(1.0, 1.0, 1.0),
|
||||
dynamic=True,
|
||||
properties={},
|
||||
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
|
||||
)
|
||||
return cc.ChunkPayload(
|
||||
world_id="12", room_id="main", coord=(3, -4, 0),
|
||||
bounds=cc.ChunkBounds(min=(96.0, -128.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
tile_layers=[layer], objects=[obj1, obj2],
|
||||
flags=cc.ContentFlags(violence=1, mature=False, sources=["asset:props/chest"]),
|
||||
created_at=1_700_000_000, updated_at=1_700_001_000, revision=5, author="agent-bot",
|
||||
)
|
||||
|
||||
|
||||
FIXTURES = {
|
||||
"empty_chunk.bin": _empty_chunk,
|
||||
"simple_tile_chunk.bin": _simple_tile_chunk,
|
||||
"chunk_with_objects.bin": _chunk_with_objects,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for filename, builder in FIXTURES.items():
|
||||
payload = builder()
|
||||
data = cc.encode_binary(payload)
|
||||
(FIXTURES_DIR / filename).write_bytes(data)
|
||||
print(f"wrote {filename} ({len(data)} bytes)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,219 @@
|
||||
"""Tests for app.services.chunk_codec -- the Python port of world.md §3.6.
|
||||
|
||||
Pure unit tests: no database, no app, no network. Golden fixtures live in
|
||||
tests/fixtures/chunks/ (see generate_fixtures.py there for how/why).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services import chunk_codec as cc
|
||||
|
||||
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "chunks"
|
||||
|
||||
|
||||
def _read_fixture(name: str) -> bytes:
|
||||
return (FIXTURES_DIR / name).read_bytes()
|
||||
|
||||
|
||||
class TestGoldenFixtures:
|
||||
"""Byte-stability: decode a committed fixture, re-encode it, and the
|
||||
bytes must match exactly (world.md §3.6.3)."""
|
||||
|
||||
@pytest.mark.parametrize("filename", [
|
||||
"empty_chunk.bin", "simple_tile_chunk.bin", "chunk_with_objects.bin",
|
||||
])
|
||||
def test_round_trip_is_byte_identical(self, filename):
|
||||
original = _read_fixture(filename)
|
||||
decoded = cc.decode_binary(original)
|
||||
re_encoded = cc.encode_binary(decoded)
|
||||
assert re_encoded == original
|
||||
|
||||
def test_simple_tile_chunk_logical_values(self):
|
||||
decoded = cc.decode_binary(_read_fixture("simple_tile_chunk.bin"))
|
||||
assert decoded.world_id == "12"
|
||||
assert decoded.room_id == "main"
|
||||
assert decoded.coord == (3, -1, 0)
|
||||
assert decoded.bounds.min == (96.0, -32.0, 0.0)
|
||||
assert decoded.bounds.size == (32.0, 32.0, 1.0)
|
||||
assert len(decoded.tile_layers) == 1
|
||||
layer = decoded.tile_layers[0]
|
||||
assert layer.name == "ground"
|
||||
assert (layer.width, layer.height, layer.depth) == (4, 4, 1)
|
||||
assert len(layer.palette) == 3
|
||||
assert layer.tiles == [0, 1, 1, 0, 1, 2, 2, 1, 1, 2, 2, 1, 0, 1, 1, 0]
|
||||
assert decoded.revision == 2
|
||||
assert decoded.author == "fixture-gen"
|
||||
|
||||
def test_chunk_with_objects_logical_values(self):
|
||||
decoded = cc.decode_binary(_read_fixture("chunk_with_objects.bin"))
|
||||
assert len(decoded.objects) == 2
|
||||
obj1, obj2 = decoded.objects
|
||||
assert obj1.id == "obj_0001"
|
||||
assert obj1.type == "interactable"
|
||||
assert obj1.pos == (101.5, -110.25, 0.5)
|
||||
assert obj1.properties == {"locked": False, "loot_table": "chest_basic"}
|
||||
assert obj2.id == "obj_0002"
|
||||
assert obj2.dynamic is True
|
||||
assert obj2.flags.violence == 1
|
||||
assert obj2.flags.sources == ["asset:props/chest"]
|
||||
assert decoded.flags.violence == 1
|
||||
assert decoded.flags.sources == ["asset:props/chest"]
|
||||
|
||||
def test_empty_chunk_has_no_layers_or_objects(self):
|
||||
decoded = cc.decode_binary(_read_fixture("empty_chunk.bin"))
|
||||
assert decoded.tile_layers == []
|
||||
assert decoded.objects == []
|
||||
|
||||
|
||||
class TestChecksum:
|
||||
def test_checksum_is_sha256_hex(self):
|
||||
import hashlib
|
||||
assert cc.checksum(b"") == hashlib.sha256(b"").hexdigest()
|
||||
assert len(cc.checksum(b"")) == 64
|
||||
|
||||
def test_header_checksum_matches_recomputed_for_valid_fixture(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
header_checksum_hex = data[16:48].hex()
|
||||
body = data[cc.HEADER_SIZE:]
|
||||
assert header_checksum_hex == cc.checksum(body)
|
||||
|
||||
|
||||
class TestBuildKey:
|
||||
def test_matches_world_persistence_impl_4_5_format(self):
|
||||
assert cc.build_key(12, "main", 3, -1, 0) == "12:main:3:-1:0"
|
||||
assert cc.build_key("12", "main", 3, -1, 0) == "12:main:3:-1:0"
|
||||
|
||||
|
||||
class TestFloat32Fidelity:
|
||||
"""This is the concrete regression test for the cross-codec divergence
|
||||
documented at the bottom of chunk_codec.py: this module must encode
|
||||
real IEEE-754 float32, including fractional and negative values,
|
||||
round-tripping exactly (up to float32 precision) -- unlike
|
||||
scripts/chunk_data_decoder.gd's current int-truncating implementation."""
|
||||
|
||||
def test_fractional_and_negative_floats_survive_round_trip(self):
|
||||
payload = cc.ChunkPayload(
|
||||
world_id="1", room_id="main", coord=(0, 0, 0),
|
||||
bounds=cc.ChunkBounds(min=(-17.25, 0.125, -3.0), size=(32.0, 32.0, 1.0)),
|
||||
objects=[cc.WorldObject(
|
||||
id="o1", type="prop", asset_id="a",
|
||||
pos=(1.5, -2.75, 0.001), rot=(0.0, 0.7071068, 0.0, 0.7071068),
|
||||
scale=(1.0, 1.0, 1.0),
|
||||
)],
|
||||
)
|
||||
encoded = cc.encode_binary(payload)
|
||||
decoded = cc.decode_binary(encoded)
|
||||
assert decoded.bounds.min == pytest.approx((-17.25, 0.125, -3.0), abs=1e-6)
|
||||
assert decoded.objects[0].pos == pytest.approx((1.5, -2.75, 0.001), abs=1e-6)
|
||||
assert decoded.objects[0].rot == pytest.approx(
|
||||
(0.0, 0.7071068, 0.0, 0.7071068), abs=1e-6
|
||||
)
|
||||
|
||||
|
||||
class TestMalformedInput:
|
||||
def test_bad_magic_raises_codec_error(self):
|
||||
data = bytearray(_read_fixture("empty_chunk.bin"))
|
||||
data[0:4] = b"XXXX"
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(bytes(data))
|
||||
|
||||
def test_too_short_raises_codec_error(self):
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(b"AGPC\x01\x00")
|
||||
|
||||
def test_unsupported_version_raises_codec_error(self):
|
||||
data = bytearray(_read_fixture("empty_chunk.bin"))
|
||||
data[4:6] = (99).to_bytes(2, "little")
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(bytes(data))
|
||||
|
||||
def test_payload_len_mismatch_raises_codec_error(self):
|
||||
data = bytearray(_read_fixture("empty_chunk.bin"))
|
||||
# Corrupt the declared payload_len (offset 12..16).
|
||||
data[12:16] = (99999).to_bytes(4, "little")
|
||||
with pytest.raises(cc.ChunkCodecError):
|
||||
cc.decode_binary(bytes(data))
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""decode_and_validate: the server-side "don't trust the client"
|
||||
boundary (world_persistence_impl.md §5.5)."""
|
||||
|
||||
def _valid_args(self):
|
||||
return {
|
||||
"expected_key": "12:main:3:-1:0",
|
||||
"grid_size": (4, 4, 1),
|
||||
"max_objects": 512,
|
||||
}
|
||||
|
||||
def test_valid_payload_passes(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
payload = cc.decode_and_validate(data, **self._valid_args())
|
||||
assert payload.coord == (3, -1, 0)
|
||||
|
||||
def test_checksum_mismatch_detected(self):
|
||||
data = bytearray(_read_fixture("simple_tile_chunk.bin"))
|
||||
# Corrupt one byte of the header's advisory checksum field
|
||||
# (offset 16..48) only -- magic/version/payload_len and the whole
|
||||
# payload body stay untouched, so this exercises exactly the
|
||||
# "recomputed checksum disagrees with the header" path rather than
|
||||
# also breaking structural decoding.
|
||||
data[16] ^= 0xFF
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(bytes(data), **self._valid_args())
|
||||
assert exc_info.value.reason == "checksum_mismatch"
|
||||
|
||||
def test_chunk_key_mismatch_detected(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
args = self._valid_args()
|
||||
args["expected_key"] = "12:main:99:99:0" # doesn't match the payload's own coord
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(data, **args)
|
||||
assert exc_info.value.reason == "chunk_key_mismatch"
|
||||
|
||||
def test_layer_dims_mismatch_detected(self):
|
||||
data = _read_fixture("simple_tile_chunk.bin")
|
||||
args = self._valid_args()
|
||||
args["grid_size"] = (8, 8, 1) # fixture's layer is 4x4x1
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(data, **args)
|
||||
assert exc_info.value.reason == "layer_dims"
|
||||
|
||||
def test_too_many_objects_detected(self):
|
||||
data = _read_fixture("chunk_with_objects.bin") # has 2 objects
|
||||
args = self._valid_args()
|
||||
args["expected_key"] = "12:main:3:-4:0"
|
||||
args["grid_size"] = (2, 2, 1)
|
||||
args["max_objects"] = 1
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(data, **args)
|
||||
assert exc_info.value.reason == "too_many_objects"
|
||||
|
||||
def test_object_out_of_bounds_detected(self):
|
||||
payload = cc.ChunkPayload(
|
||||
world_id="1", room_id="main", coord=(0, 0, 0),
|
||||
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(32.0, 32.0, 1.0)),
|
||||
objects=[cc.WorldObject(id="o1", type="prop", asset_id="a", pos=(9999.0, 0.0, 0.0))],
|
||||
)
|
||||
data = cc.encode_binary(payload)
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(
|
||||
data, expected_key="1:main:0:0:0", grid_size=(32, 32, 1), max_objects=10
|
||||
)
|
||||
assert exc_info.value.reason == "object_out_of_bounds"
|
||||
|
||||
def test_tile_out_of_palette_detected(self):
|
||||
layer = cc.TileLayer(
|
||||
name="ground", width=2, height=2, depth=1,
|
||||
palette=[cc.TileDef(id=1)], # tile 5 below isn't in this palette
|
||||
tiles=[1, 5, 0, 1],
|
||||
)
|
||||
payload = cc.ChunkPayload(world_id="1", room_id="main", coord=(0, 0, 0), tile_layers=[layer])
|
||||
data = cc.encode_binary(payload)
|
||||
with pytest.raises(cc.ChunkValidationError) as exc_info:
|
||||
cc.decode_and_validate(
|
||||
data, expected_key="1:main:0:0:0", grid_size=(2, 2, 1), max_objects=10
|
||||
)
|
||||
assert exc_info.value.reason == "tile_out_of_palette"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
@@ -0,0 +1,490 @@
|
||||
"""Integration tests for the world content API: rooms + chunks.
|
||||
|
||||
Covers docs/architecture/world_persistence_impl.md §8.1 (public router,
|
||||
app/routers/world_content.py) and §8.3 (internal router,
|
||||
app/routers/internal.py), plus the create_world -> auto "main" room
|
||||
behaviour (§4.1).
|
||||
|
||||
Runs against the real ephemeral Postgres the hermetic conftest.py stands
|
||||
up -- these are full HTTP round trips through TestClient, not mocked.
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
from app.services import chunk_codec as cc
|
||||
from app.services.game_servers import supervisor
|
||||
|
||||
GRID_32 = (32, 32, 1)
|
||||
|
||||
|
||||
def _auth(client: TestClient, name: str) -> tuple[dict, int]:
|
||||
r = client.post("/api/auth/register", json={
|
||||
"username": name, "email": f"{name}@e.com", "password": "secret123",
|
||||
"date_of_birth": "1990-01-01",
|
||||
})
|
||||
assert r.status_code == 201, r.text
|
||||
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
|
||||
me = client.get("/api/users/me", headers=headers)
|
||||
return headers, me.json()["id"]
|
||||
|
||||
|
||||
def _make_world(client: TestClient, headers: dict, visibility: str = "public") -> int:
|
||||
r = client.post("/api/worlds/", headers=headers, json={"name": "W", "visibility": visibility})
|
||||
assert r.status_code == 201, r.text
|
||||
return r.json()["id"]
|
||||
|
||||
|
||||
def _build_chunk(world_id: int, slug: str, cx: int, cy: int, cz: int, *,
|
||||
grid=GRID_32, revision: int = 1, extra_tag: str = "") -> bytes:
|
||||
"""A small but structurally-real chunk payload for `world_id`/`slug`/coord,
|
||||
sized to match the room's default 32x32x1 grid."""
|
||||
w, h, d = grid
|
||||
layer = cc.TileLayer(
|
||||
name="ground", layer_id=0, width=w, height=h, depth=d,
|
||||
palette=[cc.TileDef(id=0, asset_id="", solid=False, flags=0, variants=1)],
|
||||
tiles=[0] * (w * h * d),
|
||||
)
|
||||
obj = cc.WorldObject(
|
||||
id=f"obj_{extra_tag or '1'}", type="prop", asset_id="props/rock",
|
||||
pos=(1.0, 1.0, 0.0), properties={"tag": extra_tag},
|
||||
)
|
||||
payload = cc.ChunkPayload(
|
||||
world_id=str(world_id), room_id=slug, coord=(cx, cy, cz),
|
||||
bounds=cc.ChunkBounds(min=(0.0, 0.0, 0.0), size=(float(w), float(h), float(d))),
|
||||
tile_layers=[layer], objects=[obj], revision=revision, author="pytest",
|
||||
created_at=1_700_000_000, updated_at=1_700_000_000,
|
||||
)
|
||||
return cc.encode_binary(payload)
|
||||
|
||||
|
||||
class TestCreateWorldMainRoom:
|
||||
def test_create_world_auto_creates_main_room(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "cw_main")
|
||||
wid = _make_world(client, headers)
|
||||
|
||||
r = client.get(f"/api/worlds/{wid}/rooms", headers=headers)
|
||||
assert r.status_code == 200, r.text
|
||||
rooms = r.json()
|
||||
assert len(rooms) == 1
|
||||
assert rooms[0]["slug"] == "main"
|
||||
assert rooms[0]["name"] == "Main"
|
||||
assert rooms[0]["chunk_count"] == 0
|
||||
assert rooms[0]["grid"]["size"] == [32, 32, 1]
|
||||
|
||||
|
||||
class TestRoomAuth:
|
||||
def test_create_room_requires_owner(self):
|
||||
client = TestClient(app)
|
||||
owner_headers, _ = _auth(client, "room_owner")
|
||||
wid = _make_world(client, owner_headers)
|
||||
other_headers, _ = _auth(client, "room_other")
|
||||
r = client.post(f"/api/worlds/{wid}/rooms", headers=other_headers,
|
||||
json={"slug": "annex", "name": "Annex"})
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_list_rooms_of_private_world_is_404_for_non_owner(self):
|
||||
client = TestClient(app)
|
||||
owner_headers, _ = _auth(client, "room_priv_owner")
|
||||
wid = _make_world(client, owner_headers, "private")
|
||||
other_headers, _ = _auth(client, "room_priv_other")
|
||||
r = client.get(f"/api/worlds/{wid}/rooms", headers=other_headers)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
class TestChunkPutGetRoundTrip:
|
||||
def test_binary_put_then_binary_get_round_trips(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_rt")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0)
|
||||
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["revision"] == 1
|
||||
assert body["payload_len"] == len(payload)
|
||||
assert body["checksum"] == cc.checksum(payload[cc.HEADER_SIZE:])
|
||||
assert body["live_session"] is False
|
||||
|
||||
g = client.get(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Accept": "application/octet-stream"},
|
||||
)
|
||||
assert g.status_code == 200
|
||||
assert g.content == payload
|
||||
assert g.headers["ETag"] == f'"{body["checksum"]}"'
|
||||
assert g.headers["X-Chunk-Revision"] == "1"
|
||||
|
||||
def test_json_accept_returns_decoded_shape(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_json")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 1, 1, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
g = client.get(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/1/1/0",
|
||||
headers={**headers, "Accept": "application/json"},
|
||||
)
|
||||
assert g.status_code == 200
|
||||
data = g.json()
|
||||
assert data["coord"] == {"cx": 1, "cy": 1, "cz": 0}
|
||||
assert data["world_id"] == str(wid)
|
||||
assert data["room_id"] == "main"
|
||||
assert len(data["objects"]) == 1
|
||||
|
||||
def test_json_put_with_base64_payload(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_json_put")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 2, 2, 0)
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/2/2/0", headers=headers,
|
||||
json={"base_revision": 0, "payload_b64": base64.b64encode(payload).decode()},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["revision"] == 1
|
||||
|
||||
def test_if_none_match_returns_304(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_304")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 3, 3, 0)
|
||||
put = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
checksum = put.json()["checksum"]
|
||||
g = client.get(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/3/3/0",
|
||||
headers={**headers, "If-None-Match": f'"{checksum}"'},
|
||||
)
|
||||
assert g.status_code == 304
|
||||
|
||||
def test_unwritten_coordinate_is_404(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_404")
|
||||
wid = _make_world(client, headers)
|
||||
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/9/9/0", headers=headers)
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
class TestRevisionConflict:
|
||||
def test_create_conflict_when_chunk_already_exists(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "conflict_create")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 4, 4, 0)
|
||||
first = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert first.status_code == 200
|
||||
|
||||
second = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/4/4/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert second.status_code == 409
|
||||
body = second.json()
|
||||
assert body["detail"] == "revision conflict"
|
||||
assert body["current_revision"] == 1
|
||||
|
||||
def test_stale_base_revision_conflict_reports_current_state(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "conflict_stale")
|
||||
wid = _make_world(client, headers)
|
||||
payload_v1 = _build_chunk(wid, "main", 5, 5, 0, revision=1)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload_v1,
|
||||
)
|
||||
payload_v2 = _build_chunk(wid, "main", 5, 5, 0, revision=2, extra_tag="v2")
|
||||
r2 = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
|
||||
content=payload_v2,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
assert r2.json()["revision"] == 2
|
||||
|
||||
# Now retry with the now-stale base_revision=1 again.
|
||||
payload_v3 = _build_chunk(wid, "main", 5, 5, 0, revision=3, extra_tag="v3")
|
||||
stale = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/5/5/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
|
||||
content=payload_v3,
|
||||
)
|
||||
assert stale.status_code == 409
|
||||
body = stale.json()
|
||||
assert body["current_revision"] == 2
|
||||
assert body["current_checksum"] == r2.json()["checksum"]
|
||||
|
||||
|
||||
class TestBatchWrite:
|
||||
def test_batch_put_all_succeed(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "batch_ok")
|
||||
wid = _make_world(client, headers)
|
||||
chunks = [
|
||||
{"cx": cx, "cy": 0, "cz": 0, "base_revision": 0,
|
||||
"payload_b64": base64.b64encode(_build_chunk(wid, "main", cx, 0, 0)).decode()}
|
||||
for cx in (10, 11)
|
||||
]
|
||||
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert len(body["chunks"]) == 2
|
||||
assert all(c["revision"] == 1 for c in body["chunks"])
|
||||
|
||||
idx = client.get(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers)
|
||||
keys = {c["key"] for c in idx.json()["chunks"]}
|
||||
assert f"{wid}:main:10:0:0" in keys
|
||||
assert f"{wid}:main:11:0:0" in keys
|
||||
|
||||
def test_batch_put_is_all_or_nothing_on_conflict(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "batch_conflict")
|
||||
wid = _make_world(client, headers)
|
||||
|
||||
# Pre-create one of the two coordinates so the batch's base_revision=0
|
||||
# for it will conflict.
|
||||
pre = _build_chunk(wid, "main", 20, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/20/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=pre,
|
||||
)
|
||||
|
||||
chunks = [
|
||||
{"cx": 21, "cy": 0, "cz": 0, "base_revision": 0,
|
||||
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 21, 0, 0)).decode()},
|
||||
{"cx": 20, "cy": 0, "cz": 0, "base_revision": 0, # conflicts: already exists at revision 1
|
||||
"payload_b64": base64.b64encode(_build_chunk(wid, "main", 20, 0, 0, extra_tag="v2")).decode()},
|
||||
]
|
||||
r = client.put(f"/api/worlds/{wid}/rooms/main/chunks", headers=headers, json={"chunks": chunks})
|
||||
assert r.status_code == 409
|
||||
|
||||
# Nothing from this batch should have been persisted -- (21,0,0)
|
||||
# must NOT exist even though it was valid and came first.
|
||||
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/21/0/0", headers=headers)
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
class TestDelete:
|
||||
def test_delete_then_get_is_404(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_delete")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 30, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/30/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
d = client.delete(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
|
||||
assert d.status_code == 204
|
||||
g = client.get(f"/api/worlds/{wid}/rooms/main/chunks/30/0/0", headers=headers)
|
||||
assert g.status_code == 404
|
||||
|
||||
|
||||
class TestRevisionsAndRollback:
|
||||
def test_revisions_then_rollback(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_rollback")
|
||||
wid = _make_world(client, headers)
|
||||
v1 = _build_chunk(wid, "main", 40, 0, 0, revision=1)
|
||||
r1 = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=v1,
|
||||
)
|
||||
v2 = _build_chunk(wid, "main", 40, 0, 0, revision=2, extra_tag="v2")
|
||||
r2 = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "1"},
|
||||
content=v2,
|
||||
)
|
||||
assert r2.status_code == 200
|
||||
|
||||
revs = client.get(f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/revisions", headers=headers)
|
||||
assert revs.status_code == 200
|
||||
rev_numbers = [row["revision"] for row in revs.json()]
|
||||
assert 1 in rev_numbers and 2 in rev_numbers
|
||||
|
||||
rb = client.post(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/40/0/0/rollback",
|
||||
headers=headers, json={"revision": 1},
|
||||
)
|
||||
assert rb.status_code == 200, rb.text
|
||||
assert rb.json()["revision"] == 3 # a new revision, restoring v1's content
|
||||
assert rb.json()["checksum"] == r1.json()["checksum"]
|
||||
|
||||
def test_revisions_requires_owner(self):
|
||||
client = TestClient(app)
|
||||
owner_headers, _ = _auth(client, "revisions_owner")
|
||||
wid = _make_world(client, owner_headers)
|
||||
payload = _build_chunk(wid, "main", 41, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/41/0/0",
|
||||
headers={**owner_headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
other_headers, _ = _auth(client, "revisions_other")
|
||||
r = client.get(f"/api/worlds/{wid}/rooms/main/chunks/41/0/0/revisions", headers=other_headers)
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
class TestValidationErrors:
|
||||
def test_malformed_payload_is_422(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_malformed")
|
||||
wid = _make_world(client, headers)
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=b"not a real chunk",
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["reason"] == "malformed"
|
||||
|
||||
def test_wrong_grid_size_is_422_layer_dims(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_layerdims")
|
||||
wid = _make_world(client, headers)
|
||||
# Room's default grid is 32x32x1; build a chunk sized for 4x4x1.
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0, grid=(4, 4, 1))
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert r.status_code == 422
|
||||
assert r.json()["reason"] == "layer_dims"
|
||||
|
||||
def test_oversized_payload_is_413(self, monkeypatch):
|
||||
from app.config import settings
|
||||
monkeypatch.setattr(settings, "WORLD_CHUNK_MAX_PAYLOAD_BYTES", 10)
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "chunk_413")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0)
|
||||
r = client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
assert r.status_code == 413
|
||||
|
||||
|
||||
class TestInternalApi:
|
||||
"""docs/architecture/world_persistence_impl.md §7/§8.3. No real Godot
|
||||
process is spawned in this test env (GAME_SERVERS_ENABLED=False); we
|
||||
register a fake in-memory server entry with a known token to exercise
|
||||
require_game_server without needing a live process."""
|
||||
|
||||
@pytest.fixture
|
||||
def fake_server_token(self):
|
||||
# NB: a class body can't read a same-named local from the
|
||||
# enclosing function via `token = token` (class bodies use LOAD_NAME,
|
||||
# which skips enclosing function scopes) -- use a literal instead.
|
||||
world_id_str = "internal-test-world"
|
||||
|
||||
class _FakeSrv:
|
||||
def alive(self_inner):
|
||||
return True
|
||||
token = "test-internal-token-12345"
|
||||
|
||||
supervisor._servers[world_id_str] = _FakeSrv()
|
||||
yield _FakeSrv.token
|
||||
supervisor._servers.pop(world_id_str, None)
|
||||
|
||||
def test_missing_token_is_401(self):
|
||||
client = TestClient(app)
|
||||
r = client.get("/api/internal/worlds/1/manifest")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_wrong_token_is_403(self):
|
||||
client = TestClient(app)
|
||||
r = client.get(
|
||||
"/api/internal/worlds/1/manifest",
|
||||
headers={"Authorization": "Bearer not-the-real-token"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_valid_token_but_wrong_world_id_is_403(self, fake_server_token):
|
||||
# The fake token is registered under world_id "internal-test-world";
|
||||
# a request for a different world_id must not authenticate.
|
||||
client = TestClient(app)
|
||||
r = client.get(
|
||||
"/api/internal/worlds/999999/manifest",
|
||||
headers={"Authorization": f"Bearer {fake_server_token}"},
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_valid_token_reaches_manifest_for_real_world(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "internal_manifest")
|
||||
wid = _make_world(client, headers)
|
||||
|
||||
class _FakeSrv:
|
||||
def alive(self_inner):
|
||||
return True
|
||||
token = "another-real-token"
|
||||
|
||||
supervisor._servers[str(wid)] = _FakeSrv()
|
||||
try:
|
||||
r = client.get(
|
||||
f"/api/internal/worlds/{wid}/manifest",
|
||||
headers={"Authorization": "Bearer another-real-token"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["world_id"] == wid
|
||||
assert len(body["rooms"]) == 1
|
||||
assert body["rooms"][0]["slug"] == "main"
|
||||
finally:
|
||||
supervisor._servers.pop(str(wid), None)
|
||||
|
||||
def test_internal_chunk_fetch_returns_binary(self):
|
||||
client = TestClient(app)
|
||||
headers, _ = _auth(client, "internal_chunk")
|
||||
wid = _make_world(client, headers)
|
||||
payload = _build_chunk(wid, "main", 0, 0, 0)
|
||||
client.put(
|
||||
f"/api/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={**headers, "Content-Type": "application/octet-stream", "X-Base-Revision": "0"},
|
||||
content=payload,
|
||||
)
|
||||
|
||||
class _FakeSrv:
|
||||
def alive(self_inner):
|
||||
return True
|
||||
token = "internal-chunk-token"
|
||||
|
||||
supervisor._servers[str(wid)] = _FakeSrv()
|
||||
try:
|
||||
r = client.get(
|
||||
f"/api/internal/worlds/{wid}/rooms/main/chunks/0/0/0",
|
||||
headers={"Authorization": "Bearer internal-chunk-token"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.content == payload
|
||||
finally:
|
||||
supervisor._servers.pop(str(wid), None)
|
||||
Reference in New Issue
Block a user