summaryrefslogtreecommitdiff
path: root/tests/integration/harness.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/integration/harness.py')
-rw-r--r--tests/integration/harness.py166
1 files changed, 166 insertions, 0 deletions
diff --git a/tests/integration/harness.py b/tests/integration/harness.py
new file mode 100644
index 0000000..c8da1e2
--- /dev/null
+++ b/tests/integration/harness.py
@@ -0,0 +1,166 @@
1"""Starts a real opentracker and talks to it over the wire.
2
3Everything here drives the binary rather than the code, so these tests cover
4what the unit suite cannot reach: the event loop, the HTTP parser and the
5torrent store working together. Run against a sanitizer build and the tracker
6reports its own memory errors, which the teardown turns into test failures.
7"""
8
9import os
10import socket
11import struct
12import subprocess
13import sys
14import tempfile
15import time
16import unittest
17import urllib.error
18import urllib.parse
19import urllib.request
20
21TRACKER = os.environ.get("OPENTRACKER", os.path.join(os.path.dirname(__file__), "..", "..", "opentracker.debug"))
22
23
24def bdecode(data):
25 """Enough bencode for what a tracker answers."""
26
27 def value(i):
28 if data[i : i + 1] == b"d":
29 i, out = i + 1, {}
30 while data[i : i + 1] != b"e":
31 i, key = value(i)
32 i, out[key] = value(i)
33 return i + 1, out
34 if data[i : i + 1] == b"l":
35 i, out = i + 1, []
36 while data[i : i + 1] != b"e":
37 i, item = value(i)
38 out.append(item)
39 return i + 1, out
40 if data[i : i + 1] == b"i":
41 end = data.index(b"e", i)
42 return end + 1, int(data[i + 1 : end])
43 colon = data.index(b":", i)
44 length = int(data[i:colon])
45 return colon + 1 + length, data[colon + 1 : colon + 1 + length]
46
47 return value(0)[1]
48
49
50def free_port():
51 probe = socket.socket()
52 probe.bind(("127.0.0.1", 0))
53 port = probe.getsockname()[1]
54 probe.close()
55 return port
56
57
58class Tracker:
59 """One tracker process, listening on loopback."""
60
61 def __init__(self, extra_config=""):
62 self.port = free_port()
63 self.dir = tempfile.mkdtemp(prefix="ot-integration-")
64 self.config = os.path.join(self.dir, "opentracker.conf")
65 with open(self.config, "w") as handle:
66 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port)
67 handle.write("listen.udp 127.0.0.1:%d\n" % self.port)
68 handle.write(extra_config)
69 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b")
70 environment = dict(os.environ, ASAN_OPTIONS="detect_leaks=0:abort_on_error=0")
71 self.process = subprocess.Popen([TRACKER, "-f", self.config], stdout=self.log, stderr=self.log, env=environment)
72 self._wait_for_port()
73
74 def _wait_for_port(self):
75 for _ in range(200):
76 if self.process.poll() is not None:
77 raise AssertionError("tracker exited at once:\n" + self.output())
78 try:
79 probe = socket.create_connection(("127.0.0.1", self.port), timeout=0.2)
80 probe.close()
81 return
82 except OSError:
83 time.sleep(0.05)
84 raise AssertionError("tracker never listened on %d:\n%s" % (self.port, self.output()))
85
86 def output(self):
87 self.log.flush()
88 self.log.seek(0)
89 return self.log.read().decode("utf-8", "replace")
90
91 def stop(self):
92 """Returns anything the tracker complained about on the way out."""
93 if self.process.poll() is None:
94 self.process.kill()
95 self.process.wait()
96 complaints = [
97 line
98 for line in self.output().splitlines()
99 if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR")
100 ]
101 self.log.close()
102 return complaints
103
104 # ----------------------------------------------------------- over http
105
106 def get(self, path, timeout=10):
107 return urllib.request.urlopen("http://127.0.0.1:%d%s" % (self.port, path), timeout=timeout).read()
108
109 def announce(self, info_hash, peer_id=b"-OT0001-aaaaaaaaaaaa", port=6881, left=0, **extra):
110 query = {
111 "info_hash": urllib.parse.quote_from_bytes(info_hash, safe=""),
112 "peer_id": urllib.parse.quote_from_bytes(peer_id, safe=""),
113 "port": port,
114 "left": left,
115 }
116 query.update(extra)
117 return bdecode(self.get("/announce?" + "&".join("%s=%s" % item for item in query.items())))
118
119 def scrape(self, info_hash):
120 return bdecode(self.get("/scrape?info_hash=" + urllib.parse.quote_from_bytes(info_hash, safe="")))
121
122 def stats(self, mode):
123 return self.get("/stats?mode=" + mode)
124
125 def torrent_count(self):
126 return int(self.stats("torr").split()[0])
127
128 # ------------------------------------------------------------ over udp
129
130 def udp_connect(self):
131 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
132 sock.settimeout(5)
133 sock.sendto(struct.pack(">QII", 0x41727101980, 0, 1), ("127.0.0.1", self.port))
134 answer = sock.recv(2048)
135 action, _, connection_id = struct.unpack(">IIQ", answer[:16])
136 assert action == 0, "udp connect answered action %d" % action
137 return sock, connection_id
138
139 def udp_announce(self, sock, connection_id, info_hash, left=0, event=0, port=6881, numwant=-1):
140 sock.sendto(
141 struct.pack(
142 ">QII20s20sQQQIIIiH", connection_id, 1, 2, info_hash, b"p" * 20, 0, left, 0, event, 0, 0, numwant, port
143 ),
144 ("127.0.0.1", self.port),
145 )
146 answer = sock.recv(2048)
147 action, _, interval, leechers, seeders = struct.unpack(">IIIII", answer[:20])
148 assert action == 1, "udp announce answered action %d" % action
149 return {"interval": interval, "leechers": leechers, "seeders": seeders, "peers": answer[20:]}
150
151
152class TrackerTestCase(unittest.TestCase):
153 """Gives every test its own tracker and fails it on any sanitizer output."""
154
155 config = ""
156
157 def setUp(self):
158 self.tracker = Tracker(self.config)
159
160 def tearDown(self):
161 complaints = self.tracker.stop()
162 self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints))
163
164 def hash_for(self, name):
165 """A distinct info hash per test, so tests cannot leak into each other."""
166 return (name.encode() + b"\0" * 20)[:20]