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
|
|
@ -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