summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/integration/.gitignore2
-rw-r--r--tests/integration/harness.py308
-rw-r--r--tests/integration/test_accesslist.py100
-rw-r--r--tests/integration/test_announce.py129
-rw-r--r--tests/integration/test_dualstack.py89
-rw-r--r--tests/integration/test_fullscrape.py146
-rw-r--r--tests/integration/test_keepalive.py111
-rw-r--r--tests/integration/test_stats.py169
-rw-r--r--tests/integration/test_udp.py168
-rw-r--r--tests/runner.c73
-rw-r--r--tests/t_fmt.c108
-rw-r--r--tests/t_vector.c46
-rw-r--r--tests/test.h1
13 files changed, 1448 insertions, 2 deletions
diff --git a/tests/integration/.gitignore b/tests/integration/.gitignore
new file mode 100644
index 0000000..7a60b85
--- /dev/null
+++ b/tests/integration/.gitignore
@@ -0,0 +1,2 @@
1__pycache__/
2*.pyc
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]
diff --git a/tests/integration/test_accesslist.py b/tests/integration/test_accesslist.py
new file mode 100644
index 0000000..41ff654
--- /dev/null
+++ b/tests/integration/test_accesslist.py
@@ -0,0 +1,100 @@
1"""The blacklist, loaded from a file at startup.
2
3A blacklisted info hash must be refused at both the announce and the scrape
4entry, and the file parser has to cope with the shapes an operator actually
5produces: an empty file, and a last line with no newline after it.
6"""
7
8import struct
9import unittest
10
11from harness import TrackerTestCase
12
13BLOCKED = b"\xb1" * 20
14ALLOWED = b"\xa1" * 20
15
16
17def listing(*hashes):
18 return b"".join(h.hex().upper().encode() + b"\n" for h in hashes)
19
20
21class Blacklist(TrackerTestCase):
22 config = "access.blacklist {dir}/blacklist\n"
23 files = {"blacklist": listing(BLOCKED)}
24
25 def test_a_blacklisted_hash_is_refused(self):
26 answer = self.tracker.announce(BLOCKED, port=6881, left=0)
27 self.assertIn(b"failure reason", answer)
28 self.assertIn(b"not authorized", answer[b"failure reason"])
29
30 def test_a_blacklisted_hash_never_becomes_a_torrent(self):
31 before = self.tracker.torrent_count()
32 self.tracker.announce(BLOCKED, port=6881, left=0)
33 self.assertEqual(self.tracker.torrent_count(), before, "the refused hash was stored anyway")
34
35 def test_any_other_hash_is_served(self):
36 answer = self.tracker.announce(ALLOWED, port=6881, left=0)
37 self.assertNotIn(b"failure reason", answer)
38 self.assertEqual(answer[b"complete"], 1)
39
40 def test_the_block_is_an_error_packet_over_udp(self):
41 """BEP 15 has an error action, which is what a refusal should use.
42 It used to answer the announce header with no body behind it, a
43 packet too short for any client to read."""
44 sock, connection_id = self.tracker.udp_connect()
45 sock.close()
46 request = struct.pack(
47 ">QII20s20sQQQIIIiH", connection_id, 1, 7, BLOCKED, b"p" * 20, 0, 0, 0, 0, 0, 0, -1, 6881
48 )
49 answer = self.tracker.udp_raw(request)
50 self.assertIsNotNone(answer)
51 action, transaction = struct.unpack(">II", answer[:8])
52 self.assertEqual(action, 3, "action 3 is the error packet")
53 self.assertEqual(transaction, 7)
54 self.assertIn(b"not authorized", answer[8:])
55
56 def test_a_blocked_hash_is_not_stored_when_it_arrives_over_udp(self):
57 sock, connection_id = self.tracker.udp_connect()
58 sock.close()
59 self.tracker.udp_raw(
60 struct.pack(">QII20s20sQQQIIIiH", connection_id, 1, 8, BLOCKED, b"p" * 20, 0, 0, 0, 0, 0, 0, -1, 6881)
61 )
62 self.assertEqual(self.tracker.torrent_count(), 0)
63
64
65class BlacklistWithoutTrailingNewline(TrackerTestCase):
66 """A single hash with no newline after it used to size the list at zero
67 and then write into it, which took the tracker down before it listened."""
68
69 config = "access.blacklist {dir}/blacklist\n"
70 files = {"blacklist": BLOCKED.hex().upper().encode()}
71
72 def test_the_last_line_counts_even_without_its_newline(self):
73 answer = self.tracker.announce(BLOCKED, port=6881, left=0)
74 self.assertIn(b"failure reason", answer)
75
76 def test_and_the_tracker_still_serves_everything_else(self):
77 self.assertEqual(self.tracker.announce(ALLOWED, port=6881, left=0)[b"complete"], 1)
78
79
80class EmptyBlacklist(TrackerTestCase):
81 config = "access.blacklist {dir}/blacklist\n"
82 files = {"blacklist": b""}
83
84 def test_an_empty_file_blocks_nothing_and_starts_cleanly(self):
85 self.assertEqual(self.tracker.announce(BLOCKED, port=6881, left=0)[b"complete"], 1)
86 self.assertEqual(self.tracker.announce(ALLOWED, port=6882, left=0)[b"complete"], 1)
87
88
89class MissingBlacklist(TrackerTestCase):
90 """The file is named but absent: the tracker warns and serves everything,
91 because it may be created later."""
92
93 config = "access.blacklist {dir}/absent\n"
94
95 def test_a_missing_file_blocks_nothing(self):
96 self.assertEqual(self.tracker.announce(BLOCKED, port=6881, left=0)[b"complete"], 1)
97
98
99if __name__ == "__main__":
100 unittest.main()
diff --git a/tests/integration/test_announce.py b/tests/integration/test_announce.py
new file mode 100644
index 0000000..ffbc07b
--- /dev/null
+++ b/tests/integration/test_announce.py
@@ -0,0 +1,129 @@
1"""What a peer's announce does to the swarm it joins."""
2
3import os
4import socket
5import sys
6import unittest
7
8sys.path.insert(0, os.path.dirname(__file__))
9from harness import TrackerTestCase
10
11
12def peers_of(answer, key=b"peers"):
13 """Compact peer list as a list of (address, port)."""
14 blob, out = answer.get(key, b""), []
15 size = 6 if key == b"peers" else 18
16 for offset in range(0, len(blob), size):
17 entry = blob[offset : offset + size]
18 address = socket.inet_ntop(socket.AF_INET if size == 6 else socket.AF_INET6, entry[:-2])
19 out.append((address, entry[-2] << 8 | entry[-1]))
20 return out
21
22
23class AnnounceLifetime(TrackerTestCase):
24 config = ""
25
26 def test_a_leecher_is_counted_as_incomplete(self):
27 info_hash = self.hash_for("leecher")
28 answer = self.tracker.announce(info_hash, left=100)
29 self.assertEqual(answer[b"complete"], 0)
30 self.assertEqual(answer[b"incomplete"], 1)
31
32 def test_no_bytes_left_means_seeding(self):
33 info_hash = self.hash_for("seeder")
34 answer = self.tracker.announce(info_hash, left=0)
35 self.assertEqual(answer[b"complete"], 1)
36 self.assertEqual(answer[b"incomplete"], 0)
37
38 def test_a_peer_that_finishes_moves_from_one_column_to_the_other(self):
39 info_hash = self.hash_for("finishes")
40 peer_id = b"-OT0001-finisher0001"
41 self.tracker.announce(info_hash, peer_id=peer_id, left=100)
42 self.assertEqual(self.tracker.announce(info_hash, peer_id=peer_id, left=100)[b"incomplete"], 1)
43
44 answer = self.tracker.announce(info_hash, peer_id=peer_id, left=0, event="completed")
45 self.assertEqual(answer[b"complete"], 1, "the peer should now count as a seeder")
46 self.assertEqual(answer[b"incomplete"], 0, "and no longer as a leecher")
47
48 def test_stopped_removes_the_peer(self):
49 info_hash = self.hash_for("stopped")
50 peer_id = b"-OT0001-leaves000001"
51 self.tracker.announce(info_hash, peer_id=peer_id, left=100)
52 self.assertEqual(self.tracker.scrape(info_hash)[b"files"][info_hash][b"incomplete"], 1)
53
54 self.tracker.announce(info_hash, peer_id=peer_id, left=100, event="stopped")
55 self.assertEqual(
56 self.tracker.scrape(info_hash)[b"files"][info_hash][b"incomplete"], 0, "the peer should be gone"
57 )
58
59 def test_the_same_peer_announcing_twice_is_still_one_peer(self):
60 info_hash = self.hash_for("twice")
61 peer_id = b"-OT0001-repeats00001"
62 for _ in range(5):
63 answer = self.tracker.announce(info_hash, peer_id=peer_id, left=100)
64 self.assertEqual(answer[b"incomplete"], 1, "re-announcing must not duplicate the peer")
65
66 def test_a_peer_is_its_address_and_port_not_its_peer_id(self):
67 """Two announces from one address and port are one peer, whatever
68 peer_id they carry, because that pair is the whole identity."""
69 info_hash = self.hash_for("identity")
70 self.tracker.announce(info_hash, peer_id=b"-OT0001-a00000000001", port=6881, left=100)
71 answer = self.tracker.announce(info_hash, peer_id=b"-OT0001-b00000000002", port=6881, left=100)
72 self.assertEqual(answer[b"incomplete"], 1, "same address and port, so still one peer")
73
74 answer = self.tracker.announce(info_hash, peer_id=b"-OT0001-c00000000003", port=6882, left=100)
75 self.assertEqual(answer[b"incomplete"], 2, "a different port makes a different peer")
76
77 def test_a_downloaded_count_is_kept(self):
78 info_hash = self.hash_for("downloaded")
79 self.tracker.announce(info_hash, port=7001, left=0, event="completed")
80 self.tracker.announce(info_hash, port=7002, left=0, event="completed")
81 self.assertEqual(self.tracker.scrape(info_hash)[b"files"][info_hash][b"downloaded"], 2)
82
83 def test_a_peer_is_given_the_whole_swarm_including_itself(self):
84 """opentracker does not filter the announcing peer out of its own
85 answer. Clients are expected to ignore themselves."""
86 info_hash = self.hash_for("sees")
87 self.tracker.announce(info_hash, port=1111, left=100)
88 answer = self.tracker.announce(info_hash, port=2222, left=100)
89
90 ports = sorted(port for _, port in peers_of(answer))
91 self.assertEqual(ports, [1111, 2222])
92
93 def test_an_unknown_torrent_scrapes_as_empty(self):
94 answer = self.tracker.scrape(self.hash_for("never-seen"))
95 self.assertEqual(answer[b"files"], {}, "a torrent nobody announced has no entry")
96
97
98class AnnounceRejects(TrackerTestCase):
99 def assert_rejected(self, query):
100 import urllib.error
101
102 with self.assertRaises(urllib.error.HTTPError) as caught:
103 self.tracker.get("/announce?" + query)
104 self.assertEqual(caught.exception.code, 400)
105
106 def test_a_short_info_hash_is_rejected(self):
107 self.assert_rejected("info_hash=tooshort&peer_id=" + "b" * 20 + "&port=6881&left=0")
108
109 def test_a_missing_info_hash_answers_a_failure_reason(self):
110 """Not a 400: the tracker answers 200 with a bencoded complaint, so
111 that a client shows the message to its user."""
112 from harness import bdecode
113
114 answer = bdecode(self.tracker.get("/announce?peer_id=" + "b" * 20 + "&port=6881&left=0"))
115 self.assertIn(b"failure reason", answer)
116 self.assertIn(b"info_hash", answer[b"failure reason"])
117
118 def test_a_port_out_of_range_is_rejected(self):
119 self.assert_rejected("info_hash=" + "a" * 20 + "&peer_id=" + "b" * 20 + "&port=65536&left=0")
120
121 def test_a_port_that_is_not_a_number_is_rejected(self):
122 self.assert_rejected("info_hash=" + "a" * 20 + "&peer_id=" + "b" * 20 + "&port=abc&left=0")
123
124 def test_two_info_hashes_are_rejected(self):
125 self.assert_rejected("info_hash=" + "a" * 20 + "&info_hash=" + "c" * 20 + "&peer_id=" + "b" * 20 + "&port=6881&left=0")
126
127
128if __name__ == "__main__":
129 unittest.main()
diff --git a/tests/integration/test_dualstack.py b/tests/integration/test_dualstack.py
new file mode 100644
index 0000000..cd5ba7d
--- /dev/null
+++ b/tests/integration/test_dualstack.py
@@ -0,0 +1,89 @@
1"""A tracker serving both families from one socket.
2
3These need WANT_IP_FROM_QUERY_STRING so a peer's address can be dictated
4rather than being whatever loopback the test happened to come from.
5"""
6
7import os
8import socket
9import sys
10import unittest
11
12sys.path.insert(0, os.path.dirname(__file__))
13from harness import TrackerTestCase
14
15
16def addresses(answer, key):
17 blob, out = answer.get(key, b""), []
18 size = 6 if key == b"peers" else 18
19 for offset in range(0, len(blob), size):
20 entry = blob[offset : offset + size]
21 out.append(socket.inet_ntop(socket.AF_INET if size == 6 else socket.AF_INET6, entry[:-2]))
22 return out
23
24
25class DualStack(TrackerTestCase):
26 def test_an_ipv4_peer_lands_in_the_v4_list(self):
27 info_hash = self.hash_for("v4only")
28 self.tracker.announce(info_hash, ip="10.1.2.3", port=5001, left=100)
29 answer = self.tracker.announce(info_hash, ip="10.1.2.4", port=5002, left=100)
30
31 self.assertEqual(sorted(addresses(answer, b"peers")), ["10.1.2.3", "10.1.2.4"])
32 self.assertEqual(addresses(answer, b"peers6"), [], "no v6 peer was announced")
33
34 def test_an_ipv6_peer_lands_in_the_v6_list(self):
35 info_hash = self.hash_for("v6only")
36 self.tracker.announce(info_hash, ip="2001:db8::1", port=5001, left=100)
37 answer = self.tracker.announce(info_hash, ip="2001:db8::2", port=5002, left=100)
38
39 self.assertEqual(sorted(addresses(answer, b"peers6")), ["2001:db8::1", "2001:db8::2"])
40 self.assertEqual(addresses(answer, b"peers"), [], "no v4 peer was announced")
41
42 def test_the_two_families_are_kept_apart_but_both_returned(self):
43 info_hash = self.hash_for("both")
44 self.tracker.announce(info_hash, ip="10.9.9.9", port=6001, left=100)
45 answer = self.tracker.announce(info_hash, ip="2001:db8::9", port=6002, left=100)
46
47 self.assertEqual(addresses(answer, b"peers"), ["10.9.9.9"])
48 self.assertEqual(addresses(answer, b"peers6"), ["2001:db8::9"])
49
50 def test_a_v4_mapped_address_is_the_same_peer_as_the_plain_one(self):
51 info_hash = self.hash_for("v4mapped")
52 self.tracker.announce(info_hash, ip="10.5.5.5", port=7001, left=100)
53 answer = self.tracker.announce(info_hash, ip="::ffff:10.5.5.5", port=7001, left=100)
54
55 self.assertEqual(answer[b"incomplete"], 1, "the same address written two ways is one peer")
56 self.assertEqual(addresses(answer, b"peers"), ["10.5.5.5"], "and it is held as v4")
57
58 def test_the_two_families_count_into_one_swarm(self):
59 info_hash = self.hash_for("counts")
60 self.tracker.announce(info_hash, ip="10.4.4.4", port=8001, left=0)
61 answer = self.tracker.announce(info_hash, ip="2001:db8::4", port=8002, left=100)
62
63 self.assertEqual(answer[b"complete"], 1)
64 self.assertEqual(answer[b"incomplete"], 1)
65 self.assertEqual(self.tracker.scrape(info_hash)[b"files"][info_hash][b"complete"], 1)
66
67 def test_stopped_removes_only_the_family_it_came_from(self):
68 info_hash = self.hash_for("stopfamily")
69 self.tracker.announce(info_hash, ip="10.3.3.3", port=9001, left=100)
70 self.tracker.announce(info_hash, ip="2001:db8::3", port=9002, left=100)
71 self.tracker.announce(info_hash, ip="10.3.3.3", port=9001, left=100, event="stopped")
72
73 answer = self.tracker.announce(info_hash, ip="2001:db8::33", port=9003, left=100)
74 self.assertEqual(addresses(answer, b"peers"), [], "the v4 peer left")
75 self.assertIn("2001:db8::3", addresses(answer, b"peers6"), "the v6 one stayed")
76
77 def test_a_malformed_address_is_rejected(self):
78 import urllib.error
79
80 # The whole field has to be the address: a prefix that happens to
81 # parse would quietly record the peer somewhere else
82 for bad in ("999.1.2.3", "nonsense", "2001:db8:::1", "10.1.2.3.4", "10.1.2.3junk", "2001:db8::1%eth0"):
83 with self.assertRaises(urllib.error.HTTPError, msg="ip=%s should be refused" % bad) as caught:
84 self.tracker.announce(self.hash_for("badip"), ip=bad, port=6881, left=0)
85 self.assertEqual(caught.exception.code, 400)
86
87
88if __name__ == "__main__":
89 unittest.main()
diff --git a/tests/integration/test_fullscrape.py b/tests/integration/test_fullscrape.py
new file mode 100644
index 0000000..be7b502
--- /dev/null
+++ b/tests/integration/test_fullscrape.py
@@ -0,0 +1,146 @@
1"""The full scrape, in every format the tracker offers.
2
3Each format is a separate writer, and they differ in more than cosmetics:
4one is binary, one is bencoded, three are line based and one dumps the
5tracker's own state for a restart. A worker thread produces them in chunks,
6so these also exercise the chunked transfer the ordinary replies never take.
7"""
8
9import gzip
10import struct
11import unittest
12
13from harness import TrackerTestCase, bdecode
14
15HEX = b"0123456789ABCDEF"
16
17
18class FullScrape(TrackerTestCase):
19 def setUp(self):
20 TrackerTestCase.setUp(self)
21 self.seeding = self.hash_for("fsA")
22 self.leeching = self.hash_for("fsB")
23 self.tracker.announce(self.seeding, port=6001, left=0)
24 self.tracker.announce(self.leeching, port=6002, left=4096)
25
26 def scrape_all(self, query, headers=None):
27 body, head = self.tracker.get_with_headers(query, headers)
28 if head.get("Content-Encoding") == "gzip":
29 body = gzip.decompress(body)
30 return body
31
32 # --------------------------------------------------------- bencoded
33
34 def test_a_bare_scrape_returns_every_torrent(self):
35 answer = bdecode(self.scrape_all("/scrape"))
36 self.assertIn(self.seeding, answer[b"files"])
37 self.assertIn(self.leeching, answer[b"files"])
38 self.assertEqual(answer[b"files"][self.seeding][b"complete"], 1)
39 self.assertEqual(answer[b"files"][self.leeching][b"incomplete"], 1)
40
41 def test_the_bencoded_format_matches_the_bare_scrape(self):
42 self.assertEqual(bdecode(self.scrape_all("/stats?mode=tpbs&format=ben")), bdecode(self.scrape_all("/scrape")))
43
44 def test_a_full_scrape_can_be_gzipped(self):
45 plain = self.scrape_all("/scrape")
46 body, head = self.tracker.get_with_headers("/scrape", {"Accept-Encoding": "gzip"})
47 self.assertEqual(head.get("Content-Encoding"), "gzip")
48 self.assertEqual(gzip.decompress(body), plain)
49
50 # ----------------------------------------------------------- binary
51
52 def test_the_binary_format_is_a_hash_and_two_counts(self):
53 body = self.scrape_all("/stats?mode=tpbs&format=bin")
54 self.assertEqual(len(body) % 28, 0, "twenty byte hash plus two counts")
55 entries = {body[i : i + 20]: struct.unpack(">II", body[i + 20 : i + 28]) for i in range(0, len(body), 28)}
56 self.assertEqual(entries[self.seeding], (1, 0), "one seeder, no leechers")
57 self.assertEqual(entries[self.leeching], (0, 1), "no seeders, one leecher")
58
59 # ------------------------------------------------------- line based
60
61 def entries_from_lines(self, body):
62 return {line.split(b":")[0]: line.split(b":")[1:] for line in body.splitlines() if line}
63
64 def test_the_text_format_is_hex_colon_seeders_colon_leechers(self):
65 entries = self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txt"))
66 self.assertEqual(entries[self.seeding.hex().upper().encode()], [b"1", b"0"])
67 self.assertEqual(entries[self.leeching.hex().upper().encode()], [b"0", b"1"])
68
69 def test_the_text_plus_format_adds_the_completed_count(self):
70 entries = self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txtp"))
71 self.assertEqual(entries[self.seeding.hex().upper().encode()], [b"1", b"0", b"0"])
72
73 def test_the_urlencoded_format_escapes_the_hash(self):
74 body = self.scrape_all("/stats?mode=tpbs&format=url")
75 lines = [line for line in body.splitlines() if line]
76 self.assertEqual(len(lines), 2)
77 for line in lines:
78 escaped = line.rsplit(b":", 2)[0]
79 self.assertIn(b"%", escaped, "these hashes are mostly unprintable bytes")
80
81 def test_the_state_dump_carries_a_base_time_rather_than_a_peer_count(self):
82 """What a restart reads back, so its second column is the torrent's
83 own clock and not a count of anybody."""
84 entries = self.entries_from_lines(self.scrape_all("/stats?mode=statedump"))
85 self.assertIn(self.seeding.hex().upper().encode(), entries)
86 base, completed = entries[self.seeding.hex().upper().encode()]
87 self.assertGreater(int(base), 0)
88 self.assertEqual(int(completed), 0)
89
90 # ------------------------------------------------------------ shape
91
92 def test_every_format_sees_the_same_torrents(self):
93 counts = {
94 "ben": len(bdecode(self.scrape_all("/stats?mode=tpbs&format=ben"))[b"files"]),
95 "bin": len(self.scrape_all("/stats?mode=tpbs&format=bin")) // 28,
96 "txt": len(self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txt"))),
97 "txtp": len(self.entries_from_lines(self.scrape_all("/stats?mode=tpbs&format=txtp"))),
98 "url": len([l for l in self.scrape_all("/stats?mode=tpbs&format=url").splitlines() if l]),
99 }
100 self.assertEqual(set(counts.values()), {2}, "all formats, two torrents: %r" % counts)
101
102
103class FullScrapeChunking(TrackerTestCase):
104 def test_a_scrape_larger_than_one_chunk_arrives_whole(self):
105 """The writer hands the socket a chunk at a time, so a scrape that
106 spans several is the case where the seams show."""
107 for i in range(400):
108 self.tracker.announce(struct.pack(">I", i) + b"\0" * 16, port=7000 + i, left=0)
109 answer = bdecode(self.tracker.get("/scrape"))
110 self.assertEqual(len(answer[b"files"]), 400)
111
112
113if __name__ == "__main__":
114 unittest.main()
115
116
117class PartialScrapeOrder(TrackerTestCase):
118 """A bencoded dictionary names its keys in order, once each. The full
119 scrape got that from walking the buckets; the partial scrape used to
120 echo whatever order the client asked in."""
121
122 def setUp(self):
123 TrackerTestCase.setUp(self)
124 self.hashes = [bytes([i]) + b"\0" * 19 for i in (9, 1, 5, 3, 7)]
125 for i, h in enumerate(self.hashes):
126 self.tracker.announce(h, port=6001 + i, left=0)
127
128 def scrape_many(self, hashes):
129 import urllib.parse
130
131 query = "&".join("info_hash=" + urllib.parse.quote_from_bytes(h, safe="") for h in hashes)
132 return bdecode(self.tracker.get("/scrape?" + query))
133
134 def test_the_keys_come_back_in_order(self):
135 answer = self.scrape_many(self.hashes)
136 self.assertEqual(list(answer[b"files"]), sorted(self.hashes))
137
138 def test_reversing_the_question_does_not_reverse_the_answer(self):
139 self.assertEqual(
140 list(self.scrape_many(self.hashes[::-1])[b"files"]), list(self.scrape_many(self.hashes)[b"files"])
141 )
142
143 def test_a_hash_asked_for_twice_is_named_once(self):
144 asked = [self.hashes[0], self.hashes[1], self.hashes[0]]
145 answer = self.scrape_many(asked)
146 self.assertEqual(list(answer[b"files"]), sorted({self.hashes[0], self.hashes[1]}))
diff --git a/tests/integration/test_keepalive.py b/tests/integration/test_keepalive.py
new file mode 100644
index 0000000..01f02a5
--- /dev/null
+++ b/tests/integration/test_keepalive.py
@@ -0,0 +1,111 @@
1"""Holding a connection open across several requests.
2
3The reply path has two shapes. A small answer goes out in one write and the
4connection is done with immediately. A large one is queued and finishes on a
5later pass through the event loop, by which time the workstruct that served
6the request has moved on, so whether to keep the connection has to be
7remembered on the connection itself.
8"""
9
10import unittest
11import urllib.parse
12
13from harness import TrackerTestCase, bdecode
14
15
16class KeepAlive(TrackerTestCase):
17 def announce_path(self, name, port=6881):
18 return "/announce?info_hash=%s&peer_id=%s&port=%d&left=0" % (
19 urllib.parse.quote_from_bytes(self.hash_for(name), safe=""),
20 "x" * 20,
21 port,
22 )
23
24 def test_two_requests_over_one_connection(self):
25 c = self.tracker.connect()
26 try:
27 c.send(self.announce_path("kaA"))
28 status, _, first = c.reply()
29 self.assertIn(b"200", status)
30 c.send(self.announce_path("kaB"))
31 status, _, second = c.reply()
32 self.assertIn(b"200", status)
33 self.assertIn(b"complete", bdecode(second))
34 finally:
35 c.close()
36
37 def test_close_ends_the_connection(self):
38 c = self.tracker.connect()
39 try:
40 c.send(self.announce_path("kaclose"), connection="close")
41 c.reply()
42 self.assertFalse(c.is_open(), "the tracker was asked to hang up")
43 finally:
44 c.close()
45
46 def test_a_connection_survives_a_reply_too_large_for_one_write(self):
47 """The case that was broken: a queued reply finished draining and the
48 connection was closed regardless of what the client had asked for."""
49 for i in range(400):
50 self.tracker.announce(bytes([i // 256, i % 256]) + b"\0" * 18, port=7000 + i, left=0)
51
52 c = self.tracker.connect()
53 try:
54 c.send("/scrape")
55 status, headers, body = c.reply()
56 self.assertIn(b"200", status)
57 self.assertGreater(len(body), 16384, "this reply has to outgrow one write")
58
59 c.send(self.announce_path("kaafter"))
60 status, _, after = c.reply()
61 self.assertIn(b"200", status)
62 self.assertIn(b"complete", bdecode(after))
63 finally:
64 c.close()
65
66 def test_several_requests_in_a_row(self):
67 c = self.tracker.connect()
68 try:
69 for i in range(20):
70 c.send(self.announce_path("karow%d" % i))
71 status, _, body = c.reply()
72 self.assertIn(b"200", status)
73 self.assertEqual(self.tracker.torrent_count(), 20)
74 finally:
75 c.close()
76
77 def test_without_the_header_the_connection_is_not_held(self):
78 """HTTP/1.1 calls a connection persistent unless told otherwise; this
79 tracker holds one only when asked by name. Pinned as it is."""
80 c = self.tracker.connect()
81 try:
82 c.send(self.announce_path("kanone"), connection=None)
83 c.reply()
84 self.assertFalse(c.is_open())
85 finally:
86 c.close()
87
88 def test_the_error_path_does_not_hold_the_connection(self):
89 c = self.tracker.connect()
90 try:
91 c.send("/announce?info_hash=short")
92 status, _, _ = c.reply()
93 self.assertIn(b"400", status)
94 finally:
95 c.close()
96
97 def test_an_error_after_a_kept_connection_is_still_an_error(self):
98 """The keep-alive decision used to be taken after the handler had run,
99 so an error reply inherited whatever the previous request wanted."""
100 c = self.tracker.connect()
101 try:
102 c.send(self.announce_path("kabefore"))
103 self.assertIn(b"200", c.reply()[0])
104 c.send("/announce?info_hash=short")
105 self.assertIn(b"400", c.reply()[0])
106 finally:
107 c.close()
108
109
110if __name__ == "__main__":
111 unittest.main()
diff --git a/tests/integration/test_stats.py b/tests/integration/test_stats.py
new file mode 100644
index 0000000..820b917
--- /dev/null
+++ b/tests/integration/test_stats.py
@@ -0,0 +1,169 @@
1"""The /stats endpoint.
2
3Each mode is its own writer into a fixed buffer, so these pin the shape of
4every one of them and, where the number means something, that it moves when
5the thing it counts happens.
6"""
7
8import ipaddress
9import re
10import socket
11import unittest
12import urllib.error
13import xml.etree.ElementTree
14
15from harness import TrackerTestCase
16
17# The modes a default build answers. "tpbs" and "statedump" are full scrapes
18# and belong to test_fullscrape; "fulllog" and "woodpeckers" sit behind
19# feature flags this build does not set.
20MRTG_MODES = ["peer", "conn", "scrp", "udp4", "tcp4", "torr", "fscr", "completed", "syncs"]
21TEXT_MODES = ["herr", "s24s", "top10", "top100", "renew", "version"]
22
23
24class StatsShape(TrackerTestCase):
25 def test_every_mode_answers_something(self):
26 for mode in MRTG_MODES + TEXT_MODES + ["everything"]:
27 self.assertTrue(self.tracker.stats(mode), "mode %s answered empty" % mode)
28
29 def test_an_mrtg_mode_opens_with_two_numbers(self):
30 """MRTG reads the first two lines as counters and the rest as labels,
31 so the order matters to something outside this project."""
32 for mode in MRTG_MODES:
33 lines = self.tracker.stats(mode).splitlines()
34 self.assertGreaterEqual(len(lines), 2, "mode %s" % mode)
35 for line in lines[:2]:
36 self.assertRegex(line, rb"^\d+$", "mode %s line %r" % (mode, line))
37
38 def test_the_busy_mode_is_gone(self):
39 """It was listed in the mode table and implemented nowhere, so it only
40 ever answered 500. Removed rather than written: picking poisoning
41 networks out stopped being meaningful once consumer lines became bot
42 hosts and v6 made the address space too large to watch."""
43 with self.assertRaises(urllib.error.HTTPError) as caught:
44 self.tracker.stats("busy")
45 self.assertEqual(caught.exception.code, 400, "an unknown mode, like any other")
46
47 def test_an_unknown_mode_is_refused(self):
48 with self.assertRaises(urllib.error.HTTPError) as caught:
49 self.tracker.stats("nosuchmode")
50 self.assertEqual(caught.exception.code, 400)
51
52 def test_everything_is_well_formed_xml(self):
53 root = xml.etree.ElementTree.fromstring(self.tracker.stats("everything").decode())
54 self.assertEqual(root.tag, "stats")
55 self.assertIsNotNone(root.find("torrents/count_mutex"))
56 self.assertIsNotNone(root.find("connections/tcp/announce"))
57
58 def test_version_names_a_commit(self):
59 self.assertIn(b"opentracker", self.tracker.stats("version").lower() + b"opentracker")
60 self.assertRegex(self.tracker.stats("version"), rb"https?://")
61
62
63class StatsCount(TrackerTestCase):
64 def test_the_torrent_count_follows_the_torrents(self):
65 before = self.tracker.torrent_count()
66 self.tracker.announce(self.hash_for("statA"), port=6001, left=0)
67 self.assertEqual(self.tracker.torrent_count(), before + 1)
68 self.tracker.announce(self.hash_for("statB"), port=6002, left=0)
69 self.assertEqual(self.tracker.torrent_count(), before + 2)
70
71 def test_the_peer_count_separates_seeders_from_the_rest(self):
72 self.tracker.announce(self.hash_for("statpeers"), port=6001, left=0)
73 self.tracker.announce(self.hash_for("statpeers"), port=6002, left=4096)
74 peers, seeds = self.tracker.stats("peer").splitlines()[:2]
75 self.assertEqual(int(peers), 2)
76 self.assertEqual(int(seeds), 1)
77
78 def test_the_completed_count_follows_the_completed_event(self):
79 before = int(self.tracker.stats("completed").splitlines()[0])
80 self.tracker.announce(self.hash_for("statdone"), port=6001, left=0, event="completed")
81 self.assertEqual(int(self.tracker.stats("completed").splitlines()[0]), before + 1)
82
83 def test_a_bad_request_is_counted_as_an_http_error(self):
84 before = self.tracker.stats("herr")
85 try:
86 self.tracker.stats("nosuchmode")
87 except urllib.error.HTTPError:
88 pass
89 after = self.tracker.stats("herr")
90 self.assertNotEqual(before, after, "no error counter moved")
91
92 def test_the_tcp_counter_follows_tcp_requests(self):
93 before = int(self.tracker.stats("tcp4").splitlines()[0])
94 for _ in range(3):
95 self.tracker.announce(self.hash_for("stattcp"), port=6001, left=0)
96 self.assertGreaterEqual(int(self.tracker.stats("tcp4").splitlines()[0]), before + 3)
97
98 def test_the_udp_counter_follows_udp_requests(self):
99 before = int(self.tracker.stats("udp4").splitlines()[0])
100 sock, connection_id = self.tracker.udp_connect()
101 try:
102 self.tracker.udp_announce(sock, connection_id, self.hash_for("statudp"), left=0)
103 finally:
104 sock.close()
105 self.assertGreater(int(self.tracker.stats("udp4").splitlines()[0]), before)
106
107 def test_the_scrape_counter_follows_scrapes(self):
108 before = int(self.tracker.stats("scrp").splitlines()[0])
109 self.tracker.scrape(self.hash_for("statscrape"))
110 self.assertEqual(int(self.tracker.stats("scrp").splitlines()[0]), before + 1)
111
112
113class StatsTop(TrackerTestCase):
114 def test_the_top_list_ranks_by_peer_count(self):
115 busy = self.hash_for("topbusy")
116 quiet = self.hash_for("topquiet")
117 for port in range(6001, 6006):
118 self.tracker.announce(busy, port=port, left=0)
119 self.tracker.announce(quiet, port=6100, left=0)
120
121 body = self.tracker.stats("top10")
122 self.assertIn(b"Top 10 torrents by peers", body)
123 ranked = re.findall(rb"\t(\d+)\t([0-9A-F]{40})", body.split(b"by seeds")[0])
124 self.assertEqual(ranked[0][1], busy.hex().upper().encode(), "the busy torrent ranks first")
125 self.assertEqual(int(ranked[0][0]), 5)
126
127 def test_the_top_list_has_three_sections(self):
128 self.tracker.announce(self.hash_for("topsections"), port=6001, left=0)
129 body = self.tracker.stats("top100")
130 for heading in (b"by peers", b"by seeds", b"by leechers"):
131 self.assertIn(heading, body)
132
133
134class StatsNetworks(TrackerTestCase):
135 def source_address(self):
136 """Which address the tracker sees us from is not ours to choose: a
137 FreeBSD jail relocates a loopback connection to the jail's own
138 address, so ask the socket rather than assume 127.0.0.1."""
139 probe = socket.create_connection(("127.0.0.1", self.tracker.port), timeout=5)
140 try:
141 return probe.getsockname()[0]
142 finally:
143 probe.close()
144
145 def test_the_slash24_report_names_the_announcing_network(self):
146 source = self.source_address()
147 self.tracker.announce(self.hash_for("s24"), port=6001, left=0)
148 body = self.tracker.stats("s24s")
149 self.assertIn(b"Networks, limit /28:", body)
150 self.assertIn(b"Networks, limit /24:", body)
151
152 # The prefix is the first three address bytes of a v4 peer, left
153 # aligned in a sixteen byte buffer and then printed by the v6
154 # formatter, so 127.0.0.0/24 reads as "7f00::" rather than as the
155 # network an operator asked about. Pinned as it is, not blessed.
156 prefix = socket.inet_aton(source)[:3] + b"\0" * 13
157 self.assertIn(str(ipaddress.IPv6Address(prefix)).encode(), body)
158
159 def test_the_report_counts_one_network_per_announcing_address(self):
160 for port in (6001, 6002, 6003):
161 self.tracker.announce(self.hash_for("s24count"), port=port, left=0)
162 lines = [line for line in self.tracker.stats("s24s").splitlines() if b":" in line and b"limit" not in line]
163 self.assertEqual(len(lines), 2, "one line under each of the two limits")
164 for line in lines:
165 self.assertEqual(int(line.split(b":")[0]), 3, "three peers from the one network")
166
167
168if __name__ == "__main__":
169 unittest.main()
diff --git a/tests/integration/test_udp.py b/tests/integration/test_udp.py
new file mode 100644
index 0000000..1cb2389
--- /dev/null
+++ b/tests/integration/test_udp.py
@@ -0,0 +1,168 @@
1"""The UDP tracker protocol, BEP 15.
2
3The HTTP tests cover the torrent store; these cover the packet handling in
4front of it, which has its own framing, its own connection id and its own
5rules about what to answer and what to ignore.
6"""
7
8import socket
9import struct
10import unittest
11
12from harness import TrackerTestCase
13
14MAGIC = 0x41727101980
15
16
17class UdpProtocol(TrackerTestCase):
18 def test_connect_echoes_the_transaction_id(self):
19 sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
20 sock.settimeout(5)
21 try:
22 sock.sendto(struct.pack(">QII", MAGIC, 0, 0xDEADBEEF), ("127.0.0.1", self.tracker.port))
23 action, transaction, connection_id = struct.unpack(">IIQ", sock.recv(2048)[:16])
24 self.assertEqual(action, 0)
25 self.assertEqual(transaction, 0xDEADBEEF)
26 self.assertNotEqual(connection_id, 0)
27 finally:
28 sock.close()
29
30 def test_the_connection_id_is_stable_for_one_peer(self):
31 first, id_one = self.tracker.udp_connect()
32 second, id_two = self.tracker.udp_connect()
33 try:
34 self.assertEqual(id_one, id_two)
35 finally:
36 first.close()
37 second.close()
38
39 def test_a_wrong_connection_id_is_refused(self):
40 payload = struct.pack(">QII20s20sQQQIIIiH", 0, 1, 2, self.hash_for("udpbad"), b"p" * 20, 0, 0, 0, 0, 0, 0, -1, 6881)
41 answer = self.tracker.udp_raw(payload)
42 self.assertIsNotNone(answer, "expected an error packet")
43 action, transaction = struct.unpack(">II", answer[:8])
44 self.assertEqual(action, 3, "action 3 is the error packet")
45 self.assertIn(b"Connection ID missmatch", answer[8:])
46
47 def test_a_packet_shorter_than_the_header_is_ignored(self):
48 self.assertIsNone(self.tracker.udp_raw(b"\x00" * 15))
49
50 def test_an_unknown_action_is_ignored(self):
51 self.assertIsNone(self.tracker.udp_raw(struct.pack(">QII", MAGIC, 9, 1)))
52
53 def test_connect_requires_the_protocol_magic(self):
54 self.assertIsNone(self.tracker.udp_raw(struct.pack(">QII", MAGIC + 1, 0, 1)))
55
56 def test_an_announce_cut_short_is_ignored(self):
57 sock, connection_id = self.tracker.udp_connect()
58 try:
59 self.assertIsNone(self.tracker.udp_raw(struct.pack(">QII", connection_id, 1, 1) + b"\0" * 40))
60 finally:
61 sock.close()
62
63
64class UdpAnnounce(TrackerTestCase):
65 def test_a_seeder_is_counted_as_a_seeder(self):
66 sock, connection_id = self.tracker.udp_connect()
67 try:
68 answer = self.tracker.udp_announce(sock, connection_id, self.hash_for("udpseed"), left=0)
69 self.assertEqual(answer["seeders"], 1)
70 self.assertEqual(answer["leechers"], 0)
71 self.assertGreater(answer["interval"], 0)
72 finally:
73 sock.close()
74
75 def test_a_leecher_is_counted_as_a_leecher(self):
76 sock, connection_id = self.tracker.udp_connect()
77 try:
78 answer = self.tracker.udp_announce(sock, connection_id, self.hash_for("udpleech"), left=1024)
79 self.assertEqual(answer["seeders"], 0)
80 self.assertEqual(answer["leechers"], 1)
81 finally:
82 sock.close()
83
84 def test_stopped_removes_the_peer(self):
85 info_hash = self.hash_for("udpstop")
86 sock, connection_id = self.tracker.udp_connect()
87 try:
88 self.tracker.udp_announce(sock, connection_id, info_hash, left=0)
89 self.tracker.udp_announce(sock, connection_id, info_hash, left=0, event=3)
90 answer = self.tracker.udp_announce(sock, connection_id, info_hash, left=1024)
91 self.assertEqual(answer["seeders"], 0, "the seeder announced stopped")
92 finally:
93 sock.close()
94
95 def test_an_announce_over_udp_is_visible_over_http(self):
96 """One torrent store behind both bearers, not two."""
97 info_hash = self.hash_for("udpcross")
98 sock, connection_id = self.tracker.udp_connect()
99 try:
100 self.tracker.udp_announce(sock, connection_id, info_hash, left=0)
101 finally:
102 sock.close()
103 scrape = self.tracker.scrape(info_hash)
104 self.assertEqual(scrape[b"files"][info_hash][b"complete"], 1)
105
106 def test_the_peer_list_carries_the_announcing_peer(self):
107 info_hash = self.hash_for("udppeers")
108 sock, connection_id = self.tracker.udp_connect()
109 try:
110 answer = self.tracker.udp_announce(sock, connection_id, info_hash, left=0, port=6881)
111 self.assertEqual(len(answer["peers"]) % 6, 0, "v4 peers are six bytes each")
112 self.assertGreaterEqual(len(answer["peers"]), 6)
113 self.assertEqual(struct.unpack(">H", answer["peers"][4:6])[0], 6881)
114 finally:
115 sock.close()
116
117
118class UdpScrape(TrackerTestCase):
119 def test_scrape_reports_the_swarm(self):
120 info_hash = self.hash_for("udpscrape")
121 sock, connection_id = self.tracker.udp_connect()
122 try:
123 self.tracker.udp_announce(sock, connection_id, info_hash, left=0)
124 seeders, completed, leechers = self.tracker.udp_scrape(sock, connection_id, [info_hash])[0]
125 self.assertEqual(seeders, 1)
126 self.assertEqual(leechers, 0)
127 self.assertEqual(completed, 0)
128 finally:
129 sock.close()
130
131 def test_an_unknown_hash_scrapes_as_empty(self):
132 sock, connection_id = self.tracker.udp_connect()
133 try:
134 self.assertEqual(self.tracker.udp_scrape(sock, connection_id, [self.hash_for("udpnone")])[0], (0, 0, 0))
135 finally:
136 sock.close()
137
138 def test_several_hashes_answer_in_order(self):
139 seeding = self.hash_for("udpmultiA")
140 leeching = self.hash_for("udpmultiB")
141 sock, connection_id = self.tracker.udp_connect()
142 try:
143 self.tracker.udp_announce(sock, connection_id, seeding, left=0)
144 self.tracker.udp_announce(sock, connection_id, leeching, left=1024)
145 answers = self.tracker.udp_scrape(sock, connection_id, [seeding, leeching])
146 self.assertEqual(answers[0][0], 1, "first hash is the seeded one")
147 self.assertEqual(answers[1][2], 1, "second hash is the leeched one")
148 finally:
149 sock.close()
150
151
152class UdpWorkersConfiguredAfterTheListener(TrackerTestCase):
153 """The harness writes its listen lines first, so this directive lands
154 below them: the position a config file should not care about."""
155
156 config = "listen.udp.workers 4\n"
157
158 def test_the_tracker_starts_and_still_answers_udp(self):
159 sock, connection_id = self.tracker.udp_connect()
160 try:
161 answer = self.tracker.udp_announce(sock, connection_id, self.hash_for("udpworkers"), left=0)
162 self.assertEqual(answer["seeders"], 1)
163 finally:
164 sock.close()
165
166
167if __name__ == "__main__":
168 unittest.main()
diff --git a/tests/runner.c b/tests/runner.c
index c2350c3..71a5e2a 100644
--- a/tests/runner.c
+++ b/tests/runner.c
@@ -4,7 +4,11 @@
4 $id$ */ 4 $id$ */
5 5
6/* System */ 6/* System */
7#include <stdio.h>
8#include <stdlib.h>
7#include <string.h> 9#include <string.h>
10#include <sys/wait.h>
11#include <unistd.h>
8 12
9/* Opentracker */ 13/* Opentracker */
10#include "test.h" 14#include "test.h"
@@ -27,18 +31,83 @@ static const struct module modules[] = {{"ot_urlencoded", test_urlencoded},
27 {"ot_batch", test_batch}, 31 {"ot_batch", test_batch},
28 {"ot_fdstate", test_fdstate}, 32 {"ot_fdstate", test_fdstate},
29 {"ot_event", test_event}, 33 {"ot_event", test_event},
34 {"ot_fmt", test_fmt},
30 {"ot_accesslist", test_accesslist}, 35 {"ot_accesslist", test_accesslist},
31 {NULL, NULL}}; 36 {NULL, NULL}};
32 37
33/* Run every module, or only those whose name contains argv[1] */ 38struct tally {
39 unsigned long checks;
40 unsigned long failures;
41};
42
43/* Each module runs in a child of its own, so that a crash or a sanitizer
44 abort costs that module's results rather than every module after it. The
45 child reports its tally through a pipe; one that dies without reporting is
46 counted as a failure under its own name. */
47static void run_forked(const struct module *m) {
48 struct tally tally = {0, 0};
49 int pipefd[2], status = 0;
50 pid_t child;
51 ssize_t got;
52
53 if (pipe(pipefd)) {
54 test_failf(__FILE__, __LINE__, "pipe failed, cannot isolate %s", m->name);
55 return;
56 }
57
58 fflush(stdout); /* or the child inherits a copy and prints it again */
59 child = fork();
60 if (child < 0) {
61 close(pipefd[0]);
62 close(pipefd[1]);
63 test_failf(__FILE__, __LINE__, "fork failed, cannot isolate %s", m->name);
64 return;
65 }
66
67 if (!child) {
68 close(pipefd[0]);
69 test_checks = test_failures = 0;
70 m->run();
71 tally.checks = test_checks;
72 tally.failures = test_failures;
73 if (write(pipefd[1], &tally, sizeof(tally)) != (ssize_t)sizeof(tally))
74 _exit(1);
75 fflush(stdout);
76 _exit(0);
77 }
78
79 close(pipefd[1]);
80 got = read(pipefd[0], &tally, sizeof(tally));
81 close(pipefd[0]);
82 while (waitpid(child, &status, 0) < 0)
83 ;
84
85 if (got == (ssize_t)sizeof(tally)) {
86 test_checks += tally.checks;
87 test_failures += tally.failures;
88 }
89 if (got != (ssize_t)sizeof(tally) || !WIFEXITED(status) || WEXITSTATUS(status))
90 test_failf(__FILE__, __LINE__, "%s did not run to the end", m->name);
91}
92
93/* Run every module, or only those whose name contains argv[1].
94 OT_TEST_FORK=0 keeps everything in one process, which is what a debugger
95 wants. */
34int main(int argc, char **argv) { 96int main(int argc, char **argv) {
97 const char *fork_setting = getenv("OT_TEST_FORK");
98 int isolate = !fork_setting || strcmp(fork_setting, "0");
35 const struct module *m; 99 const struct module *m;
36 100
37 for (m = modules; m->name; ++m) { 101 for (m = modules; m->name; ++m) {
38 if (argc > 1 && !strstr(m->name, argv[1])) 102 if (argc > 1 && !strstr(m->name, argv[1]))
39 continue; 103 continue;
40 test_module(m->name); 104 test_module(m->name);
41 m->run(); 105 if (!isolate) {
106 m->run();
107 continue;
108 }
109 test_case("the module runs to the end");
110 run_forked(m);
42 } 111 }
43 112
44 return test_summary(); 113 return test_summary();
diff --git a/tests/t_fmt.c b/tests/t_fmt.c
new file mode 100644
index 0000000..bc23ed5
--- /dev/null
+++ b/tests/t_fmt.c
@@ -0,0 +1,108 @@
1/* This software was written by Dirk Engling <erdgeist@erdgeist.org>
2 It is considered beerware. Prost. Skol. Cheers or whatever.
3
4 $id$ */
5
6/* System */
7#include <string.h>
8
9/* Opentracker */
10#include "ot_fmt.h"
11#include "test.h"
12
13static void case_appends_in_sequence(void) {
14 char buffer[64];
15 char *r = buffer, *re = buffer + sizeof(buffer);
16
17 test_case("appends follow each other and advance the cursor");
18 fmt_append(&r, re, "d8:completei%zde", (size_t)7);
19 fmt_append(&r, re, "ee");
20 *r = 0;
21 CHECK_STR(buffer, "d8:completei7eee");
22 CHECK_INT(r - buffer, 16);
23}
24
25static void case_truncation_stops_at_the_end(void) {
26 char buffer[16];
27 char *r = buffer, *re = buffer + 8; /* pretend the buffer ends early */
28
29 memset(buffer, '#', sizeof(buffer));
30 test_case("a value too long for the room left stops at the end");
31 fmt_append(&r, re, "%s", "0123456789");
32 CHECK_PTR(r, re);
33
34 test_case("and writes nothing past it");
35 CHECK_INT(buffer[8], '#');
36 CHECK_INT(buffer[15], '#');
37}
38
39static void case_a_full_buffer_takes_nothing_more(void) {
40 char buffer[16];
41 char *r = buffer + 8, *re = buffer + 8;
42
43 memset(buffer, '#', sizeof(buffer));
44 test_case("appending to a cursor already at the end changes nothing");
45 fmt_append(&r, re, "more");
46 CHECK_PTR(r, re);
47 CHECK_INT(buffer[8], '#');
48}
49
50static void case_an_append_that_just_fits(void) {
51 char buffer[8];
52 char *r = buffer, *re = buffer + 3;
53
54 memset(buffer, '#', sizeof(buffer));
55 test_case("three characters into three bytes of room still truncate");
56 fmt_append(&r, re, "abc");
57 CHECK_PTR(r, re);
58 CHECK_INT(buffer[0], 'a');
59 CHECK_INT(buffer[1], 'b');
60
61 test_case("because snprintf spends the last byte on its terminator");
62 CHECK_INT(buffer[2], 0);
63 CHECK_INT(buffer[3], '#');
64}
65
66static void case_bytes_are_all_or_nothing(void) {
67 char buffer[16];
68 char *r = buffer, *re = buffer + 8;
69
70 memset(buffer, '#', sizeof(buffer));
71 test_case("a run of bytes is written when it fits");
72 fmt_bytes(&r, re, "\0\1\2\3", 4);
73 CHECK_PTR(r, buffer + 4);
74 CHECK_MEM(buffer, "\0\1\2\3", 4);
75
76 test_case("a run that would not fit is refused whole");
77 fmt_bytes(&r, re, "abcde", 5);
78 CHECK_PTR(r, re);
79 CHECK_INT(buffer[4], '#');
80
81 test_case("a run of exactly the room left is written");
82 r = buffer + 4;
83 fmt_bytes(&r, re, "wxyz", 4);
84 CHECK_PTR(r, re);
85 CHECK_MEM(buffer + 4, "wxyz", 4);
86 CHECK_INT(buffer[8], '#');
87}
88
89static void case_bytes_do_not_terminate(void) {
90 char buffer[8];
91 char *r = buffer, *re = buffer + 4;
92
93 memset(buffer, '#', sizeof(buffer));
94 test_case("unlike an append, a run of bytes may fill the room exactly");
95 fmt_bytes(&r, re, "abcd", 4);
96 CHECK_PTR(r, re);
97 CHECK_MEM(buffer, "abcd", 4);
98 CHECK_INT(buffer[4], '#');
99}
100
101void test_fmt(void) {
102 case_appends_in_sequence();
103 case_truncation_stops_at_the_end();
104 case_a_full_buffer_takes_nothing_more();
105 case_an_append_that_just_fits();
106 case_bytes_are_all_or_nothing();
107 case_bytes_do_not_terminate();
108}
diff --git a/tests/t_vector.c b/tests/t_vector.c
index 541a6da..6e048e1 100644
--- a/tests/t_vector.c
+++ b/tests/t_vector.c
@@ -112,6 +112,51 @@ static void case_insert_keeps_order(void) {
112 free(vector.data); 112 free(vector.data);
113} 113}
114 114
115/* The peer variant is reached with an empty vector on every first announce
116 for a torrent, and again whenever a swarm empties and refills, because
117 vector_fixup_peers drops the allocation and nulls the data pointer. */
118
119static void case_insert_peer_into_empty_vector(void) {
120 ot_vector vector = {NULL, 0, 0};
121 ot_peer6 low, high;
122 int exactmatch;
123 ot_peer *slot;
124
125 memset(low, 0x11, sizeof(low));
126 memset(high, 0x99, sizeof(high));
127
128 test_case("the first peer goes into a vector that has no allocation yet");
129 slot = vector_find_or_insert_peer(&vector, low, OT_PEER_SIZE6, &exactmatch);
130 CHECK(slot != NULL);
131 CHECK_INT(exactmatch, 0);
132 memcpy(slot, low, OT_PEER_SIZE6);
133 CHECK_INT(vector.size, 1);
134 CHECK_INT(vector.space, OT_VECTOR_MIN_MEMBERS);
135 CHECK_PTR(slot, vector.data);
136
137 test_case("a second peer keeps the order");
138 slot = vector_find_or_insert_peer(&vector, high, OT_PEER_SIZE6, &exactmatch);
139 CHECK(slot != NULL);
140 CHECK_INT(exactmatch, 0);
141 memcpy(slot, high, OT_PEER_SIZE6);
142 CHECK_INT(vector.size, 2);
143 CHECK_MEM((char *)vector.data, low, OT_PEER_COMPARE_SIZE6);
144 CHECK_MEM((char *)vector.data + OT_PEER_SIZE6, high, OT_PEER_COMPARE_SIZE6);
145
146 test_case("and emptying the vector hands the allocation back");
147 CHECK(vector_remove_peer(&vector, low, OT_PEER_SIZE6) != 0);
148 CHECK(vector_remove_peer(&vector, high, OT_PEER_SIZE6) != 0);
149 CHECK_INT(vector.size, 0);
150 CHECK_PTR(vector.data, NULL);
151
152 test_case("so the next peer starts from no allocation again");
153 slot = vector_find_or_insert_peer(&vector, low, OT_PEER_SIZE6, &exactmatch);
154 CHECK(slot != NULL);
155 CHECK_INT(vector.size, 1);
156
157 free(vector.data);
158}
159
115static void case_insert_finds_existing(void) { 160static void case_insert_finds_existing(void) {
116 ot_vector vector = {NULL, 0, 0}; 161 ot_vector vector = {NULL, 0, 0};
117 const unsigned char five[4] = {0, 0, 0, 5}; 162 const unsigned char five[4] = {0, 0, 0, 5};
@@ -257,6 +302,7 @@ void test_vector(void) {
257 case_search_past_the_end(); 302 case_search_past_the_end();
258 case_search_empty(); 303 case_search_empty();
259 case_insert_keeps_order(); 304 case_insert_keeps_order();
305 case_insert_peer_into_empty_vector();
260 case_insert_finds_existing(); 306 case_insert_finds_existing();
261 case_insert_compare_prefix(); 307 case_insert_compare_prefix();
262 case_redistribute_holds_every_peer(); 308 case_redistribute_holds_every_peer();
diff --git a/tests/test.h b/tests/test.h
index aa4eb00..0eddc82 100644
--- a/tests/test.h
+++ b/tests/test.h
@@ -90,5 +90,6 @@ void test_mutex(void);
90void test_batch(void); 90void test_batch(void);
91void test_fdstate(void); 91void test_fdstate(void);
92void test_event(void); 92void test_event(void);
93void test_fmt(void);
93 94
94#endif 95#endif