feat: add ephemeral friend presence and invites
Add short-lived capability channels for online friend status and public-room invitations without storing accounts, fingerprints, social graphs, or offline messages.
This commit is contained in:
parent
029e247649
commit
21d17c4211
7 changed files with 626 additions and 3 deletions
|
|
@ -8,6 +8,10 @@ The service also runs a small UDP rendezvous. It observes packets sent from the
|
|||
used by hosts and joiners, then lets an authenticated room host retrieve pending endpoints and send
|
||||
hole-punch packets. Gameplay remains direct and never passes through this service.
|
||||
|
||||
Friend presence and live room invitations use the same service as short-lived capability channels.
|
||||
There are no discovery accounts, durable friend records, or offline messages; friendship state
|
||||
stays on each player's device.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.11 or newer
|
||||
|
|
|
|||
23
docs/API.md
23
docs/API.md
|
|
@ -77,6 +77,25 @@ UDP rendezvous from the same ENet socket used for the gameplay connection.
|
|||
Requires the room lease bearer token. It consumes observed joining endpoints so the host can send
|
||||
same-socket UDP punch packets before ENet retries its connection.
|
||||
|
||||
## Live friend presence and invitations
|
||||
|
||||
These endpoints are an ephemeral capability channel, not an account or social-graph service. The
|
||||
server does not receive player identity fingerprints or a complete friend list, writes nothing to
|
||||
a database, and discards presence and invitations after short timeouts.
|
||||
|
||||
`POST /v1/presence` publishes or removes the caller's status under one or more secret write
|
||||
capabilities. `POST /v1/presence/query` reads the corresponding hashed channels. A published room
|
||||
is returned only while it remains a verified, version-compatible public room.
|
||||
|
||||
`POST /v1/invitations/poll` marks the supplied capability inboxes as currently reachable and
|
||||
consumes any live invitations. `POST /v1/invitations` sends a public-room invitation to one inbox.
|
||||
The send fails with `409 friend_offline` and `This person needs to be online to do this.` unless
|
||||
that inbox was polled recently. There is no offline delivery.
|
||||
|
||||
All four requests include the normal `game_version` and `protocol_version`. Capability values are
|
||||
64 lowercase hexadecimal characters and must be exchanged by the games during an authenticated,
|
||||
live NETfishing session.
|
||||
|
||||
## Error shape
|
||||
|
||||
```json
|
||||
|
|
@ -94,4 +113,6 @@ same-socket UDP punch packets before ENet retries its connection.
|
|||
- `X-Forwarded-For` is honored only when the immediate peer belongs to an explicitly configured
|
||||
trusted proxy CIDR.
|
||||
- Lease tokens authorize update and deletion but are never included in public listings.
|
||||
- The service is an ephemeral directory, not a source of gameplay authority.
|
||||
- Friend capability channels are short-lived and reveal neither identity fingerprints nor a full
|
||||
social graph to the service.
|
||||
- The service is an ephemeral directory, not a source of gameplay or friendship authority.
|
||||
|
|
|
|||
|
|
@ -224,6 +224,26 @@ class RoomRegistry:
|
|||
self._purge_locked(now)
|
||||
return len(self._rooms)
|
||||
|
||||
def get_public_room(
|
||||
self,
|
||||
room_id: str,
|
||||
*,
|
||||
game_version: str | None = None,
|
||||
protocol_version: int | None = None,
|
||||
) -> RoomAdvertisement | None:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
self._purge_locked(now)
|
||||
lease = self._rooms.get(room_id)
|
||||
room = lease.advertisement if lease is not None else None
|
||||
if room is None or not room.verified:
|
||||
return None
|
||||
if game_version is not None and room.game_version != game_version:
|
||||
return None
|
||||
if protocol_version is not None and room.protocol_version != protocol_version:
|
||||
return None
|
||||
return room
|
||||
|
||||
def verify_endpoint(
|
||||
self,
|
||||
room_id: str,
|
||||
|
|
|
|||
|
|
@ -27,10 +27,11 @@ from .registry import (
|
|||
RoomRegistry,
|
||||
ValidationError,
|
||||
)
|
||||
from .social_registry import FriendOfflineError, SocialRegistry
|
||||
|
||||
|
||||
LOGGER = logging.getLogger("netfishing.discovery")
|
||||
DEFAULT_MAX_BODY_BYTES = 8 * 1024
|
||||
DEFAULT_MAX_BODY_BYTES = 64 * 1024
|
||||
TRAVERSAL_PACKET_PREFIX = b"NETFISHING_TRAVERSAL_V1 "
|
||||
MAX_TRAVERSAL_PACKET_BYTES = 2048
|
||||
|
||||
|
|
@ -94,13 +95,19 @@ class DiscoveryHTTPServer(ThreadingHTTPServer):
|
|||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, config: ServerConfig, registry: RoomRegistry | None = None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
config: ServerConfig,
|
||||
registry: RoomRegistry | None = None,
|
||||
social_registry: SocialRegistry | None = None,
|
||||
) -> None:
|
||||
self.config = config
|
||||
self.registry = registry or RoomRegistry(
|
||||
config.room_ttl_seconds,
|
||||
config.max_rooms,
|
||||
config.max_rooms_per_address,
|
||||
)
|
||||
self.social_registry = social_registry or SocialRegistry()
|
||||
super().__init__((config.bind_host, config.bind_port), DiscoveryRequestHandler)
|
||||
|
||||
|
||||
|
|
@ -111,6 +118,7 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
|||
def do_GET(self) -> None:
|
||||
route = urlsplit(self.path)
|
||||
if route.path == "/health":
|
||||
presence_count, invitation_count = self.server.social_registry.counts()
|
||||
self._send_json(
|
||||
HTTPStatus.OK,
|
||||
{
|
||||
|
|
@ -119,6 +127,8 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
|||
"version": __version__,
|
||||
"build_revision": self.server.config.build_revision,
|
||||
"active_rooms": self.server.registry.room_count(),
|
||||
"active_presence_channels": presence_count,
|
||||
"pending_invitations": invitation_count,
|
||||
},
|
||||
)
|
||||
return
|
||||
|
|
@ -163,6 +173,14 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
|||
|
||||
def do_POST(self) -> None:
|
||||
path = urlsplit(self.path).path
|
||||
if path in [
|
||||
"/v1/presence",
|
||||
"/v1/presence/query",
|
||||
"/v1/invitations",
|
||||
"/v1/invitations/poll",
|
||||
]:
|
||||
self._handle_social_post(path)
|
||||
return
|
||||
room_id = self._room_id_for_suffix(path, "/join-attempts")
|
||||
if room_id is not None:
|
||||
try:
|
||||
|
|
@ -205,6 +223,63 @@ class DiscoveryRequestHandler(BaseHTTPRequestHandler):
|
|||
},
|
||||
)
|
||||
|
||||
def _handle_social_post(self, path: str) -> None:
|
||||
payload = self._read_json_object()
|
||||
if payload is None or self._reject_incompatible_game_version(payload):
|
||||
return
|
||||
try:
|
||||
game_version = payload.get("game_version")
|
||||
protocol_version = payload.get("protocol_version")
|
||||
if not isinstance(game_version, str):
|
||||
raise ValidationError("game_version must be a string")
|
||||
if (
|
||||
isinstance(protocol_version, bool)
|
||||
or not isinstance(protocol_version, int)
|
||||
or protocol_version < 1
|
||||
):
|
||||
raise ValidationError("protocol_version must be a positive integer")
|
||||
if path == "/v1/presence":
|
||||
channels = self.server.social_registry.publish_presence(
|
||||
self._client_address(), payload
|
||||
)
|
||||
self._send_json(HTTPStatus.OK, {"channels": channels})
|
||||
return
|
||||
if path == "/v1/presence/query":
|
||||
presence = self.server.social_registry.query_presence(
|
||||
payload.get("channels"),
|
||||
game_version.strip(),
|
||||
protocol_version,
|
||||
self.server.registry,
|
||||
)
|
||||
self._send_json(HTTPStatus.OK, {"presence": presence})
|
||||
return
|
||||
if path == "/v1/invitations/poll":
|
||||
invitations = self.server.social_registry.poll_invitations(
|
||||
payload.get("inbox_tokens"),
|
||||
game_version.strip(),
|
||||
protocol_version,
|
||||
self.server.registry,
|
||||
)
|
||||
self._send_json(HTTPStatus.OK, {"invitations": invitations})
|
||||
return
|
||||
invitation = self.server.social_registry.send_invitation(
|
||||
payload.get("inbox_token"),
|
||||
payload.get("room_id", ""),
|
||||
game_version.strip(),
|
||||
protocol_version,
|
||||
self.server.registry,
|
||||
)
|
||||
self._send_json(
|
||||
HTTPStatus.CREATED,
|
||||
{"invite_id": invitation.invite_id},
|
||||
)
|
||||
except FriendOfflineError as error:
|
||||
self._send_error(HTTPStatus.CONFLICT, "friend_offline", str(error))
|
||||
except RoomNotFoundError as error:
|
||||
self._send_error(HTTPStatus.NOT_FOUND, "room_not_found", str(error))
|
||||
except ValidationError as error:
|
||||
self._send_error(HTTPStatus.BAD_REQUEST, "invalid_social_request", str(error))
|
||||
|
||||
def do_PUT(self) -> None:
|
||||
room_id = self._room_id_from_path()
|
||||
if room_id is None:
|
||||
|
|
|
|||
277
netfishing_discovery/social_registry.py
Normal file
277
netfishing_discovery/social_registry.py
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
"""Ephemeral, capability-addressed friend presence and live invitations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from .registry import RoomAdvertisement, RoomNotFoundError, RoomRegistry, ValidationError
|
||||
|
||||
|
||||
PRESENCE_DOMAIN = "NETFISHING_PRESENCE_V1:"
|
||||
INVITE_DOMAIN = "NETFISHING_INVITE_V1:"
|
||||
SOCIAL_TOKEN_LENGTH = 64
|
||||
MAX_SOCIAL_CHANNELS_PER_REQUEST = 200
|
||||
MAX_SOCIAL_RECORDS = 8192
|
||||
MAX_SOCIAL_RECORDS_PER_ADDRESS = 512
|
||||
MAX_INVITES_PER_INBOX = 8
|
||||
PRESENCE_TTL_SECONDS = 45.0
|
||||
INBOX_REACHABLE_SECONDS = 15.0
|
||||
INVITE_TTL_SECONDS = 30.0
|
||||
|
||||
|
||||
class FriendOfflineError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Presence:
|
||||
channel: str
|
||||
display_name: str
|
||||
room_id: str
|
||||
game_version: str
|
||||
protocol_version: int
|
||||
address: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Invitation:
|
||||
invite_id: str
|
||||
inbox_id: str
|
||||
room_id: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
def _capability_id(domain: str, token: str) -> str:
|
||||
return hashlib.sha256(f"{domain}{token}".encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _clean_token(value: Any, field_name: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != SOCIAL_TOKEN_LENGTH:
|
||||
raise ValidationError(f"{field_name} must be a 64-character capability")
|
||||
if any(character not in "0123456789abcdef" for character in value):
|
||||
raise ValidationError(f"{field_name} must contain lowercase hexadecimal characters")
|
||||
return value
|
||||
|
||||
|
||||
def _clean_token_list(value: Any, field_name: str) -> list[str]:
|
||||
if not isinstance(value, list) or len(value) > MAX_SOCIAL_CHANNELS_PER_REQUEST:
|
||||
raise ValidationError(
|
||||
f"{field_name} must contain at most {MAX_SOCIAL_CHANNELS_PER_REQUEST} entries"
|
||||
)
|
||||
result: list[str] = []
|
||||
for raw_token in value:
|
||||
token = _clean_token(raw_token, field_name)
|
||||
if token not in result:
|
||||
result.append(token)
|
||||
return result
|
||||
|
||||
|
||||
def _clean_display_name(value: Any) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValidationError("display_name must be a string")
|
||||
result = unicodedata.normalize("NFC", value).strip()
|
||||
if not result or len(result) > 24:
|
||||
raise ValidationError("display_name must contain between 1 and 24 characters")
|
||||
if any(unicodedata.category(character).startswith("C") for character in result):
|
||||
raise ValidationError("display_name contains unsupported control characters")
|
||||
return result
|
||||
|
||||
|
||||
def _clean_protocol(value: Any) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise ValidationError("protocol_version must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
class SocialRegistry:
|
||||
"""Stores no accounts and no durable data; every record expires in seconds."""
|
||||
|
||||
def __init__(self, clock: Callable[[], float] = time.time) -> None:
|
||||
self._clock = clock
|
||||
self._presence: dict[str, Presence] = {}
|
||||
self._inbox_polls: dict[str, float] = {}
|
||||
self._invites: dict[str, list[Invitation]] = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def publish_presence(
|
||||
self, address: str, payload: Mapping[str, Any]
|
||||
) -> list[str]:
|
||||
write_tokens = _clean_token_list(payload.get("write_tokens"), "write_tokens")
|
||||
online = payload.get("online")
|
||||
if not isinstance(online, bool):
|
||||
raise ValidationError("online must be a boolean")
|
||||
channels = [_capability_id(PRESENCE_DOMAIN, token) for token in write_tokens]
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
self._purge_locked(now)
|
||||
if not online:
|
||||
for channel in channels:
|
||||
current = self._presence.get(channel)
|
||||
if current is not None and current.address == address:
|
||||
del self._presence[channel]
|
||||
return channels
|
||||
display_name = _clean_display_name(payload.get("display_name"))
|
||||
game_version = payload.get("game_version")
|
||||
if not isinstance(game_version, str) or not game_version.strip():
|
||||
raise ValidationError("game_version must be a non-empty string")
|
||||
protocol_version = _clean_protocol(payload.get("protocol_version"))
|
||||
room_id = payload.get("room_id", "")
|
||||
if not isinstance(room_id, str) or len(room_id) > 64 or "/" in room_id:
|
||||
raise ValidationError("room_id is invalid")
|
||||
new_channels = [channel for channel in channels if channel not in self._presence]
|
||||
address_count = sum(
|
||||
1 for record in self._presence.values() if record.address == address
|
||||
)
|
||||
if len(self._presence) + len(new_channels) > MAX_SOCIAL_RECORDS:
|
||||
raise ValidationError("the live presence directory is at capacity")
|
||||
if address_count + len(new_channels) > MAX_SOCIAL_RECORDS_PER_ADDRESS:
|
||||
raise ValidationError("too many live presence channels from this address")
|
||||
for channel in channels:
|
||||
self._presence[channel] = Presence(
|
||||
channel=channel,
|
||||
display_name=display_name,
|
||||
room_id=room_id,
|
||||
game_version=game_version.strip(),
|
||||
protocol_version=protocol_version,
|
||||
address=address,
|
||||
expires_at=now + PRESENCE_TTL_SECONDS,
|
||||
)
|
||||
return channels
|
||||
|
||||
def query_presence(
|
||||
self,
|
||||
channels_value: Any,
|
||||
game_version: str,
|
||||
protocol_version: int,
|
||||
rooms: RoomRegistry,
|
||||
) -> list[dict[str, Any]]:
|
||||
channels = _clean_token_list(channels_value, "channels")
|
||||
now = self._clock()
|
||||
result: list[dict[str, Any]] = []
|
||||
with self._lock:
|
||||
self._purge_locked(now)
|
||||
records = [self._presence.get(channel) for channel in channels]
|
||||
for record in records:
|
||||
if (
|
||||
record is None
|
||||
or record.game_version != game_version
|
||||
or record.protocol_version != protocol_version
|
||||
):
|
||||
continue
|
||||
room = rooms.get_public_room(
|
||||
record.room_id,
|
||||
game_version=game_version,
|
||||
protocol_version=protocol_version,
|
||||
) if record.room_id else None
|
||||
result.append(
|
||||
{
|
||||
"channel": record.channel,
|
||||
"display_name": record.display_name,
|
||||
"online": True,
|
||||
"room": room.public_dict() if room is not None else {},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def poll_invitations(
|
||||
self,
|
||||
inbox_tokens_value: Any,
|
||||
game_version: str,
|
||||
protocol_version: int,
|
||||
rooms: RoomRegistry,
|
||||
) -> list[dict[str, Any]]:
|
||||
tokens = _clean_token_list(inbox_tokens_value, "inbox_tokens")
|
||||
inbox_ids = [_capability_id(INVITE_DOMAIN, token) for token in tokens]
|
||||
now = self._clock()
|
||||
invitations: list[Invitation] = []
|
||||
with self._lock:
|
||||
self._purge_locked(now)
|
||||
for inbox_id in inbox_ids:
|
||||
self._inbox_polls[inbox_id] = now
|
||||
invitations.extend(self._invites.pop(inbox_id, []))
|
||||
result: list[dict[str, Any]] = []
|
||||
for invitation in invitations:
|
||||
room = rooms.get_public_room(
|
||||
invitation.room_id,
|
||||
game_version=game_version,
|
||||
protocol_version=protocol_version,
|
||||
)
|
||||
if room is None:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"invite_id": invitation.invite_id,
|
||||
"inbox_id": invitation.inbox_id,
|
||||
"room": room.public_dict(),
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def send_invitation(
|
||||
self,
|
||||
inbox_token_value: Any,
|
||||
room_id: str,
|
||||
game_version: str,
|
||||
protocol_version: int,
|
||||
rooms: RoomRegistry,
|
||||
) -> Invitation:
|
||||
inbox_token = _clean_token(inbox_token_value, "inbox_token")
|
||||
if not isinstance(room_id, str) or not room_id or len(room_id) > 64:
|
||||
raise ValidationError("room_id is invalid")
|
||||
room = rooms.get_public_room(
|
||||
room_id,
|
||||
game_version=game_version,
|
||||
protocol_version=protocol_version,
|
||||
)
|
||||
if room is None:
|
||||
raise RoomNotFoundError("room is not available")
|
||||
inbox_id = _capability_id(INVITE_DOMAIN, inbox_token)
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
self._purge_locked(now)
|
||||
last_poll = self._inbox_polls.get(inbox_id, 0.0)
|
||||
if now - last_poll > INBOX_REACHABLE_SECONDS:
|
||||
raise FriendOfflineError("This person needs to be online to do this.")
|
||||
queue = self._invites.setdefault(inbox_id, [])
|
||||
if len(queue) >= MAX_INVITES_PER_INBOX:
|
||||
queue.pop(0)
|
||||
invitation = Invitation(
|
||||
invite_id=secrets.token_hex(16),
|
||||
inbox_id=inbox_id,
|
||||
room_id=room.room_id,
|
||||
expires_at=now + INVITE_TTL_SECONDS,
|
||||
)
|
||||
queue.append(invitation)
|
||||
return invitation
|
||||
|
||||
def counts(self) -> tuple[int, int]:
|
||||
now = self._clock()
|
||||
with self._lock:
|
||||
self._purge_locked(now)
|
||||
return len(self._presence), sum(len(queue) for queue in self._invites.values())
|
||||
|
||||
def _purge_locked(self, now: float) -> None:
|
||||
self._presence = {
|
||||
channel: record
|
||||
for channel, record in self._presence.items()
|
||||
if record.expires_at > now
|
||||
}
|
||||
self._inbox_polls = {
|
||||
inbox_id: polled_at
|
||||
for inbox_id, polled_at in self._inbox_polls.items()
|
||||
if now - polled_at <= INBOX_REACHABLE_SECONDS
|
||||
}
|
||||
for inbox_id in list(self._invites):
|
||||
queue = [invite for invite in self._invites[inbox_id] if invite.expires_at > now]
|
||||
if queue:
|
||||
self._invites[inbox_id] = queue
|
||||
else:
|
||||
del self._invites[inbox_id]
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from http.client import HTTPConnection
|
||||
import json
|
||||
import socketserver
|
||||
|
|
@ -203,6 +204,95 @@ class DiscoveryHTTPTests(unittest.TestCase):
|
|||
self.assertEqual(body["version"], "0.16.2-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"NETFISHING_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))
|
||||
|
||||
|
|
|
|||
136
tests/test_social_registry.py
Normal file
136
tests/test_social_registry.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unittest
|
||||
|
||||
from netfishing_discovery.registry import RoomRegistry, ValidationError
|
||||
from netfishing_discovery.social_registry import (
|
||||
FriendOfflineError,
|
||||
INBOX_REACHABLE_SECONDS,
|
||||
PRESENCE_TTL_SECONDS,
|
||||
SocialRegistry,
|
||||
)
|
||||
|
||||
|
||||
GAME_VERSION = "0.16.2-alpha"
|
||||
PROTOCOL_VERSION = 9
|
||||
VALID_ROOM = {
|
||||
"room_name": "Pond Friends",
|
||||
"port": 7777,
|
||||
"current_players": 1,
|
||||
"max_players": 8,
|
||||
"game_version": GAME_VERSION,
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
}
|
||||
|
||||
|
||||
class FakeClock:
|
||||
def __init__(self) -> None:
|
||||
self.now = 1_000.0
|
||||
|
||||
def __call__(self) -> float:
|
||||
return self.now
|
||||
|
||||
|
||||
def capability_id(domain: str, token: str) -> str:
|
||||
return hashlib.sha256(f"{domain}{token}".encode()).hexdigest()
|
||||
|
||||
|
||||
class SocialRegistryTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.clock = FakeClock()
|
||||
self.rooms = RoomRegistry(ttl_seconds=300.0, clock=self.clock)
|
||||
room, _lease, verification = self.rooms.create(
|
||||
"203.0.113.10", VALID_ROOM
|
||||
)
|
||||
self.room = self.rooms.verify_endpoint(
|
||||
room.room_id, verification, "203.0.113.10", 7777
|
||||
)
|
||||
self.social = SocialRegistry(clock=self.clock)
|
||||
|
||||
def test_presence_is_capability_addressed_and_expires(self) -> None:
|
||||
write_token = "a" * 64
|
||||
channel = capability_id("NETFISHING_PRESENCE_V1:", write_token)
|
||||
self.social.publish_presence(
|
||||
"203.0.113.10",
|
||||
{
|
||||
"write_tokens": [write_token],
|
||||
"online": True,
|
||||
"display_name": "Voyager",
|
||||
"room_id": self.room.room_id,
|
||||
"game_version": GAME_VERSION,
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
},
|
||||
)
|
||||
presence = self.social.query_presence(
|
||||
[channel], GAME_VERSION, PROTOCOL_VERSION, self.rooms
|
||||
)
|
||||
self.assertEqual(len(presence), 1)
|
||||
self.assertEqual(presence[0]["channel"], channel)
|
||||
self.assertEqual(presence[0]["room"]["room_id"], self.room.room_id)
|
||||
|
||||
self.clock.now += PRESENCE_TTL_SECONDS
|
||||
self.assertEqual(
|
||||
self.social.query_presence(
|
||||
[channel], GAME_VERSION, PROTOCOL_VERSION, self.rooms
|
||||
),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_invitation_requires_a_current_live_poll(self) -> None:
|
||||
inbox_token = "b" * 64
|
||||
with self.assertRaisesRegex(
|
||||
FriendOfflineError,
|
||||
"This person needs to be online to do this",
|
||||
):
|
||||
self.social.send_invitation(
|
||||
inbox_token,
|
||||
self.room.room_id,
|
||||
GAME_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
self.rooms,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
self.social.poll_invitations(
|
||||
[inbox_token], GAME_VERSION, PROTOCOL_VERSION, self.rooms
|
||||
),
|
||||
[],
|
||||
)
|
||||
sent = self.social.send_invitation(
|
||||
inbox_token,
|
||||
self.room.room_id,
|
||||
GAME_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
self.rooms,
|
||||
)
|
||||
received = self.social.poll_invitations(
|
||||
[inbox_token], GAME_VERSION, PROTOCOL_VERSION, self.rooms
|
||||
)
|
||||
self.assertEqual(len(received), 1)
|
||||
self.assertEqual(received[0]["invite_id"], sent.invite_id)
|
||||
self.assertEqual(received[0]["room"]["room_id"], self.room.room_id)
|
||||
|
||||
self.clock.now += INBOX_REACHABLE_SECONDS + 0.01
|
||||
with self.assertRaises(FriendOfflineError):
|
||||
self.social.send_invitation(
|
||||
inbox_token,
|
||||
self.room.room_id,
|
||||
GAME_VERSION,
|
||||
PROTOCOL_VERSION,
|
||||
self.rooms,
|
||||
)
|
||||
|
||||
def test_invalid_capabilities_are_rejected(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
self.social.publish_presence(
|
||||
"203.0.113.10",
|
||||
{
|
||||
"write_tokens": ["not-a-capability"],
|
||||
"online": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue