summaryrefslogtreecommitdiff
path: root/tests/integration
diff options
context:
space:
mode:
authorDirk Engling <erdgeist@erdgeist.org>2026-10-26 23:08:23 +0100
committerDirk Engling <erdgeist@erdgeist.org>2026-10-26 23:08:23 +0100
commit687ef7fece04fb5dffa90b5d784fb1e34066c85a (patch)
treefb103f637c1294ea356c2e94854a316352403418 /tests/integration
parent1f9494979268b62ed4b4588fb7d9e790c0e12100 (diff)
Add integration test and its runner
Diffstat (limited to 'tests/integration')
-rw-r--r--tests/integration/.gitignore2
-rw-r--r--tests/integration/harness.py166
-rw-r--r--tests/integration/test_announce.py129
-rw-r--r--tests/integration/test_dualstack.py89
4 files changed, 386 insertions, 0 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..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]
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()