489 lines
17 KiB
Python
489 lines
17 KiB
Python
from __future__ import annotations
|
|
|
|
import hashlib
|
|
from http.client import HTTPConnection
|
|
import json
|
|
import socketserver
|
|
import threading
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
from straywild_discovery.registry import RoomRegistry
|
|
from straywild_discovery.server import (
|
|
DiscoveryHTTPServer,
|
|
ServerConfig,
|
|
TraversalUDPServer,
|
|
)
|
|
|
|
|
|
VALID_ROOM = {
|
|
"room_name": "Pond Friends",
|
|
"port": 7777,
|
|
"current_players": 1,
|
|
"max_players": 8,
|
|
"game_version": "0.20.3-alpha",
|
|
"protocol_version": 12,
|
|
}
|
|
|
|
|
|
class DiscoveryHTTPTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
config = ServerConfig(
|
|
bind_host="127.0.0.1",
|
|
bind_port=0,
|
|
room_ttl_seconds=30.0,
|
|
build_revision="test-build",
|
|
)
|
|
cls.server = DiscoveryHTTPServer(config, RoomRegistry(ttl_seconds=30.0))
|
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
cls.thread.start()
|
|
cls.port = cls.server.server_address[1]
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.server.shutdown()
|
|
cls.server.server_close()
|
|
cls.thread.join(timeout=2.0)
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: dict[str, object] | None = None,
|
|
token: str | None = None,
|
|
) -> tuple[int, dict[str, object] | None]:
|
|
connection = HTTPConnection("127.0.0.1", self.port, timeout=2.0)
|
|
headers: dict[str, str] = {}
|
|
body = None
|
|
if payload is not None:
|
|
body = json.dumps(payload)
|
|
headers["Content-Type"] = "application/json"
|
|
if token is not None:
|
|
headers["Authorization"] = f"Bearer {token}"
|
|
connection.request(method, path, body=body, headers=headers)
|
|
response = connection.getresponse()
|
|
response_body = response.read()
|
|
connection.close()
|
|
decoded = json.loads(response_body) if response_body else None
|
|
return response.status, decoded
|
|
|
|
def test_room_lease_lifecycle(self) -> None:
|
|
status, created = self.request("POST", "/v1/rooms", VALID_ROOM)
|
|
self.assertEqual(status, 201)
|
|
assert created is not None
|
|
room = created["room"]
|
|
assert isinstance(room, dict)
|
|
room_id = room["room_id"]
|
|
token = created["lease_token"]
|
|
traversal = created["traversal"]
|
|
assert isinstance(traversal, dict)
|
|
self.server.registry.verify_endpoint(
|
|
str(room_id),
|
|
str(traversal["verification_token"]),
|
|
"127.0.0.1",
|
|
7777,
|
|
)
|
|
self.assertIsInstance(room_id, str)
|
|
self.assertIsInstance(token, str)
|
|
|
|
status, listing = self.request("GET", "/v1/rooms?protocol_version=12")
|
|
self.assertEqual(status, 200)
|
|
assert listing is not None
|
|
listed_rooms = listing["rooms"]
|
|
self.assertTrue(any(candidate["room_id"] == room_id for candidate in listed_rooms))
|
|
listed_room = next(
|
|
candidate for candidate in listed_rooms if candidate["room_id"] == room_id
|
|
)
|
|
self.assertNotIn("address", listed_room)
|
|
self.assertNotIn("port", listed_room)
|
|
self.assertEqual(listed_room["connection_mode"], "direct")
|
|
|
|
status, updated = self.request(
|
|
"PUT",
|
|
f"/v1/rooms/{room_id}",
|
|
dict(VALID_ROOM, game_version="0.6.8-alpha"),
|
|
str(token),
|
|
)
|
|
self.assertEqual(status, 409)
|
|
assert updated is not None
|
|
self.assertEqual(updated["error"]["code"], "game_version_mismatch")
|
|
self.assertEqual(
|
|
updated["error"]["required_game_version"], "0.20.3-alpha"
|
|
)
|
|
|
|
status, updated = self.request(
|
|
"PUT",
|
|
f"/v1/rooms/{room_id}",
|
|
dict(VALID_ROOM, current_players=2),
|
|
str(token),
|
|
)
|
|
self.assertEqual(status, 200)
|
|
assert updated is not None
|
|
self.assertEqual(updated["room"]["current_players"], 2)
|
|
|
|
status, _body = self.request("DELETE", f"/v1/rooms/{room_id}", token=str(token))
|
|
self.assertEqual(status, 204)
|
|
|
|
def test_invalid_payload_returns_structured_error(self) -> None:
|
|
status, body = self.request("POST", "/v1/rooms", dict(VALID_ROOM, port=70_000))
|
|
self.assertEqual(status, 400)
|
|
assert body is not None
|
|
self.assertEqual(body["error"]["code"], "invalid_room")
|
|
|
|
def test_outdated_game_cannot_list_a_room(self) -> None:
|
|
status, body = self.request(
|
|
"POST",
|
|
"/v1/rooms",
|
|
dict(VALID_ROOM, game_version="0.6.8-alpha"),
|
|
)
|
|
self.assertEqual(status, 409)
|
|
assert body is not None
|
|
error = body["error"]
|
|
assert isinstance(error, dict)
|
|
self.assertEqual(error["code"], "game_version_mismatch")
|
|
self.assertEqual(error["required_game_version"], "0.20.3-alpha")
|
|
self.assertIn("will not be listed", str(error["message"]))
|
|
|
|
def test_empty_dedicated_room_can_be_listed(self) -> None:
|
|
status, created = self.request(
|
|
"POST", "/v1/rooms", dict(VALID_ROOM, current_players=0)
|
|
)
|
|
self.assertEqual(status, 201)
|
|
assert created is not None
|
|
room = created["room"]
|
|
assert isinstance(room, dict)
|
|
traversal = created["traversal"]
|
|
assert isinstance(traversal, dict)
|
|
self.server.registry.verify_endpoint(
|
|
str(room["room_id"]),
|
|
str(traversal["verification_token"]),
|
|
"127.0.0.1",
|
|
7777,
|
|
)
|
|
self.assertEqual(room["current_players"], 0)
|
|
self.request(
|
|
"DELETE",
|
|
f"/v1/rooms/{room['room_id']}",
|
|
token=str(created["lease_token"]),
|
|
)
|
|
|
|
def test_join_attempt_reports_observed_endpoint_to_host(self) -> None:
|
|
status, created = self.request("POST", "/v1/rooms", VALID_ROOM)
|
|
self.assertEqual(status, 201)
|
|
assert created is not None
|
|
room = created["room"]
|
|
traversal = created["traversal"]
|
|
assert isinstance(room, dict)
|
|
assert isinstance(traversal, dict)
|
|
room_id = str(room["room_id"])
|
|
self.server.registry.verify_endpoint(
|
|
room_id,
|
|
str(traversal["verification_token"]),
|
|
"127.0.0.1",
|
|
7777,
|
|
)
|
|
status, join = self.request(
|
|
"POST", f"/v1/rooms/{room_id}/join-attempts"
|
|
)
|
|
self.assertEqual(status, 201)
|
|
assert join is not None
|
|
self.assertEqual(
|
|
join["route"],
|
|
{
|
|
"transport": "direct",
|
|
"address": "127.0.0.1",
|
|
"port": 7777,
|
|
"ip_privacy": "peer_visible",
|
|
},
|
|
)
|
|
self.server.registry.register_join_endpoint(
|
|
str(join["join_token"]), "198.51.100.7", 49152
|
|
)
|
|
status, pending = self.request(
|
|
"GET",
|
|
f"/v1/rooms/{room_id}/join-attempts",
|
|
token=str(created["lease_token"]),
|
|
)
|
|
self.assertEqual(status, 200)
|
|
assert pending is not None
|
|
self.assertEqual(
|
|
pending["endpoints"],
|
|
[{"address": "198.51.100.7", "port": 49152}],
|
|
)
|
|
|
|
def test_health_endpoint(self) -> None:
|
|
status, body = self.request("GET", "/health")
|
|
self.assertEqual(status, 200)
|
|
assert body is not None
|
|
self.assertEqual(body["status"], "ok")
|
|
self.assertEqual(body["version"], "0.20.3-alpha")
|
|
self.assertEqual(body["build_revision"], "test-build")
|
|
|
|
def test_live_friend_presence_and_invitation_endpoints(self) -> None:
|
|
status, created = self.request("POST", "/v1/rooms", VALID_ROOM)
|
|
self.assertEqual(status, 201)
|
|
assert created is not None
|
|
room = created["room"]
|
|
traversal = created["traversal"]
|
|
assert isinstance(room, dict)
|
|
assert isinstance(traversal, dict)
|
|
room_id = str(room["room_id"])
|
|
self.server.registry.verify_endpoint(
|
|
room_id,
|
|
str(traversal["verification_token"]),
|
|
"127.0.0.1",
|
|
7777,
|
|
)
|
|
|
|
inbox_token = "b" * 64
|
|
social_version = {
|
|
"game_version": VALID_ROOM["game_version"],
|
|
"protocol_version": VALID_ROOM["protocol_version"],
|
|
}
|
|
status, polled = self.request(
|
|
"POST",
|
|
"/v1/invitations/poll",
|
|
dict(social_version, inbox_tokens=[inbox_token]),
|
|
)
|
|
self.assertEqual(status, 200)
|
|
assert polled is not None
|
|
self.assertEqual(polled["invitations"], [])
|
|
|
|
status, _sent = self.request(
|
|
"POST",
|
|
"/v1/invitations",
|
|
dict(social_version, inbox_token=inbox_token, room_id=room_id),
|
|
)
|
|
self.assertEqual(status, 201)
|
|
status, polled = self.request(
|
|
"POST",
|
|
"/v1/invitations/poll",
|
|
dict(social_version, inbox_tokens=[inbox_token]),
|
|
)
|
|
self.assertEqual(status, 200)
|
|
assert polled is not None
|
|
self.assertEqual(polled["invitations"][0]["room"]["room_id"], room_id)
|
|
|
|
status, offline = self.request(
|
|
"POST",
|
|
"/v1/invitations",
|
|
dict(social_version, inbox_token="c" * 64, room_id=room_id),
|
|
)
|
|
self.assertEqual(status, 409)
|
|
assert offline is not None
|
|
self.assertEqual(offline["error"]["code"], "friend_offline")
|
|
self.assertEqual(
|
|
offline["error"]["message"],
|
|
"This person needs to be online to do this.",
|
|
)
|
|
|
|
write_token = "d" * 64
|
|
channel = hashlib.sha256(
|
|
f"straywild_PRESENCE_V1:{write_token}".encode()
|
|
).hexdigest()
|
|
status, _published = self.request(
|
|
"POST",
|
|
"/v1/presence",
|
|
dict(
|
|
social_version,
|
|
write_tokens=[write_token],
|
|
online=True,
|
|
display_name="Voyager",
|
|
room_id=room_id,
|
|
),
|
|
)
|
|
self.assertEqual(status, 200)
|
|
status, presence = self.request(
|
|
"POST",
|
|
"/v1/presence/query",
|
|
dict(social_version, channels=[channel]),
|
|
)
|
|
self.assertEqual(status, 200)
|
|
assert presence is not None
|
|
self.assertEqual(presence["presence"][0]["room"]["room_id"], room_id)
|
|
|
|
self.request(
|
|
"DELETE",
|
|
f"/v1/rooms/{room_id}",
|
|
token=str(created["lease_token"]),
|
|
)
|
|
|
|
def test_udp_rendezvous_does_not_spawn_per_packet_threads(self) -> None:
|
|
self.assertFalse(issubclass(TraversalUDPServer, socketserver.ThreadingMixIn))
|
|
|
|
def test_http_access_logging_is_completely_disabled(self) -> None:
|
|
with patch("straywild_discovery.server.LOGGER.info") as log_info:
|
|
status, _health = self.request("GET", "/health")
|
|
self.assertEqual(status, 200)
|
|
log_info.assert_not_called()
|
|
|
|
def test_unexpected_http_error_log_contains_no_private_metadata(self) -> None:
|
|
private_address = "198.51.100.77"
|
|
with patch("straywild_discovery.server.LOGGER.error") as log_error:
|
|
self.server.handle_error(object(), (private_address, 49152))
|
|
log_error.assert_called_once_with(
|
|
"discovery HTTP request failed without logging request metadata"
|
|
)
|
|
self.assertNotIn(private_address, str(log_error.call_args))
|
|
|
|
def test_unexpected_udp_error_log_contains_no_private_metadata(self) -> None:
|
|
private_address = "198.51.100.78"
|
|
traversal = TraversalUDPServer(
|
|
ServerConfig(
|
|
traversal_bind_host="127.0.0.1",
|
|
traversal_port=0,
|
|
),
|
|
self.server.registry,
|
|
)
|
|
try:
|
|
with patch("straywild_discovery.server.LOGGER.error") as log_error:
|
|
traversal.handle_error(object(), (private_address, 49153))
|
|
log_error.assert_called_once_with(
|
|
"discovery traversal request failed without logging metadata"
|
|
)
|
|
self.assertNotIn(private_address, str(log_error.call_args))
|
|
finally:
|
|
traversal.server_close()
|
|
|
|
|
|
class RelayDiscoveryHTTPTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls) -> None:
|
|
config = ServerConfig(
|
|
bind_host="127.0.0.1",
|
|
bind_port=0,
|
|
room_ttl_seconds=30.0,
|
|
relay_enabled=True,
|
|
relay_bind_host="127.0.0.1",
|
|
relay_public_host="127.0.0.1",
|
|
relay_port_start=0,
|
|
relay_port_end=0,
|
|
relay_max_allocations=32,
|
|
build_revision="relay-test-build",
|
|
)
|
|
cls.server = DiscoveryHTTPServer(config)
|
|
cls.thread = threading.Thread(target=cls.server.serve_forever, daemon=True)
|
|
cls.thread.start()
|
|
cls.port = cls.server.server_address[1]
|
|
|
|
@classmethod
|
|
def tearDownClass(cls) -> None:
|
|
cls.server.shutdown()
|
|
cls.server.server_close()
|
|
cls.thread.join(timeout=2.0)
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
path: str,
|
|
payload: dict[str, object] | None = None,
|
|
) -> tuple[int, dict[str, object] | None]:
|
|
connection = HTTPConnection("127.0.0.1", self.port, timeout=2.0)
|
|
headers: dict[str, str] = {}
|
|
body = None
|
|
if payload is not None:
|
|
body = json.dumps(payload)
|
|
headers["Content-Type"] = "application/json"
|
|
connection.request(method, path, body=body, headers=headers)
|
|
response = connection.getresponse()
|
|
response_body = response.read()
|
|
connection.close()
|
|
decoded = json.loads(response_body) if response_body else None
|
|
return response.status, decoded
|
|
|
|
def test_relay_join_never_returns_host_endpoint(self) -> None:
|
|
status, created = self.request(
|
|
"POST",
|
|
"/v1/rooms",
|
|
dict(VALID_ROOM, connection_mode="relay"),
|
|
)
|
|
self.assertEqual(status, 201)
|
|
assert created is not None
|
|
room = created["room"]
|
|
traversal = created["traversal"]
|
|
assert isinstance(room, dict)
|
|
assert isinstance(traversal, dict)
|
|
room_id = str(room["room_id"])
|
|
self.server.registry.verify_endpoint(
|
|
room_id,
|
|
str(traversal["verification_token"]),
|
|
"198.51.100.42",
|
|
45678,
|
|
)
|
|
|
|
status, listing = self.request("GET", "/v1/rooms?protocol_version=12")
|
|
self.assertEqual(status, 200)
|
|
assert listing is not None
|
|
listed_room = next(
|
|
candidate
|
|
for candidate in listing["rooms"]
|
|
if candidate["room_id"] == room_id
|
|
)
|
|
self.assertEqual(listed_room["connection_mode"], "relay")
|
|
self.assertEqual(listed_room["ip_privacy"], "relayed")
|
|
self.assertNotIn("address", listed_room)
|
|
self.assertNotIn("port", listed_room)
|
|
|
|
status, join = self.request(
|
|
"POST", f"/v1/rooms/{room_id}/join-attempts"
|
|
)
|
|
self.assertEqual(status, 201)
|
|
assert join is not None
|
|
route = join["route"]
|
|
assert isinstance(route, dict)
|
|
self.assertEqual(route["transport"], "relay")
|
|
self.assertEqual(route["ip_privacy"], "relayed")
|
|
self.assertEqual(route["address"], "127.0.0.1")
|
|
self.assertNotEqual(route["address"], "198.51.100.42")
|
|
self.assertNotEqual(route["port"], 45678)
|
|
|
|
def test_direct_room_does_not_consume_relay_allocation(self) -> None:
|
|
status, created = self.request(
|
|
"POST",
|
|
"/v1/rooms",
|
|
dict(VALID_ROOM, room_name="Direct Pond", connection_mode="direct"),
|
|
)
|
|
self.assertEqual(status, 201)
|
|
assert created is not None
|
|
room = created["room"]
|
|
traversal = created["traversal"]
|
|
assert isinstance(room, dict)
|
|
assert isinstance(traversal, dict)
|
|
room_id = str(room["room_id"])
|
|
self.server.registry.verify_endpoint(
|
|
room_id,
|
|
str(traversal["verification_token"]),
|
|
"198.51.100.43",
|
|
45679,
|
|
)
|
|
allocations_before = self.server.relay_service.allocation_count()
|
|
status, join = self.request(
|
|
"POST", f"/v1/rooms/{room_id}/join-attempts"
|
|
)
|
|
self.assertEqual(status, 201)
|
|
assert join is not None
|
|
self.assertEqual(join["route"]["transport"], "direct")
|
|
self.assertEqual(join["route"]["address"], "198.51.100.43")
|
|
self.assertEqual(
|
|
self.server.relay_service.allocation_count(),
|
|
allocations_before,
|
|
)
|
|
|
|
def test_dedicated_relay_advertisement_is_rejected(self) -> None:
|
|
status, response = self.request(
|
|
"POST",
|
|
"/v1/rooms",
|
|
dict(
|
|
VALID_ROOM,
|
|
host_kind="dedicated",
|
|
connection_mode="relay",
|
|
),
|
|
)
|
|
self.assertEqual(status, 400)
|
|
assert response is not None
|
|
self.assertEqual(response["error"]["code"], "invalid_room")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|