summaryrefslogtreecommitdiff
path: root/tests/integration/test_stats.py
diff options
context:
space:
mode:
Diffstat (limited to 'tests/integration/test_stats.py')
-rw-r--r--tests/integration/test_stats.py169
1 files changed, 169 insertions, 0 deletions
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()