mirror of
https://github.com/progval/irctest.git
synced 2025-04-05 14:59:49 +00:00
Compare commits
601 Commits
master-ori
...
named-mode
Author | SHA1 | Date | |
---|---|---|---|
302dd33990 | |||
00663f15ec | |||
36901c1433 | |||
558add5229 | |||
805635c839 | |||
e1ff9fd7fe | |||
c3aa97c428 | |||
3692f2d79d | |||
04d0c8000f | |||
ecc560adeb | |||
c58167b42d | |||
34c78e5d2f | |||
1c6a7188d6 | |||
50d3a8e6da | |||
fe24e4b8b8 | |||
360a853bca | |||
653d818421 | |||
10e07aa800 | |||
b28820e562 | |||
cb147f46eb | |||
a950c724bb | |||
7255d65514 | |||
61fb287280 | |||
d190a91960 | |||
59b2cd729b | |||
e38f29befa | |||
2e45f7bfdb | |||
7bc8a81f8a | |||
4ee9c9c53a | |||
321e254d15 | |||
e5f22e8080 | |||
5a5dbdb50d | |||
52c22236a6 | |||
22c6743b24 | |||
b04db62a9b | |||
5ec44e1417 | |||
2fb8ed4000 | |||
79bbdd2948 | |||
a03e9bb8ea | |||
9b9cfdb2bf | |||
bb8a6b6c3d | |||
297bf2c554 | |||
05e9b3746e | |||
3b7f81e22c | |||
6edf4e27f1 | |||
11dc5b046e | |||
ddb37d6c3f | |||
aed6478a2c | |||
418b526033 | |||
136a7923c0 | |||
5364f963ae | |||
1ea3e1c15c | |||
8530c85adc | |||
6815dd238b | |||
00562ff82d | |||
b7e8a7a5f5 | |||
6181dd07ad | |||
5fe4d4cfd8 | |||
544ca4b7ed | |||
35d342a478 | |||
29e4c2bbdb | |||
fd0b050686 | |||
d0645ab1a8 | |||
65d7e0e506 | |||
690aaf24a1 | |||
40385c112b | |||
9d4212504b | |||
cae3aec338 | |||
c1442c4301 | |||
507f5b7426 | |||
dbdadec677 | |||
6290825c64 | |||
f1c9218fbb | |||
6b6017b40c | |||
601f49a9ef | |||
e205cc1531 | |||
8a4f254a21 | |||
81dac6f582 | |||
53710779f0 | |||
058fab85b0 | |||
683f7c0a15 | |||
0f100a5c80 | |||
83017483ba | |||
627f0b6415 | |||
7ccf5a4f9e | |||
641bea5f0a | |||
8c73ac2b75 | |||
ca35069487 | |||
011bdff7e4 | |||
c4d86aef4e | |||
c0af9bc0a8 | |||
a15025a276 | |||
a923353ec4 | |||
45dd42e682 | |||
5122c04826 | |||
9bc331483a | |||
2cd5fc1dca | |||
778510e021 | |||
8e2670df54 | |||
1e01cb3286 | |||
83867dad32 | |||
94cd2d5437 | |||
a39ce7f19b | |||
363b62cc80 | |||
6539ed881a | |||
3ab31ca4de | |||
82928bc6fc | |||
47db85f026 | |||
2bc68a2208 | |||
10b6f8d6da | |||
fc4e31e099 | |||
d90264ca9f | |||
0d64e5c1e2 | |||
09c31f428a | |||
e92aee012b | |||
358b6c2213 | |||
a3f0d42248 | |||
397509a282 | |||
107af942e9 | |||
93c454c99b | |||
d24f0b4f12 | |||
ca9ec1733c | |||
a7d3fadd8b | |||
edf3e5904b | |||
3083aeeb24 | |||
ebd7edcc74 | |||
9a19416731 | |||
f52f21897b | |||
af001fad2e | |||
a9a7a2a187 | |||
72a12ff5ce | |||
3f483243d9 | |||
491f92ca60 | |||
7608ea5145 | |||
256a8641ec | |||
f606c075f7 | |||
b63ead9546 | |||
7b38c2be8a | |||
c47b057546 | |||
2af62461bc | |||
69c5dca4b9 | |||
ee8f60d6c2 | |||
8356ace014 | |||
2a4e71eccd | |||
66c457f6ce | |||
7e112359a2 | |||
da005d7d24 | |||
50b9358ed0 | |||
2a62040b4f | |||
93f70c54d2 | |||
da8a6d1f98 | |||
79c65cf248 | |||
d34175d6a8 | |||
6b1084face | |||
1371979ccd | |||
88a8f8ad8d | |||
255ef1e469 | |||
cac4428cbd | |||
8240cd95cf | |||
e8486913a0 | |||
c826dd6c2e | |||
6c393c4e00 | |||
05e78802ca | |||
16533de157 | |||
d29c0035e6 | |||
18befc9e96 | |||
2684e7edb7 | |||
b895539bdd | |||
e89584b28e | |||
9ade524447 | |||
39587c3c49 | |||
3b96b5992c | |||
59a8a3e270 | |||
144c3a04b4 | |||
5e4ae7c999 | |||
29bfb064e9 | |||
33f0702c26 | |||
f86e11a288 | |||
3630a25c11 | |||
50b253fda8 | |||
5e33a82af6 | |||
8bc9c5b057 | |||
e03bb4734d | |||
cc3d70c7d3 | |||
ff0d795485 | |||
23c7c1642b | |||
03a401f911 | |||
15d21f4ee4 | |||
6106fc3b98 | |||
44ce324c7c | |||
a9e6605640 | |||
125a1cc106 | |||
7e2940d820 | |||
7d7df34fe5 | |||
de66606b4f | |||
57a08a0a57 | |||
3cca1ce29e | |||
3fb8cbc3ff | |||
6641b3245f | |||
7a8acb44cf | |||
9b02222c4c | |||
fe977cf361 | |||
c9911da9b8 | |||
1a66d706e7 | |||
f61e3ee608 | |||
370d6a3854 | |||
56906302b7 | |||
0cf9c37950 | |||
42e10c3848 | |||
a624bf6db8 | |||
8744a49073 | |||
d130ae89f2 | |||
dfaec16c47 | |||
84d667e95e | |||
42582f430a | |||
c37ed0f218 | |||
299c915505 | |||
a43ae63beb | |||
9de76b6063 | |||
ec386a1fc9 | |||
93a989b746 | |||
f2c80a2e96 | |||
2343930419 | |||
3289c64199 | |||
f63de0548a | |||
aee6750e7d | |||
59c7252da1 | |||
4fb7ebcd2c | |||
77272f83fb | |||
b780513e82 | |||
b845642d07 | |||
4fcc13d9c1 | |||
13b4806908 | |||
37ad8789f1 | |||
b2a2664de2 | |||
b0873d04cb | |||
8ddf39bd91 | |||
9b18d68707 | |||
a637ae3927 | |||
a29b7c5631 | |||
7e024b9ead | |||
54a911c2f5 | |||
e8dde0e989 | |||
314439787a | |||
7c32d47713 | |||
bfa183e37e | |||
853f9c4a8b | |||
d17cae6a8a | |||
91efa8b001 | |||
08a74096d0 | |||
450a413036 | |||
15f9875ae5 | |||
92a73ad4a5 | |||
0177c369dd | |||
ed2b75534e | |||
829cfbf404 | |||
06f053bf61 | |||
0b17fc8460 | |||
61974e6d0c | |||
4932d410e2 | |||
9581ca0cf3 | |||
8288e36469 | |||
76eaef39b8 | |||
f420b6cb0a | |||
26fe83d2c6 | |||
d7d6f0c521 | |||
e17234c911 | |||
fc07fa7d96 | |||
63f4130ab5 | |||
4271d5d986 | |||
d74b0e74c6 | |||
83152bdc24 | |||
4be59a77ed | |||
c4d19d44e8 | |||
3fafc76baa | |||
42225a68b7 | |||
5674bb030a | |||
a1040a4553 | |||
f83f2a4edf | |||
2d2e788275 | |||
cd58d14608 | |||
2972706ca6 | |||
26a0245a6a | |||
98824a4abd | |||
cccb937068 | |||
50cd718871 | |||
342ffcbdbe | |||
e340f86468 | |||
3d2399f62e | |||
76db5758e9 | |||
c5037e8ec9 | |||
7ee3c562d1 | |||
48eeeb7312 | |||
7ac4d7f80f | |||
b3d775f0d6 | |||
cc8b9748a7 | |||
829edddeb8 | |||
65b479c609 | |||
6458586179 | |||
eef07da56a | |||
60e6c013b2 | |||
14ee59ca84 | |||
a29e46c17a | |||
6f68a0d601 | |||
50b36f281d | |||
a7d7436929 | |||
c3b7663e06 | |||
7be29ad801 | |||
6bdfdf58b2 | |||
db0c64fa30 | |||
729b7cb8d8 | |||
322cb7ae26 | |||
277f383e02 | |||
cfe0b0d3dd | |||
498b67ae96 | |||
1846794466 | |||
5e622a34d3 | |||
4d2976c7e6 | |||
c58c238875 | |||
a0bceabf80 | |||
100b53fb18 | |||
6d74a06327 | |||
5b82b9b3d2 | |||
0f2445c1eb | |||
74f40ad23d | |||
1e0de7aefb | |||
3c2db1531a | |||
3f231403ba | |||
e012c5248b | |||
b8867cf4a2 | |||
1fbd51c0b5 | |||
62a87b5957 | |||
ac2a37362c | |||
12da7e1e3b | |||
1c1b8214a0 | |||
04ae1dcba1 | |||
00ff27f277 | |||
6125381598 | |||
daa182bcd2 | |||
f7be6cf016 | |||
6b6b86415d | |||
28ecfc4608 | |||
96a8d1f7ff | |||
8cefc57e61 | |||
70fcc15e00 | |||
da9567b612 | |||
51d0ce4483 | |||
22eb8d4369 | |||
06972fc1c4 | |||
72eee6114f | |||
407fe663d1 | |||
4d50c3eabc | |||
309a0e45e7 | |||
0352a83a73 | |||
5ab2fa709e | |||
b405a94c34 | |||
de243b38eb | |||
ae09b99d0e | |||
0a1ccfec24 | |||
8f17f85d16 | |||
592929366a | |||
21ce72a141 | |||
fe0adbaca4 | |||
d6537548c6 | |||
4de76ba1b2 | |||
b78eb9fd44 | |||
64735adf86 | |||
e6ca463dce | |||
ff67739c67 | |||
9301321813 | |||
05e75782c9 | |||
c90141bc61 | |||
2a1324fc94 | |||
f92b0e2889 | |||
fb04da39cc | |||
0cf2726f78 | |||
79399e5c99 | |||
2bd5093df9 | |||
8ea7197f76 | |||
932e9ade5a | |||
2ef4689004 | |||
efab101890 | |||
10edb9dd9d | |||
c2ed9ca79f | |||
4ac891382e | |||
ca93caa69d | |||
f9d0ec18ff | |||
836cc5d6d2 | |||
8016e01daf | |||
34ed62fd85 | |||
e9a2bdd008 | |||
f0141b0a93 | |||
e45a1fb9db | |||
28f1ceb4e6 | |||
ff54d9cfd6 | |||
b4873fdea4 | |||
373c705247 | |||
68c2dad8d9 | |||
4ded96fbba | |||
9f68f12b3a | |||
85d14f3e12 | |||
81d5715465 | |||
13be312366 | |||
85f02c4626 | |||
efa5b5eb3b | |||
fe694487c7 | |||
c4a9592156 | |||
3d7a539d06 | |||
3932a40d74 | |||
0dfe0de549 | |||
c9c08c7f6f | |||
684c889304 | |||
c9dbba985c | |||
fe0d65f7c8 | |||
8d427c80c8 | |||
a74f893942 | |||
75dafa47fe | |||
d69c41756b | |||
9b2a6a063c | |||
de49571b1e | |||
b58fe44b5b | |||
307722fbec | |||
0b9087cc39 | |||
40ac45cdbe | |||
5aeb297de5 | |||
8c66157a9e | |||
3b489a2125 | |||
14435ce0e8 | |||
a723791942 | |||
d741ab86d5 | |||
b7975ada46 | |||
b43e127805 | |||
512b4bd74d | |||
d48cbc4287 | |||
215ed3171b | |||
95a26bfa57 | |||
706e794df6 | |||
62197e4c4d | |||
f0eb6e4e80 | |||
513c74a52b | |||
1ac1c1c6a7 | |||
d144dad001 | |||
0c069b7418 | |||
616785eae4 | |||
b2a1df41bd | |||
9eef97e615 | |||
a67cfea82f | |||
0287b83797 | |||
59eb7502f5 | |||
a37b454ee7 | |||
9cf318ed66 | |||
1614c5a888 | |||
873a304445 | |||
f9ccc4c824 | |||
d7c231ba9e | |||
a14ebf9ec2 | |||
c5e565ed27 | |||
8851083a3e | |||
61941e2be0 | |||
8922c0ef4e | |||
ceb2431134 | |||
f09ec7d9aa | |||
ed2b6148e5 | |||
0bdbe2ce24 | |||
347e4fd74a | |||
829cb38863 | |||
59e3b873ea | |||
23d6fecae9 | |||
d1f15fb05c | |||
b54a1b1455 | |||
4e9de71f7d | |||
bdefa32d3a | |||
a87416e5ee | |||
e5789f9a37 | |||
40364408a4 | |||
53f5ef711e | |||
b208baaa11 | |||
21b225f23d | |||
c12c44b993 | |||
d1d94646a7 | |||
d490f532c8 | |||
e89d394ce5 | |||
957e7ce1fd | |||
dcec0a48ce | |||
015eef0bfa | |||
41d63ff3cc | |||
0ad60477be | |||
2401f6a07f | |||
10070f3efd | |||
b35258f6ab | |||
1b372e996a | |||
7465c6432f | |||
7749407d6a | |||
8012b380e2 | |||
50bc578e0b | |||
c5708e5722 | |||
68d7813325 | |||
5073dd7a3d | |||
224cb4dde5 | |||
1109820f72 | |||
c8e4f1eaa2 | |||
493e1eba65 | |||
ab9e6788db | |||
47f94a8133 | |||
28048e319f | |||
020564bdcb | |||
0a875ed7de | |||
b98ca189c0 | |||
998035e17e | |||
d351b84b03 | |||
cb3c87cb84 | |||
b2890a2d10 | |||
b044d857a0 | |||
e6c2c0d619 | |||
63a45a6c07 | |||
3697ecbebf | |||
a72c9a74c0 | |||
3660e9b9a3 | |||
8ccf59c28a | |||
fddc395d43 | |||
5f566e7164 | |||
7d81888b44 | |||
f0c5cc5648 | |||
79f29a768a | |||
1d0c8687ee | |||
18d123357f | |||
f7d927cbc4 | |||
90f43d509d | |||
383a65d58e | |||
b184892a1c | |||
7b2efeb2d4 | |||
088d02e8ec | |||
852cd71ff6 | |||
85dc8a2636 | |||
17303fa7fe | |||
3914537315 | |||
2825454dfe | |||
1463d4b2c4 | |||
b9872018ad | |||
7f5a489cae | |||
dc3ac21f75 | |||
a3ad8a1038 | |||
83f5c924f0 | |||
9497bc72a9 | |||
ecfb0e327f | |||
73237237ef | |||
866ad726eb | |||
dd5bbc3dd5 | |||
a28e6e0f75 | |||
884f2010cd | |||
125b49f878 | |||
655a1b63c2 | |||
aabf89a737 | |||
60e24d34a6 | |||
721b9022e7 | |||
f4b65a453d | |||
8b1c484ac4 | |||
12a1046d5a | |||
12493b1eb5 | |||
019639ba88 | |||
6497f97951 | |||
b42bb1ade1 | |||
7b32961bc7 | |||
226fbd5ad4 | |||
bed3a4581a | |||
481c6a03b2 | |||
9824bb2296 | |||
afec48d26b | |||
a47e42f562 | |||
a9e0de4896 | |||
16a138828b | |||
b961f19922 | |||
6ff0c52442 | |||
97b3f4fb8b | |||
eda43b3cda | |||
38f7836fa5 | |||
e39f7be14c | |||
bceb5883cc | |||
39a90e5726 | |||
37ea5be753 | |||
754f9ad250 | |||
e4c3490787 | |||
59d5d2c76e | |||
463733c772 | |||
e670df8b56 | |||
5d1d3ce03b | |||
136e65eb13 | |||
dc8bca9436 | |||
a077f264a3 | |||
924d17b747 | |||
19394fdd09 | |||
717b557610 | |||
4cc60247da | |||
a0009a0267 | |||
f359feb8e2 | |||
2f95675348 | |||
dadf85c4a3 | |||
c6663bc9b6 | |||
ca8b3cf625 | |||
da25b59380 | |||
9ede9045ad |
120
.github/deploy_to_netlify.py
vendored
Executable file
120
.github/deploy_to_netlify.py
vendored
Executable file
@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pprint
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
event_name = os.environ["GITHUB_EVENT_NAME"]
|
||||||
|
|
||||||
|
is_pull_request = is_push = False
|
||||||
|
if event_name.startswith("pull_request"):
|
||||||
|
is_pull_request = True
|
||||||
|
elif event_name.startswith("push"):
|
||||||
|
is_push = True
|
||||||
|
elif event_name.startswith("schedule"):
|
||||||
|
# Don't publish; scheduled workflows run against the latest commit of every
|
||||||
|
# implementation, so they are likely to have failed tests for the wrong reasons
|
||||||
|
sys.exit(0)
|
||||||
|
else:
|
||||||
|
print("Unexpected event name:", event_name)
|
||||||
|
|
||||||
|
with open(os.environ["GITHUB_EVENT_PATH"]) as fd:
|
||||||
|
github_event = json.load(fd)
|
||||||
|
|
||||||
|
pprint.pprint(github_event)
|
||||||
|
|
||||||
|
context_suffix = ""
|
||||||
|
|
||||||
|
command = ["netlify", "deploy", "--dir=dashboard/"]
|
||||||
|
if is_pull_request:
|
||||||
|
pr_number = github_event["number"]
|
||||||
|
sha = github_event.get("after") or github_event["pull_request"]["head"]["sha"]
|
||||||
|
# Aliases can't exceed 37 chars
|
||||||
|
command.extend(["--alias", f"pr-{pr_number}-{sha[0:10]}"])
|
||||||
|
context_suffix = " (pull_request)"
|
||||||
|
elif is_push:
|
||||||
|
ref = github_event["ref"]
|
||||||
|
m = re.match("refs/heads/(.*)", ref)
|
||||||
|
if m:
|
||||||
|
branch = m.group(1)
|
||||||
|
sha = github_event["head_commit"]["id"]
|
||||||
|
|
||||||
|
if branch in ("main", "master"):
|
||||||
|
command.extend(["--prod"])
|
||||||
|
else:
|
||||||
|
command.extend(["--alias", f"br-{branch[0:23]}-{sha[0:10]}"])
|
||||||
|
context_suffix = " (push)"
|
||||||
|
else:
|
||||||
|
# TODO
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
print("Running", command)
|
||||||
|
proc = subprocess.run(command, capture_output=True)
|
||||||
|
|
||||||
|
output = proc.stdout.decode()
|
||||||
|
assert proc.returncode == 0, (output, proc.stderr.decode())
|
||||||
|
print(output)
|
||||||
|
|
||||||
|
m = re.search("https://[^ ]*--[^ ]*netlify.app", output)
|
||||||
|
assert m
|
||||||
|
netlify_site_url = m.group(0)
|
||||||
|
target_url = f"{netlify_site_url}/index.xhtml"
|
||||||
|
|
||||||
|
print("Published to", netlify_site_url)
|
||||||
|
|
||||||
|
|
||||||
|
def send_status() -> None:
|
||||||
|
statuses_url = github_event["repository"]["statuses_url"].format(sha=sha)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"state": "success",
|
||||||
|
"context": f"Dashboard{context_suffix}",
|
||||||
|
"description": "Table of all test results",
|
||||||
|
"target_url": target_url,
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(
|
||||||
|
statuses_url,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f'token {os.environ["GITHUB_TOKEN"]}',
|
||||||
|
"Content-Type": "text/json",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = urllib.request.urlopen(request)
|
||||||
|
|
||||||
|
assert response.status == 201, response.read()
|
||||||
|
|
||||||
|
|
||||||
|
send_status()
|
||||||
|
|
||||||
|
|
||||||
|
def send_pr_comment() -> None:
|
||||||
|
comments_url = github_event["pull_request"]["_links"]["comments"]["href"]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"body": f"[Test results]({target_url})",
|
||||||
|
}
|
||||||
|
request = urllib.request.Request(
|
||||||
|
comments_url,
|
||||||
|
data=json.dumps(payload).encode(),
|
||||||
|
headers={
|
||||||
|
"Authorization": f'token {os.environ["GITHUB_TOKEN"]}',
|
||||||
|
"Content-Type": "text/json",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = urllib.request.urlopen(request)
|
||||||
|
|
||||||
|
assert response.status == 201, response.read()
|
||||||
|
|
||||||
|
|
||||||
|
if is_pull_request:
|
||||||
|
send_pr_comment()
|
41
.github/workflows/lint.yml
vendored
Normal file
41
.github/workflows/lint.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up Python 3.7
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: 3.7
|
||||||
|
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v2
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache
|
||||||
|
key: ${{ runner.os }}-lint
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
python -m pip install pre-commit pytest
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: |
|
||||||
|
pre-commit run -a
|
||||||
|
|
||||||
|
- name: Check generated workflows are in sync
|
||||||
|
run: |
|
||||||
|
python make_workflows.py
|
||||||
|
git diff --exit-code
|
1279
.github/workflows/test-devel.yml
vendored
Normal file
1279
.github/workflows/test-devel.yml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
223
.github/workflows/test-devel_release.yml
vendored
Normal file
223
.github/workflows/test-devel_release.yml
vendored
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
# This file was auto-generated by make_workflows.py.
|
||||||
|
# Do not edit it manually, modifications will be lost.
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-anope:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Create directories
|
||||||
|
run: cd ~/; mkdir -p .local/ go/
|
||||||
|
- name: Cache dependencies
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
key: 3-${{ runner.os }}-anope-devel_release
|
||||||
|
path: '~/.cache
|
||||||
|
|
||||||
|
${ github.workspace }/anope
|
||||||
|
|
||||||
|
'
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Checkout Anope
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
path: anope
|
||||||
|
ref: 2.0.9
|
||||||
|
repository: anope/anope
|
||||||
|
- name: Build Anope
|
||||||
|
run: |
|
||||||
|
cd $GITHUB_WORKSPACE/anope/
|
||||||
|
cp $GITHUB_WORKSPACE/data/anope/* .
|
||||||
|
CFLAGS=-O0 ./Config -quick
|
||||||
|
make -C build -j 4
|
||||||
|
make -C build install
|
||||||
|
- name: Make artefact tarball
|
||||||
|
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
|
||||||
|
- name: Upload build artefacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: installed-anope
|
||||||
|
path: ~/artefacts-*.tar.gz
|
||||||
|
retention-days: 1
|
||||||
|
build-inspircd:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- name: Create directories
|
||||||
|
run: cd ~/; mkdir -p .local/ go/
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Checkout InspIRCd
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
path: inspircd
|
||||||
|
ref: insp3
|
||||||
|
repository: inspircd/inspircd
|
||||||
|
- name: Build InspIRCd
|
||||||
|
run: |
|
||||||
|
cd $GITHUB_WORKSPACE/inspircd/
|
||||||
|
|
||||||
|
# Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP
|
||||||
|
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true
|
||||||
|
|
||||||
|
wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp
|
||||||
|
./configure --prefix=$HOME/.local/inspircd --development
|
||||||
|
|
||||||
|
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
|
||||||
|
make install
|
||||||
|
- name: Make artefact tarball
|
||||||
|
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
|
||||||
|
- name: Upload build artefacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: installed-inspircd
|
||||||
|
path: ~/artefacts-*.tar.gz
|
||||||
|
retention-days: 1
|
||||||
|
publish-test-results:
|
||||||
|
if: success() || failure()
|
||||||
|
name: Publish Dashboard
|
||||||
|
needs:
|
||||||
|
- test-inspircd
|
||||||
|
- test-inspircd-anope
|
||||||
|
- test-inspircd-atheme
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Download Artifacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
path: artifacts
|
||||||
|
- name: Install dashboard dependencies
|
||||||
|
run: |-
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install defusedxml docutils -r requirements.txt
|
||||||
|
- name: Generate dashboard
|
||||||
|
run: |-
|
||||||
|
shopt -s globstar
|
||||||
|
python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml
|
||||||
|
echo '/ /index.xhtml' > dashboard/_redirects
|
||||||
|
- name: Install netlify-cli
|
||||||
|
run: npm i -g netlify-cli
|
||||||
|
- env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
|
name: Deploy to Netlify
|
||||||
|
run: ./.github/deploy_to_netlify.py
|
||||||
|
test-inspircd:
|
||||||
|
needs:
|
||||||
|
- build-inspircd
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Download build artefacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: installed-inspircd
|
||||||
|
path: '~'
|
||||||
|
- name: Unpack artefacts
|
||||||
|
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get install atheme-services faketime
|
||||||
|
- name: Install irctest dependencies
|
||||||
|
run: |-
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pytest pytest-xdist -r requirements.txt
|
||||||
|
- name: Test with pytest
|
||||||
|
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
|
||||||
|
make inspircd
|
||||||
|
timeout-minutes: 30
|
||||||
|
- if: always()
|
||||||
|
name: Publish results
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: pytest-results_inspircd_devel_release
|
||||||
|
path: pytest.xml
|
||||||
|
test-inspircd-anope:
|
||||||
|
needs:
|
||||||
|
- build-inspircd
|
||||||
|
- build-anope
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Download build artefacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: installed-inspircd
|
||||||
|
path: '~'
|
||||||
|
- name: Download build artefacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: installed-anope
|
||||||
|
path: '~'
|
||||||
|
- name: Unpack artefacts
|
||||||
|
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get install atheme-services faketime
|
||||||
|
- name: Install irctest dependencies
|
||||||
|
run: |-
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pytest pytest-xdist -r requirements.txt
|
||||||
|
- name: Test with pytest
|
||||||
|
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make
|
||||||
|
inspircd-anope
|
||||||
|
timeout-minutes: 30
|
||||||
|
- if: always()
|
||||||
|
name: Publish results
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: pytest-results_inspircd-anope_devel_release
|
||||||
|
path: pytest.xml
|
||||||
|
test-inspircd-atheme:
|
||||||
|
needs:
|
||||||
|
- build-inspircd
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v3
|
||||||
|
- name: Set up Python 3.11
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
- name: Download build artefacts
|
||||||
|
uses: actions/download-artifact@v3
|
||||||
|
with:
|
||||||
|
name: installed-inspircd
|
||||||
|
path: '~'
|
||||||
|
- name: Unpack artefacts
|
||||||
|
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt-get install atheme-services faketime
|
||||||
|
- name: Install irctest dependencies
|
||||||
|
run: |-
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install pytest pytest-xdist -r requirements.txt
|
||||||
|
- name: Test with pytest
|
||||||
|
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
|
||||||
|
make inspircd-atheme
|
||||||
|
timeout-minutes: 30
|
||||||
|
- if: always()
|
||||||
|
name: Publish results
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: pytest-results_inspircd-atheme_devel_release
|
||||||
|
path: pytest.xml
|
||||||
|
name: irctest with devel_release versions
|
||||||
|
'on':
|
||||||
|
schedule:
|
||||||
|
- cron: 51 8 * * 6
|
||||||
|
- cron: 51 8 * * 0
|
||||||
|
- cron: 51 17 * * *
|
||||||
|
workflow_dispatch: null
|
1388
.github/workflows/test-stable.yml
vendored
Normal file
1388
.github/workflows/test-stable.yml
vendored
Normal file
File diff suppressed because it is too large
Load Diff
104
.gitignore
vendored
104
.gitignore
vendored
@ -1,10 +1,73 @@
|
|||||||
# Text editors
|
|
||||||
*~
|
|
||||||
*.swp
|
|
||||||
|
|
||||||
|
# Created by https://www.gitignore.io/api/windows,osx,linux,python
|
||||||
|
|
||||||
|
### Windows ###
|
||||||
|
# Windows image file caches
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
|
||||||
|
### OSX ###
|
||||||
|
*.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
|
||||||
|
### Linux ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
|
||||||
|
### Python ###
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
@ -46,6 +109,7 @@ htmlcov/
|
|||||||
nosetests.xml
|
nosetests.xml
|
||||||
coverage.xml
|
coverage.xml
|
||||||
*,cover
|
*,cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
# Translations
|
# Translations
|
||||||
*.mo
|
*.mo
|
||||||
@ -53,9 +117,43 @@ coverage.xml
|
|||||||
|
|
||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
# Sphinx documentation
|
# Sphinx documentation
|
||||||
docs/_build/
|
docs/_build/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
target/
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# celery beat schedule file
|
||||||
|
celerybeat-schedule
|
||||||
|
|
||||||
|
# dotenv
|
||||||
|
.env
|
||||||
|
|
||||||
|
# virtualenv
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# vim swapfiles
|
||||||
|
*.swp
|
||||||
|
24
.pre-commit-config.yaml
Normal file
24
.pre-commit-config.yaml
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
exclude: ^irctest/scram
|
||||||
|
|
||||||
|
repos:
|
||||||
|
- repo: https://github.com/psf/black
|
||||||
|
rev: 23.1.0
|
||||||
|
hooks:
|
||||||
|
- id: black
|
||||||
|
language_version: python3
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/isort
|
||||||
|
rev: 5.11.5
|
||||||
|
hooks:
|
||||||
|
- id: isort
|
||||||
|
|
||||||
|
- repo: https://github.com/PyCQA/flake8
|
||||||
|
rev: 5.0.4
|
||||||
|
hooks:
|
||||||
|
- id: flake8
|
||||||
|
|
||||||
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
|
rev: v1.0.1
|
||||||
|
hooks:
|
||||||
|
- id: mypy
|
||||||
|
additional_dependencies: [types-PyYAML, types-docutils]
|
92
CONTRIBUTING.md
Normal file
92
CONTRIBUTING.md
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
### Syntax
|
||||||
|
|
||||||
|
Any color you like as long as it's [Black](https://github.com/psf/black).
|
||||||
|
In short:
|
||||||
|
|
||||||
|
* 88 columns
|
||||||
|
* double quotes
|
||||||
|
* avoid backslashes at line breaks (use parentheses)
|
||||||
|
* closing brackets/parentheses/... go on the same indent level as the line
|
||||||
|
that opened them
|
||||||
|
|
||||||
|
We also use `isort` to order imports (in short: just
|
||||||
|
[follow PEP 8](https://www.python.org/dev/peps/pep-0008/#imports))
|
||||||
|
|
||||||
|
You can use [pre-commit](https://pre-commit.com/) to automatically run them
|
||||||
|
when you create a git commit.
|
||||||
|
Alternatively, run `pre-commit run -a`
|
||||||
|
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
[Follow PEP 8](https://www.python.org/dev/peps/pep-0008/#naming-conventions),
|
||||||
|
with these exceptions:
|
||||||
|
|
||||||
|
* assertion methods (eg. `assertMessageMatch` are mixedCase to be consistent
|
||||||
|
with the unittest module)
|
||||||
|
* other methods defined in `cases.py` are also mixedCase for consistency with
|
||||||
|
the former, for now
|
||||||
|
* test names are also mixedCase for the same reason
|
||||||
|
|
||||||
|
Additionally:
|
||||||
|
|
||||||
|
* test module names should be snake\_case and match the name of the
|
||||||
|
specification they are testing (if IRCv3), or the feature they are
|
||||||
|
testing (if RFC or just common usage)
|
||||||
|
|
||||||
|
|
||||||
|
## What to test
|
||||||
|
|
||||||
|
**All tests should have a docstring** pointing to a reference document
|
||||||
|
(eg. RFC, IRCv3 specification, or modern.ircdocs.horse).
|
||||||
|
If there is no reference, documentation can do.
|
||||||
|
|
||||||
|
If the behavior being tested is not documented, then **please document it
|
||||||
|
outside** this repository (eg. at modern.ircdocs.horse),
|
||||||
|
and/or get it specified through IRCv3.
|
||||||
|
|
||||||
|
"That's just how everyone does it" is not good justification.
|
||||||
|
Linking to an external document saying "Here is how everyone does it" is.
|
||||||
|
|
||||||
|
If reference documents / documentations are long or not trivial,
|
||||||
|
**try to quote the specific part being tested**.
|
||||||
|
See `irctest/server_tests/kick.py` for example.
|
||||||
|
|
||||||
|
Tests for **pending/draft specifications are welcome**.
|
||||||
|
|
||||||
|
Note that irctest also welcomes implementation-specific tests for
|
||||||
|
functional testing; for now only Ergo.
|
||||||
|
This does not relax the requirement on documentating tests.
|
||||||
|
|
||||||
|
|
||||||
|
## Writing tests
|
||||||
|
|
||||||
|
**Use unittest-style assertions** (`self.assertEqual(x, y)` instead of
|
||||||
|
pytest-style (`assert x == y`). This allows consistency with the assertion
|
||||||
|
methods we define, such as `assertMessageMatch`.
|
||||||
|
|
||||||
|
Always **add an error message in assertions**.
|
||||||
|
`irctest` should show readable errors to people unfamiliar with the
|
||||||
|
codebase.
|
||||||
|
Ideally, explain what happened and what should have happened instead.
|
||||||
|
|
||||||
|
All tests should be tagged with
|
||||||
|
`@cases.mark_specifications`.
|
||||||
|
|
||||||
|
|
||||||
|
## Continuous integration
|
||||||
|
|
||||||
|
We run automated tests on all commits and pull requests, to check that tests
|
||||||
|
accept existing implementations.
|
||||||
|
Scripts to run the tests are defined in `workflows.yml`, and the
|
||||||
|
`make_workflows.py` script reads this configuration to generate files
|
||||||
|
in `.github/workflows/` that are used by the CI.
|
||||||
|
|
||||||
|
If an implementation cannot pass a test, that test should be excluded via
|
||||||
|
a definition in the Makefile.
|
||||||
|
If it is a bug, please open a bug report to the affected software if possible,
|
||||||
|
and link to the bug report in a comment.
|
305
Makefile
Normal file
305
Makefile
Normal file
@ -0,0 +1,305 @@
|
|||||||
|
PYTEST ?= python3 -m pytest
|
||||||
|
|
||||||
|
# Extra arguments to pass to pytest (eg. `-n 4` to run in parallel if
|
||||||
|
# pytest-xdist is installed)
|
||||||
|
PYTEST_ARGS ?=
|
||||||
|
|
||||||
|
# Will be appended at the end of the -k argument to pytest
|
||||||
|
EXTRA_SELECTORS ?=
|
||||||
|
|
||||||
|
BAHAMUT_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
and not IRCv3 \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
CHARYBDIS_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
ERGO_SELECTORS := \
|
||||||
|
not deprecated \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
HYBRID_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
INSPIRCD_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
IRCU2_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
NEFARIOUS_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
SNIRCD_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
IRC2_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
MAMMON_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
NGIRCD_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
PLEXUS4_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
# Limnoria can actually pass all the test so there is none to exclude.
|
||||||
|
# `(foo or not foo)` serves as a `true` value so it doesn't break when
|
||||||
|
# $(EXTRA_SELECTORS) is non-empty
|
||||||
|
LIMNORIA_SELECTORS := \
|
||||||
|
(foo or not foo) \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
SABLE_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
and not whowas and not list and not lusers and not userhost and not time and not info \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
SOLANUM_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
# Same as Limnoria
|
||||||
|
SOPEL_SELECTORS := \
|
||||||
|
(foo or not foo) \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
# TheLounge can actually pass all the test so there is none to exclude.
|
||||||
|
# `(foo or not foo)` serves as a `true` value so it doesn't break when
|
||||||
|
# $(EXTRA_SELECTORS) is non-empty
|
||||||
|
THELOUNGE_SELECTORS := \
|
||||||
|
(foo or not foo) \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
# Tests marked with arbitrary_client_tags can't pass because Unreal whitelists which tags it relays
|
||||||
|
# Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149
|
||||||
|
# Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs
|
||||||
|
|
||||||
|
UNREALIRCD_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
and not arbitrary_client_tags \
|
||||||
|
and not react_tag \
|
||||||
|
and not private_chathistory \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd
|
||||||
|
|
||||||
|
all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd
|
||||||
|
|
||||||
|
flakes:
|
||||||
|
find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3
|
||||||
|
|
||||||
|
bahamut:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.bahamut \
|
||||||
|
-m 'not services' \
|
||||||
|
-n 4 \
|
||||||
|
-vv -s \
|
||||||
|
-k '$(BAHAMUT_SELECTORS)'
|
||||||
|
|
||||||
|
bahamut-atheme:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.bahamut \
|
||||||
|
--services-controller=irctest.controllers.atheme_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k '$(BAHAMUT_SELECTORS)'
|
||||||
|
|
||||||
|
bahamut-anope:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.bahamut \
|
||||||
|
--services-controller=irctest.controllers.anope_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k '$(BAHAMUT_SELECTORS)'
|
||||||
|
|
||||||
|
charybdis:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.charybdis \
|
||||||
|
--services-controller=irctest.controllers.atheme_services \
|
||||||
|
-k '$(CHARYBDIS_SELECTORS)'
|
||||||
|
|
||||||
|
ergo:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller irctest.controllers.ergo \
|
||||||
|
-k "$(ERGO_SELECTORS)"
|
||||||
|
|
||||||
|
hybrid:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller irctest.controllers.hybrid \
|
||||||
|
--services-controller=irctest.controllers.anope_services \
|
||||||
|
-k "$(HYBRID_SELECTORS)"
|
||||||
|
|
||||||
|
inspircd:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.inspircd \
|
||||||
|
-m 'not services' \
|
||||||
|
-k '$(INSPIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
inspircd-atheme:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.inspircd \
|
||||||
|
--services-controller=irctest.controllers.atheme_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k '$(INSPIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
inspircd-anope:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.inspircd \
|
||||||
|
--services-controller=irctest.controllers.anope_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k '$(INSPIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
ircu2:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.ircu2 \
|
||||||
|
-m 'not services and not IRCv3' \
|
||||||
|
-n 4 \
|
||||||
|
-k '$(IRCU2_SELECTORS)'
|
||||||
|
|
||||||
|
nefarious:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.nefarious \
|
||||||
|
-m 'not services' \
|
||||||
|
-n 4 \
|
||||||
|
-k '$(NEFARIOUS_SELECTORS)'
|
||||||
|
|
||||||
|
snircd:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.snircd \
|
||||||
|
-m 'not services and not IRCv3' \
|
||||||
|
-n 4 \
|
||||||
|
-k '$(SNIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
irc2:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.irc2 \
|
||||||
|
-m 'not services and not IRCv3' \
|
||||||
|
-n 4 \
|
||||||
|
-k '$(IRC2_SELECTORS)'
|
||||||
|
|
||||||
|
limnoria:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.limnoria \
|
||||||
|
-k '$(LIMNORIA_SELECTORS)'
|
||||||
|
|
||||||
|
mammon:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.mammon \
|
||||||
|
-k '$(MAMMON_SELECTORS)'
|
||||||
|
|
||||||
|
plexus4:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller irctest.controllers.plexus4 \
|
||||||
|
--services-controller=irctest.controllers.anope_services \
|
||||||
|
-k "$(PLEXUS4_SELECTORS)"
|
||||||
|
|
||||||
|
ngircd:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller irctest.controllers.ngircd \
|
||||||
|
-m 'not services' \
|
||||||
|
-n 4 \
|
||||||
|
-k "$(NGIRCD_SELECTORS)"
|
||||||
|
|
||||||
|
ngircd-anope:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller irctest.controllers.ngircd \
|
||||||
|
--services-controller=irctest.controllers.anope_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k "$(NGIRCD_SELECTORS)"
|
||||||
|
|
||||||
|
ngircd-atheme:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller irctest.controllers.ngircd \
|
||||||
|
--services-controller=irctest.controllers.atheme_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k "$(NGIRCD_SELECTORS)"
|
||||||
|
|
||||||
|
sable:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.sable \
|
||||||
|
-n 20 \
|
||||||
|
-m 'not services' \
|
||||||
|
-k '$(SABLE_SELECTORS)'
|
||||||
|
|
||||||
|
solanum:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.solanum \
|
||||||
|
--services-controller=irctest.controllers.atheme_services \
|
||||||
|
-k '$(SOLANUM_SELECTORS)'
|
||||||
|
|
||||||
|
sopel:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.sopel \
|
||||||
|
-k '$(SOPEL_SELECTORS)'
|
||||||
|
|
||||||
|
thelounge:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.thelounge \
|
||||||
|
-k '$(THELOUNGE_SELECTORS)'
|
||||||
|
|
||||||
|
unrealircd:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.unrealircd \
|
||||||
|
-m 'not services' \
|
||||||
|
-k '$(UNREALIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
unrealircd-5: unrealircd
|
||||||
|
|
||||||
|
unrealircd-atheme:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.unrealircd \
|
||||||
|
--services-controller=irctest.controllers.atheme_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k '$(UNREALIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
unrealircd-anope:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.unrealircd \
|
||||||
|
--services-controller=irctest.controllers.anope_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k '$(UNREALIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
unrealircd-dlk:
|
||||||
|
pifpaf run mysql -- $(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.unrealircd \
|
||||||
|
--services-controller=irctest.controllers.dlk_services \
|
||||||
|
-m 'services' \
|
||||||
|
-k '$(UNREALIRCD_SELECTORS)'
|
207
README.md
207
README.md
@ -1,10 +1,7 @@
|
|||||||
# irctest
|
# irctest
|
||||||
|
|
||||||
This project aims at testing interoperability of software using the
|
This project aims at testing interoperability of software using the
|
||||||
IRC protocol, by running them against test suites and making different
|
IRC protocol, by running them against common test suites.
|
||||||
software communicate with each other.
|
|
||||||
|
|
||||||
It is very young and does not contain a lot of test cases yet.
|
|
||||||
|
|
||||||
## The big picture
|
## The big picture
|
||||||
|
|
||||||
@ -14,95 +11,199 @@ This project contains:
|
|||||||
* small wrappers around existing software to run tests on them
|
* small wrappers around existing software to run tests on them
|
||||||
|
|
||||||
Wrappers run software in temporary directories, so running `irctest` should
|
Wrappers run software in temporary directories, so running `irctest` should
|
||||||
have no side effect, with [the exception of Sopel](https://github.com/sopel-irc/sopel/issues/946).
|
have no side effect.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Install irctest and dependencies:
|
Install irctest and dependencies:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
sudo apt install faketime # Optional, but greatly speeds up irctest/server_tests/list.py
|
||||||
|
cd ~
|
||||||
git clone https://github.com/ProgVal/irctest.git
|
git clone https://github.com/ProgVal/irctest.git
|
||||||
cd irctest
|
cd irctest
|
||||||
pip3 install --user -r requirements.txt
|
pip3 install --user -r requirements.txt
|
||||||
python3 setup.py install --user
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Add `~/.local/bin/` to your `PATH` if it is not.
|
Add `~/.local/bin/` (and/or `~/go/bin/` for Ergo)
|
||||||
|
to your `PATH` if it is not.
|
||||||
|
|
||||||
```
|
```
|
||||||
export PATH=$HOME/.local/bin/:$PATH
|
export PATH=$HOME/.local/bin/:$HOME/go/bin/:$PATH
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run tests
|
## Using pytest
|
||||||
|
|
||||||
To run (client) tests on Limnoria:
|
irctest is invoked using the pytest test runner / CLI.
|
||||||
|
|
||||||
```
|
You can usually invoke it with `python3 -m pytest` command; which can often
|
||||||
pip3 install --user limnoria
|
be called by the `pytest` or `pytest-3` commands (if not, alias them if you
|
||||||
python3 -m irctest irctest.controllers.limnoria
|
are planning to use them often).
|
||||||
```
|
|
||||||
|
|
||||||
To run (client) tests on Sopel:
|
The rest of this README assumes `pytest` works.
|
||||||
|
|
||||||
```
|
## Test selection
|
||||||
pip3 install --user sopel
|
|
||||||
mkdir ~/.sopel/
|
|
||||||
python3 -m irctest irctest.controllers.sopel
|
|
||||||
```
|
|
||||||
|
|
||||||
To run (server) tests on InspIRCd:
|
A major feature of pytest that irctest heavily relies on is test selection.
|
||||||
|
Using the `-k` option, you can select and deselect tests based on their names
|
||||||
|
and/or markers (listed in `pytest.ini`).
|
||||||
|
For example, you can run `LUSERS`-related tests with `-k lusers`.
|
||||||
|
Or only tests based on RFC1459 with `-k rfc1459`.
|
||||||
|
|
||||||
|
By default, all tests run; even niche ones. So you probably always want to
|
||||||
|
use these options: `-k 'not Ergo and not deprecated and not strict`.
|
||||||
|
This excludes:
|
||||||
|
|
||||||
|
* `Ergo`-specific tests (included as Ergo uses irctest as its official
|
||||||
|
integration test suite)
|
||||||
|
* tests for deprecated specifications, such as the IRCv3 METADATA
|
||||||
|
specification
|
||||||
|
* tests that check for a strict interpretation of a specification, when
|
||||||
|
the specification is ambiguous.
|
||||||
|
|
||||||
|
## Running tests
|
||||||
|
|
||||||
|
### Servers
|
||||||
|
|
||||||
|
#### Ergo:
|
||||||
|
|
||||||
```
|
```
|
||||||
cd /tmp/
|
cd /tmp/
|
||||||
git clone https://github.com/inspircd/inspircd.git
|
git clone https://github.com/ergochat/ergo.git
|
||||||
cd inspircd
|
cd ergo/
|
||||||
./configure --prefix=$HOME/.local/ --development
|
make install
|
||||||
|
cd ~/irctest
|
||||||
|
pytest --controller irctest.controllers.ergo -k 'not deprecated'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Solanum:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /tmp/
|
||||||
|
git clone https://github.com/solanum-ircd/solanum.git
|
||||||
|
cd solanum
|
||||||
|
./autogen.sh
|
||||||
|
./configure --prefix=$HOME/.local/
|
||||||
make -j 4
|
make -j 4
|
||||||
make install
|
make install
|
||||||
python3 -m irctest irctest.controllers.inspircd
|
pytest --controller irctest.controllers.solanum -k 'not Ergo and not deprecated and not strict'
|
||||||
```
|
```
|
||||||
|
|
||||||
To run (server) tests on Mammon:
|
#### Charybdis:
|
||||||
|
|
||||||
```
|
|
||||||
pip3 install --user git+https://github.com/mammon-ircd/mammon.git
|
|
||||||
python3 -m irctest irctest.controllers.mammon
|
|
||||||
```
|
|
||||||
|
|
||||||
To run (server) tests on Charybdis::
|
|
||||||
|
|
||||||
```
|
```
|
||||||
cd /tmp/
|
cd /tmp/
|
||||||
git clone https://github.com/atheme/charybdis.git
|
git clone https://github.com/atheme/charybdis.git
|
||||||
cd charybdis
|
cd charybdis
|
||||||
|
./autogen.sh
|
||||||
./configure --prefix=$HOME/.local/
|
./configure --prefix=$HOME/.local/
|
||||||
make -j 4
|
make -j 4
|
||||||
make install
|
make install
|
||||||
python3 -m irctest irctest.controllers.charybdis
|
cd ~/irctest
|
||||||
|
pytest --controller irctest.controllers.charybdis -k 'not Ergo and not deprecated and not strict'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Full help
|
#### InspIRCd:
|
||||||
|
|
||||||
```
|
```
|
||||||
usage: python3 -m irctest [-h] [--show-io] [-v] [-s SPECIFICATION] [-l] module
|
cd /tmp/
|
||||||
|
git clone https://github.com/inspircd/inspircd.git
|
||||||
|
cd inspircd
|
||||||
|
|
||||||
positional arguments:
|
# Optional, makes tests run considerably faster. Pick one depending on the InspIRCd version:
|
||||||
module The module used to run the tested program.
|
# on Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21:
|
||||||
|
patch src/inspircd.cpp < ~/irctest/patches/inspircd_mainloop.patch
|
||||||
|
# on Insp3 >= 3.17.0 and Insp4 >= 4.0.0a22:
|
||||||
|
export CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP
|
||||||
|
|
||||||
optional arguments:
|
# third-party module, used in named-modes tests because the spec is not implemented upstream
|
||||||
-h, --help show this help message and exit
|
wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp
|
||||||
--show-io Show input/outputs with the tested program.
|
|
||||||
-v, --verbose Verbosity. Give this option multiple times to make it
|
./configure --prefix=$HOME/.local/ --development
|
||||||
even more verbose.
|
make -j 4
|
||||||
-s SPECIFICATION, --specification SPECIFICATION
|
make install
|
||||||
The set of specifications to test the program with.
|
cd ~/irctest
|
||||||
Valid values: RFC1459, RFC2812, IRCv3.1, IRCv3.2. Use
|
pytest --controller irctest.controllers.inspircd -k 'not Ergo and not deprecated and not strict'
|
||||||
this option multiple times to test with multiple
|
```
|
||||||
specifications. If it is not given, defaults to all.
|
|
||||||
-l, --loose Disables strict checks of conformity to the
|
#### Mammon:
|
||||||
specification. Strict means the specification is
|
|
||||||
unclear, and the most restrictive interpretation is
|
```
|
||||||
choosen.
|
pip3 install --user git+https://github.com/mammon-ircd/mammon.git
|
||||||
|
cd ~/irctest
|
||||||
|
pytest --controller irctest.controllers.mammon -k 'not Ergo and not deprecated and not strict'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### UnrealIRCd:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /tmp/
|
||||||
|
git clone https://github.com/unrealircd/unrealircd.git
|
||||||
|
cd unrealircd
|
||||||
|
./Config # This will ask a few questions, answer them.
|
||||||
|
make -j 4
|
||||||
|
make install
|
||||||
|
cd ~/irctest
|
||||||
|
pytest --controller irctest.controllers.unreal -k 'not Ergo and not deprecated and not strict'
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Servers with services
|
||||||
|
|
||||||
|
Besides Ergo (that has built-in services), most server controllers can optionally run
|
||||||
|
service packages.
|
||||||
|
|
||||||
|
#### Atheme:
|
||||||
|
|
||||||
|
You can install it with
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo apt install atheme-services
|
||||||
|
```
|
||||||
|
|
||||||
|
and add this to the `pytest` call:
|
||||||
|
|
||||||
|
```
|
||||||
|
--services-controller irctest.controllers.atheme_services
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Anope:
|
||||||
|
|
||||||
|
Build with:
|
||||||
|
|
||||||
|
```
|
||||||
|
cd /tmp/
|
||||||
|
git clone https://github.com/anope/anope.git
|
||||||
|
cd anope
|
||||||
|
./Config # This will ask a few questions, answer them.
|
||||||
|
make -C build -j 4
|
||||||
|
make -C build install
|
||||||
|
```
|
||||||
|
|
||||||
|
and add this to the `pytest` call:
|
||||||
|
|
||||||
|
```
|
||||||
|
--services-controller irctest.controllers.anope_services
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Clients
|
||||||
|
|
||||||
|
#### Limnoria:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip3 install --user limnoria pyxmpp2-scram
|
||||||
|
cd ~/irctest
|
||||||
|
pytest --controller irctest.controllers.limnoria
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Sopel:
|
||||||
|
|
||||||
|
```
|
||||||
|
pip3 install --user sopel
|
||||||
|
mkdir ~/.sopel/
|
||||||
|
cd ~/irctest
|
||||||
|
pytest --controller irctest.controllers.sopel
|
||||||
```
|
```
|
||||||
|
|
||||||
## What `irctest` is not
|
## What `irctest` is not
|
||||||
@ -113,3 +214,5 @@ or anything near that.
|
|||||||
At best, `irctest` can help you find issues in your software, but it may
|
At best, `irctest` can help you find issues in your software, but it may
|
||||||
still have false positives (because it does not implement itself a
|
still have false positives (because it does not implement itself a
|
||||||
full-featured client/server, so it supports only “usual” behavior).
|
full-featured client/server, so it supports only “usual” behavior).
|
||||||
|
Bug reports for false positives are welcome.
|
||||||
|
|
||||||
|
129
conftest.py
Normal file
129
conftest.py
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
import _pytest.unittest
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Must be called before importing irctest.cases.
|
||||||
|
pytest.register_assert_rewrite("irctest.cases")
|
||||||
|
|
||||||
|
from irctest.basecontrollers import ( # noqa: E402
|
||||||
|
BaseClientController,
|
||||||
|
BaseServerController,
|
||||||
|
)
|
||||||
|
from irctest.cases import ( # noqa: E402
|
||||||
|
BaseClientTestCase,
|
||||||
|
BaseServerTestCase,
|
||||||
|
_IrcTestCase,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser):
|
||||||
|
"""Called by pytest, registers CLI options passed to the pytest command."""
|
||||||
|
parser.addoption(
|
||||||
|
"--controller", help="Which module to use to run the tested software."
|
||||||
|
)
|
||||||
|
parser.addoption(
|
||||||
|
"--services-controller", help="Which module to use to run a services package."
|
||||||
|
)
|
||||||
|
parser.addoption(
|
||||||
|
"--openssl-bin", type=str, default="openssl", help="The openssl binary to use"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
"""Called by pytest, after it parsed the command-line."""
|
||||||
|
module_name = config.getoption("controller")
|
||||||
|
services_module_name = config.getoption("services_controller")
|
||||||
|
|
||||||
|
if module_name is None:
|
||||||
|
print("Missing --controller option, errors may occur.")
|
||||||
|
_IrcTestCase.controllerClass = None
|
||||||
|
_IrcTestCase.show_io = True # TODO
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except ImportError:
|
||||||
|
pytest.exit("Cannot import module {}".format(module_name), 1)
|
||||||
|
|
||||||
|
controller_class = module.get_irctest_controller_class()
|
||||||
|
if issubclass(controller_class, BaseClientController):
|
||||||
|
from irctest import client_tests as module
|
||||||
|
|
||||||
|
if services_module_name is not None:
|
||||||
|
pytest.exit("You may not use --services-controller for client tests.")
|
||||||
|
elif issubclass(controller_class, BaseServerController):
|
||||||
|
from irctest import server_tests as module
|
||||||
|
else:
|
||||||
|
pytest.exit(
|
||||||
|
r"{}.Controller should be a subclass of "
|
||||||
|
r"irctest.basecontroller.Base{{Client,Server}}Controller".format(
|
||||||
|
module_name
|
||||||
|
),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
|
if services_module_name is not None:
|
||||||
|
try:
|
||||||
|
services_module = importlib.import_module(services_module_name)
|
||||||
|
except ImportError:
|
||||||
|
pytest.exit("Cannot import module {}".format(services_module_name), 1)
|
||||||
|
controller_class.services_controller_class = (
|
||||||
|
services_module.get_irctest_controller_class()
|
||||||
|
)
|
||||||
|
|
||||||
|
_IrcTestCase.controllerClass = controller_class
|
||||||
|
_IrcTestCase.controllerClass.openssl_bin = config.getoption("openssl_bin")
|
||||||
|
_IrcTestCase.show_io = True # TODO
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(session, config, items):
|
||||||
|
"""Called by pytest after finishing the test collection,
|
||||||
|
and before actually running the tests.
|
||||||
|
|
||||||
|
This function filters out client tests if running with a server controller,
|
||||||
|
and vice versa.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# First, check if we should run server tests or client tests
|
||||||
|
server_tests = client_tests = False
|
||||||
|
if _IrcTestCase.controllerClass is None:
|
||||||
|
pass
|
||||||
|
elif issubclass(_IrcTestCase.controllerClass, BaseServerController):
|
||||||
|
server_tests = True
|
||||||
|
elif issubclass(_IrcTestCase.controllerClass, BaseClientController):
|
||||||
|
client_tests = True
|
||||||
|
else:
|
||||||
|
assert False, (
|
||||||
|
f"{_IrcTestCase.controllerClass} inherits neither "
|
||||||
|
f"BaseClientController or BaseServerController"
|
||||||
|
)
|
||||||
|
|
||||||
|
filtered_items = []
|
||||||
|
|
||||||
|
# Iterate over each of the test functions (they are pytest "Nodes")
|
||||||
|
for item in items:
|
||||||
|
assert isinstance(item, _pytest.python.Function)
|
||||||
|
|
||||||
|
# unittest-style test functions have the node of UnitTest class as parent
|
||||||
|
if tuple(map(int, _pytest.__version__.split("."))) >= (7,):
|
||||||
|
assert isinstance(item.parent, _pytest.python.Class)
|
||||||
|
else:
|
||||||
|
assert isinstance(item.parent, _pytest.python.Instance)
|
||||||
|
|
||||||
|
# and that node references the UnitTest class
|
||||||
|
assert issubclass(item.parent.cls, _IrcTestCase)
|
||||||
|
|
||||||
|
# and in this project, TestCase classes all inherit either from
|
||||||
|
# BaseClientController or BaseServerController.
|
||||||
|
if issubclass(item.parent.cls, BaseServerTestCase):
|
||||||
|
if server_tests:
|
||||||
|
filtered_items.append(item)
|
||||||
|
elif issubclass(item.parent.cls, BaseClientTestCase):
|
||||||
|
if client_tests:
|
||||||
|
filtered_items.append(item)
|
||||||
|
else:
|
||||||
|
filtered_items.append(item)
|
||||||
|
|
||||||
|
# Finally, rewrite in-place the list of tests pytest will run
|
||||||
|
items[:] = filtered_items
|
8
data/anope/config.cache
Normal file
8
data/anope/config.cache
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
INSTDIR="$HOME/.local/"
|
||||||
|
RUNGROUP=""
|
||||||
|
UMASK=077
|
||||||
|
DEBUG="yes"
|
||||||
|
USE_PCH="yes"
|
||||||
|
EXTRA_INCLUDE_DIRS=""
|
||||||
|
EXTRA_LIB_DIRS=""
|
||||||
|
EXTRA_CONFIG_ARGS=""
|
83
data/nefarious/ircd.pem
Normal file
83
data/nefarious/ircd.pem
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
-----BEGIN PRIVATE KEY-----
|
||||||
|
MIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQDT0URxi7/l7ZGe
|
||||||
|
tkPv9Yh8h2s9BpbAR4Wq8sakgqETWg/nE/JQM5dPxroVbtZWWQXuJEFsgBKbASLa
|
||||||
|
/eg5cyJv4Uu5WIZpG1LxdPEEIOSMWjzoAGwoLxbTRGrS7qNXsknB9RwDuq8lPQiK
|
||||||
|
kiAahg1Cn1vRrQ4cRrG+AkQWpRHJEDoLjCSo8IcAsKAZlw/eGtAcmeNvkr5AujEw
|
||||||
|
XjIwx2FoDyKaNGRH5Z7gLWvCKBNxQuJuMTzh8guLqdGbE4hH3rqyICbW5DGPaOZL
|
||||||
|
LErWuJ7kEhLZG2HDW5JaXOr0QfFYAA8pl9/qCuFMdoxRUKRcYBoxoMmz6dlsmipN
|
||||||
|
7vIj+TT6TemwcAT25pwMJIVS4WC4+BZilNH2eWKD9hZA8Kq7FDPu+1rxOJaLbE/b
|
||||||
|
vpK8jZeRdqFzE1eBCgPkw8D8V0r7J18d+DsmgOe2kRycaia/t9M4rhqe0FXjX1X1
|
||||||
|
lzQ52grxgc28Ejd1fGQXIJmdTh4BqKqTzxup0izS7dgFP1Ezm6Z4O+wklpL5uQF2
|
||||||
|
Ex4X6QEj76iCxH+J/01/cvbxMe3iuGXECbO/y1FIrg7FKzZSrQo4aP63lS7Y7aq0
|
||||||
|
t2t6kOS83ebhnpgHClgFs8/C3ayzYBBtbK63PYthwO8Rt6WamCIZFF5tA3XoI4Ak
|
||||||
|
fZcWD18loZai+QznVzbLNINf++rTwwIDAQABAoICAQCs1tT3piZHU2yAyo9zLbJa
|
||||||
|
kxGxcT/v1CzBSmtG8ATJyrKxRzhxszdj9HABbzjcqrXJFbKA+5yy+OFdOtSUlFtk
|
||||||
|
Wb21lwPOnmo29sp4KPL1h+itEzMuMwZ4DBry1aFZvPSsnPpoHJwwUbY3hHdHzVzi
|
||||||
|
oTCGTqT188Wzmxu+MqHppCEJLSj45ZPzvyxU1UwwW0a4H+ZTM7WlEYlzw1lHLlpQ
|
||||||
|
VBFTLS8q77aNjOKiQptiz0X+zpS0dhJvu3l7BhwtMRS8prmqnffG4r0QWCsVPP8C
|
||||||
|
cbEJkWtbwswQikN6Xpi1yw6UTQZ8brZa810aOSh07EJTfrU35rjxAndEspbJPd+4
|
||||||
|
Zz6fKNnRA7A4fFap2hF4TSP/UF12b4ulQ8FfcMMTFszWko5M6fWFuCeWfNbyXML5
|
||||||
|
fmn+NmSOboj7LkDqbpxtyaIVXXb2Y3F6A2fNllm/mxaGrRoEGNaH3o0qBgWRzJJB
|
||||||
|
TDSvIQtJddzL+iMaqxz4ufXAREJernZmPa3vlkVGLINNQUC9JLrB5eFjLzPiZN2U
|
||||||
|
8RgQ9YX5tjoJ+DtPWuMFDiCS1ZE20/UBOEYTeqIVuKdK3AjJDMFSjg8fRvsWRqZe
|
||||||
|
zsHv6tCtIFZFxYRxtrRGTUPQF+1QD6zBjYxZZk1B4n3uYBGVQFM/LnNHUxRnJBx1
|
||||||
|
PunD4ICOY97xd2hcPmGiCQKCAQEA8NCXYaHzhv6fg95H/iMuJVcOCKrJ5rVr4poG
|
||||||
|
SD0KZtS7SLzUYat8WcuoSubh5Eb2hHtrsnLrSOTnwQUO61f4gCRm2sEqHYsOAd7+
|
||||||
|
mNe1jfil0UBVqqL9GBcGYJkc5+DHgUlJQaxMV+4YLt8fD0KfZEnHaDAYX3kUdz+p
|
||||||
|
be//YAKv+JmxWcUdBF60AUWPjbCJT/1pfJeY8nEBFiYtlYKKN24+4OiRdJ2yRGzt
|
||||||
|
ZtNHaWy5EFF70yVgPX5MGQ7Z2JpejzK+lt+9nG4h1uJ4M2X4YrGVrRCn1W8jwqm/
|
||||||
|
bXest3E6wkkLoWDm9EaeYj00DUgMOviPyP4ckyxilG+Fny4JbwKCAQEA4SyUV03X
|
||||||
|
KEoL5sOD69sLu3TpnIQz73u9an9W/f2f7rsGwmCcR9RdYGV29ltwfBvOm0FnPQio
|
||||||
|
GliN+3PTWAL6bb8VYo2IR53VKpVHKSQUlzDOD9PGObXw1CT/+0zoMP7FBA4dTJDf
|
||||||
|
xQ63AQNpSCGdwbxZygPWzLV5O1WxMeXhnQRL1EBvMyJ52od0+HbajDXg5mNiBKNQ
|
||||||
|
AtVhB9pEu47Blu/KBqWjfh/GeBLPZB7MHmGNBYbBGGskKRLG4sIbwShs9cx8UM0/
|
||||||
|
9dxXkX2d8hjnSD/0ZBh54HHUaEvKAKfpz1L8AC0ll7biCAy0CZK23AoZu/KT8zJ+
|
||||||
|
qvz3AuJcW3lo7QKCAQEAzfGltNJawPUamBzNttKBUV+s2c6tkkdO92C/xKGnNp/x
|
||||||
|
dtg+TTTpyKV5zGy9fIsPoecnCFptS06vwAvCYZQ/Kd93stcFXHSiSwlY9H9tfffK
|
||||||
|
XzTEzoRLLIHsa0omRUufcrqpEqf2NjChr9wS5OsWAx9xkHGpNmUHEqB4FlPsM0C5
|
||||||
|
G0LdQCdplGYlTP0fMo5qL+VJhErle1kXE8kcrMMRzyvSTGe4lWGTph79vDUt2kQn
|
||||||
|
1IPLAJzzPEO5cqiXtzz1Z0N/aOn5b0FkYTAWmeY30LeMiJA46Df+/ihLVKPHKq6E
|
||||||
|
EMmFT8LeYMPQCbXLwRv/kaMm3D4tU9PejpD9Vk95swKCAQAtULBlxXeIVyaAAVba
|
||||||
|
L1H0Hroo0n41MtzSwt+568G05JSep5yr4/QKw0CmoY5Im7v/iLEDGmviKXIhaZTd
|
||||||
|
wHOvhGYEWGFVsFDG6hXRFL7EEoFVtBPPZ2sY9n1BkJ+lxI/XmhORZhJycNypaotU
|
||||||
|
hddets4HFrCyr86+/ybS2OWHmOa9x13Zl5WYQexrWFfxIaKqGtQOBOPEPjbxwp5U
|
||||||
|
dI1HF+i7X7hAWJqzbW2pQ31mm9EqjIztoho73diCp/e37q/G46kdBcFadEZ3NCWG
|
||||||
|
JDbfVmeTgU19usq5Vo9HhINMQvIOAwfuuVJRtmTBDHKaY7n8FfxqU/4j4RbA0Ncv
|
||||||
|
XYadAoIBAQC7yh4/UZCGhklUhhk/667OfchWvWGriCSaYGmQPdMzxjnIjAvvIUe9
|
||||||
|
riOTBSZXYYLmZHsmY/jK7KMGB3AsLTypSa9+ddAWqWn2dvOYyxNiAaSJK/RZfA9A
|
||||||
|
ocVfvvkhOfNAYIF+A+fyJ2pznsDkBf9tPkhN7kovl+mr/e25qZb1d09377770Pi7
|
||||||
|
thzEi+JLrRgYVLrCrPi2j4l7/Va/UaAPz+Dtu2GCT9vXgnhZtpb8R1kTViZFryTv
|
||||||
|
k+qbNYJzVm61Vit9mVAGe+WuzhlclJnN6LIZGG3zYHIulRAJrH1XDauHZfHzCKgi
|
||||||
|
FnMesy4thDMH/MhUfRtbylZTq45gtvCA
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIIFazCCA1OgAwIBAgIUYHD08+9S32VTD9IEsr2Oe1dH3VEwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM
|
||||||
|
GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjA0MDQxODE2NTZaFw0yMzA0
|
||||||
|
MDQxODE2NTZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw
|
||||||
|
HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB
|
||||||
|
AQUAA4ICDwAwggIKAoICAQDT0URxi7/l7ZGetkPv9Yh8h2s9BpbAR4Wq8sakgqET
|
||||||
|
Wg/nE/JQM5dPxroVbtZWWQXuJEFsgBKbASLa/eg5cyJv4Uu5WIZpG1LxdPEEIOSM
|
||||||
|
WjzoAGwoLxbTRGrS7qNXsknB9RwDuq8lPQiKkiAahg1Cn1vRrQ4cRrG+AkQWpRHJ
|
||||||
|
EDoLjCSo8IcAsKAZlw/eGtAcmeNvkr5AujEwXjIwx2FoDyKaNGRH5Z7gLWvCKBNx
|
||||||
|
QuJuMTzh8guLqdGbE4hH3rqyICbW5DGPaOZLLErWuJ7kEhLZG2HDW5JaXOr0QfFY
|
||||||
|
AA8pl9/qCuFMdoxRUKRcYBoxoMmz6dlsmipN7vIj+TT6TemwcAT25pwMJIVS4WC4
|
||||||
|
+BZilNH2eWKD9hZA8Kq7FDPu+1rxOJaLbE/bvpK8jZeRdqFzE1eBCgPkw8D8V0r7
|
||||||
|
J18d+DsmgOe2kRycaia/t9M4rhqe0FXjX1X1lzQ52grxgc28Ejd1fGQXIJmdTh4B
|
||||||
|
qKqTzxup0izS7dgFP1Ezm6Z4O+wklpL5uQF2Ex4X6QEj76iCxH+J/01/cvbxMe3i
|
||||||
|
uGXECbO/y1FIrg7FKzZSrQo4aP63lS7Y7aq0t2t6kOS83ebhnpgHClgFs8/C3ayz
|
||||||
|
YBBtbK63PYthwO8Rt6WamCIZFF5tA3XoI4AkfZcWD18loZai+QznVzbLNINf++rT
|
||||||
|
wwIDAQABo1MwUTAdBgNVHQ4EFgQU+9eHi2eqy0f3fDS0GjqkijGDDocwHwYDVR0j
|
||||||
|
BBgwFoAU+9eHi2eqy0f3fDS0GjqkijGDDocwDwYDVR0TAQH/BAUwAwEB/zANBgkq
|
||||||
|
hkiG9w0BAQsFAAOCAgEAAJXO3qUc/PW75pI2dt1cKv20VqozkfEf7P0eeVisCDxn
|
||||||
|
1p3QhVgI2lEe9kzdHp7t42g5xLkUhQEVmBcKm9xbl+k2D1X0+T8px1x6ZiWfbhXL
|
||||||
|
ptc/qCIXjPCgVN3s+Kasii3hHkZxKGZz/ySmBmfDJZjQZtbZzQWpvvX6SD4s7sjo
|
||||||
|
gWbZW3qvQ0bFTGdD1IjKYGaxK6aSrNkAIutiAX4RczJ1QSwb9Z2EIen+ABAvOZS9
|
||||||
|
xv3LiiidWcuOT7WzXEa4QvOslCEkAF+jj6mGYB7NWtly0kj4AEPvI4IoYTi9dohS
|
||||||
|
CA0zd1DTfjRwpAnT5P4sj4mpjLyRBumeeVGpCZhUxfKpFjIB2AnlgxrU+LPq5c9R
|
||||||
|
ZZ9Q5oeLxjRPjpqBeWwgnbjXstQCL9g0U7SsEemsv+zmvG5COhAmG5Wce/65ILlg
|
||||||
|
450H4bcn1ul0xvxz9hat6tqEZry3HcNE/CGDT+tXuhHpqOXkY1/c78C0QbWjWodR
|
||||||
|
tCvlXW00a+7TlEhNr4XBNdqtIQfYS9K9yiVVNfZLPEsN/SA3BGXmrr+du1/E4Ria
|
||||||
|
CkVpmBdJsVu5eMaUj1arsCqI4fwHzljtojJe/pCzZBVkOaSWQEQ+LL4iVnMas68m
|
||||||
|
qyshtNf4KNiM55OQmyTiFHMTIxCtdEcHaR3mUxR7GrIhc/bxyxUUBtMAuUX0Kjs=
|
||||||
|
-----END CERTIFICATE-----
|
3
data/unreal/README
Normal file
3
data/unreal/README
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
Boilerplate files so Unreal can be built non-interactively.
|
||||||
|
|
||||||
|
Obviously, you shouldn't use the .pem in a production environment!
|
28
data/unreal/config.settings
Normal file
28
data/unreal/config.settings
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
BASEPATH="$HOME/.local/unrealircd"
|
||||||
|
BINDIR="$HOME/.local/unrealircd/bin"
|
||||||
|
DATADIR="$HOME/.local/unrealircd/data"
|
||||||
|
CONFDIR="$HOME/.local/unrealircd/conf"
|
||||||
|
MODULESDIR="$HOME/.local/unrealircd/modules"
|
||||||
|
LOGDIR="$HOME/.local/unrealircd/logs"
|
||||||
|
CACHEDIR="$HOME/.local/unrealircd/cache"
|
||||||
|
DOCDIR="$HOME/.local/unrealircd/doc"
|
||||||
|
TMPDIR="$HOME/.local/unrealircd/tmp"
|
||||||
|
PRIVATELIBDIR="$HOME/.local/unrealircd/lib"
|
||||||
|
PREFIXAQ="1"
|
||||||
|
MAXCONNECTIONS_REQUEST="auto"
|
||||||
|
NICKNAMEHISTORYLENGTH="2000"
|
||||||
|
DEFPERM="0600"
|
||||||
|
SSLDIR=""
|
||||||
|
REMOTEINC=""
|
||||||
|
CURLDIR=""
|
||||||
|
SHOWLISTMODES="1"
|
||||||
|
NOOPEROVERRIDE=""
|
||||||
|
OPEROVERRIDEVERIFY=""
|
||||||
|
GENCERTIFICATE="1"
|
||||||
|
|
||||||
|
# Use system argon to avoid getting SIGILLed if the build machine has a more recent
|
||||||
|
# CPU than the one running the tests.
|
||||||
|
EXTRAPARA="--with-system-argon2"
|
||||||
|
|
||||||
|
ADVANCED=""
|
||||||
|
|
14
data/unreal/server.cert.pem
Normal file
14
data/unreal/server.cert.pem
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIICGDCCAZ6gAwIBAgIUeHAOQnvT7N9kCmUuIklelkzz8SUwCgYIKoZIzj0EAwIw
|
||||||
|
QzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRIwEAYDVQQKDAlJUkMg
|
||||||
|
Z2Vla3MxDTALBgNVBAsMBElSQ2QwHhcNMjEwNzAyMTk1MTM5WhcNMzEwNjMwMTk1
|
||||||
|
MTM5WjBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQBgNVBAoM
|
||||||
|
CUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuBBAAiA2IA
|
||||||
|
BHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBCUbISsk/C
|
||||||
|
Txru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTpEAqMSnrc
|
||||||
|
zKNTMFEwHQYDVR0OBBYEFFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMB8GA1UdIwQYMBaA
|
||||||
|
FFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0E
|
||||||
|
AwIDaAAwZQIwAo29xUEAzqOMgPAWtMifHFLuPQPuWcNGbaI5S4W81NO8uIcNv/kM
|
||||||
|
mFocuITr76p0AjEApzGjc5wM+KydwoVTP+fg1aGQA13Ba2nCzN3R5XwR/USCigjv
|
||||||
|
na1QtWAKjpvR/rsp
|
||||||
|
-----END CERTIFICATE-----
|
9
data/unreal/server.key.pem
Normal file
9
data/unreal/server.key.pem
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN EC PARAMETERS-----
|
||||||
|
BgUrgQQAIg==
|
||||||
|
-----END EC PARAMETERS-----
|
||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MIGkAgEBBDCWkDHktJiTqC7im+Ni37fbXxtMBqIKPwkAItpKMeuh28QrXWwNE1a5
|
||||||
|
wSa38C1nd8igBwYFK4EEACKhZANiAARwOoqi0IJEuNsR4P3RD6vXSo5S4tB1rwjO
|
||||||
|
5zglwMhctQ465mxaEgkAQlGyErJPwk8a7t2CH13uo0gkvFE29eZu9Jq5polK/7ps
|
||||||
|
YqowDorKNOdYQq88VZo06RAKjEp63Mw=
|
||||||
|
-----END EC PRIVATE KEY-----
|
9
data/unreal/server.req.pem
Normal file
9
data/unreal/server.req.pem
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
-----BEGIN CERTIFICATE REQUEST-----
|
||||||
|
MIIBOjCBwgIBADBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQ
|
||||||
|
BgNVBAoMCUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuB
|
||||||
|
BAAiA2IABHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBC
|
||||||
|
UbISsk/CTxru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTp
|
||||||
|
EAqMSnrczKAAMAoGCCqGSM49BAMCA2cAMGQCMEL5ezlauGUaxh+pXt897ffmzqci
|
||||||
|
fqYm3FgVW5x6EdtCxtcwwAwnR84LKcd/YRKOygIwNmZiRVKeSeC7Ess1PxuzT1Mu
|
||||||
|
Cw3bBqkE5LmO1hu/+0lK+QoFPEeLDrygIh+SDdGH
|
||||||
|
-----END CERTIFICATE REQUEST-----
|
@ -1,84 +0,0 @@
|
|||||||
import sys
|
|
||||||
import unittest
|
|
||||||
import argparse
|
|
||||||
import unittest
|
|
||||||
import functools
|
|
||||||
import importlib
|
|
||||||
from .cases import _IrcTestCase
|
|
||||||
from .runner import TextTestRunner
|
|
||||||
from .specifications import Specifications
|
|
||||||
from .basecontrollers import BaseClientController, BaseServerController
|
|
||||||
|
|
||||||
def main(args):
|
|
||||||
try:
|
|
||||||
module = importlib.import_module(args.module)
|
|
||||||
except ImportError:
|
|
||||||
print('Cannot import module {}'.format(args.module), file=sys.stderr)
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
controller_class = module.get_irctest_controller_class()
|
|
||||||
if issubclass(controller_class, BaseClientController):
|
|
||||||
import irctest.client_tests as module
|
|
||||||
elif issubclass(controller_class, BaseServerController):
|
|
||||||
import irctest.server_tests as module
|
|
||||||
else:
|
|
||||||
print(r'{}.Controller should be a subclass of '
|
|
||||||
r'irctest.basecontroller.Base{{Client,Server}}Controller'
|
|
||||||
.format(args.module),
|
|
||||||
file=sys.stderr)
|
|
||||||
exit(1)
|
|
||||||
_IrcTestCase.controllerClass = controller_class
|
|
||||||
_IrcTestCase.show_io = args.show_io
|
|
||||||
_IrcTestCase.strictTests = not args.loose
|
|
||||||
if args.specification:
|
|
||||||
try:
|
|
||||||
_IrcTestCase.testedSpecifications = frozenset(
|
|
||||||
Specifications.of_name(x) for x in args.specification
|
|
||||||
)
|
|
||||||
except ValueError:
|
|
||||||
print('Invalid set of specifications: {}'
|
|
||||||
.format(', '.join(args.specification)))
|
|
||||||
exit(1)
|
|
||||||
else:
|
|
||||||
_IrcTestCase.testedSpecifications = frozenset(
|
|
||||||
Specifications)
|
|
||||||
print('Testing {} on specification(s): {}'.format(
|
|
||||||
controller_class.software_name,
|
|
||||||
', '.join(sorted(map(lambda x:x.value,
|
|
||||||
_IrcTestCase.testedSpecifications)))))
|
|
||||||
ts = module.discover()
|
|
||||||
testRunner = TextTestRunner(
|
|
||||||
verbosity=args.verbose,
|
|
||||||
descriptions=True,
|
|
||||||
)
|
|
||||||
testLoader = unittest.loader.defaultTestLoader
|
|
||||||
result = testRunner.run(ts)
|
|
||||||
if result.failures or result.errors:
|
|
||||||
exit(1)
|
|
||||||
else:
|
|
||||||
exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description='A script to test interoperability of IRC software.')
|
|
||||||
parser.add_argument('module', type=str,
|
|
||||||
help='The module used to run the tested program.')
|
|
||||||
parser.add_argument('--show-io', action='store_true',
|
|
||||||
help='Show input/outputs with the tested program.')
|
|
||||||
parser.add_argument('-v', '--verbose', action='count', default=1,
|
|
||||||
help='Verbosity. Give this option multiple times to make '
|
|
||||||
'it even more verbose.')
|
|
||||||
parser.add_argument('-s', '--specification', type=str, action='append',
|
|
||||||
help=('The set of specifications to test the program with. '
|
|
||||||
'Valid values: {}. '
|
|
||||||
'Use this option multiple times to test with multiple '
|
|
||||||
'specifications. If it is not given, defaults to all.')
|
|
||||||
.format(', '.join(x.value for x in Specifications)))
|
|
||||||
parser.add_argument('-l', '--loose', action='store_true',
|
|
||||||
help='Disables strict checks of conformity to the specification. '
|
|
||||||
'Strict means the specification is unclear, and the most restrictive '
|
|
||||||
'interpretation is choosen.')
|
|
||||||
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
main(args)
|
|
@ -1,17 +1,23 @@
|
|||||||
|
import dataclasses
|
||||||
import enum
|
import enum
|
||||||
import collections
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
@enum.unique
|
@enum.unique
|
||||||
class Mechanisms(enum.Enum):
|
class Mechanisms(enum.Enum):
|
||||||
"""Enumeration for representing possible mechanisms."""
|
"""Enumeration for representing possible mechanisms."""
|
||||||
@classmethod
|
|
||||||
def as_string(cls, mech):
|
def to_string(self) -> str:
|
||||||
return {cls.plain: 'PLAIN',
|
return self.name.upper().replace("_", "-")
|
||||||
cls.ecdsa_nist256p_challenge: 'ECDSA-NIST256P-CHALLENGE',
|
|
||||||
}[mech]
|
|
||||||
plain = 1
|
plain = 1
|
||||||
ecdsa_nist256p_challenge = 2
|
ecdsa_nist256p_challenge = 2
|
||||||
|
scram_sha_256 = 3
|
||||||
|
|
||||||
Authentication = collections.namedtuple('Authentication',
|
|
||||||
'mechanisms username password ecdsa_key')
|
@dataclasses.dataclass
|
||||||
Authentication.__new__.__defaults__ = ([Mechanisms.plain], None, None, None)
|
class Authentication:
|
||||||
|
mechanisms: Tuple[Mechanisms] = (Mechanisms.plain,)
|
||||||
|
username: Optional[str] = None
|
||||||
|
password: Optional[str] = None
|
||||||
|
ecdsa_key: Optional[str] = None
|
||||||
|
@ -1,90 +1,429 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import dataclasses
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import tempfile
|
|
||||||
import time
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import psutil
|
import tempfile
|
||||||
|
import textwrap
|
||||||
|
import time
|
||||||
|
from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type
|
||||||
|
|
||||||
|
import irctest
|
||||||
|
|
||||||
|
from . import authentication, tls
|
||||||
|
from .client_mock import ClientMock
|
||||||
|
from .irc_utils.filelock import FileLock
|
||||||
|
from .irc_utils.junkdrawer import find_hostname_and_port
|
||||||
|
from .irc_utils.message_parser import Message
|
||||||
from .runner import NotImplementedByController
|
from .runner import NotImplementedByController
|
||||||
|
|
||||||
|
|
||||||
|
class ProcessStopped(Exception):
|
||||||
|
"""Raised when the controlled process stopped unexpectedly"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class TestCaseControllerConfig:
|
||||||
|
"""Test-case-specific configuration passed to the controller.
|
||||||
|
This is usually used to ask controllers to enable a feature;
|
||||||
|
but should not be an issue if controllers enable it all the time."""
|
||||||
|
|
||||||
|
chathistory: bool = False
|
||||||
|
"""Whether to enable chathistory features."""
|
||||||
|
|
||||||
|
ergo_roleplay: bool = False
|
||||||
|
"""Whether to enable the Ergo role-play commands."""
|
||||||
|
|
||||||
|
ergo_config: Optional[Callable[[Dict], Any]] = None
|
||||||
|
"""Oragono-specific configuration function that alters the dict in-place
|
||||||
|
This should be used as little as possible, using the other attributes instead;
|
||||||
|
as they are work with any controller."""
|
||||||
|
|
||||||
|
|
||||||
class _BaseController:
|
class _BaseController:
|
||||||
"""Base class for software controllers.
|
"""Base class for software controllers.
|
||||||
|
|
||||||
A software controller is an object that handles configuring and running
|
A software controller is an object that handles configuring and running
|
||||||
a process (eg. a server or a client), as well as sending it instructions
|
a process (eg. a server or a client), as well as sending it instructions
|
||||||
that are not part of the IRC specification."""
|
that are not part of the IRC specification."""
|
||||||
pass
|
|
||||||
|
|
||||||
class DirectoryBasedController(_BaseController):
|
# set by conftest.py
|
||||||
"""Helper for controllers whose software configuration is based on an
|
openssl_bin: str
|
||||||
arbitrary directory."""
|
|
||||||
def __init__(self):
|
supports_sts: bool
|
||||||
super().__init__()
|
supported_sasl_mechanisms: Set[str]
|
||||||
self.directory = None
|
|
||||||
|
proc: Optional[subprocess.Popen]
|
||||||
|
|
||||||
|
_used_ports_path = Path(tempfile.gettempdir()) / "irctest_ports.json"
|
||||||
|
_port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock")
|
||||||
|
|
||||||
|
def __init__(self, test_config: TestCaseControllerConfig):
|
||||||
|
self.test_config = test_config
|
||||||
self.proc = None
|
self.proc = None
|
||||||
|
self._own_ports: Set[Tuple[str, int]] = set()
|
||||||
|
|
||||||
def kill_proc(self):
|
@contextlib.contextmanager
|
||||||
|
def _used_ports(self) -> Iterator[Set[Tuple[str, int]]]:
|
||||||
|
with self._port_lock:
|
||||||
|
if not self._used_ports_path.exists():
|
||||||
|
self._used_ports_path.write_text("[]")
|
||||||
|
used_ports = {
|
||||||
|
(h, p) for (h, p) in json.loads(self._used_ports_path.read_text())
|
||||||
|
}
|
||||||
|
yield used_ports
|
||||||
|
self._used_ports_path.write_text(json.dumps(list(used_ports)))
|
||||||
|
|
||||||
|
def get_hostname_and_port(self) -> Tuple[str, int]:
|
||||||
|
with self._used_ports() as used_ports:
|
||||||
|
while True:
|
||||||
|
(hostname, port) = find_hostname_and_port()
|
||||||
|
if (hostname, port) not in used_ports:
|
||||||
|
# double-checking in self._used_ports to prevent collisions
|
||||||
|
# between controllers starting at the same time.
|
||||||
|
break
|
||||||
|
|
||||||
|
used_ports.add((hostname, port))
|
||||||
|
self._own_ports.add((hostname, port))
|
||||||
|
|
||||||
|
return (hostname, port)
|
||||||
|
|
||||||
|
def check_is_alive(self) -> None:
|
||||||
|
assert self.proc
|
||||||
|
self.proc.poll()
|
||||||
|
if self.proc.returncode is not None:
|
||||||
|
raise ProcessStopped(f"process returned {self.proc.returncode}")
|
||||||
|
|
||||||
|
def kill_proc(self) -> None:
|
||||||
"""Terminates the controlled process, waits for it to exit, and
|
"""Terminates the controlled process, waits for it to exit, and
|
||||||
eventually kills it."""
|
eventually kills it."""
|
||||||
|
assert self.proc
|
||||||
self.proc.terminate()
|
self.proc.terminate()
|
||||||
try:
|
try:
|
||||||
self.proc.wait(5)
|
self.proc.wait(5)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
self.proc = None
|
self.proc = None
|
||||||
def kill(self):
|
|
||||||
|
def kill(self) -> None:
|
||||||
"""Calls `kill_proc` and cleans the configuration."""
|
"""Calls `kill_proc` and cleans the configuration."""
|
||||||
if self.proc:
|
if self.proc:
|
||||||
self.kill_proc()
|
self.kill_proc()
|
||||||
|
|
||||||
|
with self._used_ports() as used_ports:
|
||||||
|
for hostname, port in list(self._own_ports):
|
||||||
|
used_ports.remove((hostname, port))
|
||||||
|
self._own_ports.remove((hostname, port))
|
||||||
|
|
||||||
|
|
||||||
|
class DirectoryBasedController(_BaseController):
|
||||||
|
"""Helper for controllers whose software configuration is based on an
|
||||||
|
arbitrary directory."""
|
||||||
|
|
||||||
|
directory: Optional[Path]
|
||||||
|
|
||||||
|
def __init__(self, test_config: TestCaseControllerConfig):
|
||||||
|
super().__init__(test_config)
|
||||||
|
self.directory = None
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
"""Calls `kill_proc` and cleans the configuration."""
|
||||||
|
super().kill()
|
||||||
if self.directory:
|
if self.directory:
|
||||||
shutil.rmtree(self.directory)
|
shutil.rmtree(self.directory)
|
||||||
def open_file(self, name, mode='a'):
|
|
||||||
|
def terminate(self) -> None:
|
||||||
|
"""Stops the process gracefully, and does not clean its config."""
|
||||||
|
assert self.proc
|
||||||
|
self.proc.terminate()
|
||||||
|
self.proc.wait()
|
||||||
|
self.proc = None
|
||||||
|
|
||||||
|
def open_file(self, name: str, mode: str = "a") -> IO:
|
||||||
"""Open a file in the configuration directory."""
|
"""Open a file in the configuration directory."""
|
||||||
assert self.directory
|
assert self.directory
|
||||||
if os.sep in name:
|
if os.sep in name:
|
||||||
dir_ = os.path.join(self.directory, os.path.dirname(name))
|
dir_ = self.directory / os.path.dirname(name)
|
||||||
if not os.path.isdir(dir_):
|
dir_.mkdir(parents=True, exist_ok=True)
|
||||||
os.makedirs(dir_)
|
assert dir_.is_dir()
|
||||||
assert os.path.isdir(dir_)
|
return (self.directory / name).open(mode)
|
||||||
return open(os.path.join(self.directory, name), mode)
|
|
||||||
def create_config(self):
|
def create_config(self) -> None:
|
||||||
self.directory = tempfile.mkdtemp()
|
if not self.directory:
|
||||||
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
|
def gen_ssl(self) -> None:
|
||||||
|
assert self.directory
|
||||||
|
self.csr_path = self.directory / "ssl.csr"
|
||||||
|
self.key_path = self.directory / "ssl.key"
|
||||||
|
self.pem_path = self.directory / "ssl.pem"
|
||||||
|
self.dh_path = self.directory / "dh.pem"
|
||||||
|
subprocess.check_output(
|
||||||
|
[
|
||||||
|
self.openssl_bin,
|
||||||
|
"req",
|
||||||
|
"-new",
|
||||||
|
"-newkey",
|
||||||
|
"rsa",
|
||||||
|
"-nodes",
|
||||||
|
"-out",
|
||||||
|
self.csr_path,
|
||||||
|
"-keyout",
|
||||||
|
self.key_path,
|
||||||
|
"-batch",
|
||||||
|
],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
subprocess.check_output(
|
||||||
|
[
|
||||||
|
self.openssl_bin,
|
||||||
|
"x509",
|
||||||
|
"-req",
|
||||||
|
"-in",
|
||||||
|
self.csr_path,
|
||||||
|
"-signkey",
|
||||||
|
self.key_path,
|
||||||
|
"-out",
|
||||||
|
self.pem_path,
|
||||||
|
],
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
with self.dh_path.open("w") as fd:
|
||||||
|
fd.write(
|
||||||
|
textwrap.dedent(
|
||||||
|
"""
|
||||||
|
-----BEGIN DH PARAMETERS-----
|
||||||
|
MIGHAoGBAJICSyQAiLj1fw8b5xELcnpqBQ+wvOyKgim4IetWOgZnRQFkTgOeoRZD
|
||||||
|
HksACRFJL/EqHxDKcy/2Ghwr2axhNxSJ+UOBmraP3WfodV/fCDPnZ+XnI9fjHsIr
|
||||||
|
rjisPMqomjXeiTB1UeAHvLUmCK4yx6lpAJsCYwJjsqkycUfHiy1bAgEC
|
||||||
|
-----END DH PARAMETERS-----
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def gen_ssl(self):
|
|
||||||
self.csr_path = os.path.join(self.directory, 'ssl.csr')
|
|
||||||
self.key_path = os.path.join(self.directory, 'ssl.key')
|
|
||||||
self.pem_path = os.path.join(self.directory, 'ssl.pem')
|
|
||||||
self.dh_path = os.path.join(self.directory, 'dh.pem')
|
|
||||||
subprocess.check_output(['openssl', 'req', '-new', '-newkey', 'rsa',
|
|
||||||
'-nodes', '-out', self.csr_path, '-keyout', self.key_path,
|
|
||||||
'-batch'],
|
|
||||||
stderr=subprocess.DEVNULL)
|
|
||||||
subprocess.check_output(['openssl', 'x509', '-req',
|
|
||||||
'-in', self.csr_path, '-signkey', self.key_path,
|
|
||||||
'-out', self.pem_path],
|
|
||||||
stderr=subprocess.DEVNULL)
|
|
||||||
subprocess.check_output(['openssl', 'dhparam',
|
|
||||||
'-out', self.dh_path, '128'],
|
|
||||||
stderr=subprocess.DEVNULL)
|
|
||||||
|
|
||||||
class BaseClientController(_BaseController):
|
class BaseClientController(_BaseController):
|
||||||
"""Base controller for IRC clients."""
|
"""Base controller for IRC clients."""
|
||||||
def run(self, hostname, port, auth):
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
auth: Optional[authentication.Authentication],
|
||||||
|
tls_config: Optional[tls.TlsConfig] = None,
|
||||||
|
) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
|
||||||
class BaseServerController(_BaseController):
|
class BaseServerController(_BaseController):
|
||||||
"""Base controller for IRC server."""
|
"""Base controller for IRC server."""
|
||||||
port_open = False
|
|
||||||
def run(self, hostname, port, password,
|
|
||||||
valid_metadata_keys, invalid_metadata_keys):
|
|
||||||
raise NotImplementedError()
|
|
||||||
def registerUser(self, case, username, password=None):
|
|
||||||
raise NotImplementedByController('registration')
|
|
||||||
def wait_for_port(self):
|
|
||||||
while not self.port_open:
|
|
||||||
time.sleep(0.1)
|
|
||||||
for conn in psutil.Process(self.proc.pid).connections():
|
|
||||||
if conn.laddr[1] == self.port:
|
|
||||||
self.port_open = True
|
|
||||||
|
|
||||||
|
software_name: str # Class property
|
||||||
|
_port_wait_interval = 0.1
|
||||||
|
port_open = False
|
||||||
|
port: int
|
||||||
|
hostname: str
|
||||||
|
services_controller: Optional[BaseServicesController] = None
|
||||||
|
services_controller_class: Type[BaseServicesController]
|
||||||
|
extban_mute_char: Optional[str] = None
|
||||||
|
"""Character used for the 'mute' extban"""
|
||||||
|
nickserv = "NickServ"
|
||||||
|
sync_sleep_time = 0.0
|
||||||
|
"""How many seconds to sleep before clients synchronously get messages.
|
||||||
|
|
||||||
|
This can be 0 for servers answering all commands in order (all but Sable as of
|
||||||
|
this writing), as irctest emits a PING, waits for a PONG, and captures all messages
|
||||||
|
between the two."""
|
||||||
|
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.faketime_enabled = False
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def registerUser(
|
||||||
|
self,
|
||||||
|
case: irctest.cases.BaseServerTestCase, # type: ignore
|
||||||
|
username: str,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
if self.services_controller is not None:
|
||||||
|
self.services_controller.registerUser(case, username, password)
|
||||||
|
else:
|
||||||
|
raise NotImplementedByController("account registration")
|
||||||
|
|
||||||
|
def wait_for_port(self) -> None:
|
||||||
|
started_at = time.time()
|
||||||
|
while not self.port_open:
|
||||||
|
self.check_is_alive()
|
||||||
|
time.sleep(self._port_wait_interval)
|
||||||
|
try:
|
||||||
|
c = socket.create_connection(("localhost", self.port), timeout=1.0)
|
||||||
|
c.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
|
||||||
|
# Make sure the server properly processes the disconnect.
|
||||||
|
# Otherwise, it may still count it in LUSER and fail tests in
|
||||||
|
# test_lusers.py (eg. this happens with Charybdis 3.5.0)
|
||||||
|
c.sendall(b"QUIT :chkport\r\n")
|
||||||
|
data = b""
|
||||||
|
try:
|
||||||
|
while b"chkport" not in data and b"ERROR" not in data:
|
||||||
|
data += c.recv(4096)
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
c.send(b" ") # Triggers BrokenPipeError
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
# ircu2 cuts the connection without a message if registration
|
||||||
|
# is not complete.
|
||||||
|
pass
|
||||||
|
except socket.timeout:
|
||||||
|
# irc2 just keeps it open
|
||||||
|
pass
|
||||||
|
|
||||||
|
c.close()
|
||||||
|
self.port_open = True
|
||||||
|
except ConnectionRefusedError:
|
||||||
|
if time.time() - started_at >= 60:
|
||||||
|
# waited for 60 seconds, giving up
|
||||||
|
raise
|
||||||
|
|
||||||
|
def wait_for_services(self) -> None:
|
||||||
|
assert self.services_controller
|
||||||
|
self.services_controller.wait_for_services()
|
||||||
|
|
||||||
|
def terminate(self) -> None:
|
||||||
|
if self.services_controller is not None:
|
||||||
|
self.services_controller.terminate() # type: ignore
|
||||||
|
super().terminate() # type: ignore
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
if self.services_controller is not None:
|
||||||
|
self.services_controller.kill() # type: ignore
|
||||||
|
super().kill()
|
||||||
|
|
||||||
|
|
||||||
|
class BaseServicesController(_BaseController):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
test_config: TestCaseControllerConfig,
|
||||||
|
server_controller: BaseServerController,
|
||||||
|
):
|
||||||
|
super().__init__(test_config)
|
||||||
|
self.test_config = test_config
|
||||||
|
self.server_controller = server_controller
|
||||||
|
self.services_up = False
|
||||||
|
|
||||||
|
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
||||||
|
raise NotImplementedError("BaseServerController.run()")
|
||||||
|
|
||||||
|
def wait_for_services(self) -> None:
|
||||||
|
if self.services_up:
|
||||||
|
# Don't check again if they are already available
|
||||||
|
return
|
||||||
|
self.server_controller.wait_for_port()
|
||||||
|
|
||||||
|
c = ClientMock(name="chkNS", show_io=True)
|
||||||
|
c.connect(self.server_controller.hostname, self.server_controller.port)
|
||||||
|
c.sendLine("NICK chkNS")
|
||||||
|
c.sendLine("USER chk chk chk chk")
|
||||||
|
time.sleep(self.server_controller.sync_sleep_time)
|
||||||
|
got_end_of_motd = False
|
||||||
|
while not got_end_of_motd:
|
||||||
|
for msg in c.getMessages(synchronize=False):
|
||||||
|
if msg.command == "PING":
|
||||||
|
# Hi Unreal
|
||||||
|
c.sendLine("PONG :" + msg.params[0])
|
||||||
|
if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD
|
||||||
|
got_end_of_motd = True
|
||||||
|
|
||||||
|
timeout = time.time() + 10
|
||||||
|
while True:
|
||||||
|
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :help")
|
||||||
|
|
||||||
|
msgs = self.getNickServResponse(c, timeout=1)
|
||||||
|
for msg in msgs:
|
||||||
|
if msg.command == "401":
|
||||||
|
# NickServ not available yet
|
||||||
|
pass
|
||||||
|
elif msg.command in ("MODE", "221"): # RPL_UMODEIS
|
||||||
|
pass
|
||||||
|
elif msg.command == "NOTICE":
|
||||||
|
assert msg.prefix is not None
|
||||||
|
if "!" not in msg.prefix and "." in msg.prefix:
|
||||||
|
# Server notice
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# NickServ is available
|
||||||
|
assert "nickserv" in (msg.prefix or "").lower(), msg
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
assert False, f"unexpected reply from NickServ: {msg}"
|
||||||
|
else:
|
||||||
|
if time.time() > timeout:
|
||||||
|
raise Exception("Timeout while waiting for NickServ")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If we're here, it means we broke from the for loop, so NickServ
|
||||||
|
# is available and we can break again
|
||||||
|
break
|
||||||
|
|
||||||
|
c.sendLine("QUIT")
|
||||||
|
c.getMessages()
|
||||||
|
c.disconnect()
|
||||||
|
self.services_up = True
|
||||||
|
|
||||||
|
def getNickServResponse(self, client: Any, timeout: int = 0) -> List[Message]:
|
||||||
|
"""Wrapper aroung getMessages() that waits longer, because NickServ
|
||||||
|
is queried asynchronously."""
|
||||||
|
msgs: List[Message] = []
|
||||||
|
start_time = time.time()
|
||||||
|
while not msgs and (not timeout or start_time + timeout > time.time()):
|
||||||
|
time.sleep(0.05)
|
||||||
|
msgs = client.getMessages()
|
||||||
|
return msgs
|
||||||
|
|
||||||
|
def registerUser(
|
||||||
|
self,
|
||||||
|
case: irctest.cases.BaseServerTestCase, # type: ignore
|
||||||
|
username: str,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
if not case.run_services:
|
||||||
|
raise ValueError(
|
||||||
|
"Attempted to register a nick, but `run_services` it not True."
|
||||||
|
)
|
||||||
|
assert password
|
||||||
|
client = case.addClient(show_io=True)
|
||||||
|
case.sendLine(client, "NICK " + username)
|
||||||
|
case.sendLine(client, "USER r e g :user")
|
||||||
|
while case.getRegistrationMessage(client).command != "001":
|
||||||
|
pass
|
||||||
|
case.getMessages(client)
|
||||||
|
case.sendLine(
|
||||||
|
client,
|
||||||
|
f"PRIVMSG {self.server_controller.nickserv} "
|
||||||
|
f":REGISTER {password} foo@example.org",
|
||||||
|
)
|
||||||
|
msgs = self.getNickServResponse(case.clients[client])
|
||||||
|
if self.server_controller.software_name == "inspircd":
|
||||||
|
assert "900" in {msg.command for msg in msgs}, msgs
|
||||||
|
assert "NOTICE" in {msg.command for msg in msgs}, msgs
|
||||||
|
case.sendLine(client, "QUIT")
|
||||||
|
case.assertDisconnected(client)
|
||||||
|
989
irctest/cases.py
989
irctest/cases.py
File diff suppressed because it is too large
Load Diff
@ -1,35 +1,55 @@
|
|||||||
import ssl
|
|
||||||
import time
|
|
||||||
import socket
|
import socket
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from typing import Any, Callable, List, Optional, Union
|
||||||
|
|
||||||
|
from .exceptions import ConnectionClosed, NoMessageException
|
||||||
from .irc_utils import message_parser
|
from .irc_utils import message_parser
|
||||||
from .exceptions import NoMessageException, ConnectionClosed
|
|
||||||
|
|
||||||
class ClientMock:
|
class ClientMock:
|
||||||
def __init__(self, name, show_io):
|
def __init__(self, name: Any, show_io: bool):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.show_io = show_io
|
self.show_io = show_io
|
||||||
self.inbuffer = []
|
self.inbuffer: List[message_parser.Message] = []
|
||||||
self.ssl = False
|
self.ssl = False
|
||||||
def connect(self, hostname, port):
|
|
||||||
|
def connect(self, hostname: str, port: int) -> None:
|
||||||
self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
self.conn.settimeout(1) # TODO: configurable
|
|
||||||
|
# probably useful for test_buffering, as it relies on chunking
|
||||||
|
# the packets to be useful
|
||||||
|
self.conn.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
|
||||||
|
|
||||||
|
self.conn.settimeout(1) # TODO: configurable
|
||||||
self.conn.connect((hostname, port))
|
self.conn.connect((hostname, port))
|
||||||
if self.show_io:
|
if self.show_io:
|
||||||
print('{:.3f} {}: connects to server.'.format(time.time(), self.name))
|
print("{:.3f} {}: connects to server.".format(time.time(), self.name))
|
||||||
def disconnect(self):
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
if self.show_io:
|
if self.show_io:
|
||||||
print('{:.3f} {}: disconnects from server.'.format(time.time(), self.name))
|
print("{:.3f} {}: disconnects from server.".format(time.time(), self.name))
|
||||||
self.conn.close()
|
self.conn.close()
|
||||||
def starttls(self):
|
|
||||||
assert not self.ssl, 'SSL already active.'
|
def starttls(self) -> None:
|
||||||
|
assert not self.ssl, "SSL already active."
|
||||||
self.conn = ssl.wrap_socket(self.conn)
|
self.conn = ssl.wrap_socket(self.conn)
|
||||||
self.ssl = True
|
self.ssl = True
|
||||||
def getMessages(self, synchronize=True, assert_get_one=False):
|
|
||||||
|
def getMessages(
|
||||||
|
self, synchronize: bool = True, assert_get_one: bool = False, raw: bool = False
|
||||||
|
) -> List[message_parser.Message]:
|
||||||
|
"""actually returns List[str] in the rare case where raw=True."""
|
||||||
|
__tracebackhide__ = True # Hide from pytest tracebacks on test failure.
|
||||||
|
token: Optional[str]
|
||||||
if synchronize:
|
if synchronize:
|
||||||
token = 'synchronize{}'.format(time.monotonic())
|
token = "synchronize{}".format(time.monotonic())
|
||||||
self.sendLine('PING {}'.format(token))
|
self.sendLine("PING {}".format(token))
|
||||||
|
else:
|
||||||
|
token = None
|
||||||
got_pong = False
|
got_pong = False
|
||||||
data = b''
|
data = b""
|
||||||
(self.inbuffer, messages) = ([], self.inbuffer)
|
(self.inbuffer, messages) = ([], self.inbuffer)
|
||||||
conn = self.conn
|
conn = self.conn
|
||||||
try:
|
try:
|
||||||
@ -37,12 +57,11 @@ class ClientMock:
|
|||||||
try:
|
try:
|
||||||
new_data = conn.recv(4096)
|
new_data = conn.recv(4096)
|
||||||
except socket.timeout:
|
except socket.timeout:
|
||||||
if not assert_get_one and not synchronize and data == b'':
|
if not assert_get_one and not synchronize and data == b"":
|
||||||
# Received nothing
|
# Received nothing
|
||||||
return []
|
return []
|
||||||
if self.show_io:
|
if self.show_io:
|
||||||
print('{:.3f} waiting…'.format(time.time()))
|
print("{:.3f} {}: waiting…".format(time.time(), self.name))
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
continue
|
||||||
except ConnectionResetError:
|
except ConnectionResetError:
|
||||||
raise ConnectionClosed()
|
raise ConnectionClosed()
|
||||||
@ -51,26 +70,38 @@ class ClientMock:
|
|||||||
# Connection closed
|
# Connection closed
|
||||||
raise ConnectionClosed()
|
raise ConnectionClosed()
|
||||||
data += new_data
|
data += new_data
|
||||||
if not new_data.endswith(b'\r\n'):
|
if not new_data.endswith(b"\r\n"):
|
||||||
time.sleep(0.1)
|
|
||||||
continue
|
continue
|
||||||
if not synchronize:
|
if not synchronize:
|
||||||
got_pong = True
|
got_pong = True
|
||||||
for line in data.decode().split('\r\n'):
|
for line in data.decode().split("\r\n"):
|
||||||
if line:
|
if line:
|
||||||
if self.show_io:
|
if self.show_io:
|
||||||
print('{time:.3f}{ssl} S -> {client}: {line}'.format(
|
print(
|
||||||
time=time.time(),
|
"{time:.3f}{ssl} S -> {client}: {line}".format(
|
||||||
ssl=' (ssl)' if self.ssl else '',
|
time=time.time(),
|
||||||
client=self.name,
|
ssl=" (ssl)" if self.ssl else "",
|
||||||
line=line))
|
client=self.name,
|
||||||
message = message_parser.parse_message(line + '\r\n')
|
line=line,
|
||||||
if message.command == 'PONG' and \
|
)
|
||||||
token in message.params:
|
)
|
||||||
|
message = message_parser.parse_message(line)
|
||||||
|
if message.command == "PONG" and token in message.params:
|
||||||
got_pong = True
|
got_pong = True
|
||||||
|
elif (
|
||||||
|
synchronize
|
||||||
|
and message.command == "451"
|
||||||
|
and message.params[1] == "PING"
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
"Got '451 * PONG'. Did you forget synchronize=False?"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
messages.append(message)
|
if raw:
|
||||||
data = b''
|
messages.append(line) # type: ignore
|
||||||
|
else:
|
||||||
|
messages.append(message)
|
||||||
|
data = b""
|
||||||
except ConnectionClosed:
|
except ConnectionClosed:
|
||||||
if messages:
|
if messages:
|
||||||
return messages
|
return messages
|
||||||
@ -78,31 +109,58 @@ class ClientMock:
|
|||||||
raise
|
raise
|
||||||
else:
|
else:
|
||||||
return messages
|
return messages
|
||||||
def getMessage(self, filter_pred=None, synchronize=True):
|
|
||||||
|
def getMessage(
|
||||||
|
self,
|
||||||
|
filter_pred: Optional[Callable[[message_parser.Message], bool]] = None,
|
||||||
|
synchronize: bool = True,
|
||||||
|
raw: bool = False,
|
||||||
|
) -> message_parser.Message:
|
||||||
|
"""Returns str in the rare case where raw=True"""
|
||||||
|
__tracebackhide__ = True # Hide from pytest tracebacks on test failure.
|
||||||
while True:
|
while True:
|
||||||
if not self.inbuffer:
|
if not self.inbuffer:
|
||||||
self.inbuffer = self.getMessages(
|
self.inbuffer = self.getMessages(
|
||||||
synchronize=synchronize, assert_get_one=True)
|
synchronize=synchronize, assert_get_one=True, raw=raw
|
||||||
|
)
|
||||||
if not self.inbuffer:
|
if not self.inbuffer:
|
||||||
raise NoMessageException()
|
raise NoMessageException()
|
||||||
message = self.inbuffer.pop(0) # TODO: use dequeue
|
message = self.inbuffer.pop(0) # TODO: use dequeue
|
||||||
if not filter_pred or filter_pred(message):
|
if not filter_pred or filter_pred(message):
|
||||||
return message
|
return message
|
||||||
def sendLine(self, line):
|
|
||||||
if not line.endswith('\r\n'):
|
def sendLine(self, line: Union[str, bytes]) -> None:
|
||||||
line += '\r\n'
|
if isinstance(line, str):
|
||||||
encoded_line = line.encode()
|
encoded_line = line.encode()
|
||||||
|
elif isinstance(line, bytes):
|
||||||
|
encoded_line = line
|
||||||
|
else:
|
||||||
|
raise ValueError(line)
|
||||||
|
if not encoded_line.endswith(b"\r\n"):
|
||||||
|
encoded_line += b"\r\n"
|
||||||
try:
|
try:
|
||||||
ret = self.conn.sendall(encoded_line)
|
ret = self.conn.sendall(encoded_line) # type: ignore
|
||||||
except BrokenPipeError:
|
except BrokenPipeError:
|
||||||
raise ConnectionClosed()
|
raise ConnectionClosed()
|
||||||
if self.ssl: # https://bugs.python.org/issue25951
|
if (
|
||||||
|
sys.version_info <= (3, 6) and self.ssl
|
||||||
|
): # https://bugs.python.org/issue25951
|
||||||
assert ret == len(encoded_line), (ret, repr(encoded_line))
|
assert ret == len(encoded_line), (ret, repr(encoded_line))
|
||||||
else:
|
else:
|
||||||
assert ret is None, ret
|
assert ret is None, ret
|
||||||
if self.show_io:
|
if self.show_io:
|
||||||
print('{time:.3f}{ssl} {client} -> S: {line}'.format(
|
if isinstance(line, str):
|
||||||
time=time.time(),
|
escaped_line = line
|
||||||
ssl=' (ssl)' if self.ssl else '',
|
escaped = ""
|
||||||
client=self.name,
|
else:
|
||||||
line=line.strip('\r\n')))
|
escaped_line = repr(line)
|
||||||
|
escaped = " (escaped)"
|
||||||
|
print(
|
||||||
|
"{time:.3f}{escaped}{ssl} {client} -> S: {line}".format(
|
||||||
|
time=time.time(),
|
||||||
|
escaped=escaped,
|
||||||
|
ssl=" (ssl)" if self.ssl else "",
|
||||||
|
client=self.name,
|
||||||
|
line=escaped_line.strip("\r\n"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
@ -1,14 +1,17 @@
|
|||||||
|
"""Format of ``CAP LS`` sent by IRCv3 clients."""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.irc_utils.message_parser import Message
|
from irctest.irc_utils.message_parser import Message
|
||||||
|
|
||||||
class CapTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper):
|
|
||||||
@cases.SpecificationSelector.requiredBySpecification('IRCv3.1', 'IRCv3.2')
|
class CapTestCase(cases.BaseClientTestCase):
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
def testSendCap(self):
|
def testSendCap(self):
|
||||||
"""Send CAP LS 302 and read the result."""
|
"""Send CAP LS 302 and read the result."""
|
||||||
self.readCapLs()
|
self.readCapLs()
|
||||||
|
|
||||||
@cases.SpecificationSelector.requiredBySpecification('IRCv3.1', 'IRCv3.2')
|
@cases.mark_specifications("IRCv3")
|
||||||
def testEmptyCapLs(self):
|
def testEmptyCapLs(self):
|
||||||
"""Empty result to CAP LS. Client should send CAP END."""
|
"""Empty result to CAP LS. Client should send CAP END."""
|
||||||
m = self.negotiateCapabilities([])
|
m = self.negotiateCapabilities([])
|
||||||
self.assertEqual(m, Message([], None, 'CAP', ['END']))
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
314
irctest/client_tests/sasl.py
Normal file
314
irctest/client_tests/sasl.py
Normal file
@ -0,0 +1,314 @@
|
|||||||
|
"""SASL authentication from clients, for all known mechanisms.
|
||||||
|
|
||||||
|
For now, only `SASLv3.1 <https://ircv3.net/specs/extensions/sasl-3.1>`_
|
||||||
|
is tested, not `SASLv3.2 <https://ircv3.net/specs/extensions/sasl-3.2>`_."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ecdsa
|
||||||
|
from ecdsa.util import sigdecode_der
|
||||||
|
except ImportError:
|
||||||
|
ecdsa = None
|
||||||
|
|
||||||
|
from irctest import authentication, cases, scram
|
||||||
|
from irctest.irc_utils.message_parser import Message
|
||||||
|
|
||||||
|
ECDSA_KEY = """
|
||||||
|
-----BEGIN EC PARAMETERS-----
|
||||||
|
BggqhkjOPQMBBw==
|
||||||
|
-----END EC PARAMETERS-----
|
||||||
|
-----BEGIN EC PRIVATE KEY-----
|
||||||
|
MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
|
||||||
|
AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
|
||||||
|
IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
|
||||||
|
-----END EC PRIVATE KEY-----
|
||||||
|
"""
|
||||||
|
|
||||||
|
CHALLENGE = bytes(range(32))
|
||||||
|
assert len(CHALLENGE) == 32
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityHash:
|
||||||
|
def __init__(self, data):
|
||||||
|
self._data = data
|
||||||
|
|
||||||
|
def digest(self):
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
|
class SaslTestCase(cases.BaseClientTestCase):
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
def testPlain(self):
|
||||||
|
"""Test PLAIN authentication with correct username/password."""
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.plain],
|
||||||
|
username="jilles",
|
||||||
|
password="sesame",
|
||||||
|
)
|
||||||
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
||||||
|
self.sendLine("AUTHENTICATE +")
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(
|
||||||
|
m, Message({}, None, "AUTHENTICATE", ["amlsbGVzAGppbGxlcwBzZXNhbWU="])
|
||||||
|
)
|
||||||
|
self.sendLine("900 * * jilles :You are now logged in.")
|
||||||
|
self.sendLine("903 * :SASL authentication successful")
|
||||||
|
m = self.negotiateCapabilities(["sasl"], False)
|
||||||
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
@cases.xfailIfSoftware(["Sopel"], "Sopel requests SASL PLAIN even if not available")
|
||||||
|
def testPlainNotAvailable(self):
|
||||||
|
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
|
||||||
|
to use PLAIN.
|
||||||
|
|
||||||
|
A client implementing sasl-3.2 can give up authentication immediately.
|
||||||
|
A client not implementing it will try authenticating, and will get
|
||||||
|
a 904.
|
||||||
|
"""
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.plain],
|
||||||
|
username="jilles",
|
||||||
|
password="sesame",
|
||||||
|
)
|
||||||
|
m = self.negotiateCapabilities(["sasl=EXTERNAL"], auth=auth)
|
||||||
|
self.assertEqual(self.acked_capabilities, {"sasl"})
|
||||||
|
if m == Message({}, None, "CAP", ["END"]):
|
||||||
|
# IRCv3.2-style, for clients that skip authentication
|
||||||
|
# when unavailable (eg. Limnoria)
|
||||||
|
return
|
||||||
|
elif m.command == "QUIT":
|
||||||
|
# IRCv3.2-style, for clients that quit when unavailable
|
||||||
|
# (eg. Sopel)
|
||||||
|
return
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
||||||
|
self.sendLine("904 {} :SASL auth failed".format(self.nick))
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertMessageMatch(m, command="CAP")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("pattern", ["barbaz", "éèà"])
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
def testPlainLarge(self, pattern):
|
||||||
|
"""Test the client splits large AUTHENTICATE messages whose payload
|
||||||
|
is not a multiple of 400.
|
||||||
|
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
||||||
|
"""
|
||||||
|
# TODO: authzid is optional
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.plain],
|
||||||
|
username="foo",
|
||||||
|
password=pattern * 100,
|
||||||
|
)
|
||||||
|
authstring = base64.b64encode(
|
||||||
|
b"\x00".join([b"foo", b"foo", pattern.encode() * 100])
|
||||||
|
).decode()
|
||||||
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
||||||
|
self.sendLine("AUTHENTICATE +")
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[0:400]]), m)
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[400:800]]))
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[800:]]))
|
||||||
|
self.sendLine("900 * * {} :You are now logged in.".format("foo"))
|
||||||
|
self.sendLine("903 * :SASL authentication successful")
|
||||||
|
m = self.negotiateCapabilities(["sasl"], False)
|
||||||
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
@pytest.mark.parametrize("pattern", ["quux", "éè"])
|
||||||
|
def testPlainLargeMultiple(self, pattern):
|
||||||
|
"""Test the client splits large AUTHENTICATE messages whose payload
|
||||||
|
is a multiple of 400.
|
||||||
|
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
||||||
|
"""
|
||||||
|
# TODO: authzid is optional
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.plain],
|
||||||
|
username="foo",
|
||||||
|
password=pattern * 148,
|
||||||
|
)
|
||||||
|
authstring = base64.b64encode(
|
||||||
|
b"\x00".join([b"foo", b"foo", pattern.encode() * 148])
|
||||||
|
).decode()
|
||||||
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
||||||
|
self.sendLine("AUTHENTICATE +")
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[0:400]]), m)
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[400:800]]))
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["+"]))
|
||||||
|
self.sendLine("900 * * {} :You are now logged in.".format("foo"))
|
||||||
|
self.sendLine("903 * :SASL authentication successful")
|
||||||
|
m = self.negotiateCapabilities(["sasl"], False)
|
||||||
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
|
@pytest.mark.skipif(ecdsa is None, reason="python3-ecdsa is not available")
|
||||||
|
@cases.skipUnlessHasMechanism("ECDSA-NIST256P-CHALLENGE")
|
||||||
|
def testEcdsa(self):
|
||||||
|
"""Test ECDSA authentication."""
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.ecdsa_nist256p_challenge],
|
||||||
|
username="jilles",
|
||||||
|
ecdsa_key=ECDSA_KEY,
|
||||||
|
)
|
||||||
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
|
self.assertEqual(
|
||||||
|
m, Message({}, None, "AUTHENTICATE", ["ECDSA-NIST256P-CHALLENGE"])
|
||||||
|
)
|
||||||
|
self.sendLine("AUTHENTICATE +")
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["amlsbGVz"])) # jilles
|
||||||
|
self.sendLine(
|
||||||
|
"AUTHENTICATE {}".format(base64.b64encode(CHALLENGE).decode("ascii"))
|
||||||
|
)
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertMessageMatch(m, command="AUTHENTICATE")
|
||||||
|
sk = ecdsa.SigningKey.from_pem(ECDSA_KEY)
|
||||||
|
vk = sk.get_verifying_key()
|
||||||
|
signature = base64.b64decode(m.params[0])
|
||||||
|
try:
|
||||||
|
vk.verify(
|
||||||
|
signature, CHALLENGE, hashfunc=IdentityHash, sigdecode=sigdecode_der
|
||||||
|
)
|
||||||
|
except ecdsa.BadSignatureError:
|
||||||
|
raise AssertionError("Bad signature")
|
||||||
|
self.sendLine("900 * * foo :You are now logged in.")
|
||||||
|
self.sendLine("903 * :SASL authentication successful")
|
||||||
|
m = self.negotiateCapabilities(["sasl"], False)
|
||||||
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
|
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
|
||||||
|
def testScram(self):
|
||||||
|
"""Test SCRAM-SHA-256 authentication."""
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.scram_sha_256],
|
||||||
|
username="jilles",
|
||||||
|
password="sesame",
|
||||||
|
)
|
||||||
|
|
||||||
|
class PasswdDb:
|
||||||
|
def get_password(self, *args):
|
||||||
|
return ("sesame", "plain")
|
||||||
|
|
||||||
|
authenticator = scram.SCRAMServerAuthenticator(
|
||||||
|
"SHA-256", channel_binding=False, password_database=PasswdDb()
|
||||||
|
)
|
||||||
|
|
||||||
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["SCRAM-SHA-256"]))
|
||||||
|
self.sendLine("AUTHENTICATE +")
|
||||||
|
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m.command, "AUTHENTICATE", m)
|
||||||
|
client_first = base64.b64decode(m.params[0])
|
||||||
|
response = authenticator.start(properties={}, initial_response=client_first)
|
||||||
|
assert isinstance(response, bytes), response
|
||||||
|
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
|
||||||
|
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m.command, "AUTHENTICATE", m)
|
||||||
|
msg = base64.b64decode(m.params[0])
|
||||||
|
r = authenticator.response(msg)
|
||||||
|
assert isinstance(r, tuple), r
|
||||||
|
assert len(r) == 2, r
|
||||||
|
(properties, response) = r
|
||||||
|
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
|
||||||
|
self.assertEqual(properties, {"authzid": None, "username": "jilles"})
|
||||||
|
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m.command, "AUTHENTICATE", m)
|
||||||
|
self.assertEqual(m.params, ["+"], m)
|
||||||
|
|
||||||
|
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
|
||||||
|
def testScramBadPassword(self, server_fakes_success=False, fake_response=None):
|
||||||
|
"""Test SCRAM-SHA-256 authentication with a bad password."""
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.scram_sha_256],
|
||||||
|
username="jilles",
|
||||||
|
password="sesame",
|
||||||
|
)
|
||||||
|
|
||||||
|
class PasswdDb:
|
||||||
|
def get_password(self, *args):
|
||||||
|
return ("notsesame", "plain")
|
||||||
|
|
||||||
|
authenticator = scram.SCRAMServerAuthenticator(
|
||||||
|
"SHA-256", channel_binding=False, password_database=PasswdDb()
|
||||||
|
)
|
||||||
|
|
||||||
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["SCRAM-SHA-256"]))
|
||||||
|
self.sendLine("AUTHENTICATE +")
|
||||||
|
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m.command, "AUTHENTICATE", m)
|
||||||
|
client_first = base64.b64decode(m.params[0])
|
||||||
|
response = authenticator.start(properties={}, initial_response=client_first)
|
||||||
|
assert isinstance(response, bytes), response
|
||||||
|
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
|
||||||
|
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertEqual(m.command, "AUTHENTICATE", m)
|
||||||
|
msg = base64.b64decode(m.params[0])
|
||||||
|
with self.assertRaises(scram.NotAuthorizedException):
|
||||||
|
authenticator.response(msg)
|
||||||
|
|
||||||
|
if server_fakes_success:
|
||||||
|
self.sendLine(f"AUTHENTICATE :{fake_response}")
|
||||||
|
|
||||||
|
m = self.getMessage()
|
||||||
|
while m.command == "PING":
|
||||||
|
self.sendLine(f"PONG server. {m.params[-1]}")
|
||||||
|
m = self.getMessage()
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="AUTHENTICATE",
|
||||||
|
params=["*"],
|
||||||
|
fail_msg="Client did not abort: {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"fake_response",
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"AAAA",
|
||||||
|
"dj1ubU1mM1FIV2NKUWk5cE1ndHFLU0tQclZueUk2c3FOTzZJN3BFLzBveUdjPQ==",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def testScramMaliciousServer(self, fake_response):
|
||||||
|
"""Test SCRAM-SHA-256 authentication to a server which pretends to know
|
||||||
|
the password"""
|
||||||
|
self.testScramBadPassword(
|
||||||
|
server_fakes_success=True, fake_response=fake_response
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Irc302SaslTestCase(cases.BaseClientTestCase):
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
def testPlainNotAvailable(self):
|
||||||
|
"""Test the client does not try to authenticate using a mechanism the
|
||||||
|
server does not advertise.
|
||||||
|
Actually, this is optional."""
|
||||||
|
auth = authentication.Authentication(
|
||||||
|
mechanisms=[authentication.Mechanisms.plain],
|
||||||
|
username="jilles",
|
||||||
|
password="sesame",
|
||||||
|
)
|
||||||
|
m = self.negotiateCapabilities(["sasl=EXTERNAL"], auth=auth)
|
||||||
|
self.assertEqual(self.acked_capabilities, {"sasl"})
|
||||||
|
|
||||||
|
if m.command == "QUIT":
|
||||||
|
# Some clients quit when it can't authenticate (eg. Sopel)
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Others will just skip authentication (eg. Limnoria)
|
||||||
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
@ -1,170 +0,0 @@
|
|||||||
import ecdsa
|
|
||||||
import base64
|
|
||||||
from irctest import cases
|
|
||||||
from irctest import authentication
|
|
||||||
from irctest.irc_utils.message_parser import Message
|
|
||||||
|
|
||||||
ECDSA_KEY = """
|
|
||||||
-----BEGIN EC PARAMETERS-----
|
|
||||||
BggqhkjOPQMBBw==
|
|
||||||
-----END EC PARAMETERS-----
|
|
||||||
-----BEGIN EC PRIVATE KEY-----
|
|
||||||
MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
|
|
||||||
AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
|
|
||||||
IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
|
|
||||||
-----END EC PRIVATE KEY-----
|
|
||||||
"""
|
|
||||||
|
|
||||||
class SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
|
|
||||||
cases.OptionalityHelper):
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
|
|
||||||
def testPlain(self):
|
|
||||||
"""Test PLAIN authentication with correct username/password."""
|
|
||||||
auth = authentication.Authentication(
|
|
||||||
mechanisms=[authentication.Mechanisms.plain],
|
|
||||||
username='jilles',
|
|
||||||
password='sesame',
|
|
||||||
)
|
|
||||||
m = self.negotiateCapabilities(['sasl'], auth=auth)
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
|
|
||||||
self.sendLine('AUTHENTICATE +')
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
['amlsbGVzAGppbGxlcwBzZXNhbWU=']))
|
|
||||||
self.sendLine('900 * * jilles :You are now logged in.')
|
|
||||||
self.sendLine('903 * :SASL authentication successful')
|
|
||||||
m = self.negotiateCapabilities(['sasl'], False)
|
|
||||||
self.assertEqual(m, Message([], None, 'CAP', ['END']))
|
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
|
|
||||||
def testPlainNotAvailable(self):
|
|
||||||
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
|
|
||||||
to use PLAIN.
|
|
||||||
|
|
||||||
A client implementing sasl-3.2 can give up authentication immediately.
|
|
||||||
A client not implementing it will try authenticating, and will get
|
|
||||||
a 904.
|
|
||||||
"""
|
|
||||||
auth = authentication.Authentication(
|
|
||||||
mechanisms=[authentication.Mechanisms.plain],
|
|
||||||
username='jilles',
|
|
||||||
password='sesame',
|
|
||||||
)
|
|
||||||
m = self.negotiateCapabilities(['sasl=EXTERNAL'], auth=auth)
|
|
||||||
self.assertEqual(self.acked_capabilities, {'sasl'})
|
|
||||||
if m == Message([], None, 'CAP', ['END']):
|
|
||||||
# IRCv3.2-style
|
|
||||||
return
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
|
|
||||||
self.sendLine('904 {} :SASL auth failed'.format(self.nick))
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertMessageEqual(m, command='CAP')
|
|
||||||
|
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
|
|
||||||
def testPlainLarge(self):
|
|
||||||
"""Test the client splits large AUTHENTICATE messages whose payload
|
|
||||||
is not a multiple of 400.
|
|
||||||
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
|
||||||
"""
|
|
||||||
# TODO: authzid is optional
|
|
||||||
auth = authentication.Authentication(
|
|
||||||
mechanisms=[authentication.Mechanisms.plain],
|
|
||||||
username='foo',
|
|
||||||
password='bar'*200,
|
|
||||||
)
|
|
||||||
authstring = base64.b64encode(b'\x00'.join(
|
|
||||||
[b'foo', b'foo', b'bar'*200])).decode()
|
|
||||||
m = self.negotiateCapabilities(['sasl'], auth=auth)
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
|
|
||||||
self.sendLine('AUTHENTICATE +')
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
[authstring[0:400]]), m)
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
[authstring[400:800]]))
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
[authstring[800:]]))
|
|
||||||
self.sendLine('900 * * {} :You are now logged in.'.format('foo'))
|
|
||||||
self.sendLine('903 * :SASL authentication successful')
|
|
||||||
m = self.negotiateCapabilities(['sasl'], False)
|
|
||||||
self.assertEqual(m, Message([], None, 'CAP', ['END']))
|
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
|
|
||||||
def testPlainLargeMultiple(self):
|
|
||||||
"""Test the client splits large AUTHENTICATE messages whose payload
|
|
||||||
is a multiple of 400.
|
|
||||||
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
|
||||||
"""
|
|
||||||
# TODO: authzid is optional
|
|
||||||
auth = authentication.Authentication(
|
|
||||||
mechanisms=[authentication.Mechanisms.plain],
|
|
||||||
username='foo',
|
|
||||||
password='quux'*148,
|
|
||||||
)
|
|
||||||
authstring = base64.b64encode(b'\x00'.join(
|
|
||||||
[b'foo', b'foo', b'quux'*148])).decode()
|
|
||||||
m = self.negotiateCapabilities(['sasl'], auth=auth)
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
|
|
||||||
self.sendLine('AUTHENTICATE +')
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
[authstring[0:400]]), m)
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
[authstring[400:800]]))
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
['+']))
|
|
||||||
self.sendLine('900 * * {} :You are now logged in.'.format('foo'))
|
|
||||||
self.sendLine('903 * :SASL authentication successful')
|
|
||||||
m = self.negotiateCapabilities(['sasl'], False)
|
|
||||||
self.assertEqual(m, Message([], None, 'CAP', ['END']))
|
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism('ECDSA-NIST256P-CHALLENGE')
|
|
||||||
def testEcdsa(self):
|
|
||||||
"""Test ECDSA authentication.
|
|
||||||
"""
|
|
||||||
auth = authentication.Authentication(
|
|
||||||
mechanisms=[authentication.Mechanisms.ecdsa_nist256p_challenge],
|
|
||||||
username='jilles',
|
|
||||||
ecdsa_key=ECDSA_KEY,
|
|
||||||
)
|
|
||||||
m = self.negotiateCapabilities(['sasl'], auth=auth)
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['ECDSA-NIST256P-CHALLENGE']))
|
|
||||||
self.sendLine('AUTHENTICATE +')
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
|
|
||||||
['amlsbGVz'])) # jilles
|
|
||||||
self.sendLine('AUTHENTICATE Zm9vYmFy') # foobar
|
|
||||||
m = self.getMessage()
|
|
||||||
self.assertMessageEqual(m, command='AUTHENTICATE')
|
|
||||||
sk = ecdsa.SigningKey.from_pem(ECDSA_KEY)
|
|
||||||
vk = sk.get_verifying_key()
|
|
||||||
signature = base64.b64decode(m.params[0])
|
|
||||||
try:
|
|
||||||
vk.verify(signature, b'foobar')
|
|
||||||
except ecdsa.BadSignatureError:
|
|
||||||
raise AssertionError('Bad signature')
|
|
||||||
self.sendLine('900 * * foo :You are now logged in.')
|
|
||||||
self.sendLine('903 * :SASL authentication successful')
|
|
||||||
m = self.negotiateCapabilities(['sasl'], False)
|
|
||||||
self.assertEqual(m, Message([], None, 'CAP', ['END']))
|
|
||||||
|
|
||||||
class Irc302SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
|
|
||||||
cases.OptionalityHelper):
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
|
|
||||||
def testPlainNotAvailable(self):
|
|
||||||
"""Test the client does not try to authenticate using a mechanism the
|
|
||||||
server does not advertise.
|
|
||||||
Actually, this is optional."""
|
|
||||||
auth = authentication.Authentication(
|
|
||||||
mechanisms=[authentication.Mechanisms.plain],
|
|
||||||
username='jilles',
|
|
||||||
password='sesame',
|
|
||||||
)
|
|
||||||
m = self.negotiateCapabilities(['sasl=EXTERNAL'], auth=auth)
|
|
||||||
self.assertEqual(self.acked_capabilities, {'sasl'})
|
|
||||||
self.assertEqual(m, Message([], None, 'CAP', ['END']))
|
|
@ -1,7 +1,13 @@
|
|||||||
from irctest import tls
|
"""Clients should validate certificates; either with a CA or fingerprints."""
|
||||||
from irctest import cases
|
|
||||||
|
import socket
|
||||||
|
import ssl
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases, runner, tls
|
||||||
from irctest.exceptions import ConnectionClosed
|
from irctest.exceptions import ConnectionClosed
|
||||||
from irctest.irc_utils.message_parser import Message
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
BAD_CERT = """
|
BAD_CERT = """
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
@ -57,7 +63,7 @@ h4WuPDAI4yh24GjaCZYGR5xcqPCy5CNjMLxdA7HsP+Gcr3eY5XS7noBrbC6IaA0j
|
|||||||
-----END PRIVATE KEY-----
|
-----END PRIVATE KEY-----
|
||||||
"""
|
"""
|
||||||
|
|
||||||
GOOD_FINGERPRINT = 'E1EE6DE2DBC0D43E3B60407B5EE389AEC9D2C53178E0FB14CD51C3DFD544AA2B'
|
GOOD_FINGERPRINT = "E1EE6DE2DBC0D43E3B60407B5EE389AEC9D2C53178E0FB14CD51C3DFD544AA2B"
|
||||||
GOOD_CERT = """
|
GOOD_CERT = """
|
||||||
-----BEGIN CERTIFICATE-----
|
-----BEGIN CERTIFICATE-----
|
||||||
MIIDXTCCAkWgAwIBAgIJAKtD9XMC1R0vMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
MIIDXTCCAkWgAwIBAgIJAKtD9XMC1R0vMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
|
||||||
@ -112,32 +118,113 @@ El9iqRlAhgqaXc4Iz/Zxxhs=
|
|||||||
-----END PRIVATE KEY-----
|
-----END PRIVATE KEY-----
|
||||||
"""
|
"""
|
||||||
|
|
||||||
class TlsTestCase(cases.BaseClientTestCase):
|
|
||||||
def testTrustedCertificate(self):
|
|
||||||
tls_config = tls.TlsConfig(
|
|
||||||
enable=True,
|
|
||||||
trusted_fingerprints=[GOOD_FINGERPRINT])
|
|
||||||
(hostname, port) = self.server.getsockname()
|
|
||||||
self.controller.run(
|
|
||||||
hostname=hostname,
|
|
||||||
port=port,
|
|
||||||
auth=None,
|
|
||||||
tls_config=tls_config,
|
|
||||||
)
|
|
||||||
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
|
|
||||||
m = self.getMessage()
|
|
||||||
|
|
||||||
def testUntrustedCertificate(self):
|
class TlsTestCase(cases.BaseClientTestCase):
|
||||||
tls_config = tls.TlsConfig(
|
def testTrustedCertificate(self):
|
||||||
enable=True,
|
tls_config = tls.TlsConfig(enable=True, trusted_fingerprints=[GOOD_FINGERPRINT])
|
||||||
trusted_fingerprints=[GOOD_FINGERPRINT])
|
|
||||||
(hostname, port) = self.server.getsockname()
|
(hostname, port) = self.server.getsockname()
|
||||||
self.controller.run(
|
self.controller.run(
|
||||||
hostname=hostname,
|
hostname=hostname, port=port, auth=None, tls_config=tls_config
|
||||||
port=port,
|
)
|
||||||
auth=None,
|
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
|
||||||
tls_config=tls_config,
|
self.getMessage()
|
||||||
)
|
|
||||||
|
def testUntrustedCertificate(self):
|
||||||
|
tls_config = tls.TlsConfig(enable=True, trusted_fingerprints=[GOOD_FINGERPRINT])
|
||||||
|
(hostname, port) = self.server.getsockname()
|
||||||
|
self.controller.run(
|
||||||
|
hostname=hostname, port=port, auth=None, tls_config=tls_config
|
||||||
|
)
|
||||||
self.acceptClient(tls_cert=BAD_CERT, tls_key=BAD_KEY)
|
self.acceptClient(tls_cert=BAD_CERT, tls_key=BAD_KEY)
|
||||||
with self.assertRaises(ConnectionClosed):
|
with self.assertRaises((ConnectionClosed, ConnectionResetError)):
|
||||||
m = self.getMessage()
|
self.getMessage()
|
||||||
|
|
||||||
|
|
||||||
|
class StsTestCase(cases.BaseClientTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self.insecure_server.bind(("", 0)) # Bind any free port
|
||||||
|
self.insecure_server.listen(1)
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
self.insecure_server.close()
|
||||||
|
super().tearDown()
|
||||||
|
|
||||||
|
@cases.mark_capabilities("sts")
|
||||||
|
@pytest.mark.parametrize("portOnSecure", [False, True])
|
||||||
|
def testSts(self, portOnSecure):
|
||||||
|
if not self.controller.supports_sts:
|
||||||
|
raise runner.CapabilityNotSupported("sts")
|
||||||
|
tls_config = tls.TlsConfig(
|
||||||
|
enable=False, trusted_fingerprints=[GOOD_FINGERPRINT]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Connect client to insecure server
|
||||||
|
(hostname, port) = self.insecure_server.getsockname()
|
||||||
|
self.controller.run(
|
||||||
|
hostname=hostname, port=port, auth=None, tls_config=tls_config
|
||||||
|
)
|
||||||
|
self.acceptClient(server=self.insecure_server)
|
||||||
|
|
||||||
|
# Send STS policy to client
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(),
|
||||||
|
command="CAP",
|
||||||
|
params=["LS", ANYSTR],
|
||||||
|
fail_msg="First message is not CAP LS: {got}",
|
||||||
|
)
|
||||||
|
self.sendLine("CAP * LS :sts=port={}".format(self.server.getsockname()[1]))
|
||||||
|
|
||||||
|
# "If the client is not already connected securely to the server
|
||||||
|
# at the requested hostname, it MUST close the insecure connection
|
||||||
|
# and reconnect securely on the stated port."
|
||||||
|
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
|
||||||
|
|
||||||
|
# Send the STS policy, over secure connection this time.
|
||||||
|
if portOnSecure:
|
||||||
|
# Should be ignored
|
||||||
|
self.sendLine("CAP * LS :sts=duration=10,port=12345")
|
||||||
|
else:
|
||||||
|
self.sendLine("CAP * LS :sts=duration=10")
|
||||||
|
|
||||||
|
# Make the client reconnect. It should reconnect to the secure server.
|
||||||
|
self.sendLine("ERROR :closing link")
|
||||||
|
self.acceptClient()
|
||||||
|
|
||||||
|
# Kill the client
|
||||||
|
self.controller.terminate()
|
||||||
|
|
||||||
|
# Run the client, still configured to connect to the insecure server
|
||||||
|
self.controller.run(
|
||||||
|
hostname=hostname, port=port, auth=None, tls_config=tls_config
|
||||||
|
)
|
||||||
|
|
||||||
|
# The client should remember the STS policy and connect to the secure
|
||||||
|
# server
|
||||||
|
self.acceptClient()
|
||||||
|
|
||||||
|
@cases.mark_capabilities("sts")
|
||||||
|
def testStsInvalidCertificate(self):
|
||||||
|
if not self.controller.supports_sts:
|
||||||
|
raise runner.CapabilityNotSupported("sts")
|
||||||
|
|
||||||
|
# Connect client to insecure server
|
||||||
|
(hostname, port) = self.insecure_server.getsockname()
|
||||||
|
self.controller.run(hostname=hostname, port=port, auth=None)
|
||||||
|
self.acceptClient(server=self.insecure_server)
|
||||||
|
|
||||||
|
# Send STS policy to client
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(),
|
||||||
|
command="CAP",
|
||||||
|
params=["LS", ANYSTR],
|
||||||
|
fail_msg="First message is not CAP LS: {got}",
|
||||||
|
)
|
||||||
|
self.sendLine("CAP * LS :sts=port={}".format(self.server.getsockname()[1]))
|
||||||
|
|
||||||
|
# The client will reconnect to the TLS port. Unfortunately, it does
|
||||||
|
# not trust its fingerprint.
|
||||||
|
|
||||||
|
with self.assertRaises((ssl.SSLError, socket.error)):
|
||||||
|
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
|
125
irctest/controllers/anope_services.py
Normal file
125
irctest/controllers/anope_services.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
serverinfo {{
|
||||||
|
name = "services.example.org"
|
||||||
|
description = "Anope IRC Services"
|
||||||
|
numeric = "00A"
|
||||||
|
pid = "services.pid"
|
||||||
|
motd = "conf/empty_file"
|
||||||
|
}}
|
||||||
|
|
||||||
|
uplink {{
|
||||||
|
host = "{server_hostname}"
|
||||||
|
port = {server_port}
|
||||||
|
password = "password"
|
||||||
|
}}
|
||||||
|
|
||||||
|
module {{
|
||||||
|
name = "{protocol}"
|
||||||
|
}}
|
||||||
|
|
||||||
|
networkinfo {{
|
||||||
|
networkname = "testnet"
|
||||||
|
nicklen = 31
|
||||||
|
userlen = 10
|
||||||
|
hostlen = 64
|
||||||
|
chanlen = 32
|
||||||
|
}}
|
||||||
|
|
||||||
|
mail {{
|
||||||
|
usemail = no
|
||||||
|
}}
|
||||||
|
|
||||||
|
service {{
|
||||||
|
nick = "NickServ"
|
||||||
|
user = "services"
|
||||||
|
host = "services.host"
|
||||||
|
gecos = "Nickname Registration Service"
|
||||||
|
}}
|
||||||
|
|
||||||
|
module {{
|
||||||
|
name = "nickserv"
|
||||||
|
client = "NickServ"
|
||||||
|
forceemail = no
|
||||||
|
passlen = 1000 # Some tests need long passwords
|
||||||
|
}}
|
||||||
|
command {{ service = "NickServ"; name = "HELP"; command = "generic/help"; }}
|
||||||
|
|
||||||
|
module {{
|
||||||
|
name = "ns_register"
|
||||||
|
registration = "none"
|
||||||
|
}}
|
||||||
|
command {{ service = "NickServ"; name = "REGISTER"; command = "nickserv/register"; }}
|
||||||
|
|
||||||
|
options {{
|
||||||
|
casemap = "ascii"
|
||||||
|
readtimeout = 5s
|
||||||
|
warningtimeout = 4h
|
||||||
|
}}
|
||||||
|
|
||||||
|
module {{ name = "m_sasl" }}
|
||||||
|
module {{ name = "enc_sha256" }}
|
||||||
|
module {{ name = "ns_cert" }}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class AnopeController(BaseServicesController, DirectoryBasedController):
|
||||||
|
"""Collaborator for server controllers that rely on Anope"""
|
||||||
|
|
||||||
|
software_name = "Anope"
|
||||||
|
|
||||||
|
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
||||||
|
self.create_config()
|
||||||
|
|
||||||
|
assert protocol in (
|
||||||
|
"bahamut",
|
||||||
|
"inspircd3",
|
||||||
|
"charybdis",
|
||||||
|
"hybrid",
|
||||||
|
"plexus",
|
||||||
|
"unreal4",
|
||||||
|
"ngircd",
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.open_file("conf/services.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
protocol=protocol,
|
||||||
|
server_hostname=server_hostname,
|
||||||
|
server_port=server_port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.open_file("conf/empty_file") as fd:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
services_path = shutil.which("services")
|
||||||
|
assert services_path
|
||||||
|
|
||||||
|
# Config and code need to be in the same directory, *obviously*
|
||||||
|
(self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib")
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"services",
|
||||||
|
"-n", # don't fork
|
||||||
|
"--config=services.conf", # can't be an absolute path
|
||||||
|
# "--logdir",
|
||||||
|
# f"/tmp/services-{server_port}.log",
|
||||||
|
],
|
||||||
|
cwd=self.directory,
|
||||||
|
# stdout=subprocess.DEVNULL,
|
||||||
|
# stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[AnopeController]:
|
||||||
|
return AnopeController
|
111
irctest/controllers/atheme_services.py
Normal file
111
irctest/controllers/atheme_services.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
import irctest
|
||||||
|
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
|
||||||
|
import irctest.cases
|
||||||
|
import irctest.runner
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
loadmodule "modules/protocol/{protocol}";
|
||||||
|
loadmodule "modules/backend/opensex";
|
||||||
|
loadmodule "modules/crypto/pbkdf2";
|
||||||
|
|
||||||
|
loadmodule "modules/nickserv/main";
|
||||||
|
loadmodule "modules/nickserv/cert";
|
||||||
|
loadmodule "modules/nickserv/register";
|
||||||
|
loadmodule "modules/nickserv/verify";
|
||||||
|
|
||||||
|
loadmodule "modules/saslserv/main";
|
||||||
|
loadmodule "modules/saslserv/authcookie";
|
||||||
|
#loadmodule "modules/saslserv/ecdh-x25519-challenge";
|
||||||
|
loadmodule "modules/saslserv/ecdsa-nist256p-challenge";
|
||||||
|
loadmodule "modules/saslserv/external";
|
||||||
|
loadmodule "modules/saslserv/plain";
|
||||||
|
#loadmodule "modules/saslserv/scram";
|
||||||
|
|
||||||
|
serverinfo {{
|
||||||
|
name = "services.example.org";
|
||||||
|
desc = "Atheme IRC Services";
|
||||||
|
numeric = "00A";
|
||||||
|
netname = "testnet";
|
||||||
|
adminname = "no admin";
|
||||||
|
adminemail = "no-admin@example.org";
|
||||||
|
registeremail = "registration@example.org";
|
||||||
|
auth = none; // Disable email check
|
||||||
|
}};
|
||||||
|
|
||||||
|
general {{
|
||||||
|
commit_interval = 5;
|
||||||
|
}};
|
||||||
|
|
||||||
|
uplink "My.Little.Server" {{
|
||||||
|
host = "{server_hostname}";
|
||||||
|
port = {server_port};
|
||||||
|
send_password = "password";
|
||||||
|
receive_password = "password";
|
||||||
|
}};
|
||||||
|
|
||||||
|
saslserv {{
|
||||||
|
nick = "SaslServ";
|
||||||
|
}};
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class AthemeController(BaseServicesController, DirectoryBasedController):
|
||||||
|
"""Mixin for server controllers that rely on Atheme"""
|
||||||
|
|
||||||
|
software_name = "Atheme"
|
||||||
|
|
||||||
|
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
||||||
|
self.create_config()
|
||||||
|
|
||||||
|
if protocol == "inspircd3":
|
||||||
|
# That's the name used by Anope
|
||||||
|
protocol = "inspircd"
|
||||||
|
assert protocol in ("bahamut", "inspircd", "charybdis", "unreal4", "ngircd")
|
||||||
|
|
||||||
|
with self.open_file("services.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
protocol=protocol,
|
||||||
|
server_hostname=server_hostname,
|
||||||
|
server_port=server_port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"atheme-services",
|
||||||
|
"-n", # don't fork
|
||||||
|
"-c",
|
||||||
|
self.directory / "services.conf",
|
||||||
|
"-l",
|
||||||
|
f"/tmp/services-{server_port}.log",
|
||||||
|
"-p",
|
||||||
|
self.directory / "services.pid",
|
||||||
|
"-D",
|
||||||
|
self.directory,
|
||||||
|
],
|
||||||
|
# stdout=subprocess.DEVNULL,
|
||||||
|
# stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
def registerUser(
|
||||||
|
self,
|
||||||
|
case: irctest.cases.BaseServerTestCase,
|
||||||
|
username: str,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
assert password
|
||||||
|
if len(password.encode()) > 288:
|
||||||
|
# It's hardcoded at compile-time :(
|
||||||
|
# https://github.com/atheme/atheme/blob/4fa0e03bd3ce2cb6041a339f308616580c5aac29/include/atheme/constants.h#L51
|
||||||
|
raise irctest.runner.NotImplementedByController("Passwords over 288 bytes")
|
||||||
|
|
||||||
|
super().registerUser(case, username, password)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[AthemeController]:
|
||||||
|
return AthemeController
|
176
irctest/controllers/bahamut.py
Normal file
176
irctest/controllers/bahamut.py
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Set, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
global {{
|
||||||
|
name My.Little.Server; # IRC name of the server
|
||||||
|
info "located on earth"; # A short info line
|
||||||
|
}};
|
||||||
|
|
||||||
|
options {{
|
||||||
|
network_name unconfigured;
|
||||||
|
allow_split_ops; # Give ops in empty channels
|
||||||
|
|
||||||
|
services_name services.example.org;
|
||||||
|
|
||||||
|
// if you need to link more than 1 server, uncomment the following line
|
||||||
|
servtype hub;
|
||||||
|
}};
|
||||||
|
|
||||||
|
/* where to listen for connections */
|
||||||
|
port {{
|
||||||
|
port {port};
|
||||||
|
bind {hostname};
|
||||||
|
}};
|
||||||
|
|
||||||
|
/* allow clients to connect */
|
||||||
|
allow {{
|
||||||
|
host *@*; # Allow anyone
|
||||||
|
class users; # Place them in the users class
|
||||||
|
flags T; # No throttling
|
||||||
|
{password_field}
|
||||||
|
}};
|
||||||
|
|
||||||
|
/* connection class for users */
|
||||||
|
class {{
|
||||||
|
name users; # Class name
|
||||||
|
maxusers 100; # Maximum connections
|
||||||
|
pingfreq 1000; # Check idle connections every N seconds
|
||||||
|
maxsendq 100000; # 100KB send buffer limit
|
||||||
|
}};
|
||||||
|
|
||||||
|
/* for services */
|
||||||
|
super {{
|
||||||
|
"services.example.org";
|
||||||
|
}};
|
||||||
|
|
||||||
|
|
||||||
|
/* class for services */
|
||||||
|
class {{
|
||||||
|
name services;
|
||||||
|
pingfreq 60; # Idle check every minute
|
||||||
|
maxsendq 5000000; # 5MB backlog buffer
|
||||||
|
}};
|
||||||
|
|
||||||
|
/* our services */
|
||||||
|
connect {{
|
||||||
|
name services.example.org;
|
||||||
|
host *@127.0.0.1; # unfortunately, masks aren't allowed here
|
||||||
|
apasswd password;
|
||||||
|
cpasswd password;
|
||||||
|
class services;
|
||||||
|
}};
|
||||||
|
|
||||||
|
oper {{
|
||||||
|
name operuser;
|
||||||
|
host *@*;
|
||||||
|
passwd operpassword;
|
||||||
|
access *Aa;
|
||||||
|
class users;
|
||||||
|
}};
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def initialize_entropy(directory: Path) -> None:
|
||||||
|
# https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/include/dh.h#L35-L38
|
||||||
|
nb_rand_bytes = 512 // 8
|
||||||
|
# https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/src/dh.c#L186
|
||||||
|
entropy_file_size = nb_rand_bytes * 4
|
||||||
|
|
||||||
|
# Not actually random; but we don't care.
|
||||||
|
entropy = b"\x00" * entropy_file_size
|
||||||
|
|
||||||
|
with (directory / ".ircd.entropy").open("wb") as fd:
|
||||||
|
fd.write(entropy)
|
||||||
|
|
||||||
|
|
||||||
|
class BahamutController(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "Bahamut"
|
||||||
|
supported_sasl_mechanisms: Set[str] = set()
|
||||||
|
supports_sts = False
|
||||||
|
nickserv = "NickServ@services.example.org"
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("server.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
|
self.create_config()
|
||||||
|
(unused_hostname, unused_port) = self.get_hostname_and_port()
|
||||||
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
|
password_field = "passwd {};".format(password) if password else ""
|
||||||
|
|
||||||
|
self.gen_ssl()
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
# Bahamut reads some bytes from /dev/urandom on startup, which causes
|
||||||
|
# GitHub Actions to sometimes freeze and timeout.
|
||||||
|
# This initializes the entropy file so Bahamut does not need to do it itself.
|
||||||
|
initialize_entropy(self.directory)
|
||||||
|
|
||||||
|
# they are hardcoded... thankfully Bahamut reads them from the CWD.
|
||||||
|
shutil.copy(self.pem_path, self.directory / "ircd.crt")
|
||||||
|
shutil.copy(self.key_path, self.directory / "ircd.key")
|
||||||
|
|
||||||
|
with self.open_file("server.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
services_hostname=services_hostname,
|
||||||
|
services_port=services_port,
|
||||||
|
password_field=password_field,
|
||||||
|
# key_path=self.key_path,
|
||||||
|
# pem_path=self.pem_path,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"ircd",
|
||||||
|
"-t", # don't fork
|
||||||
|
"-f",
|
||||||
|
self.directory / "server.conf",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_services:
|
||||||
|
self.wait_for_port()
|
||||||
|
self.services_controller = self.services_controller_class(
|
||||||
|
self.test_config, self
|
||||||
|
)
|
||||||
|
self.services_controller.run(
|
||||||
|
protocol="bahamut",
|
||||||
|
server_hostname=hostname,
|
||||||
|
server_port=port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[BahamutController]:
|
||||||
|
return BahamutController
|
95
irctest/controllers/base_hybrid.py
Normal file
95
irctest/controllers/base_hybrid.py
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
|
|
||||||
|
TEMPLATE_SSL_CONFIG = """
|
||||||
|
ssl_private_key = "{key_path}";
|
||||||
|
ssl_cert = "{pem_path}";
|
||||||
|
ssl_dh_params = "{dh_path}";
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class BaseHybridController(BaseServerController, DirectoryBasedController):
|
||||||
|
"""A base class for all controllers derived from ircd-hybrid (Hybrid itself,
|
||||||
|
Charybdis, Solanum, ...)"""
|
||||||
|
|
||||||
|
binary_name: str
|
||||||
|
services_protocol: str
|
||||||
|
|
||||||
|
supports_sts = False
|
||||||
|
extban_mute_char = None
|
||||||
|
|
||||||
|
template_config: str
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("server.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
|
self.create_config()
|
||||||
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
|
password_field = 'password = "{}";'.format(password) if password else ""
|
||||||
|
if ssl:
|
||||||
|
self.gen_ssl()
|
||||||
|
ssl_config = TEMPLATE_SSL_CONFIG.format(
|
||||||
|
key_path=self.key_path, pem_path=self.pem_path, dh_path=self.dh_path
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
ssl_config = ""
|
||||||
|
with self.open_file("server.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
(self.template_config).format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
services_hostname=services_hostname,
|
||||||
|
services_port=services_port,
|
||||||
|
password_field=password_field,
|
||||||
|
ssl_config=ssl_config,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
self.binary_name,
|
||||||
|
"-foreground",
|
||||||
|
"-configfile",
|
||||||
|
self.directory / "server.conf",
|
||||||
|
"-pidfile",
|
||||||
|
self.directory / "server.pid",
|
||||||
|
],
|
||||||
|
# stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_services:
|
||||||
|
self.wait_for_port()
|
||||||
|
self.services_controller = self.services_controller_class(
|
||||||
|
self.test_config, self
|
||||||
|
)
|
||||||
|
self.services_controller.run(
|
||||||
|
protocol=self.services_protocol,
|
||||||
|
server_hostname=hostname,
|
||||||
|
server_port=services_port,
|
||||||
|
)
|
@ -1,13 +1,6 @@
|
|||||||
import os
|
from typing import Type
|
||||||
import time
|
|
||||||
import shutil
|
|
||||||
import tempfile
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from irctest import client_mock
|
from .base_hybrid import BaseHybridController
|
||||||
from irctest import authentication
|
|
||||||
from irctest.basecontrollers import NotImplementedByController
|
|
||||||
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
serverinfo {{
|
serverinfo {{
|
||||||
@ -16,72 +9,79 @@ serverinfo {{
|
|||||||
description = "test server";
|
description = "test server";
|
||||||
{ssl_config}
|
{ssl_config}
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
general {{
|
||||||
|
throttle_count = 100; # We need to connect lots of clients quickly
|
||||||
|
# disable throttling for LIST and similar:
|
||||||
|
pace_wait_simple = 0 second;
|
||||||
|
pace_wait = 0 second;
|
||||||
|
sasl_service = "SaslServ";
|
||||||
|
}};
|
||||||
|
|
||||||
|
class "server" {{
|
||||||
|
ping_time = 5 minutes;
|
||||||
|
connectfreq = 5 minutes;
|
||||||
|
}};
|
||||||
|
|
||||||
listen {{
|
listen {{
|
||||||
defer_accept = yes;
|
defer_accept = yes;
|
||||||
|
|
||||||
host = "{hostname}";
|
host = "{hostname}";
|
||||||
port = {port};
|
port = {port};
|
||||||
|
port = {services_port};
|
||||||
}};
|
}};
|
||||||
|
|
||||||
auth {{
|
auth {{
|
||||||
user = "*";
|
user = "*";
|
||||||
flags = exceed_limit;
|
flags = exceed_limit;
|
||||||
{password_field}
|
{password_field}
|
||||||
}};
|
}};
|
||||||
|
|
||||||
channel {{
|
channel {{
|
||||||
disable_local_channels = no;
|
disable_local_channels = no;
|
||||||
no_create_on_split = no;
|
no_create_on_split = no;
|
||||||
no_join_on_split = no;
|
no_join_on_split = no;
|
||||||
displayed_usercount = 0;
|
displayed_usercount = 0;
|
||||||
}};
|
}};
|
||||||
"""
|
|
||||||
|
|
||||||
TEMPLATE_SSL_CONFIG = """
|
connect "services.example.org" {{
|
||||||
ssl_private_key = "{key_path}";
|
host = "localhost"; # Used to validate incoming connection
|
||||||
ssl_cert = "{pem_path}";
|
port = 0; # We don't need servers to connect to services
|
||||||
ssl_dh_params = "{dh_path}";
|
send_password = "password";
|
||||||
|
accept_password = "password";
|
||||||
|
class = "server";
|
||||||
|
flags = topicburst;
|
||||||
|
}};
|
||||||
|
service {{
|
||||||
|
name = "services.example.org";
|
||||||
|
}};
|
||||||
|
|
||||||
|
privset "omnioper" {{
|
||||||
|
privs = oper:general, oper:privs, oper:testline, oper:kill, oper:operwall, oper:message,
|
||||||
|
oper:routing, oper:kline, oper:unkline, oper:xline,
|
||||||
|
oper:resv, oper:cmodes, oper:mass_notice, oper:wallops,
|
||||||
|
oper:remoteban,
|
||||||
|
usermode:servnotice, auspex:oper, auspex:hostname, auspex:umodes, auspex:cmodes,
|
||||||
|
oper:admin, oper:die, oper:rehash, oper:spy, oper:grant;
|
||||||
|
}};
|
||||||
|
operator "operuser" {{
|
||||||
|
user = "*@*";
|
||||||
|
password = "operpassword";
|
||||||
|
privset = "omnioper";
|
||||||
|
flags = ~encrypted;
|
||||||
|
}};
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class CharybdisController(BaseServerController, DirectoryBasedController):
|
class CharybdisController(BaseHybridController):
|
||||||
software_name = 'Charybdis'
|
software_name = "Charybdis"
|
||||||
supported_sasl_mechanisms = set()
|
binary_name = "charybdis"
|
||||||
def create_config(self):
|
services_protocol = "charybdis"
|
||||||
super().create_config()
|
|
||||||
with self.open_file('server.conf'):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def run(self, hostname, port, password=None, ssl=False,
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
valid_metadata_keys=None, invalid_metadata_keys=None):
|
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
template_config = TEMPLATE_CONFIG
|
||||||
raise NotImplementedByController(
|
|
||||||
'Defining valid and invalid METADATA keys.')
|
|
||||||
assert self.proc is None
|
|
||||||
self.create_config()
|
|
||||||
self.port = port
|
|
||||||
password_field = 'password = "{}";'.format(password) if password else ''
|
|
||||||
if ssl:
|
|
||||||
self.gen_ssl()
|
|
||||||
ssl_config = TEMPLATE_SSL_CONFIG.format(
|
|
||||||
key_path=self.key_path,
|
|
||||||
pem_path=self.pem_path,
|
|
||||||
dh_path=self.dh_path,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
ssl_config = ''
|
|
||||||
with self.open_file('server.conf') as fd:
|
|
||||||
fd.write(TEMPLATE_CONFIG.format(
|
|
||||||
hostname=hostname,
|
|
||||||
port=port,
|
|
||||||
password_field=password_field,
|
|
||||||
ssl_config=ssl_config,
|
|
||||||
))
|
|
||||||
self.proc = subprocess.Popen(['ircd', '-foreground',
|
|
||||||
'-configfile', os.path.join(self.directory, 'server.conf'),
|
|
||||||
'-pidfile', os.path.join(self.directory, 'server.pid'),
|
|
||||||
],
|
|
||||||
stderr=subprocess.DEVNULL
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_irctest_controller_class():
|
def get_irctest_controller_class() -> Type[CharybdisController]:
|
||||||
return CharybdisController
|
return CharybdisController
|
||||||
|
245
irctest/controllers/dlk_services.py
Normal file
245
irctest/controllers/dlk_services.py
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import secrets
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
import irctest
|
||||||
|
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
|
||||||
|
import irctest.cases
|
||||||
|
import irctest.runner
|
||||||
|
|
||||||
|
TEMPLATE_DLK_CONFIG = """\
|
||||||
|
info {{
|
||||||
|
SID "00A";
|
||||||
|
network-name "testnetwork";
|
||||||
|
services-name "services.example.org";
|
||||||
|
admin-email "admin@example.org";
|
||||||
|
}}
|
||||||
|
|
||||||
|
link {{
|
||||||
|
hostname "{server_hostname}";
|
||||||
|
port "{server_port}";
|
||||||
|
password "password";
|
||||||
|
}}
|
||||||
|
|
||||||
|
log {{
|
||||||
|
debug "yes";
|
||||||
|
}}
|
||||||
|
|
||||||
|
sql {{
|
||||||
|
port "3306";
|
||||||
|
username "pifpaf";
|
||||||
|
password "pifpaf";
|
||||||
|
database "pifpaf";
|
||||||
|
sockfile "{mysql_socket}";
|
||||||
|
prefix "{dlk_prefix}";
|
||||||
|
}}
|
||||||
|
|
||||||
|
wordpress {{
|
||||||
|
prefix "{wp_prefix}";
|
||||||
|
}}
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
TEMPLATE_DLK_WP_CONFIG = """
|
||||||
|
<?php
|
||||||
|
|
||||||
|
global $wpconfig;
|
||||||
|
$wpconfig = [
|
||||||
|
|
||||||
|
"dbprefix" => "{wp_prefix}",
|
||||||
|
|
||||||
|
|
||||||
|
"default_avatar" => "https://valware.uk/wp-content/plugins/ultimate-member/assets/img/default_avatar.jpg",
|
||||||
|
"forumschan" => "#DLK-Support",
|
||||||
|
|
||||||
|
];
|
||||||
|
"""
|
||||||
|
|
||||||
|
TEMPLATE_WP_CONFIG = """
|
||||||
|
define( 'DB_NAME', 'pifpaf' );
|
||||||
|
define( 'DB_USER', 'pifpaf' );
|
||||||
|
define( 'DB_PASSWORD', 'pifpaf' );
|
||||||
|
define( 'DB_HOST', 'localhost:{mysql_socket}' );
|
||||||
|
define( 'DB_CHARSET', 'utf8' );
|
||||||
|
define( 'DB_COLLATE', '' );
|
||||||
|
|
||||||
|
define( 'AUTH_KEY', 'put your unique phrase here' );
|
||||||
|
define( 'SECURE_AUTH_KEY', 'put your unique phrase here' );
|
||||||
|
define( 'LOGGED_IN_KEY', 'put your unique phrase here' );
|
||||||
|
define( 'NONCE_KEY', 'put your unique phrase here' );
|
||||||
|
define( 'AUTH_SALT', 'put your unique phrase here' );
|
||||||
|
define( 'SECURE_AUTH_SALT', 'put your unique phrase here' );
|
||||||
|
define( 'LOGGED_IN_SALT', 'put your unique phrase here' );
|
||||||
|
define( 'NONCE_SALT', 'put your unique phrase here' );
|
||||||
|
|
||||||
|
$table_prefix = '{wp_prefix}';
|
||||||
|
|
||||||
|
define( 'WP_DEBUG', false );
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {{
|
||||||
|
define( 'ABSPATH', '{wp_path}' );
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* That's all, stop editing! Happy publishing. */
|
||||||
|
|
||||||
|
/** Absolute path to the WordPress directory. */
|
||||||
|
|
||||||
|
|
||||||
|
/** Sets up WordPress vars and included files. */
|
||||||
|
require_once ABSPATH . 'wp-settings.php';
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class DlkController(BaseServicesController, DirectoryBasedController):
|
||||||
|
"""Mixin for server controllers that rely on DLK"""
|
||||||
|
|
||||||
|
software_name = "Dlk-Services"
|
||||||
|
|
||||||
|
def run_sql(self, sql: str) -> None:
|
||||||
|
mysql_socket = os.environ["PIFPAF_MYSQL_SOCKET"]
|
||||||
|
subprocess.run(
|
||||||
|
["mysql", "-S", mysql_socket, "pifpaf"],
|
||||||
|
input=sql.encode(),
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
||||||
|
self.create_config()
|
||||||
|
|
||||||
|
if protocol == "unreal4":
|
||||||
|
protocol = "unreal5"
|
||||||
|
assert protocol in ("unreal5",), protocol
|
||||||
|
|
||||||
|
mysql_socket = os.environ["PIFPAF_MYSQL_SOCKET"]
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.wp_cli_path = Path(os.environ["IRCTEST_WP_CLI_PATH"])
|
||||||
|
if not self.wp_cli_path.is_file():
|
||||||
|
raise KeyError()
|
||||||
|
except KeyError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"$IRCTEST_WP_CLI_PATH must be set to a WP-CLI executable (eg. "
|
||||||
|
"downloaded from <https://raw.githubusercontent.com/wp-cli/builds/"
|
||||||
|
"gh-pages/phar/wp-cli.phar>)"
|
||||||
|
) from None
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.dlk_path = Path(os.environ["IRCTEST_DLK_PATH"])
|
||||||
|
if not self.dlk_path.is_dir():
|
||||||
|
raise KeyError()
|
||||||
|
except KeyError:
|
||||||
|
raise RuntimeError("$IRCTEST_DLK_PATH is not set") from None
|
||||||
|
self.dlk_path = self.dlk_path.resolve()
|
||||||
|
|
||||||
|
# Unpack a fresh Wordpress install in the temporary directory.
|
||||||
|
# In theory we could have a common Wordpress install and only wp-config.php
|
||||||
|
# in the temporary directory; but wp-cli assumes wp-config.php must be
|
||||||
|
# in a Wordpress directory, and fails in various places if it isn't.
|
||||||
|
# Rather than symlinking everything to make it work, let's just copy
|
||||||
|
# the whole code, it's not that big.
|
||||||
|
try:
|
||||||
|
wp_zip_path = Path(os.environ["IRCTEST_WP_ZIP_PATH"])
|
||||||
|
if not wp_zip_path.is_file():
|
||||||
|
raise KeyError()
|
||||||
|
except KeyError:
|
||||||
|
raise RuntimeError(
|
||||||
|
"$IRCTEST_WP_ZIP_PATH must be set to a Wordpress source zipball "
|
||||||
|
"(eg. downloaded from <https://wordpress.org/latest.zip>)"
|
||||||
|
) from None
|
||||||
|
subprocess.run(
|
||||||
|
["unzip", wp_zip_path, "-d", self.directory], stdout=subprocess.DEVNULL
|
||||||
|
)
|
||||||
|
self.wp_path = self.directory / "wordpress"
|
||||||
|
|
||||||
|
rand_hex = secrets.token_hex(6)
|
||||||
|
self.wp_prefix = f"wp{rand_hex}_"
|
||||||
|
self.dlk_prefix = f"dlk{rand_hex}_"
|
||||||
|
template_vars = dict(
|
||||||
|
protocol=protocol,
|
||||||
|
server_hostname=server_hostname,
|
||||||
|
server_port=server_port,
|
||||||
|
mysql_socket=mysql_socket,
|
||||||
|
wp_path=self.wp_path,
|
||||||
|
wp_prefix=self.wp_prefix,
|
||||||
|
dlk_prefix=self.dlk_prefix,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure Wordpress
|
||||||
|
wp_config_path = self.directory / "wp-config.php"
|
||||||
|
with open(wp_config_path, "w") as fd:
|
||||||
|
fd.write(TEMPLATE_WP_CONFIG.format(**template_vars))
|
||||||
|
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"php",
|
||||||
|
self.wp_cli_path,
|
||||||
|
"core",
|
||||||
|
"install",
|
||||||
|
"--url=http://localhost/",
|
||||||
|
"--title=irctest site",
|
||||||
|
"--admin_user=adminuser",
|
||||||
|
"--admin_email=adminuser@example.org",
|
||||||
|
f"--path={self.wp_path}",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure Dlk
|
||||||
|
dlk_log_dir = self.directory / "logs"
|
||||||
|
dlk_conf_dir = self.directory / "conf"
|
||||||
|
dlk_conf_path = dlk_conf_dir / "dalek.conf"
|
||||||
|
os.mkdir(dlk_conf_dir)
|
||||||
|
with open(dlk_conf_path, "w") as fd:
|
||||||
|
fd.write(TEMPLATE_DLK_CONFIG.format(**template_vars))
|
||||||
|
dlk_wp_config_path = dlk_conf_dir / "wordpress.conf"
|
||||||
|
with open(dlk_wp_config_path, "w") as fd:
|
||||||
|
fd.write(TEMPLATE_DLK_WP_CONFIG.format(**template_vars))
|
||||||
|
(dlk_conf_dir / "modules.conf").symlink_to(self.dlk_path / "conf/modules.conf")
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"php",
|
||||||
|
"src/dalek",
|
||||||
|
],
|
||||||
|
cwd=self.dlk_path,
|
||||||
|
env={
|
||||||
|
**os.environ,
|
||||||
|
"DALEK_CONF_DIR": str(dlk_conf_dir),
|
||||||
|
"DALEK_LOG_DIR": str(dlk_log_dir),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def terminate(self) -> None:
|
||||||
|
super().terminate()
|
||||||
|
|
||||||
|
def kill(self) -> None:
|
||||||
|
super().kill()
|
||||||
|
|
||||||
|
def registerUser(
|
||||||
|
self,
|
||||||
|
case: irctest.cases.BaseServerTestCase,
|
||||||
|
username: str,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
assert password
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"php",
|
||||||
|
self.wp_cli_path,
|
||||||
|
"user",
|
||||||
|
"create",
|
||||||
|
username,
|
||||||
|
f"{username}@example.org",
|
||||||
|
f"--user_pass={password}",
|
||||||
|
f"--path={self.wp_path}",
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[DlkController]:
|
||||||
|
return DlkController
|
297
irctest/controllers/ergo.py
Normal file
297
irctest/controllers/ergo.py
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Any, Dict, Optional, Type, Union
|
||||||
|
|
||||||
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
|
from irctest.cases import BaseServerTestCase
|
||||||
|
|
||||||
|
BASE_CONFIG = {
|
||||||
|
"network": {"name": "ErgoTest"},
|
||||||
|
"server": {
|
||||||
|
"name": "My.Little.Server",
|
||||||
|
"listeners": {},
|
||||||
|
"max-sendq": "16k",
|
||||||
|
"connection-limits": {
|
||||||
|
"enabled": True,
|
||||||
|
"cidr-len-ipv4": 32,
|
||||||
|
"cidr-len-ipv6": 64,
|
||||||
|
"ips-per-subnet": 1,
|
||||||
|
"exempted": ["localhost"],
|
||||||
|
},
|
||||||
|
"connection-throttling": {
|
||||||
|
"enabled": True,
|
||||||
|
"cidr-len-ipv4": 32,
|
||||||
|
"cidr-len-ipv6": 64,
|
||||||
|
"ips-per-subnet": 16,
|
||||||
|
"duration": "10m",
|
||||||
|
"max-connections": 1,
|
||||||
|
"ban-duration": "10m",
|
||||||
|
"ban-message": "Try again later",
|
||||||
|
"exempted": ["localhost"],
|
||||||
|
},
|
||||||
|
"lookup-hostnames": False,
|
||||||
|
"enforce-utf8": True,
|
||||||
|
"relaymsg": {"enabled": True, "separators": "/", "available-to-chanops": True},
|
||||||
|
"compatibility": {
|
||||||
|
"allow-truncation": False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"accounts": {
|
||||||
|
"authentication-enabled": True,
|
||||||
|
"advertise-scram": True,
|
||||||
|
"multiclient": {
|
||||||
|
"allowed-by-default": True,
|
||||||
|
"enabled": True,
|
||||||
|
"always-on": "disabled",
|
||||||
|
},
|
||||||
|
"registration": {
|
||||||
|
"bcrypt-cost": 4,
|
||||||
|
"enabled": True,
|
||||||
|
"enabled-callbacks": ["none"],
|
||||||
|
"verify-timeout": "120h",
|
||||||
|
},
|
||||||
|
"nick-reservation": {
|
||||||
|
"enabled": True,
|
||||||
|
"method": "strict",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"channels": {"registration": {"enabled": True}},
|
||||||
|
"datastore": {"path": None},
|
||||||
|
"limits": {
|
||||||
|
"awaylen": 200,
|
||||||
|
"chan-list-modes": 60,
|
||||||
|
"channellen": 64,
|
||||||
|
"kicklen": 390,
|
||||||
|
"linelen": {"rest": 2048},
|
||||||
|
"monitor-entries": 100,
|
||||||
|
"nicklen": 32,
|
||||||
|
"topiclen": 390,
|
||||||
|
"whowas-entries": 100,
|
||||||
|
"multiline": {"max-bytes": 4096, "max-lines": 32},
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"enabled": True,
|
||||||
|
"channel-length": 128,
|
||||||
|
"client-length": 128,
|
||||||
|
"chathistory-maxmessages": 100,
|
||||||
|
"tagmsg-storage": {
|
||||||
|
"default": False,
|
||||||
|
"whitelist": ["+draft/persist", "+persist"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"oper-classes": {
|
||||||
|
"server-admin": {
|
||||||
|
"title": "Server Admin",
|
||||||
|
"capabilities": [
|
||||||
|
"oper:local_kill",
|
||||||
|
"oper:local_ban",
|
||||||
|
"oper:local_unban",
|
||||||
|
"nofakelag",
|
||||||
|
"oper:remote_kill",
|
||||||
|
"oper:remote_ban",
|
||||||
|
"oper:remote_unban",
|
||||||
|
"oper:rehash",
|
||||||
|
"oper:die",
|
||||||
|
"accreg",
|
||||||
|
"sajoin",
|
||||||
|
"samode",
|
||||||
|
"vhosts",
|
||||||
|
"chanreg",
|
||||||
|
"relaymsg",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"opers": {
|
||||||
|
"operuser": {
|
||||||
|
"class": "server-admin",
|
||||||
|
"whois-line": "is a server admin",
|
||||||
|
# "operpassword"
|
||||||
|
"password": "$2a$04$bKb6k5A6yuFA2wx.iJtxcuT2dojHQAjHd5ZPK/I2sjJml7p4spxjG",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGING_CONFIG = {"logging": [{"method": "stderr", "level": "debug", "type": "*"}]}
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password: Union[str, bytes]) -> str:
|
||||||
|
if isinstance(password, str):
|
||||||
|
password = password.encode("utf-8")
|
||||||
|
# simulate entry of password and confirmation:
|
||||||
|
input_ = password + b"\n" + password + b"\n"
|
||||||
|
p = subprocess.Popen(
|
||||||
|
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
||||||
|
)
|
||||||
|
out, _ = p.communicate(input_)
|
||||||
|
return out.decode("utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
|
class ErgoController(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "Ergo"
|
||||||
|
_port_wait_interval = 0.01
|
||||||
|
supported_sasl_mechanisms = {"PLAIN", "SCRAM-SHA-256"}
|
||||||
|
supports_sts = True
|
||||||
|
extban_mute_char = "m"
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("ircd.yaml"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
config: Optional[Any] = None,
|
||||||
|
) -> None:
|
||||||
|
self.create_config()
|
||||||
|
if config is None:
|
||||||
|
config = copy.deepcopy(BASE_CONFIG)
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
enable_chathistory = self.test_config.chathistory
|
||||||
|
enable_roleplay = self.test_config.ergo_roleplay
|
||||||
|
if enable_chathistory or enable_roleplay:
|
||||||
|
config = self.addMysqlToConfig(config)
|
||||||
|
|
||||||
|
if enable_roleplay:
|
||||||
|
config["roleplay"] = {"enabled": True}
|
||||||
|
|
||||||
|
if self.test_config.ergo_config:
|
||||||
|
self.test_config.ergo_config(config)
|
||||||
|
|
||||||
|
self.port = port
|
||||||
|
bind_address = "127.0.0.1:%s" % (port,)
|
||||||
|
listener_conf = None # plaintext
|
||||||
|
if ssl:
|
||||||
|
self.key_path = self.directory / "ssl.key"
|
||||||
|
self.pem_path = self.directory / "ssl.pem"
|
||||||
|
listener_conf = {"tls": {"cert": self.pem_path, "key": self.key_path}}
|
||||||
|
config["server"]["listeners"][bind_address] = listener_conf # type: ignore
|
||||||
|
|
||||||
|
config["datastore"]["path"] = str(self.directory / "ircd.db") # type: ignore
|
||||||
|
|
||||||
|
if password is not None:
|
||||||
|
config["server"]["password"] = hash_password(password) # type: ignore
|
||||||
|
|
||||||
|
assert self.proc is None
|
||||||
|
|
||||||
|
self._config_path = self.directory / "server.yml"
|
||||||
|
self._config = config
|
||||||
|
self._write_config()
|
||||||
|
subprocess.call(["ergo", "initdb", "--conf", self._config_path, "--quiet"])
|
||||||
|
subprocess.call(["ergo", "mkcerts", "--conf", self._config_path, "--quiet"])
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
|
||||||
|
)
|
||||||
|
|
||||||
|
def wait_for_services(self) -> None:
|
||||||
|
# Nothing to wait for, they start at the same time as Ergo.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def registerUser(
|
||||||
|
self,
|
||||||
|
case: BaseServerTestCase,
|
||||||
|
username: str,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
if not case.run_services:
|
||||||
|
# Ergo does not actually need this, but other controllers do, so we
|
||||||
|
# are checking it here as well for tests that aren't tested with other
|
||||||
|
# controllers.
|
||||||
|
raise ValueError(
|
||||||
|
"Attempted to register a nick, but `run_services` it not True."
|
||||||
|
)
|
||||||
|
client = case.addClient(show_io=False)
|
||||||
|
case.sendLine(client, "CAP LS 302")
|
||||||
|
case.sendLine(client, "NICK " + username)
|
||||||
|
case.sendLine(client, "USER r e g :user")
|
||||||
|
case.sendLine(client, "CAP END")
|
||||||
|
while case.getRegistrationMessage(client).command != "001":
|
||||||
|
pass
|
||||||
|
case.getMessages(client)
|
||||||
|
assert password
|
||||||
|
case.sendLine(client, "NS REGISTER " + password)
|
||||||
|
msg = case.getMessage(client)
|
||||||
|
assert msg.params == [username, "Account created"]
|
||||||
|
case.sendLine(client, "QUIT")
|
||||||
|
case.assertDisconnected(client)
|
||||||
|
|
||||||
|
def _write_config(self) -> None:
|
||||||
|
with open(self._config_path, "w") as fd:
|
||||||
|
json.dump(self._config, fd)
|
||||||
|
|
||||||
|
def baseConfig(self) -> Dict:
|
||||||
|
return copy.deepcopy(BASE_CONFIG)
|
||||||
|
|
||||||
|
def getConfig(self) -> Dict:
|
||||||
|
return copy.deepcopy(self._config)
|
||||||
|
|
||||||
|
def addLoggingToConfig(self, config: Optional[Dict] = None) -> Dict:
|
||||||
|
if config is None:
|
||||||
|
config = self.baseConfig()
|
||||||
|
config.update(LOGGING_CONFIG)
|
||||||
|
return config
|
||||||
|
|
||||||
|
def addMysqlToConfig(self, config: Optional[Dict] = None) -> Dict:
|
||||||
|
mysql_password = os.getenv("MYSQL_PASSWORD")
|
||||||
|
if config is None:
|
||||||
|
config = self.baseConfig()
|
||||||
|
if not mysql_password:
|
||||||
|
return config
|
||||||
|
config["datastore"]["mysql"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"host": "localhost",
|
||||||
|
"user": "ergo",
|
||||||
|
"password": mysql_password,
|
||||||
|
"history-database": "ergo_history",
|
||||||
|
"timeout": "3s",
|
||||||
|
}
|
||||||
|
config["accounts"]["multiclient"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"allowed-by-default": True,
|
||||||
|
"always-on": "disabled",
|
||||||
|
}
|
||||||
|
config["history"]["persistent"] = {
|
||||||
|
"enabled": True,
|
||||||
|
"unregistered-channels": True,
|
||||||
|
"registered-channels": "opt-out",
|
||||||
|
"direct-messages": "opt-out",
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
|
||||||
|
def rehash(self, case: BaseServerTestCase, config: Dict) -> None:
|
||||||
|
self._config = config
|
||||||
|
self._write_config()
|
||||||
|
client = "operator_for_rehash"
|
||||||
|
case.connectClient(nick=client, name=client)
|
||||||
|
case.sendLine(client, "OPER operuser operpassword")
|
||||||
|
case.sendLine(client, "REHASH")
|
||||||
|
case.getMessages(client)
|
||||||
|
case.sendLine(client, "QUIT")
|
||||||
|
case.assertDisconnected(client)
|
||||||
|
|
||||||
|
def enable_debug_logging(self, case: BaseServerTestCase) -> None:
|
||||||
|
config = self.getConfig()
|
||||||
|
config.update(LOGGING_CONFIG)
|
||||||
|
self.rehash(case, config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[ErgoController]:
|
||||||
|
return ErgoController
|
48
irctest/controllers/external_server.py
Normal file
48
irctest/controllers/external_server.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import os
|
||||||
|
from typing import Optional, Tuple, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import BaseServerController
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalServerController(BaseServerController):
|
||||||
|
"""Dummy controller that doesn't run a server.
|
||||||
|
Instead, it allows connecting to servers ran outside irctest."""
|
||||||
|
|
||||||
|
software_name = "unknown external server"
|
||||||
|
supported_sasl_mechanisms = set(
|
||||||
|
os.environ.get("IRCTEST_SERVER_SASL_MECHS", "").split()
|
||||||
|
)
|
||||||
|
|
||||||
|
def check_is_alive(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def kill_proc(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def wait_for_port(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_hostname_and_port(self) -> Tuple[str, int]:
|
||||||
|
hostname = os.environ.get("IRCTEST_SERVER_HOSTNAME")
|
||||||
|
port = os.environ.get("IRCTEST_SERVER_PORT")
|
||||||
|
if not hostname or not port:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Please set IRCTEST_SERVER_HOSTNAME and IRCTEST_SERVER_PORT."
|
||||||
|
)
|
||||||
|
return (hostname, int(port))
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[ExternalServerController]:
|
||||||
|
return ExternalServerController
|
38
irctest/controllers/girc.py
Normal file
38
irctest/controllers/girc.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from irctest import authentication, tls
|
||||||
|
from irctest.basecontrollers import (
|
||||||
|
BaseClientController,
|
||||||
|
DirectoryBasedController,
|
||||||
|
NotImplementedByController,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GircController(BaseClientController, DirectoryBasedController):
|
||||||
|
software_name = "gIRC"
|
||||||
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
auth: Optional[authentication.Authentication],
|
||||||
|
tls_config: Optional[tls.TlsConfig] = None,
|
||||||
|
) -> None:
|
||||||
|
if tls_config:
|
||||||
|
print(tls_config)
|
||||||
|
raise NotImplementedByController("TLS options")
|
||||||
|
args = ["--host", hostname, "--port", str(port), "--quiet"]
|
||||||
|
|
||||||
|
if auth and auth.username and auth.password:
|
||||||
|
args += ["--sasl-name", auth.username]
|
||||||
|
args += ["--sasl-pass", auth.password]
|
||||||
|
args += ["--sasl-fail-is-ok"]
|
||||||
|
|
||||||
|
# Runs a client with the config given as arguments
|
||||||
|
self.proc = subprocess.Popen(["girc_test", "connect"] + args)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[GircController]:
|
||||||
|
return GircController
|
82
irctest/controllers/hybrid.py
Normal file
82
irctest/controllers/hybrid.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
from typing import Set, Type
|
||||||
|
|
||||||
|
from .base_hybrid import BaseHybridController
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
serverinfo {{
|
||||||
|
name = "My.Little.Server";
|
||||||
|
sid = "42X";
|
||||||
|
description = "test server";
|
||||||
|
|
||||||
|
# Hybrid defaults to 9
|
||||||
|
max_nick_length = 20;
|
||||||
|
{ssl_config}
|
||||||
|
}};
|
||||||
|
|
||||||
|
general {{
|
||||||
|
throttle_count = 100; # We need to connect lots of clients quickly
|
||||||
|
sasl_service = "SaslServ";
|
||||||
|
|
||||||
|
# Allow PART/QUIT reasons quickly
|
||||||
|
anti_spam_exit_message_time = 0;
|
||||||
|
|
||||||
|
# Allow all commands quickly
|
||||||
|
pace_wait_simple = 0;
|
||||||
|
pace_wait = 0;
|
||||||
|
}};
|
||||||
|
|
||||||
|
listen {{
|
||||||
|
defer_accept = yes;
|
||||||
|
|
||||||
|
host = "{hostname}";
|
||||||
|
port = {port};
|
||||||
|
port = {services_port};
|
||||||
|
}};
|
||||||
|
|
||||||
|
class {{
|
||||||
|
name = "server";
|
||||||
|
ping_time = 5 minutes;
|
||||||
|
connectfreq = 5 minutes;
|
||||||
|
}};
|
||||||
|
connect {{
|
||||||
|
name = "services.example.org";
|
||||||
|
host = "127.0.0.1"; # Used to validate incoming connection
|
||||||
|
port = 0; # We don't need servers to connect to services
|
||||||
|
send_password = "password";
|
||||||
|
accept_password = "password";
|
||||||
|
class = "server";
|
||||||
|
}};
|
||||||
|
service {{
|
||||||
|
name = "services.example.org";
|
||||||
|
}};
|
||||||
|
|
||||||
|
auth {{
|
||||||
|
user = "*";
|
||||||
|
flags = exceed_limit;
|
||||||
|
{password_field}
|
||||||
|
}};
|
||||||
|
|
||||||
|
operator {{
|
||||||
|
name = "operuser";
|
||||||
|
user = "*@*";
|
||||||
|
password = "operpassword";
|
||||||
|
encrypted = no;
|
||||||
|
umodes = locops, servnotice, wallop;
|
||||||
|
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
|
||||||
|
kline, module, rehash, restart, set, unkline, unxline, wallops, xline;
|
||||||
|
}};
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class HybridController(BaseHybridController):
|
||||||
|
software_name = "Hybrid"
|
||||||
|
binary_name = "ircd"
|
||||||
|
services_protocol = "hybrid"
|
||||||
|
|
||||||
|
supported_sasl_mechanisms: Set[str] = set()
|
||||||
|
|
||||||
|
template_config = TEMPLATE_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[HybridController]:
|
||||||
|
return HybridController
|
@ -1,25 +1,89 @@
|
|||||||
import os
|
import functools
|
||||||
import time
|
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
from irctest import authentication
|
|
||||||
from irctest.basecontrollers import NotImplementedByController
|
|
||||||
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
|
# Clients:
|
||||||
<bind address="{hostname}" port="{port}" type="clients">
|
<bind address="{hostname}" port="{port}" type="clients">
|
||||||
{ssl_config}
|
{ssl_config}
|
||||||
<module name="cap">
|
|
||||||
<module name="ircv3">
|
|
||||||
<module name="ircv3_capnotify">
|
|
||||||
<module name="ircv3_echomessage">
|
|
||||||
<module name="namesx"> # For multi-prefix
|
|
||||||
<connect allow="*"
|
<connect allow="*"
|
||||||
resolvehostnames="no" # Faster
|
resolvehostnames="no" # Faster
|
||||||
|
recvq="40960" # Needs to be larger than a valid message with tags
|
||||||
|
timeout="10" # So tests don't hang too long
|
||||||
|
localmax="1000"
|
||||||
|
globalmax="1000"
|
||||||
{password_field}>
|
{password_field}>
|
||||||
|
|
||||||
|
<class
|
||||||
|
name="ServerOperators"
|
||||||
|
commands="WALLOPS GLOBOPS"
|
||||||
|
privs="channels/auspex users/auspex channels/auspex servers/auspex"
|
||||||
|
>
|
||||||
|
<type
|
||||||
|
name="NetAdmin"
|
||||||
|
classes="ServerOperators"
|
||||||
|
>
|
||||||
|
<oper name="operuser"
|
||||||
|
password="operpassword"
|
||||||
|
host="*@*"
|
||||||
|
type="NetAdmin"
|
||||||
|
class="ServerOperators"
|
||||||
|
>
|
||||||
|
|
||||||
|
<options casemapping="ascii">
|
||||||
|
|
||||||
|
# Disable 'NOTICE #chan :*** foo invited bar into the channel-
|
||||||
|
<security announceinvites="none">
|
||||||
|
|
||||||
|
# Services:
|
||||||
|
<bind address="{services_hostname}" port="{services_port}" type="servers">
|
||||||
|
<link name="services.example.org"
|
||||||
|
ipaddr="{services_hostname}"
|
||||||
|
port="{services_port}"
|
||||||
|
allowmask="*"
|
||||||
|
recvpass="password"
|
||||||
|
sendpass="password"
|
||||||
|
>
|
||||||
|
<module name="spanningtree">
|
||||||
|
<module name="services_account">
|
||||||
|
<module name="hidechans"> # Anope errors when missing
|
||||||
|
<module name="svshold"> # Atheme raises a warning when missing
|
||||||
|
<sasl requiressl="no"
|
||||||
|
target="services.example.org">
|
||||||
|
|
||||||
|
# Protocol:
|
||||||
|
<module name="banexception">
|
||||||
|
<module name="botmode">
|
||||||
|
<module name="cap">
|
||||||
|
<module name="inviteexception">
|
||||||
|
<module name="ircv3">
|
||||||
|
<module name="ircv3_accounttag">
|
||||||
|
<module name="ircv3_batch">
|
||||||
|
<module name="ircv3_capnotify">
|
||||||
|
<module name="ircv3_ctctags">
|
||||||
|
<module name="ircv3_echomessage">
|
||||||
|
<module name="ircv3_invitenotify">
|
||||||
|
<module name="ircv3_labeledresponse">
|
||||||
|
<module name="ircv3_msgid">
|
||||||
|
<module name="ircv3_namedmodes"> # third-party, https://github.com/progval/inspircd-contrib/blob/namedmodes/4.0/m_ircv3_namedmodes.cpp
|
||||||
|
<module name="ircv3_servertime">
|
||||||
|
<module name="monitor">
|
||||||
|
<module name="m_muteban"> # for testing mute extbans
|
||||||
|
<module name="namesx"> # For multi-prefix
|
||||||
|
<module name="sasl">
|
||||||
|
<module name="uhnames"> # For userhost-in-names
|
||||||
|
|
||||||
|
# HELP/HELPOP
|
||||||
|
<module name="alias"> # for the HELP alias
|
||||||
|
<module name="{help_module_name}">
|
||||||
|
<include file="examples/{help_module_name}.conf.example">
|
||||||
|
|
||||||
|
# Misc:
|
||||||
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
|
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
|
||||||
|
<server name="My.Little.Server" description="test server" id="000" network="testnet">
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TEMPLATE_SSL_CONFIG = """
|
TEMPLATE_SSL_CONFIG = """
|
||||||
@ -27,45 +91,104 @@ TEMPLATE_SSL_CONFIG = """
|
|||||||
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
|
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache()
|
||||||
|
def installed_version() -> int:
|
||||||
|
output = subprocess.check_output(["inspircd", "--version"], universal_newlines=True)
|
||||||
|
if output.startswith("InspIRCd-3"):
|
||||||
|
return 3
|
||||||
|
if output.startswith("InspIRCd-4"):
|
||||||
|
return 4
|
||||||
|
else:
|
||||||
|
assert False, f"unexpected version: {output}"
|
||||||
|
|
||||||
|
|
||||||
class InspircdController(BaseServerController, DirectoryBasedController):
|
class InspircdController(BaseServerController, DirectoryBasedController):
|
||||||
software_name = 'InspIRCd'
|
software_name = "InspIRCd"
|
||||||
supported_sasl_mechanisms = set()
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
def create_config(self):
|
supports_sts = False
|
||||||
|
extban_mute_char = "m"
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
super().create_config()
|
super().create_config()
|
||||||
with self.open_file('server.conf'):
|
with self.open_file("server.conf"):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def run(self, hostname, port, password=None, ssl=False,
|
def run(
|
||||||
restricted_metadata_keys=None,
|
self,
|
||||||
valid_metadata_keys=None, invalid_metadata_keys=None):
|
hostname: str,
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
port: int,
|
||||||
raise NotImplementedByController(
|
*,
|
||||||
'Defining valid and invalid METADATA keys.')
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.port = port
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
self.create_config()
|
self.create_config()
|
||||||
password_field = 'password="{}"'.format(password) if password else ''
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
|
password_field = 'password="{}"'.format(password) if password else ""
|
||||||
|
|
||||||
if ssl:
|
if ssl:
|
||||||
self.gen_ssl()
|
self.gen_ssl()
|
||||||
ssl_config = TEMPLATE_SSL_CONFIG.format(
|
ssl_config = TEMPLATE_SSL_CONFIG.format(
|
||||||
key_path=self.key_path,
|
key_path=self.key_path, pem_path=self.pem_path, dh_path=self.dh_path
|
||||||
pem_path=self.pem_path,
|
)
|
||||||
dh_path=self.dh_path,
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
ssl_config = ''
|
ssl_config = ""
|
||||||
with self.open_file('server.conf') as fd:
|
|
||||||
fd.write(TEMPLATE_CONFIG.format(
|
if installed_version() == 3:
|
||||||
hostname=hostname,
|
help_module_name = "helpop"
|
||||||
port=port,
|
elif installed_version() == 4:
|
||||||
password_field=password_field,
|
help_module_name = "help"
|
||||||
ssl_config=ssl_config
|
else:
|
||||||
))
|
assert False, f"unexpected version: {installed_version()}"
|
||||||
self.proc = subprocess.Popen(['inspircd', '--nofork', '--config',
|
|
||||||
os.path.join(self.directory, 'server.conf')],
|
with self.open_file("server.conf") as fd:
|
||||||
stdout=subprocess.DEVNULL
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
services_hostname=services_hostname,
|
||||||
|
services_port=services_port,
|
||||||
|
password_field=password_field,
|
||||||
|
ssl_config=ssl_config,
|
||||||
|
help_module_name=help_module_name,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"inspircd",
|
||||||
|
"--nofork",
|
||||||
|
"--config",
|
||||||
|
self.directory / "server.conf",
|
||||||
|
],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_services:
|
||||||
|
self.wait_for_port()
|
||||||
|
self.services_controller = self.services_controller_class(
|
||||||
|
self.test_config, self
|
||||||
|
)
|
||||||
|
self.services_controller.run(
|
||||||
|
protocol="inspircd3",
|
||||||
|
server_hostname=services_hostname,
|
||||||
|
server_port=services_port,
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_irctest_controller_class():
|
|
||||||
return InspircdController
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[InspircdController]:
|
||||||
|
return InspircdController
|
||||||
|
96
irctest/controllers/irc2.py
Normal file
96
irctest/controllers/irc2.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import (
|
||||||
|
BaseServerController,
|
||||||
|
DirectoryBasedController,
|
||||||
|
NotImplementedByController,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
# M:<Server NAME>:<YOUR Internet IP#>:<Geographic Location>:<Port>:<SID>:
|
||||||
|
M:My.Little.Server:{hostname}:test server:{port}:0042:
|
||||||
|
|
||||||
|
# A:<Your Name/Location>:<Your E-Mail Addr>:<other info>::<network name>:
|
||||||
|
A:Organization, IRC dept.:Daemon <ircd@example.irc.org>:Client Server::IRCnet:
|
||||||
|
|
||||||
|
# P:<YOUR Internet IP#>:<*>::<Port>:<Flags>
|
||||||
|
P::::{port}::
|
||||||
|
|
||||||
|
# Y:<Class>:<Ping Frequency>::<Max Links>:<SendQ>:<Local Limit>:<Global Limit>:
|
||||||
|
Y:10:90::100:512000:100.100:100.100:
|
||||||
|
|
||||||
|
# I:<TARGET Host Addr>:<Password>:<TARGET Hosts NAME>:<Port>:<Class>:<Flags>:
|
||||||
|
I::{password_field}:::10::
|
||||||
|
|
||||||
|
# O:<TARGET Host NAME>:<Password>:<Nickname>:<Port>:<Class>:<Flags>:
|
||||||
|
O:*:operpassword:operuser::::
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Irc2Controller(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "irc2"
|
||||||
|
services_protocol: str
|
||||||
|
|
||||||
|
supports_sts = False
|
||||||
|
extban_mute_char = None
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("server.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
if ssl:
|
||||||
|
raise NotImplementedByController("TLS")
|
||||||
|
if run_services:
|
||||||
|
raise NotImplementedByController("Services")
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
|
self.create_config()
|
||||||
|
password_field = password if password else ""
|
||||||
|
assert self.directory
|
||||||
|
pidfile = self.directory / "ircd.pid"
|
||||||
|
with self.open_file("server.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
password_field=password_field,
|
||||||
|
pidfile=pidfile,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"ircd",
|
||||||
|
"-s", # no iauth
|
||||||
|
"-p",
|
||||||
|
"on",
|
||||||
|
"-f",
|
||||||
|
self.directory / "server.conf",
|
||||||
|
],
|
||||||
|
# stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[Irc2Controller]:
|
||||||
|
return Irc2Controller
|
115
irctest/controllers/ircu2.py
Normal file
115
irctest/controllers/ircu2.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import (
|
||||||
|
BaseServerController,
|
||||||
|
DirectoryBasedController,
|
||||||
|
NotImplementedByController,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
General {{
|
||||||
|
name = "My.Little.Server";
|
||||||
|
numeric = 42;
|
||||||
|
description = "test server";
|
||||||
|
}};
|
||||||
|
|
||||||
|
Port {{
|
||||||
|
vhost = "{hostname}";
|
||||||
|
port = {port};
|
||||||
|
}};
|
||||||
|
|
||||||
|
Class {{
|
||||||
|
name = "Client";
|
||||||
|
pingfreq = 5 minutes;
|
||||||
|
sendq = 160000;
|
||||||
|
maxlinks = 1024;
|
||||||
|
}};
|
||||||
|
|
||||||
|
Client {{
|
||||||
|
username = "*";
|
||||||
|
class = "Client";
|
||||||
|
{password_field}
|
||||||
|
}};
|
||||||
|
|
||||||
|
Operator {{
|
||||||
|
local = no;
|
||||||
|
host = "*@*";
|
||||||
|
password = "$PLAIN$operpassword";
|
||||||
|
name = "operuser";
|
||||||
|
class = "Client";
|
||||||
|
}};
|
||||||
|
|
||||||
|
features {{
|
||||||
|
"PPATH" = "{pidfile}";
|
||||||
|
|
||||||
|
# workaround for whois tests, checking the server name
|
||||||
|
"HIS_SERVERNAME" = "My.Little.Server";
|
||||||
|
}};
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "ircu2"
|
||||||
|
supports_sts = False
|
||||||
|
extban_mute_char = None
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("server.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
if ssl:
|
||||||
|
raise NotImplementedByController("TLS")
|
||||||
|
if run_services:
|
||||||
|
raise NotImplementedByController("Services")
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
|
self.create_config()
|
||||||
|
password_field = 'password = "{}";'.format(password) if password else ""
|
||||||
|
assert self.directory
|
||||||
|
pidfile = self.directory / "ircd.pid"
|
||||||
|
with self.open_file("server.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
password_field=password_field,
|
||||||
|
pidfile=pidfile,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"ircd",
|
||||||
|
"-n", # don't detach
|
||||||
|
"-f",
|
||||||
|
self.directory / "server.conf",
|
||||||
|
"-x",
|
||||||
|
"DEBUG",
|
||||||
|
],
|
||||||
|
# stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[Ircu2Controller]:
|
||||||
|
return Ircu2Controller
|
@ -1,14 +1,14 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
from irctest import authentication
|
from irctest import authentication, tls
|
||||||
from irctest.basecontrollers import NotImplementedByController
|
|
||||||
from irctest.basecontrollers import BaseClientController, DirectoryBasedController
|
from irctest.basecontrollers import BaseClientController, DirectoryBasedController
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
supybot.directories.conf: {directory}/conf
|
supybot.directories.conf: {directory}/conf
|
||||||
supybot.directories.data: {directory}/data
|
supybot.directories.data: {directory}/data
|
||||||
supybot.directories.migrations: {directory}/migrations
|
supybot.directories.migrations: {directory}/migrations
|
||||||
|
supybot.log.level: DEBUG
|
||||||
supybot.log.stdout.level: {loglevel}
|
supybot.log.stdout.level: {loglevel}
|
||||||
|
|
||||||
supybot.networks: testnet
|
supybot.networks: testnet
|
||||||
@ -24,44 +24,68 @@ supybot.networks.testnet.sasl.ecdsa_key: {directory}/ecdsa_key.pem
|
|||||||
supybot.networks.testnet.sasl.mechanisms: {mechanisms}
|
supybot.networks.testnet.sasl.mechanisms: {mechanisms}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class LimnoriaController(BaseClientController, DirectoryBasedController):
|
class LimnoriaController(BaseClientController, DirectoryBasedController):
|
||||||
software_name = 'Limnoria'
|
software_name = "Limnoria"
|
||||||
supported_sasl_mechanisms = {
|
supported_sasl_mechanisms = {
|
||||||
'PLAIN', 'ECDSA-NIST256P-CHALLENGE', 'EXTERNAL',
|
"PLAIN",
|
||||||
}
|
"ECDSA-NIST256P-CHALLENGE",
|
||||||
def create_config(self):
|
"SCRAM-SHA-256",
|
||||||
|
"EXTERNAL",
|
||||||
|
}
|
||||||
|
supports_sts = True
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
super().create_config()
|
super().create_config()
|
||||||
with self.open_file('bot.conf'):
|
with self.open_file("bot.conf"):
|
||||||
pass
|
pass
|
||||||
with self.open_file('conf/users.conf'):
|
with self.open_file("conf/users.conf"):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def run(self, hostname, port, auth, tls_config):
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
auth: Optional[authentication.Authentication],
|
||||||
|
tls_config: Optional[tls.TlsConfig] = None,
|
||||||
|
) -> None:
|
||||||
|
if tls_config is None:
|
||||||
|
tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[])
|
||||||
# Runs a client with the config given as arguments
|
# Runs a client with the config given as arguments
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.create_config()
|
self.create_config()
|
||||||
if auth:
|
|
||||||
mechanisms = ' '.join(map(authentication.Mechanisms.as_string,
|
|
||||||
auth.mechanisms))
|
|
||||||
if auth.ecdsa_key:
|
|
||||||
with self.open_file('ecdsa_key.pem') as fd:
|
|
||||||
fd.write(auth.ecdsa_key)
|
|
||||||
else:
|
|
||||||
mechanisms = ''
|
|
||||||
with self.open_file('bot.conf') as fd:
|
|
||||||
fd.write(TEMPLATE_CONFIG.format(
|
|
||||||
directory=self.directory,
|
|
||||||
loglevel='CRITICAL',
|
|
||||||
hostname=hostname,
|
|
||||||
port=port,
|
|
||||||
username=auth.username if auth else '',
|
|
||||||
password=auth.password if auth else '',
|
|
||||||
mechanisms=mechanisms.lower(),
|
|
||||||
enable_tls=tls_config.enable if tls_config else 'False',
|
|
||||||
trusted_fingerprints=' '.join(tls_config.trusted_fingerprints) if tls_config else '',
|
|
||||||
))
|
|
||||||
self.proc = subprocess.Popen(['supybot',
|
|
||||||
os.path.join(self.directory, 'bot.conf')])
|
|
||||||
|
|
||||||
def get_irctest_controller_class():
|
username = password = ""
|
||||||
|
mechanisms = ""
|
||||||
|
if auth:
|
||||||
|
mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms)
|
||||||
|
if auth.ecdsa_key:
|
||||||
|
with self.open_file("ecdsa_key.pem") as fd:
|
||||||
|
fd.write(auth.ecdsa_key)
|
||||||
|
|
||||||
|
if auth.username:
|
||||||
|
username = auth.username.encode("unicode_escape").decode()
|
||||||
|
if auth.password:
|
||||||
|
password = auth.password.encode("unicode_escape").decode()
|
||||||
|
with self.open_file("bot.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
directory=self.directory,
|
||||||
|
loglevel="CRITICAL",
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
mechanisms=mechanisms.lower(),
|
||||||
|
enable_tls=tls_config.enable if tls_config else "False",
|
||||||
|
trusted_fingerprints=" ".join(tls_config.trusted_fingerprints)
|
||||||
|
if tls_config
|
||||||
|
else "",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
assert self.directory
|
||||||
|
self.proc = subprocess.Popen(["supybot", self.directory / "bot.conf"])
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[LimnoriaController]:
|
||||||
return LimnoriaController
|
return LimnoriaController
|
||||||
|
@ -1,9 +1,13 @@
|
|||||||
import os
|
import shutil
|
||||||
import time
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from typing import Optional, Set, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import NotImplementedByController
|
from irctest.basecontrollers import (
|
||||||
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
BaseServerController,
|
||||||
|
DirectoryBasedController,
|
||||||
|
NotImplementedByController,
|
||||||
|
)
|
||||||
|
from irctest.cases import BaseServerTestCase
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
clients:
|
clients:
|
||||||
@ -29,10 +33,10 @@ extensions:
|
|||||||
- mammon.ext.ircv3.sasl
|
- mammon.ext.ircv3.sasl
|
||||||
- mammon.ext.misc.nopost
|
- mammon.ext.misc.nopost
|
||||||
metadata:
|
metadata:
|
||||||
restricted_keys:
|
restricted_keys: []
|
||||||
{restricted_keys}
|
|
||||||
whitelist:
|
whitelist:
|
||||||
{authorized_keys}
|
- display-name
|
||||||
|
- avatar
|
||||||
monitor:
|
monitor:
|
||||||
limit: 20
|
limit: 20
|
||||||
motd:
|
motd:
|
||||||
@ -58,64 +62,93 @@ server:
|
|||||||
recvq_len: 20
|
recvq_len: 20
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def make_list(l):
|
|
||||||
return '\n'.join(map(' - {}'.format, l))
|
def make_list(list_: Set[str]) -> str:
|
||||||
|
return "\n".join(map(" - {}".format, list_))
|
||||||
|
|
||||||
|
|
||||||
class MammonController(BaseServerController, DirectoryBasedController):
|
class MammonController(BaseServerController, DirectoryBasedController):
|
||||||
software_name = 'Mammon'
|
software_name = "Mammon"
|
||||||
supported_sasl_mechanisms = {
|
supported_sasl_mechanisms = {"PLAIN", "ECDSA-NIST256P-CHALLENGE"}
|
||||||
'PLAIN', 'ECDSA-NIST256P-CHALLENGE',
|
|
||||||
}
|
def create_config(self) -> None:
|
||||||
def create_config(self):
|
|
||||||
super().create_config()
|
super().create_config()
|
||||||
with self.open_file('server.conf'):
|
with self.open_file("server.conf"):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def kill_proc(self):
|
def kill_proc(self) -> None:
|
||||||
# Mammon does not seem to handle SIGTERM very well
|
# Mammon does not seem to handle SIGTERM very well
|
||||||
|
assert self.proc
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
|
|
||||||
def run(self, hostname, port, password=None, ssl=False,
|
def run(
|
||||||
restricted_metadata_keys=(),
|
self,
|
||||||
valid_metadata_keys=(), invalid_metadata_keys=()):
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
if password is not None:
|
if password is not None:
|
||||||
raise NotImplementedByController('PASS command')
|
raise NotImplementedByController("PASS command")
|
||||||
if ssl:
|
if ssl:
|
||||||
raise NotImplementedByController('SSL')
|
raise NotImplementedByController("SSL")
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.port = port
|
self.port = port
|
||||||
self.create_config()
|
self.create_config()
|
||||||
with self.open_file('server.yml') as fd:
|
with self.open_file("server.yml") as fd:
|
||||||
fd.write(TEMPLATE_CONFIG.format(
|
fd.write(
|
||||||
directory=self.directory,
|
TEMPLATE_CONFIG.format(
|
||||||
hostname=hostname,
|
directory=self.directory,
|
||||||
port=port,
|
hostname=hostname,
|
||||||
authorized_keys=make_list(valid_metadata_keys),
|
port=port,
|
||||||
restricted_keys=make_list(restricted_metadata_keys),
|
)
|
||||||
))
|
)
|
||||||
#with self.open_file('server.yml', 'r') as fd:
|
# with self.open_file('server.yml', 'r') as fd:
|
||||||
# print(fd.read())
|
# print(fd.read())
|
||||||
self.proc = subprocess.Popen(['mammond', '--nofork', #'--debug',
|
assert self.directory
|
||||||
'--config', os.path.join(self.directory, 'server.yml')])
|
|
||||||
|
|
||||||
def registerUser(self, case, username, password=None):
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"mammond",
|
||||||
|
"--nofork", # '--debug',
|
||||||
|
"--config",
|
||||||
|
self.directory / "server.yml",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
def registerUser(
|
||||||
|
self,
|
||||||
|
case: BaseServerTestCase,
|
||||||
|
username: str,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
# XXX: Move this somewhere else when
|
# XXX: Move this somewhere else when
|
||||||
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
|
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
|
||||||
# part of the specification
|
# part of the specification
|
||||||
client = case.addClient(show_io=False)
|
client = case.addClient(show_io=False)
|
||||||
case.sendLine(client, 'CAP LS 302')
|
case.sendLine(client, "CAP LS 302")
|
||||||
case.sendLine(client, 'NICK registration_user')
|
case.sendLine(client, "NICK registration_user")
|
||||||
case.sendLine(client, 'USER r e g :user')
|
case.sendLine(client, "USER r e g :user")
|
||||||
case.sendLine(client, 'CAP END')
|
case.sendLine(client, "CAP END")
|
||||||
while case.getRegistrationMessage(client).command != '001':
|
while case.getRegistrationMessage(client).command != "001":
|
||||||
pass
|
pass
|
||||||
list(case.getMessages(client))
|
list(case.getMessages(client))
|
||||||
case.sendLine(client, 'REG CREATE {} passphrase {}'.format(
|
case.sendLine(client, "REG CREATE {} passphrase {}".format(username, password))
|
||||||
username, password))
|
|
||||||
msg = case.getMessage(client)
|
msg = case.getMessage(client)
|
||||||
assert msg.command == '920', msg
|
assert msg.command == "920", msg
|
||||||
list(case.getMessages(client))
|
list(case.getMessages(client))
|
||||||
case.removeClient(client)
|
case.removeClient(client)
|
||||||
|
|
||||||
def get_irctest_controller_class():
|
|
||||||
|
def get_irctest_controller_class() -> Type[MammonController]:
|
||||||
return MammonController
|
return MammonController
|
||||||
|
11
irctest/controllers/nefarious.py
Normal file
11
irctest/controllers/nefarious.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from .ircu2 import Ircu2Controller
|
||||||
|
|
||||||
|
|
||||||
|
class NefariousController(Ircu2Controller):
|
||||||
|
software_name = "Nefarious"
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[NefariousController]:
|
||||||
|
return NefariousController
|
118
irctest/controllers/ngircd.py
Normal file
118
irctest/controllers/ngircd.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Set, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
[Global]
|
||||||
|
Name = My.Little.Server
|
||||||
|
Info = test server
|
||||||
|
Bind = {hostname}
|
||||||
|
Ports = {port}
|
||||||
|
AdminInfo1 = Bob Smith
|
||||||
|
AdminEMail = email@example.org
|
||||||
|
{password_field}
|
||||||
|
|
||||||
|
[Server]
|
||||||
|
Name = services.example.org
|
||||||
|
MyPassword = password
|
||||||
|
PeerPassword = password
|
||||||
|
Passive = yes # don't connect to it
|
||||||
|
ServiceMask = *Serv
|
||||||
|
|
||||||
|
[Options]
|
||||||
|
MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
|
||||||
|
|
||||||
|
[Operator]
|
||||||
|
Name = operuser
|
||||||
|
Password = operpassword
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class NgircdController(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "ngIRCd"
|
||||||
|
supported_sasl_mechanisms: Set[str] = set()
|
||||||
|
supports_sts = False
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("server.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
|
self.create_config()
|
||||||
|
(unused_hostname, unused_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
|
password_field = "Password = {}".format(password) if password else ""
|
||||||
|
|
||||||
|
self.gen_ssl()
|
||||||
|
if ssl:
|
||||||
|
(tls_hostname, tls_port) = (hostname, port)
|
||||||
|
(hostname, port) = (unused_hostname, unused_port)
|
||||||
|
else:
|
||||||
|
# Unreal refuses to start without TLS enabled
|
||||||
|
(tls_hostname, tls_port) = (unused_hostname, unused_port)
|
||||||
|
|
||||||
|
with self.open_file("empty.txt") as fd:
|
||||||
|
fd.write("\n")
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
with self.open_file("server.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
tls_hostname=tls_hostname,
|
||||||
|
tls_port=tls_port,
|
||||||
|
password_field=password_field,
|
||||||
|
key_path=self.key_path,
|
||||||
|
pem_path=self.pem_path,
|
||||||
|
empty_file=self.directory / "empty.txt",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"ngircd",
|
||||||
|
"--nodaemon",
|
||||||
|
"--config",
|
||||||
|
self.directory / "server.conf",
|
||||||
|
],
|
||||||
|
# stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_services:
|
||||||
|
self.wait_for_port()
|
||||||
|
self.services_controller = self.services_controller_class(
|
||||||
|
self.test_config, self
|
||||||
|
)
|
||||||
|
self.services_controller.run(
|
||||||
|
protocol="ngircd",
|
||||||
|
server_hostname=hostname,
|
||||||
|
server_port=port,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[NgircdController]:
|
||||||
|
return NgircdController
|
87
irctest/controllers/plexus4.py
Normal file
87
irctest/controllers/plexus4.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
from typing import Set, Type
|
||||||
|
|
||||||
|
from .base_hybrid import BaseHybridController
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
serverinfo {{
|
||||||
|
name = "My.Little.Server";
|
||||||
|
sid = "42X";
|
||||||
|
description = "test server";
|
||||||
|
|
||||||
|
# Hybrid defaults to 9
|
||||||
|
max_nick_length = 20;
|
||||||
|
{ssl_config}
|
||||||
|
}};
|
||||||
|
|
||||||
|
general {{
|
||||||
|
throttle_count = 100; # We need to connect lots of clients quickly
|
||||||
|
sasl_service = "SaslServ";
|
||||||
|
|
||||||
|
# Allow connections quickly
|
||||||
|
throttle_num = 100;
|
||||||
|
|
||||||
|
# Allow PART/QUIT reasons quickly
|
||||||
|
anti_spam_exit_message_time = 0;
|
||||||
|
|
||||||
|
# Allow all commands quickly
|
||||||
|
pace_wait_simple = 0;
|
||||||
|
pace_wait = 0;
|
||||||
|
}};
|
||||||
|
|
||||||
|
listen {{
|
||||||
|
defer_accept = yes;
|
||||||
|
|
||||||
|
host = "{hostname}";
|
||||||
|
port = {port};
|
||||||
|
|
||||||
|
flags = server;
|
||||||
|
port = {services_port};
|
||||||
|
}};
|
||||||
|
|
||||||
|
class {{
|
||||||
|
name = "server";
|
||||||
|
ping_time = 5 minutes;
|
||||||
|
connectfreq = 5 minutes;
|
||||||
|
}};
|
||||||
|
connect {{
|
||||||
|
name = "services.example.org";
|
||||||
|
host = "127.0.0.1"; # Used to validate incoming connection
|
||||||
|
port = 0; # We don't need servers to connect to services
|
||||||
|
send_password = "password";
|
||||||
|
accept_password = "password";
|
||||||
|
class = "server";
|
||||||
|
}};
|
||||||
|
service {{
|
||||||
|
name = "services.example.org";
|
||||||
|
}};
|
||||||
|
|
||||||
|
auth {{
|
||||||
|
user = "*";
|
||||||
|
flags = exceed_limit;
|
||||||
|
{password_field}
|
||||||
|
}};
|
||||||
|
|
||||||
|
operator {{
|
||||||
|
name = "operuser";
|
||||||
|
user = "*@*";
|
||||||
|
password = "operpassword";
|
||||||
|
encrypted = no;
|
||||||
|
umodes = locops, servnotice, wallop;
|
||||||
|
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
|
||||||
|
kline, module, rehash, restart, set, unkline, unxline, wallops, xline;
|
||||||
|
}};
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class Plexus4Controller(BaseHybridController):
|
||||||
|
software_name = "Plexus4"
|
||||||
|
binary_name = "ircd"
|
||||||
|
services_protocol = "plexus"
|
||||||
|
|
||||||
|
supported_sasl_mechanisms: Set[str] = set()
|
||||||
|
|
||||||
|
template_config = TEMPLATE_CONFIG
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[Plexus4Controller]:
|
||||||
|
return Plexus4Controller
|
481
irctest/controllers/sable.py
Normal file
481
irctest/controllers/sable.py
Normal file
@ -0,0 +1,481 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import (
|
||||||
|
BaseServerController,
|
||||||
|
BaseServicesController,
|
||||||
|
DirectoryBasedController,
|
||||||
|
NotImplementedByController,
|
||||||
|
)
|
||||||
|
from irctest.cases import BaseServerTestCase
|
||||||
|
from irctest.exceptions import NoMessageException
|
||||||
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
|
GEN_CERTS = """
|
||||||
|
mkdir -p useless_openssl_data/
|
||||||
|
|
||||||
|
cat > openssl.cnf <<EOF
|
||||||
|
[ ca ]
|
||||||
|
default_ca = CA_default # The default ca section
|
||||||
|
|
||||||
|
[ CA_default ]
|
||||||
|
new_certs_dir = useless_openssl_data/
|
||||||
|
database = useless_openssl_data/db
|
||||||
|
policy = policy_anything
|
||||||
|
serial = useless_openssl_data/serial
|
||||||
|
copy_extensions = copy
|
||||||
|
email_in_dn = no
|
||||||
|
rand_serial = no
|
||||||
|
|
||||||
|
[ policy_anything ]
|
||||||
|
countryName = optional
|
||||||
|
stateOrProvinceName = optional
|
||||||
|
localityName = optional
|
||||||
|
organizationName = optional
|
||||||
|
organizationalUnitName = optional
|
||||||
|
commonName = supplied
|
||||||
|
emailAddress = optional
|
||||||
|
|
||||||
|
[ usr_cert ]
|
||||||
|
subjectAltName=subject:copy
|
||||||
|
EOF
|
||||||
|
|
||||||
|
rm -f useless_openssl_data/db
|
||||||
|
touch useless_openssl_data/db
|
||||||
|
echo 01 > useless_openssl_data/serial
|
||||||
|
|
||||||
|
# Generate CA
|
||||||
|
openssl req -x509 -nodes -newkey rsa:2048 -batch \
|
||||||
|
-subj "/CN=Test CA" \
|
||||||
|
-outform PEM -out ca_cert.pem \
|
||||||
|
-keyout ca_cert.key
|
||||||
|
|
||||||
|
for server in $*; do
|
||||||
|
openssl genrsa -traditional \
|
||||||
|
-out $server.key \
|
||||||
|
2048
|
||||||
|
openssl req -nodes -batch -new \
|
||||||
|
-addext "subjectAltName = DNS:$server" \
|
||||||
|
-key $server.key \
|
||||||
|
-outform PEM -out server_$server.req
|
||||||
|
openssl ca -config openssl.cnf -days 3650 -md sha512 -batch \
|
||||||
|
-subj /CN=$server \
|
||||||
|
-keyfile ca_cert.key -cert ca_cert.pem \
|
||||||
|
-in server_$server.req \
|
||||||
|
-out $server.pem
|
||||||
|
openssl x509 -sha1 -in $server.pem -fingerprint -noout \
|
||||||
|
| sed "s/.*=//" | sed "s/://g" | tr '[:upper:]' '[:lower:]' > $server.pem.sha1
|
||||||
|
done
|
||||||
|
|
||||||
|
rm -r useless_openssl_data/
|
||||||
|
"""
|
||||||
|
|
||||||
|
_certs_dir = None
|
||||||
|
|
||||||
|
|
||||||
|
def certs_dir() -> Path:
|
||||||
|
global _certs_dir
|
||||||
|
if _certs_dir is None:
|
||||||
|
certs_dir = tempfile.TemporaryDirectory()
|
||||||
|
(Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS)
|
||||||
|
subprocess.run(
|
||||||
|
["bash", "gen_certs.sh", "My.Little.Server", "My.Little.Services"],
|
||||||
|
cwd=certs_dir.name,
|
||||||
|
check=True,
|
||||||
|
)
|
||||||
|
_certs_dir = certs_dir
|
||||||
|
return Path(_certs_dir.name)
|
||||||
|
|
||||||
|
|
||||||
|
NETWORK_CONFIG = """
|
||||||
|
{
|
||||||
|
"fanout": 1,
|
||||||
|
"ca_file": "%(certs_dir)s/ca_cert.pem",
|
||||||
|
|
||||||
|
"peers": [
|
||||||
|
{ "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" },
|
||||||
|
{ "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
NETWORK_CONFIG_CONFIG = """
|
||||||
|
{
|
||||||
|
"opers": [
|
||||||
|
{
|
||||||
|
"name": "operuser",
|
||||||
|
// echo -n "operpassword" | openssl passwd -6 -stdin
|
||||||
|
"hash": "$6$z5yA.OfGliDoi/R2$BgSsguS6bxAsPSCygDisgDw5JZuo5.88eU3Hyc7/4OaNpeKIxWGjOggeHzOl0xLiZg1vfwxXjOTFN14wG5vNI."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"alias_users": [
|
||||||
|
{
|
||||||
|
"nick": "ChanServ",
|
||||||
|
"user": "ChanServ",
|
||||||
|
"host": "services.",
|
||||||
|
"realname": "Channel services compatibility layer",
|
||||||
|
"command_alias": "CS"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nick": "NickServ",
|
||||||
|
"user": "NickServ",
|
||||||
|
"host": "services.",
|
||||||
|
"realname": "Account services compatibility layer",
|
||||||
|
"command_alias": "NS"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
"default_roles": {
|
||||||
|
"builtin:op": [
|
||||||
|
"always_send",
|
||||||
|
"op_self", "op_grant", "voice_self", "voice_grant",
|
||||||
|
"receive_op", "receive_voice", "receive_opmod",
|
||||||
|
"topic", "kick", "set_simple_mode", "set_key",
|
||||||
|
"rename",
|
||||||
|
"ban_view", "ban_add", "ban_remove_any",
|
||||||
|
"quiet_view", "quiet_add", "quiet_remove_any",
|
||||||
|
"exempt_view", "exempt_add", "exempt_remove_any",
|
||||||
|
"invite_self", "invite_other",
|
||||||
|
"invex_view", "invex_add", "invex_remove_any"
|
||||||
|
],
|
||||||
|
"builtin:voice": [
|
||||||
|
"always_send",
|
||||||
|
"voice_self",
|
||||||
|
"receive_voice",
|
||||||
|
"ban_view", "quiet_view"
|
||||||
|
],
|
||||||
|
"builtin:all": [
|
||||||
|
"ban_view", "quiet_view"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"debug_mode": true
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SERVER_CONFIG = """
|
||||||
|
{
|
||||||
|
"server_id": 1,
|
||||||
|
"server_name": "My.Little.Server",
|
||||||
|
|
||||||
|
"management": {
|
||||||
|
"address": "%(server1_management_hostname)s:%(server1_management_port)s",
|
||||||
|
"client_ca": "%(certs_dir)s/ca_cert.pem",
|
||||||
|
"authorised_fingerprints": [
|
||||||
|
{ "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
"server": {
|
||||||
|
"listeners": [
|
||||||
|
{ "address": "%(c2s_hostname)s:%(c2s_port)s" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
"event_log": {
|
||||||
|
"event_expiry": 300, // five minutes, for local testing
|
||||||
|
},
|
||||||
|
|
||||||
|
"tls_config": {
|
||||||
|
"key_file": "%(certs_dir)s/My.Little.Server.key",
|
||||||
|
"cert_file": "%(certs_dir)s/My.Little.Server.pem",
|
||||||
|
},
|
||||||
|
|
||||||
|
"node_config": {
|
||||||
|
"listen_addr": "%(server1_hostname)s:%(server1_port)s",
|
||||||
|
"cert_file": "%(certs_dir)s/My.Little.Server.pem",
|
||||||
|
"key_file": "%(certs_dir)s/My.Little.Server.key",
|
||||||
|
},
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"dir": "log/server1/",
|
||||||
|
|
||||||
|
"module-levels": {
|
||||||
|
"": "debug",
|
||||||
|
"sable_ircd": "trace",
|
||||||
|
},
|
||||||
|
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target": "stdout",
|
||||||
|
"level": "trace",
|
||||||
|
"modules": [ "sable", "audit", "client_listener" ],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SERVICES_CONFIG = """
|
||||||
|
{
|
||||||
|
"server_id": 99,
|
||||||
|
"server_name": "My.Little.Services",
|
||||||
|
|
||||||
|
"management": {
|
||||||
|
"address": "%(services_management_hostname)s:%(services_management_port)s",
|
||||||
|
"client_ca": "%(certs_dir)s/ca_cert.pem",
|
||||||
|
"authorised_fingerprints": [
|
||||||
|
{ "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
"server": {
|
||||||
|
"database": "test_database.json",
|
||||||
|
"default_roles": {
|
||||||
|
"builtin:founder": [
|
||||||
|
"founder", "access_view", "access_edit", "role_view", "role_edit",
|
||||||
|
"op_self", "op_grant",
|
||||||
|
"voice_self", "voice_grant",
|
||||||
|
"always_send",
|
||||||
|
"invite_self", "invite_other",
|
||||||
|
"receive_op", "receive_voice", "receive_opmod",
|
||||||
|
"topic", "kick", "set_simple_mode", "set_key",
|
||||||
|
"rename",
|
||||||
|
"ban_view", "ban_add", "ban_remove_any",
|
||||||
|
"quiet_view", "quiet_add", "quiet_remove_any",
|
||||||
|
"exempt_view", "exempt_add", "exempt_remove_any",
|
||||||
|
"invex_view", "invex_add", "invex_remove_any"
|
||||||
|
],
|
||||||
|
"builtin:op": [
|
||||||
|
"always_send",
|
||||||
|
"receive_op", "receive_voice", "receive_opmod",
|
||||||
|
"topic", "kick", "set_simple_mode", "set_key",
|
||||||
|
"rename",
|
||||||
|
"ban_view", "ban_add", "ban_remove_any",
|
||||||
|
"quiet_view", "quiet_add", "quiet_remove_any",
|
||||||
|
"exempt_view", "exempt_add", "exempt_remove_any",
|
||||||
|
"invex_view", "invex_add", "invex_remove_any"
|
||||||
|
],
|
||||||
|
"builtin:voice": [
|
||||||
|
"always_send", "voice_self", "receive_voice"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
"event_log": {
|
||||||
|
"event_expiry": 300, // five minutes, for local testing
|
||||||
|
},
|
||||||
|
|
||||||
|
"tls_config": {
|
||||||
|
"key_file": "%(certs_dir)s/My.Little.Services.key",
|
||||||
|
"cert_file": "%(certs_dir)s/My.Little.Services.pem"
|
||||||
|
},
|
||||||
|
|
||||||
|
"node_config": {
|
||||||
|
"listen_addr": "%(services_hostname)s:%(services_port)s",
|
||||||
|
"cert_file": "%(certs_dir)s/My.Little.Services.pem",
|
||||||
|
"key_file": "%(certs_dir)s/My.Little.Services.key"
|
||||||
|
},
|
||||||
|
|
||||||
|
"log": {
|
||||||
|
"dir": "log/services/",
|
||||||
|
|
||||||
|
"module-levels": {
|
||||||
|
"": "debug"
|
||||||
|
},
|
||||||
|
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"target": "stdout",
|
||||||
|
"level": "debug",
|
||||||
|
"modules": [ "sable_services" ]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SableController(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "Sable"
|
||||||
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
|
sync_sleep_time = 0.1
|
||||||
|
"""Sable processes commands very quickly, but responses for commands changing the
|
||||||
|
state may be sent after later commands for messages which don't."""
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
if password is not None:
|
||||||
|
raise NotImplementedByController("PASS command")
|
||||||
|
if ssl:
|
||||||
|
raise NotImplementedByController("SSL")
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.create_config()
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
(self.directory / "configs").mkdir()
|
||||||
|
|
||||||
|
c2s_hostname = hostname
|
||||||
|
c2s_port = port
|
||||||
|
del hostname, port
|
||||||
|
# base controller expects this to check for NickServ presence itself
|
||||||
|
self.hostname = c2s_hostname
|
||||||
|
self.port = c2s_port
|
||||||
|
|
||||||
|
(server1_hostname, server1_port) = self.get_hostname_and_port()
|
||||||
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
|
# Sable requires inbound connections to match the configured hostname,
|
||||||
|
# so we can't configure 0.0.0.0
|
||||||
|
server1_hostname = services_hostname = "127.0.0.1"
|
||||||
|
|
||||||
|
(
|
||||||
|
server1_management_hostname,
|
||||||
|
server1_management_port,
|
||||||
|
) = self.get_hostname_and_port()
|
||||||
|
(
|
||||||
|
services_management_hostname,
|
||||||
|
services_management_port,
|
||||||
|
) = self.get_hostname_and_port()
|
||||||
|
|
||||||
|
self.template_vars = dict(
|
||||||
|
certs_dir=certs_dir(),
|
||||||
|
c2s_hostname=c2s_hostname,
|
||||||
|
c2s_port=c2s_port,
|
||||||
|
server1_hostname=server1_hostname,
|
||||||
|
server1_port=server1_port,
|
||||||
|
server1_cert_sha1=(certs_dir() / "My.Little.Server.pem.sha1")
|
||||||
|
.read_text()
|
||||||
|
.strip(),
|
||||||
|
server1_management_hostname=server1_management_hostname,
|
||||||
|
server1_management_port=server1_management_port,
|
||||||
|
services_hostname=services_hostname,
|
||||||
|
services_port=services_port,
|
||||||
|
services_cert_sha1=(certs_dir() / "My.Little.Services.pem.sha1")
|
||||||
|
.read_text()
|
||||||
|
.strip(),
|
||||||
|
services_management_hostname=services_management_hostname,
|
||||||
|
services_management_port=services_management_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
with self.open_file("configs/network.conf") as fd:
|
||||||
|
fd.write(NETWORK_CONFIG % self.template_vars)
|
||||||
|
with self.open_file("configs/network_config.conf") as fd:
|
||||||
|
fd.write(NETWORK_CONFIG_CONFIG % self.template_vars)
|
||||||
|
with self.open_file("configs/server1.conf") as fd:
|
||||||
|
fd.write(SERVER_CONFIG % self.template_vars)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"sable_ircd",
|
||||||
|
"--foreground",
|
||||||
|
"--server-conf",
|
||||||
|
self.directory / "configs/server1.conf",
|
||||||
|
"--network-conf",
|
||||||
|
self.directory / "configs/network.conf",
|
||||||
|
"--bootstrap-network",
|
||||||
|
self.directory / "configs/network_config.conf",
|
||||||
|
],
|
||||||
|
cwd=self.directory,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
self.pgroup_id = os.getpgid(self.proc.pid)
|
||||||
|
|
||||||
|
if run_services:
|
||||||
|
self.services_controller = SableServicesController(self.test_config, self)
|
||||||
|
self.services_controller.run(
|
||||||
|
protocol="sable",
|
||||||
|
server_hostname=services_hostname,
|
||||||
|
server_port=services_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
def kill_proc(self) -> None:
|
||||||
|
os.killpg(self.pgroup_id, signal.SIGKILL)
|
||||||
|
super().kill_proc()
|
||||||
|
|
||||||
|
def registerUser(
|
||||||
|
self,
|
||||||
|
case: BaseServerTestCase, # type: ignore
|
||||||
|
username: str,
|
||||||
|
password: Optional[str] = None,
|
||||||
|
) -> None:
|
||||||
|
# XXX: Move this somewhere else when
|
||||||
|
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
|
||||||
|
# part of the specification
|
||||||
|
if not case.run_services:
|
||||||
|
raise ValueError(
|
||||||
|
"Attempted to register a nick, but `run_services` it not True."
|
||||||
|
)
|
||||||
|
assert password
|
||||||
|
client = case.addClient(show_io=True)
|
||||||
|
case.sendLine(client, "NICK " + username)
|
||||||
|
case.sendLine(client, "USER r e g :user")
|
||||||
|
while case.getRegistrationMessage(client).command != "001":
|
||||||
|
pass
|
||||||
|
case.getMessages(client)
|
||||||
|
case.sendLine(
|
||||||
|
client,
|
||||||
|
f"REGISTER * * {password}",
|
||||||
|
)
|
||||||
|
for _ in range(100):
|
||||||
|
time.sleep(0.1)
|
||||||
|
try:
|
||||||
|
msg = case.getMessage(client)
|
||||||
|
except NoMessageException:
|
||||||
|
continue
|
||||||
|
case.assertMessageMatch(
|
||||||
|
msg, command="REGISTER", params=["SUCCESS", username, ANYSTR]
|
||||||
|
)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise NoMessageException()
|
||||||
|
case.sendLine(client, "QUIT")
|
||||||
|
case.assertDisconnected(client)
|
||||||
|
|
||||||
|
|
||||||
|
class SableServicesController(BaseServicesController):
|
||||||
|
server_controller: SableController
|
||||||
|
software_name = "Sable Services"
|
||||||
|
|
||||||
|
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
||||||
|
assert protocol == "sable"
|
||||||
|
assert self.server_controller.directory is not None
|
||||||
|
|
||||||
|
with self.server_controller.open_file("configs/services.conf") as fd:
|
||||||
|
fd.write(SERVICES_CONFIG % self.server_controller.template_vars)
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
"sable_services",
|
||||||
|
"--foreground",
|
||||||
|
"--server-conf",
|
||||||
|
self.server_controller.directory / "configs/services.conf",
|
||||||
|
"--network-conf",
|
||||||
|
self.server_controller.directory / "configs/network.conf",
|
||||||
|
],
|
||||||
|
cwd=self.server_controller.directory,
|
||||||
|
preexec_fn=os.setsid,
|
||||||
|
)
|
||||||
|
self.pgroup_id = os.getpgid(self.proc.pid)
|
||||||
|
|
||||||
|
def kill_proc(self) -> None:
|
||||||
|
os.killpg(self.pgroup_id, signal.SIGKILL)
|
||||||
|
super().kill_proc()
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[SableController]:
|
||||||
|
return SableController
|
114
irctest/controllers/snircd.py
Normal file
114
irctest/controllers/snircd.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import (
|
||||||
|
BaseServerController,
|
||||||
|
DirectoryBasedController,
|
||||||
|
NotImplementedByController,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
General {{
|
||||||
|
name = "My.Little.Server";
|
||||||
|
numeric = 42;
|
||||||
|
description = "test server";
|
||||||
|
}};
|
||||||
|
|
||||||
|
Port {{
|
||||||
|
vhost = "{hostname}";
|
||||||
|
port = {port};
|
||||||
|
}};
|
||||||
|
|
||||||
|
Class {{
|
||||||
|
name = "Client";
|
||||||
|
pingfreq = 5 minutes;
|
||||||
|
sendq = 160000;
|
||||||
|
maxlinks = 1024;
|
||||||
|
}};
|
||||||
|
|
||||||
|
Client {{
|
||||||
|
username = "*";
|
||||||
|
class = "Client";
|
||||||
|
{password_field}
|
||||||
|
}};
|
||||||
|
|
||||||
|
Operator {{
|
||||||
|
local = no;
|
||||||
|
host = "*@*";
|
||||||
|
password = "$PLAIN$operpassword";
|
||||||
|
name = "operuser";
|
||||||
|
class = "Client";
|
||||||
|
}};
|
||||||
|
|
||||||
|
features {{
|
||||||
|
"PPATH" = "{pidfile}";
|
||||||
|
|
||||||
|
# don't block notices by default, wtf
|
||||||
|
"AUTOCHANMODES_LIST" = "+tnC";
|
||||||
|
}};
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class SnircdController(BaseServerController, DirectoryBasedController):
|
||||||
|
supports_sts = False
|
||||||
|
extban_mute_char = None
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("server.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
if ssl:
|
||||||
|
raise NotImplementedByController("TLS")
|
||||||
|
if run_services:
|
||||||
|
raise NotImplementedByController("Services")
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
|
self.create_config()
|
||||||
|
password_field = 'password = "{}";'.format(password) if password else ""
|
||||||
|
assert self.directory
|
||||||
|
pidfile = self.directory / "ircd.pid"
|
||||||
|
with self.open_file("server.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
password_field=password_field,
|
||||||
|
pidfile=pidfile,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"ircd",
|
||||||
|
"-n", # don't detach
|
||||||
|
"-f",
|
||||||
|
self.directory / "server.conf",
|
||||||
|
"-x",
|
||||||
|
"DEBUG",
|
||||||
|
],
|
||||||
|
# stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[SnircdController]:
|
||||||
|
return SnircdController
|
12
irctest/controllers/solanum.py
Normal file
12
irctest/controllers/solanum.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
from typing import Type
|
||||||
|
|
||||||
|
from .charybdis import CharybdisController
|
||||||
|
|
||||||
|
|
||||||
|
class SolanumController(CharybdisController):
|
||||||
|
software_name = "Solanum"
|
||||||
|
binary_name = "solanum"
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[SolanumController]:
|
||||||
|
return SolanumController
|
@ -1,9 +1,14 @@
|
|||||||
import os
|
from pathlib import Path
|
||||||
import tempfile
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
from typing import Optional, TextIO, Type, cast
|
||||||
|
|
||||||
from irctest.basecontrollers import BaseClientController
|
from irctest import authentication, tls
|
||||||
from irctest.basecontrollers import NotImplementedByController
|
from irctest.basecontrollers import (
|
||||||
|
BaseClientController,
|
||||||
|
NotImplementedByController,
|
||||||
|
TestCaseControllerConfig,
|
||||||
|
)
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
[core]
|
[core]
|
||||||
@ -12,57 +17,64 @@ host = {hostname}
|
|||||||
use_ssl = false
|
use_ssl = false
|
||||||
port = {port}
|
port = {port}
|
||||||
owner = me
|
owner = me
|
||||||
channels =
|
channels =
|
||||||
timeout = 5
|
timeout = 5
|
||||||
auth_username = {username}
|
auth_username = {username}
|
||||||
auth_password = {password}
|
auth_password = {password}
|
||||||
{auth_method}
|
{auth_method}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class SopelController(BaseClientController):
|
class SopelController(BaseClientController):
|
||||||
software_name = 'Sopel'
|
software_name = "Sopel"
|
||||||
supported_sasl_mechanisms = {
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
'PLAIN',
|
supports_sts = False
|
||||||
}
|
|
||||||
def __init__(self):
|
def __init__(self, test_config: TestCaseControllerConfig):
|
||||||
super().__init__()
|
super().__init__(test_config)
|
||||||
self.filename = next(tempfile._get_candidate_names()) + '.cfg'
|
self.filename = next(tempfile._get_candidate_names()) + ".cfg" # type: ignore
|
||||||
self.proc = None
|
|
||||||
def kill(self):
|
def kill(self) -> None:
|
||||||
if self.proc:
|
super().kill()
|
||||||
self.proc.kill()
|
|
||||||
if self.filename:
|
if self.filename:
|
||||||
try:
|
try:
|
||||||
os.unlink(os.path.join(os.path.expanduser('~/.sopel/'),
|
(Path("~/.sopel/").expanduser() / self.filename).unlink()
|
||||||
self.filename))
|
except OSError: # File does not exist
|
||||||
except OSError: # File does not exist
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def open_file(self, filename, mode='a'):
|
def open_file(self, filename: str, mode: str = "a") -> TextIO:
|
||||||
return open(os.path.join(os.path.expanduser('~/.sopel/'), filename),
|
dir_path = Path("~/.sopel/").expanduser()
|
||||||
mode)
|
dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return cast(TextIO, (dir_path / filename).open(mode))
|
||||||
|
|
||||||
def create_config(self):
|
def create_config(self) -> None:
|
||||||
with self.open_file(self.filename) as fd:
|
with self.open_file(self.filename):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def run(self, hostname, port, auth, tls_config):
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
auth: Optional[authentication.Authentication],
|
||||||
|
tls_config: Optional[tls.TlsConfig] = None,
|
||||||
|
) -> None:
|
||||||
# Runs a client with the config given as arguments
|
# Runs a client with the config given as arguments
|
||||||
if tls_config is not None:
|
if tls_config is not None:
|
||||||
raise NotImplementedByController(
|
raise NotImplementedByController("TLS configuration")
|
||||||
'TLS configuration')
|
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.create_config()
|
self.create_config()
|
||||||
with self.open_file(self.filename) as fd:
|
with self.open_file(self.filename) as fd:
|
||||||
fd.write(TEMPLATE_CONFIG.format(
|
fd.write(
|
||||||
hostname=hostname,
|
TEMPLATE_CONFIG.format(
|
||||||
port=port,
|
hostname=hostname,
|
||||||
username=auth.username if auth else '',
|
port=port,
|
||||||
password=auth.password if auth else '',
|
username=auth.username if auth else "",
|
||||||
auth_method='auth_method = sasl' if auth else '',
|
password=auth.password if auth else "",
|
||||||
))
|
auth_method="auth_method = sasl" if auth else "",
|
||||||
self.proc = subprocess.Popen(['sopel', '--quiet', '-c', self.filename])
|
)
|
||||||
|
)
|
||||||
|
self.proc = subprocess.Popen(["sopel", "-c", self.filename])
|
||||||
|
|
||||||
def get_irctest_controller_class():
|
|
||||||
|
def get_irctest_controller_class() -> Type[SopelController]:
|
||||||
return SopelController
|
return SopelController
|
||||||
|
|
||||||
|
106
irctest/controllers/thelounge.py
Normal file
106
irctest/controllers/thelounge.py
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from typing import Optional, Type
|
||||||
|
|
||||||
|
from irctest import authentication, tls
|
||||||
|
from irctest.basecontrollers import (
|
||||||
|
BaseClientController,
|
||||||
|
DirectoryBasedController,
|
||||||
|
NotImplementedByController,
|
||||||
|
)
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
module.exports = {config};
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class TheLoungeController(BaseClientController, DirectoryBasedController):
|
||||||
|
software_name = "TheLounge"
|
||||||
|
supported_sasl_mechanisms = {
|
||||||
|
"PLAIN",
|
||||||
|
"ECDSA-NIST256P-CHALLENGE",
|
||||||
|
"SCRAM-SHA-256",
|
||||||
|
"EXTERNAL",
|
||||||
|
}
|
||||||
|
supports_sts = True
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("bot.conf"):
|
||||||
|
pass
|
||||||
|
with self.open_file("conf/users.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
auth: Optional[authentication.Authentication],
|
||||||
|
tls_config: Optional[tls.TlsConfig] = None,
|
||||||
|
) -> None:
|
||||||
|
if tls_config is None:
|
||||||
|
tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[])
|
||||||
|
if tls_config and tls_config.trusted_fingerprints:
|
||||||
|
raise NotImplementedByController("Trusted fingerprints.")
|
||||||
|
if auth and any(
|
||||||
|
mech.to_string().startswith(("SCRAM-", "ECDSA-"))
|
||||||
|
for mech in auth.mechanisms
|
||||||
|
):
|
||||||
|
raise NotImplementedByController("ecdsa")
|
||||||
|
if auth and auth.password and len(auth.password) > 300:
|
||||||
|
# https://github.com/thelounge/thelounge/pull/4480
|
||||||
|
# Note that The Lounge truncates on 300 characters, not bytes.
|
||||||
|
raise NotImplementedByController("Passwords longer than 300 chars")
|
||||||
|
# Runs a client with the config given as arguments
|
||||||
|
assert self.proc is None
|
||||||
|
self.create_config()
|
||||||
|
if auth:
|
||||||
|
mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms)
|
||||||
|
if auth.ecdsa_key:
|
||||||
|
with self.open_file("ecdsa_key.pem") as fd:
|
||||||
|
fd.write(auth.ecdsa_key)
|
||||||
|
else:
|
||||||
|
mechanisms = ""
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
with self.open_file("config.js") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
config=json.dumps(
|
||||||
|
dict(
|
||||||
|
public=False,
|
||||||
|
host=f"unix:{self.directory}/sock", # prevents binding
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with self.open_file("users/testuser.json") as fd:
|
||||||
|
json.dump(
|
||||||
|
dict(
|
||||||
|
networks=[
|
||||||
|
dict(
|
||||||
|
name="testnet",
|
||||||
|
host=hostname,
|
||||||
|
port=port,
|
||||||
|
tls=tls_config.enable if tls_config else "False",
|
||||||
|
sasl=mechanisms.lower(),
|
||||||
|
saslAccount=auth.username if auth else "",
|
||||||
|
saslPassword=auth.password if auth else "",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
),
|
||||||
|
fd,
|
||||||
|
)
|
||||||
|
with self.open_file("users/testuser.json", "r") as fd:
|
||||||
|
print("config", json.load(fd)["networks"][0]["saslPassword"])
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[os.environ.get("THELOUNGE_BIN", "thelounge"), "start"],
|
||||||
|
env={**os.environ, "THELOUNGE_HOME": str(self.directory)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[TheLoungeController]:
|
||||||
|
return TheLoungeController
|
297
irctest/controllers/unrealircd.py
Normal file
297
irctest/controllers/unrealircd.py
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
import contextlib
|
||||||
|
import fcntl
|
||||||
|
import functools
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import textwrap
|
||||||
|
from typing import Callable, ContextManager, Iterator, Optional, Type
|
||||||
|
|
||||||
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
|
|
||||||
|
TEMPLATE_CONFIG = """
|
||||||
|
include "modules.default.conf";
|
||||||
|
include "operclass.default.conf";
|
||||||
|
{extras}
|
||||||
|
include "help/help.conf";
|
||||||
|
|
||||||
|
me {{
|
||||||
|
name "My.Little.Server";
|
||||||
|
info "test server";
|
||||||
|
sid "001";
|
||||||
|
}}
|
||||||
|
admin {{
|
||||||
|
"Bob Smith";
|
||||||
|
"bob";
|
||||||
|
"email@example.org";
|
||||||
|
}}
|
||||||
|
class clients {{
|
||||||
|
pingfreq 90;
|
||||||
|
maxclients 1000;
|
||||||
|
sendq 200k;
|
||||||
|
recvq 8000;
|
||||||
|
}}
|
||||||
|
class servers {{
|
||||||
|
pingfreq 60;
|
||||||
|
connfreq 15; /* try to connect every 15 seconds */
|
||||||
|
maxclients 10; /* max servers */
|
||||||
|
sendq 20M;
|
||||||
|
}}
|
||||||
|
allow {{
|
||||||
|
mask *;
|
||||||
|
class clients;
|
||||||
|
maxperip 50;
|
||||||
|
{password_field}
|
||||||
|
}}
|
||||||
|
listen {{
|
||||||
|
ip {hostname};
|
||||||
|
port {port};
|
||||||
|
}}
|
||||||
|
listen {{
|
||||||
|
ip {tls_hostname};
|
||||||
|
port {tls_port};
|
||||||
|
options {{ tls; }}
|
||||||
|
tls-options {{
|
||||||
|
certificate "{pem_path}";
|
||||||
|
key "{key_path}";
|
||||||
|
}};
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* Special SSL/TLS servers-only port for linking */
|
||||||
|
listen {{
|
||||||
|
ip {services_hostname};
|
||||||
|
port {services_port};
|
||||||
|
options {{ serversonly; }}
|
||||||
|
}}
|
||||||
|
|
||||||
|
link services.example.org {{
|
||||||
|
incoming {{
|
||||||
|
mask *;
|
||||||
|
}}
|
||||||
|
password "password";
|
||||||
|
class servers;
|
||||||
|
}}
|
||||||
|
ulines {{
|
||||||
|
services.example.org;
|
||||||
|
}}
|
||||||
|
|
||||||
|
set {{
|
||||||
|
sasl-server services.example.org;
|
||||||
|
kline-address "example@example.org";
|
||||||
|
network-name "ExampleNET";
|
||||||
|
default-server "irc.example.org";
|
||||||
|
help-channel "#Help";
|
||||||
|
cloak-keys {{ "aaaA1"; "bbbB2"; "cccC3"; }}
|
||||||
|
options {{
|
||||||
|
identd-check; // Disable it, so it doesn't prefix idents with a tilde
|
||||||
|
}}
|
||||||
|
anti-flood {{
|
||||||
|
// Prevent throttling, especially test_buffering.py which
|
||||||
|
// triggers anti-flood with its very long lines
|
||||||
|
unknown-users {{
|
||||||
|
nick-flood 255:10;
|
||||||
|
lag-penalty 1;
|
||||||
|
lag-penalty-bytes 10000;
|
||||||
|
}}
|
||||||
|
}}
|
||||||
|
modes-on-join "+H 100:1d"; // Enables CHATHISTORY
|
||||||
|
|
||||||
|
{set_v6only}
|
||||||
|
|
||||||
|
}}
|
||||||
|
|
||||||
|
tld {{
|
||||||
|
mask *;
|
||||||
|
motd "{empty_file}";
|
||||||
|
botmotd "{empty_file}";
|
||||||
|
rules "{empty_file}";
|
||||||
|
}}
|
||||||
|
|
||||||
|
files {{
|
||||||
|
tunefile "{empty_file}";
|
||||||
|
}}
|
||||||
|
|
||||||
|
oper "operuser" {{
|
||||||
|
password "operpassword";
|
||||||
|
mask *;
|
||||||
|
class clients;
|
||||||
|
operclass netadmin;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
|
SET_V6ONLY = """
|
||||||
|
// Remove RPL_WHOISSPECIAL used to advertise security groups
|
||||||
|
whois-details {
|
||||||
|
security-groups { everyone none; self none; oper none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
plaintext-policy {
|
||||||
|
server warn; // https://www.unrealircd.org/docs/FAQ#server-requires-tls
|
||||||
|
oper warn; // https://www.unrealircd.org/docs/FAQ#oper-requires-tls
|
||||||
|
}
|
||||||
|
|
||||||
|
anti-flood {
|
||||||
|
everyone {
|
||||||
|
connect-flood 255:10;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _filelock(path: Path) -> Callable[[], ContextManager]:
|
||||||
|
"""Alternative to :cls:`multiprocessing.Lock` that works with pytest-xdist"""
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def f() -> Iterator[None]:
|
||||||
|
with open(path, "a") as fd:
|
||||||
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
||||||
|
yield
|
||||||
|
|
||||||
|
return f
|
||||||
|
|
||||||
|
|
||||||
|
_UNREALIRCD_BIN = shutil.which("unrealircd")
|
||||||
|
if _UNREALIRCD_BIN:
|
||||||
|
_UNREALIRCD_PREFIX = Path(_UNREALIRCD_BIN).parent.parent
|
||||||
|
|
||||||
|
# Try to keep that lock file specific to this Unrealircd instance
|
||||||
|
_LOCK_PATH = _UNREALIRCD_PREFIX / "irctest-unrealircd-startstop.lock"
|
||||||
|
else:
|
||||||
|
# unrealircd not found; we are probably going to crash later anyway...
|
||||||
|
_LOCK_PATH = Path("/tmp/irctest-unrealircd-startstop.lock")
|
||||||
|
|
||||||
|
_STARTSTOP_LOCK = _filelock(_LOCK_PATH)
|
||||||
|
"""
|
||||||
|
Unreal cleans its tmp/ directory after each run, which prevents
|
||||||
|
multiple processes from starting/stopping at the same time.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@functools.lru_cache()
|
||||||
|
def installed_version() -> int:
|
||||||
|
output = subprocess.check_output(["unrealircd", "-v"], universal_newlines=True)
|
||||||
|
if output.startswith("UnrealIRCd-5."):
|
||||||
|
return 5
|
||||||
|
elif output.startswith("UnrealIRCd-6."):
|
||||||
|
return 6
|
||||||
|
else:
|
||||||
|
assert False, f"unexpected version: {output}"
|
||||||
|
|
||||||
|
|
||||||
|
class UnrealircdController(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "UnrealIRCd"
|
||||||
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
|
supports_sts = False
|
||||||
|
|
||||||
|
extban_mute_char = "quiet" if installed_version() >= 6 else "q"
|
||||||
|
software_version = installed_version()
|
||||||
|
|
||||||
|
def create_config(self) -> None:
|
||||||
|
super().create_config()
|
||||||
|
with self.open_file("server.conf"):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
*,
|
||||||
|
password: Optional[str],
|
||||||
|
ssl: bool,
|
||||||
|
run_services: bool,
|
||||||
|
faketime: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
assert self.proc is None
|
||||||
|
self.port = port
|
||||||
|
self.hostname = hostname
|
||||||
|
self.create_config()
|
||||||
|
|
||||||
|
if installed_version() >= 6:
|
||||||
|
extras = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
include "snomasks.default.conf";
|
||||||
|
loadmodule "cloak_md5";
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
set_v6only = SET_V6ONLY
|
||||||
|
else:
|
||||||
|
extras = ""
|
||||||
|
set_v6only = ""
|
||||||
|
|
||||||
|
with self.open_file("empty.txt") as fd:
|
||||||
|
fd.write("\n")
|
||||||
|
|
||||||
|
password_field = 'password "{}";'.format(password) if password else ""
|
||||||
|
|
||||||
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
|
(unused_hostname, unused_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
|
self.gen_ssl()
|
||||||
|
if ssl:
|
||||||
|
(tls_hostname, tls_port) = (hostname, port)
|
||||||
|
(hostname, port) = (unused_hostname, unused_port)
|
||||||
|
else:
|
||||||
|
# Unreal refuses to start without TLS enabled
|
||||||
|
(tls_hostname, tls_port) = (unused_hostname, unused_port)
|
||||||
|
|
||||||
|
assert self.directory
|
||||||
|
|
||||||
|
with self.open_file("unrealircd.conf") as fd:
|
||||||
|
fd.write(
|
||||||
|
TEMPLATE_CONFIG.format(
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
services_hostname=services_hostname,
|
||||||
|
services_port=services_port,
|
||||||
|
tls_hostname=tls_hostname,
|
||||||
|
tls_port=tls_port,
|
||||||
|
password_field=password_field,
|
||||||
|
key_path=self.key_path,
|
||||||
|
pem_path=self.pem_path,
|
||||||
|
empty_file=self.directory / "empty.txt",
|
||||||
|
set_v6only=set_v6only,
|
||||||
|
extras=extras,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
|
with _STARTSTOP_LOCK():
|
||||||
|
self.proc = subprocess.Popen(
|
||||||
|
[
|
||||||
|
*faketime_cmd,
|
||||||
|
"unrealircd",
|
||||||
|
"-t",
|
||||||
|
"-F", # BOOT_NOFORK
|
||||||
|
"-f",
|
||||||
|
self.directory / "unrealircd.conf",
|
||||||
|
],
|
||||||
|
# stdout=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
self.wait_for_port()
|
||||||
|
|
||||||
|
if run_services:
|
||||||
|
self.services_controller = self.services_controller_class(
|
||||||
|
self.test_config, self
|
||||||
|
)
|
||||||
|
self.services_controller.run(
|
||||||
|
protocol="unreal4",
|
||||||
|
server_hostname=services_hostname,
|
||||||
|
server_port=services_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
def kill_proc(self) -> None:
|
||||||
|
assert self.proc
|
||||||
|
|
||||||
|
with _STARTSTOP_LOCK():
|
||||||
|
self.proc.kill()
|
||||||
|
self.proc.wait(5) # wait for it to actually die
|
||||||
|
self.proc = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_irctest_controller_class() -> Type[UnrealircdController]:
|
||||||
|
return UnrealircdController
|
465
irctest/dashboard/format.py
Normal file
465
irctest/dashboard/format.py
Normal file
@ -0,0 +1,465 @@
|
|||||||
|
import base64
|
||||||
|
import dataclasses
|
||||||
|
import gzip
|
||||||
|
import hashlib
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from typing import (
|
||||||
|
IO,
|
||||||
|
Callable,
|
||||||
|
Dict,
|
||||||
|
Iterable,
|
||||||
|
Iterator,
|
||||||
|
List,
|
||||||
|
Optional,
|
||||||
|
Tuple,
|
||||||
|
TypeVar,
|
||||||
|
Union,
|
||||||
|
)
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
from defusedxml.ElementTree import parse as parse_xml
|
||||||
|
import docutils.core
|
||||||
|
|
||||||
|
from .shortxml import Namespace
|
||||||
|
|
||||||
|
NETLIFY_CHAR_BLACKLIST = frozenset('":<>|*?\r\n#')
|
||||||
|
"""Characters not allowed in output filenames"""
|
||||||
|
|
||||||
|
|
||||||
|
HTML = Namespace("http://www.w3.org/1999/xhtml")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class CaseResult:
|
||||||
|
module_name: str
|
||||||
|
class_name: str
|
||||||
|
test_name: str
|
||||||
|
job: str
|
||||||
|
success: bool
|
||||||
|
skipped: bool
|
||||||
|
system_out: Optional[str]
|
||||||
|
details: Optional[str] = None
|
||||||
|
type: Optional[str] = None
|
||||||
|
message: Optional[str] = None
|
||||||
|
|
||||||
|
def output_filename(self) -> str:
|
||||||
|
test_name = self.test_name
|
||||||
|
if len(test_name) > 50 or set(test_name) & NETLIFY_CHAR_BLACKLIST:
|
||||||
|
# File name too long or otherwise invalid. This should be good enough:
|
||||||
|
m = re.match(r"(?P<function_name>\w+?)\[(?P<params>.+)\]", test_name)
|
||||||
|
assert m, "File name is too long but has no parameter."
|
||||||
|
test_name = f'{m.group("function_name")}[{md5sum(m.group("params"))}]'
|
||||||
|
return f"{self.job}_{self.module_name}.{self.class_name}.{test_name}.txt"
|
||||||
|
|
||||||
|
|
||||||
|
TK = TypeVar("TK")
|
||||||
|
TV = TypeVar("TV")
|
||||||
|
|
||||||
|
|
||||||
|
def md5sum(text: str) -> str:
|
||||||
|
return base64.urlsafe_b64encode(hashlib.md5(text.encode()).digest()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def group_by(list_: Iterable[TV], key: Callable[[TV], TK]) -> Dict[TK, List[TV]]:
|
||||||
|
groups: Dict[TK, List[TV]] = {}
|
||||||
|
for value in list_:
|
||||||
|
groups.setdefault(key(value), []).append(value)
|
||||||
|
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def iter_job_results(job_file_name: Path, job: ET.ElementTree) -> Iterator[CaseResult]:
|
||||||
|
(suite,) = job.getroot()
|
||||||
|
for case in suite:
|
||||||
|
if "name" not in case.attrib:
|
||||||
|
continue
|
||||||
|
|
||||||
|
success = True
|
||||||
|
skipped = False
|
||||||
|
details = None
|
||||||
|
system_out = None
|
||||||
|
extra: Dict[str, str] = {}
|
||||||
|
for child in case:
|
||||||
|
if child.tag == "skipped":
|
||||||
|
success = True
|
||||||
|
skipped = True
|
||||||
|
details = None
|
||||||
|
extra = child.attrib
|
||||||
|
elif child.tag in ("failure", "error"):
|
||||||
|
success = False
|
||||||
|
skipped = False
|
||||||
|
details = child.text
|
||||||
|
extra = child.attrib
|
||||||
|
elif child.tag == "system-out":
|
||||||
|
assert (
|
||||||
|
system_out is None
|
||||||
|
# for some reason, skipped tests have two system-out;
|
||||||
|
# and the second one contains test teardown
|
||||||
|
or child.text.startswith(system_out.rstrip())
|
||||||
|
), ("Duplicate system-out tag", repr(system_out), repr(child.text))
|
||||||
|
system_out = child.text
|
||||||
|
else:
|
||||||
|
assert False, child
|
||||||
|
|
||||||
|
(module_name, class_name) = case.attrib["classname"].rsplit(".", 1)
|
||||||
|
m = re.match(
|
||||||
|
r"(.*/)?pytest[ -]results[ _](?P<name>.*)"
|
||||||
|
r"[ _][(]?(stable|release|devel|devel_release)[)]?/pytest.xml(.gz)?",
|
||||||
|
str(job_file_name),
|
||||||
|
)
|
||||||
|
assert m, job_file_name
|
||||||
|
yield CaseResult(
|
||||||
|
module_name=module_name,
|
||||||
|
class_name=class_name,
|
||||||
|
test_name=case.attrib["name"],
|
||||||
|
job=m.group("name"),
|
||||||
|
success=success,
|
||||||
|
skipped=skipped,
|
||||||
|
details=details,
|
||||||
|
system_out=system_out,
|
||||||
|
**extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rst_to_element(s: str) -> ET.Element:
|
||||||
|
html = docutils.core.publish_parts(s, writer_name="xhtml")["html_body"]
|
||||||
|
|
||||||
|
# Force the HTML namespace on all elements produced by docutils, which are
|
||||||
|
# unqualified
|
||||||
|
tree_builder = ET.TreeBuilder(
|
||||||
|
element_factory=lambda tag, attrib: ET.Element(
|
||||||
|
"{%s}%s" % (HTML.uri, tag),
|
||||||
|
{"{%s}%s" % (HTML.uri, k): v for (k, v) in attrib.items()},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
parser = ET.XMLParser(target=tree_builder)
|
||||||
|
|
||||||
|
htmltree = ET.fromstring(html, parser=parser)
|
||||||
|
return htmltree
|
||||||
|
|
||||||
|
|
||||||
|
def docstring(obj: object) -> Optional[ET.Element]:
|
||||||
|
if obj.__doc__ is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return rst_to_element(obj.__doc__)
|
||||||
|
|
||||||
|
|
||||||
|
def build_job_html(job: str, results: List[CaseResult]) -> ET.Element:
|
||||||
|
jobs = sorted({result.job for result in results})
|
||||||
|
|
||||||
|
table = build_test_table(jobs, results, "job-results test-matrix")
|
||||||
|
|
||||||
|
return HTML.html(
|
||||||
|
HTML.head(
|
||||||
|
HTML.title(job),
|
||||||
|
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
|
||||||
|
),
|
||||||
|
HTML.body(
|
||||||
|
HTML.h1(job),
|
||||||
|
table,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_module_html(
|
||||||
|
jobs: List[str], results: List[CaseResult], module_name: str
|
||||||
|
) -> ET.Element:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
|
||||||
|
table = build_test_table(jobs, results, "module-results test-matrix")
|
||||||
|
|
||||||
|
return HTML.html(
|
||||||
|
HTML.head(
|
||||||
|
HTML.title(module_name),
|
||||||
|
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
|
||||||
|
),
|
||||||
|
HTML.body(
|
||||||
|
HTML.h1(module_name),
|
||||||
|
docstring(module),
|
||||||
|
table,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_test_table(
|
||||||
|
jobs: List[str], results: List[CaseResult], class_: str
|
||||||
|
) -> ET.Element:
|
||||||
|
multiple_modules = len({r.module_name for r in results}) > 1
|
||||||
|
results_by_module_and_class = group_by(
|
||||||
|
results, lambda r: (r.module_name, r.class_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
job_row = HTML.tr(
|
||||||
|
HTML.th(), # column of case name
|
||||||
|
[HTML.th(HTML.div(HTML.span(job)), class_="job-name") for job in jobs],
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
|
||||||
|
for (module_name, class_name), class_results in sorted(
|
||||||
|
results_by_module_and_class.items()
|
||||||
|
):
|
||||||
|
if multiple_modules:
|
||||||
|
# if the page shows classes from various modules, use the fully-qualified
|
||||||
|
# name in order to disambiguate and be clearer (eg. show
|
||||||
|
# "irctest.server_tests.extended_join.MetadataTestCase" instead of just
|
||||||
|
# "MetadataTestCase" which looks like it's about IRCv3's METADATA spec.
|
||||||
|
qualified_class_name = f"{module_name}.{class_name}"
|
||||||
|
else:
|
||||||
|
# otherwise, it's not needed, so let's not display it
|
||||||
|
qualified_class_name = class_name
|
||||||
|
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
|
||||||
|
# Header row: class name
|
||||||
|
row_anchor = f"{qualified_class_name}"
|
||||||
|
rows.append(
|
||||||
|
HTML.tr(
|
||||||
|
HTML.th(
|
||||||
|
HTML.h2(
|
||||||
|
HTML.a(
|
||||||
|
qualified_class_name,
|
||||||
|
href=f"#{row_anchor}",
|
||||||
|
id=row_anchor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
docstring(getattr(module, class_name)),
|
||||||
|
colspan=str(len(jobs) + 1),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Header row: one column for each implementation
|
||||||
|
rows.append(job_row)
|
||||||
|
|
||||||
|
# One row for each test:
|
||||||
|
results_by_test = group_by(class_results, key=lambda r: r.test_name)
|
||||||
|
for test_name, test_results in sorted(results_by_test.items()):
|
||||||
|
row_anchor = f"{qualified_class_name}.{test_name}"
|
||||||
|
if len(row_anchor) >= 50:
|
||||||
|
# Too long; give up on generating readable URL
|
||||||
|
# TODO: only hash test parameter
|
||||||
|
row_anchor = md5sum(row_anchor)
|
||||||
|
|
||||||
|
row = HTML.tr(
|
||||||
|
HTML.th(HTML.a(test_name, href=f"#{row_anchor}"), class_="test-name"),
|
||||||
|
id=row_anchor,
|
||||||
|
)
|
||||||
|
rows.append(row)
|
||||||
|
|
||||||
|
results_by_job = group_by(test_results, key=lambda r: r.job)
|
||||||
|
for job_name in jobs:
|
||||||
|
try:
|
||||||
|
(result,) = results_by_job[job_name]
|
||||||
|
except KeyError:
|
||||||
|
row.append(HTML.td("d", class_="deselected"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
text: Union[str, None, ET.Element]
|
||||||
|
attrib = {}
|
||||||
|
|
||||||
|
if result.skipped:
|
||||||
|
attrib["class"] = "skipped"
|
||||||
|
if result.type == "pytest.skip":
|
||||||
|
text = "s"
|
||||||
|
elif result.type == "pytest.xfail":
|
||||||
|
text = "X"
|
||||||
|
attrib["class"] = "expected-failure"
|
||||||
|
else:
|
||||||
|
text = result.type
|
||||||
|
elif result.success:
|
||||||
|
attrib["class"] = "success"
|
||||||
|
if result.type:
|
||||||
|
# dead code?
|
||||||
|
text = result.type
|
||||||
|
else:
|
||||||
|
text = "."
|
||||||
|
else:
|
||||||
|
attrib["class"] = "failure"
|
||||||
|
if result.type:
|
||||||
|
# dead code?
|
||||||
|
text = result.type
|
||||||
|
else:
|
||||||
|
text = "f"
|
||||||
|
|
||||||
|
if result.system_out:
|
||||||
|
# There is a log file; link to it.
|
||||||
|
text = HTML.a(text or "?", href=f"./{result.output_filename()}")
|
||||||
|
else:
|
||||||
|
text = text or "?"
|
||||||
|
if result.message:
|
||||||
|
attrib["title"] = result.message
|
||||||
|
|
||||||
|
row.append(HTML.td(text, attrib))
|
||||||
|
|
||||||
|
return HTML.table(*rows, class_=class_)
|
||||||
|
|
||||||
|
|
||||||
|
def write_html_pages(
|
||||||
|
output_dir: Path, results: List[CaseResult]
|
||||||
|
) -> List[Tuple[str, str, str]]:
|
||||||
|
"""Returns the list of (module_name, file_name)."""
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
results_by_module = group_by(results, lambda r: r.module_name)
|
||||||
|
|
||||||
|
# used as columns
|
||||||
|
jobs = list(sorted({r.job for r in results}))
|
||||||
|
|
||||||
|
job_categories = {}
|
||||||
|
for job in jobs:
|
||||||
|
is_client = any(
|
||||||
|
"client_tests" in result.module_name and result.job == job
|
||||||
|
for result in results
|
||||||
|
)
|
||||||
|
is_server = any(
|
||||||
|
"server_tests" in result.module_name and result.job == job
|
||||||
|
for result in results
|
||||||
|
)
|
||||||
|
assert is_client != is_server, (job, is_client, is_server)
|
||||||
|
if job.endswith(("-atheme", "-anope", "-dlk")):
|
||||||
|
assert is_server
|
||||||
|
job_categories[job] = "server-with-services"
|
||||||
|
elif is_server:
|
||||||
|
job_categories[job] = "server" # with or without services
|
||||||
|
else:
|
||||||
|
assert is_client
|
||||||
|
job_categories[job] = "client"
|
||||||
|
|
||||||
|
pages = []
|
||||||
|
|
||||||
|
for module_name, module_results in sorted(results_by_module.items()):
|
||||||
|
# Filter out client jobs if this is a server test module, and vice versa
|
||||||
|
module_categories = {
|
||||||
|
job_categories[result.job]
|
||||||
|
for result in results
|
||||||
|
if result.module_name == module_name and not result.skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
module_jobs = [job for job in jobs if job_categories[job] in module_categories]
|
||||||
|
|
||||||
|
root = build_module_html(module_jobs, module_results, module_name)
|
||||||
|
file_name = f"{module_name}.xhtml"
|
||||||
|
write_xml_file(output_dir / file_name, root)
|
||||||
|
pages.append(("module", module_name, file_name))
|
||||||
|
|
||||||
|
for category in ("server", "client"):
|
||||||
|
for job in [job for job in job_categories if job_categories[job] == category]:
|
||||||
|
job_results = [
|
||||||
|
result
|
||||||
|
for result in results
|
||||||
|
if result.job == job or result.job.startswith(job + "-")
|
||||||
|
]
|
||||||
|
root = build_job_html(job, job_results)
|
||||||
|
file_name = f"{job}.xhtml"
|
||||||
|
write_xml_file(output_dir / file_name, root)
|
||||||
|
pages.append(("job", job, file_name))
|
||||||
|
|
||||||
|
return pages
|
||||||
|
|
||||||
|
|
||||||
|
def write_test_outputs(output_dir: Path, results: List[CaseResult]) -> None:
|
||||||
|
"""Writes stdout files of each test."""
|
||||||
|
for result in results:
|
||||||
|
if result.system_out is None:
|
||||||
|
continue
|
||||||
|
output_file = output_dir / result.output_filename()
|
||||||
|
with output_file.open("wt") as fd:
|
||||||
|
fd.write(result.system_out)
|
||||||
|
|
||||||
|
|
||||||
|
def write_html_index(output_dir: Path, pages: List[Tuple[str, str, str]]) -> None:
|
||||||
|
module_pages = []
|
||||||
|
job_pages = []
|
||||||
|
for page_type, title, file_name in sorted(pages):
|
||||||
|
if page_type == "module":
|
||||||
|
module_pages.append((title, file_name))
|
||||||
|
elif page_type == "job":
|
||||||
|
job_pages.append((title, file_name))
|
||||||
|
else:
|
||||||
|
assert False, page_type
|
||||||
|
|
||||||
|
page = HTML.html(
|
||||||
|
HTML.head(
|
||||||
|
HTML.title("irctest dashboard"),
|
||||||
|
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
|
||||||
|
),
|
||||||
|
HTML.body(
|
||||||
|
HTML.h1("irctest dashboard"),
|
||||||
|
HTML.h2("Tests by command/specification"),
|
||||||
|
HTML.dl(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
HTML.dt(HTML.a(module_name, href=f"./{file_name}")),
|
||||||
|
HTML.dd(docstring(importlib.import_module(module_name))),
|
||||||
|
)
|
||||||
|
for module_name, file_name in sorted(module_pages)
|
||||||
|
],
|
||||||
|
class_="module-index",
|
||||||
|
),
|
||||||
|
HTML.h2("Tests by implementation"),
|
||||||
|
HTML.ul(
|
||||||
|
[
|
||||||
|
HTML.li(HTML.a(job, href=f"./{file_name}"))
|
||||||
|
for job, file_name in sorted(job_pages)
|
||||||
|
],
|
||||||
|
class_="job-index",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
write_xml_file(output_dir / "index.xhtml", page)
|
||||||
|
|
||||||
|
|
||||||
|
def write_assets(output_dir: Path) -> None:
|
||||||
|
css_path = output_dir / "style.css"
|
||||||
|
source_css_path = Path(__file__).parent / "style.css"
|
||||||
|
with css_path.open("wt") as fd:
|
||||||
|
with source_css_path.open() as source_fd:
|
||||||
|
fd.write(source_fd.read())
|
||||||
|
|
||||||
|
|
||||||
|
def write_xml_file(filename: Path, root: ET.Element) -> None:
|
||||||
|
# Serialize
|
||||||
|
if sys.version_info >= (3, 8):
|
||||||
|
s = ET.tostring(root, default_namespace=HTML.uri)
|
||||||
|
else:
|
||||||
|
# default_namespace not supported
|
||||||
|
s = ET.tostring(root)
|
||||||
|
|
||||||
|
with filename.open("wb") as fd:
|
||||||
|
fd.write(s)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_xml_file(filename: Path) -> ET.ElementTree:
|
||||||
|
fd: IO
|
||||||
|
if filename.suffix == ".gz":
|
||||||
|
with gzip.open(filename, "rb") as fd: # type: ignore
|
||||||
|
return parse_xml(fd) # type: ignore
|
||||||
|
else:
|
||||||
|
with open(filename) as fd:
|
||||||
|
return parse_xml(fd) # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
def main(output_path: Path, filenames: List[Path]) -> int:
|
||||||
|
results = [
|
||||||
|
result
|
||||||
|
for filename in filenames
|
||||||
|
for result in iter_job_results(filename, parse_xml_file(filename))
|
||||||
|
]
|
||||||
|
|
||||||
|
pages = write_html_pages(output_path, results)
|
||||||
|
|
||||||
|
write_html_index(output_path, pages)
|
||||||
|
write_test_outputs(output_path, results)
|
||||||
|
write_assets(output_path)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
(_, output_path, *filenames) = sys.argv
|
||||||
|
exit(main(Path(output_path), list(map(Path, filenames))))
|
87
irctest/dashboard/github_download.py
Normal file
87
irctest/dashboard/github_download.py
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import dataclasses
|
||||||
|
import gzip
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
from typing import Iterator
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Artifact:
|
||||||
|
repo: str
|
||||||
|
run_id: int
|
||||||
|
name: str
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def public_download_url(self) -> str:
|
||||||
|
# GitHub API is not available publicly for artifacts, we need to use
|
||||||
|
# a third-party proxy to access it...
|
||||||
|
name = urllib.parse.quote(self.name)
|
||||||
|
return f"https://nightly.link/{repo}/actions/runs/{self.run_id}/{name}.zip"
|
||||||
|
|
||||||
|
|
||||||
|
def iter_run_artifacts(repo: str, run_id: int) -> Iterator[Artifact]:
|
||||||
|
request = urllib.request.Request(
|
||||||
|
f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/artifacts"
|
||||||
|
"?per_page=100",
|
||||||
|
headers={"Accept": "application/vnd.github.v3+json"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = urllib.request.urlopen(request)
|
||||||
|
|
||||||
|
for artifact in json.load(response)["artifacts"]:
|
||||||
|
if not artifact["name"].startswith(("pytest-results_", "pytest results ")):
|
||||||
|
continue
|
||||||
|
if artifact["expired"]:
|
||||||
|
continue
|
||||||
|
yield Artifact(
|
||||||
|
repo=repo,
|
||||||
|
run_id=run_id,
|
||||||
|
name=artifact["name"],
|
||||||
|
download_url=artifact["archive_download_url"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def download_artifact(output_name: Path, url: str) -> None:
|
||||||
|
if output_name.exists():
|
||||||
|
return
|
||||||
|
response = urllib.request.urlopen(url)
|
||||||
|
archive_bytes = response.read() # Can't stream it, it's a ZIP
|
||||||
|
with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive:
|
||||||
|
with archive.open("pytest.xml") as input_fd:
|
||||||
|
pytest_xml = input_fd.read()
|
||||||
|
|
||||||
|
tmp_output_path = output_name.with_suffix(".tmp")
|
||||||
|
with gzip.open(tmp_output_path, "wb") as output_fd:
|
||||||
|
output_fd.write(pytest_xml)
|
||||||
|
|
||||||
|
# Atomically write to the output path, so that we don't write partial files in case
|
||||||
|
# the download process is interrupted
|
||||||
|
tmp_output_path.rename(output_name)
|
||||||
|
|
||||||
|
|
||||||
|
def main(output_dir: Path, repo: str, run_id: int) -> int:
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
run_path = output_dir / str(run_id)
|
||||||
|
run_path.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
for artifact in iter_run_artifacts(repo, run_id):
|
||||||
|
artifact_path = run_path / artifact.name / "pytest.xml.gz"
|
||||||
|
artifact_path.parent.mkdir(exist_ok=True)
|
||||||
|
try:
|
||||||
|
download_artifact(artifact_path, artifact.download_url)
|
||||||
|
except Exception:
|
||||||
|
download_artifact(artifact_path, artifact.public_download_url)
|
||||||
|
print("downloaded", artifact.name)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
(_, output_path, repo, run_id) = sys.argv
|
||||||
|
exit(main(Path(output_path), repo, int(run_id)))
|
126
irctest/dashboard/shortxml.py
Normal file
126
irctest/dashboard/shortxml.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
# Copyright (c) 2023 Valentin Lorentz
|
||||||
|
#
|
||||||
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
# of this software and associated documentation files (the "Software"), to deal
|
||||||
|
# in the Software without restriction, including without limitation the rights
|
||||||
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
# copies of the Software, and to permit persons to whom the Software is
|
||||||
|
# furnished to do so, subject to the following conditions:
|
||||||
|
#
|
||||||
|
# The above copyright notice and this permission notice shall be included in all
|
||||||
|
# copies or substantial portions of the Software.
|
||||||
|
#
|
||||||
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
# SOFTWARE.
|
||||||
|
|
||||||
|
"""This module allows writing XML ASTs in a way that is more concise than the default
|
||||||
|
:mod:`xml.etree.ElementTree` interface.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
from .shortxml import Namespace
|
||||||
|
|
||||||
|
HTML = Namespace("http://www.w3.org/1999/xhtml")
|
||||||
|
|
||||||
|
page = HTML.html(
|
||||||
|
HTML.head(
|
||||||
|
HTML.title("irctest dashboard"),
|
||||||
|
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
|
||||||
|
),
|
||||||
|
HTML.body(
|
||||||
|
HTML.h1("irctest dashboard"),
|
||||||
|
HTML.h2("Tests by command/specification"),
|
||||||
|
HTML.dl(
|
||||||
|
[
|
||||||
|
( # elements can be arbitrarily nested in lists
|
||||||
|
HTML.dt(HTML.a(title, href=f"./{title}.xhtml")),
|
||||||
|
HTML.dd(defintion),
|
||||||
|
)
|
||||||
|
for title, definition in sorted(definitions)
|
||||||
|
],
|
||||||
|
class_="module-index",
|
||||||
|
),
|
||||||
|
HTML.h2("Tests by implementation"),
|
||||||
|
HTML.ul(
|
||||||
|
[
|
||||||
|
HTML.li(HTML.a(job, href=f"./{file_name}"))
|
||||||
|
for job, file_name in sorted(job_pages)
|
||||||
|
],
|
||||||
|
class_="job-index",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
print(ET.tostring(page, default_namespace=HTML.uri))
|
||||||
|
|
||||||
|
|
||||||
|
Attributes can be passed either as dictionaries or as kwargs, and can be mixed
|
||||||
|
with child elements.
|
||||||
|
Trailing underscores are stripped from attributes, which allows passing reserved
|
||||||
|
Python keywords (eg. ``class_`` instead of ``class``)
|
||||||
|
|
||||||
|
Attributes are always qualified, and share the namespace of the element they are
|
||||||
|
attached to.
|
||||||
|
|
||||||
|
Mixed content (elements containing both text and child elements) is not supported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, Sequence, Union
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
def _namespacify(ns: str, s: str) -> str:
|
||||||
|
return "{%s}%s" % (ns, s)
|
||||||
|
|
||||||
|
|
||||||
|
_Children = Union[None, Dict[str, str], ET.Element, Sequence["_Children"]]
|
||||||
|
|
||||||
|
|
||||||
|
class ElementFactory:
|
||||||
|
def __init__(self, namespace: str, tag: str):
|
||||||
|
self._tag = _namespacify(namespace, tag)
|
||||||
|
self._namespace = namespace
|
||||||
|
|
||||||
|
def __call__(self, *args: Union[str, _Children], **kwargs: str) -> ET.Element:
|
||||||
|
e = ET.Element(self._tag)
|
||||||
|
|
||||||
|
attributes = {k.rstrip("_"): v for (k, v) in kwargs.items()}
|
||||||
|
children = [*args, attributes]
|
||||||
|
|
||||||
|
if args and isinstance(children[0], str):
|
||||||
|
e.text = children[0]
|
||||||
|
children.pop(0)
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
self._append_child(e, child)
|
||||||
|
|
||||||
|
return e
|
||||||
|
|
||||||
|
def _append_child(self, e: ET.Element, child: _Children) -> None:
|
||||||
|
if isinstance(child, ET.Element):
|
||||||
|
e.append(child)
|
||||||
|
elif child is None:
|
||||||
|
pass
|
||||||
|
elif isinstance(child, dict):
|
||||||
|
for k, v in child.items():
|
||||||
|
e.set(_namespacify(self._namespace, k), str(v))
|
||||||
|
elif isinstance(child, str):
|
||||||
|
raise ValueError("Mixed content is not supported")
|
||||||
|
else:
|
||||||
|
for grandchild in child:
|
||||||
|
self._append_child(e, grandchild)
|
||||||
|
|
||||||
|
|
||||||
|
class Namespace:
|
||||||
|
def __init__(self, uri: str):
|
||||||
|
self.uri = uri
|
||||||
|
|
||||||
|
def __getattr__(self, tag: str) -> ElementFactory:
|
||||||
|
return ElementFactory(self.uri, tag)
|
67
irctest/dashboard/style.css
Normal file
67
irctest/dashboard/style.css
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #121212;
|
||||||
|
color: rgba(255, 255, 255, 0.87);
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
filter: invert(0.85) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dl.module-index {
|
||||||
|
column-width: 40em; /* Magic constant for 2 columns on average laptop/desktop */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only 1px solid border between cells */
|
||||||
|
table.test-matrix {
|
||||||
|
border-spacing: 0;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
table.test-matrix td {
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid grey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Make link take the whole cell */
|
||||||
|
table.test-matrix td a {
|
||||||
|
display: block;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
color: black;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Test matrix colors */
|
||||||
|
table.test-matrix .deselected {
|
||||||
|
background-color: grey;
|
||||||
|
}
|
||||||
|
table.test-matrix .success {
|
||||||
|
background-color: green;
|
||||||
|
}
|
||||||
|
table.test-matrix .skipped {
|
||||||
|
background-color: yellow;
|
||||||
|
}
|
||||||
|
table.test-matrix .failure {
|
||||||
|
background-color: red;
|
||||||
|
}
|
||||||
|
table.test-matrix .expected-failure {
|
||||||
|
background-color: orange;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rotate headers, thanks to https://css-tricks.com/rotated-table-column-headers/ */
|
||||||
|
table.module-results th.job-name {
|
||||||
|
height: 140px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
table.module-results th.job-name > div {
|
||||||
|
transform:
|
||||||
|
translate(28px, 50px)
|
||||||
|
rotate(315deg);
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
table.module-results th.job-name > div > span {
|
||||||
|
border-bottom: 1px solid grey;
|
||||||
|
padding-left: 0px;
|
||||||
|
}
|
@ -1,6 +1,6 @@
|
|||||||
class NoMessageException(AssertionError):
|
class NoMessageException(AssertionError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ConnectionClosed(Exception):
|
class ConnectionClosed(Exception):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -2,7 +2,10 @@
|
|||||||
Handles ambiguities of RFCs.
|
Handles ambiguities of RFCs.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def normalize_namreply_params(params):
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_namreply_params(params: List[str]) -> List[str]:
|
||||||
# So… RFC 2812 says:
|
# So… RFC 2812 says:
|
||||||
# "( "=" / "*" / "@" ) <channel>
|
# "( "=" / "*" / "@" ) <channel>
|
||||||
# :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )
|
# :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )
|
||||||
@ -11,9 +14,10 @@ def normalize_namreply_params(params):
|
|||||||
# prefix.
|
# prefix.
|
||||||
# So let's normalize this to “with space”, and strip spaces at the
|
# So let's normalize this to “with space”, and strip spaces at the
|
||||||
# end of the nick list.
|
# end of the nick list.
|
||||||
|
params = list(params) # copy the list
|
||||||
if len(params) == 3:
|
if len(params) == 3:
|
||||||
assert params[1][0] in '=*@', params
|
assert params[1][0] in "=*@", params
|
||||||
params.insert(1), params[1][0]
|
params.insert(1, params[1][0])
|
||||||
params[2] = params[2][1:]
|
params[2] = params[2][1:]
|
||||||
params[3] = params[3].rstrip()
|
params[3] = params[3].rstrip()
|
||||||
return params
|
return params
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
def cap_list_to_dict(l):
|
from typing import Dict, List, Optional
|
||||||
d = {}
|
|
||||||
for cap in l:
|
|
||||||
if '=' in cap:
|
def cap_list_to_dict(caps: List[str]) -> Dict[str, Optional[str]]:
|
||||||
(key, value) = cap.split('=', 1)
|
d: Dict[str, Optional[str]] = {}
|
||||||
|
for cap in caps:
|
||||||
|
if "=" in cap:
|
||||||
|
(key, value) = cap.split("=", 1)
|
||||||
|
d[key] = value
|
||||||
else:
|
else:
|
||||||
key = cap
|
d[cap] = None
|
||||||
value = None
|
|
||||||
d[key] = value
|
|
||||||
return d
|
return d
|
||||||
|
19
irctest/irc_utils/filelock.py
Normal file
19
irctest/irc_utils/filelock.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""
|
||||||
|
Compatibility layer for filelock ( https://pypi.org/project/filelock/ );
|
||||||
|
commonly packaged by Linux distributions but might not be available
|
||||||
|
in some environments.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import ContextManager
|
||||||
|
|
||||||
|
if os.getenv("PYTEST_XDIST_WORKER"):
|
||||||
|
# running under pytest-xdist; filelock is required for reliability
|
||||||
|
from filelock import FileLock
|
||||||
|
else:
|
||||||
|
# normal test execution, no port races
|
||||||
|
import contextlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
def FileLock(*args: Any, **kwargs: Any) -> ContextManager[None]:
|
||||||
|
return contextlib.nullcontext()
|
46
irctest/irc_utils/junkdrawer.py
Normal file
46
irctest/irc_utils/junkdrawer.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import datetime
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import socket
|
||||||
|
from typing import Dict, Tuple
|
||||||
|
|
||||||
|
# thanks jess!
|
||||||
|
IRCV3_FORMAT_STRFTIME = "%Y-%m-%dT%H:%M:%S.%f%z"
|
||||||
|
|
||||||
|
|
||||||
|
def ircv3_timestamp_to_unixtime(timestamp: str) -> float:
|
||||||
|
return datetime.datetime.strptime(timestamp, IRCV3_FORMAT_STRFTIME).timestamp()
|
||||||
|
|
||||||
|
|
||||||
|
def random_name(base: str) -> str:
|
||||||
|
return base + "-" + secrets.token_hex(5)
|
||||||
|
|
||||||
|
|
||||||
|
def find_hostname_and_port() -> Tuple[str, int]:
|
||||||
|
"""Find available hostname/port to listen on."""
|
||||||
|
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
s.bind(("", 0))
|
||||||
|
(hostname, port) = s.getsockname()
|
||||||
|
s.close()
|
||||||
|
return (hostname, port)
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Stolen from supybot:
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class MultipleReplacer:
|
||||||
|
"""Return a callable that replaces all dict keys by the associated
|
||||||
|
value. More efficient than multiple .replace()."""
|
||||||
|
|
||||||
|
# We use an object instead of a lambda function because it avoids the
|
||||||
|
# need for using the staticmethod() on the lambda function if assigning
|
||||||
|
# it to a class in Python 3.
|
||||||
|
def __init__(self, dict_: Dict[str, str]):
|
||||||
|
self._dict = dict_
|
||||||
|
dict_ = dict([(re.escape(key), val) for key, val in dict_.items()])
|
||||||
|
self._matcher = re.compile("|".join(dict_.keys()))
|
||||||
|
|
||||||
|
def __call__(self, s: str) -> str:
|
||||||
|
return self._matcher.sub(lambda m: self._dict[m.group(0)], s)
|
@ -1,62 +1,76 @@
|
|||||||
|
import dataclasses
|
||||||
import re
|
import re
|
||||||
import collections
|
from typing import Any, Dict, List, Optional
|
||||||
import supybot.utils
|
|
||||||
|
from .junkdrawer import MultipleReplacer
|
||||||
|
|
||||||
# http://ircv3.net/specs/core/message-tags-3.2.html#escaping-values
|
# http://ircv3.net/specs/core/message-tags-3.2.html#escaping-values
|
||||||
TAG_ESCAPE = [
|
TAG_ESCAPE = [
|
||||||
('\\', '\\\\'), # \ -> \\
|
("\\", "\\\\"), # \ -> \\
|
||||||
(' ', r'\s'),
|
(" ", r"\s"),
|
||||||
(';', r'\:'),
|
(";", r"\:"),
|
||||||
('\r', r'\r'),
|
("\r", r"\r"),
|
||||||
('\n', r'\n'),
|
("\n", r"\n"),
|
||||||
]
|
]
|
||||||
unescape_tag_value = supybot.utils.str.MultipleReplacer(
|
unescape_tag_value = MultipleReplacer(dict(map(lambda x: (x[1], x[0]), TAG_ESCAPE)))
|
||||||
dict(map(lambda x:(x[1],x[0]), TAG_ESCAPE)))
|
|
||||||
|
|
||||||
# TODO: validate host
|
# TODO: validate host
|
||||||
tag_key_validator = re.compile('(\S+/)?[a-zA-Z0-9-]+')
|
tag_key_validator = re.compile(r"\+?(\S+/)?[a-zA-Z0-9-]+")
|
||||||
|
|
||||||
def parse_tags(s):
|
|
||||||
tags = {}
|
def parse_tags(s: str) -> Dict[str, Optional[str]]:
|
||||||
for tag in s.split(';'):
|
tags: Dict[str, Optional[str]] = {}
|
||||||
if '=' not in tag:
|
for tag in s.split(";"):
|
||||||
|
if "=" not in tag:
|
||||||
tags[tag] = None
|
tags[tag] = None
|
||||||
else:
|
else:
|
||||||
(key, value) = tag.split('=', 1)
|
(key, value) = tag.split("=", 1)
|
||||||
assert tag_key_validator.match(key), \
|
assert tag_key_validator.match(key), "Invalid tag key: {}".format(key)
|
||||||
'Invalid tag key: {}'.format(key)
|
|
||||||
tags[key] = unescape_tag_value(value)
|
tags[key] = unescape_tag_value(value)
|
||||||
return tags
|
return tags
|
||||||
|
|
||||||
Message = collections.namedtuple('Message',
|
|
||||||
'tags prefix command params')
|
|
||||||
|
|
||||||
def parse_message(s):
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class HistoryMessage:
|
||||||
|
time: Any
|
||||||
|
msgid: Optional[str]
|
||||||
|
target: str
|
||||||
|
text: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Message:
|
||||||
|
tags: Dict[str, Optional[str]]
|
||||||
|
prefix: Optional[str]
|
||||||
|
command: str
|
||||||
|
params: List[str]
|
||||||
|
|
||||||
|
def to_history_message(self) -> HistoryMessage:
|
||||||
|
return HistoryMessage(
|
||||||
|
time=self.tags.get("time"),
|
||||||
|
msgid=self.tags.get("msgid"),
|
||||||
|
target=self.params[0],
|
||||||
|
text=self.params[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_message(s: str) -> Message:
|
||||||
"""Parse a message according to
|
"""Parse a message according to
|
||||||
http://tools.ietf.org/html/rfc1459#section-2.3.1
|
http://tools.ietf.org/html/rfc1459#section-2.3.1
|
||||||
and
|
and
|
||||||
http://ircv3.net/specs/core/message-tags-3.2.html"""
|
http://ircv3.net/specs/core/message-tags-3.2.html"""
|
||||||
assert s.endswith('\r\n'), 'Message does not end with CR LF: {!r}'.format(s)
|
s = s.rstrip("\r\n")
|
||||||
s = s[0:-2]
|
if s.startswith("@"):
|
||||||
if s.startswith('@'):
|
(tags_str, s) = s.split(" ", 1)
|
||||||
(tags, s) = s.split(' ', 1)
|
tags = parse_tags(tags_str[1:])
|
||||||
tags = parse_tags(tags[1:])
|
|
||||||
else:
|
else:
|
||||||
tags = []
|
tags = {}
|
||||||
if ' :' in s:
|
if " :" in s:
|
||||||
(other_tokens, trailing_param) = s.split(' :', 1)
|
(other_tokens, trailing_param) = s.split(" :", 1)
|
||||||
tokens = list(filter(bool, other_tokens.split(' '))) + [trailing_param]
|
tokens = list(filter(bool, other_tokens.split(" "))) + [trailing_param]
|
||||||
else:
|
else:
|
||||||
tokens = list(filter(bool, s.split(' ')))
|
tokens = list(filter(bool, s.split(" ")))
|
||||||
if tokens[0].startswith(':'):
|
prefix = prefix = tokens.pop(0)[1:] if tokens[0].startswith(":") else None
|
||||||
prefix = tokens.pop(0)[1:]
|
|
||||||
else:
|
|
||||||
prefix = None
|
|
||||||
command = tokens.pop(0)
|
command = tokens.pop(0)
|
||||||
params = tokens
|
params = tokens
|
||||||
return Message(
|
return Message(tags=tags, prefix=prefix, command=command, params=params)
|
||||||
tags=tags,
|
|
||||||
prefix=prefix,
|
|
||||||
command=command,
|
|
||||||
params=params,
|
|
||||||
)
|
|
||||||
|
15
irctest/irc_utils/sasl.py
Normal file
15
irctest/irc_utils/sasl.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import base64
|
||||||
|
|
||||||
|
|
||||||
|
def sasl_plain_blob(username: str, passphrase: str) -> str:
|
||||||
|
blob = base64.b64encode(
|
||||||
|
b"\x00".join(
|
||||||
|
(
|
||||||
|
username.encode("utf-8"),
|
||||||
|
username.encode("utf-8"),
|
||||||
|
passphrase.encode("utf-8"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
blobstr = blob.decode("ascii")
|
||||||
|
return f"AUTHENTICATE {blobstr}"
|
214
irctest/numerics.py
Normal file
214
irctest/numerics.py
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
# Copyright (c) 2012-2014 Jeremy Latt
|
||||||
|
# Copyright (c) 2014-2015 Edmund Huber
|
||||||
|
# Copyright (c) 2016-2017 Daniel Oaks <daniel@danieloaks.net>
|
||||||
|
# released under the MIT license
|
||||||
|
|
||||||
|
# These numerics have been retrieved from:
|
||||||
|
# http://defs.ircdocs.horse/ and http://modern.ircdocs.horse/
|
||||||
|
|
||||||
|
# They're intended to represent a relatively-standard cross-section of the IRC
|
||||||
|
# server ecosystem out there. Custom numerics will be marked as such.
|
||||||
|
|
||||||
|
RPL_WELCOME = "001"
|
||||||
|
RPL_YOURHOST = "002"
|
||||||
|
RPL_CREATED = "003"
|
||||||
|
RPL_MYINFO = "004"
|
||||||
|
RPL_ISUPPORT = "005"
|
||||||
|
RPL_SNOMASKIS = "008"
|
||||||
|
RPL_BOUNCE = "010"
|
||||||
|
RPL_HELLO = "020"
|
||||||
|
RPL_TRACELINK = "200"
|
||||||
|
RPL_TRACECONNECTING = "201"
|
||||||
|
RPL_TRACEHANDSHAKE = "202"
|
||||||
|
RPL_TRACEUNKNOWN = "203"
|
||||||
|
RPL_TRACEOPERATOR = "204"
|
||||||
|
RPL_TRACEUSER = "205"
|
||||||
|
RPL_TRACESERVER = "206"
|
||||||
|
RPL_TRACESERVICE = "207"
|
||||||
|
RPL_TRACENEWTYPE = "208"
|
||||||
|
RPL_TRACECLASS = "209"
|
||||||
|
RPL_TRACERECONNECT = "210"
|
||||||
|
RPL_STATSLINKINFO = "211"
|
||||||
|
RPL_STATSCOMMANDS = "212"
|
||||||
|
RPL_ENDOFSTATS = "219"
|
||||||
|
RPL_UMODEIS = "221"
|
||||||
|
RPL_SERVLIST = "234"
|
||||||
|
RPL_SERVLISTEND = "235"
|
||||||
|
RPL_STATSUPTIME = "242"
|
||||||
|
RPL_STATSOLINE = "243"
|
||||||
|
RPL_LUSERCLIENT = "251"
|
||||||
|
RPL_LUSEROP = "252"
|
||||||
|
RPL_LUSERUNKNOWN = "253"
|
||||||
|
RPL_LUSERCHANNELS = "254"
|
||||||
|
RPL_LUSERME = "255"
|
||||||
|
RPL_ADMINME = "256"
|
||||||
|
RPL_ADMINLOC1 = "257"
|
||||||
|
RPL_ADMINLOC2 = "258"
|
||||||
|
RPL_ADMINEMAIL = "259"
|
||||||
|
RPL_TRACELOG = "261"
|
||||||
|
RPL_TRACEEND = "262"
|
||||||
|
RPL_TRYAGAIN = "263"
|
||||||
|
RPL_LOCALUSERS = "265"
|
||||||
|
RPL_GLOBALUSERS = "266"
|
||||||
|
RPL_WHOISCERTFP = "276"
|
||||||
|
RPL_AWAY = "301"
|
||||||
|
RPL_USERHOST = "302"
|
||||||
|
RPL_ISON = "303"
|
||||||
|
RPL_UNAWAY = "305"
|
||||||
|
RPL_NOWAWAY = "306"
|
||||||
|
RPL_WHOISREGNICK = "307"
|
||||||
|
RPL_WHOISUSER = "311"
|
||||||
|
RPL_WHOISSERVER = "312"
|
||||||
|
RPL_WHOISOPERATOR = "313"
|
||||||
|
RPL_WHOWASUSER = "314"
|
||||||
|
RPL_ENDOFWHO = "315"
|
||||||
|
RPL_WHOISIDLE = "317"
|
||||||
|
RPL_ENDOFWHOIS = "318"
|
||||||
|
RPL_WHOISCHANNELS = "319"
|
||||||
|
RPL_WHOISSPECIAL = "320"
|
||||||
|
RPL_LISTSTART = "321"
|
||||||
|
RPL_LIST = "322"
|
||||||
|
RPL_LISTEND = "323"
|
||||||
|
RPL_CHANNELMODEIS = "324"
|
||||||
|
RPL_UNIQOPIS = "325"
|
||||||
|
RPL_CHANNELCREATED = "329"
|
||||||
|
RPL_WHOISACCOUNT = "330"
|
||||||
|
RPL_NOTOPIC = "331"
|
||||||
|
RPL_TOPIC = "332"
|
||||||
|
RPL_TOPICTIME = "333"
|
||||||
|
RPL_WHOISBOT = "335"
|
||||||
|
RPL_WHOISACTUALLY = "338"
|
||||||
|
RPL_INVITING = "341"
|
||||||
|
RPL_SUMMONING = "342"
|
||||||
|
RPL_INVITELIST = "346"
|
||||||
|
RPL_ENDOFINVITELIST = "347"
|
||||||
|
RPL_EXCEPTLIST = "348"
|
||||||
|
RPL_ENDOFEXCEPTLIST = "349"
|
||||||
|
RPL_VERSION = "351"
|
||||||
|
RPL_WHOREPLY = "352"
|
||||||
|
RPL_NAMREPLY = "353"
|
||||||
|
RPL_WHOSPCRPL = "354"
|
||||||
|
RPL_LINKS = "364"
|
||||||
|
RPL_ENDOFLINKS = "365"
|
||||||
|
RPL_ENDOFNAMES = "366"
|
||||||
|
RPL_BANLIST = "367"
|
||||||
|
RPL_ENDOFBANLIST = "368"
|
||||||
|
RPL_ENDOFWHOWAS = "369"
|
||||||
|
RPL_INFO = "371"
|
||||||
|
RPL_MOTD = "372"
|
||||||
|
RPL_ENDOFINFO = "374"
|
||||||
|
RPL_MOTDSTART = "375"
|
||||||
|
RPL_ENDOFMOTD = "376"
|
||||||
|
RPL_WHOISHOST = "378"
|
||||||
|
RPL_WHOISMODES = "379"
|
||||||
|
RPL_YOUREOPER = "381"
|
||||||
|
RPL_REHASHING = "382"
|
||||||
|
RPL_YOURESERVICE = "383"
|
||||||
|
RPL_TIME = "391"
|
||||||
|
RPL_USERSSTART = "392"
|
||||||
|
RPL_USERS = "393"
|
||||||
|
RPL_ENDOFUSERS = "394"
|
||||||
|
RPL_NOUSERS = "395"
|
||||||
|
ERR_UNKNOWNERROR = "400"
|
||||||
|
ERR_NOSUCHNICK = "401"
|
||||||
|
ERR_NOSUCHSERVER = "402"
|
||||||
|
ERR_NOSUCHCHANNEL = "403"
|
||||||
|
ERR_CANNOTSENDTOCHAN = "404"
|
||||||
|
ERR_TOOMANYCHANNELS = "405"
|
||||||
|
ERR_WASNOSUCHNICK = "406"
|
||||||
|
ERR_TOOMANYTARGETS = "407"
|
||||||
|
ERR_NOSUCHSERVICE = "408"
|
||||||
|
ERR_NOORIGIN = "409"
|
||||||
|
ERR_INVALIDCAPCMD = "410"
|
||||||
|
ERR_NORECIPIENT = "411"
|
||||||
|
ERR_NOTEXTTOSEND = "412"
|
||||||
|
ERR_NOTOPLEVEL = "413"
|
||||||
|
ERR_WILDTOPLEVEL = "414"
|
||||||
|
ERR_BADMASK = "415"
|
||||||
|
ERR_INPUTTOOLONG = "417"
|
||||||
|
ERR_UNKNOWNCOMMAND = "421"
|
||||||
|
ERR_NOMOTD = "422"
|
||||||
|
ERR_NOADMININFO = "423"
|
||||||
|
ERR_FILEERROR = "424"
|
||||||
|
ERR_NONICKNAMEGIVEN = "431"
|
||||||
|
ERR_ERRONEUSNICKNAME = "432"
|
||||||
|
ERR_NICKNAMEINUSE = "433"
|
||||||
|
ERR_NICKCOLLISION = "436"
|
||||||
|
ERR_UNAVAILRESOURCE = "437"
|
||||||
|
ERR_REG_UNAVAILABLE = "440"
|
||||||
|
ERR_USERNOTINCHANNEL = "441"
|
||||||
|
ERR_NOTONCHANNEL = "442"
|
||||||
|
ERR_USERONCHANNEL = "443"
|
||||||
|
ERR_NOLOGIN = "444"
|
||||||
|
ERR_SUMMONDISABLED = "445"
|
||||||
|
ERR_USERSDISABLED = "446"
|
||||||
|
ERR_FORBIDDENCHANNEL = "448"
|
||||||
|
ERR_NOTREGISTERED = "451"
|
||||||
|
ERR_NEEDMOREPARAMS = "461"
|
||||||
|
ERR_ALREADYREGISTRED = "462"
|
||||||
|
ERR_NOPERMFORHOST = "463"
|
||||||
|
ERR_PASSWDMISMATCH = "464"
|
||||||
|
ERR_YOUREBANNEDCREEP = "465"
|
||||||
|
ERR_YOUWILLBEBANNED = "466"
|
||||||
|
ERR_KEYSET = "467"
|
||||||
|
ERR_INVALIDUSERNAME = "468"
|
||||||
|
ERR_LINKCHANNEL = "470"
|
||||||
|
ERR_CHANNELISFULL = "471"
|
||||||
|
ERR_UNKNOWNMODE = "472"
|
||||||
|
ERR_INVITEONLYCHAN = "473"
|
||||||
|
ERR_BANNEDFROMCHAN = "474"
|
||||||
|
ERR_BADCHANNELKEY = "475"
|
||||||
|
ERR_BADCHANMASK = "476"
|
||||||
|
ERR_NOCHANMODES = "477"
|
||||||
|
ERR_NEEDREGGEDNICK = "477"
|
||||||
|
ERR_BANLISTFULL = "478"
|
||||||
|
ERR_NOPRIVILEGES = "481"
|
||||||
|
ERR_CHANOPRIVSNEEDED = "482"
|
||||||
|
ERR_CANTKILLSERVER = "483"
|
||||||
|
ERR_RESTRICTED = "484"
|
||||||
|
ERR_UNIQOPPRIVSNEEDED = "485"
|
||||||
|
ERR_NOOPERHOST = "491"
|
||||||
|
ERR_UMODEUNKNOWNFLAG = "501"
|
||||||
|
ERR_USERSDONTMATCH = "502"
|
||||||
|
ERR_HELPNOTFOUND = "524"
|
||||||
|
ERR_INVALIDKEY = "525"
|
||||||
|
ERR_CANNOTSENDRP = "573"
|
||||||
|
RPL_WHOISSECURE = "671"
|
||||||
|
RPL_YOURLANGUAGESARE = "687"
|
||||||
|
RPL_WHOISLANGUAGE = "690"
|
||||||
|
ERR_INVALIDMODEPARAM = "696"
|
||||||
|
RPL_HELPSTART = "704"
|
||||||
|
RPL_HELPTXT = "705"
|
||||||
|
RPL_ENDOFHELP = "706"
|
||||||
|
ERR_NOPRIVS = "723"
|
||||||
|
RPL_MONONLINE = "730"
|
||||||
|
RPL_MONOFFLINE = "731"
|
||||||
|
RPL_MONLIST = "732"
|
||||||
|
RPL_ENDOFMONLIST = "733"
|
||||||
|
ERR_MONLISTFULL = "734"
|
||||||
|
RPL_LOGGEDIN = "900"
|
||||||
|
RPL_LOGGEDOUT = "901"
|
||||||
|
ERR_NICKLOCKED = "902"
|
||||||
|
RPL_SASLSUCCESS = "903"
|
||||||
|
ERR_SASLFAIL = "904"
|
||||||
|
ERR_SASLTOOLONG = "905"
|
||||||
|
ERR_SASLABORTED = "906"
|
||||||
|
ERR_SASLALREADY = "907"
|
||||||
|
RPL_SASLMECHS = "908"
|
||||||
|
RPL_REGISTRATION_SUCCESS = "920"
|
||||||
|
ERR_ACCOUNT_ALREADY_EXISTS = "921"
|
||||||
|
ERR_REG_UNSPECIFIED_ERROR = "922"
|
||||||
|
RPL_VERIFYSUCCESS = "923"
|
||||||
|
ERR_ACCOUNT_ALREADY_VERIFIED = "924"
|
||||||
|
ERR_ACCOUNT_INVALID_VERIFY_CODE = "925"
|
||||||
|
RPL_REG_VERIFICATION_REQUIRED = "927"
|
||||||
|
ERR_REG_INVALID_CRED_TYPE = "928"
|
||||||
|
ERR_REG_INVALID_CALLBACK = "929"
|
||||||
|
RPL_ENDOFPROPLIST = "960"
|
||||||
|
RPL_PROPLIST = "961"
|
||||||
|
RPL_ENDOFLISTPROPLIST = "962"
|
||||||
|
RPL_LISTPROPLIST = "963"
|
||||||
|
RPL_CHMODELIST = "964"
|
||||||
|
RPL_UMODELIST = "965"
|
||||||
|
ERR_TOOMANYLANGUAGES = "981"
|
||||||
|
ERR_NOLANGUAGE = "982"
|
180
irctest/patma.py
Normal file
180
irctest/patma.py
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
"""Pattern-matching utilities"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import re
|
||||||
|
from typing import Dict, List, Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
class Operator:
|
||||||
|
"""Used as a wildcards and operators when matching message arguments
|
||||||
|
(see assertMessageMatch and match_list)"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _AnyStr(Operator):
|
||||||
|
"""Wildcard matching any string"""
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return "ANYSTR"
|
||||||
|
|
||||||
|
|
||||||
|
class _AnyOptStr(Operator):
|
||||||
|
"""Wildcard matching any string as well as None"""
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return "ANYOPTSTR"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class StrRe(Operator):
|
||||||
|
regexp: str
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"StrRe(r'{self.regexp}')"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class NotStrRe(Operator):
|
||||||
|
regexp: str
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"NotStrRe(r'{self.regexp}')"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class InsensitiveStr(Operator):
|
||||||
|
string: str
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"InsensitiveStr({self.string!r})"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class RemainingKeys(Operator):
|
||||||
|
"""Used in a dict pattern to match all remaining keys.
|
||||||
|
May only be present once."""
|
||||||
|
|
||||||
|
key: Operator
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
return f"RemainingKeys({self.key!r})"
|
||||||
|
|
||||||
|
|
||||||
|
ANYSTR = _AnyStr()
|
||||||
|
"""Singleton, spares two characters"""
|
||||||
|
|
||||||
|
ANYOPTSTR = _AnyOptStr()
|
||||||
|
"""Singleton, spares two characters"""
|
||||||
|
|
||||||
|
ANYDICT = {RemainingKeys(ANYSTR): ANYOPTSTR}
|
||||||
|
"""Matches any dictionary; useful to compare tags dict, eg.
|
||||||
|
`match_dict(got_tags, {"label": "foo", **ANYDICT})`"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class ListRemainder:
|
||||||
|
item: Operator
|
||||||
|
min_length: int = 0
|
||||||
|
|
||||||
|
def __repr__(self) -> str:
|
||||||
|
if self.min_length:
|
||||||
|
return f"ListRemainder({self.item!r}, min_length={self.min_length})"
|
||||||
|
elif self.item is ANYSTR:
|
||||||
|
return "*ANYLIST"
|
||||||
|
else:
|
||||||
|
return f"ListRemainder({self.item!r})"
|
||||||
|
|
||||||
|
|
||||||
|
ANYLIST = [ListRemainder(ANYSTR)]
|
||||||
|
"""Matches any list remainder"""
|
||||||
|
|
||||||
|
|
||||||
|
def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool:
|
||||||
|
if isinstance(expected, _AnyOptStr):
|
||||||
|
return True
|
||||||
|
elif isinstance(expected, _AnyStr) and got is not None:
|
||||||
|
return True
|
||||||
|
elif isinstance(expected, StrRe):
|
||||||
|
if got is None or not re.match(expected.regexp, got):
|
||||||
|
return False
|
||||||
|
elif isinstance(expected, NotStrRe):
|
||||||
|
if got is None or re.match(expected.regexp, got):
|
||||||
|
return False
|
||||||
|
elif isinstance(expected, InsensitiveStr):
|
||||||
|
if got is None or got.lower() != expected.string.lower():
|
||||||
|
return False
|
||||||
|
elif isinstance(expected, Operator):
|
||||||
|
raise NotImplementedError(f"Unsupported operator: {expected}")
|
||||||
|
elif got != expected:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def match_list(
|
||||||
|
got: List[Optional[str]], expected: List[Union[str, None, Operator]]
|
||||||
|
) -> bool:
|
||||||
|
"""Returns True iff the list are equal.
|
||||||
|
|
||||||
|
The ANYSTR operator can be used on the 'expected' side as a wildcard,
|
||||||
|
matching any *single* value; and StrRe("<regexp>") can be used to match regular
|
||||||
|
expressions"""
|
||||||
|
if expected and isinstance(expected[-1], ListRemainder):
|
||||||
|
# Expand the 'expected' list to have as many items as the 'got' list
|
||||||
|
expected = list(expected) # copy
|
||||||
|
remainder = expected.pop()
|
||||||
|
nb_remaining_items = len(got) - len(expected)
|
||||||
|
expected += [remainder.item] * max(nb_remaining_items, remainder.min_length)
|
||||||
|
|
||||||
|
if len(got) != len(expected):
|
||||||
|
return False
|
||||||
|
return all(
|
||||||
|
match_string(got_value, expected_value)
|
||||||
|
for (got_value, expected_value) in zip(got, expected)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def match_dict(
|
||||||
|
got: Dict[str, Optional[str]],
|
||||||
|
expected: Dict[Union[str, Operator], Union[str, Operator, None]],
|
||||||
|
) -> bool:
|
||||||
|
"""Returns True iff the list are equal.
|
||||||
|
|
||||||
|
The ANYSTR operator can be used on the 'expected' side as a wildcard,
|
||||||
|
matching any *single* value; and StrRe("<regexp>") can be used to match regular
|
||||||
|
expressions
|
||||||
|
Additionally, the Keys() operator can be used to match remaining keys, and
|
||||||
|
ANYDICT to match any remaining dict"""
|
||||||
|
got = dict(got) # shallow copy, as we will remove keys
|
||||||
|
|
||||||
|
# Set to not-None if we find a Keys() operator in the dict keys
|
||||||
|
remaining_keys_wildcard = None
|
||||||
|
|
||||||
|
for expected_key, expected_value in expected.items():
|
||||||
|
if isinstance(expected_key, RemainingKeys):
|
||||||
|
remaining_keys_wildcard = (expected_key.key, expected_value)
|
||||||
|
else:
|
||||||
|
for key in got:
|
||||||
|
if match_string(key, expected_key) and match_string(
|
||||||
|
got[key], expected_value
|
||||||
|
):
|
||||||
|
got.pop(key)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Found no (key, value) pair matching the request
|
||||||
|
return False
|
||||||
|
|
||||||
|
if remaining_keys_wildcard:
|
||||||
|
(expected_key, expected_value) = remaining_keys_wildcard
|
||||||
|
for key, value in got.items():
|
||||||
|
if not match_string(key, expected_key):
|
||||||
|
return False
|
||||||
|
if not match_string(value, expected_value):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
# There should be nothing left unmatched in the dict
|
||||||
|
return got == {}
|
@ -1,56 +1,59 @@
|
|||||||
import unittest
|
import unittest
|
||||||
import operator
|
|
||||||
import collections
|
|
||||||
|
|
||||||
class NotImplementedByController(unittest.SkipTest, NotImplementedError):
|
class NotImplementedByController(unittest.SkipTest, NotImplementedError):
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return 'Not implemented by controller: {}'.format(self.args[0])
|
return "Not implemented by controller: {}".format(self.args[0])
|
||||||
|
|
||||||
|
|
||||||
class ImplementationChoice(unittest.SkipTest):
|
class ImplementationChoice(unittest.SkipTest):
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return 'Choice in the implementation makes it impossible to ' \
|
return (
|
||||||
'perform a test: {}'.format(self.args[0])
|
"Choice in the implementation makes it impossible to "
|
||||||
|
"perform a test: {}".format(self.args[0])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OptionalCommandNotSupported(unittest.SkipTest):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Unsupported command: {}".format(self.args[0])
|
||||||
|
|
||||||
|
|
||||||
class OptionalExtensionNotSupported(unittest.SkipTest):
|
class OptionalExtensionNotSupported(unittest.SkipTest):
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return 'Unsupported extension: {}'.format(self.args[0])
|
return "Unsupported extension: {}".format(self.args[0])
|
||||||
|
|
||||||
|
|
||||||
class OptionalSaslMechanismNotSupported(unittest.SkipTest):
|
class OptionalSaslMechanismNotSupported(unittest.SkipTest):
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return 'Unsupported SASL mechanism: {}'.format(self.args[0])
|
return "Unsupported SASL mechanism: {}".format(self.args[0])
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityNotSupported(unittest.SkipTest):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Unsupported capability: {}".format(self.args[0])
|
||||||
|
|
||||||
|
|
||||||
|
class IsupportTokenNotSupported(unittest.SkipTest):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Unsupported ISUPPORT token: {}".format(self.args[0])
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelModeNotSupported(unittest.SkipTest):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Unsupported channel mode: {} ({})".format(self.args[0], self.args[1])
|
||||||
|
|
||||||
|
|
||||||
|
class ExtbanNotSupported(unittest.SkipTest):
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return "Unsupported extban: {} ({})".format(self.args[0], self.args[1])
|
||||||
|
|
||||||
|
|
||||||
class NotRequiredBySpecifications(unittest.SkipTest):
|
class NotRequiredBySpecifications(unittest.SkipTest):
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return 'Tests not required by the set of tested specification(s).'
|
return "Tests not required by the set of tested specification(s)."
|
||||||
|
|
||||||
|
|
||||||
class SkipStrictTest(unittest.SkipTest):
|
class SkipStrictTest(unittest.SkipTest):
|
||||||
def __str__(self):
|
def __str__(self) -> str:
|
||||||
return 'Tests not required because strict tests are disabled.'
|
return "Tests not required because strict tests are disabled."
|
||||||
|
|
||||||
class TextTestResult(unittest.TextTestResult):
|
|
||||||
def getDescription(self, test):
|
|
||||||
if hasattr(test, 'description'):
|
|
||||||
doc_first_lines = test.description()
|
|
||||||
else:
|
|
||||||
doc_first_lines = test.shortDescription()
|
|
||||||
return '\n'.join((str(test), doc_first_lines or ''))
|
|
||||||
|
|
||||||
class TextTestRunner(unittest.TextTestRunner):
|
|
||||||
"""Small wrapper around unittest.TextTestRunner that reports the
|
|
||||||
number of tests that were skipped because the software does not support
|
|
||||||
an optional feature."""
|
|
||||||
resultclass = TextTestResult
|
|
||||||
|
|
||||||
def run(self, test):
|
|
||||||
result = super().run(test)
|
|
||||||
assert self.resultclass is TextTestResult
|
|
||||||
if result.skipped:
|
|
||||||
print()
|
|
||||||
print('Some tests were skipped because the following optional '
|
|
||||||
'specifications/mechanisms are not supported:')
|
|
||||||
msg_to_count = collections.defaultdict(lambda: 0)
|
|
||||||
for (test, msg) in result.skipped:
|
|
||||||
msg_to_count[msg] += 1
|
|
||||||
for (msg, count) in sorted(msg_to_count.items()):
|
|
||||||
print('\t{} ({} test(s))'.format(msg, count))
|
|
||||||
return result
|
|
||||||
|
2
irctest/scram/__init__.py
Normal file
2
irctest/scram/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from .scram import *
|
||||||
|
from .exceptions import *
|
9
irctest/scram/core.py
Normal file
9
irctest/scram/core.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
def default_nonce_factory():
|
||||||
|
"""Generate a random string for digest authentication challenges.
|
||||||
|
The string should be cryptographicaly secure random pattern.
|
||||||
|
:return: the string generated.
|
||||||
|
:returntype: `bytes`
|
||||||
|
"""
|
||||||
|
return uuid.uuid4().hex.encode("us-ascii")
|
17
irctest/scram/exceptions.py
Normal file
17
irctest/scram/exceptions.py
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
class ScramException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BadChallengeException(ScramException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ExtraChallengeException(ScramException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ServerScramError(ScramException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BadSuccessException(ScramException):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class NotAuthorizedException(ScramException):
|
||||||
|
pass
|
561
irctest/scram/scram.py
Normal file
561
irctest/scram/scram.py
Normal file
@ -0,0 +1,561 @@
|
|||||||
|
#
|
||||||
|
# (C) Copyright 2011 Jacek Konieczny <jajcus@jajcus.net>
|
||||||
|
#
|
||||||
|
# This program is free software; you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Lesser General Public License Version
|
||||||
|
# 2.1 as published by the Free Software Foundation.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Lesser General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Lesser General Public
|
||||||
|
# License along with this program; if not, write to the Free Software
|
||||||
|
# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
|
||||||
|
#
|
||||||
|
"""SCRAM authentication mechanisms for PyXMPP SASL implementation.
|
||||||
|
|
||||||
|
Normative reference:
|
||||||
|
- :RFC:`5802`
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import absolute_import, division, unicode_literals
|
||||||
|
|
||||||
|
__docformat__ = "restructuredtext en"
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
from binascii import a2b_base64
|
||||||
|
from base64 import standard_b64encode
|
||||||
|
|
||||||
|
from .core import default_nonce_factory
|
||||||
|
from .exceptions import BadChallengeException, \
|
||||||
|
ExtraChallengeException, ServerScramError, BadSuccessException, \
|
||||||
|
NotAuthorizedException
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger("pyxmpp2_scram")
|
||||||
|
|
||||||
|
HASH_FACTORIES = {
|
||||||
|
"SHA-1": hashlib.sha1, # pylint: disable=E1101
|
||||||
|
"SHA-224": hashlib.sha224, # pylint: disable=E1101
|
||||||
|
"SHA-256": hashlib.sha256, # pylint: disable=E1101
|
||||||
|
"SHA-384": hashlib.sha384, # pylint: disable=E1101
|
||||||
|
"SHA-512": hashlib.sha512, # pylint: disable=E1101
|
||||||
|
"MD-5": hashlib.md5, # pylint: disable=E1101
|
||||||
|
}
|
||||||
|
|
||||||
|
VALUE_CHARS_RE = re.compile(br"^[\x21-\x2B\x2D-\x7E]+$")
|
||||||
|
_QUOTED_VALUE_RE = br"(?:[\x21-\x2B\x2D-\x7E]|=2C|=3D)+"
|
||||||
|
|
||||||
|
CLIENT_FIRST_MESSAGE_RE = re.compile(
|
||||||
|
br"^(?P<gs2_header>(?:y|n|p=(?P<cb_name>[a-zA-z0-9.-]+)),"
|
||||||
|
br"(?:a=(?P<authzid>" + _QUOTED_VALUE_RE + br"))?,)"
|
||||||
|
br"(?P<client_first_bare>(?P<mext>m=[^\000=]+,)?"
|
||||||
|
br"n=(?P<username>" + _QUOTED_VALUE_RE + br"),"
|
||||||
|
br"r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+)"
|
||||||
|
br"(?:,.*)?)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVER_FIRST_MESSAGE_RE = re.compile(
|
||||||
|
br"^(?P<mext>m=[^\000=]+,)?"
|
||||||
|
br"r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+),"
|
||||||
|
br"s=(?P<salt>[a-zA-Z0-9/+=]+),"
|
||||||
|
br"i=(?P<iteration_count>\d+)"
|
||||||
|
br"(?:,.*)?$"
|
||||||
|
)
|
||||||
|
|
||||||
|
CLIENT_FINAL_MESSAGE_RE = re.compile(
|
||||||
|
br"(?P<without_proof>c=(?P<cb>[a-zA-Z0-9/+=]+),"
|
||||||
|
br"(?:r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+))"
|
||||||
|
br"(?:,.*)?)"
|
||||||
|
br",p=(?P<proof>[a-zA-Z0-9/+=]+)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
SERVER_FINAL_MESSAGE_RE = re.compile(
|
||||||
|
br"^(?:e=(?P<error>[^,]+)|v=(?P<verifier>[a-zA-Z0-9/+=]+)(?:,.*)?)$")
|
||||||
|
|
||||||
|
class SCRAMOperations(object):
|
||||||
|
"""Functions used during SCRAM authentication and defined in the RFC.
|
||||||
|
|
||||||
|
"""
|
||||||
|
def __init__(self, hash_function_name):
|
||||||
|
self.hash_function_name = hash_function_name
|
||||||
|
self.hash_factory = HASH_FACTORIES[hash_function_name]
|
||||||
|
self.digest_size = self.hash_factory().digest_size
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def Normalize(str_):
|
||||||
|
"""The Normalize(str) function.
|
||||||
|
|
||||||
|
This one also accepts Unicode string input (in the RFC only UTF-8
|
||||||
|
strings are used).
|
||||||
|
"""
|
||||||
|
# pylint: disable=C0103
|
||||||
|
if isinstance(str_, bytes):
|
||||||
|
str_ = str_.decode("utf-8")
|
||||||
|
return str_.encode("utf-8")
|
||||||
|
|
||||||
|
def HMAC(self, key, str_):
|
||||||
|
"""The HMAC(key, str) function."""
|
||||||
|
# pylint: disable=C0103
|
||||||
|
return hmac.new(key, str_, self.hash_factory).digest()
|
||||||
|
|
||||||
|
def H(self, str_):
|
||||||
|
"""The H(str) function."""
|
||||||
|
# pylint: disable=C0103
|
||||||
|
return self.hash_factory(str_).digest()
|
||||||
|
|
||||||
|
if sys.version_info.major >= 3:
|
||||||
|
@staticmethod
|
||||||
|
# pylint: disable=C0103
|
||||||
|
def XOR(str1, str2):
|
||||||
|
"""The XOR operator for two byte strings."""
|
||||||
|
return bytes(a ^ b for a, b in zip(str1, str2))
|
||||||
|
else:
|
||||||
|
@staticmethod
|
||||||
|
# pylint: disable=C0103
|
||||||
|
def XOR(str1, str2):
|
||||||
|
"""The XOR operator for two byte strings."""
|
||||||
|
return "".join(chr(ord(a) ^ ord(b)) for a, b in zip(str1, str2))
|
||||||
|
|
||||||
|
def Hi(self, str_, salt, i):
|
||||||
|
"""The Hi(str, salt, i) function."""
|
||||||
|
# pylint: disable=C0103
|
||||||
|
Uj = self.HMAC(str_, salt + b"\000\000\000\001") # U1
|
||||||
|
result = Uj
|
||||||
|
for _ in range(2, i + 1):
|
||||||
|
Uj = self.HMAC(str_, Uj) # Uj = HMAC(str, Uj-1)
|
||||||
|
result = self.XOR(result, Uj) # ... XOR Uj-1 XOR Uj
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def escape(data):
|
||||||
|
"""Escape the ',' and '=' characters for 'a=' and 'n=' attributes.
|
||||||
|
|
||||||
|
Replaces '=' with '=3D' and ',' with '=2C'.
|
||||||
|
|
||||||
|
:Parameters:
|
||||||
|
- `data`: string to escape
|
||||||
|
:Types:
|
||||||
|
- `data`: `bytes`
|
||||||
|
"""
|
||||||
|
return data.replace(b'=', b'=3D').replace(b',', b'=2C')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def unescape(data):
|
||||||
|
"""Unescape the ',' and '=' characters for 'a=' and 'n=' attributes.
|
||||||
|
|
||||||
|
Reverse of `escape`.
|
||||||
|
|
||||||
|
:Parameters:
|
||||||
|
- `data`: string to unescape
|
||||||
|
:Types:
|
||||||
|
- `data`: `bytes`
|
||||||
|
"""
|
||||||
|
return data.replace(b'=2C', b',').replace(b'=3D', b'=')
|
||||||
|
|
||||||
|
class SCRAMClientAuthenticator(SCRAMOperations):
|
||||||
|
"""Provides SCRAM SASL authentication for a client.
|
||||||
|
|
||||||
|
:Ivariables:
|
||||||
|
- `password`: current authentication password
|
||||||
|
- `pformat`: current authentication password format
|
||||||
|
- `realm`: current authentication realm
|
||||||
|
"""
|
||||||
|
# pylint: disable-msg=R0902
|
||||||
|
def __init__(self, hash_name, channel_binding):
|
||||||
|
"""Initialize a `SCRAMClientAuthenticator` object.
|
||||||
|
|
||||||
|
:Parameters:
|
||||||
|
- `hash_function_name`: hash function name, e.g. ``"SHA-1"``
|
||||||
|
- `channel_binding`: `True` to enable channel binding
|
||||||
|
:Types:
|
||||||
|
- `hash_function_name`: `unicode`
|
||||||
|
- `channel_binding`: `bool`
|
||||||
|
"""
|
||||||
|
SCRAMOperations.__init__(self, hash_name)
|
||||||
|
self.name = "SCRAM-{0}".format(hash_name)
|
||||||
|
if channel_binding:
|
||||||
|
self.name += "-PLUS"
|
||||||
|
self.channel_binding = channel_binding
|
||||||
|
self.username = None
|
||||||
|
self.password = None
|
||||||
|
self.authzid = None
|
||||||
|
self._c_nonce = None
|
||||||
|
self._server_first_message = False
|
||||||
|
self._client_first_message_bare = False
|
||||||
|
self._gs2_header = None
|
||||||
|
self._finished = False
|
||||||
|
self._auth_message = None
|
||||||
|
self._salted_password = None
|
||||||
|
self._cb_data = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def are_properties_sufficient(cls, properties):
|
||||||
|
return "username" in properties and "password" in properties
|
||||||
|
|
||||||
|
def start(self, properties):
|
||||||
|
self.username = properties["username"]
|
||||||
|
self.password = properties["password"]
|
||||||
|
self.authzid = properties.get("authzid", "")
|
||||||
|
c_nonce = properties.get("nonce_factory", default_nonce_factory)()
|
||||||
|
if not VALUE_CHARS_RE.match(c_nonce):
|
||||||
|
c_nonce = standard_b64encode(c_nonce)
|
||||||
|
self._c_nonce = c_nonce
|
||||||
|
|
||||||
|
if self.channel_binding:
|
||||||
|
cb_data = properties.get("channel-binding")
|
||||||
|
if not cb_data:
|
||||||
|
raise ValueError("No channel binding data provided")
|
||||||
|
if "tls-unique" in cb_data:
|
||||||
|
cb_type = "tls-unique"
|
||||||
|
elif "tls-server-end-point" in cb_data:
|
||||||
|
cb_type = "tls-server-end-point"
|
||||||
|
elif cb_data:
|
||||||
|
cb_type = cb_data.keys()[0]
|
||||||
|
self._cb_data = cb_data[cb_type]
|
||||||
|
cb_flag = b"p=" + cb_type.encode("utf-8")
|
||||||
|
else:
|
||||||
|
plus_name = self.name + "-PLUS"
|
||||||
|
if plus_name in properties.get("enabled_mechanisms", []):
|
||||||
|
# -PLUS is enabled (supported) on our side,
|
||||||
|
# but was not selected - that means it was not included
|
||||||
|
# in the server features
|
||||||
|
cb_flag = b"y"
|
||||||
|
else:
|
||||||
|
cb_flag = b"n"
|
||||||
|
|
||||||
|
if self.authzid:
|
||||||
|
authzid = b"a=" + self.escape(self.authzid.encode("utf-8"))
|
||||||
|
else:
|
||||||
|
authzid = b""
|
||||||
|
gs2_header = cb_flag + b"," + authzid + b","
|
||||||
|
self._gs2_header = gs2_header
|
||||||
|
nonce = b"r=" + c_nonce
|
||||||
|
client_first_message_bare = (b"n=" +
|
||||||
|
self.escape(self.username.encode("utf-8")) + b"," + nonce)
|
||||||
|
self._client_first_message_bare = client_first_message_bare
|
||||||
|
client_first_message = gs2_header + client_first_message_bare
|
||||||
|
return client_first_message
|
||||||
|
|
||||||
|
def challenge(self, challenge):
|
||||||
|
"""Process a challenge and return the response.
|
||||||
|
|
||||||
|
:Parameters:
|
||||||
|
- `challenge`: the challenge from server.
|
||||||
|
:Types:
|
||||||
|
- `challenge`: `bytes`
|
||||||
|
|
||||||
|
:return: the response
|
||||||
|
:returntype: bytes
|
||||||
|
:raises: `BadChallengeException`
|
||||||
|
"""
|
||||||
|
# pylint: disable=R0911
|
||||||
|
if not challenge:
|
||||||
|
raise BadChallengeException('Empty challenge')
|
||||||
|
|
||||||
|
if self._server_first_message:
|
||||||
|
return self._final_challenge(challenge)
|
||||||
|
|
||||||
|
match = SERVER_FIRST_MESSAGE_RE.match(challenge)
|
||||||
|
if not match:
|
||||||
|
raise BadChallengeException("Bad challenge syntax: {0!r}".format(challenge))
|
||||||
|
|
||||||
|
self._server_first_message = challenge
|
||||||
|
|
||||||
|
mext = match.group("mext")
|
||||||
|
if mext:
|
||||||
|
raise BadChallengeException("Unsupported extension received: {0!r}".format(mext))
|
||||||
|
|
||||||
|
nonce = match.group("nonce")
|
||||||
|
if not nonce.startswith(self._c_nonce):
|
||||||
|
raise BadChallengeException("Nonce does not start with our nonce")
|
||||||
|
|
||||||
|
salt = match.group("salt")
|
||||||
|
try:
|
||||||
|
salt = a2b_base64(salt)
|
||||||
|
except ValueError:
|
||||||
|
raise BadChallengeException("Bad base64 encoding for salt: {0!r}".format(salt))
|
||||||
|
|
||||||
|
iteration_count = match.group("iteration_count")
|
||||||
|
try:
|
||||||
|
iteration_count = int(iteration_count)
|
||||||
|
except ValueError:
|
||||||
|
raise BadChallengeException("Bad iteration_count: {0!r}".format(iteration_count))
|
||||||
|
|
||||||
|
return self._make_response(nonce, salt, iteration_count)
|
||||||
|
|
||||||
|
def _make_response(self, nonce, salt, iteration_count):
|
||||||
|
"""Make a response for the first challenge from the server.
|
||||||
|
|
||||||
|
:return: the response
|
||||||
|
:returntype: bytes
|
||||||
|
"""
|
||||||
|
self._salted_password = self.Hi(self.Normalize(self.password), salt,
|
||||||
|
iteration_count)
|
||||||
|
self.password = None # not needed any more
|
||||||
|
if self.channel_binding:
|
||||||
|
channel_binding = b"c=" + standard_b64encode(self._gs2_header +
|
||||||
|
self._cb_data)
|
||||||
|
else:
|
||||||
|
channel_binding = b"c=" + standard_b64encode(self._gs2_header)
|
||||||
|
|
||||||
|
# pylint: disable=C0103
|
||||||
|
client_final_message_without_proof = (channel_binding + b",r=" + nonce)
|
||||||
|
|
||||||
|
client_key = self.HMAC(self._salted_password, b"Client Key")
|
||||||
|
stored_key = self.H(client_key)
|
||||||
|
auth_message = ( self._client_first_message_bare + b"," +
|
||||||
|
self._server_first_message + b"," +
|
||||||
|
client_final_message_without_proof )
|
||||||
|
self._auth_message = auth_message
|
||||||
|
client_signature = self.HMAC(stored_key, auth_message)
|
||||||
|
client_proof = self.XOR(client_key, client_signature)
|
||||||
|
proof = b"p=" + standard_b64encode(client_proof)
|
||||||
|
client_final_message = (client_final_message_without_proof + b"," +
|
||||||
|
proof)
|
||||||
|
return client_final_message
|
||||||
|
|
||||||
|
def _final_challenge(self, challenge):
|
||||||
|
"""Process the second challenge from the server and return the
|
||||||
|
response.
|
||||||
|
|
||||||
|
:Parameters:
|
||||||
|
- `challenge`: the challenge from server.
|
||||||
|
:Types:
|
||||||
|
- `challenge`: `bytes`
|
||||||
|
|
||||||
|
:raises: `ExtraChallengeException`, `BadChallengeException`, `ServerScramError`, or `BadSuccessException`
|
||||||
|
"""
|
||||||
|
if self._finished:
|
||||||
|
return ExtraChallengeException()
|
||||||
|
|
||||||
|
match = SERVER_FINAL_MESSAGE_RE.match(challenge)
|
||||||
|
if not match:
|
||||||
|
raise BadChallengeException("Bad final message syntax: {0!r}".format(challenge))
|
||||||
|
|
||||||
|
error = match.group("error")
|
||||||
|
if error:
|
||||||
|
raise ServerScramError("{0!r}".format(error))
|
||||||
|
|
||||||
|
verifier = match.group("verifier")
|
||||||
|
if not verifier:
|
||||||
|
raise BadSuccessException("No verifier value in the final message")
|
||||||
|
|
||||||
|
server_key = self.HMAC(self._salted_password, b"Server Key")
|
||||||
|
server_signature = self.HMAC(server_key, self._auth_message)
|
||||||
|
if server_signature != a2b_base64(verifier):
|
||||||
|
raise BadSuccessException("Server verifier does not match")
|
||||||
|
|
||||||
|
self._finished = True
|
||||||
|
|
||||||
|
def finish(self, data):
|
||||||
|
"""Process success indicator from the server.
|
||||||
|
|
||||||
|
Process any addiitional data passed with the success.
|
||||||
|
Fail if the server was not authenticated.
|
||||||
|
|
||||||
|
:Parameters:
|
||||||
|
- `data`: an optional additional data with success.
|
||||||
|
:Types:
|
||||||
|
- `data`: `bytes`
|
||||||
|
|
||||||
|
:return: username and authzid
|
||||||
|
:returntype: `dict`
|
||||||
|
:raises: `BadSuccessException`"""
|
||||||
|
if not self._server_first_message:
|
||||||
|
raise BadSuccessException("Got success too early")
|
||||||
|
if self._finished:
|
||||||
|
return {"username": self.username, "authzid": self.authzid}
|
||||||
|
else:
|
||||||
|
self._final_challenge(data)
|
||||||
|
if self._finished:
|
||||||
|
return {"username": self.username,
|
||||||
|
"authzid": self.authzid}
|
||||||
|
else:
|
||||||
|
raise BadSuccessException("Something went wrong when processing additional"
|
||||||
|
" data with success?")
|
||||||
|
|
||||||
|
class SCRAMServerAuthenticator(SCRAMOperations):
|
||||||
|
"""Provides SCRAM SASL authentication for a server.
|
||||||
|
"""
|
||||||
|
def __init__(self, hash_name, channel_binding, password_database):
|
||||||
|
"""Initialize a `SCRAMClientAuthenticator` object.
|
||||||
|
|
||||||
|
:Parameters:
|
||||||
|
- `hash_function_name`: hash function name, e.g. ``"SHA-1"``
|
||||||
|
- `channel_binding`: `True` to enable channel binding
|
||||||
|
:Types:
|
||||||
|
- `hash_function_name`: `unicode`
|
||||||
|
- `channel_binding`: `bool`
|
||||||
|
"""
|
||||||
|
SCRAMOperations.__init__(self, hash_name)
|
||||||
|
self.name = "SCRAM-{0}".format(hash_name)
|
||||||
|
if channel_binding:
|
||||||
|
self.name += "-PLUS"
|
||||||
|
self.channel_binding = channel_binding
|
||||||
|
self.properties = None
|
||||||
|
self.out_properties = None
|
||||||
|
self.password_database = password_database
|
||||||
|
self._client_first_message_bare = None
|
||||||
|
self._stored_key = None
|
||||||
|
self._server_key = None
|
||||||
|
|
||||||
|
def start(self, properties, initial_response):
|
||||||
|
self.properties = properties
|
||||||
|
self._client_first_message_bare = None
|
||||||
|
self.out_properties = {}
|
||||||
|
if not initial_response:
|
||||||
|
return b""
|
||||||
|
return self.response(initial_response)
|
||||||
|
|
||||||
|
def response(self, response):
|
||||||
|
if self._client_first_message_bare:
|
||||||
|
logger.debug("Client final message: {0!r}".format(response))
|
||||||
|
return self._handle_final_response(response)
|
||||||
|
else:
|
||||||
|
logger.debug("Client first message: {0!r}".format(response))
|
||||||
|
return self._handle_first_response(response)
|
||||||
|
|
||||||
|
def _handle_first_response(self, response):
|
||||||
|
match = CLIENT_FIRST_MESSAGE_RE.match(response)
|
||||||
|
if not match:
|
||||||
|
raise NotAuthorizedException("Bad response syntax: {0!r}".format(response))
|
||||||
|
|
||||||
|
mext = match.group("mext")
|
||||||
|
if mext:
|
||||||
|
raise NotAuthorizedException("Unsupported extension received: {0!r}".format(mext))
|
||||||
|
|
||||||
|
gs2_header = match.group("gs2_header")
|
||||||
|
cb_name = match.group("cb_name")
|
||||||
|
if self.channel_binding:
|
||||||
|
if not cb_name:
|
||||||
|
raise NotAuthorizedException("{0!r} used with no channel-binding"
|
||||||
|
.format(self.name))
|
||||||
|
cb_name = cb_name.decode("utf-8")
|
||||||
|
if cb_name not in self.properties["channel-binding"]:
|
||||||
|
raise NotAuthorizedException("Channel binding data type {0!r} not available"
|
||||||
|
.format(cb_name))
|
||||||
|
else:
|
||||||
|
if gs2_header.startswith(b'y'):
|
||||||
|
plus_name = self.name + "-PLUS"
|
||||||
|
if plus_name in self.properties.get("enabled_mechanisms", []):
|
||||||
|
raise NotAuthorizedException("Channel binding downgrade attack detected")
|
||||||
|
elif gs2_header.startswith(b'p'):
|
||||||
|
# is this really an error?
|
||||||
|
raise NotAuthorizedException("Channel binding requested for {0!r}"
|
||||||
|
.format(self.name))
|
||||||
|
|
||||||
|
authzid = match.group("authzid")
|
||||||
|
if authzid:
|
||||||
|
self.out_properties['authzid'] = self.unescape(authzid
|
||||||
|
).decode("utf-8")
|
||||||
|
else:
|
||||||
|
self.out_properties['authzid'] = None
|
||||||
|
username = self.unescape(match.group("username")).decode("utf-8")
|
||||||
|
self.out_properties['username'] = username
|
||||||
|
|
||||||
|
nonce_factory = self.properties.get("nonce_factory",
|
||||||
|
default_nonce_factory)
|
||||||
|
|
||||||
|
properties = dict(self.properties)
|
||||||
|
properties.update(self.out_properties)
|
||||||
|
|
||||||
|
s_pformat = "SCRAM-{0}-SaltedPassword".format(self.hash_function_name)
|
||||||
|
k_pformat = "SCRAM-{0}-Keys".format(self.hash_function_name)
|
||||||
|
password, pformat = self.password_database.get_password(username,
|
||||||
|
(s_pformat, "plain"), properties)
|
||||||
|
if pformat == s_pformat:
|
||||||
|
if password is not None:
|
||||||
|
salt, iteration_count, salted_password = password
|
||||||
|
else:
|
||||||
|
logger.debug("No password for user {0!r}".format(username))
|
||||||
|
elif pformat != k_pformat:
|
||||||
|
salt = self.properties.get("SCRAM-salt")
|
||||||
|
if not salt:
|
||||||
|
salt = nonce_factory()
|
||||||
|
iteration_count = self.properties.get("SCRAM-iteration-count", 4096)
|
||||||
|
if pformat == "plain" and password is not None:
|
||||||
|
salted_password = self.Hi(self.Normalize(password), salt,
|
||||||
|
iteration_count)
|
||||||
|
else:
|
||||||
|
logger.debug("No password for user {0!r}".format(username))
|
||||||
|
password = None
|
||||||
|
# to prevent timing attack, compute the key anyway
|
||||||
|
salted_password = self.Hi(self.Normalize(""), salt,
|
||||||
|
iteration_count)
|
||||||
|
if pformat == k_pformat:
|
||||||
|
salt, iteration_count, stored_key, server_key = password
|
||||||
|
else:
|
||||||
|
client_key = self.HMAC(salted_password, b"Client Key")
|
||||||
|
stored_key = self.H(client_key)
|
||||||
|
server_key = self.HMAC(salted_password, b"Server Key")
|
||||||
|
|
||||||
|
if password is not None:
|
||||||
|
self._stored_key = stored_key
|
||||||
|
self._server_key = server_key
|
||||||
|
else:
|
||||||
|
self._stored_key = None
|
||||||
|
self._server_key = None
|
||||||
|
|
||||||
|
c_nonce = match.group("nonce")
|
||||||
|
s_nonce = nonce_factory()
|
||||||
|
if not VALUE_CHARS_RE.match(s_nonce):
|
||||||
|
s_nonce = standard_b64encode(s_nonce)
|
||||||
|
nonce = c_nonce + s_nonce
|
||||||
|
server_first_message = (
|
||||||
|
b"r=" + nonce
|
||||||
|
+ b",s=" + standard_b64encode(salt)
|
||||||
|
+ b",i=" + str(iteration_count).encode("utf-8")
|
||||||
|
)
|
||||||
|
self._nonce = nonce
|
||||||
|
self._cb_name = cb_name
|
||||||
|
self._gs2_header = gs2_header
|
||||||
|
self._client_first_message_bare = match.group("client_first_bare")
|
||||||
|
self._server_first_message = server_first_message
|
||||||
|
return server_first_message
|
||||||
|
|
||||||
|
def _handle_final_response(self, response):
|
||||||
|
match = CLIENT_FINAL_MESSAGE_RE.match(response)
|
||||||
|
if not match:
|
||||||
|
raise NotAuthorizedException("Bad response syntax: {0!r}".format(response))
|
||||||
|
if match.group("nonce") != self._nonce:
|
||||||
|
raise NotAuthorizedException("Bad nonce in the final client response")
|
||||||
|
cb_input = a2b_base64(match.group("cb"))
|
||||||
|
if not cb_input.startswith(self._gs2_header):
|
||||||
|
raise NotAuthorizedException("GS2 header in the final response ({0!r}) doesn't"
|
||||||
|
" match the one sent in the first message ({1!r})"
|
||||||
|
.format(cb_input, self._gs2_header))
|
||||||
|
if self._cb_name:
|
||||||
|
cb_data = cb_input[len(self._gs2_header):]
|
||||||
|
if cb_data != self.properties["channel-binding"][self._cb_name]:
|
||||||
|
raise NotAuthorizedException("Channel binding data doesn't match")
|
||||||
|
|
||||||
|
proof = a2b_base64(match.group("proof"))
|
||||||
|
|
||||||
|
auth_message = (self._client_first_message_bare + b"," +
|
||||||
|
self._server_first_message + b"," +
|
||||||
|
match.group("without_proof"))
|
||||||
|
if self._stored_key is None:
|
||||||
|
# compute something to prevent timing attack
|
||||||
|
client_signature = self.HMAC(b"", auth_message)
|
||||||
|
client_key = self.XOR(client_signature, proof)
|
||||||
|
self.H(client_key)
|
||||||
|
raise NotAuthorizedException("Authentication failed (bad username)")
|
||||||
|
|
||||||
|
client_signature = self.HMAC(self._stored_key, auth_message)
|
||||||
|
client_key = self.XOR(client_signature, proof)
|
||||||
|
if self.H(client_key) != self._stored_key:
|
||||||
|
raise NotAuthorizedException("Authentication failed")
|
||||||
|
|
||||||
|
server_signature = self.HMAC(self._server_key, auth_message)
|
||||||
|
server_final_message = b"v=" + standard_b64encode(server_signature)
|
||||||
|
return (self.out_properties, server_final_message)
|
||||||
|
|
||||||
|
|
369
irctest/self_tests/cases.py
Normal file
369
irctest/self_tests/cases.py
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
"""Internal checks of assertion implementations."""
|
||||||
|
|
||||||
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.irc_utils.message_parser import parse_message
|
||||||
|
from irctest.patma import (
|
||||||
|
ANYDICT,
|
||||||
|
ANYLIST,
|
||||||
|
ANYOPTSTR,
|
||||||
|
ANYSTR,
|
||||||
|
ListRemainder,
|
||||||
|
NotStrRe,
|
||||||
|
RemainingKeys,
|
||||||
|
StrRe,
|
||||||
|
)
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", "hello"],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"PRIVMSG #chan hello",
|
||||||
|
"PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
":foo!baz@qux PRIVMSG #chan hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"PRIVMSG #chan hello2",
|
||||||
|
"PRIVMSG #chan2 hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
|
||||||
|
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", StrRe("hello.*")],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"PRIVMSG #chan hello",
|
||||||
|
"PRIVMSG #chan :hello",
|
||||||
|
"PRIVMSG #chan hello2",
|
||||||
|
"@tag1=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
":foo!baz@qux PRIVMSG #chan hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"PRIVMSG #chan :hi",
|
||||||
|
"PRIVMSG #chan2 hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan', 'hi']",
|
||||||
|
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan2', 'hello']",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
nick="foo",
|
||||||
|
command="PRIVMSG",
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
":foo!baz@qux PRIVMSG #chan hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"PRIVMSG #chan :hi",
|
||||||
|
":foo2!baz@qux PRIVMSG #chan hello",
|
||||||
|
"@tag1=bar :foo2!baz@qux PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected nick to be foo, got None instead",
|
||||||
|
"expected nick to be foo, got foo2 instead",
|
||||||
|
"expected nick to be foo, got foo2 instead",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
tags={"tag1": "bar"},
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", "hello"],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"@tag1=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
"@tag1=value1 PRIVMSG #chan :hello",
|
||||||
|
"PRIVMSG #chan hello",
|
||||||
|
":foo!baz@qux PRIVMSG #chan hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'bar', 'tag2': ''}",
|
||||||
|
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'value1'}",
|
||||||
|
"expected tags to match {'tag1': 'bar'}, got {}",
|
||||||
|
"expected tags to match {'tag1': 'bar'}, got {}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
tags={"tag1": ANYSTR},
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", ANYSTR],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"@tag1=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=value1 PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
"PRIVMSG #chan hello",
|
||||||
|
":foo!baz@qux PRIVMSG #chan hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected tags to match {'tag1': ANYSTR}, got {'tag1': 'bar', 'tag2': ''}",
|
||||||
|
"expected tags to match {'tag1': ANYSTR}, got {}",
|
||||||
|
"expected tags to match {'tag1': ANYSTR}, got {}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
tags={"tag1": "bar", **ANYDICT},
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", "hello"],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"@tag1=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"PRIVMG #chan :hello",
|
||||||
|
"@tag1=value1 PRIVMSG #chan :hello",
|
||||||
|
"PRIVMSG #chan hello2",
|
||||||
|
"PRIVMSG #chan2 hello",
|
||||||
|
":foo!baz@qux PRIVMSG #chan hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected command to be PRIVMSG, got PRIVMG",
|
||||||
|
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}",
|
||||||
|
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
|
||||||
|
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
|
||||||
|
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
tags={StrRe("tag[12]"): "bar", **ANYDICT},
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", "hello"],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"@tag1=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
"@tag2=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
"@tag1=;tag2=bar PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"PRIVMG #chan :hello",
|
||||||
|
"@tag1=value1 PRIVMSG #chan :hello",
|
||||||
|
"PRIVMSG #chan hello2",
|
||||||
|
"PRIVMSG #chan2 hello",
|
||||||
|
":foo!baz@qux PRIVMSG #chan hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected command to be PRIVMSG, got PRIVMG",
|
||||||
|
"expected tags to match {StrRe(r'tag[12]'): 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}",
|
||||||
|
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
|
||||||
|
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
|
||||||
|
"expected tags to match {StrRe(r'tag[12]'): 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
tags={"tag1": "bar", RemainingKeys(NotStrRe("tag2")): ANYOPTSTR},
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", "hello"],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"@tag1=bar PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag3= PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"PRIVMG #chan :hello",
|
||||||
|
"@tag1=value1 PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
|
"@tag1=bar;tag2=baz PRIVMSG #chan :hello",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected command to be PRIVMSG, got PRIVMG",
|
||||||
|
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'value1'}",
|
||||||
|
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': ''}",
|
||||||
|
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': 'baz'}",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
command="005",
|
||||||
|
params=["nick", "FOO=1", *ANYLIST],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"005 nick FOO=1",
|
||||||
|
"005 nick FOO=1 BAR=2",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"005 nick",
|
||||||
|
"005 nick BAR=2",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick']",
|
||||||
|
"expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick', 'BAR=2']",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
command="005",
|
||||||
|
params=["nick", ListRemainder(ANYSTR, min_length=1)],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"005 nick FOO=1",
|
||||||
|
"005 nick FOO=1 BAR=2",
|
||||||
|
"005 nick BAR=2",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"005 nick",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected params to match ['nick', ListRemainder(ANYSTR, min_length=1)], got ['nick']",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
command="005",
|
||||||
|
params=["nick", ListRemainder(StrRe("[A-Z]+=.*"), min_length=1)],
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"005 nick FOO=1",
|
||||||
|
"005 nick FOO=1 BAR=2",
|
||||||
|
"005 nick BAR=2",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"005 nick",
|
||||||
|
"005 nick foo=1",
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick']",
|
||||||
|
"expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick', 'foo=1']",
|
||||||
|
]
|
||||||
|
),
|
||||||
|
(
|
||||||
|
# the specification:
|
||||||
|
dict(
|
||||||
|
command="PING",
|
||||||
|
params=["abc"]
|
||||||
|
),
|
||||||
|
# matches:
|
||||||
|
[
|
||||||
|
"PING abc",
|
||||||
|
],
|
||||||
|
# and does not match:
|
||||||
|
[
|
||||||
|
"PONG def"
|
||||||
|
],
|
||||||
|
# and they each error with:
|
||||||
|
[
|
||||||
|
"expected command to be PING, got PONG"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
]
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
|
class IrcTestCaseTestCase(cases._IrcTestCase):
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"spec,msg",
|
||||||
|
[
|
||||||
|
pytest.param(spec, msg, id=f"{spec}-{msg}")
|
||||||
|
for (spec, positive_matches, _, _) in MESSAGE_SPECS
|
||||||
|
for msg in positive_matches
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_message_matching_positive(self, spec, msg):
|
||||||
|
assert not self.messageDiffers(parse_message(msg), **spec), msg
|
||||||
|
assert self.messageEqual(parse_message(msg), **spec), msg
|
||||||
|
self.assertMessageMatch(parse_message(msg), **spec), msg
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"spec,msg",
|
||||||
|
[
|
||||||
|
pytest.param(spec, msg, id=f"{spec}-{msg}")
|
||||||
|
for (spec, _, negative_matches, _) in MESSAGE_SPECS
|
||||||
|
for msg in negative_matches
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_message_matching_negative(self, spec, msg):
|
||||||
|
assert self.messageDiffers(parse_message(msg), **spec), msg
|
||||||
|
assert not self.messageEqual(parse_message(msg), **spec), msg
|
||||||
|
with pytest.raises(AssertionError):
|
||||||
|
self.assertMessageMatch(parse_message(msg), **spec), msg
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"spec,msg,error_string",
|
||||||
|
[
|
||||||
|
pytest.param(spec, msg, error_string, id=error_string)
|
||||||
|
for (spec, _, negative_matches, error_stringgexps) in MESSAGE_SPECS
|
||||||
|
for (msg, error_string) in zip(negative_matches, error_stringgexps)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_message_matching_negative_message(self, spec, msg, error_string):
|
||||||
|
self.assertIn(error_string, self.messageDiffers(parse_message(msg), **spec))
|
141
irctest/server_tests/account_registration.py
Normal file
141
irctest/server_tests/account_registration.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
"""
|
||||||
|
`Draft IRCv3 account-registration
|
||||||
|
<https://ircv3.net/specs/extensions/account-registration>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
|
REGISTER_CAP_NAME = "draft/account-registration"
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
|
||||||
|
@staticmethod
|
||||||
|
def config() -> cases.TestCaseControllerConfig:
|
||||||
|
return cases.TestCaseControllerConfig(
|
||||||
|
ergo_config=lambda config: config["accounts"]["registration"].update(
|
||||||
|
{"allow-before-connect": True}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def testBeforeConnect(self):
|
||||||
|
self.addClient("bar")
|
||||||
|
self.requestCapabilities("bar", [REGISTER_CAP_NAME], skip_if_cap_nak=True)
|
||||||
|
self.sendLine("bar", "CAP LS 302")
|
||||||
|
caps = self.getCapLs("bar")
|
||||||
|
self.assertIn(REGISTER_CAP_NAME, caps)
|
||||||
|
self.assertIn("before-connect", caps[REGISTER_CAP_NAME])
|
||||||
|
self.sendLine("bar", "NICK bar")
|
||||||
|
self.sendLine("bar", "REGISTER * * shivarampassphrase")
|
||||||
|
msgs = self.getMessages("bar")
|
||||||
|
register_response = [msg for msg in msgs if msg.command == "REGISTER"][0]
|
||||||
|
self.assertMessageMatch(register_response, params=["SUCCESS", ANYSTR, ANYSTR])
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
|
||||||
|
@staticmethod
|
||||||
|
def config() -> cases.TestCaseControllerConfig:
|
||||||
|
return cases.TestCaseControllerConfig(
|
||||||
|
ergo_config=lambda config: config["accounts"]["registration"].update(
|
||||||
|
{"allow-before-connect": False}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def testBeforeConnect(self):
|
||||||
|
self.addClient("bar")
|
||||||
|
self.requestCapabilities("bar", [REGISTER_CAP_NAME], skip_if_cap_nak=True)
|
||||||
|
self.sendLine("bar", "CAP LS 302")
|
||||||
|
caps = self.getCapLs("bar")
|
||||||
|
self.assertIn(REGISTER_CAP_NAME, caps)
|
||||||
|
self.assertEqual(caps[REGISTER_CAP_NAME], None)
|
||||||
|
self.sendLine("bar", "NICK bar")
|
||||||
|
self.sendLine("bar", "REGISTER * * shivarampassphrase")
|
||||||
|
msgs = self.getMessages("bar")
|
||||||
|
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
fail_response,
|
||||||
|
params=["REGISTER", "COMPLETE_CONNECTION_REQUIRED", ANYSTR, ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
|
||||||
|
@staticmethod
|
||||||
|
def config() -> cases.TestCaseControllerConfig:
|
||||||
|
return cases.TestCaseControllerConfig(
|
||||||
|
ergo_config=lambda config: config["accounts"]["registration"].update(
|
||||||
|
{
|
||||||
|
"email-verification": {
|
||||||
|
"enabled": True,
|
||||||
|
"sender": "test@example.com",
|
||||||
|
"require-tls": True,
|
||||||
|
"helo-domain": "example.com",
|
||||||
|
},
|
||||||
|
"allow-before-connect": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def testBeforeConnect(self):
|
||||||
|
self.addClient("bar")
|
||||||
|
self.requestCapabilities(
|
||||||
|
"bar", capabilities=[REGISTER_CAP_NAME], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
self.sendLine("bar", "CAP LS 302")
|
||||||
|
caps = self.getCapLs("bar")
|
||||||
|
self.assertIn(REGISTER_CAP_NAME, caps)
|
||||||
|
self.assertEqual(
|
||||||
|
set(caps[REGISTER_CAP_NAME].split(",")),
|
||||||
|
{"before-connect", "email-required"},
|
||||||
|
)
|
||||||
|
self.sendLine("bar", "NICK bar")
|
||||||
|
self.sendLine("bar", "REGISTER * * shivarampassphrase")
|
||||||
|
msgs = self.getMessages("bar")
|
||||||
|
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
fail_response, params=["REGISTER", "INVALID_EMAIL", ANYSTR, ANYSTR]
|
||||||
|
)
|
||||||
|
|
||||||
|
def testAfterConnect(self):
|
||||||
|
self.connectClient(
|
||||||
|
"bar", name="bar", capabilities=[REGISTER_CAP_NAME], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
self.sendLine("bar", "REGISTER * * shivarampassphrase")
|
||||||
|
msgs = self.getMessages("bar")
|
||||||
|
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
fail_response, params=["REGISTER", "INVALID_EMAIL", ANYSTR, ANYSTR]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
@cases.mark_specifications("IRCv3", "Ergo")
|
||||||
|
class RegisterNoLandGrabsTestCase(cases.BaseServerTestCase):
|
||||||
|
@staticmethod
|
||||||
|
def config() -> cases.TestCaseControllerConfig:
|
||||||
|
return cases.TestCaseControllerConfig(
|
||||||
|
ergo_config=lambda config: config["accounts"]["registration"].update(
|
||||||
|
{"allow-before-connect": True}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def testBeforeConnect(self):
|
||||||
|
# have an anonymous client take the 'root' username:
|
||||||
|
self.connectClient(
|
||||||
|
"root", name="root", capabilities=[REGISTER_CAP_NAME], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# cannot register it out from under the anonymous nick holder:
|
||||||
|
self.addClient("bar")
|
||||||
|
self.sendLine("bar", "NICK root")
|
||||||
|
self.sendLine("bar", "REGISTER * * shivarampassphrase")
|
||||||
|
msgs = self.getMessages("bar")
|
||||||
|
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
fail_response, params=["REGISTER", "USERNAME_EXISTS", ANYSTR, ANYSTR]
|
||||||
|
)
|
73
irctest/server_tests/account_tag.py
Normal file
73
irctest/server_tests/account_tag.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 account-tag <https://ircv3.net/specs/extensions/account-tag>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
class AccountTagTestCase(cases.BaseServerTestCase):
|
||||||
|
def connectRegisteredClient(self, nick):
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(2, "CAP LS 302")
|
||||||
|
capabilities = self.getCapLs(2)
|
||||||
|
assert "sasl" in capabilities
|
||||||
|
|
||||||
|
self.sendLine(2, "USER f * * :Realname")
|
||||||
|
self.sendLine(2, "NICK {}".format(nick))
|
||||||
|
self.sendLine(2, "CAP REQ :sasl")
|
||||||
|
self.getRegistrationMessage(2)
|
||||||
|
|
||||||
|
self.sendLine(2, "AUTHENTICATE PLAIN")
|
||||||
|
m = self.getRegistrationMessage(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="AUTHENTICATE",
|
||||||
|
params=["+"],
|
||||||
|
fail_msg="Sent “AUTHENTICATE PLAIN”, server should have "
|
||||||
|
"replied with “AUTHENTICATE +”, but instead sent: {msg}",
|
||||||
|
)
|
||||||
|
self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=")
|
||||||
|
m = self.getRegistrationMessage(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="900",
|
||||||
|
fail_msg="Did not send 900 after correct SASL authentication.",
|
||||||
|
)
|
||||||
|
self.sendLine(2, "USER f * * :Realname")
|
||||||
|
self.sendLine(2, "NICK {}".format(nick))
|
||||||
|
self.sendLine(2, "CAP END")
|
||||||
|
self.skipToWelcome(2)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("account-tag")
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
def testPrivmsg(self):
|
||||||
|
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.controller.registerUser(self, "jilles", "sesame")
|
||||||
|
self.connectRegisteredClient("bar")
|
||||||
|
self.sendLine(2, "PRIVMSG foo :hi")
|
||||||
|
self.getMessages(2)
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="PRIVMSG", params=["foo", "hi"], tags={"account": "jilles"}
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("account-tag")
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Charybdis"], "https://github.com/solanum-ircd/solanum/issues/166"
|
||||||
|
)
|
||||||
|
def testInvite(self):
|
||||||
|
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.controller.registerUser(self, "jilles", "sesame")
|
||||||
|
self.connectRegisteredClient("bar")
|
||||||
|
self.sendLine(2, "JOIN #chan")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.sendLine(2, "INVITE foo #chan")
|
||||||
|
self.getMessages(2)
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="INVITE", params=["foo", "#chan"], tags={"account": "jilles"}
|
||||||
|
)
|
183
irctest/server_tests/away.py
Normal file
183
irctest/server_tests/away.py
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"""
|
||||||
|
AWAY command (`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-4.1>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#away-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import (
|
||||||
|
RPL_AWAY,
|
||||||
|
RPL_NOWAWAY,
|
||||||
|
RPL_UNAWAY,
|
||||||
|
RPL_USERHOST,
|
||||||
|
RPL_WHOISUSER,
|
||||||
|
)
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
class AwayTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC2812", "Modern")
|
||||||
|
def testAway(self):
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "AWAY :I'm not here right now")
|
||||||
|
replies = self.getMessages(1)
|
||||||
|
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
||||||
|
|
||||||
|
self.connectClient("qux")
|
||||||
|
self.sendLine(2, "PRIVMSG bar :what's up")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2),
|
||||||
|
command=RPL_AWAY,
|
||||||
|
params=["qux", "bar", "I'm not here right now"],
|
||||||
|
)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "AWAY")
|
||||||
|
replies = self.getMessages(1)
|
||||||
|
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
|
||||||
|
|
||||||
|
self.sendLine(2, "PRIVMSG bar :what's up")
|
||||||
|
replies = self.getMessages(2)
|
||||||
|
self.assertEqual(len(replies), 0)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testAwayAck(self):
|
||||||
|
"""
|
||||||
|
"The server acknowledges the change in away status by returning the
|
||||||
|
`RPL_NOWAWAY` and `RPL_UNAWAY` numerics."
|
||||||
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
"""
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(1, "AWAY :I'm not here right now")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1), command=RPL_NOWAWAY, params=["bar", ANYSTR]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [])
|
||||||
|
|
||||||
|
self.sendLine(1, "AWAY")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1), command=RPL_UNAWAY, params=["bar", ANYSTR]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [])
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testAwayPrivmsg(self):
|
||||||
|
"""
|
||||||
|
"Servers SHOULD notify clients when a user they're interacting with
|
||||||
|
is away when relevant"
|
||||||
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
|
||||||
|
"<client> <nick> :<message>"
|
||||||
|
-- https://modern.ircdocs.horse/#rplaway-301
|
||||||
|
"""
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.connectClient("qux")
|
||||||
|
|
||||||
|
self.sendLine(2, "PRIVMSG bar :what's up")
|
||||||
|
self.assertEqual(self.getMessages(2), [])
|
||||||
|
|
||||||
|
self.sendLine(1, "AWAY :I'm not here right now")
|
||||||
|
replies = self.getMessages(1)
|
||||||
|
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
||||||
|
|
||||||
|
self.sendLine(2, "PRIVMSG bar :what's up")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2),
|
||||||
|
command=RPL_AWAY,
|
||||||
|
params=["qux", "bar", "I'm not here right now"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testAwayWhois(self):
|
||||||
|
"""
|
||||||
|
"Servers SHOULD notify clients when a user they're interacting with
|
||||||
|
is away when relevant"
|
||||||
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
|
||||||
|
"<client> <nick> :<message>"
|
||||||
|
-- https://modern.ircdocs.horse/#rplaway-301
|
||||||
|
"""
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.connectClient("qux")
|
||||||
|
|
||||||
|
self.sendLine(2, "WHOIS bar")
|
||||||
|
msgs = [msg for msg in self.getMessages(2) if msg.command == RPL_AWAY]
|
||||||
|
self.assertEqual(
|
||||||
|
len(msgs),
|
||||||
|
0,
|
||||||
|
fail_msg="Expected no RPL_AWAY (301), got: {}",
|
||||||
|
extra_format=(msgs,),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "AWAY :I'm not here right now")
|
||||||
|
replies = self.getMessages(1)
|
||||||
|
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
||||||
|
|
||||||
|
self.sendLine(2, "WHOIS bar")
|
||||||
|
msgs = [msg for msg in self.getMessages(2) if msg.command == RPL_AWAY]
|
||||||
|
self.assertEqual(
|
||||||
|
len(msgs),
|
||||||
|
1,
|
||||||
|
fail_msg="Expected one RPL_AWAY (301), got: {}",
|
||||||
|
extra_format=(msgs,),
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msgs[0], command=RPL_AWAY, params=["qux", "bar", "I'm not here right now"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testAwayUserhost(self):
|
||||||
|
"""
|
||||||
|
"Servers SHOULD notify clients when a user they're interacting with
|
||||||
|
is away when relevant"
|
||||||
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
|
||||||
|
"<client> <nick> :<message>"
|
||||||
|
-- https://modern.ircdocs.horse/#rplaway-301
|
||||||
|
"""
|
||||||
|
self.connectClient("bar")
|
||||||
|
|
||||||
|
self.connectClient("qux")
|
||||||
|
self.sendLine(2, "USERHOST bar")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=\+.*")]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "AWAY :I'm not here right now")
|
||||||
|
replies = self.getMessages(1)
|
||||||
|
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
||||||
|
|
||||||
|
self.sendLine(2, "USERHOST bar")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=-.*")]
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testAwayEmptyMessage(self):
|
||||||
|
"""
|
||||||
|
"If [AWAY] is sent with a nonempty parameter (the 'away message')
|
||||||
|
then the user is set to be away. If this command is sent with no
|
||||||
|
parameters, or with the empty string as the parameter, the user is no
|
||||||
|
longer away."
|
||||||
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
"""
|
||||||
|
self.connectClient("bar", name="bar")
|
||||||
|
self.connectClient("qux", name="qux")
|
||||||
|
|
||||||
|
self.sendLine("bar", "AWAY :I'm not here right now")
|
||||||
|
replies = self.getMessages("bar")
|
||||||
|
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
||||||
|
self.sendLine("qux", "WHOIS bar")
|
||||||
|
replies = self.getMessages("qux")
|
||||||
|
self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies])
|
||||||
|
self.assertIn(RPL_AWAY, [msg.command for msg in replies])
|
||||||
|
|
||||||
|
# empty final parameter to AWAY is treated the same as no parameter,
|
||||||
|
# i.e., the client is considered to be no longer away
|
||||||
|
self.sendLine("bar", "AWAY :")
|
||||||
|
replies = self.getMessages("bar")
|
||||||
|
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
|
||||||
|
self.sendLine("qux", "WHOIS bar")
|
||||||
|
replies = self.getMessages("qux")
|
||||||
|
self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies])
|
||||||
|
self.assertNotIn(RPL_AWAY, [msg.command for msg in replies])
|
83
irctest/server_tests/away_notify.py
Normal file
83
irctest/server_tests/away_notify.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 away-notify <https://ircv3.net/specs/extensions/away-notify>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import RPL_NOWAWAY, RPL_UNAWAY
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
class AwayNotifyTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_capabilities("away-notify")
|
||||||
|
def testAwayNotify(self):
|
||||||
|
"""Basic away-notify test."""
|
||||||
|
self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.joinChannel(2, "#chan")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(2, "AWAY :i'm going away")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2), command=RPL_NOWAWAY, params=["bar", ANYSTR]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(2), [])
|
||||||
|
|
||||||
|
awayNotify = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
awayNotify,
|
||||||
|
prefix=StrRe("bar!.*"),
|
||||||
|
command="AWAY",
|
||||||
|
params=["i'm going away"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(2, "AWAY")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2), command=RPL_UNAWAY, params=["bar", ANYSTR]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(2), [])
|
||||||
|
|
||||||
|
awayNotify = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
awayNotify, prefix=StrRe("bar!.*"), command="AWAY", params=[]
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("away-notify")
|
||||||
|
def testAwayNotifyOnJoin(self):
|
||||||
|
"""The away-notify specification states:
|
||||||
|
"Clients will be sent an AWAY message [...] when a user joins
|
||||||
|
and has an away message set."
|
||||||
|
"""
|
||||||
|
self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.sendLine(2, "AWAY :i'm already away")
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.joinChannel(2, "#chan")
|
||||||
|
self.assertNotIn(
|
||||||
|
"AWAY",
|
||||||
|
[m.command for m in self.getMessages(2)],
|
||||||
|
"joining user got their own away status when they joined",
|
||||||
|
)
|
||||||
|
|
||||||
|
messages = [msg for msg in self.getMessages(1) if msg.command == "AWAY"]
|
||||||
|
self.assertEqual(
|
||||||
|
len(messages),
|
||||||
|
1,
|
||||||
|
"Someone away joined a channel, "
|
||||||
|
"but users in the channel did not get AWAY messages.",
|
||||||
|
)
|
||||||
|
awayNotify = messages[0]
|
||||||
|
self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm already away"])
|
||||||
|
self.assertTrue(
|
||||||
|
awayNotify.prefix.startswith("bar!"),
|
||||||
|
"Unexpected away-notify source: %s" % (awayNotify.prefix,),
|
||||||
|
)
|
160
irctest/server_tests/bot_mode.py
Normal file
160
irctest/server_tests/bot_mode.py
Normal file
@ -0,0 +1,160 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 bot mode <https://ircv3.net/specs/extensions/bot-mode>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import RPL_WHOISBOT
|
||||||
|
from irctest.patma import ANYDICT, ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_isupport("BOT")
|
||||||
|
class BotModeTestCase(cases.BaseServerTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.connectClient("modegettr")
|
||||||
|
if "BOT" not in self.server_support:
|
||||||
|
raise runner.IsupportTokenNotSupported("BOT")
|
||||||
|
self._mode_char = self.server_support["BOT"]
|
||||||
|
|
||||||
|
def _initBot(self):
|
||||||
|
self.assertEqual(
|
||||||
|
len(self._mode_char),
|
||||||
|
1,
|
||||||
|
fail_msg=(
|
||||||
|
f"BOT ISUPPORT token should be exactly one character, "
|
||||||
|
f"but is: {self._mode_char!r}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.connectClient("botnick", "bot")
|
||||||
|
|
||||||
|
self.sendLine("bot", f"MODE botnick +{self._mode_char}")
|
||||||
|
|
||||||
|
# Check echoed mode
|
||||||
|
while True:
|
||||||
|
msg = self.getMessage("bot")
|
||||||
|
if msg.command != "NOTICE":
|
||||||
|
# Unreal sends the BOTMOTD here
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg,
|
||||||
|
command="MODE",
|
||||||
|
params=["botnick", StrRe(r"\+?" + self._mode_char)],
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
def testBotMode(self):
|
||||||
|
self._initBot()
|
||||||
|
|
||||||
|
def testBotWhois(self):
|
||||||
|
self._initBot()
|
||||||
|
|
||||||
|
self.connectClient("usernick", "user")
|
||||||
|
self.sendLine("user", "WHOIS botnick")
|
||||||
|
messages = self.getMessages("user")
|
||||||
|
messages = [msg for msg in messages if msg.command == RPL_WHOISBOT]
|
||||||
|
self.assertEqual(
|
||||||
|
len(messages),
|
||||||
|
1,
|
||||||
|
msg=(
|
||||||
|
f"Expected exactly one RPL_WHOISBOT ({RPL_WHOISBOT}), "
|
||||||
|
f"got: {messages}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
(message,) = messages
|
||||||
|
self.assertMessageMatch(
|
||||||
|
message, command=RPL_WHOISBOT, params=["usernick", "botnick", ANYSTR]
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["InspIRCd"],
|
||||||
|
"Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910",
|
||||||
|
)
|
||||||
|
def testBotPrivateMessage(self):
|
||||||
|
self._initBot()
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine("bot", "PRIVMSG usernick :beep boop")
|
||||||
|
self.getMessages("bot") # Synchronizes
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("user"),
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["usernick", "beep boop"],
|
||||||
|
tags={StrRe("(draft/)?bot"): None, **ANYDICT},
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["InspIRCd"],
|
||||||
|
"Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910",
|
||||||
|
)
|
||||||
|
def testBotChannelMessage(self):
|
||||||
|
self._initBot()
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine("bot", "JOIN #chan")
|
||||||
|
self.sendLine("user", "JOIN #chan")
|
||||||
|
self.getMessages("bot")
|
||||||
|
self.getMessages("user")
|
||||||
|
|
||||||
|
self.sendLine("bot", "PRIVMSG #chan :beep boop")
|
||||||
|
self.getMessages("bot") # Synchronizes
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("user"),
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=["#chan", "beep boop"],
|
||||||
|
tags={StrRe("(draft/)?bot"): None, **ANYDICT},
|
||||||
|
)
|
||||||
|
|
||||||
|
def testBotWhox(self):
|
||||||
|
self._initBot()
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine("bot", "JOIN #chan")
|
||||||
|
self.sendLine("user", "JOIN #chan")
|
||||||
|
self.getMessages("bot")
|
||||||
|
self.getMessages("user")
|
||||||
|
|
||||||
|
self.sendLine("user", "WHO #chan")
|
||||||
|
msg1 = self.getMessage("user")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg1, command="352", fail_msg="Expected WHO response (352), got: {msg}"
|
||||||
|
)
|
||||||
|
msg2 = self.getMessage("user")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg2, command="352", fail_msg="Expected WHO response (352), got: {msg}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if msg1.params[5] == "botnick":
|
||||||
|
msg = msg1
|
||||||
|
elif msg2.params[5] == "botnick":
|
||||||
|
msg = msg2
|
||||||
|
else:
|
||||||
|
assert False, "No WHO response contained botnick"
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg,
|
||||||
|
command="352",
|
||||||
|
params=[
|
||||||
|
"usernick",
|
||||||
|
"#chan",
|
||||||
|
ANYSTR, # ident
|
||||||
|
ANYSTR, # hostname
|
||||||
|
ANYSTR, # server
|
||||||
|
"botnick",
|
||||||
|
StrRe(f".*{self._mode_char}.*"),
|
||||||
|
ANYSTR, # realname
|
||||||
|
],
|
||||||
|
fail_msg="Expected WHO response with bot flag, got: {msg}",
|
||||||
|
)
|
166
irctest/server_tests/bouncer.py
Normal file
166
irctest/server_tests/bouncer.py
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`_-specific tests of
|
||||||
|
`multiclient features
|
||||||
|
<https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#multiclient-bouncer>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.irc_utils.sasl import sasl_plain_blob
|
||||||
|
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
class BouncerTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testBouncer(self):
|
||||||
|
"""Test basic bouncer functionality."""
|
||||||
|
self.controller.registerUser(self, "observer", "observerpassword")
|
||||||
|
self.controller.registerUser(self, "testuser", "mypassword")
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"observer", password="observerpassword", capabilities=["sasl"]
|
||||||
|
)
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
self.sendLine(1, "CAP REQ :message-tags server-time")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(2, "CAP LS 302")
|
||||||
|
self.sendLine(2, "AUTHENTICATE PLAIN")
|
||||||
|
self.sendLine(2, sasl_plain_blob("testuser", "mypassword"))
|
||||||
|
self.sendLine(2, "NICK testnick")
|
||||||
|
self.sendLine(2, "USER a 0 * a")
|
||||||
|
self.sendLine(2, "CAP REQ :server-time message-tags")
|
||||||
|
self.sendLine(2, "CAP END")
|
||||||
|
messages = self.getMessages(2)
|
||||||
|
welcomes = [message for message in messages if message.command == RPL_WELCOME]
|
||||||
|
self.assertEqual(len(welcomes), 1)
|
||||||
|
# should see a regburst for testnick
|
||||||
|
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
|
||||||
|
self.joinChannel(2, "#chan")
|
||||||
|
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(3, "CAP LS 302")
|
||||||
|
self.sendLine(3, "AUTHENTICATE PLAIN")
|
||||||
|
self.sendLine(3, sasl_plain_blob("testuser", "mypassword"))
|
||||||
|
self.sendLine(3, "NICK testnick")
|
||||||
|
self.sendLine(3, "USER a 0 * a")
|
||||||
|
self.sendLine(3, "CAP REQ :server-time message-tags account-tag")
|
||||||
|
self.sendLine(3, "CAP END")
|
||||||
|
messages = self.getMessages(3)
|
||||||
|
welcomes = [message for message in messages if message.command == RPL_WELCOME]
|
||||||
|
self.assertEqual(len(welcomes), 1)
|
||||||
|
# should see the *same* regburst for testnick
|
||||||
|
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
|
||||||
|
joins = [message for message in messages if message.command == "JOIN"]
|
||||||
|
# we should be automatically joined to #chan
|
||||||
|
self.assertMessageMatch(joins[0], params=["#chan"])
|
||||||
|
|
||||||
|
# disable multiclient in nickserv
|
||||||
|
self.sendLine(3, "NS SET MULTICLIENT OFF")
|
||||||
|
self.getMessages(3)
|
||||||
|
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(4, "CAP LS 302")
|
||||||
|
self.sendLine(4, "AUTHENTICATE PLAIN")
|
||||||
|
self.sendLine(4, sasl_plain_blob("testuser", "mypassword"))
|
||||||
|
self.sendLine(4, "NICK testnick")
|
||||||
|
self.sendLine(4, "USER a 0 * a")
|
||||||
|
self.sendLine(4, "CAP REQ :server-time message-tags")
|
||||||
|
self.sendLine(4, "CAP END")
|
||||||
|
# with multiclient disabled, we should not be able to attach to the nick
|
||||||
|
messages = self.getMessages(4)
|
||||||
|
welcomes = [message for message in messages if message.command == RPL_WELCOME]
|
||||||
|
self.assertEqual(len(welcomes), 0)
|
||||||
|
errors = [
|
||||||
|
message for message in messages if message.command == ERR_NICKNAMEINUSE
|
||||||
|
]
|
||||||
|
self.assertEqual(len(errors), 1)
|
||||||
|
|
||||||
|
self.sendLine(3, "NS SET MULTICLIENT ON")
|
||||||
|
self.getMessages(3)
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(5, "CAP LS 302")
|
||||||
|
self.sendLine(5, "AUTHENTICATE PLAIN")
|
||||||
|
self.sendLine(5, sasl_plain_blob("testuser", "mypassword"))
|
||||||
|
self.sendLine(5, "NICK testnick")
|
||||||
|
self.sendLine(5, "USER a 0 * a")
|
||||||
|
self.sendLine(5, "CAP REQ server-time")
|
||||||
|
self.sendLine(5, "CAP END")
|
||||||
|
messages = self.getMessages(5)
|
||||||
|
welcomes = [message for message in messages if message.command == RPL_WELCOME]
|
||||||
|
self.assertEqual(len(welcomes), 1)
|
||||||
|
|
||||||
|
self.sendLine(1, "@+clientOnlyTag=Value PRIVMSG #chan :hey")
|
||||||
|
self.getMessages(1)
|
||||||
|
messagesfortwo = [
|
||||||
|
msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"
|
||||||
|
]
|
||||||
|
messagesforthree = [
|
||||||
|
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(messagesfortwo), 1)
|
||||||
|
self.assertEqual(len(messagesforthree), 1)
|
||||||
|
messagefortwo = messagesfortwo[0]
|
||||||
|
messageforthree = messagesforthree[0]
|
||||||
|
messageforfive = self.getMessage(5)
|
||||||
|
self.assertMessageMatch(messagefortwo, params=["#chan", "hey"])
|
||||||
|
self.assertMessageMatch(messageforthree, params=["#chan", "hey"])
|
||||||
|
self.assertMessageMatch(messageforfive, params=["#chan", "hey"])
|
||||||
|
self.assertIn("time", messagefortwo.tags)
|
||||||
|
self.assertIn("time", messageforthree.tags)
|
||||||
|
self.assertIn("time", messageforfive.tags)
|
||||||
|
# 3 has account-tag
|
||||||
|
self.assertIn("account", messageforthree.tags)
|
||||||
|
# should get same msgid
|
||||||
|
self.assertEqual(messagefortwo.tags["msgid"], messageforthree.tags["msgid"])
|
||||||
|
# 5 only has server-time, shouldn't get account or msgid tags
|
||||||
|
self.assertNotIn("account", messageforfive.tags)
|
||||||
|
self.assertNotIn("msgid", messageforfive.tags)
|
||||||
|
|
||||||
|
# test that copies of sent messages go out to other sessions
|
||||||
|
self.sendLine(2, "PRIVMSG observer :this is a direct message")
|
||||||
|
self.getMessages(2)
|
||||||
|
messageForRecipient = [
|
||||||
|
msg for msg in self.getMessages(1) if msg.command == "PRIVMSG"
|
||||||
|
][0]
|
||||||
|
copyForOtherSession = [
|
||||||
|
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
|
||||||
|
][0]
|
||||||
|
self.assertEqual(messageForRecipient.params, copyForOtherSession.params)
|
||||||
|
self.assertEqual(
|
||||||
|
messageForRecipient.tags["msgid"], copyForOtherSession.tags["msgid"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(2, "QUIT :two out")
|
||||||
|
quitLines = [msg for msg in self.getMessages(2) if msg.command == "QUIT"]
|
||||||
|
self.assertEqual(len(quitLines), 1)
|
||||||
|
self.assertMessageMatch(quitLines[0], params=[StrRe(".*two out.*")])
|
||||||
|
# neither the observer nor the other attached session should see a quit here
|
||||||
|
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
|
||||||
|
self.assertEqual(quitLines, [])
|
||||||
|
quitLines = [msg for msg in self.getMessages(3) if msg.command == "QUIT"]
|
||||||
|
self.assertEqual(quitLines, [])
|
||||||
|
|
||||||
|
# session 3 should be untouched at this point
|
||||||
|
self.sendLine(1, "@+clientOnlyTag=Value PRIVMSG #chan :hey again")
|
||||||
|
self.getMessages(1)
|
||||||
|
messagesforthree = [
|
||||||
|
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(messagesforthree), 1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messagesforthree[0], command="PRIVMSG", params=["#chan", "hey again"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(5, "QUIT :five out")
|
||||||
|
self.getMessages(5)
|
||||||
|
self.sendLine(3, "QUIT :three out")
|
||||||
|
quitLines = [msg for msg in self.getMessages(3) if msg.command == "QUIT"]
|
||||||
|
self.assertEqual(len(quitLines), 1)
|
||||||
|
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])
|
||||||
|
# observer should see *this* quit
|
||||||
|
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
|
||||||
|
self.assertEqual(len(quitLines), 1)
|
||||||
|
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])
|
146
irctest/server_tests/buffering.py
Normal file
146
irctest/server_tests/buffering.py
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
"""
|
||||||
|
Sends packets with various length to check the server reassembles them
|
||||||
|
correctly. Also checks truncation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.irc_utils import message_parser
|
||||||
|
from irctest.numerics import ERR_INPUTTOOLONG
|
||||||
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
|
|
||||||
|
def _sendWhole(self, line):
|
||||||
|
print("(repr) 1 -> S", repr(line.encode()))
|
||||||
|
self.clients[1].conn.sendall(line.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def _sendCharPerChar(self, line):
|
||||||
|
print("(repr) 1 -> S", repr(line.encode()))
|
||||||
|
for char in line:
|
||||||
|
self.clients[1].conn.sendall(char.encode())
|
||||||
|
|
||||||
|
|
||||||
|
def _sendBytePerByte(self, line):
|
||||||
|
print("(repr) 1 -> S", repr(line.encode()))
|
||||||
|
for byte in line.encode():
|
||||||
|
self.clients[1].conn.sendall(bytes([byte]))
|
||||||
|
|
||||||
|
|
||||||
|
class BufferingTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Bahamut"],
|
||||||
|
"cannot pass because of issues with UTF-8 handling: "
|
||||||
|
"https://github.com/DALnet/bahamut/issues/196",
|
||||||
|
)
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2", "Nefarious", "snircd"],
|
||||||
|
"ircu2 discards the whole buffer on long lines "
|
||||||
|
"(TODO: refine how we exclude these tests)",
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sender_function,colon",
|
||||||
|
[
|
||||||
|
pytest.param(_sendWhole, "", id="whole-no colon"),
|
||||||
|
pytest.param(_sendCharPerChar, "", id="charperchar-no colon"),
|
||||||
|
pytest.param(_sendBytePerByte, "", id="byteperbyte-no colon"),
|
||||||
|
pytest.param(_sendWhole, ":", id="whole-colon"),
|
||||||
|
pytest.param(_sendCharPerChar, ":", id="charperchar-colon"),
|
||||||
|
pytest.param(_sendBytePerByte, ":", id="byteperbyte-colon"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def testNoTags(self, sender_function, colon):
|
||||||
|
self.connectClient("nick1")
|
||||||
|
self.connectClient("nick2")
|
||||||
|
|
||||||
|
overhead = self.get_overhead(1, 2, colon=colon)
|
||||||
|
print(f"overhead is {overhead}")
|
||||||
|
|
||||||
|
line = f"PRIVMSG nick2 {colon}"
|
||||||
|
remaining_size = 512 - len(line) - len("\r\n")
|
||||||
|
emoji_size = len("😃".encode())
|
||||||
|
payloads = [
|
||||||
|
# one byte:
|
||||||
|
"a",
|
||||||
|
# one multi-byte char:
|
||||||
|
"😃",
|
||||||
|
# full payload, will be truncated
|
||||||
|
"a" * remaining_size,
|
||||||
|
"a" * (remaining_size - emoji_size) + "😃",
|
||||||
|
# full payload to recipient:
|
||||||
|
"a" * (remaining_size - overhead),
|
||||||
|
"a" * (remaining_size - emoji_size - overhead) + "😃",
|
||||||
|
# full payload to recipient plus one byte:
|
||||||
|
"a" * (remaining_size - overhead + 1),
|
||||||
|
"a" * (remaining_size - emoji_size - overhead + 1) + "😃",
|
||||||
|
# full payload to recipient plus two bytes:
|
||||||
|
"a" * (remaining_size - emoji_size - overhead + 1) + "😃",
|
||||||
|
]
|
||||||
|
for payload in payloads:
|
||||||
|
sender_function(self, line + payload + "\r\n")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
if messages and ERR_INPUTTOOLONG in (m.command for m in messages):
|
||||||
|
# https://defs.ircdocs.horse/defs/numerics.html#err-inputtoolong-417
|
||||||
|
self.assertGreater(
|
||||||
|
len(line + payload + "\r\n"),
|
||||||
|
512 - overhead,
|
||||||
|
"Got ERR_INPUTTOOLONG for a messag that should fit "
|
||||||
|
"withing 512 characters.",
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
received_line = self._getLine(2)
|
||||||
|
print("(repr) S -> 2", repr(received_line))
|
||||||
|
try:
|
||||||
|
decoded_line = received_line.decode()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# server truncated a byte off the emoji at the end
|
||||||
|
if "UTF8ONLY" in self.server_support:
|
||||||
|
# https://github.com/ircv3/ircv3-specifications/pull/432
|
||||||
|
raise self.failureException(
|
||||||
|
f"Server advertizes UTF8ONLY, but sent an invalid UTF8 "
|
||||||
|
f"message: {received_line!r}"
|
||||||
|
)
|
||||||
|
payload_intact = False
|
||||||
|
else:
|
||||||
|
msg = message_parser.parse_message(decoded_line)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command="PRIVMSG", params=["nick2", ANYSTR]
|
||||||
|
)
|
||||||
|
payload_intact = msg.params[1] == payload
|
||||||
|
if not payload_intact:
|
||||||
|
# truncated
|
||||||
|
self.assertLessEqual(len(received_line), 512, received_line)
|
||||||
|
if received_line.endswith(b"[CUT]\r\n"):
|
||||||
|
# ngircd
|
||||||
|
received_line = received_line[0:-7] + b"\r\n"
|
||||||
|
self.assertTrue(
|
||||||
|
payload.encode().startswith(
|
||||||
|
received_line.split(b" ")[-1].strip().lstrip(b":")
|
||||||
|
),
|
||||||
|
f"expected payload to be a prefix of {payload!r}, "
|
||||||
|
f"but got {payload!r}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_overhead(self, client1, client2, colon):
|
||||||
|
self.sendLine(client1, f"PRIVMSG nick2 {colon}a\r\n")
|
||||||
|
line = self._getLine(client2)
|
||||||
|
return len(line) - len(f"PRIVMSG nick2 {colon}a\r\n")
|
||||||
|
|
||||||
|
def _getLine(self, client) -> bytes:
|
||||||
|
line = b""
|
||||||
|
for _ in range(30):
|
||||||
|
try:
|
||||||
|
data = self.clients[client].conn.recv(4096)
|
||||||
|
except socket.timeout:
|
||||||
|
data = b""
|
||||||
|
line += data
|
||||||
|
if data.endswith(b"\r\n"):
|
||||||
|
return line
|
||||||
|
time.sleep(0.1)
|
||||||
|
print(f"{client}: Waiting...")
|
||||||
|
return line
|
491
irctest/server_tests/cap.py
Normal file
491
irctest/server_tests/cap.py
Normal file
@ -0,0 +1,491 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 Capability negotiation
|
||||||
|
<https://ircv3.net/specs/extensions/capability-negotiation>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
from irctest.runner import CapabilityNotSupported, ImplementationChoice
|
||||||
|
|
||||||
|
|
||||||
|
class CapTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testInvalidCapSubcommand(self):
|
||||||
|
"""“If no capabilities are active, an empty parameter must be sent.”
|
||||||
|
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-list-subcommand>
|
||||||
|
""" # noqa
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "CAP NOTACOMMAND")
|
||||||
|
self.sendLine(1, "PING test123")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertTrue(
|
||||||
|
self.messageDiffers(m, command="PONG", params=[ANYSTR, "test123"]),
|
||||||
|
"Sending “CAP NOTACOMMAND” as first message got no reply",
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="410",
|
||||||
|
params=["*", "NOTACOMMAND", ANYSTR],
|
||||||
|
fail_msg="Sending “CAP NOTACOMMAND” as first message got a reply "
|
||||||
|
"that is not ERR_INVALIDCAPCMD: {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testNoReq(self):
|
||||||
|
"""Test the server handles gracefully clients which do not send
|
||||||
|
REQs.
|
||||||
|
|
||||||
|
“Clients that support capabilities but do not wish to enter
|
||||||
|
negotiation SHOULD send CAP END upon connection to the server.”
|
||||||
|
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-end-subcommand>
|
||||||
|
""" # noqa
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS 302")
|
||||||
|
self.getCapLs(1)
|
||||||
|
self.sendLine(1, "USER foo foo foo :foo")
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
|
||||||
|
# Make sure the server didn't send anything yet
|
||||||
|
self.sendLine(1, "CAP LS 302")
|
||||||
|
self.getCapLs(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Sable"],
|
||||||
|
"does not support multi-prefix",
|
||||||
|
)
|
||||||
|
def testReqOne(self):
|
||||||
|
"""Tests requesting a single capability"""
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS")
|
||||||
|
self.getCapLs(1)
|
||||||
|
self.sendLine(1, "USER foo foo foo :foo")
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "CAP REQ :multi-prefix")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
|
||||||
|
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "LIST", StrRe("multi-prefix ?")],
|
||||||
|
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ngIRCd", "Sable"],
|
||||||
|
"does not support userhost-in-names",
|
||||||
|
)
|
||||||
|
def testReqTwo(self):
|
||||||
|
"""Tests requesting two capabilities at once"""
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS")
|
||||||
|
self.getCapLs(1)
|
||||||
|
self.sendLine(1, "USER foo foo foo :foo")
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "CAP REQ :multi-prefix userhost-in-names")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "ACK", StrRe("multi-prefix userhost-in-names ?")],
|
||||||
|
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[
|
||||||
|
ANYSTR,
|
||||||
|
"LIST",
|
||||||
|
StrRe(
|
||||||
|
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ngIRCd", "Sable"],
|
||||||
|
"does not support userhost-in-names",
|
||||||
|
)
|
||||||
|
def testReqOneThenOne(self):
|
||||||
|
"""Tests requesting two capabilities in different messages"""
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS")
|
||||||
|
self.getCapLs(1)
|
||||||
|
self.sendLine(1, "USER foo foo foo :foo")
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP REQ :multi-prefix")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
|
||||||
|
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP REQ :userhost-in-names")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")],
|
||||||
|
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[
|
||||||
|
ANYSTR,
|
||||||
|
"LIST",
|
||||||
|
StrRe(
|
||||||
|
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ngIRCd", "Sable"],
|
||||||
|
"does not support userhost-in-names",
|
||||||
|
)
|
||||||
|
def testReqPostRegistration(self):
|
||||||
|
"""Tests requesting more capabilities after CAP END"""
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS")
|
||||||
|
self.getCapLs(1)
|
||||||
|
self.sendLine(1, "USER foo foo foo :foo")
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP REQ :multi-prefix")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
|
||||||
|
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "LIST", StrRe("multi-prefix ?")],
|
||||||
|
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP REQ :userhost-in-names")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")],
|
||||||
|
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[
|
||||||
|
ANYSTR,
|
||||||
|
"LIST",
|
||||||
|
StrRe(
|
||||||
|
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testReqUnavailable(self):
|
||||||
|
"""Test the server handles gracefully clients which request
|
||||||
|
capabilities that are not available.
|
||||||
|
<http://ircv3.net/specs/core/capability-negotiation-3.1.html>
|
||||||
|
"""
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS 302")
|
||||||
|
self.getCapLs(1)
|
||||||
|
self.sendLine(1, "USER foo foo foo :foo")
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "CAP REQ :foo")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "NAK", StrRe("foo ?")],
|
||||||
|
fail_msg="Expected CAP NAK after requesting non-existing "
|
||||||
|
"capability, got {msg}.",
|
||||||
|
)
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testNakExactString(self):
|
||||||
|
"""“The argument of the NAK subcommand MUST consist of at least the
|
||||||
|
first 100 characters of the capability list in the REQ subcommand which
|
||||||
|
triggered the NAK.”
|
||||||
|
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-nak-subcommand>
|
||||||
|
""" # noqa
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS 302")
|
||||||
|
self.getCapLs(1)
|
||||||
|
# Five should be enough to check there is no reordering, even
|
||||||
|
# alphabetical
|
||||||
|
self.sendLine(1, "CAP REQ :foo qux bar baz qux quux")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "NAK", "foo qux bar baz qux quux"],
|
||||||
|
fail_msg="Expected “CAP NAK :foo qux bar baz qux quux” after "
|
||||||
|
"sending “CAP REQ :foo qux bar baz qux quux”, but got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testNakWhole(self):
|
||||||
|
"""“The capability identifier set must be accepted as a whole, or
|
||||||
|
rejected entirely.”
|
||||||
|
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-req-subcommand>
|
||||||
|
""" # noqa
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS 302")
|
||||||
|
if "multi-prefix" not in self.getCapLs(1):
|
||||||
|
raise CapabilityNotSupported("multi-prefix")
|
||||||
|
self.sendLine(1, "CAP REQ :foo multi-prefix bar")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "NAK", "foo multi-prefix bar"],
|
||||||
|
fail_msg="Expected “CAP NAK :foo multi-prefix bar” after "
|
||||||
|
"sending “CAP REQ :foo multi-prefix bar”, but got {msg}.",
|
||||||
|
)
|
||||||
|
self.sendLine(1, "CAP REQ :multi-prefix bar")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "NAK", "multi-prefix bar"],
|
||||||
|
fail_msg="Expected “CAP NAK :multi-prefix bar” after "
|
||||||
|
"sending “CAP REQ :multi-prefix bar”, but got {msg}.",
|
||||||
|
)
|
||||||
|
self.sendLine(1, "CAP REQ :foo multi-prefix")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "NAK", "foo multi-prefix"],
|
||||||
|
fail_msg="Expected “CAP NAK :foo multi-prefix” after "
|
||||||
|
"sending “CAP REQ :foo multi-prefix”, but got {msg}.",
|
||||||
|
)
|
||||||
|
# TODO: make sure multi-prefix is not enabled at this point
|
||||||
|
self.sendLine(1, "CAP REQ :multi-prefix")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
|
||||||
|
fail_msg="Expected “CAP ACK :multi-prefix” after "
|
||||||
|
"sending “CAP REQ :multi-prefix”, but got {msg}.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testCapRemovalByClient(self):
|
||||||
|
"""Test CAP LIST and removal of caps via CAP REQ :-tagname."""
|
||||||
|
cap1 = "echo-message"
|
||||||
|
cap2 = "server-time"
|
||||||
|
self.addClient(1)
|
||||||
|
self.connectClient("sender")
|
||||||
|
self.sendLine(1, "CAP LS 302")
|
||||||
|
caps = set()
|
||||||
|
while True:
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
caps.update(m.params[-1].split())
|
||||||
|
if m.params[2] != "*":
|
||||||
|
break
|
||||||
|
if not ({cap1, cap2} <= caps):
|
||||||
|
raise CapabilityNotSupported(f"{cap1} or {cap2}")
|
||||||
|
self.sendLine(1, f"CAP REQ :{cap1} {cap2}")
|
||||||
|
self.sendLine(1, "nick bar")
|
||||||
|
self.sendLine(1, "user user 0 * realname")
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(m, command="CAP", params=[ANYSTR, "ACK", ANYSTR])
|
||||||
|
self.assertEqual(
|
||||||
|
set(m.params[2].split()), {cap1, cap2}, "Didn't ACK both REQed caps"
|
||||||
|
)
|
||||||
|
self.skipToWelcome(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
cap_list = [m for m in messages if m.command == "CAP"][0]
|
||||||
|
enabled_caps = set(cap_list.params[2].split())
|
||||||
|
enabled_caps.discard("cap-notify") # implicitly added by some impls
|
||||||
|
self.assertEqual(enabled_caps, {cap1, cap2})
|
||||||
|
|
||||||
|
self.sendLine(2, "PRIVMSG bar :hi")
|
||||||
|
self.getMessages(2) # Synchronize
|
||||||
|
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertIn("time", m.tags, m)
|
||||||
|
|
||||||
|
# remove the multi-prefix cap
|
||||||
|
self.sendLine(1, f"CAP REQ :-{cap2}")
|
||||||
|
m = self.getMessage(1)
|
||||||
|
# Must be either ACK or NAK
|
||||||
|
if self.messageDiffers(
|
||||||
|
m, command="CAP", params=[ANYSTR, "ACK", StrRe(f"-{cap2} ?")]
|
||||||
|
):
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="CAP", params=[ANYSTR, "NAK", StrRe(f"-{cap2} ?")]
|
||||||
|
)
|
||||||
|
raise ImplementationChoice(f"Does not support CAP REQ -{cap2}")
|
||||||
|
|
||||||
|
# multi-prefix should be disabled
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
cap_list = [m for m in messages if m.command == "CAP"][0]
|
||||||
|
enabled_caps = set(cap_list.params[2].split())
|
||||||
|
enabled_caps.discard("cap-notify") # implicitly added by some impls
|
||||||
|
self.assertEqual(enabled_caps, {cap1})
|
||||||
|
self.assertNotIn("time", cap_list.tags)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testIrc301CapLs(self):
|
||||||
|
"""
|
||||||
|
Current version:
|
||||||
|
|
||||||
|
"The LS subcommand is used to list the capabilities supported by the server.
|
||||||
|
The client should send an LS subcommand with no other arguments to solicit
|
||||||
|
a list of all capabilities."
|
||||||
|
|
||||||
|
"If a client has not indicated support for CAP LS 302 features,
|
||||||
|
the server MUST NOT send these new features to the client."
|
||||||
|
-- <https://ircv3.net/specs/core/capability-negotiation.html>
|
||||||
|
|
||||||
|
Before the v3.1 / v3.2 merge:
|
||||||
|
|
||||||
|
IRCv3.1: “The LS subcommand is used to list the capabilities
|
||||||
|
supported by the server. The client should send an LS subcommand with
|
||||||
|
no other arguments to solicit a list of all capabilities.”
|
||||||
|
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-ls-subcommand>
|
||||||
|
|
||||||
|
IRCv3.2: “Servers MUST NOT send messages described by this document if
|
||||||
|
the client only supports version 3.1.”
|
||||||
|
-- <http://ircv3.net/specs/core/capability-negotiation-3.2.html#version-in-cap-ls>
|
||||||
|
""" # noqa
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "CAP LS")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertNotEqual(
|
||||||
|
m.params[2],
|
||||||
|
"*",
|
||||||
|
m,
|
||||||
|
fail_msg="Server replied with multi-line CAP LS to a "
|
||||||
|
"“CAP LS” (ie. IRCv3.1) request: {msg}",
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
any("=" in cap for cap in m.params[2].split()),
|
||||||
|
"Server replied with a name-value capability in "
|
||||||
|
"CAP LS reply as a response to “CAP LS” (ie. IRCv3.1) "
|
||||||
|
"request: {}".format(m),
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testEmptyCapList(self):
|
||||||
|
"""“If no capabilities are active, an empty parameter must be sent.”
|
||||||
|
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-list-subcommand>
|
||||||
|
""" # noqa
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "CAP LIST")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="CAP",
|
||||||
|
params=["*", "LIST", ""],
|
||||||
|
fail_msg="Sending “CAP LIST” as first message got a reply "
|
||||||
|
"that is not “CAP * LIST :”: {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testNoMultiline301Response(self):
|
||||||
|
"""
|
||||||
|
Current version: "If the client supports CAP version 302, the server MAY send
|
||||||
|
multiple lines in response to CAP LS and CAP LIST." This should be read as
|
||||||
|
disallowing multiline responses to pre-302 clients.
|
||||||
|
-- <https://ircv3.net/specs/extensions/capability-negotiation#multiline-replies-to-cap-ls-and-cap-list>
|
||||||
|
""" # noqa
|
||||||
|
self.check301ResponsePreRegistration("bar", "CAP LS")
|
||||||
|
self.check301ResponsePreRegistration("qux", "CAP LS 301")
|
||||||
|
self.check301ResponsePostRegistration("baz", "CAP LS")
|
||||||
|
self.check301ResponsePostRegistration("bat", "CAP LS 301")
|
||||||
|
|
||||||
|
def check301ResponsePreRegistration(self, nick, cap_ls):
|
||||||
|
self.addClient(nick)
|
||||||
|
self.sendLine(nick, cap_ls)
|
||||||
|
self.sendLine(nick, "NICK " + nick)
|
||||||
|
self.sendLine(nick, "USER u s e r")
|
||||||
|
self.sendLine(nick, "CAP END")
|
||||||
|
responses = [msg for msg in self.skipToWelcome(nick) if msg.command == "CAP"]
|
||||||
|
self.assertLessEqual(len(responses), 1, responses)
|
||||||
|
|
||||||
|
def check301ResponsePostRegistration(self, nick, cap_ls):
|
||||||
|
self.connectClient(nick, name=nick)
|
||||||
|
self.sendLine(nick, cap_ls)
|
||||||
|
responses = [msg for msg in self.getMessages(nick) if msg.command == "CAP"]
|
||||||
|
self.assertLessEqual(len(responses), 1, responses)
|
65
irctest/server_tests/channel.py
Normal file
65
irctest/server_tests/channel.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Channel casemapping
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases, client_mock, runner
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase):
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"casemapping,name1,name2",
|
||||||
|
[
|
||||||
|
("ascii", "#Foo", "#foo"),
|
||||||
|
("rfc1459", "#Foo", "#foo"),
|
||||||
|
("rfc1459", "#F]|oo{", "#f}\\oo["),
|
||||||
|
("rfc1459", "#F}o\\o[", "#f]o|o{"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
|
||||||
|
def testChannelsEquivalent(self, casemapping, name1, name2):
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.connectClient("bar")
|
||||||
|
if self.server_support["CASEMAPPING"] != casemapping:
|
||||||
|
raise runner.ImplementationChoice(
|
||||||
|
"Casemapping {} not implemented".format(casemapping)
|
||||||
|
)
|
||||||
|
self.joinClient(1, name1)
|
||||||
|
self.joinClient(2, name2)
|
||||||
|
try:
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(m, command="JOIN", nick="bar")
|
||||||
|
except client_mock.NoMessageException:
|
||||||
|
raise AssertionError(
|
||||||
|
"Channel names {} and {} are not equivalent.".format(name1, name2)
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"casemapping,name1,name2",
|
||||||
|
[
|
||||||
|
("ascii", "#Foo", "#fooa"),
|
||||||
|
("rfc1459", "#Foo", "#fooa"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
|
||||||
|
def testChannelsNotEquivalent(self, casemapping, name1, name2):
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.connectClient("bar")
|
||||||
|
if self.server_support["CASEMAPPING"] != casemapping:
|
||||||
|
raise runner.ImplementationChoice(
|
||||||
|
"Casemapping {} not implemented".format(casemapping)
|
||||||
|
)
|
||||||
|
self.joinClient(1, name1)
|
||||||
|
self.joinClient(2, name2)
|
||||||
|
try:
|
||||||
|
m = self.getMessage(1)
|
||||||
|
except client_mock.NoMessageException:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m, command="JOIN", nick="bar"
|
||||||
|
) # This should always be true
|
||||||
|
raise AssertionError(
|
||||||
|
"Channel names {} and {} are equivalent.".format(name1, name2)
|
||||||
|
)
|
58
irctest/server_tests/channel_forward.py
Normal file
58
irctest/server_tests/channel_forward.py
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`_-specific tests of channel forwarding
|
||||||
|
|
||||||
|
TODO: Should be extended to other servers, once a specification is written.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL
|
||||||
|
|
||||||
|
MODERN_CAPS = [
|
||||||
|
"server-time",
|
||||||
|
"message-tags",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"echo-message",
|
||||||
|
"account-tag",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelForwardingTestCase(cases.BaseServerTestCase):
|
||||||
|
"""Test the +f channel forwarding mode."""
|
||||||
|
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testChannelForwarding(self):
|
||||||
|
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS)
|
||||||
|
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("bar", "#bar")
|
||||||
|
self.joinChannel("bar", "#bar_two")
|
||||||
|
self.joinChannel("baz", "#baz")
|
||||||
|
|
||||||
|
self.sendLine("bar", "MODE #bar +f #nonexistent")
|
||||||
|
msg = self.getMessage("bar")
|
||||||
|
self.assertMessageMatch(msg, command=ERR_INVALIDMODEPARAM)
|
||||||
|
|
||||||
|
# need chanops in the target channel as well
|
||||||
|
self.sendLine("bar", "MODE #bar +f #baz")
|
||||||
|
responses = set(msg.command for msg in self.getMessages("bar"))
|
||||||
|
self.assertIn(ERR_CHANOPRIVSNEEDED, responses)
|
||||||
|
|
||||||
|
self.sendLine("bar", "MODE #bar +f #bar_two")
|
||||||
|
msg = self.getMessage("bar")
|
||||||
|
self.assertMessageMatch(msg, command="MODE", params=["#bar", "+f", "#bar_two"])
|
||||||
|
|
||||||
|
# can still join the channel fine
|
||||||
|
self.joinChannel("baz", "#bar")
|
||||||
|
self.sendLine("baz", "PART #bar")
|
||||||
|
self.getMessages("baz")
|
||||||
|
|
||||||
|
# now make it invite-only, which should cause forwarding
|
||||||
|
self.sendLine("bar", "MODE #bar +i")
|
||||||
|
self.getMessages("bar")
|
||||||
|
|
||||||
|
self.sendLine("baz", "JOIN #bar")
|
||||||
|
msgs = self.getMessages("baz")
|
||||||
|
forward = [msg for msg in msgs if msg.command == ERR_LINKCHANNEL]
|
||||||
|
self.assertEqual(forward[0].params[:3], ["baz", "#bar", "#bar_two"])
|
||||||
|
join = [msg for msg in msgs if msg.command == "JOIN"]
|
||||||
|
self.assertMessageMatch(join[0], params=["#bar_two"])
|
57
irctest/server_tests/channel_rename.py
Normal file
57
irctest/server_tests/channel_rename.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""
|
||||||
|
`Draft IRCv3 channel-rename <https://ircv3.net/specs/extensions/channel-rename>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_CHANOPRIVSNEEDED
|
||||||
|
|
||||||
|
RENAME_CAP = "draft/channel-rename"
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
class ChannelRenameTestCase(cases.BaseServerTestCase):
|
||||||
|
"""Basic tests for channel-rename."""
|
||||||
|
|
||||||
|
def testChannelRename(self):
|
||||||
|
self.connectClient(
|
||||||
|
"bar", name="bar", capabilities=[RENAME_CAP], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
self.connectClient("baz", name="baz")
|
||||||
|
self.joinChannel("bar", "#bar")
|
||||||
|
self.joinChannel("baz", "#bar")
|
||||||
|
self.getMessages("bar")
|
||||||
|
self.getMessages("baz")
|
||||||
|
|
||||||
|
self.sendLine("bar", "RENAME #bar #qux :no reason")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("bar"),
|
||||||
|
command="RENAME",
|
||||||
|
params=["#bar", "#qux", "no reason"],
|
||||||
|
)
|
||||||
|
legacy_responses = self.getMessages("baz")
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
len(
|
||||||
|
[
|
||||||
|
msg
|
||||||
|
for msg in legacy_responses
|
||||||
|
if msg.command == "PART" and msg.params[0] == "#bar"
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
1,
|
||||||
|
len(
|
||||||
|
[
|
||||||
|
msg
|
||||||
|
for msg in legacy_responses
|
||||||
|
if msg.command == "JOIN" and msg.params == ["#qux"]
|
||||||
|
]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.joinChannel("baz", "#bar")
|
||||||
|
self.sendLine("baz", "MODE #bar +k beer")
|
||||||
|
self.assertNotIn(
|
||||||
|
ERR_CHANOPRIVSNEEDED, [msg.command for msg in self.getMessages("baz")]
|
||||||
|
)
|
788
irctest/server_tests/chathistory.py
Normal file
788
irctest/server_tests/chathistory.py
Normal file
@ -0,0 +1,788 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 draft chathistory <https://ircv3.net/specs/extensions/chathistory>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.irc_utils.junkdrawer import random_name
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
CHATHISTORY_CAP = "draft/chathistory"
|
||||||
|
EVENT_PLAYBACK_CAP = "draft/event-playback"
|
||||||
|
|
||||||
|
# Keep this in sync with validate_chathistory()
|
||||||
|
SUBCOMMANDS = ["LATEST", "BEFORE", "AFTER", "BETWEEN", "AROUND"]
|
||||||
|
|
||||||
|
MYSQL_PASSWORD = ""
|
||||||
|
|
||||||
|
|
||||||
|
def skip_ngircd(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
def newf(self, *args, **kwargs):
|
||||||
|
if self.controller.software_name == "ngIRCd":
|
||||||
|
raise runner.OptionalExtensionNotSupported("nicks longer 9 characters")
|
||||||
|
return f(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return newf
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_services
|
||||||
|
class ChathistoryTestCase(cases.BaseServerTestCase):
|
||||||
|
def validate_chathistory_batch(self, msgs, target):
|
||||||
|
(start, *inner_msgs, end) = msgs
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", target]
|
||||||
|
)
|
||||||
|
batch_tag = start.params[0][1:]
|
||||||
|
self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag])
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for msg in inner_msgs:
|
||||||
|
if (
|
||||||
|
msg.command in ("PRIVMSG", "TOPIC")
|
||||||
|
and batch_tag is not None
|
||||||
|
and msg.tags.get("batch") == batch_tag
|
||||||
|
):
|
||||||
|
if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific
|
||||||
|
result.append(msg.to_history_message())
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def config() -> cases.TestCaseControllerConfig:
|
||||||
|
return cases.TestCaseControllerConfig(chathistory=True)
|
||||||
|
|
||||||
|
@skip_ngircd
|
||||||
|
def testInvalidTargets(self):
|
||||||
|
bar, pw = random_name("bar"), random_name("pw")
|
||||||
|
self.controller.registerUser(self, bar, pw)
|
||||||
|
self.connectClient(
|
||||||
|
bar,
|
||||||
|
name=bar,
|
||||||
|
capabilities=[
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password=pw,
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.getMessages(bar)
|
||||||
|
|
||||||
|
qux = random_name("qux")
|
||||||
|
real_chname = random_name("#real_channel")
|
||||||
|
self.connectClient(qux, name=qux)
|
||||||
|
self.joinChannel(qux, real_chname)
|
||||||
|
self.getMessages(qux)
|
||||||
|
|
||||||
|
# test a nonexistent channel
|
||||||
|
self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10")
|
||||||
|
msgs = self.getMessages(bar)
|
||||||
|
msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msgs[0],
|
||||||
|
command="FAIL",
|
||||||
|
params=["CHATHISTORY", "INVALID_TARGET", "LATEST", ANYSTR, ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
# as should a real channel to which one is not joined:
|
||||||
|
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,))
|
||||||
|
msgs = self.getMessages(bar)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msgs[0],
|
||||||
|
command="FAIL",
|
||||||
|
params=["CHATHISTORY", "INVALID_TARGET", "LATEST", ANYSTR, ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.private_chathistory
|
||||||
|
@skip_ngircd
|
||||||
|
def testMessagesToSelf(self):
|
||||||
|
bar, pw = random_name("bar"), random_name("pw")
|
||||||
|
self.controller.registerUser(self, bar, pw)
|
||||||
|
self.connectClient(
|
||||||
|
bar,
|
||||||
|
name=bar,
|
||||||
|
capabilities=[
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"message-tags",
|
||||||
|
"sasl",
|
||||||
|
"server-time",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password=pw,
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.getMessages(bar)
|
||||||
|
|
||||||
|
messages = []
|
||||||
|
|
||||||
|
self.sendLine(bar, "PRIVMSG %s :this is a privmsg sent to myself" % (bar,))
|
||||||
|
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
|
||||||
|
self.assertEqual(len(replies), 1)
|
||||||
|
msg = replies[0]
|
||||||
|
self.assertMessageMatch(msg, params=[bar, "this is a privmsg sent to myself"])
|
||||||
|
messages.append(msg.to_history_message())
|
||||||
|
|
||||||
|
self.sendLine(bar, "CAP REQ echo-message")
|
||||||
|
self.getMessages(bar)
|
||||||
|
self.sendLine(
|
||||||
|
bar, "PRIVMSG %s :this is a second privmsg sent to myself" % (bar,)
|
||||||
|
)
|
||||||
|
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
|
||||||
|
# two messages, the echo and the delivery
|
||||||
|
self.assertEqual(len(replies), 2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
replies[0], params=[bar, "this is a second privmsg sent to myself"]
|
||||||
|
)
|
||||||
|
messages.append(replies[0].to_history_message())
|
||||||
|
# messages should be otherwise identical
|
||||||
|
self.assertEqual(
|
||||||
|
replies[0].to_history_message(), replies[1].to_history_message()
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
bar,
|
||||||
|
"@label=xyz PRIVMSG %s :this is a third privmsg sent to myself" % (bar,),
|
||||||
|
)
|
||||||
|
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
|
||||||
|
self.assertEqual(len(replies), 2)
|
||||||
|
# exactly one of the replies MUST be labeled
|
||||||
|
echo = [msg for msg in replies if msg.tags.get("label") == "xyz"][0]
|
||||||
|
delivery = [msg for msg in replies if msg.tags.get("label") is None][0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
echo, params=[bar, "this is a third privmsg sent to myself"]
|
||||||
|
)
|
||||||
|
messages.append(echo.to_history_message())
|
||||||
|
self.assertEqual(echo.to_history_message(), delivery.to_history_message())
|
||||||
|
|
||||||
|
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,))
|
||||||
|
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
|
||||||
|
self.assertEqual([msg.to_history_message() for msg in replies], messages)
|
||||||
|
|
||||||
|
def validate_echo_messages(self, num_messages, echo_messages):
|
||||||
|
# sanity checks: should have received the correct number of echo messages,
|
||||||
|
# all with distinct time tags (because we slept) and msgids
|
||||||
|
self.assertEqual(len(echo_messages), num_messages)
|
||||||
|
self.assertEqual(len(set(msg.msgid for msg in echo_messages)), num_messages)
|
||||||
|
self.assertEqual(len(set(msg.time for msg in echo_messages)), num_messages)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
|
||||||
|
@skip_ngircd
|
||||||
|
def testChathistory(self, subcommand):
|
||||||
|
if subcommand == "BETWEEN" and self.controller.software_name == "UnrealIRCd":
|
||||||
|
pytest.xfail(
|
||||||
|
"CHATHISTORY BETWEEN does not apply bounds correct "
|
||||||
|
"https://bugs.unrealircd.org/view.php?id=5952"
|
||||||
|
)
|
||||||
|
if subcommand == "AROUND" and self.controller.software_name == "UnrealIRCd":
|
||||||
|
pytest.xfail(
|
||||||
|
"CHATHISTORY AROUND excludes 'central' messages "
|
||||||
|
"https://bugs.unrealircd.org/view.php?id=5953"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"bar",
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
chname = "#chan" + secrets.token_hex(12)
|
||||||
|
self.joinChannel(1, chname)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
NUM_MESSAGES = 10
|
||||||
|
echo_messages = []
|
||||||
|
for i in range(NUM_MESSAGES):
|
||||||
|
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
|
||||||
|
echo_messages.extend(
|
||||||
|
msg.to_history_message() for msg in self.getMessages(1)
|
||||||
|
)
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
||||||
|
self.validate_chathistory(subcommand, echo_messages, 1, chname)
|
||||||
|
|
||||||
|
@skip_ngircd
|
||||||
|
def testChathistoryNoEventPlayback(self):
|
||||||
|
"""Tests that non-messages don't appear in the chat history when event-playback
|
||||||
|
is not enabled."""
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"bar",
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
chname = "#chan" + secrets.token_hex(12)
|
||||||
|
self.joinChannel(1, chname)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
NUM_MESSAGES = 10
|
||||||
|
echo_messages = []
|
||||||
|
for i in range(NUM_MESSAGES):
|
||||||
|
self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i))
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
|
||||||
|
echo_messages.extend(
|
||||||
|
msg.to_history_message() for msg in self.getMessages(1)
|
||||||
|
)
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
||||||
|
self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname)
|
||||||
|
(batch_open, *messages, batch_close) = self.getMessages(1)
|
||||||
|
self.assertMessageMatch(batch_open, command="BATCH")
|
||||||
|
self.assertMessageMatch(batch_close, command="BATCH")
|
||||||
|
self.assertEqual([msg for msg in messages if msg.command != "PRIVMSG"], [])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
|
||||||
|
@skip_ngircd
|
||||||
|
def testChathistoryEventPlayback(self, subcommand):
|
||||||
|
self.connectClient(
|
||||||
|
"bar",
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
EVENT_PLAYBACK_CAP,
|
||||||
|
],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
chname = "#chan" + secrets.token_hex(12)
|
||||||
|
self.joinChannel(1, chname)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
NUM_MESSAGES = 10
|
||||||
|
echo_messages = []
|
||||||
|
for i in range(NUM_MESSAGES):
|
||||||
|
self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i))
|
||||||
|
echo_messages.extend(
|
||||||
|
msg.to_history_message() for msg in self.getMessages(1)
|
||||||
|
)
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
|
||||||
|
echo_messages.extend(
|
||||||
|
msg.to_history_message() for msg in self.getMessages(1)
|
||||||
|
)
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages)
|
||||||
|
self.validate_chathistory(subcommand, echo_messages, 1, chname)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
|
||||||
|
@pytest.mark.private_chathistory
|
||||||
|
@skip_ngircd
|
||||||
|
def testChathistoryDMs(self, subcommand):
|
||||||
|
c1 = random_name("foo")
|
||||||
|
c2 = random_name("bar")
|
||||||
|
self.controller.registerUser(self, c1, "sesame1")
|
||||||
|
self.controller.registerUser(self, c2, "sesame2")
|
||||||
|
self.connectClient(
|
||||||
|
c1,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password="sesame1",
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.connectClient(
|
||||||
|
c2,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password="sesame2",
|
||||||
|
)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
NUM_MESSAGES = 10
|
||||||
|
echo_messages = []
|
||||||
|
for i in range(NUM_MESSAGES):
|
||||||
|
user = (i % 2) + 1
|
||||||
|
if user == 1:
|
||||||
|
target = c2
|
||||||
|
else:
|
||||||
|
target = c1
|
||||||
|
self.getMessages(user)
|
||||||
|
self.sendLine(user, "PRIVMSG %s :this is message %d" % (target, i))
|
||||||
|
echo_messages.extend(
|
||||||
|
msg.to_history_message() for msg in self.getMessages(user)
|
||||||
|
)
|
||||||
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
||||||
|
self.validate_chathistory(subcommand, echo_messages, 1, c2)
|
||||||
|
self.validate_chathistory(subcommand, echo_messages, 2, c1)
|
||||||
|
|
||||||
|
c3 = random_name("baz")
|
||||||
|
self.connectClient(
|
||||||
|
c3,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.sendLine(
|
||||||
|
1, "PRIVMSG %s :this is a message in a separate conversation" % (c3,)
|
||||||
|
)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(
|
||||||
|
3, "PRIVMSG %s :i agree that this is a separate conversation" % (c1,)
|
||||||
|
)
|
||||||
|
# 3 received the first message as a delivery and the second as an echo
|
||||||
|
new_convo = [
|
||||||
|
msg.to_history_message()
|
||||||
|
for msg in self.getMessages(3)
|
||||||
|
if msg.command == "PRIVMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(
|
||||||
|
[msg.text for msg in new_convo],
|
||||||
|
[
|
||||||
|
"this is a message in a separate conversation",
|
||||||
|
"i agree that this is a separate conversation",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
# messages should be stored and retrievable by c1,
|
||||||
|
# even though c3 is not registered
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (c3,))
|
||||||
|
results = [
|
||||||
|
msg.to_history_message()
|
||||||
|
for msg in self.getMessages(1)
|
||||||
|
if msg.command == "PRIVMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(results, new_convo)
|
||||||
|
|
||||||
|
# additional messages with c3 should not show up in the c1-c2 history:
|
||||||
|
self.validate_chathistory(subcommand, echo_messages, 1, c2)
|
||||||
|
self.validate_chathistory(subcommand, echo_messages, 2, c1)
|
||||||
|
self.validate_chathistory(subcommand, echo_messages, 2, c1.upper())
|
||||||
|
|
||||||
|
# regression test for #833
|
||||||
|
self.sendLine(3, "QUIT")
|
||||||
|
self.assertDisconnected(3)
|
||||||
|
# register c3 as an account, then attempt to retrieve
|
||||||
|
# the conversation history with c1
|
||||||
|
self.controller.registerUser(self, c3, "sesame3")
|
||||||
|
self.connectClient(
|
||||||
|
c3,
|
||||||
|
name=c3,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password="sesame3",
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.getMessages(c3)
|
||||||
|
self.sendLine(c3, "CHATHISTORY LATEST %s * 10" % (c1,))
|
||||||
|
results = [
|
||||||
|
msg.to_history_message()
|
||||||
|
for msg in self.getMessages(c3)
|
||||||
|
if msg.command == "PRIVMSG"
|
||||||
|
]
|
||||||
|
# should get nothing
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
|
||||||
|
def validate_chathistory(self, subcommand, echo_messages, user, chname):
|
||||||
|
# Keep this list of subcommands in sync with the SUBCOMMANDS global
|
||||||
|
method = getattr(self, f"_validate_chathistory_{subcommand}")
|
||||||
|
method(echo_messages, user, chname)
|
||||||
|
|
||||||
|
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
|
||||||
|
INCLUSIVE_LIMIT = len(echo_messages) * 2
|
||||||
|
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages, result)
|
||||||
|
|
||||||
|
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[-5:], result)
|
||||||
|
|
||||||
|
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[-1:], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY LATEST %s msgid=%s %d"
|
||||||
|
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[5:], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY LATEST %s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[5:], result)
|
||||||
|
|
||||||
|
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
|
||||||
|
INCLUSIVE_LIMIT = len(echo_messages) * 2
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BEFORE %s msgid=%s %d"
|
||||||
|
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[:6], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BEFORE %s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[:6], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BEFORE %s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[6].time, 2),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[4:6], result)
|
||||||
|
|
||||||
|
def _validate_chathistory_AFTER(self, echo_messages, user, chname):
|
||||||
|
INCLUSIVE_LIMIT = len(echo_messages) * 2
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY AFTER %s msgid=%s %d"
|
||||||
|
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[4:], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY AFTER %s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[4:], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[4:7], result)
|
||||||
|
|
||||||
|
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
|
||||||
|
INCLUSIVE_LIMIT = len(echo_messages) * 2
|
||||||
|
# BETWEEN forwards and backwards
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
||||||
|
% (
|
||||||
|
chname,
|
||||||
|
echo_messages[0].msgid,
|
||||||
|
echo_messages[-1].msgid,
|
||||||
|
INCLUSIVE_LIMIT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
||||||
|
% (
|
||||||
|
chname,
|
||||||
|
echo_messages[-1].msgid,
|
||||||
|
echo_messages[0].msgid,
|
||||||
|
INCLUSIVE_LIMIT,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
|
|
||||||
|
# BETWEEN forwards and backwards with a limit, should get
|
||||||
|
# different results this time
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
||||||
|
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[1:4], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
||||||
|
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[-4:-1], result)
|
||||||
|
|
||||||
|
# same stuff again but with timestamps
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[-1].time, echo_messages[0].time, INCLUSIVE_LIMIT),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[0].time, echo_messages[-1].time, 3),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[1:4], result)
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[-1].time, echo_messages[0].time, 3),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[-4:-1], result)
|
||||||
|
|
||||||
|
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual([echo_messages[7]], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertEqual(echo_messages[6:9], result)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
user,
|
||||||
|
"CHATHISTORY AROUND %s timestamp=%s %d"
|
||||||
|
% (chname, echo_messages[7].time, 3),
|
||||||
|
)
|
||||||
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
|
self.assertIn(echo_messages[7], result)
|
||||||
|
|
||||||
|
@pytest.mark.arbitrary_client_tags
|
||||||
|
@skip_ngircd
|
||||||
|
def testChathistoryTagmsg(self):
|
||||||
|
c1 = random_name("foo")
|
||||||
|
c2 = random_name("bar")
|
||||||
|
chname = "#chan" + secrets.token_hex(12)
|
||||||
|
self.controller.registerUser(self, c1, "sesame1")
|
||||||
|
self.controller.registerUser(self, c2, "sesame2")
|
||||||
|
self.connectClient(
|
||||||
|
c1,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
EVENT_PLAYBACK_CAP,
|
||||||
|
],
|
||||||
|
password="sesame1",
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.connectClient(
|
||||||
|
c2,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password="sesame2",
|
||||||
|
)
|
||||||
|
self.joinChannel(1, chname)
|
||||||
|
self.joinChannel(2, chname)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
1, "@+client-only-tag-test=success;+draft/persist TAGMSG %s" % (chname,)
|
||||||
|
)
|
||||||
|
echo = self.getMessages(1)[0]
|
||||||
|
msgid = echo.tags["msgid"]
|
||||||
|
|
||||||
|
def validate_tagmsg(msg, target, msgid):
|
||||||
|
self.assertMessageMatch(msg, command="TAGMSG", params=[target])
|
||||||
|
self.assertEqual(msg.tags["+client-only-tag-test"], "success")
|
||||||
|
self.assertEqual(msg.tags["msgid"], msgid)
|
||||||
|
|
||||||
|
validate_tagmsg(echo, chname, msgid)
|
||||||
|
|
||||||
|
relay = self.getMessages(2)
|
||||||
|
self.assertEqual(len(relay), 1)
|
||||||
|
validate_tagmsg(relay[0], chname, msgid)
|
||||||
|
|
||||||
|
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,))
|
||||||
|
history_tagmsgs = [
|
||||||
|
msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(history_tagmsgs), 1)
|
||||||
|
validate_tagmsg(history_tagmsgs[0], chname, msgid)
|
||||||
|
|
||||||
|
# c2 doesn't have event-playback and MUST NOT receive replayed tagmsg
|
||||||
|
self.sendLine(2, "CHATHISTORY LATEST %s * 10" % (chname,))
|
||||||
|
history_tagmsgs = [
|
||||||
|
msg for msg in self.getMessages(2) if msg.command == "TAGMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(history_tagmsgs), 0)
|
||||||
|
|
||||||
|
# now try a DM
|
||||||
|
self.sendLine(
|
||||||
|
1, "@+client-only-tag-test=success;+draft/persist TAGMSG %s" % (c2,)
|
||||||
|
)
|
||||||
|
echo = self.getMessages(1)[0]
|
||||||
|
msgid = echo.tags["msgid"]
|
||||||
|
validate_tagmsg(echo, c2, msgid)
|
||||||
|
|
||||||
|
relay = self.getMessages(2)
|
||||||
|
self.assertEqual(len(relay), 1)
|
||||||
|
validate_tagmsg(relay[0], c2, msgid)
|
||||||
|
|
||||||
|
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (c2,))
|
||||||
|
history_tagmsgs = [
|
||||||
|
msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(history_tagmsgs), 1)
|
||||||
|
validate_tagmsg(history_tagmsgs[0], c2, msgid)
|
||||||
|
|
||||||
|
# c2 doesn't have event-playback and MUST NOT receive replayed tagmsg
|
||||||
|
self.sendLine(2, "CHATHISTORY LATEST %s * 10" % (c1,))
|
||||||
|
history_tagmsgs = [
|
||||||
|
msg for msg in self.getMessages(2) if msg.command == "TAGMSG"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(history_tagmsgs), 0)
|
||||||
|
|
||||||
|
@pytest.mark.arbitrary_client_tags
|
||||||
|
@pytest.mark.private_chathistory
|
||||||
|
@skip_ngircd
|
||||||
|
def testChathistoryDMClientOnlyTags(self):
|
||||||
|
# regression test for Ergo #1411
|
||||||
|
c1 = random_name("foo")
|
||||||
|
c2 = random_name("bar")
|
||||||
|
self.controller.registerUser(self, c1, "sesame1")
|
||||||
|
self.controller.registerUser(self, c2, "sesame2")
|
||||||
|
self.connectClient(
|
||||||
|
c1,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password="sesame1",
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.connectClient(
|
||||||
|
c2,
|
||||||
|
capabilities=[
|
||||||
|
"message-tags",
|
||||||
|
"server-time",
|
||||||
|
"echo-message",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"sasl",
|
||||||
|
CHATHISTORY_CAP,
|
||||||
|
],
|
||||||
|
password="sesame2",
|
||||||
|
)
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
echo_msgid = None
|
||||||
|
|
||||||
|
def validate_msg(msg):
|
||||||
|
self.assertMessageMatch(msg, command="PRIVMSG", params=[c2, "hi"])
|
||||||
|
self.assertEqual(msg.tags["+client-only-tag-test"], "success")
|
||||||
|
self.assertEqual(msg.tags["msgid"], echo_msgid)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
1, "@+client-only-tag-test=success;+draft/persist PRIVMSG %s hi" % (c2,)
|
||||||
|
)
|
||||||
|
echo = self.getMessage(1)
|
||||||
|
echo_msgid = echo.tags["msgid"]
|
||||||
|
validate_msg(echo)
|
||||||
|
relay = self.getMessage(2)
|
||||||
|
validate_msg(relay)
|
||||||
|
|
||||||
|
|
||||||
|
assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == {
|
||||||
|
meth_name
|
||||||
|
for meth_name in dir(ChathistoryTestCase)
|
||||||
|
if meth_name.startswith("_validate_chathistory_")
|
||||||
|
}, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync"
|
0
irctest/server_tests/chmodes/__init__.py
Normal file
0
irctest/server_tests/chmodes/__init__.py
Normal file
133
irctest/server_tests/chmodes/auditorium.py
Normal file
133
irctest/server_tests/chmodes/auditorium.py
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`_-specific tests of auditorium mode
|
||||||
|
|
||||||
|
TODO: Should be extended to other servers, once a specification is written.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.irc_utils.junkdrawer import ircv3_timestamp_to_unixtime
|
||||||
|
from irctest.numerics import RPL_NAMREPLY
|
||||||
|
|
||||||
|
MODERN_CAPS = [
|
||||||
|
"server-time",
|
||||||
|
"message-tags",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"echo-message",
|
||||||
|
"account-tag",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AuditoriumTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testAuditorium(self):
|
||||||
|
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("bar", "#auditorium")
|
||||||
|
self.getMessages("bar")
|
||||||
|
self.sendLine("bar", "MODE #auditorium +u")
|
||||||
|
modelines = [msg for msg in self.getMessages("bar") if msg.command == "MODE"]
|
||||||
|
self.assertEqual(len(modelines), 1)
|
||||||
|
self.assertMessageMatch(modelines[0], params=["#auditorium", "+u"])
|
||||||
|
|
||||||
|
self.connectClient("guest1", name="guest1", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("guest1", "#auditorium")
|
||||||
|
self.getMessages("guest1")
|
||||||
|
# chanop should get a JOIN message
|
||||||
|
join_msgs = [msg for msg in self.getMessages("bar") if msg.command == "JOIN"]
|
||||||
|
self.assertEqual(len(join_msgs), 1)
|
||||||
|
self.assertMessageMatch(join_msgs[0], nick="guest1", params=["#auditorium"])
|
||||||
|
|
||||||
|
self.connectClient("guest2", name="guest2", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("guest2", "#auditorium")
|
||||||
|
self.getMessages("guest2")
|
||||||
|
# chanop should get a JOIN message
|
||||||
|
join_msgs = [msg for msg in self.getMessages("bar") if msg.command == "JOIN"]
|
||||||
|
self.assertEqual(len(join_msgs), 1)
|
||||||
|
join_msg = join_msgs[0]
|
||||||
|
self.assertMessageMatch(join_msg, nick="guest2", params=["#auditorium"])
|
||||||
|
# oragono/oragono#1642 ; msgid should be populated,
|
||||||
|
# and the time tag should be sane
|
||||||
|
self.assertTrue(join_msg.tags.get("msgid"))
|
||||||
|
self.assertLessEqual(
|
||||||
|
math.fabs(time.time() - ircv3_timestamp_to_unixtime(join_msg.tags["time"])),
|
||||||
|
60.0,
|
||||||
|
)
|
||||||
|
# fellow unvoiced participant should not
|
||||||
|
unvoiced_join_msgs = [
|
||||||
|
msg for msg in self.getMessages("guest1") if msg.command == "JOIN"
|
||||||
|
]
|
||||||
|
self.assertEqual(len(unvoiced_join_msgs), 0)
|
||||||
|
|
||||||
|
self.connectClient("guest3", name="guest3", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("guest3", "#auditorium")
|
||||||
|
self.getMessages("guest3")
|
||||||
|
|
||||||
|
self.sendLine("bar", "PRIVMSG #auditorium hi")
|
||||||
|
echo_message = [
|
||||||
|
msg for msg in self.getMessages("bar") if msg.command == "PRIVMSG"
|
||||||
|
][0]
|
||||||
|
self.assertEqual(echo_message, self.getMessages("guest1")[0])
|
||||||
|
self.assertEqual(echo_message, self.getMessages("guest2")[0])
|
||||||
|
self.assertEqual(echo_message, self.getMessages("guest3")[0])
|
||||||
|
|
||||||
|
# unvoiced users can speak
|
||||||
|
self.sendLine("guest1", "PRIVMSG #auditorium :hi you")
|
||||||
|
echo_message = [
|
||||||
|
msg for msg in self.getMessages("guest1") if msg.command == "PRIVMSG"
|
||||||
|
][0]
|
||||||
|
self.assertEqual(self.getMessages("bar"), [echo_message])
|
||||||
|
self.assertEqual(self.getMessages("guest2"), [echo_message])
|
||||||
|
self.assertEqual(self.getMessages("guest3"), [echo_message])
|
||||||
|
|
||||||
|
def names(client):
|
||||||
|
self.sendLine(client, "NAMES #auditorium")
|
||||||
|
result = set()
|
||||||
|
for msg in self.getMessages(client):
|
||||||
|
if msg.command == RPL_NAMREPLY:
|
||||||
|
result.update(msg.params[-1].split())
|
||||||
|
return result
|
||||||
|
|
||||||
|
self.assertEqual(names("bar"), {"@bar", "guest1", "guest2", "guest3"})
|
||||||
|
self.assertEqual(names("guest1"), {"@bar"})
|
||||||
|
self.assertEqual(names("guest2"), {"@bar"})
|
||||||
|
self.assertEqual(names("guest3"), {"@bar"})
|
||||||
|
|
||||||
|
self.sendLine("bar", "MODE #auditorium +v guest1")
|
||||||
|
modeLine = [msg for msg in self.getMessages("bar") if msg.command == "MODE"][0]
|
||||||
|
self.assertEqual(self.getMessages("guest1"), [modeLine])
|
||||||
|
self.assertEqual(self.getMessages("guest2"), [modeLine])
|
||||||
|
self.assertEqual(self.getMessages("guest3"), [modeLine])
|
||||||
|
self.assertEqual(names("bar"), {"@bar", "+guest1", "guest2", "guest3"})
|
||||||
|
self.assertEqual(names("guest2"), {"@bar", "+guest1"})
|
||||||
|
self.assertEqual(names("guest3"), {"@bar", "+guest1"})
|
||||||
|
|
||||||
|
self.sendLine("guest1", "PART #auditorium")
|
||||||
|
part = [msg for msg in self.getMessages("guest1") if msg.command == "PART"][0]
|
||||||
|
# everyone should see voiced PART
|
||||||
|
self.assertEqual(self.getMessages("bar")[0], part)
|
||||||
|
self.assertEqual(self.getMessages("guest2")[0], part)
|
||||||
|
self.assertEqual(self.getMessages("guest3")[0], part)
|
||||||
|
|
||||||
|
self.joinChannel("guest1", "#auditorium")
|
||||||
|
self.getMessages("guest1")
|
||||||
|
self.getMessages("bar")
|
||||||
|
|
||||||
|
self.sendLine("guest2", "PART #auditorium")
|
||||||
|
part = [msg for msg in self.getMessages("guest2") if msg.command == "PART"][0]
|
||||||
|
self.assertEqual(self.getMessages("bar"), [part])
|
||||||
|
# part should be hidden from unvoiced participants
|
||||||
|
self.assertEqual(self.getMessages("guest1"), [])
|
||||||
|
self.assertEqual(self.getMessages("guest3"), [])
|
||||||
|
|
||||||
|
self.sendLine("guest3", "QUIT")
|
||||||
|
self.assertDisconnected("guest3")
|
||||||
|
# quit should be hidden from unvoiced participants
|
||||||
|
self.assertEqual(
|
||||||
|
len([msg for msg in self.getMessages("bar") if msg.command == "QUIT"]), 1
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
len([msg for msg in self.getMessages("guest1") if msg.command == "QUIT"]), 0
|
||||||
|
)
|
159
irctest/server_tests/chmodes/ban.py
Normal file
159
irctest/server_tests/chmodes/ban.py
Normal file
@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Channel ban (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.3.1>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.3>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#ban-channel-mode>`__)
|
||||||
|
and ban exception (`Modern <https://modern.ircdocs.horse/#exception-channel-mode>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import ERR_BANNEDFROMCHAN, RPL_BANLIST, RPL_ENDOFBANLIST
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
class BanModeTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
def testBan(self):
|
||||||
|
"""Basic ban operation"""
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", "MODE #chan +b bar!*@*")
|
||||||
|
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
|
||||||
|
|
||||||
|
self.connectClient("Bar", name="bar")
|
||||||
|
self.getMessages("bar")
|
||||||
|
self.sendLine("bar", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
|
||||||
|
|
||||||
|
self.sendLine("chanop", "MODE #chan -b bar!*@*")
|
||||||
|
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
|
||||||
|
|
||||||
|
self.sendLine("bar", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testBanList(self):
|
||||||
|
"""`RPL_BANLIST <https://modern.ircdocs.horse/#rplbanlist-367>`_"""
|
||||||
|
self.connectClient("chanop")
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "MODE #chan +b bar!*@*")
|
||||||
|
self.assertMessageMatch(self.getMessage(1), command="MODE")
|
||||||
|
|
||||||
|
self.sendLine(1, "MODE #chan +b")
|
||||||
|
|
||||||
|
m = self.getMessage(1)
|
||||||
|
if len(m.params) == 3:
|
||||||
|
# Old format
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command=RPL_BANLIST,
|
||||||
|
params=[
|
||||||
|
"chanop",
|
||||||
|
"#chan",
|
||||||
|
"bar!*@*",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command=RPL_BANLIST,
|
||||||
|
params=[
|
||||||
|
"chanop",
|
||||||
|
"#chan",
|
||||||
|
"bar!*@*",
|
||||||
|
StrRe("chanop(!.*@.*)?"),
|
||||||
|
StrRe("[0-9]+"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_ENDOFBANLIST,
|
||||||
|
params=[
|
||||||
|
"chanop",
|
||||||
|
"#chan",
|
||||||
|
ANYSTR,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testBanException(self):
|
||||||
|
"""`Exception mode <https://modern.ircdocs.horse/#exception-channel-mode`_,
|
||||||
|
detected using `ISUPPORT EXCEPTS
|
||||||
|
<https://modern.ircdocs.horse/#excepts-parameter>`_ and checked against
|
||||||
|
`ISUPPORT CHANMODES <https://modern.ircdocs.horse/#chanmodes-parameter>`_"""
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
|
||||||
|
if "EXCEPTS" in self.server_support:
|
||||||
|
mode = self.server_support["EXCEPTS"] or "e"
|
||||||
|
if "CHANMODES" in self.server_support:
|
||||||
|
self.assertIn(
|
||||||
|
mode,
|
||||||
|
self.server_support["CHANMODES"],
|
||||||
|
fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is missing "
|
||||||
|
"from 'CHANMODES={list}'",
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
mode,
|
||||||
|
self.server_support["CHANMODES"].split(",")[0],
|
||||||
|
fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is not "
|
||||||
|
"in group A",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mode = "e"
|
||||||
|
if "CHANMODES" in self.server_support:
|
||||||
|
if "e" not in self.server_support["CHANMODES"]:
|
||||||
|
raise runner.OptionalExtensionNotSupported(
|
||||||
|
"Ban exception (or mode letter is not +e)"
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
mode,
|
||||||
|
self.server_support["CHANMODES"].split(",")[0],
|
||||||
|
fail_msg="Mode +e (assumed to be ban exception) is present, "
|
||||||
|
"but 'e' is not in group A",
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise runner.OptionalExtensionNotSupported("ISUPPORT CHANMODES")
|
||||||
|
|
||||||
|
self.sendLine("chanop", "JOIN #chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", "MODE #chan +b ba*!*@*")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
|
||||||
|
# banned client cannot join
|
||||||
|
self.connectClient("Bar", name="bar")
|
||||||
|
self.sendLine("bar", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
|
||||||
|
|
||||||
|
# chanop sets exception
|
||||||
|
self.sendLine("chanop", "MODE #chan +e *ar!*@*")
|
||||||
|
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
|
||||||
|
|
||||||
|
# client can now join
|
||||||
|
self.sendLine("bar", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
|
||||||
|
|
||||||
|
# TODO: Add testBanExceptionList, once the numerics are specified in Modern
|
||||||
|
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testCaseInsensitive(self):
|
||||||
|
"""Some clients allow unsetting modes if their argument matches
|
||||||
|
up to normalization"""
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", "MODE #chan +b BAR!*@*")
|
||||||
|
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
|
||||||
|
|
||||||
|
self.connectClient("Bar", name="bar")
|
||||||
|
self.getMessages("bar")
|
||||||
|
self.sendLine("bar", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
|
||||||
|
|
||||||
|
self.sendLine("chanop", "MODE #chan -b bar!*@*")
|
||||||
|
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
|
||||||
|
|
||||||
|
self.sendLine("bar", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
|
128
irctest/server_tests/chmodes/ergo.py
Normal file
128
irctest/server_tests/chmodes/ergo.py
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
"""
|
||||||
|
Various Ergo-specific channel modes
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
|
||||||
|
|
||||||
|
MODERN_CAPS = [
|
||||||
|
"server-time",
|
||||||
|
"message-tags",
|
||||||
|
"batch",
|
||||||
|
"labeled-response",
|
||||||
|
"echo-message",
|
||||||
|
"account-tag",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
class RegisteredOnlySpeakModeTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testRegisteredOnlySpeakMode(self):
|
||||||
|
self.controller.registerUser(self, "evan", "sesame")
|
||||||
|
|
||||||
|
# test the +M (only registered users and ops can speak) channel mode
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", "MODE #chan +M")
|
||||||
|
replies = self.getMessages("chanop")
|
||||||
|
modeLines = [line for line in replies if line.command == "MODE"]
|
||||||
|
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+M"])
|
||||||
|
|
||||||
|
self.connectClient("baz", name="baz")
|
||||||
|
self.joinChannel("baz", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
# this message should be suppressed completely by +M
|
||||||
|
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
|
||||||
|
replies = self.getMessages("baz")
|
||||||
|
reply_cmds = {reply.command for reply in replies}
|
||||||
|
self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
# +v exempts users from the registration requirement:
|
||||||
|
self.sendLine("chanop", "MODE #chan +v baz")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.getMessages("baz")
|
||||||
|
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
|
||||||
|
replies = self.getMessages("baz")
|
||||||
|
# baz should not receive an error (or an echo)
|
||||||
|
self.assertEqual(replies, [])
|
||||||
|
replies = self.getMessages("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
replies[0], command="PRIVMSG", params=["#chan", "hi again from baz"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"evan",
|
||||||
|
name="evan",
|
||||||
|
account="evan",
|
||||||
|
password="sesame",
|
||||||
|
capabilities=["sasl"],
|
||||||
|
)
|
||||||
|
self.joinChannel("evan", "#chan")
|
||||||
|
self.getMessages("baz")
|
||||||
|
self.sendLine("evan", "PRIVMSG #chan :hi from evan")
|
||||||
|
replies = self.getMessages("evan")
|
||||||
|
# evan should not receive an error (or an echo)
|
||||||
|
self.assertEqual(replies, [])
|
||||||
|
replies = self.getMessages("baz")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
replies[0], command="PRIVMSG", params=["#chan", "hi from evan"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpModeratedTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testOpModerated(self):
|
||||||
|
# test the +U channel mode
|
||||||
|
self.connectClient("chanop", name="chanop", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", "MODE #chan +U")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("baz", "#chan")
|
||||||
|
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
|
||||||
|
echo = self.getMessages("baz")[0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
echo, command="PRIVMSG", params=["#chan", "hi from baz"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
|
||||||
|
[echo],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.connectClient("qux", name="qux", capabilities=MODERN_CAPS)
|
||||||
|
self.joinChannel("qux", "#chan")
|
||||||
|
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
|
||||||
|
echo = self.getMessages("qux")[0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
echo, command="PRIVMSG", params=["#chan", "hi from qux"]
|
||||||
|
)
|
||||||
|
# message is relayed to chanop but not to unprivileged
|
||||||
|
self.assertEqual(
|
||||||
|
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
|
||||||
|
[echo],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], []
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine("chanop", "MODE #chan +v qux")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("qux", "PRIVMSG #chan :hi again from qux")
|
||||||
|
echo = [msg for msg in self.getMessages("qux") if msg.command == "PRIVMSG"][0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
echo, command="PRIVMSG", params=["#chan", "hi again from qux"]
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
|
||||||
|
[echo],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
[msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], [echo]
|
||||||
|
)
|
164
irctest/server_tests/chmodes/key.py
Normal file
164
irctest/server_tests/chmodes/key.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Channel key (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.3.1>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.3>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#key-channel-mode>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import (
|
||||||
|
ERR_BADCHANNELKEY,
|
||||||
|
ERR_INVALIDKEY,
|
||||||
|
ERR_INVALIDMODEPARAM,
|
||||||
|
ERR_UNKNOWNERROR,
|
||||||
|
)
|
||||||
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
|
|
||||||
|
class KeyTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
def testKeyNormal(self):
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
self.sendLine(1, "MODE #chan +k beer")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.connectClient("qux")
|
||||||
|
self.getMessages(2)
|
||||||
|
# JOIN with a missing key MUST receive ERR_BADCHANNELKEY:
|
||||||
|
self.sendLine(2, "JOIN #chan")
|
||||||
|
reply_cmds = {msg.command for msg in self.getMessages(2)}
|
||||||
|
self.assertNotIn("JOIN", reply_cmds)
|
||||||
|
self.assertIn(ERR_BADCHANNELKEY, reply_cmds)
|
||||||
|
# similarly for JOIN with an incorrect key:
|
||||||
|
self.sendLine(2, "JOIN #chan bees")
|
||||||
|
reply_cmds = {msg.command for msg in self.getMessages(2)}
|
||||||
|
self.assertNotIn("JOIN", reply_cmds)
|
||||||
|
self.assertIn(ERR_BADCHANNELKEY, reply_cmds)
|
||||||
|
|
||||||
|
self.sendLine(2, "JOIN #chan beer")
|
||||||
|
reply = self.getMessages(2)
|
||||||
|
self.assertMessageMatch(reply[0], command="JOIN", params=["#chan"])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"key",
|
||||||
|
["passphrase with spaces", "long" * 100, "", " "],
|
||||||
|
ids=["spaces", "long", "empty", "only-space"],
|
||||||
|
)
|
||||||
|
@cases.mark_specifications("RFC2812", "Modern")
|
||||||
|
def testKeyValidation(self, key):
|
||||||
|
"""
|
||||||
|
key = 1*23( %x01-05 / %x07-08 / %x0C / %x0E-1F / %x21-7F )
|
||||||
|
; any 7-bit US_ASCII character,
|
||||||
|
; except NUL, CR, LF, FF, h/v TABs, and " "
|
||||||
|
-- https://tools.ietf.org/html/rfc2812#page-8
|
||||||
|
|
||||||
|
"Servers may validate the value (eg. to forbid spaces, as they make it harder
|
||||||
|
to use the key in `JOIN` messages). If the value is invalid, they SHOULD
|
||||||
|
return [`ERR_INVALIDMODEPARAM`](#errinvalidmodeparam-696).
|
||||||
|
However, clients MUST be able to handle any of the following:
|
||||||
|
|
||||||
|
* [`ERR_INVALIDMODEPARAM`](#errinvalidmodeparam-696)
|
||||||
|
* [`ERR_INVALIDKEY`](#errinvalidkey-525)
|
||||||
|
* `MODE` echoed with a different key (eg. truncated or stripped of invalid
|
||||||
|
characters)
|
||||||
|
* the key changed ignored, and no `MODE` echoed if no other mode change
|
||||||
|
was valid.
|
||||||
|
"
|
||||||
|
-- https://modern.ircdocs.horse/#key-channel-mode
|
||||||
|
-- https://github.com/ircdocs/modern-irc/pull/111
|
||||||
|
"""
|
||||||
|
if key == "" and self.controller.software_name in (
|
||||||
|
"ircu2",
|
||||||
|
"Nefarious",
|
||||||
|
"snircd",
|
||||||
|
):
|
||||||
|
pytest.xfail(
|
||||||
|
"ircu2 returns ERR_NEEDMOREPARAMS on empty keys: "
|
||||||
|
"https://github.com/UndernetIRC/ircu2/issues/13"
|
||||||
|
)
|
||||||
|
if (key == "" or " " in key) and self.controller.software_name == "ngIRCd":
|
||||||
|
pytest.xfail(
|
||||||
|
"ngIRCd does not validate channel keys: "
|
||||||
|
"https://github.com/ngircd/ngircd/issues/290"
|
||||||
|
)
|
||||||
|
if key == " " and self.controller.software_name == "irc2":
|
||||||
|
pytest.xfail("irc2 rewrites non-empty keys that contain only spaces")
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
self.sendLine(1, f"MODE #chan +k :{key}")
|
||||||
|
|
||||||
|
# The spec requires no space; but doesn't say what to do
|
||||||
|
# if there is one.
|
||||||
|
# Let's check the various alternatives
|
||||||
|
|
||||||
|
replies = self.getMessages(1)
|
||||||
|
self.assertNotIn(
|
||||||
|
ERR_UNKNOWNERROR,
|
||||||
|
{msg.command for msg in replies},
|
||||||
|
fail_msg="Sending an invalid key caused an "
|
||||||
|
"ERR_UNKNOWNERROR instead of being handled explicitly "
|
||||||
|
"(eg. ERR_INVALIDMODEPARAM or truncation): {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
commands = {msg.command for msg in replies}
|
||||||
|
if {ERR_INVALIDMODEPARAM, ERR_INVALIDKEY} & commands:
|
||||||
|
# First option: ERR_INVALIDMODEPARAM (eg. Ergo) or ERR_INVALIDKEY
|
||||||
|
# (eg. ircu2)
|
||||||
|
if ERR_INVALIDMODEPARAM in commands:
|
||||||
|
command = [
|
||||||
|
msg for msg in replies if msg.command == ERR_INVALIDMODEPARAM
|
||||||
|
]
|
||||||
|
self.assertEqual(len(command), 1, command)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
command[0],
|
||||||
|
command=ERR_INVALIDMODEPARAM,
|
||||||
|
params=["bar", "#chan", "k", "*", ANYSTR],
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not replies:
|
||||||
|
# MODE was ignored entirely
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.sendLine(2, "JOIN #chan")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2), command="JOIN", params=["#chan"]
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Second and third options: truncating the key (eg. UnrealIRCd)
|
||||||
|
# or replacing spaces (eg. Charybdis)
|
||||||
|
mode_commands = [msg for msg in replies if msg.command == "MODE"]
|
||||||
|
self.assertGreaterEqual(
|
||||||
|
len(mode_commands),
|
||||||
|
1,
|
||||||
|
fail_msg="Sending an invalid key (with a space) triggered "
|
||||||
|
"neither ERR_UNKNOWNERROR, ERR_INVALIDMODEPARAM, ERR_INVALIDKEY, "
|
||||||
|
" or a MODE. Only these: {}",
|
||||||
|
extra_format=(replies,),
|
||||||
|
)
|
||||||
|
self.assertLessEqual(
|
||||||
|
len(mode_commands),
|
||||||
|
1,
|
||||||
|
fail_msg="Sending an invalid key (with a space) triggered "
|
||||||
|
"multiple MODE responses: {}",
|
||||||
|
extra_format=(replies,),
|
||||||
|
)
|
||||||
|
|
||||||
|
mode_command = mode_commands[0]
|
||||||
|
if mode_command.params == ["#chan", "+k", "passphrase"]:
|
||||||
|
key = "passphrase"
|
||||||
|
elif mode_command.params == ["#chan", "+k", "passphrasewithspaces"]:
|
||||||
|
key = "passphrasewithspaces"
|
||||||
|
elif mode_command.params[2].startswith("longlonglong"):
|
||||||
|
key = mode_command.params[2]
|
||||||
|
assert mode_command.params == ["#chan", "+k", key]
|
||||||
|
elif mode_command.params == ["#chan", "+k", "passphrase with spaces"]:
|
||||||
|
raise self.failureException("Invalid key (with a space) was not rejected.")
|
||||||
|
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.sendLine(2, f"JOIN #chan {key}")
|
||||||
|
self.assertMessageMatch(self.getMessage(2), command="JOIN", params=["#chan"])
|
43
irctest/server_tests/chmodes/moderated.py
Normal file
43
irctest/server_tests/chmodes/moderated.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Channel moderation mode (`RFC 2812
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.3>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#ban-channel-mode>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN
|
||||||
|
|
||||||
|
|
||||||
|
class ModeratedModeTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC2812")
|
||||||
|
def testModeratedMode(self):
|
||||||
|
# test the +m channel mode
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", "MODE #chan +m")
|
||||||
|
replies = self.getMessages("chanop")
|
||||||
|
modeLines = [line for line in replies if line.command == "MODE"]
|
||||||
|
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+m"])
|
||||||
|
|
||||||
|
self.connectClient("baz", name="baz")
|
||||||
|
self.joinChannel("baz", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
# this message should be suppressed completely by +m
|
||||||
|
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
|
||||||
|
replies = self.getMessages("baz")
|
||||||
|
reply_cmds = {reply.command for reply in replies}
|
||||||
|
self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
# grant +v, user should be able to send messages
|
||||||
|
self.sendLine("chanop", "MODE #chan +v baz")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.getMessages("baz")
|
||||||
|
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
|
||||||
|
self.getMessages("baz")
|
||||||
|
relays = self.getMessages("chanop")
|
||||||
|
relay = relays[0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
relay, command="PRIVMSG", params=["#chan", "hi again from baz"]
|
||||||
|
)
|
271
irctest/server_tests/chmodes/mute_extban.py
Normal file
271
irctest/server_tests/chmodes/mute_extban.py
Normal file
@ -0,0 +1,271 @@
|
|||||||
|
"""
|
||||||
|
Mute extban, currently no specifications or ways to discover it.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
|
||||||
|
from irctest.patma import ANYLIST, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
class MuteExtbanTestCase(cases.BaseServerTestCase):
|
||||||
|
"""https://defs.ircdocs.horse/defs/isupport.html#extban
|
||||||
|
|
||||||
|
It magically guesses what char the IRCd uses for mutes."""
|
||||||
|
|
||||||
|
def char(self):
|
||||||
|
if self.controller.extban_mute_char is None:
|
||||||
|
raise runner.ExtbanNotSupported("", "mute")
|
||||||
|
else:
|
||||||
|
return self.controller.extban_mute_char
|
||||||
|
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testISupport(self):
|
||||||
|
self.connectClient("chk") # Fetches ISUPPORT
|
||||||
|
isupport = self.server_support
|
||||||
|
token = isupport["EXTBAN"]
|
||||||
|
prefix, comma, types = token.partition(",")
|
||||||
|
self.assertIn(self.char(), types, f"Missing '{self.char()}' in ISUPPORT EXTBAN")
|
||||||
|
self.assertEqual(prefix, "")
|
||||||
|
self.assertEqual(comma, ",")
|
||||||
|
|
||||||
|
@cases.mark_specifications("ircdocs")
|
||||||
|
def testMuteExtban(self):
|
||||||
|
"""Basic usage of mute"""
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
|
||||||
|
isupport = self.server_support
|
||||||
|
token = isupport.get("EXTBAN", "")
|
||||||
|
prefix, comma, types = token.partition(",")
|
||||||
|
if self.char() not in types:
|
||||||
|
raise runner.ExtbanNotSupported(self.char(), "mute")
|
||||||
|
|
||||||
|
clients = ("chanop", "bar")
|
||||||
|
|
||||||
|
# Mute "bar"
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:bar!*@*")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
self.connectClient("bar", name="bar", capabilities=["echo-message"])
|
||||||
|
self.joinChannel("bar", "#chan")
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
self.getMessages(client)
|
||||||
|
|
||||||
|
# "bar" sees the MODE too
|
||||||
|
self.sendLine("bar", "MODE #chan +b")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("bar"),
|
||||||
|
command="367",
|
||||||
|
params=[
|
||||||
|
"bar",
|
||||||
|
"#chan",
|
||||||
|
f"{prefix}{self.char()}:bar!*@*",
|
||||||
|
StrRe("chanop(!.*)?"),
|
||||||
|
*ANYLIST,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.getMessages("bar")
|
||||||
|
|
||||||
|
# "bar" talks: rejected
|
||||||
|
self.sendLine("bar", "PRIVMSG #chan :hi from bar")
|
||||||
|
replies = self.getMessages("bar")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertNotIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
# remove mute on "bar" with -b
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", f"MODE #chan -b {prefix}{self.char()}:bar!*@*")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
# "bar" can now talk
|
||||||
|
self.sendLine("bar", "PRIVMSG #chan :hi again from bar")
|
||||||
|
replies = self.getMessages("bar")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(
|
||||||
|
self.getMessages("chanop"),
|
||||||
|
[msg for msg in replies if msg.command == "PRIVMSG"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("ircdocs")
|
||||||
|
def testMuteExtbanVoiced(self):
|
||||||
|
"""Checks +v overrides the mute"""
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
|
||||||
|
isupport = self.server_support
|
||||||
|
token = isupport.get("EXTBAN", "")
|
||||||
|
prefix, comma, types = token.partition(",")
|
||||||
|
if self.char() not in types:
|
||||||
|
raise runner.ExtbanNotSupported(self.char(), "mute")
|
||||||
|
|
||||||
|
clients = ("chanop", "qux")
|
||||||
|
|
||||||
|
# Mute "qux"
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:qux!*@*")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"qux", name="qux", ident="evan", capabilities=["echo-message"]
|
||||||
|
)
|
||||||
|
self.joinChannel("qux", "#chan")
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
self.getMessages(client)
|
||||||
|
|
||||||
|
# "qux" talks: rejected
|
||||||
|
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
|
||||||
|
replies = self.getMessages("qux")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertNotIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
self.getMessages(client)
|
||||||
|
|
||||||
|
# +v grants an exemption to +b
|
||||||
|
self.sendLine("chanop", "MODE #chan +v qux")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
# so "qux" can now talk
|
||||||
|
self.sendLine("qux", "PRIVMSG #chan :hi again from qux")
|
||||||
|
replies = self.getMessages("qux")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(
|
||||||
|
self.getMessages("chanop"),
|
||||||
|
[msg for msg in replies if msg.command == "PRIVMSG"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("ircdocs")
|
||||||
|
def testMuteExtbanExempt(self):
|
||||||
|
"""Checks +e overrides the mute
|
||||||
|
|
||||||
|
<https://defs.ircdocs.horse/defs/chanmodes.html#e-ban-exception>"""
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
|
||||||
|
isupport = self.server_support
|
||||||
|
token = isupport.get("EXTBAN", "")
|
||||||
|
prefix, comma, types = token.partition(",")
|
||||||
|
if self.char() not in types:
|
||||||
|
raise runner.ExtbanNotSupported(self.char(), "mute")
|
||||||
|
if "e" not in self.server_support["CHANMODES"]:
|
||||||
|
raise runner.ChannelModeNotSupported(self.char(), "mute")
|
||||||
|
|
||||||
|
clients = ("chanop", "qux")
|
||||||
|
|
||||||
|
# Mute "qux"
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:qux!*@*")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"qux", name="qux", ident="evan", capabilities=["echo-message"]
|
||||||
|
)
|
||||||
|
self.joinChannel("qux", "#chan")
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
self.getMessages(client)
|
||||||
|
|
||||||
|
# "qux" talks: rejected
|
||||||
|
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
|
||||||
|
replies = self.getMessages("qux")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertNotIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
self.getMessages(client)
|
||||||
|
|
||||||
|
# +e grants an exemption to +b
|
||||||
|
self.sendLine("chanop", f"MODE #chan +e {prefix}{self.char()}:*!*evan@*")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
self.getMessages("qux")
|
||||||
|
|
||||||
|
# so "qux" can now talk
|
||||||
|
self.sendLine("qux", "PRIVMSG #chan :thanks for mute-excepting me")
|
||||||
|
replies = self.getMessages("qux")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(
|
||||||
|
self.getMessages("chanop"),
|
||||||
|
[msg for msg in replies if msg.command == "PRIVMSG"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testCapitalization(self):
|
||||||
|
"""
|
||||||
|
Regression test for oragono #1370: mutes not correctly enforced against
|
||||||
|
users with capital letters in their NUH
|
||||||
|
|
||||||
|
For consistency with regular -b, which allows unsetting up to
|
||||||
|
normalization
|
||||||
|
"""
|
||||||
|
clients = ("chanop", "bar")
|
||||||
|
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
|
||||||
|
isupport = self.server_support
|
||||||
|
token = isupport.get("EXTBAN", "")
|
||||||
|
prefix, comma, types = token.partition(",")
|
||||||
|
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:BAR!*@*")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
self.connectClient("Bar", name="bar", capabilities=["echo-message"])
|
||||||
|
self.joinChannel("bar", "#chan")
|
||||||
|
|
||||||
|
for client in clients:
|
||||||
|
self.getMessages(client)
|
||||||
|
|
||||||
|
self.sendLine("bar", "PRIVMSG #chan :hi from bar")
|
||||||
|
replies = self.getMessages("bar")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertNotIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
# remove mute with -b
|
||||||
|
self.sendLine("chanop", f"MODE #chan -b {prefix}{self.char()}:bar!*@*")
|
||||||
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
|
self.assertIn("MODE", replies)
|
||||||
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
|
||||||
|
# "bar" can talk again
|
||||||
|
self.sendLine("bar", "PRIVMSG #chan :hi again from bar")
|
||||||
|
replies = self.getMessages("bar")
|
||||||
|
replies_cmds = {msg.command for msg in replies}
|
||||||
|
self.assertIn("PRIVMSG", replies_cmds)
|
||||||
|
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
|
||||||
|
self.assertEqual(
|
||||||
|
self.getMessages("chanop"),
|
||||||
|
[msg for msg in replies if msg.command == "PRIVMSG"],
|
||||||
|
)
|
31
irctest/server_tests/chmodes/no_ctcp.py
Normal file
31
irctest/server_tests/chmodes/no_ctcp.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN
|
||||||
|
|
||||||
|
|
||||||
|
class NoCTCPChannelModeTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testNoCTCPChannelMode(self):
|
||||||
|
"""Test Ergo's +C channel mode that blocks CTCPs."""
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
self.sendLine(1, "MODE #chan +C")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.connectClient("qux")
|
||||||
|
self.joinChannel(2, "#chan")
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.sendLine(1, "PRIVMSG #chan :\x01ACTION hi\x01")
|
||||||
|
self.getMessages(1)
|
||||||
|
ms = self.getMessages(2)
|
||||||
|
self.assertEqual(len(ms), 1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
ms[0], command="PRIVMSG", params=["#chan", "\x01ACTION hi\x01"]
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "PRIVMSG #chan :\x01PING 1473523796 918320\x01")
|
||||||
|
ms = self.getMessages(1)
|
||||||
|
self.assertEqual(len(ms), 1)
|
||||||
|
self.assertMessageMatch(ms[0], command=ERR_CANNOTSENDTOCHAN)
|
||||||
|
ms = self.getMessages(2)
|
||||||
|
self.assertEqual(ms, [])
|
38
irctest/server_tests/chmodes/no_external.py
Normal file
38
irctest/server_tests/chmodes/no_external.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""
|
||||||
|
Channel "no external messages" mode (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.3.1>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#no-external-messages-mode>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN
|
||||||
|
|
||||||
|
|
||||||
|
class NoExternalMessagesTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC1459", "Modern")
|
||||||
|
def testNoExternalMessagesMode(self):
|
||||||
|
# test the +n channel mode
|
||||||
|
self.connectClient("chanop", name="chanop")
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.sendLine("chanop", "MODE #chan +n")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
|
||||||
|
self.connectClient("baz", name="baz")
|
||||||
|
# this message should be suppressed completely by +n
|
||||||
|
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
|
||||||
|
replies = self.getMessages("baz")
|
||||||
|
reply_cmds = {reply.command for reply in replies}
|
||||||
|
self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
# set the channel to -n: baz should be able to send now
|
||||||
|
self.sendLine("chanop", "MODE #chan -n")
|
||||||
|
replies = self.getMessages("chanop")
|
||||||
|
modeLines = [line for line in replies if line.command == "MODE"]
|
||||||
|
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "-n"])
|
||||||
|
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
|
||||||
|
self.getMessages("baz")
|
||||||
|
relays = self.getMessages("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
relays[0], command="PRIVMSG", params=["#chan", "hi again from baz"]
|
||||||
|
)
|
62
irctest/server_tests/chmodes/secret.py
Normal file
62
irctest/server_tests/chmodes/secret.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""
|
||||||
|
Channel secrecy mode (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.3.1>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.3>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#secret-channel-mode>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import RPL_LIST
|
||||||
|
|
||||||
|
|
||||||
|
class SecretChannelTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC1459", "Modern")
|
||||||
|
def testSecretChannelListCommand(self):
|
||||||
|
"""
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.6>
|
||||||
|
|
||||||
|
"Likewise, secret channels are not listed
|
||||||
|
at all unless the client is a member of the channel in question."
|
||||||
|
|
||||||
|
<https://modern.ircdocs.horse/#secret-channel-mode>
|
||||||
|
"A channel that is set to secret will not show up in responses to
|
||||||
|
the LIST or NAMES command unless the client sending the command is
|
||||||
|
joined to the channel."
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_listed_channels(replies):
|
||||||
|
channels = set()
|
||||||
|
for reply in replies:
|
||||||
|
# skip pseudo-channels (&SERVER, &NOTICES) listed by ngircd
|
||||||
|
# and ircu:
|
||||||
|
if reply.command == RPL_LIST and reply.params[1].startswith("#"):
|
||||||
|
channels.add(reply.params[1])
|
||||||
|
return channels
|
||||||
|
|
||||||
|
# test that a silent channel is shown in list if the user is in the channel.
|
||||||
|
self.connectClient("first", name="first")
|
||||||
|
self.joinChannel("first", "#gen")
|
||||||
|
self.getMessages("first")
|
||||||
|
self.sendLine("first", "MODE #gen +s")
|
||||||
|
# run command LIST
|
||||||
|
self.sendLine("first", "LIST")
|
||||||
|
replies = self.getMessages("first")
|
||||||
|
self.assertEqual(get_listed_channels(replies), {"#gen"})
|
||||||
|
|
||||||
|
# test that another client would not see the secret
|
||||||
|
# channel.
|
||||||
|
self.connectClient("second", name="second")
|
||||||
|
self.getMessages("second")
|
||||||
|
self.sendLine("second", "LIST")
|
||||||
|
replies = self.getMessages("second")
|
||||||
|
# RPL_LIST 322 should NOT be present this time.
|
||||||
|
self.assertEqual(get_listed_channels(replies), set())
|
||||||
|
|
||||||
|
# Second client will join the secret channel
|
||||||
|
# and call command LIST. The channel SHOULD
|
||||||
|
# appear this time.
|
||||||
|
self.joinChannel("second", "#gen")
|
||||||
|
self.sendLine("second", "LIST")
|
||||||
|
replies = self.getMessages("second")
|
||||||
|
# Should be only one line with command RPL_LIST
|
||||||
|
self.assertEqual(get_listed_channels(replies), {"#gen"})
|
40
irctest/server_tests/confusables.py
Normal file
40
irctest/server_tests/confusables.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`_-specific tests for nick collisions based on Unicode
|
||||||
|
confusable characters
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
class ConfusablesTestCase(cases.BaseServerTestCase):
|
||||||
|
@staticmethod
|
||||||
|
def config() -> cases.TestCaseControllerConfig:
|
||||||
|
return cases.TestCaseControllerConfig(
|
||||||
|
ergo_config=lambda config: config["accounts"].update(
|
||||||
|
{"nick-reservation": {"enabled": True, "method": "strict"}}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testConfusableNicks(self):
|
||||||
|
self.controller.registerUser(self, "evan", "sesame")
|
||||||
|
|
||||||
|
self.addClient(1)
|
||||||
|
# U+0435 in place of e:
|
||||||
|
self.sendLine(1, "NICK еvan")
|
||||||
|
self.sendLine(1, "USER a 0 * a")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
commands = set(msg.command for msg in messages)
|
||||||
|
self.assertNotIn(RPL_WELCOME, commands)
|
||||||
|
self.assertIn(ERR_NICKNAMEINUSE, commands)
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"evan", name="evan", password="sesame", capabilities=["sasl"]
|
||||||
|
)
|
||||||
|
# should be able to switch to the confusable nick
|
||||||
|
self.sendLine("evan", "NICK еvan")
|
||||||
|
messages = self.getMessages("evan")
|
||||||
|
commands = set(msg.command for msg in messages)
|
||||||
|
self.assertIn("NICK", commands)
|
265
irctest/server_tests/connection_registration.py
Normal file
265
irctest/server_tests/connection_registration.py
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
"""
|
||||||
|
Tests section 4.1 of RFC 1459.
|
||||||
|
<https://tools.ietf.org/html/rfc1459#section-4.1>
|
||||||
|
|
||||||
|
TODO: cross-reference Modern and RFC 2812 too
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.client_mock import ConnectionClosed
|
||||||
|
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
||||||
|
password = "testpassword"
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
def testPassBeforeNickuser(self):
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "PASS {}".format(self.password))
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "USER username * * :Realname")
|
||||||
|
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="001",
|
||||||
|
fail_msg="Did not get 001 after correct PASS+NICK+USER: {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
def testNoPassword(self):
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "USER username * * :Realname")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertNotEqual(
|
||||||
|
m.command, "001", msg="Got 001 after NICK+USER but missing PASS"
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testWrongPassword(self):
|
||||||
|
"""
|
||||||
|
"If the password supplied does not match the password expected by the server,
|
||||||
|
then the server SHOULD send ERR_PASSWDMISMATCH and MUST close the connection
|
||||||
|
with ERROR."
|
||||||
|
-- https://github.com/ircdocs/modern-irc/pull/172
|
||||||
|
"""
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "PASS {}".format(self.password + "garbage"))
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "USER username * * :Realname")
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertNotEqual(
|
||||||
|
m.command, "001", msg="Got 001 after NICK+USER but incorrect PASS"
|
||||||
|
)
|
||||||
|
self.assertIn(m.command, {ERR_PASSWDMISMATCH, "ERROR"})
|
||||||
|
|
||||||
|
if m.command == "ERR_PASSWDMISMATCH":
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertEqual(
|
||||||
|
m.command, "ERROR", msg="ERR_PASSWDMISMATCH not followed by ERROR."
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
|
||||||
|
def testPassAfterNickuser(self):
|
||||||
|
"""“The password can and must be set before any attempt to register
|
||||||
|
the connection is made.”
|
||||||
|
-- <https://tools.ietf.org/html/rfc1459#section-4.1.1>
|
||||||
|
|
||||||
|
“The optional password can and MUST be set before any attempt to
|
||||||
|
register the connection is made.
|
||||||
|
Currently this requires that user send a PASS command before
|
||||||
|
sending the NICK/USER combination.”
|
||||||
|
-- <https://tools.ietf.org/html/rfc2812#section-3.1.1>
|
||||||
|
"""
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "USER username * * :Realname")
|
||||||
|
self.sendLine(1, "PASS {}".format(self.password))
|
||||||
|
m = self.getRegistrationMessage(1)
|
||||||
|
self.assertNotEqual(m.command, "001", "Got 001 after PASS sent after NICK+USER")
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC1459")
|
||||||
|
def testQuitDisconnects(self):
|
||||||
|
"""“The server must close the connection to a client which sends a
|
||||||
|
QUIT message.”
|
||||||
|
-- <https://tools.ietf.org/html/rfc1459#section-4.1.3>
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "QUIT")
|
||||||
|
with self.assertRaises(ConnectionClosed):
|
||||||
|
self.getMessages(1) # Fetch remaining messages
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC2812")
|
||||||
|
@cases.xfailIfSoftware(["Charybdis", "Solanum"], "very flaky")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2", "Nefarious", "snircd"], "ircu2 does not send ERROR"
|
||||||
|
)
|
||||||
|
def testQuitErrors(self):
|
||||||
|
"""“A client session is terminated with a quit message. The server
|
||||||
|
acknowledges this by sending an ERROR message to the client.”
|
||||||
|
-- <https://tools.ietf.org/html/rfc2812#section-3.1.7>
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "QUIT")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
new_messages = self.getMessages(1)
|
||||||
|
if not new_messages:
|
||||||
|
break
|
||||||
|
commands = {m.command for m in new_messages}
|
||||||
|
except ConnectionClosed:
|
||||||
|
break
|
||||||
|
self.assertIn(
|
||||||
|
"ERROR", commands, fail_msg="Did not receive ERROR as a reply to QUIT."
|
||||||
|
)
|
||||||
|
|
||||||
|
def testNickCollision(self):
|
||||||
|
"""A user connects and requests the same nickname as an already
|
||||||
|
registered user.
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(2, "NICK foo")
|
||||||
|
self.sendLine(2, "USER username * * :Realname")
|
||||||
|
m = self.getRegistrationMessage(2)
|
||||||
|
self.assertNotEqual(
|
||||||
|
m.command,
|
||||||
|
"001",
|
||||||
|
"Received 001 after registering with the nick of a registered user.",
|
||||||
|
)
|
||||||
|
|
||||||
|
def testEarlyNickCollision(self):
|
||||||
|
"""Two users register simultaneously with the same nick."""
|
||||||
|
self.addClient()
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(2, "NICK foo")
|
||||||
|
self.sendLine(1, "USER username * * :Realname")
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.sendLine(2, "USER username * * :Realname")
|
||||||
|
except (ConnectionClosed, ConnectionResetError):
|
||||||
|
# Bahamut closes the connection here
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
m1 = self.getRegistrationMessage(1)
|
||||||
|
except (ConnectionClosed, ConnectionResetError):
|
||||||
|
# Unreal closes the connection, see
|
||||||
|
# https://bugs.unrealircd.org/view.php?id=5950
|
||||||
|
command1 = None
|
||||||
|
else:
|
||||||
|
command1 = m1.command
|
||||||
|
|
||||||
|
try:
|
||||||
|
m2 = self.getRegistrationMessage(2)
|
||||||
|
except (ConnectionClosed, ConnectionResetError):
|
||||||
|
# ditto
|
||||||
|
command2 = None
|
||||||
|
else:
|
||||||
|
command2 = m2.command
|
||||||
|
|
||||||
|
self.assertNotEqual(
|
||||||
|
(command1, command2),
|
||||||
|
("001", "001"),
|
||||||
|
"Two concurrently registering requesting the same nickname "
|
||||||
|
"both got 001.",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"001",
|
||||||
|
(command1, command2),
|
||||||
|
"Two concurrently registering requesting the same nickname "
|
||||||
|
"neither got 001.",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2", "Nefarious", "ngIRCd"],
|
||||||
|
"uses a default value instead of ERR_NEEDMOREPARAMS",
|
||||||
|
)
|
||||||
|
def testEmptyRealname(self):
|
||||||
|
"""
|
||||||
|
Syntax:
|
||||||
|
"<client> <command> :Not enough parameters"
|
||||||
|
-- https://defs.ircdocs.horse/defs/numerics.html#err-needmoreparams-461
|
||||||
|
-- https://modern.ircdocs.horse/#errneedmoreparams-461
|
||||||
|
|
||||||
|
Use of this numeric:
|
||||||
|
"The minimum length of `<username>` is 1, ie. it MUST not be empty.
|
||||||
|
If it is empty, the server SHOULD reject the command with ERR_NEEDMOREPARAMS
|
||||||
|
(even an empty parameter is provided)"
|
||||||
|
https://github.com/ircdocs/modern-irc/issues/85
|
||||||
|
"""
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "USER username * * :")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getRegistrationMessage(1),
|
||||||
|
command=ERR_NEEDMOREPARAMS,
|
||||||
|
params=[StrRe(r"(\*|foo)"), "USER", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
def testNonutf8Realname(self):
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
line = b"USER username * * :i\xe8rc\xe9\r\n"
|
||||||
|
print("1 -> S (repr): " + repr(line))
|
||||||
|
self.clients[1].conn.sendall(line)
|
||||||
|
for _ in range(10):
|
||||||
|
time.sleep(1)
|
||||||
|
d = self.clients[1].conn.recv(10000)
|
||||||
|
self.assertTrue(d, "Server closed connection")
|
||||||
|
print("S -> 1 (repr): " + repr(d))
|
||||||
|
if b" 001 " in d:
|
||||||
|
break
|
||||||
|
if b"ERROR " in d or b" FAIL " in d:
|
||||||
|
# Rejected; nothing more to test.
|
||||||
|
return
|
||||||
|
for line in d.split(b"\r\n"):
|
||||||
|
if line.startswith(b"PING "):
|
||||||
|
line = line.replace(b"PING", b"PONG") + b"\r\n"
|
||||||
|
print("1 -> S (repr): " + repr(line))
|
||||||
|
self.clients[1].conn.sendall(line)
|
||||||
|
else:
|
||||||
|
self.assertTrue(False, "stuck waiting")
|
||||||
|
self.sendLine(1, "WHOIS foo")
|
||||||
|
time.sleep(3) # for ngIRCd
|
||||||
|
d = self.clients[1].conn.recv(10000)
|
||||||
|
print("S -> 1 (repr): " + repr(d))
|
||||||
|
self.assertIn(b"username", d)
|
||||||
|
|
||||||
|
def testNonutf8Username(self):
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(1, "NICK foo")
|
||||||
|
self.sendLine(1, "USER 😊😊😊😊😊😊😊😊😊😊 * * :realname")
|
||||||
|
for _ in range(10):
|
||||||
|
time.sleep(1)
|
||||||
|
d = self.clients[1].conn.recv(10000)
|
||||||
|
self.assertTrue(d, "Server closed connection")
|
||||||
|
print("S -> 1 (repr): " + repr(d))
|
||||||
|
if b" 001 " in d:
|
||||||
|
break
|
||||||
|
if b" 468" in d or b"ERROR " in d:
|
||||||
|
# Rejected; nothing more to test.
|
||||||
|
return
|
||||||
|
for line in d.split(b"\r\n"):
|
||||||
|
if line.startswith(b"PING "):
|
||||||
|
line = line.replace(b"PING", b"PONG") + b"\r\n"
|
||||||
|
print("1 -> S (repr): " + repr(line))
|
||||||
|
self.clients[1].conn.sendall(line)
|
||||||
|
else:
|
||||||
|
self.assertTrue(False, "stuck waiting")
|
||||||
|
self.sendLine(1, "WHOIS foo")
|
||||||
|
d = self.clients[1].conn.recv(10000)
|
||||||
|
print("S -> 1 (repr): " + repr(d))
|
||||||
|
self.assertIn(b"realname", d)
|
132
irctest/server_tests/echo_message.py
Normal file
132
irctest/server_tests/echo_message.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 echo-message <https://ircv3.net/specs/extensions/echo-message>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.irc_utils.junkdrawer import random_name
|
||||||
|
from irctest.patma import ANYDICT
|
||||||
|
|
||||||
|
|
||||||
|
class EchoMessageTestCase(cases.BaseServerTestCase):
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"command,solo,server_time",
|
||||||
|
[
|
||||||
|
("PRIVMSG", False, False),
|
||||||
|
("PRIVMSG", True, True),
|
||||||
|
("PRIVMSG", False, True),
|
||||||
|
("NOTICE", False, True),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
@cases.mark_capabilities("echo-message")
|
||||||
|
def testEchoMessage(self, command, solo, server_time):
|
||||||
|
"""<http://ircv3.net/specs/extensions/echo-message-3.2.html>"""
|
||||||
|
capabilities = ["server-time"] if server_time else []
|
||||||
|
|
||||||
|
self.connectClient(
|
||||||
|
"baz",
|
||||||
|
capabilities=["echo-message", *capabilities],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #chan")
|
||||||
|
|
||||||
|
# Synchronize
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
if not solo:
|
||||||
|
self.connectClient("qux", capabilities=capabilities)
|
||||||
|
self.sendLine(2, "JOIN #chan")
|
||||||
|
|
||||||
|
# Synchronize and clean
|
||||||
|
self.getMessages(1)
|
||||||
|
if not solo:
|
||||||
|
self.getMessages(2)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "{} #chan :hello everyone".format(command))
|
||||||
|
m1 = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m1,
|
||||||
|
command=command,
|
||||||
|
params=["#chan", "hello everyone"],
|
||||||
|
fail_msg="Did not echo “{} #chan :hello everyone”: {msg}",
|
||||||
|
extra_format=(command,),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not solo:
|
||||||
|
m2 = self.getMessage(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m2,
|
||||||
|
command=command,
|
||||||
|
params=["#chan", "hello everyone"],
|
||||||
|
fail_msg="Did not propagate “{} #chan :hello everyone”: "
|
||||||
|
"after echoing it to the author: {msg}",
|
||||||
|
extra_format=(command,),
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
m1.params,
|
||||||
|
m2.params,
|
||||||
|
fail_msg="Parameters of forwarded and echoed " "messages differ: {} {}",
|
||||||
|
extra_format=(m1, m2),
|
||||||
|
)
|
||||||
|
if server_time:
|
||||||
|
self.assertIn(
|
||||||
|
"time",
|
||||||
|
m1.tags,
|
||||||
|
fail_msg="Echoed message is missing server time: {}",
|
||||||
|
extra_format=(m1,),
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"time",
|
||||||
|
m2.tags,
|
||||||
|
fail_msg="Forwarded message is missing server time: {}",
|
||||||
|
extra_format=(m2,),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.arbitrary_client_tags
|
||||||
|
@cases.mark_capabilities(
|
||||||
|
"batch", "labeled-response", "echo-message", "message-tags"
|
||||||
|
)
|
||||||
|
def testDirectMessageEcho(self):
|
||||||
|
bar = random_name("bar")
|
||||||
|
self.connectClient(
|
||||||
|
bar,
|
||||||
|
name=bar,
|
||||||
|
capabilities=["batch", "labeled-response", "echo-message", "message-tags"],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
self.getMessages(bar)
|
||||||
|
|
||||||
|
qux = random_name("qux")
|
||||||
|
self.connectClient(
|
||||||
|
qux,
|
||||||
|
name=qux,
|
||||||
|
capabilities=["batch", "labeled-response", "echo-message", "message-tags"],
|
||||||
|
)
|
||||||
|
self.getMessages(qux)
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
bar,
|
||||||
|
"@label=xyz;+example-client-tag=example-value PRIVMSG %s :hi there"
|
||||||
|
% (qux,),
|
||||||
|
)
|
||||||
|
echo = self.getMessages(bar)[0]
|
||||||
|
delivery = self.getMessages(qux)[0]
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
echo,
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=[qux, "hi there"],
|
||||||
|
tags={"label": "xyz", "+example-client-tag": "example-value", **ANYDICT},
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
delivery,
|
||||||
|
command="PRIVMSG",
|
||||||
|
params=[qux, "hi there"],
|
||||||
|
tags={"+example-client-tag": "example-value", **ANYDICT},
|
||||||
|
)
|
||||||
|
|
||||||
|
# Either both messages have a msgid, or neither does
|
||||||
|
self.assertEqual(delivery.tags.get("msgid"), echo.tags.get("msgid"))
|
0
irctest/server_tests/ergo/__init__.py
Normal file
0
irctest/server_tests/ergo/__init__.py
Normal file
29
irctest/server_tests/ergo/services.py
Normal file
29
irctest/server_tests/ergo/services.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`-specific tests of NickServ.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import RPL_YOUREOPER
|
||||||
|
|
||||||
|
|
||||||
|
class NickservTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def test_saregister(self):
|
||||||
|
self.connectClient("root", name="root")
|
||||||
|
self.sendLine("root", "OPER operuser operpassword")
|
||||||
|
self.assertIn(RPL_YOUREOPER, {msg.command for msg in self.getMessages("root")})
|
||||||
|
|
||||||
|
self.sendLine(
|
||||||
|
"root",
|
||||||
|
"PRIVMSG NickServ :SAREGISTER saregister_test saregistertestpassphrase",
|
||||||
|
)
|
||||||
|
self.getMessages("root")
|
||||||
|
|
||||||
|
# test that the account was registered
|
||||||
|
self.connectClient(
|
||||||
|
name="saregister_test",
|
||||||
|
nick="saregister_test",
|
||||||
|
capabilities=["sasl"],
|
||||||
|
account="saregister_test",
|
||||||
|
password="saregistertestpassphrase",
|
||||||
|
)
|
69
irctest/server_tests/extended_join.py
Normal file
69
irctest/server_tests/extended_join.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 extended-join <https://ircv3.net/specs/extensions/extended-join>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
class MetadataTestCase(cases.BaseServerTestCase):
|
||||||
|
def connectRegisteredClient(self, nick):
|
||||||
|
self.addClient()
|
||||||
|
self.sendLine(2, "CAP LS 302")
|
||||||
|
capabilities = self.getCapLs(2)
|
||||||
|
assert "sasl" in capabilities
|
||||||
|
self.requestCapabilities(2, ["sasl"], skip_if_cap_nak=False)
|
||||||
|
self.sendLine(2, "AUTHENTICATE PLAIN")
|
||||||
|
m = self.getRegistrationMessage(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="AUTHENTICATE",
|
||||||
|
params=["+"],
|
||||||
|
fail_msg="Sent “AUTHENTICATE PLAIN”, server should have "
|
||||||
|
"replied with “AUTHENTICATE +”, but instead sent: {msg}",
|
||||||
|
)
|
||||||
|
self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=")
|
||||||
|
m = self.getRegistrationMessage(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="900",
|
||||||
|
fail_msg="Did not send 900 after correct SASL authentication.",
|
||||||
|
)
|
||||||
|
self.sendLine(2, "USER f * * :Realname")
|
||||||
|
self.sendLine(2, "NICK {}".format(nick))
|
||||||
|
self.sendLine(2, "CAP END")
|
||||||
|
self.skipToWelcome(2)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("extended-join")
|
||||||
|
def testNotLoggedIn(self):
|
||||||
|
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.joinChannel(2, "#chan")
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="JOIN",
|
||||||
|
params=["#chan", "*", "Realname"],
|
||||||
|
fail_msg="Expected “JOIN #chan * :Realname” after "
|
||||||
|
"unregistered user joined, got: {msg}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("extended-join")
|
||||||
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
def testLoggedIn(self):
|
||||||
|
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
|
||||||
|
self.joinChannel(1, "#chan")
|
||||||
|
|
||||||
|
self.controller.registerUser(self, "jilles", "sesame")
|
||||||
|
self.connectRegisteredClient("bar")
|
||||||
|
self.joinChannel(2, "#chan")
|
||||||
|
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="JOIN",
|
||||||
|
params=["#chan", "jilles", "Realname"],
|
||||||
|
fail_msg="Expected “JOIN #chan * :Realname” after "
|
||||||
|
"nick “bar” logged in as “jilles” joined, got: {msg}",
|
||||||
|
)
|
126
irctest/server_tests/help.py
Normal file
126
irctest/server_tests/help.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
"""
|
||||||
|
The HELP and HELPOP command (`Modern <https://modern.ircdocs.horse/#help-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import (
|
||||||
|
ERR_HELPNOTFOUND,
|
||||||
|
ERR_UNKNOWNCOMMAND,
|
||||||
|
RPL_ENDOFHELP,
|
||||||
|
RPL_HELPSTART,
|
||||||
|
RPL_HELPTXT,
|
||||||
|
)
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
def with_xfails(f):
|
||||||
|
@functools.wraps(f)
|
||||||
|
def newf(self, command, *args, **kwargs):
|
||||||
|
if command == "HELP" and self.controller.software_name == "Bahamut":
|
||||||
|
raise runner.ImplementationChoice(
|
||||||
|
"fail because Bahamut forwards /HELP to HelpServ (but not /HELPOP)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.controller.software_name in ("irc2", "ircu2", "ngIRCd"):
|
||||||
|
raise runner.ImplementationChoice(
|
||||||
|
"numerics in reply to /HELP and /HELPOP (uses NOTICE instead)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.controller.software_name == "UnrealIRCd":
|
||||||
|
raise runner.ImplementationChoice(
|
||||||
|
"fails because Unreal uses custom numerics "
|
||||||
|
"https://github.com/unrealircd/unrealircd/pull/184"
|
||||||
|
)
|
||||||
|
|
||||||
|
return f(self, command, *args, **kwargs)
|
||||||
|
|
||||||
|
return newf
|
||||||
|
|
||||||
|
|
||||||
|
class HelpTestCase(cases.BaseServerTestCase):
|
||||||
|
def _assertValidHelp(self, messages, subject):
|
||||||
|
if subject != ANYSTR:
|
||||||
|
subject = StrRe("(?i)" + re.escape(subject))
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages[0],
|
||||||
|
command=RPL_HELPSTART,
|
||||||
|
params=["nick", subject, ANYSTR],
|
||||||
|
fail_msg=f"Expected {RPL_HELPSTART} (RPL_HELPSTART), got: {{msg}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages[-1],
|
||||||
|
command=RPL_ENDOFHELP,
|
||||||
|
params=["nick", subject, ANYSTR],
|
||||||
|
fail_msg=f"Expected {RPL_ENDOFHELP} (RPL_ENDOFHELP), got: {{msg}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
for i in range(1, len(messages) - 1):
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages[i],
|
||||||
|
command=RPL_HELPTXT,
|
||||||
|
params=["nick", subject, ANYSTR],
|
||||||
|
fail_msg=f"Expected {RPL_HELPTXT} (RPL_HELPTXT), got: {{msg}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@with_xfails
|
||||||
|
def testHelpNoArg(self, command):
|
||||||
|
self.connectClient("nick")
|
||||||
|
self.sendLine(1, f"{command}")
|
||||||
|
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
|
||||||
|
if messages[0].command == ERR_UNKNOWNCOMMAND:
|
||||||
|
raise runner.OptionalCommandNotSupported(command)
|
||||||
|
|
||||||
|
self._assertValidHelp(messages, ANYSTR)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@with_xfails
|
||||||
|
def testHelpPrivmsg(self, command):
|
||||||
|
self.connectClient("nick")
|
||||||
|
self.sendLine(1, f"{command} PRIVMSG")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
|
||||||
|
if messages[0].command == ERR_UNKNOWNCOMMAND:
|
||||||
|
raise runner.OptionalCommandNotSupported(command)
|
||||||
|
|
||||||
|
self._assertValidHelp(messages, "PRIVMSG")
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@with_xfails
|
||||||
|
def testHelpUnknownSubject(self, command):
|
||||||
|
self.connectClient("nick")
|
||||||
|
self.sendLine(1, f"{command} THISISNOTACOMMAND")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
|
||||||
|
if messages[0].command == ERR_UNKNOWNCOMMAND:
|
||||||
|
raise runner.OptionalCommandNotSupported(command)
|
||||||
|
|
||||||
|
if messages[0].command == ERR_HELPNOTFOUND:
|
||||||
|
# Inspircd, Hybrid et al
|
||||||
|
self.assertEqual(len(messages), 1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages[0],
|
||||||
|
command=ERR_HELPNOTFOUND,
|
||||||
|
params=[
|
||||||
|
"nick",
|
||||||
|
StrRe(
|
||||||
|
"(?i)THISISNOTACOMMAND"
|
||||||
|
), # case-insensitive, for Hybrid and Plexus4 (but not Chary et al)
|
||||||
|
ANYSTR,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Unrealircd
|
||||||
|
self._assertValidHelp(messages, ANYSTR)
|
118
irctest/server_tests/info.py
Normal file
118
irctest/server_tests/info.py
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"""
|
||||||
|
The INFO command (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#info-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import ERR_NOSUCHSERVER, RPL_ENDOFINFO, RPL_INFO, RPL_YOUREOPER
|
||||||
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
|
|
||||||
|
class InfoTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
def testInfo(self):
|
||||||
|
"""
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
|
||||||
|
|
||||||
|
"Upon receiving an INFO command, the given server will respond with zero or
|
||||||
|
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
|
||||||
|
-- <https://modern.ircdocs.horse/#info-message>
|
||||||
|
"""
|
||||||
|
self.connectClient("nick")
|
||||||
|
|
||||||
|
# Remote /INFO is oper-only on Unreal and ircu2
|
||||||
|
self.sendLine(1, "OPER operuser operpassword")
|
||||||
|
self.assertIn(
|
||||||
|
RPL_YOUREOPER,
|
||||||
|
[m.command for m in self.getMessages(1)],
|
||||||
|
fail_msg="OPER failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, "INFO")
|
||||||
|
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
last_message = messages.pop()
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR]
|
||||||
|
)
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"target",
|
||||||
|
["My.Little.Server", "*Little*", "nick"],
|
||||||
|
ids=["target-server", "target-wildcard", "target-nick"],
|
||||||
|
)
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
|
||||||
|
def testInfoTarget(self, target):
|
||||||
|
"""
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
|
||||||
|
|
||||||
|
"Upon receiving an INFO command, the given server will respond with zero or
|
||||||
|
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
|
||||||
|
-- <https://modern.ircdocs.horse/#info-message>
|
||||||
|
"""
|
||||||
|
self.connectClient("nick")
|
||||||
|
|
||||||
|
# Remote /INFO is oper-only on Unreal and ircu2
|
||||||
|
self.sendLine(1, "OPER operuser operpassword")
|
||||||
|
self.assertIn(
|
||||||
|
RPL_YOUREOPER,
|
||||||
|
[m.command for m in self.getMessages(1)],
|
||||||
|
fail_msg="OPER failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
if target:
|
||||||
|
self.sendLine(1, "INFO My.Little.Server")
|
||||||
|
else:
|
||||||
|
self.sendLine(1, "INFO")
|
||||||
|
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
last_message = messages.pop()
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR]
|
||||||
|
)
|
||||||
|
|
||||||
|
for message in messages:
|
||||||
|
self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR])
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("target", ["invalid.server.example", "invalidserver"])
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Ergo"], "does not apply to Ergo, which ignores the optional <target> argument"
|
||||||
|
)
|
||||||
|
def testInfoNosuchserver(self, target):
|
||||||
|
"""
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
|
||||||
|
|
||||||
|
"Upon receiving an INFO command, the given server will respond with zero or
|
||||||
|
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
|
||||||
|
-- <https://modern.ircdocs.horse/#info-message>
|
||||||
|
"""
|
||||||
|
self.connectClient("nick")
|
||||||
|
|
||||||
|
# Remote /INFO is oper-only on Unreal and ircu2
|
||||||
|
self.sendLine(1, "OPER operuser operpassword")
|
||||||
|
self.assertIn(
|
||||||
|
RPL_YOUREOPER,
|
||||||
|
[m.command for m in self.getMessages(1)],
|
||||||
|
fail_msg="OPER failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sendLine(1, f"INFO {target}")
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=ERR_NOSUCHSERVER,
|
||||||
|
params=["nick", target, ANYSTR],
|
||||||
|
)
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user