""" 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]] # 18804 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]] # 10927 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]] # 54536 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]] # 40809 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]] # 63901 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'"