344 lines
13 KiB
Python
344 lines
13 KiB
Python
"""
|
|
Tests for RN Praxis 2
|
|
"""
|
|
|
|
import contextlib
|
|
import errno
|
|
import struct
|
|
import time
|
|
import urllib.request as req
|
|
from urllib.parse import urlparse
|
|
from http.client import HTTPConnection
|
|
|
|
import pytest
|
|
|
|
import util
|
|
import dht
|
|
|
|
# We assume that all tests from the previous sheet(s) are working!
|
|
|
|
# -i dumps header
|
|
# -L follows redirects
|
|
# curl -iL localhost:12345/static/baz
|
|
|
|
|
|
def _iter_with_neighbors(xs):
|
|
"""Return iterator to list that includes each elements neighbors
|
|
|
|
For each element in the original list a triple of its neighbors
|
|
is generated: `(xs[i - 1], xs[i], xs[i + 1])`
|
|
"""
|
|
return zip(xs[-1:] + xs[:-1], xs, xs[1:] + xs[:1])
|
|
|
|
|
|
@pytest.fixture
|
|
def static_peer(request):
|
|
"""Return a function for spawning DHT peers
|
|
"""
|
|
def runner(peer, predecessor=None, successor=None):
|
|
"""Spawn a static DHT peer
|
|
|
|
The peer is passed its local neighborhood via environment variables.
|
|
"""
|
|
return util.KillOnExit(
|
|
[request.config.getoption('executable'), peer.ip, f'{peer.port}'] + ([f'{peer.id}'] if peer.id is not None else []),
|
|
env={
|
|
**({'PRED_ID': f'{predecessor.id}', 'PRED_IP': predecessor.ip, 'PRED_PORT': f'{predecessor.port}'} if predecessor is not None else {}),
|
|
**({'SUCC_ID': f'{successor.id}', 'SUCC_IP': successor.ip, 'SUCC_PORT': f'{successor.port}'} if successor is not None else {}),
|
|
'NO_STABILIZE': '1', # Forward compatibility with P3.
|
|
},
|
|
)
|
|
|
|
@contextlib.contextmanager
|
|
def empty_context(*args, **kwargs):
|
|
yield # No peer initialization here, just an empty context that never fails
|
|
|
|
if request.config.getoption('debug_own'):
|
|
return empty_context
|
|
|
|
return runner
|
|
|
|
|
|
@pytest.mark.timeout(1)
|
|
def test_listen(static_peer):
|
|
"""
|
|
Tests chord part of the system (1.1).
|
|
Listens on UDP port.
|
|
"""
|
|
|
|
self = dht.Peer(None, '127.0.0.1', 4711)
|
|
with static_peer(self), pytest.raises(OSError) as exception_info:
|
|
time.sleep(.1)
|
|
|
|
# Attempt to open the port that the implementation should have opened
|
|
with dht.peer_socket(self):
|
|
pass
|
|
|
|
assert exception_info.value.errno == errno.EADDRINUSE, "UDP port not open"
|
|
|
|
|
|
@pytest.mark.timeout(1)
|
|
@pytest.mark.parametrize("uri", ['a', 'b', 'aa', 'ab', 'ac', 'ad', 'klm', 'xyz'])
|
|
def test_immediate_dht(static_peer, uri):
|
|
"""Test hashing of request (1.2)
|
|
Run peer in minimal (non-trivial) DHT
|
|
- two nodes, equal split of namespace
|
|
- first node real, second mock sockets
|
|
|
|
1. make request - internally requires hashing of location part in URL
|
|
2. check that request yields either 404 (if peer is responsible) or 303 (if it isn't)
|
|
3. no packet should be received by the second peer
|
|
"""
|
|
|
|
predecessor = dht.Peer(0xc000, '127.0.0.1', 4712)
|
|
self = dht.Peer(0x4000, '127.0.0.1', 4711)
|
|
successor = predecessor
|
|
|
|
with dht.peer_socket(
|
|
successor
|
|
) as mock, static_peer(
|
|
self, predecessor, successor,
|
|
), contextlib.closing(
|
|
HTTPConnection(self.ip, self.port) # Use `http.client` instead of `urllib` to avoid redirection
|
|
) as conn:
|
|
conn.connect()
|
|
|
|
conn.request('GET', f'/{uri}')
|
|
time.sleep(.1)
|
|
reply = conn.getresponse()
|
|
_ = reply.read()
|
|
|
|
uri_hash = dht.hash(f'/{uri}'.encode('latin1'))
|
|
implementation_responsible = not self.id < uri_hash <= successor.id
|
|
|
|
if implementation_responsible:
|
|
assert reply.status == 404, "Server should've indicated missing data"
|
|
else:
|
|
assert reply.status == 303, "Server should've delegated response"
|
|
assert reply.headers['Location'] == f'http://{successor.ip}:{successor.port}/{uri}', "Server should've delegated to its successor"
|
|
|
|
assert util.bytes_available(mock) == 0, "Data received on successor socket"
|
|
|
|
|
|
@pytest.mark.timeout(1)
|
|
@pytest.mark.parametrize("uri", ['a', 'aa'])
|
|
def test_lookup_sent(static_peer, uri):
|
|
"""Test for lookup to correct peer (1.3)
|
|
|
|
Node is running with minimal assigned address space, should send lookup messages
|
|
for the correct hash to its successors and reply with 503 & Retry-After header.
|
|
"""
|
|
|
|
predecessor = dht.Peer(0xffff, '127.0.0.1', 4710)
|
|
self = dht.Peer(0x0000, '127.0.0.1', 4711)
|
|
successor = dht.Peer(0x0001, '127.0.0.1', 4712)
|
|
|
|
with dht.peer_socket(successor) as mock: # Can't differentiate predecessor & successor, reusing ports.
|
|
with static_peer(
|
|
self, predecessor, successor
|
|
), pytest.raises(req.HTTPError) as exception_info:
|
|
|
|
url = f'http://{self.ip}:{self.port}/{uri}'
|
|
req.urlopen(url)
|
|
|
|
assert exception_info.value.status == 503, "Server should reply with 503"
|
|
assert exception_info.value.headers.get("Retry-After", None) == "1", "Server should set 'Retry-After' header to 1s"
|
|
|
|
time.sleep(.1)
|
|
assert util.bytes_available(mock) > 0, "No data received on successor socket"
|
|
data = mock.recv(1024)
|
|
assert len(data) == struct.calcsize(dht.message_format), "Received message has invalid length for DHT message"
|
|
msg = dht.deserialize(data)
|
|
|
|
assert dht.Flags(msg.flags) == dht.Flags.lookup, "Received message should be a lookup"
|
|
|
|
uri_hash = dht.hash(urlparse(url).path.encode('latin1'))
|
|
assert msg.id == uri_hash, "Received lookup should query the requested datum's hash"
|
|
|
|
assert msg.peer == self, "Received lookup should indicate its originator"
|
|
|
|
|
|
@pytest.mark.timeout(1)
|
|
def test_lookup_reply(static_peer):
|
|
"""Test whether peer replies to lookup correctly (1.4)"""
|
|
|
|
predecessor = dht.Peer(0x0000, '127.0.0.1', 4710)
|
|
self = dht.Peer(0x1000, '127.0.0.1', 4711)
|
|
successor = dht.Peer(0x2000, '127.0.0.1', 4712)
|
|
|
|
with dht.peer_socket(
|
|
predecessor
|
|
) as pred_mock, static_peer(
|
|
self, predecessor, successor
|
|
), dht.peer_socket(
|
|
successor
|
|
) as succ_mock:
|
|
lookup = dht.Message(dht.Flags.lookup, 0x1800, predecessor)
|
|
pred_mock.sendto(dht.serialize(lookup), (self.ip, self.port))
|
|
|
|
time.sleep(.1)
|
|
|
|
assert util.bytes_available(succ_mock) == 0, "Data received on successor socket"
|
|
assert util.bytes_available(pred_mock) > 0, "No data received on predecessor socket"
|
|
data = pred_mock.recv(1024)
|
|
assert len(data) == struct.calcsize(dht.message_format), "Received message has invalid length for DHT message"
|
|
reply = dht.deserialize(data)
|
|
|
|
assert dht.Flags(reply.flags) == dht.Flags.reply, "Received message should be a reply"
|
|
assert reply.peer == successor, "Reply does not indicate successor as responsible"
|
|
assert reply.id == self.id, "Reply does not indicate implementation as previous ID"
|
|
|
|
|
|
@pytest.mark.timeout(1)
|
|
def test_lookup_forward(static_peer):
|
|
"""Test whether peer forwards lookup correctly (1.5)"""
|
|
|
|
predecessor = dht.Peer(0x0000, '127.0.0.1', 4710)
|
|
self = dht.Peer(0x1000, '127.0.0.1', 4711)
|
|
successor = dht.Peer(0x2000, '127.0.0.1', 4712)
|
|
|
|
with dht.peer_socket(
|
|
predecessor
|
|
) as pred_mock, static_peer(
|
|
self, predecessor, successor
|
|
), dht.peer_socket(
|
|
successor
|
|
) as succ_mock:
|
|
lookup = dht.Message(dht.Flags.lookup, 0x2800, predecessor)
|
|
pred_mock.sendto(dht.serialize(lookup), (self.ip, self.port))
|
|
|
|
time.sleep(.1)
|
|
|
|
assert util.bytes_available(pred_mock) == 0, "Data received on predecessor socket"
|
|
assert util.bytes_available(succ_mock) > 0, "No data received on successor socket"
|
|
data = succ_mock.recv(1024)
|
|
assert len(data) == struct.calcsize(dht.message_format), "Received message has invalid length for DHT message"
|
|
received = dht.deserialize(data)
|
|
|
|
assert received == lookup, "Received message should be equal to original lookup"
|
|
|
|
|
|
@pytest.mark.timeout(1)
|
|
@pytest.mark.parametrize("uri", ['a', 'aa'])
|
|
def test_lookup_complete(static_peer, uri):
|
|
"""Test for correct lookup use (1.6)
|
|
|
|
Node is running with minimal assigned address space, should send lookup messages
|
|
for the correct hash to its successors and reply with 503 & Retry-After header.
|
|
"""
|
|
|
|
predecessor = dht.Peer(0xffff, '127.0.0.1', 4710)
|
|
self = dht.Peer(0x0000, '127.0.0.1', 4711)
|
|
successor = dht.Peer(0x0001, '127.0.0.1', 4712)
|
|
|
|
with dht.peer_socket(
|
|
predecessor
|
|
) as pred_mock, static_peer(
|
|
self, predecessor, successor
|
|
), dht.peer_socket(
|
|
successor
|
|
) as succ_mock, contextlib.closing(
|
|
HTTPConnection(self.ip, self.port) # We have to use the more low-level `http.client` instead of `urllib` to avoid it interpreting the `Retry-After` header
|
|
) as conn:
|
|
conn.connect()
|
|
time.sleep(.1)
|
|
|
|
# Check HTTP reply
|
|
conn.request('GET', f'/{uri}')
|
|
time.sleep(.1)
|
|
response = conn.getresponse()
|
|
_ = response.read()
|
|
assert response.status == 503, "Server should reply with 503"
|
|
assert response.headers.get("Retry-After", None) == "1", "Server should set 'Retry-After' header to 1s"
|
|
time.sleep(.1)
|
|
|
|
# Check lookup message
|
|
time.sleep(.1)
|
|
assert util.bytes_available(pred_mock) == 0, "Data received on predecessor socket"
|
|
assert util.bytes_available(succ_mock) > 0, "No data received on successor socket"
|
|
data = succ_mock.recv(1024)
|
|
assert len(data) == struct.calcsize(dht.message_format), "Received message has invalid length for DHT message"
|
|
msg = dht.deserialize(data)
|
|
|
|
time.sleep(.1)
|
|
assert dht.Flags(msg.flags) == dht.Flags.lookup, "Received message should be a lookup"
|
|
|
|
uri_hash = dht.hash(f'/{uri}'.encode('latin1'))
|
|
assert msg.id == uri_hash, "Received lookup should query the requested datum's hash"
|
|
|
|
assert msg.peer == self, "Received lookup should indicate its originator"
|
|
|
|
time.sleep(.1)
|
|
|
|
# Send reply
|
|
reply = dht.Message(dht.Flags.reply, successor.id, predecessor)
|
|
pred_mock.sendto(dht.serialize(reply), (self.ip, self.port))
|
|
|
|
time.sleep(.1)
|
|
|
|
# Check HTTP reply again
|
|
conn.request('GET', f'/{uri}')
|
|
response = conn.getresponse()
|
|
_ = response.read()
|
|
assert response.status == 303, "Server should've delegated response"
|
|
assert response.headers['Location'] == f'http://{predecessor.ip}:{predecessor.port}/{uri}', "Server should've delegated to its predecessor"
|
|
|
|
|
|
@pytest.mark.timeout(10) # five requests, two seconds each
|
|
def test_dht(static_peer):
|
|
"""Test a complete DHT (1.7)
|
|
|
|
At this point, a DHT consisting only of the implementation should work as expected.
|
|
We will repeat the dynamic content test, but will contact a different peer for each request.
|
|
"""
|
|
|
|
# chosen by fair dice roll.
|
|
# guaranteed to be random.
|
|
dht_ids = [10927, 18804, 40809, 54536, 63901]
|
|
contact_order = [1, 0, 3, 2, 4]
|
|
datum = '191b023eb6e0090d'
|
|
content = b'8392cb0f8991fb706b8d80b898fd7bdc888e8fc4b40858e9eb136743ba1ac290'
|
|
|
|
peers = [
|
|
dht.Peer(id_, '127.0.0.1', 4710 + i)
|
|
for i, id_
|
|
in enumerate(dht_ids)
|
|
]
|
|
|
|
with contextlib.ExitStack() as contexts:
|
|
for predecessor, peer, successor in _iter_with_neighbors(peers):
|
|
contexts.enter_context(static_peer(
|
|
peer, predecessor, successor
|
|
))
|
|
|
|
# Ensure datum is missing
|
|
contact = peers[contact_order[0]]
|
|
with pytest.raises(req.HTTPError) as exception_info:
|
|
util.urlopen(f'http://{contact.ip}:{contact.port}/dynamic/{datum}')
|
|
|
|
assert exception_info.value.status == 404, f"'/dynamic/{datum}' should be missing, but GET was not answered with '404'"
|
|
|
|
# Create datum
|
|
contact = peers[contact_order[1]]
|
|
reply = util.urlopen(req.Request(f'http://{contact.ip}:{contact.port}/dynamic/{datum}', data=content, method='PUT'))
|
|
assert reply.status == 201, f"Creation of '/dynamic/{datum}' did not yield '201'"
|
|
|
|
# Ensure datum exists
|
|
contact = peers[contact_order[2]]
|
|
reply = util.urlopen(f'http://{contact.ip}:{contact.port}/dynamic/{datum}')
|
|
assert reply.status == 200
|
|
assert reply.read() == content, f"Content of '/dynamic/{datum}' does not match what was passed"
|
|
|
|
# Delete datum
|
|
contact = peers[contact_order[3]]
|
|
real_url = util.urlopen(f'http://{contact.ip}:{contact.port}/dynamic/{datum}').geturl() # Determine correct URL
|
|
reply = util.urlopen(req.Request(real_url, method='DELETE'))
|
|
assert reply.status in {200, 202, 204}, f"Deletion of '/dynamic/{datum}' did not succeed"
|
|
|
|
# Ensure datum is missing
|
|
contact = peers[contact_order[4]]
|
|
with pytest.raises(req.HTTPError) as exception_info:
|
|
util.urlopen(f'http://{contact.ip}:{contact.port}/dynamic/{datum}')
|
|
|
|
assert exception_info.value.status == 404, f"'/dynamic/{datum}' should be missing, but GET was not answered with '404'"
|