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.py308
1 files changed, 308 insertions, 0 deletions
diff --git a/tests/integration/harness.py b/tests/integration/harness.py
new file mode 100644
index 0000000..03a0561
--- /dev/null
+++ b/tests/integration/harness.py
@@ -0,0 +1,308 @@
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 shutil
11import signal
12import socket
13import struct
14import subprocess
15import sys
16import tempfile
17import time
18import unittest
19import urllib.error
20import urllib.parse
21import urllib.request
22
23TRACKER = os.environ.get("OPENTRACKER", os.path.join(os.path.dirname(__file__), "..", "..", "opentracker.debug"))
24
25
26def bdecode(data):
27 """Enough bencode for what a tracker answers."""
28
29 def value(i):
30 if data[i : i + 1] == b"d":
31 i, out = i + 1, {}
32 while data[i : i + 1] != b"e":
33 i, key = value(i)
34 i, out[key] = value(i)
35 return i + 1, out
36 if data[i : i + 1] == b"l":
37 i, out = i + 1, []
38 while data[i : i + 1] != b"e":
39 i, item = value(i)
40 out.append(item)
41 return i + 1, out
42 if data[i : i + 1] == b"i":
43 end = data.index(b"e", i)
44 return end + 1, int(data[i + 1 : end])
45 colon = data.index(b":", i)
46 length = int(data[i:colon])
47 return colon + 1 + length, data[colon + 1 : colon + 1 + length]
48
49 return value(0)[1]
50
51
52def free_port():
53 probe = socket.socket()
54 probe.bind(("127.0.0.1", 0))
55 port = probe.getsockname()[1]
56 probe.close()
57 return port
58
59
60class Tracker:
61 """One tracker process, listening on loopback."""
62
63 def __init__(self, extra_config="", files=None):
64 self.port = free_port()
65 self.dir = tempfile.mkdtemp(prefix="ot-integration-")
66 self.log = None
67 self.process = None
68 try:
69 for name, content in (files or {}).items():
70 with open(os.path.join(self.dir, name), "wb") as handle:
71 handle.write(content)
72 self.config = os.path.join(self.dir, "opentracker.conf")
73 with open(self.config, "w") as handle:
74 handle.write("listen.tcp 127.0.0.1:%d\n" % self.port)
75 handle.write("listen.udp 127.0.0.1:%d\n" % self.port)
76 handle.write(extra_config.replace("{dir}", self.dir))
77 self.log = open(os.path.join(self.dir, "tracker.log"), "w+b")
78 # Leak detection is left at the sanitizer's own default, which is on
79 # where LeakSanitizer exists and off where it does not. Asking for it
80 # by name makes the tracker refuse to start on macOS and FreeBSD.
81 environment = dict(os.environ, ASAN_OPTIONS="abort_on_error=0")
82 self.process = subprocess.Popen([TRACKER, "-f", self.config], stdout=self.log, stderr=self.log, env=environment)
83 self._wait_for_port()
84 except BaseException:
85 self._cleanup()
86 raise
87
88 def _cleanup(self):
89 """Close what we opened and take the scratch directory with it."""
90 if self.process is not None and self.process.poll() is None:
91 self.process.kill()
92 self.process.wait()
93 if self.log is not None:
94 self.log.close()
95 self.log = None
96 shutil.rmtree(self.dir, ignore_errors=True)
97
98 def _wait_for_port(self):
99 for _ in range(200):
100 if self.process.poll() is not None:
101 raise AssertionError("tracker exited at once:\n" + self.output())
102 try:
103 probe = socket.create_connection(("127.0.0.1", self.port), timeout=0.2)
104 probe.close()
105 return
106 except OSError:
107 time.sleep(0.05)
108 raise AssertionError("tracker never listened on %d:\n%s" % (self.port, self.output()))
109
110 def output(self):
111 if self.log is None:
112 return ""
113 self.log.flush()
114 self.log.seek(0)
115 return self.log.read().decode("utf-8", "replace")
116
117 def stop(self):
118 """Returns anything the tracker complained about on the way out.
119
120 The interrupt rather than a kill is what makes the leak check work at
121 all: LeakSanitizer reports from an exit handler, so a killed tracker
122 reports nothing and the suite passes over leaks it never looked for.
123 """
124 if self.process is not None and self.process.poll() is None:
125 self.process.send_signal(signal.SIGINT)
126 try:
127 self.process.wait(timeout=5)
128 except subprocess.TimeoutExpired:
129 self.process.kill()
130 self.process.wait()
131 complaints = [
132 line
133 for line in self.output().splitlines()
134 if "Sanitizer" in line or "runtime error" in line or line.startswith("ERROR")
135 ]
136 self._cleanup()
137 return complaints
138
139 # ----------------------------------------------------------- over http
140
141 def get(self, path, timeout=10):
142 return urllib.request.urlopen("http://127.0.0.1:%d%s" % (self.port, path), timeout=timeout).read()
143
144 def announce(self, info_hash, peer_id=b"-OT0001-aaaaaaaaaaaa", port=6881, left=0, **extra):
145 query = {
146 "info_hash": urllib.parse.quote_from_bytes(info_hash, safe=""),
147 "peer_id": urllib.parse.quote_from_bytes(peer_id, safe=""),
148 "port": port,
149 "left": left,
150 }
151 query.update(extra)
152 return bdecode(self.get("/announce?" + "&".join("%s=%s" % item for item in query.items())))
153
154 def scrape(self, info_hash):
155 return bdecode(self.get("/scrape?info_hash=" + urllib.parse.quote_from_bytes(info_hash, safe="")))
156
157 def stats(self, mode):
158 return self.get("/stats?mode=" + mode)
159
160 def get_with_headers(self, path, headers=None, timeout=10):
161 """The body and the response headers, for the compressed answers."""
162 request = urllib.request.Request("http://127.0.0.1:%d%s" % (self.port, path), headers=headers or {})
163 with urllib.request.urlopen(request, timeout=timeout) as response:
164 return response.read(), dict(response.headers)
165
166 def torrent_count(self):
167 return int(self.stats("torr").split()[0])
168
169 # ------------------------------------------------------------ over udp
170
171 def udp_connect(self):
172 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
173 sock.settimeout(5)
174 sock.sendto(struct.pack(">QII", 0x41727101980, 0, 1), ("127.0.0.1", self.port))
175 answer = sock.recv(2048)
176 action, _, connection_id = struct.unpack(">IIQ", answer[:16])
177 assert action == 0, "udp connect answered action %d" % action
178 return sock, connection_id
179
180 def udp_announce(self, sock, connection_id, info_hash, left=0, event=0, port=6881, numwant=-1):
181 sock.sendto(
182 struct.pack(
183 ">QII20s20sQQQIIIiH", connection_id, 1, 2, info_hash, b"p" * 20, 0, left, 0, event, 0, 0, numwant, port
184 ),
185 ("127.0.0.1", self.port),
186 )
187 answer = sock.recv(2048)
188 action, _, interval, leechers, seeders = struct.unpack(">IIIII", answer[:20])
189 assert action == 1, "udp announce answered action %d" % action
190 return {"interval": interval, "leechers": leechers, "seeders": seeders, "peers": answer[20:]}
191
192 def udp_scrape(self, sock, connection_id, hashes):
193 """One (seeders, completed, leechers) triple per hash, in order."""
194 sock.sendto(struct.pack(">QII", connection_id, 2, 3) + b"".join(hashes), ("127.0.0.1", self.port))
195 answer = sock.recv(2048)
196 action, _ = struct.unpack(">II", answer[:8])
197 assert action == 2, "udp scrape answered action %d" % action
198 return [struct.unpack(">III", answer[8 + 12 * i : 20 + 12 * i]) for i in range(len(hashes))]
199
200 def connect(self):
201 """A connection the caller drives and closes."""
202 return Connection(self.port)
203
204 def udp_raw(self, payload, timeout=1.0):
205 """Sends a packet exactly as given. Returns the answer, or None if the
206 tracker chose to say nothing, which is how it treats a broken one."""
207 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
208 sock.settimeout(timeout)
209 try:
210 sock.sendto(payload, ("127.0.0.1", self.port))
211 try:
212 return sock.recv(2048)
213 except socket.timeout:
214 return None
215 finally:
216 sock.close()
217
218
219class Connection:
220 """One HTTP connection the caller keeps open, which urllib will not do.
221
222 Reads exactly one reply at a time, so a test can tell "the tracker
223 answered and kept the socket" from "the tracker answered and hung up"."""
224
225 def __init__(self, port, timeout=5):
226 self.sock = socket.create_connection(("127.0.0.1", port), timeout=timeout)
227 self.sock.settimeout(timeout)
228 self.buffer = b""
229
230 def send(self, path, connection="Keep-Alive", version="1.1"):
231 request = "GET %s HTTP/%s\r\nHost: tracker\r\n" % (path, version)
232 if connection is not None:
233 request += "Connection: %s\r\n" % connection
234 self.sock.sendall((request + "\r\n").encode())
235
236 def _fill(self):
237 chunk = self.sock.recv(65536)
238 if not chunk:
239 raise EOFError("the tracker closed the connection")
240 self.buffer += chunk
241
242 def reply(self):
243 """The one complete reply at the front of the stream, headers and body."""
244 while b"\r\n\r\n" not in self.buffer:
245 self._fill()
246 head, rest = self.buffer.split(b"\r\n\r\n", 1)
247 headers = {}
248 for line in head.split(b"\r\n")[1:]:
249 name, _, value = line.partition(b":")
250 headers[name.strip().lower()] = value.strip()
251
252 if headers.get(b"transfer-encoding") == b"chunked":
253 while not rest.endswith(b"0\r\n\r\n"):
254 self.buffer = head + b"\r\n\r\n" + rest
255 self._fill()
256 rest = self.buffer.split(b"\r\n\r\n", 1)[1]
257 body, self.buffer = rest, b""
258 else:
259 length = int(headers.get(b"content-length", b"0"))
260 while len(rest) < length:
261 self.buffer = head + b"\r\n\r\n" + rest
262 self._fill()
263 rest = self.buffer.split(b"\r\n\r\n", 1)[1]
264 body, self.buffer = rest[:length], rest[length:]
265 return head.split(b"\r\n")[0], headers, body
266
267 def is_open(self):
268 """True if the tracker is still holding the connection."""
269 try:
270 self.sock.settimeout(0.4)
271 return self.sock.recv(1) != b""
272 except socket.timeout:
273 return True
274 except OSError:
275 return False
276 finally:
277 try:
278 self.sock.settimeout(5)
279 except OSError:
280 pass
281
282 def close(self):
283 self.sock.close()
284
285
286class TrackerTestCase(unittest.TestCase):
287 """Gives every test its own tracker and fails it on any sanitizer output."""
288
289 config = ""
290 files = None
291
292 def setUp(self):
293 self.tracker = Tracker(self.config, self.files)
294
295 def tearDown(self):
296 # A connection the test closed is only reaped when the event loop gets
297 # round to it, and a cookie still held at exit reads as a leak. One
298 # more request puts us behind that work in the loop's queue.
299 try:
300 self.tracker.torrent_count()
301 except Exception:
302 pass
303 complaints = self.tracker.stop()
304 self.assertEqual(complaints, [], "the tracker reported:\n" + "\n".join(complaints))
305
306 def hash_for(self, name):
307 """A distinct info hash per test, so tests cannot leak into each other."""
308 return (name.encode() + b"\0" * 20)[:20]