mirror of
https://github.com/progval/irctest.git
synced 2025-04-05 14:59:49 +00:00
Compare commits
172 Commits
privmsg-se
...
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 |
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()
|
990
.github/workflows/test-devel.yml
vendored
990
.github/workflows/test-devel.yml
vendored
File diff suppressed because it is too large
Load Diff
162
.github/workflows/test-devel_release.yml
vendored
162
.github/workflows/test-devel_release.yml
vendored
@ -3,28 +3,32 @@
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-anope:
|
build-anope:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Create directories
|
- name: Create directories
|
||||||
run: cd ~/; mkdir -p .local/ go/
|
run: cd ~/; mkdir -p .local/ go/
|
||||||
- name: Cache Anope
|
- name: Cache dependencies
|
||||||
uses: actions/cache@v2
|
uses: actions/cache@v3
|
||||||
with:
|
with:
|
||||||
key: 3-${{ runner.os }}-anope-2.0.9
|
key: 3-${{ runner.os }}-anope-devel_release
|
||||||
path: '~/.cache
|
path: '~/.cache
|
||||||
|
|
||||||
${{ github.workspace }}/anope
|
${ 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
|
- name: Checkout Anope
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
path: anope
|
path: anope
|
||||||
ref: 2.0.9
|
ref: 2.0.9
|
||||||
repository: anope/anope
|
repository: anope/anope
|
||||||
- name: Build Anope
|
- name: Build Anope
|
||||||
run: |-
|
run: |
|
||||||
cd $GITHUB_WORKSPACE/anope/
|
cd $GITHUB_WORKSPACE/anope/
|
||||||
cp $GITHUB_WORKSPACE/data/anope/* .
|
cp $GITHUB_WORKSPACE/data/anope/* .
|
||||||
CFLAGS=-O0 ./Config -quick
|
CFLAGS=-O0 ./Config -quick
|
||||||
@ -33,23 +37,23 @@ jobs:
|
|||||||
- name: Make artefact tarball
|
- name: Make artefact tarball
|
||||||
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
|
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
|
||||||
- name: Upload build artefacts
|
- name: Upload build artefacts
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: installed-anope
|
name: installed-anope
|
||||||
path: ~/artefacts-*.tar.gz
|
path: ~/artefacts-*.tar.gz
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
build-inspircd:
|
build-inspircd:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- name: Create directories
|
- name: Create directories
|
||||||
run: cd ~/; mkdir -p .local/ go/
|
run: cd ~/; mkdir -p .local/ go/
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.7
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: 3.11
|
||||||
- name: Checkout InspIRCd
|
- name: Checkout InspIRCd
|
||||||
uses: actions/checkout@v2
|
uses: actions/checkout@v3
|
||||||
with:
|
with:
|
||||||
path: inspircd
|
path: inspircd
|
||||||
ref: insp3
|
ref: insp3
|
||||||
@ -57,154 +61,158 @@ jobs:
|
|||||||
- name: Build InspIRCd
|
- name: Build InspIRCd
|
||||||
run: |
|
run: |
|
||||||
cd $GITHUB_WORKSPACE/inspircd/
|
cd $GITHUB_WORKSPACE/inspircd/
|
||||||
patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch
|
|
||||||
|
# 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
|
./configure --prefix=$HOME/.local/inspircd --development
|
||||||
make -j 4
|
|
||||||
|
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
|
||||||
make install
|
make install
|
||||||
- name: Make artefact tarball
|
- name: Make artefact tarball
|
||||||
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
|
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
|
||||||
- name: Upload build artefacts
|
- name: Upload build artefacts
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: installed-inspircd
|
name: installed-inspircd
|
||||||
path: ~/artefacts-*.tar.gz
|
path: ~/artefacts-*.tar.gz
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
publish-test-results:
|
publish-test-results:
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
name: Publish Unit Tests Results
|
name: Publish Dashboard
|
||||||
needs:
|
needs:
|
||||||
- test-inspircd
|
- test-inspircd
|
||||||
- test-inspircd-anope
|
- test-inspircd-anope
|
||||||
- test-inspircd-atheme
|
- test-inspircd-atheme
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Download Artifacts
|
- name: Download Artifacts
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: artifacts
|
path: artifacts
|
||||||
- if: github.event_name == 'pull_request'
|
- name: Install dashboard dependencies
|
||||||
name: Publish Unit Test Results
|
run: |-
|
||||||
uses: actions/github-script@v4
|
python -m pip install --upgrade pip
|
||||||
with:
|
pip install defusedxml docutils -r requirements.txt
|
||||||
result-encoding: string
|
- name: Generate dashboard
|
||||||
script: |
|
run: |-
|
||||||
let body = '';
|
shopt -s globstar
|
||||||
const options = {};
|
python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml
|
||||||
options.listeners = {
|
echo '/ /index.xhtml' > dashboard/_redirects
|
||||||
stdout: (data) => {
|
- name: Install netlify-cli
|
||||||
body += data.toString();
|
run: npm i -g netlify-cli
|
||||||
}
|
- env:
|
||||||
};
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
await exec.exec('bash', ['-c', 'shopt -s globstar; python3 report.py artifacts/**/*.xml'], options);
|
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||||
github.issues.createComment({
|
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
|
||||||
issue_number: context.issue.number,
|
name: Deploy to Netlify
|
||||||
owner: context.repo.owner,
|
run: ./.github/deploy_to_netlify.py
|
||||||
repo: context.repo.repo,
|
|
||||||
body: body,
|
|
||||||
});
|
|
||||||
return body;
|
|
||||||
test-inspircd:
|
test-inspircd:
|
||||||
needs:
|
needs:
|
||||||
- build-inspircd
|
- build-inspircd
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.7
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: 3.11
|
||||||
- name: Download build artefacts
|
- name: Download build artefacts
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: installed-inspircd
|
name: installed-inspircd
|
||||||
path: '~'
|
path: '~'
|
||||||
- name: Unpack artefacts
|
- name: Unpack artefacts
|
||||||
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
||||||
- name: Install Atheme
|
- name: Install system dependencies
|
||||||
run: sudo apt-get install atheme-services
|
run: sudo apt-get install atheme-services faketime
|
||||||
- name: Install irctest dependencies
|
- name: Install irctest dependencies
|
||||||
run: |-
|
run: |-
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pytest pytest-xdist -r requirements.txt
|
pip install pytest pytest-xdist -r requirements.txt
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH
|
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
|
||||||
make inspircd
|
make inspircd
|
||||||
|
timeout-minutes: 30
|
||||||
- if: always()
|
- if: always()
|
||||||
name: Publish results
|
name: Publish results
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: pytest results inspircd (devel_release)
|
name: pytest-results_inspircd_devel_release
|
||||||
path: pytest.xml
|
path: pytest.xml
|
||||||
test-inspircd-anope:
|
test-inspircd-anope:
|
||||||
needs:
|
needs:
|
||||||
- build-inspircd
|
- build-inspircd
|
||||||
- build-anope
|
- build-anope
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.7
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: 3.11
|
||||||
- name: Download build artefacts
|
- name: Download build artefacts
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: installed-inspircd
|
name: installed-inspircd
|
||||||
path: '~'
|
path: '~'
|
||||||
- name: Download build artefacts
|
- name: Download build artefacts
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: installed-anope
|
name: installed-anope
|
||||||
path: '~'
|
path: '~'
|
||||||
- name: Unpack artefacts
|
- name: Unpack artefacts
|
||||||
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
||||||
- name: Install Atheme
|
- name: Install system dependencies
|
||||||
run: sudo apt-get install atheme-services
|
run: sudo apt-get install atheme-services faketime
|
||||||
- name: Install irctest dependencies
|
- name: Install irctest dependencies
|
||||||
run: |-
|
run: |-
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pytest pytest-xdist -r requirements.txt
|
pip install pytest pytest-xdist -r requirements.txt
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH make
|
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
|
inspircd-anope
|
||||||
|
timeout-minutes: 30
|
||||||
- if: always()
|
- if: always()
|
||||||
name: Publish results
|
name: Publish results
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: pytest results inspircd-anope (devel_release)
|
name: pytest-results_inspircd-anope_devel_release
|
||||||
path: pytest.xml
|
path: pytest.xml
|
||||||
test-inspircd-atheme:
|
test-inspircd-atheme:
|
||||||
needs:
|
needs:
|
||||||
- build-inspircd
|
- build-inspircd
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-22.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v3
|
||||||
- name: Set up Python 3.7
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: 3.7
|
python-version: 3.11
|
||||||
- name: Download build artefacts
|
- name: Download build artefacts
|
||||||
uses: actions/download-artifact@v2
|
uses: actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: installed-inspircd
|
name: installed-inspircd
|
||||||
path: '~'
|
path: '~'
|
||||||
- name: Unpack artefacts
|
- name: Unpack artefacts
|
||||||
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
|
||||||
- name: Install Atheme
|
- name: Install system dependencies
|
||||||
run: sudo apt-get install atheme-services
|
run: sudo apt-get install atheme-services faketime
|
||||||
- name: Install irctest dependencies
|
- name: Install irctest dependencies
|
||||||
run: |-
|
run: |-
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install pytest pytest-xdist -r requirements.txt
|
pip install pytest pytest-xdist -r requirements.txt
|
||||||
- name: Test with pytest
|
- name: Test with pytest
|
||||||
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH
|
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
|
make inspircd-atheme
|
||||||
|
timeout-minutes: 30
|
||||||
- if: always()
|
- if: always()
|
||||||
name: Publish results
|
name: Publish results
|
||||||
uses: actions/upload-artifact@v2
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: pytest results inspircd-atheme (devel_release)
|
name: pytest-results_inspircd-atheme_devel_release
|
||||||
path: pytest.xml
|
path: pytest.xml
|
||||||
name: irctest with devel_release versions
|
name: irctest with devel_release versions
|
||||||
'on':
|
'on':
|
||||||
|
1080
.github/workflows/test-stable.yml
vendored
1080
.github/workflows/test-stable.yml
vendored
File diff suppressed because it is too large
Load Diff
@ -2,22 +2,23 @@ exclude: ^irctest/scram
|
|||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/psf/black
|
- repo: https://github.com/psf/black
|
||||||
rev: 20.8b1
|
rev: 23.1.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
language_version: python3
|
language_version: python3
|
||||||
|
|
||||||
- repo: https://github.com/PyCQA/isort
|
- repo: https://github.com/PyCQA/isort
|
||||||
rev: 5.5.2
|
rev: 5.11.5
|
||||||
hooks:
|
hooks:
|
||||||
- id: isort
|
- id: isort
|
||||||
|
|
||||||
- repo: https://gitlab.com/pycqa/flake8
|
- repo: https://github.com/PyCQA/flake8
|
||||||
rev: 3.8.3
|
rev: 5.0.4
|
||||||
hooks:
|
hooks:
|
||||||
- id: flake8
|
- id: flake8
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v0.812
|
rev: v1.0.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
|
additional_dependencies: [types-PyYAML, types-docutils]
|
||||||
|
154
Makefile
154
Makefile
@ -7,97 +7,56 @@ PYTEST_ARGS ?=
|
|||||||
# Will be appended at the end of the -k argument to pytest
|
# Will be appended at the end of the -k argument to pytest
|
||||||
EXTRA_SELECTORS ?=
|
EXTRA_SELECTORS ?=
|
||||||
|
|
||||||
# testPlainLarge fails because it doesn't handle split AUTHENTICATE (reported on IRC)
|
|
||||||
ANOPE_SELECTORS := \
|
|
||||||
and not testPlainLarge
|
|
||||||
|
|
||||||
# buffering tests cannot pass because of issues with UTF-8 handling: https://github.com/DALnet/bahamut/issues/196
|
|
||||||
BAHAMUT_SELECTORS := \
|
BAHAMUT_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not IRCv3 \
|
and not IRCv3 \
|
||||||
and not buffering \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testQuitErrors is very flaky
|
|
||||||
# AccountTagTestCase.testInvite fails because https://github.com/solanum-ircd/solanum/issues/166
|
|
||||||
# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
|
|
||||||
# testWhoisNumerics[oper] fails because charybdis uses RPL_WHOISSPECIAL instead of RPL_WHOISOPERATOR
|
|
||||||
CHARYBDIS_SELECTORS := \
|
CHARYBDIS_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not testQuitErrors \
|
|
||||||
and not testKickDefaultComment \
|
|
||||||
and not (AccountTagTestCase and testInvite) \
|
|
||||||
and not (testWhoisNumerics and oper) \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
ERGO_SELECTORS := \
|
ERGO_SELECTORS := \
|
||||||
not deprecated \
|
not deprecated \
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testInviteUnoppedModern is the only strict test that Hybrid fails
|
|
||||||
HYBRID_SELECTORS := \
|
HYBRID_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not testInviteUnoppedModern \
|
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testNoticeNonexistentChannel fails because of https://github.com/inspircd/inspircd/issues/1849
|
|
||||||
# testBotPrivateMessage and testBotChannelMessage fail because https://github.com/inspircd/inspircd/pull/1910 is not released yet
|
|
||||||
# testNamesInvalidChannel and testNamesNonexistingChannel fail because https://github.com/inspircd/inspircd/pull/1922 is not released yet.
|
|
||||||
INSPIRCD_SELECTORS := \
|
INSPIRCD_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not testNoticeNonexistentChannel \
|
|
||||||
and not testBotPrivateMessage and not testBotChannelMessage \
|
|
||||||
and not testNamesInvalidChannel and not testNamesNonexistingChannel \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# buffering tests fail because ircu2 discards the whole buffer on long lines (TODO: refine how we exclude these tests)
|
|
||||||
# testQuit and testQuitErrors fail because ircu2 does not send ERROR or QUIT
|
|
||||||
# lusers tests fail because they depend on Modern behavior, not just RFC2812 (TODO: update lusers tests to accept RFC2812-compliant implementations)
|
|
||||||
# statusmsg tests fail because STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG target
|
|
||||||
# testKeyValidation[empty] fails because ircu2 returns ERR_NEEDMOREPARAMS on empty keys: https://github.com/UndernetIRC/ircu2/issues/13
|
|
||||||
# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
|
|
||||||
# testEmptyRealname fails because it uses a default value instead of ERR_NEEDMOREPARAMS.
|
|
||||||
IRCU2_SELECTORS := \
|
IRCU2_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not buffering \
|
|
||||||
and not testQuit \
|
|
||||||
and not lusers \
|
|
||||||
and not statusmsg \
|
|
||||||
and not (testKeyValidation and empty) \
|
|
||||||
and not testKickDefaultComment \
|
|
||||||
and not testEmptyRealname \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# same justification as ircu2
|
NEFARIOUS_SELECTORS := \
|
||||||
|
not Ergo \
|
||||||
|
and not deprecated \
|
||||||
|
and not strict \
|
||||||
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
SNIRCD_SELECTORS := \
|
SNIRCD_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not buffering \
|
|
||||||
and not testQuit \
|
|
||||||
and not lusers \
|
|
||||||
and not statusmsg \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testListEmpty and testListOne fails because irc2 deprecated LIST
|
|
||||||
# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
|
|
||||||
# testWallopsPrivileges fails because it ignores the command instead of replying ERR_UNKNOWNCOMMAND
|
|
||||||
IRC2_SELECTORS := \
|
IRC2_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not testListEmpty and not testListOne \
|
|
||||||
and not testKickDefaultComment \
|
|
||||||
and not testWallopsPrivileges \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
MAMMON_SELECTORS := \
|
MAMMON_SELECTORS := \
|
||||||
@ -106,26 +65,14 @@ MAMMON_SELECTORS := \
|
|||||||
and not strict \
|
and not strict \
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testKeyValidation[spaces] and testKeyValidation[empty] fail because ngIRCd does not validate them https://github.com/ngircd/ngircd/issues/290
|
|
||||||
# testStarNick: wat
|
|
||||||
# testEmptyRealname fails because it uses a default value instead of ERR_NEEDMOREPARAMS.
|
|
||||||
# chathistory tests fail because they need nicks longer than 9 chars
|
|
||||||
NGIRCD_SELECTORS := \
|
NGIRCD_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not (testKeyValidation and (spaces or empty)) \
|
|
||||||
and not testStarNick \
|
|
||||||
and not testEmptyRealname \
|
|
||||||
and not chathistory \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testInviteUnoppedModern is the only strict test that Plexus4 fails
|
|
||||||
# testInviteInviteOnlyModern fails because Plexus4 allows non-op to invite if (and only if) the channel is not invite-only
|
|
||||||
PLEXUS4_SELECTORS := \
|
PLEXUS4_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not testInviteUnoppedModern \
|
|
||||||
and not testInviteInviteOnlyModern \
|
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
@ -136,47 +83,47 @@ LIMNORIA_SELECTORS := \
|
|||||||
(foo or not foo) \
|
(foo or not foo) \
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testQuitErrors is too flaky for CI
|
SABLE_SELECTORS := \
|
||||||
# testKickDefaultComment fails because solanum uses the nick of the kickee rather than the kicker.
|
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 := \
|
SOLANUM_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not testQuitErrors \
|
|
||||||
and not testKickDefaultComment \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
|
# Same as Limnoria
|
||||||
SOPEL_SELECTORS := \
|
SOPEL_SELECTORS := \
|
||||||
not testPlainNotAvailable \
|
(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)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
# testNoticeNonexistentChannel fails: https://bugs.unrealircd.org/view.php?id=5949
|
|
||||||
# regressions::testTagCap fails: https://bugs.unrealircd.org/view.php?id=5948
|
|
||||||
# messages::testLineTooLong fails: https://bugs.unrealircd.org/view.php?id=5947
|
|
||||||
# testCapRemovalByClient and testNakWhole fail pending https://github.com/unrealircd/unrealircd/pull/148
|
|
||||||
# Tests marked with arbitrary_client_tags can't pass because Unreal whitelists which tags it relays
|
# 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 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
|
# Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs
|
||||||
# testChathistory[BETWEEN] fails: https://bugs.unrealircd.org/view.php?id=5952
|
|
||||||
# testChathistory[AROUND] fails: https://bugs.unrealircd.org/view.php?id=5953
|
|
||||||
UNREALIRCD_SELECTORS := \
|
UNREALIRCD_SELECTORS := \
|
||||||
not Ergo \
|
not Ergo \
|
||||||
and not deprecated \
|
and not deprecated \
|
||||||
and not strict \
|
and not strict \
|
||||||
and not testNoticeNonexistentChannel \
|
|
||||||
and not (regressions.py and testTagCap) \
|
|
||||||
and not (messages.py and testLineTooLong) \
|
|
||||||
and not (cap.py and (testCapRemovalByClient or testNakWhole)) \
|
|
||||||
and not (account_tag.py and testInvite) \
|
|
||||||
and not arbitrary_client_tags \
|
and not arbitrary_client_tags \
|
||||||
and not react_tag \
|
and not react_tag \
|
||||||
and not private_chathistory \
|
and not private_chathistory \
|
||||||
and not (testChathistory and (between or around)) \
|
|
||||||
$(EXTRA_SELECTORS)
|
$(EXTRA_SELECTORS)
|
||||||
|
|
||||||
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon limnoria sopel solanum unrealircd
|
.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 limnoria sopel solanum unrealircd
|
all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd
|
||||||
|
|
||||||
flakes:
|
flakes:
|
||||||
find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3
|
find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3
|
||||||
@ -185,7 +132,8 @@ bahamut:
|
|||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller=irctest.controllers.bahamut \
|
--controller=irctest.controllers.bahamut \
|
||||||
-m 'not services' \
|
-m 'not services' \
|
||||||
-n 10 \
|
-n 4 \
|
||||||
|
-vv -s \
|
||||||
-k '$(BAHAMUT_SELECTORS)'
|
-k '$(BAHAMUT_SELECTORS)'
|
||||||
|
|
||||||
bahamut-atheme:
|
bahamut-atheme:
|
||||||
@ -193,7 +141,6 @@ bahamut-atheme:
|
|||||||
--controller=irctest.controllers.bahamut \
|
--controller=irctest.controllers.bahamut \
|
||||||
--services-controller=irctest.controllers.atheme_services \
|
--services-controller=irctest.controllers.atheme_services \
|
||||||
-m 'services' \
|
-m 'services' \
|
||||||
-n 10 \
|
|
||||||
-k '$(BAHAMUT_SELECTORS)'
|
-k '$(BAHAMUT_SELECTORS)'
|
||||||
|
|
||||||
bahamut-anope:
|
bahamut-anope:
|
||||||
@ -201,8 +148,7 @@ bahamut-anope:
|
|||||||
--controller=irctest.controllers.bahamut \
|
--controller=irctest.controllers.bahamut \
|
||||||
--services-controller=irctest.controllers.anope_services \
|
--services-controller=irctest.controllers.anope_services \
|
||||||
-m 'services' \
|
-m 'services' \
|
||||||
-n 10 \
|
-k '$(BAHAMUT_SELECTORS)'
|
||||||
-k '$(BAHAMUT_SELECTORS) $(ANOPE_SELECTORS)'
|
|
||||||
|
|
||||||
charybdis:
|
charybdis:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
@ -218,7 +164,7 @@ ergo:
|
|||||||
hybrid:
|
hybrid:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller irctest.controllers.hybrid \
|
--controller irctest.controllers.hybrid \
|
||||||
-m 'not services' \
|
--services-controller=irctest.controllers.anope_services \
|
||||||
-k "$(HYBRID_SELECTORS)"
|
-k "$(HYBRID_SELECTORS)"
|
||||||
|
|
||||||
inspircd:
|
inspircd:
|
||||||
@ -239,27 +185,34 @@ inspircd-anope:
|
|||||||
--controller=irctest.controllers.inspircd \
|
--controller=irctest.controllers.inspircd \
|
||||||
--services-controller=irctest.controllers.anope_services \
|
--services-controller=irctest.controllers.anope_services \
|
||||||
-m 'services' \
|
-m 'services' \
|
||||||
-k '$(INSPIRCD_SELECTORS) $(ANOPE_SELECTORS)'
|
-k '$(INSPIRCD_SELECTORS)'
|
||||||
|
|
||||||
ircu2:
|
ircu2:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller=irctest.controllers.ircu2 \
|
--controller=irctest.controllers.ircu2 \
|
||||||
-m 'not services and not IRCv3' \
|
-m 'not services and not IRCv3' \
|
||||||
-n 10 \
|
-n 4 \
|
||||||
-k '$(IRCU2_SELECTORS)'
|
-k '$(IRCU2_SELECTORS)'
|
||||||
|
|
||||||
|
nefarious:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.nefarious \
|
||||||
|
-m 'not services' \
|
||||||
|
-n 4 \
|
||||||
|
-k '$(NEFARIOUS_SELECTORS)'
|
||||||
|
|
||||||
snircd:
|
snircd:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller=irctest.controllers.snircd \
|
--controller=irctest.controllers.snircd \
|
||||||
-m 'not services and not IRCv3' \
|
-m 'not services and not IRCv3' \
|
||||||
-n 10 \
|
-n 4 \
|
||||||
-k '$(SNIRCD_SELECTORS)'
|
-k '$(SNIRCD_SELECTORS)'
|
||||||
|
|
||||||
irc2:
|
irc2:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller=irctest.controllers.irc2 \
|
--controller=irctest.controllers.irc2 \
|
||||||
-m 'not services and not IRCv3' \
|
-m 'not services and not IRCv3' \
|
||||||
-n 10 \
|
-n 4 \
|
||||||
-k '$(IRC2_SELECTORS)'
|
-k '$(IRC2_SELECTORS)'
|
||||||
|
|
||||||
limnoria:
|
limnoria:
|
||||||
@ -275,14 +228,14 @@ mammon:
|
|||||||
plexus4:
|
plexus4:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller irctest.controllers.plexus4 \
|
--controller irctest.controllers.plexus4 \
|
||||||
-m 'not services' \
|
--services-controller=irctest.controllers.anope_services \
|
||||||
-k "$(PLEXUS4_SELECTORS)"
|
-k "$(PLEXUS4_SELECTORS)"
|
||||||
|
|
||||||
ngircd:
|
ngircd:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller irctest.controllers.ngircd \
|
--controller irctest.controllers.ngircd \
|
||||||
-m 'not services' \
|
-m 'not services' \
|
||||||
-n 10 \
|
-n 4 \
|
||||||
-k "$(NGIRCD_SELECTORS)"
|
-k "$(NGIRCD_SELECTORS)"
|
||||||
|
|
||||||
ngircd-anope:
|
ngircd-anope:
|
||||||
@ -299,6 +252,13 @@ ngircd-atheme:
|
|||||||
-m 'services' \
|
-m 'services' \
|
||||||
-k "$(NGIRCD_SELECTORS)"
|
-k "$(NGIRCD_SELECTORS)"
|
||||||
|
|
||||||
|
sable:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.sable \
|
||||||
|
-n 20 \
|
||||||
|
-m 'not services' \
|
||||||
|
-k '$(SABLE_SELECTORS)'
|
||||||
|
|
||||||
solanum:
|
solanum:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller=irctest.controllers.solanum \
|
--controller=irctest.controllers.solanum \
|
||||||
@ -310,12 +270,19 @@ sopel:
|
|||||||
--controller=irctest.controllers.sopel \
|
--controller=irctest.controllers.sopel \
|
||||||
-k '$(SOPEL_SELECTORS)'
|
-k '$(SOPEL_SELECTORS)'
|
||||||
|
|
||||||
|
thelounge:
|
||||||
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
|
--controller=irctest.controllers.thelounge \
|
||||||
|
-k '$(THELOUNGE_SELECTORS)'
|
||||||
|
|
||||||
unrealircd:
|
unrealircd:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller=irctest.controllers.unrealircd \
|
--controller=irctest.controllers.unrealircd \
|
||||||
-m 'not services' \
|
-m 'not services' \
|
||||||
-k '$(UNREALIRCD_SELECTORS)'
|
-k '$(UNREALIRCD_SELECTORS)'
|
||||||
|
|
||||||
|
unrealircd-5: unrealircd
|
||||||
|
|
||||||
unrealircd-atheme:
|
unrealircd-atheme:
|
||||||
$(PYTEST) $(PYTEST_ARGS) \
|
$(PYTEST) $(PYTEST_ARGS) \
|
||||||
--controller=irctest.controllers.unrealircd \
|
--controller=irctest.controllers.unrealircd \
|
||||||
@ -328,4 +295,11 @@ unrealircd-anope:
|
|||||||
--controller=irctest.controllers.unrealircd \
|
--controller=irctest.controllers.unrealircd \
|
||||||
--services-controller=irctest.controllers.anope_services \
|
--services-controller=irctest.controllers.anope_services \
|
||||||
-m 'services' \
|
-m 'services' \
|
||||||
-k '$(UNREALIRCD_SELECTORS) $(ANOPE_SELECTORS)'
|
-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)'
|
||||||
|
12
README.md
12
README.md
@ -18,11 +18,11 @@ have no side effect.
|
|||||||
Install irctest and dependencies:
|
Install irctest and dependencies:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
sudo apt install faketime # Optional, but greatly speeds up irctest/server_tests/list.py
|
||||||
cd ~
|
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/` (and/or `~/go/bin/` for Ergo)
|
Add `~/.local/bin/` (and/or `~/go/bin/` for Ergo)
|
||||||
@ -110,8 +110,14 @@ cd /tmp/
|
|||||||
git clone https://github.com/inspircd/inspircd.git
|
git clone https://github.com/inspircd/inspircd.git
|
||||||
cd inspircd
|
cd inspircd
|
||||||
|
|
||||||
# optional, makes tests run considerably faster
|
# Optional, makes tests run considerably faster. Pick one depending on the InspIRCd version:
|
||||||
patch src/inspircd.cpp < ~/irctest/inspircd_mainloop.patch
|
# 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
|
||||||
|
|
||||||
|
# third-party module, used in named-modes tests because the spec is not implemented upstream
|
||||||
|
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/ --development
|
./configure --prefix=$HOME/.local/ --development
|
||||||
make -j 4
|
make -j 4
|
||||||
|
@ -106,7 +106,10 @@ def pytest_collection_modifyitems(session, config, items):
|
|||||||
assert isinstance(item, _pytest.python.Function)
|
assert isinstance(item, _pytest.python.Function)
|
||||||
|
|
||||||
# unittest-style test functions have the node of UnitTest class as parent
|
# unittest-style test functions have the node of UnitTest class as parent
|
||||||
assert isinstance(item.parent, _pytest.python.Instance)
|
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
|
# and that node references the UnitTest class
|
||||||
assert issubclass(item.parent.cls, _IrcTestCase)
|
assert issubclass(item.parent.cls, _IrcTestCase)
|
||||||
|
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-----
|
@ -19,6 +19,10 @@ SHOWLISTMODES="1"
|
|||||||
NOOPEROVERRIDE=""
|
NOOPEROVERRIDE=""
|
||||||
OPEROVERRIDEVERIFY=""
|
OPEROVERRIDEVERIFY=""
|
||||||
GENCERTIFICATE="1"
|
GENCERTIFICATE="1"
|
||||||
EXTRAPARA=""
|
|
||||||
|
# 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=""
|
ADVANCED=""
|
||||||
|
|
||||||
|
@ -1,18 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
|
import json
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
import textwrap
|
||||||
import time
|
import time
|
||||||
from typing import IO, Any, Callable, Dict, List, Optional, Set, Tuple, Type
|
from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type
|
||||||
|
|
||||||
import irctest
|
import irctest
|
||||||
|
|
||||||
from . import authentication, tls
|
from . import authentication, tls
|
||||||
from .client_mock import ClientMock
|
from .client_mock import ClientMock
|
||||||
|
from .irc_utils.filelock import FileLock
|
||||||
from .irc_utils.junkdrawer import find_hostname_and_port
|
from .irc_utils.junkdrawer import find_hostname_and_port
|
||||||
from .irc_utils.message_parser import Message
|
from .irc_utils.message_parser import Message
|
||||||
from .runner import NotImplementedByController
|
from .runner import NotImplementedByController
|
||||||
@ -54,17 +59,47 @@ class _BaseController:
|
|||||||
|
|
||||||
supports_sts: bool
|
supports_sts: bool
|
||||||
supported_sasl_mechanisms: Set[str]
|
supported_sasl_mechanisms: Set[str]
|
||||||
|
|
||||||
proc: Optional[subprocess.Popen]
|
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):
|
def __init__(self, test_config: TestCaseControllerConfig):
|
||||||
self.test_config = test_config
|
self.test_config = test_config
|
||||||
self.proc = None
|
self.proc = None
|
||||||
|
self._own_ports: Set[Tuple[str, int]] = set()
|
||||||
|
|
||||||
|
@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:
|
def check_is_alive(self) -> None:
|
||||||
assert self.proc
|
assert self.proc
|
||||||
self.proc.poll()
|
self.proc.poll()
|
||||||
if self.proc.returncode is not None:
|
if self.proc.returncode is not None:
|
||||||
raise ProcessStopped()
|
raise ProcessStopped(f"process returned {self.proc.returncode}")
|
||||||
|
|
||||||
def kill_proc(self) -> None:
|
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
|
||||||
@ -82,12 +117,17 @@ class _BaseController:
|
|||||||
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):
|
class DirectoryBasedController(_BaseController):
|
||||||
"""Helper for controllers whose software configuration is based on an
|
"""Helper for controllers whose software configuration is based on an
|
||||||
arbitrary directory."""
|
arbitrary directory."""
|
||||||
|
|
||||||
directory: Optional[str]
|
directory: Optional[Path]
|
||||||
|
|
||||||
def __init__(self, test_config: TestCaseControllerConfig):
|
def __init__(self, test_config: TestCaseControllerConfig):
|
||||||
super().__init__(test_config)
|
super().__init__(test_config)
|
||||||
@ -110,22 +150,21 @@ class DirectoryBasedController(_BaseController):
|
|||||||
"""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) -> None:
|
def create_config(self) -> None:
|
||||||
if not self.directory:
|
if not self.directory:
|
||||||
self.directory = tempfile.mkdtemp()
|
self.directory = Path(tempfile.mkdtemp())
|
||||||
|
|
||||||
def gen_ssl(self) -> None:
|
def gen_ssl(self) -> None:
|
||||||
assert self.directory
|
assert self.directory
|
||||||
self.csr_path = os.path.join(self.directory, "ssl.csr")
|
self.csr_path = self.directory / "ssl.csr"
|
||||||
self.key_path = os.path.join(self.directory, "ssl.key")
|
self.key_path = self.directory / "ssl.key"
|
||||||
self.pem_path = os.path.join(self.directory, "ssl.pem")
|
self.pem_path = self.directory / "ssl.pem"
|
||||||
self.dh_path = os.path.join(self.directory, "dh.pem")
|
self.dh_path = self.directory / "dh.pem"
|
||||||
subprocess.check_output(
|
subprocess.check_output(
|
||||||
[
|
[
|
||||||
self.openssl_bin,
|
self.openssl_bin,
|
||||||
@ -156,10 +195,18 @@ class DirectoryBasedController(_BaseController):
|
|||||||
],
|
],
|
||||||
stderr=subprocess.DEVNULL,
|
stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
subprocess.check_output(
|
with self.dh_path.open("w") as fd:
|
||||||
[self.openssl_bin, "dhparam", "-out", self.dh_path, "128"],
|
fd.write(
|
||||||
stderr=subprocess.DEVNULL,
|
textwrap.dedent(
|
||||||
)
|
"""
|
||||||
|
-----BEGIN DH PARAMETERS-----
|
||||||
|
MIGHAoGBAJICSyQAiLj1fw8b5xELcnpqBQ+wvOyKgim4IetWOgZnRQFkTgOeoRZD
|
||||||
|
HksACRFJL/EqHxDKcy/2Ghwr2axhNxSJ+UOBmraP3WfodV/fCDPnZ+XnI9fjHsIr
|
||||||
|
rjisPMqomjXeiTB1UeAHvLUmCK4yx6lpAJsCYwJjsqkycUfHiy1bAgEC
|
||||||
|
-----END DH PARAMETERS-----
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BaseClientController(_BaseController):
|
class BaseClientController(_BaseController):
|
||||||
@ -188,9 +235,16 @@ class BaseServerController(_BaseController):
|
|||||||
extban_mute_char: Optional[str] = None
|
extban_mute_char: Optional[str] = None
|
||||||
"""Character used for the 'mute' extban"""
|
"""Character used for the 'mute' extban"""
|
||||||
nickserv = "NickServ"
|
nickserv = "NickServ"
|
||||||
|
sync_sleep_time = 0.0
|
||||||
|
"""How many seconds to sleep before clients synchronously get messages.
|
||||||
|
|
||||||
def get_hostname_and_port(self) -> Tuple[str, int]:
|
This can be 0 for servers answering all commands in order (all but Sable as of
|
||||||
return find_hostname_and_port()
|
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(
|
def run(
|
||||||
self,
|
self,
|
||||||
@ -200,8 +254,7 @@ class BaseServerController(_BaseController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]],
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]],
|
|
||||||
) -> None:
|
) -> None:
|
||||||
raise NotImplementedError()
|
raise NotImplementedError()
|
||||||
|
|
||||||
@ -217,6 +270,7 @@ class BaseServerController(_BaseController):
|
|||||||
raise NotImplementedByController("account registration")
|
raise NotImplementedByController("account registration")
|
||||||
|
|
||||||
def wait_for_port(self) -> None:
|
def wait_for_port(self) -> None:
|
||||||
|
started_at = time.time()
|
||||||
while not self.port_open:
|
while not self.port_open:
|
||||||
self.check_is_alive()
|
self.check_is_alive()
|
||||||
time.sleep(self._port_wait_interval)
|
time.sleep(self._port_wait_interval)
|
||||||
@ -235,15 +289,20 @@ class BaseServerController(_BaseController):
|
|||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
|
||||||
c.send(b" ") # Triggers BrokenPipeError
|
c.send(b" ") # Triggers BrokenPipeError
|
||||||
except BrokenPipeError:
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
# ircu2 cuts the connection without a message if registration
|
# ircu2 cuts the connection without a message if registration
|
||||||
# is not complete.
|
# is not complete.
|
||||||
pass
|
pass
|
||||||
|
except socket.timeout:
|
||||||
|
# irc2 just keeps it open
|
||||||
|
pass
|
||||||
|
|
||||||
c.close()
|
c.close()
|
||||||
self.port_open = True
|
self.port_open = True
|
||||||
except Exception:
|
except ConnectionRefusedError:
|
||||||
continue
|
if time.time() - started_at >= 60:
|
||||||
|
# waited for 60 seconds, giving up
|
||||||
|
raise
|
||||||
|
|
||||||
def wait_for_services(self) -> None:
|
def wait_for_services(self) -> None:
|
||||||
assert self.services_controller
|
assert self.services_controller
|
||||||
@ -284,25 +343,36 @@ class BaseServicesController(_BaseController):
|
|||||||
c.connect(self.server_controller.hostname, self.server_controller.port)
|
c.connect(self.server_controller.hostname, self.server_controller.port)
|
||||||
c.sendLine("NICK chkNS")
|
c.sendLine("NICK chkNS")
|
||||||
c.sendLine("USER chk chk chk chk")
|
c.sendLine("USER chk chk chk chk")
|
||||||
for msg in c.getMessages(synchronize=False):
|
time.sleep(self.server_controller.sync_sleep_time)
|
||||||
if msg.command == "PING":
|
got_end_of_motd = False
|
||||||
# Hi Unreal
|
while not got_end_of_motd:
|
||||||
c.sendLine("PONG :" + msg.params[0])
|
for msg in c.getMessages(synchronize=False):
|
||||||
c.getMessages()
|
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() + 5
|
timeout = time.time() + 10
|
||||||
while True:
|
while True:
|
||||||
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :HELP")
|
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :help")
|
||||||
msgs = self.getNickServResponse(c)
|
|
||||||
|
msgs = self.getNickServResponse(c, timeout=1)
|
||||||
for msg in msgs:
|
for msg in msgs:
|
||||||
if msg.command == "401":
|
if msg.command == "401":
|
||||||
# NickServ not available yet
|
# NickServ not available yet
|
||||||
pass
|
pass
|
||||||
|
elif msg.command in ("MODE", "221"): # RPL_UMODEIS
|
||||||
|
pass
|
||||||
elif msg.command == "NOTICE":
|
elif msg.command == "NOTICE":
|
||||||
# NickServ is available
|
assert msg.prefix is not None
|
||||||
assert "nickserv" in (msg.prefix or "").lower(), msg
|
if "!" not in msg.prefix and "." in msg.prefix:
|
||||||
print("breaking")
|
# Server notice
|
||||||
break
|
pass
|
||||||
|
else:
|
||||||
|
# NickServ is available
|
||||||
|
assert "nickserv" in (msg.prefix or "").lower(), msg
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
assert False, f"unexpected reply from NickServ: {msg}"
|
assert False, f"unexpected reply from NickServ: {msg}"
|
||||||
else:
|
else:
|
||||||
@ -319,11 +389,12 @@ class BaseServicesController(_BaseController):
|
|||||||
c.disconnect()
|
c.disconnect()
|
||||||
self.services_up = True
|
self.services_up = True
|
||||||
|
|
||||||
def getNickServResponse(self, client: Any) -> List[Message]:
|
def getNickServResponse(self, client: Any, timeout: int = 0) -> List[Message]:
|
||||||
"""Wrapper aroung getMessages() that waits longer, because NickServ
|
"""Wrapper aroung getMessages() that waits longer, because NickServ
|
||||||
is queried asynchronously."""
|
is queried asynchronously."""
|
||||||
msgs: List[Message] = []
|
msgs: List[Message] = []
|
||||||
while not msgs:
|
start_time = time.time()
|
||||||
|
while not msgs and (not timeout or start_time + timeout > time.time()):
|
||||||
time.sleep(0.05)
|
time.sleep(0.05)
|
||||||
msgs = client.getMessages()
|
msgs = client.getMessages()
|
||||||
return msgs
|
return msgs
|
||||||
|
178
irctest/cases.py
178
irctest/cases.py
@ -69,6 +69,30 @@ TController = TypeVar("TController", bound=basecontrollers._BaseController)
|
|||||||
T = TypeVar("T")
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
def retry(f: TCallable) -> TCallable:
|
||||||
|
"""Retry the function if it raises ConnectionClosed; as a workaround for flaky
|
||||||
|
connection, such as::
|
||||||
|
|
||||||
|
1: connects to server.
|
||||||
|
1 -> S: NICK foo
|
||||||
|
1 -> S: USER username * * :Realname
|
||||||
|
S -> 1: :My.Little.Server NOTICE * :*** Found your hostname (cached)
|
||||||
|
S -> 1: :My.Little.Server NOTICE * :*** Checking Ident
|
||||||
|
S -> 1: :My.Little.Server NOTICE * :*** No Ident response
|
||||||
|
S -> 1: ERROR :Closing Link: cpu-pool.com (Use a different port)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@functools.wraps(f)
|
||||||
|
def newf(*args, **kwargs): # type: ignore
|
||||||
|
try:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except ConnectionClosed:
|
||||||
|
time.sleep(1)
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return newf # type: ignore
|
||||||
|
|
||||||
|
|
||||||
class ChannelJoinException(Exception):
|
class ChannelJoinException(Exception):
|
||||||
def __init__(self, code: str, params: List[str]):
|
def __init__(self, code: str, params: List[str]):
|
||||||
super().__init__(f"Failed to join channel ({code}): {params}")
|
super().__init__(f"Failed to join channel ({code}): {params}")
|
||||||
@ -149,7 +173,7 @@ class _IrcTestCase(Generic[TController]):
|
|||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Returns an error message if the message doesn't match the given arguments,
|
"""Returns an error message if the message doesn't match the given arguments,
|
||||||
or None if it matches."""
|
or None if it matches."""
|
||||||
for (key, value) in kwargs.items():
|
for key, value in kwargs.items():
|
||||||
if getattr(msg, key) != value:
|
if getattr(msg, key) != value:
|
||||||
fail_msg = (
|
fail_msg = (
|
||||||
fail_msg or "expected {param} to be {expects}, got {got}: {msg}"
|
fail_msg or "expected {param} to be {expects}, got {got}: {msg}"
|
||||||
@ -162,7 +186,7 @@ class _IrcTestCase(Generic[TController]):
|
|||||||
msg=msg,
|
msg=msg,
|
||||||
)
|
)
|
||||||
|
|
||||||
if prefix and not patma.match_string(msg.prefix, prefix):
|
if prefix is not None and not patma.match_string(msg.prefix, prefix):
|
||||||
fail_msg = (
|
fail_msg = (
|
||||||
fail_msg or "expected prefix to match {expects}, got {got}: {msg}"
|
fail_msg or "expected prefix to match {expects}, got {got}: {msg}"
|
||||||
)
|
)
|
||||||
@ -170,7 +194,7 @@ class _IrcTestCase(Generic[TController]):
|
|||||||
*extra_format, got=msg.prefix, expects=prefix, msg=msg
|
*extra_format, got=msg.prefix, expects=prefix, msg=msg
|
||||||
)
|
)
|
||||||
|
|
||||||
if params and not patma.match_list(list(msg.params), params):
|
if params is not None and not patma.match_list(list(msg.params), params):
|
||||||
fail_msg = (
|
fail_msg = (
|
||||||
fail_msg or "expected params to match {expects}, got {got}: {msg}"
|
fail_msg or "expected params to match {expects}, got {got}: {msg}"
|
||||||
)
|
)
|
||||||
@ -178,11 +202,11 @@ class _IrcTestCase(Generic[TController]):
|
|||||||
*extra_format, got=msg.params, expects=params, msg=msg
|
*extra_format, got=msg.params, expects=params, msg=msg
|
||||||
)
|
)
|
||||||
|
|
||||||
if tags and not patma.match_dict(msg.tags, tags):
|
if tags is not None and not patma.match_dict(msg.tags, tags):
|
||||||
fail_msg = fail_msg or "expected tags to match {expects}, got {got}: {msg}"
|
fail_msg = fail_msg or "expected tags to match {expects}, got {got}: {msg}"
|
||||||
return fail_msg.format(*extra_format, got=msg.tags, expects=tags, msg=msg)
|
return fail_msg.format(*extra_format, got=msg.tags, expects=tags, msg=msg)
|
||||||
|
|
||||||
if nick:
|
if nick is not None:
|
||||||
got_nick = msg.prefix.split("!")[0] if msg.prefix else None
|
got_nick = msg.prefix.split("!")[0] if msg.prefix else None
|
||||||
if nick != got_nick:
|
if nick != got_nick:
|
||||||
fail_msg = (
|
fail_msg = (
|
||||||
@ -327,8 +351,8 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]):
|
|||||||
nick: Optional[str] = None
|
nick: Optional[str] = None
|
||||||
user: Optional[List[str]] = None
|
user: Optional[List[str]] = None
|
||||||
server: socket.socket
|
server: socket.socket
|
||||||
protocol_version = Optional[str]
|
protocol_version: Optional[str]
|
||||||
acked_capabilities = Optional[Set[str]]
|
acked_capabilities: Optional[Set[str]]
|
||||||
|
|
||||||
__new__ = object.__new__ # pytest won't collect Generic[] subclasses otherwise
|
__new__ = object.__new__ # pytest won't collect Generic[] subclasses otherwise
|
||||||
|
|
||||||
@ -424,7 +448,9 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]):
|
|||||||
print("{:.3f} S: {}".format(time.time(), line.strip()))
|
print("{:.3f} S: {}".format(time.time(), line.strip()))
|
||||||
|
|
||||||
def readCapLs(
|
def readCapLs(
|
||||||
self, auth: Optional[Authentication] = None, tls_config: tls.TlsConfig = None
|
self,
|
||||||
|
auth: Optional[Authentication] = None,
|
||||||
|
tls_config: Optional[tls.TlsConfig] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
(hostname, port) = self.server.getsockname()
|
(hostname, port) = self.server.getsockname()
|
||||||
self.controller.run(
|
self.controller.run(
|
||||||
@ -434,9 +460,9 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]):
|
|||||||
m = self.getMessage()
|
m = self.getMessage()
|
||||||
self.assertEqual(m.command, "CAP", "First message is not CAP LS.")
|
self.assertEqual(m.command, "CAP", "First message is not CAP LS.")
|
||||||
if m.params == ["LS"]:
|
if m.params == ["LS"]:
|
||||||
self.protocol_version = 301
|
self.protocol_version = "301"
|
||||||
elif m.params == ["LS", "302"]:
|
elif m.params == ["LS", "302"]:
|
||||||
self.protocol_version = 302
|
self.protocol_version = "302"
|
||||||
elif m.params == ["END"]:
|
elif m.params == ["END"]:
|
||||||
self.protocol_version = None
|
self.protocol_version = None
|
||||||
else:
|
else:
|
||||||
@ -503,11 +529,15 @@ class BaseServerTestCase(
|
|||||||
|
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
ssl = False
|
ssl = False
|
||||||
valid_metadata_keys: Set[str] = set()
|
|
||||||
invalid_metadata_keys: Set[str] = set()
|
|
||||||
server_support: Optional[Dict[str, Optional[str]]]
|
server_support: Optional[Dict[str, Optional[str]]]
|
||||||
run_services = False
|
run_services = False
|
||||||
|
|
||||||
|
faketime: Optional[str] = None
|
||||||
|
"""If not None and the controller supports it and libfaketime is available,
|
||||||
|
runs the server using faketime and this value set as the $FAKETIME env variable.
|
||||||
|
Tests must check ``self.controller.faketime_enabled`` is True before
|
||||||
|
relying on this."""
|
||||||
|
|
||||||
__new__ = object.__new__ # pytest won't collect Generic[] subclasses otherwise
|
__new__ = object.__new__ # pytest won't collect Generic[] subclasses otherwise
|
||||||
|
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@ -518,10 +548,9 @@ class BaseServerTestCase(
|
|||||||
self.hostname,
|
self.hostname,
|
||||||
self.port,
|
self.port,
|
||||||
password=self.password,
|
password=self.password,
|
||||||
valid_metadata_keys=self.valid_metadata_keys,
|
|
||||||
invalid_metadata_keys=self.invalid_metadata_keys,
|
|
||||||
ssl=self.ssl,
|
ssl=self.ssl,
|
||||||
run_services=self.run_services,
|
run_services=self.run_services,
|
||||||
|
faketime=self.faketime,
|
||||||
)
|
)
|
||||||
self.clients: Dict[TClientName, client_mock.ClientMock] = {}
|
self.clients: Dict[TClientName, client_mock.ClientMock] = {}
|
||||||
|
|
||||||
@ -539,13 +568,10 @@ class BaseServerTestCase(
|
|||||||
if self.run_services:
|
if self.run_services:
|
||||||
self.controller.wait_for_services()
|
self.controller.wait_for_services()
|
||||||
if not name:
|
if not name:
|
||||||
new_name: int = (
|
used_ids: List[int] = [
|
||||||
max(
|
int(name) for name in self.clients if isinstance(name, (int, str))
|
||||||
[int(name) for name in self.clients if isinstance(name, (int, str))]
|
]
|
||||||
+ [0]
|
new_name = max(used_ids + [0]) + 1
|
||||||
)
|
|
||||||
+ 1
|
|
||||||
)
|
|
||||||
name = cast(TClientName, new_name)
|
name = cast(TClientName, new_name)
|
||||||
show_io = show_io if show_io is not None else self.show_io
|
show_io = show_io if show_io is not None else self.show_io
|
||||||
self.clients[name] = client_mock.ClientMock(name=name, show_io=show_io)
|
self.clients[name] = client_mock.ClientMock(name=name, show_io=show_io)
|
||||||
@ -559,9 +585,13 @@ class BaseServerTestCase(
|
|||||||
del self.clients[name]
|
del self.clients[name]
|
||||||
|
|
||||||
def getMessages(self, client: TClientName, **kwargs: Any) -> List[Message]:
|
def getMessages(self, client: TClientName, **kwargs: Any) -> List[Message]:
|
||||||
|
if kwargs.get("synchronize", True):
|
||||||
|
time.sleep(self.controller.sync_sleep_time)
|
||||||
return self.clients[client].getMessages(**kwargs)
|
return self.clients[client].getMessages(**kwargs)
|
||||||
|
|
||||||
def getMessage(self, client: TClientName, **kwargs: Any) -> Message:
|
def getMessage(self, client: TClientName, **kwargs: Any) -> Message:
|
||||||
|
if kwargs.get("synchronize", True):
|
||||||
|
time.sleep(self.controller.sync_sleep_time)
|
||||||
return self.clients[client].getMessage(**kwargs)
|
return self.clients[client].getMessage(**kwargs)
|
||||||
|
|
||||||
def getRegistrationMessage(self, client: TClientName) -> Message:
|
def getRegistrationMessage(self, client: TClientName) -> Message:
|
||||||
@ -647,10 +677,21 @@ class BaseServerTestCase(
|
|||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
def authenticateClient(
|
||||||
|
self, client: TClientName, account: str, password: str
|
||||||
|
) -> None:
|
||||||
|
self.sendLine(client, "AUTHENTICATE PLAIN")
|
||||||
|
m = self.getRegistrationMessage(client)
|
||||||
|
self.assertMessageMatch(m, command="AUTHENTICATE", params=["+"])
|
||||||
|
self.sendLine(client, sasl_plain_blob(account, password))
|
||||||
|
m = self.getRegistrationMessage(client)
|
||||||
|
self.assertIn(m.command, ["900", "903"], str(m))
|
||||||
|
|
||||||
|
@retry
|
||||||
def connectClient(
|
def connectClient(
|
||||||
self,
|
self,
|
||||||
nick: str,
|
nick: str,
|
||||||
name: TClientName = None,
|
name: Optional[TClientName] = None,
|
||||||
capabilities: Optional[List[str]] = None,
|
capabilities: Optional[List[str]] = None,
|
||||||
skip_if_cap_nak: bool = False,
|
skip_if_cap_nak: bool = False,
|
||||||
show_io: Optional[bool] = None,
|
show_io: Optional[bool] = None,
|
||||||
@ -665,17 +706,12 @@ class BaseServerTestCase(
|
|||||||
client = self.addClient(name, show_io=show_io)
|
client = self.addClient(name, show_io=show_io)
|
||||||
if capabilities:
|
if capabilities:
|
||||||
self.sendLine(client, "CAP LS 302")
|
self.sendLine(client, "CAP LS 302")
|
||||||
m = self.getRegistrationMessage(client)
|
self.getCapLs(client)
|
||||||
self.requestCapabilities(client, capabilities, skip_if_cap_nak)
|
self.requestCapabilities(client, capabilities, skip_if_cap_nak)
|
||||||
if password is not None:
|
if password is not None:
|
||||||
if "sasl" not in (capabilities or ()):
|
if "sasl" not in (capabilities or ()):
|
||||||
raise ValueError("Used 'password' option without sasl capbilitiy")
|
raise ValueError("Used 'password' option without sasl capbilitiy")
|
||||||
self.sendLine(client, "AUTHENTICATE PLAIN")
|
self.authenticateClient(client, account or nick, password)
|
||||||
m = self.getRegistrationMessage(client)
|
|
||||||
self.assertMessageMatch(m, command="AUTHENTICATE", params=["+"])
|
|
||||||
self.sendLine(client, sasl_plain_blob(account or nick, password))
|
|
||||||
m = self.getRegistrationMessage(client)
|
|
||||||
self.assertIn(m.command, ["900", "903"], str(m))
|
|
||||||
|
|
||||||
self.sendLine(client, "NICK {}".format(nick))
|
self.sendLine(client, "NICK {}".format(nick))
|
||||||
self.sendLine(client, "USER %s * * :Realname" % (ident,))
|
self.sendLine(client, "USER %s * * :Realname" % (ident,))
|
||||||
@ -700,6 +736,12 @@ class BaseServerTestCase(
|
|||||||
self.server_support[param] = None
|
self.server_support[param] = None
|
||||||
welcome.append(m)
|
welcome.append(m)
|
||||||
|
|
||||||
|
self.targmax: Dict[str, Optional[str]] = dict( # type: ignore[assignment]
|
||||||
|
item.split(":", 1)
|
||||||
|
for item in (self.server_support.get("TARGMAX") or "").split(",")
|
||||||
|
if item
|
||||||
|
)
|
||||||
|
|
||||||
return welcome
|
return welcome
|
||||||
|
|
||||||
def joinClient(self, client: TClientName, channel: str) -> None:
|
def joinClient(self, client: TClientName, channel: str) -> None:
|
||||||
@ -730,50 +772,58 @@ class BaseServerTestCase(
|
|||||||
raise ChannelJoinException(msg.command, msg.params)
|
raise ChannelJoinException(msg.command, msg.params)
|
||||||
|
|
||||||
|
|
||||||
_TSelf = TypeVar("_TSelf", bound="OptionalityHelper")
|
_TSelf = TypeVar("_TSelf", bound="_IrcTestCase")
|
||||||
_TReturn = TypeVar("_TReturn")
|
_TReturn = TypeVar("_TReturn")
|
||||||
|
|
||||||
|
|
||||||
class OptionalityHelper(Generic[TController]):
|
def skipUnlessHasMechanism(
|
||||||
controller: TController
|
mech: str,
|
||||||
|
) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]:
|
||||||
def checkSaslSupport(self) -> None:
|
# Just a function returning a function that takes functions and
|
||||||
if self.controller.supported_sasl_mechanisms:
|
# returns functions, nothing to see here.
|
||||||
return
|
# If Python didn't have such an awful syntax for callables, it would be:
|
||||||
raise runner.NotImplementedByController("SASL")
|
# str -> ((TSelf -> TReturn) -> (TSelf -> TReturn))
|
||||||
|
def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]:
|
||||||
def checkMechanismSupport(self, mechanism: str) -> None:
|
|
||||||
if mechanism in self.controller.supported_sasl_mechanisms:
|
|
||||||
return
|
|
||||||
raise runner.OptionalSaslMechanismNotSupported(mechanism)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def skipUnlessHasMechanism(
|
|
||||||
mech: str,
|
|
||||||
) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]:
|
|
||||||
# Just a function returning a function that takes functions and
|
|
||||||
# returns functions, nothing to see here.
|
|
||||||
# If Python didn't have such an awful syntax for callables, it would be:
|
|
||||||
# str -> ((TSelf -> TReturn) -> (TSelf -> TReturn))
|
|
||||||
def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]:
|
|
||||||
@functools.wraps(f)
|
|
||||||
def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn:
|
|
||||||
self.checkMechanismSupport(mech)
|
|
||||||
return f(self, *args, **kwargs)
|
|
||||||
|
|
||||||
return newf
|
|
||||||
|
|
||||||
return decorator
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def skipUnlessHasSasl(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]:
|
|
||||||
@functools.wraps(f)
|
@functools.wraps(f)
|
||||||
def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn:
|
def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn:
|
||||||
self.checkSaslSupport()
|
if mech not in self.controller.supported_sasl_mechanisms:
|
||||||
|
raise runner.OptionalSaslMechanismNotSupported(mech)
|
||||||
return f(self, *args, **kwargs)
|
return f(self, *args, **kwargs)
|
||||||
|
|
||||||
return newf
|
return newf
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def xfailIf(
|
||||||
|
condition: Callable[..., bool], reason: str
|
||||||
|
) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]:
|
||||||
|
# Works about the same as skipUnlessHasMechanism
|
||||||
|
def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]:
|
||||||
|
@functools.wraps(f)
|
||||||
|
def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn:
|
||||||
|
if condition(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return f(self, *args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
pytest.xfail(reason)
|
||||||
|
assert False # make mypy happy
|
||||||
|
else:
|
||||||
|
return f(self, *args, **kwargs)
|
||||||
|
|
||||||
|
return newf
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def xfailIfSoftware(
|
||||||
|
names: List[str], reason: str
|
||||||
|
) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]:
|
||||||
|
def pred(testcase: _IrcTestCase, *args: Any, **kwargs: Any) -> bool:
|
||||||
|
return testcase.controller.software_name in names
|
||||||
|
|
||||||
|
return xfailIf(pred, reason)
|
||||||
|
|
||||||
|
|
||||||
def mark_services(cls: TClass) -> TClass:
|
def mark_services(cls: TClass) -> TClass:
|
||||||
cls.run_services = True
|
cls.run_services = True
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
@ -1,3 +1,8 @@
|
|||||||
|
"""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 base64
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -34,8 +39,8 @@ class IdentityHash:
|
|||||||
return self._data
|
return self._data
|
||||||
|
|
||||||
|
|
||||||
class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
class SaslTestCase(cases.BaseClientTestCase):
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
def testPlain(self):
|
def testPlain(self):
|
||||||
"""Test PLAIN authentication with correct username/password."""
|
"""Test PLAIN authentication with correct username/password."""
|
||||||
auth = authentication.Authentication(
|
auth = authentication.Authentication(
|
||||||
@ -55,7 +60,8 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
m = self.negotiateCapabilities(["sasl"], False)
|
m = self.negotiateCapabilities(["sasl"], False)
|
||||||
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
@cases.xfailIfSoftware(["Sopel"], "Sopel requests SASL PLAIN even if not available")
|
||||||
def testPlainNotAvailable(self):
|
def testPlainNotAvailable(self):
|
||||||
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
|
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
|
||||||
to use PLAIN.
|
to use PLAIN.
|
||||||
@ -84,8 +90,9 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
m = self.getMessage()
|
m = self.getMessage()
|
||||||
self.assertMessageMatch(m, command="CAP")
|
self.assertMessageMatch(m, command="CAP")
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@pytest.mark.parametrize("pattern", ["barbaz", "éèà"])
|
||||||
def testPlainLarge(self):
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
def testPlainLarge(self, pattern):
|
||||||
"""Test the client splits large AUTHENTICATE messages whose payload
|
"""Test the client splits large AUTHENTICATE messages whose payload
|
||||||
is not a multiple of 400.
|
is not a multiple of 400.
|
||||||
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
||||||
@ -94,10 +101,10 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
auth = authentication.Authentication(
|
auth = authentication.Authentication(
|
||||||
mechanisms=[authentication.Mechanisms.plain],
|
mechanisms=[authentication.Mechanisms.plain],
|
||||||
username="foo",
|
username="foo",
|
||||||
password="bar" * 200,
|
password=pattern * 100,
|
||||||
)
|
)
|
||||||
authstring = base64.b64encode(
|
authstring = base64.b64encode(
|
||||||
b"\x00".join([b"foo", b"foo", b"bar" * 200])
|
b"\x00".join([b"foo", b"foo", pattern.encode() * 100])
|
||||||
).decode()
|
).decode()
|
||||||
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
||||||
@ -113,8 +120,9 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
m = self.negotiateCapabilities(["sasl"], False)
|
m = self.negotiateCapabilities(["sasl"], False)
|
||||||
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
def testPlainLargeMultiple(self):
|
@pytest.mark.parametrize("pattern", ["quux", "éè"])
|
||||||
|
def testPlainLargeMultiple(self, pattern):
|
||||||
"""Test the client splits large AUTHENTICATE messages whose payload
|
"""Test the client splits large AUTHENTICATE messages whose payload
|
||||||
is a multiple of 400.
|
is a multiple of 400.
|
||||||
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
|
||||||
@ -123,10 +131,10 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
auth = authentication.Authentication(
|
auth = authentication.Authentication(
|
||||||
mechanisms=[authentication.Mechanisms.plain],
|
mechanisms=[authentication.Mechanisms.plain],
|
||||||
username="foo",
|
username="foo",
|
||||||
password="quux" * 148,
|
password=pattern * 148,
|
||||||
)
|
)
|
||||||
authstring = base64.b64encode(
|
authstring = base64.b64encode(
|
||||||
b"\x00".join([b"foo", b"foo", b"quux" * 148])
|
b"\x00".join([b"foo", b"foo", pattern.encode() * 148])
|
||||||
).decode()
|
).decode()
|
||||||
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
m = self.negotiateCapabilities(["sasl"], auth=auth)
|
||||||
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
|
||||||
@ -143,7 +151,7 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
@pytest.mark.skipif(ecdsa is None, reason="python3-ecdsa is not available")
|
@pytest.mark.skipif(ecdsa is None, reason="python3-ecdsa is not available")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("ECDSA-NIST256P-CHALLENGE")
|
@cases.skipUnlessHasMechanism("ECDSA-NIST256P-CHALLENGE")
|
||||||
def testEcdsa(self):
|
def testEcdsa(self):
|
||||||
"""Test ECDSA authentication."""
|
"""Test ECDSA authentication."""
|
||||||
auth = authentication.Authentication(
|
auth = authentication.Authentication(
|
||||||
@ -177,7 +185,7 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
m = self.negotiateCapabilities(["sasl"], False)
|
m = self.negotiateCapabilities(["sasl"], False)
|
||||||
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
|
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
|
||||||
def testScram(self):
|
def testScram(self):
|
||||||
"""Test SCRAM-SHA-256 authentication."""
|
"""Test SCRAM-SHA-256 authentication."""
|
||||||
auth = authentication.Authentication(
|
auth = authentication.Authentication(
|
||||||
@ -219,8 +227,8 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
self.assertEqual(m.command, "AUTHENTICATE", m)
|
self.assertEqual(m.command, "AUTHENTICATE", m)
|
||||||
self.assertEqual(m.params, ["+"], m)
|
self.assertEqual(m.params, ["+"], m)
|
||||||
|
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
|
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
|
||||||
def testScramBadPassword(self):
|
def testScramBadPassword(self, server_fakes_success=False, fake_response=None):
|
||||||
"""Test SCRAM-SHA-256 authentication with a bad password."""
|
"""Test SCRAM-SHA-256 authentication with a bad password."""
|
||||||
auth = authentication.Authentication(
|
auth = authentication.Authentication(
|
||||||
mechanisms=[authentication.Mechanisms.scram_sha_256],
|
mechanisms=[authentication.Mechanisms.scram_sha_256],
|
||||||
@ -253,9 +261,39 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
with self.assertRaises(scram.NotAuthorizedException):
|
with self.assertRaises(scram.NotAuthorizedException):
|
||||||
authenticator.response(msg)
|
authenticator.response(msg)
|
||||||
|
|
||||||
|
if server_fakes_success:
|
||||||
|
self.sendLine(f"AUTHENTICATE :{fake_response}")
|
||||||
|
|
||||||
class Irc302SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
m = self.getMessage()
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
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):
|
def testPlainNotAvailable(self):
|
||||||
"""Test the client does not try to authenticate using a mechanism the
|
"""Test the client does not try to authenticate using a mechanism the
|
||||||
server does not advertise.
|
server does not advertise.
|
||||||
|
@ -1,6 +1,10 @@
|
|||||||
|
"""Clients should validate certificates; either with a CA or fingerprints."""
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
import ssl
|
import ssl
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from irctest import cases, runner, tls
|
from irctest import cases, runner, tls
|
||||||
from irctest.exceptions import ConnectionClosed
|
from irctest.exceptions import ConnectionClosed
|
||||||
from irctest.patma import ANYSTR
|
from irctest.patma import ANYSTR
|
||||||
@ -136,7 +140,7 @@ class TlsTestCase(cases.BaseClientTestCase):
|
|||||||
self.getMessage()
|
self.getMessage()
|
||||||
|
|
||||||
|
|
||||||
class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
class StsTestCase(cases.BaseClientTestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
@ -148,7 +152,8 @@ class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
super().tearDown()
|
super().tearDown()
|
||||||
|
|
||||||
@cases.mark_capabilities("sts")
|
@cases.mark_capabilities("sts")
|
||||||
def testSts(self):
|
@pytest.mark.parametrize("portOnSecure", [False, True])
|
||||||
|
def testSts(self, portOnSecure):
|
||||||
if not self.controller.supports_sts:
|
if not self.controller.supports_sts:
|
||||||
raise runner.CapabilityNotSupported("sts")
|
raise runner.CapabilityNotSupported("sts")
|
||||||
tls_config = tls.TlsConfig(
|
tls_config = tls.TlsConfig(
|
||||||
@ -176,10 +181,12 @@ class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
|
|||||||
# and reconnect securely on the stated port."
|
# and reconnect securely on the stated port."
|
||||||
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
|
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
|
||||||
|
|
||||||
# Send the STS policy, over secure connection this time
|
# Send the STS policy, over secure connection this time.
|
||||||
self.sendLine(
|
if portOnSecure:
|
||||||
"CAP * LS :sts=duration=10,port={}".format(self.server.getsockname()[1])
|
# 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.
|
# Make the client reconnect. It should reconnect to the secure server.
|
||||||
self.sendLine("ERROR :closing link")
|
self.sendLine("ERROR :closing link")
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import os
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Type
|
from typing import Type
|
||||||
@ -73,6 +73,8 @@ module {{ name = "ns_cert" }}
|
|||||||
class AnopeController(BaseServicesController, DirectoryBasedController):
|
class AnopeController(BaseServicesController, DirectoryBasedController):
|
||||||
"""Collaborator for server controllers that rely on Anope"""
|
"""Collaborator for server controllers that rely on Anope"""
|
||||||
|
|
||||||
|
software_name = "Anope"
|
||||||
|
|
||||||
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
||||||
self.create_config()
|
self.create_config()
|
||||||
|
|
||||||
@ -99,14 +101,11 @@ class AnopeController(BaseServicesController, DirectoryBasedController):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
assert self.directory
|
assert self.directory
|
||||||
|
services_path = shutil.which("services")
|
||||||
|
assert services_path
|
||||||
|
|
||||||
# Config and code need to be in the same directory, *obviously*
|
# Config and code need to be in the same directory, *obviously*
|
||||||
os.symlink(
|
(self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib")
|
||||||
os.path.join(
|
|
||||||
os.path.dirname(shutil.which("services")), "..", "lib" # type: ignore
|
|
||||||
),
|
|
||||||
os.path.join(self.directory, "lib"),
|
|
||||||
)
|
|
||||||
|
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
@ -56,6 +55,8 @@ saslserv {{
|
|||||||
class AthemeController(BaseServicesController, DirectoryBasedController):
|
class AthemeController(BaseServicesController, DirectoryBasedController):
|
||||||
"""Mixin for server controllers that rely on Atheme"""
|
"""Mixin for server controllers that rely on Atheme"""
|
||||||
|
|
||||||
|
software_name = "Atheme"
|
||||||
|
|
||||||
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
|
||||||
self.create_config()
|
self.create_config()
|
||||||
|
|
||||||
@ -79,11 +80,11 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
|
|||||||
"atheme-services",
|
"atheme-services",
|
||||||
"-n", # don't fork
|
"-n", # don't fork
|
||||||
"-c",
|
"-c",
|
||||||
os.path.join(self.directory, "services.conf"),
|
self.directory / "services.conf",
|
||||||
"-l",
|
"-l",
|
||||||
f"/tmp/services-{server_port}.log",
|
f"/tmp/services-{server_port}.log",
|
||||||
"-p",
|
"-p",
|
||||||
os.path.join(self.directory, "services.pid"),
|
self.directory / "services.pid",
|
||||||
"-D",
|
"-D",
|
||||||
self.directory,
|
self.directory,
|
||||||
],
|
],
|
||||||
|
@ -1,14 +1,9 @@
|
|||||||
import os
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
from typing import Optional, Set, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
BaseServerController,
|
|
||||||
DirectoryBasedController,
|
|
||||||
NotImplementedByController,
|
|
||||||
)
|
|
||||||
from irctest.irc_utils.junkdrawer import find_hostname_and_port
|
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
global {{
|
global {{
|
||||||
@ -80,6 +75,19 @@ oper {{
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class BahamutController(BaseServerController, DirectoryBasedController):
|
||||||
software_name = "Bahamut"
|
software_name = "Bahamut"
|
||||||
supported_sasl_mechanisms: Set[str] = set()
|
supported_sasl_mechanisms: Set[str] = set()
|
||||||
@ -99,20 +107,14 @@ class BahamutController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
restricted_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.port = port
|
self.port = port
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.create_config()
|
self.create_config()
|
||||||
(unused_hostname, unused_port) = find_hostname_and_port()
|
(unused_hostname, unused_port) = self.get_hostname_and_port()
|
||||||
(services_hostname, services_port) = find_hostname_and_port()
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
password_field = "passwd {};".format(password) if password else ""
|
password_field = "passwd {};".format(password) if password else ""
|
||||||
|
|
||||||
@ -120,9 +122,14 @@ class BahamutController(BaseServerController, DirectoryBasedController):
|
|||||||
|
|
||||||
assert self.directory
|
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.
|
# they are hardcoded... thankfully Bahamut reads them from the CWD.
|
||||||
shutil.copy(self.pem_path, os.path.join(self.directory, "ircd.crt"))
|
shutil.copy(self.pem_path, self.directory / "ircd.crt")
|
||||||
shutil.copy(self.key_path, os.path.join(self.directory, "ircd.key"))
|
shutil.copy(self.key_path, self.directory / "ircd.key")
|
||||||
|
|
||||||
with self.open_file("server.conf") as fd:
|
with self.open_file("server.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
@ -136,15 +143,21 @@ class BahamutController(BaseServerController, DirectoryBasedController):
|
|||||||
# pem_path=self.pem_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(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
# "strace", "-f", "-e", "file",
|
*faketime_cmd,
|
||||||
"ircd",
|
"ircd",
|
||||||
"-t", # don't fork
|
"-t", # don't fork
|
||||||
"-f",
|
"-f",
|
||||||
os.path.join(self.directory, "server.conf"),
|
self.directory / "server.conf",
|
||||||
],
|
],
|
||||||
# stdout=subprocess.DEVNULL,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if run_services:
|
if run_services:
|
||||||
|
@ -1,13 +1,8 @@
|
|||||||
import os
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set
|
from typing import Optional
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
BaseServerController,
|
|
||||||
DirectoryBasedController,
|
|
||||||
NotImplementedByController,
|
|
||||||
)
|
|
||||||
from irctest.irc_utils.junkdrawer import find_hostname_and_port
|
|
||||||
|
|
||||||
TEMPLATE_SSL_CONFIG = """
|
TEMPLATE_SSL_CONFIG = """
|
||||||
ssl_private_key = "{key_path}";
|
ssl_private_key = "{key_path}";
|
||||||
@ -41,18 +36,13 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.port = port
|
self.port = port
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.create_config()
|
self.create_config()
|
||||||
(services_hostname, services_port) = find_hostname_and_port()
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
password_field = 'password = "{}";'.format(password) if password else ""
|
password_field = 'password = "{}";'.format(password) if password else ""
|
||||||
if ssl:
|
if ssl:
|
||||||
self.gen_ssl()
|
self.gen_ssl()
|
||||||
@ -73,14 +63,22 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert self.directory
|
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(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
*faketime_cmd,
|
||||||
self.binary_name,
|
self.binary_name,
|
||||||
"-foreground",
|
"-foreground",
|
||||||
"-configfile",
|
"-configfile",
|
||||||
os.path.join(self.directory, "server.conf"),
|
self.directory / "server.conf",
|
||||||
"-pidfile",
|
"-pidfile",
|
||||||
os.path.join(self.directory, "server.pid"),
|
self.directory / "server.pid",
|
||||||
],
|
],
|
||||||
# stderr=subprocess.DEVNULL,
|
# stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
@ -12,6 +12,9 @@ serverinfo {{
|
|||||||
|
|
||||||
general {{
|
general {{
|
||||||
throttle_count = 100; # We need to connect lots of clients quickly
|
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";
|
sasl_service = "SaslServ";
|
||||||
}};
|
}};
|
||||||
|
|
||||||
|
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
|
@ -1,14 +1,11 @@
|
|||||||
import copy
|
import copy
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Any, Dict, Optional, Set, Type, Union
|
from typing import Any, Dict, Optional, Type, Union
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
BaseServerController,
|
|
||||||
DirectoryBasedController,
|
|
||||||
NotImplementedByController,
|
|
||||||
)
|
|
||||||
from irctest.cases import BaseServerTestCase
|
from irctest.cases import BaseServerTestCase
|
||||||
|
|
||||||
BASE_CONFIG = {
|
BASE_CONFIG = {
|
||||||
@ -129,7 +126,7 @@ def hash_password(password: Union[str, bytes]) -> str:
|
|||||||
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
|
||||||
)
|
)
|
||||||
out, _ = p.communicate(input_)
|
out, _ = p.communicate(input_)
|
||||||
return out.decode("utf-8")
|
return out.decode("utf-8").strip()
|
||||||
|
|
||||||
|
|
||||||
class ErgoController(BaseServerController, DirectoryBasedController):
|
class ErgoController(BaseServerController, DirectoryBasedController):
|
||||||
@ -152,16 +149,9 @@ class ErgoController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
restricted_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
config: Optional[Any] = None,
|
config: Optional[Any] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.create_config()
|
self.create_config()
|
||||||
if config is None:
|
if config is None:
|
||||||
config = copy.deepcopy(BASE_CONFIG)
|
config = copy.deepcopy(BASE_CONFIG)
|
||||||
@ -183,27 +173,32 @@ class ErgoController(BaseServerController, DirectoryBasedController):
|
|||||||
bind_address = "127.0.0.1:%s" % (port,)
|
bind_address = "127.0.0.1:%s" % (port,)
|
||||||
listener_conf = None # plaintext
|
listener_conf = None # plaintext
|
||||||
if ssl:
|
if ssl:
|
||||||
self.key_path = os.path.join(self.directory, "ssl.key")
|
self.key_path = self.directory / "ssl.key"
|
||||||
self.pem_path = os.path.join(self.directory, "ssl.pem")
|
self.pem_path = self.directory / "ssl.pem"
|
||||||
listener_conf = {"tls": {"cert": self.pem_path, "key": self.key_path}}
|
listener_conf = {"tls": {"cert": self.pem_path, "key": self.key_path}}
|
||||||
config["server"]["listeners"][bind_address] = listener_conf # type: ignore
|
config["server"]["listeners"][bind_address] = listener_conf # type: ignore
|
||||||
|
|
||||||
config["datastore"]["path"] = os.path.join( # type: ignore
|
config["datastore"]["path"] = str(self.directory / "ircd.db") # type: ignore
|
||||||
self.directory, "ircd.db"
|
|
||||||
)
|
|
||||||
|
|
||||||
if password is not None:
|
if password is not None:
|
||||||
config["server"]["password"] = hash_password(password) # type: ignore
|
config["server"]["password"] = hash_password(password) # type: ignore
|
||||||
|
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
|
|
||||||
self._config_path = os.path.join(self.directory, "server.yml")
|
self._config_path = self.directory / "server.yml"
|
||||||
self._config = config
|
self._config = config
|
||||||
self._write_config()
|
self._write_config()
|
||||||
subprocess.call(["ergo", "initdb", "--conf", self._config_path, "--quiet"])
|
subprocess.call(["ergo", "initdb", "--conf", self._config_path, "--quiet"])
|
||||||
subprocess.call(["ergo", "mkcerts", "--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(
|
self.proc = subprocess.Popen(
|
||||||
["ergo", "run", "--conf", self._config_path, "--quiet"]
|
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
|
||||||
)
|
)
|
||||||
|
|
||||||
def wait_for_services(self) -> None:
|
def wait_for_services(self) -> None:
|
||||||
@ -216,9 +211,6 @@ class ErgoController(BaseServerController, DirectoryBasedController):
|
|||||||
username: str,
|
username: str,
|
||||||
password: Optional[str] = None,
|
password: Optional[str] = None,
|
||||||
) -> 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:
|
if not case.run_services:
|
||||||
# Ergo does not actually need this, but other controllers do, so we
|
# 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
|
# are checking it here as well for tests that aren't tested with other
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Optional, Set, Tuple, Type
|
from typing import Optional, Tuple, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import BaseServerController
|
from irctest.basecontrollers import BaseServerController
|
||||||
|
|
||||||
@ -39,9 +39,7 @@ class ExternalServerController(BaseServerController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
restricted_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -40,7 +40,7 @@ class {{
|
|||||||
}};
|
}};
|
||||||
connect {{
|
connect {{
|
||||||
name = "services.example.org";
|
name = "services.example.org";
|
||||||
host = "localhost"; # Used to validate incoming connection
|
host = "127.0.0.1"; # Used to validate incoming connection
|
||||||
port = 0; # We don't need servers to connect to services
|
port = 0; # We don't need servers to connect to services
|
||||||
send_password = "password";
|
send_password = "password";
|
||||||
accept_password = "password";
|
accept_password = "password";
|
||||||
|
@ -1,13 +1,9 @@
|
|||||||
import os
|
import functools
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
BaseServerController,
|
|
||||||
DirectoryBasedController,
|
|
||||||
NotImplementedByController,
|
|
||||||
)
|
|
||||||
from irctest.irc_utils.junkdrawer import find_hostname_and_port
|
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
# Clients:
|
# Clients:
|
||||||
@ -17,6 +13,8 @@ TEMPLATE_CONFIG = """
|
|||||||
resolvehostnames="no" # Faster
|
resolvehostnames="no" # Faster
|
||||||
recvq="40960" # Needs to be larger than a valid message with tags
|
recvq="40960" # Needs to be larger than a valid message with tags
|
||||||
timeout="10" # So tests don't hang too long
|
timeout="10" # So tests don't hang too long
|
||||||
|
localmax="1000"
|
||||||
|
globalmax="1000"
|
||||||
{password_field}>
|
{password_field}>
|
||||||
|
|
||||||
<class
|
<class
|
||||||
@ -57,8 +55,10 @@ TEMPLATE_CONFIG = """
|
|||||||
target="services.example.org">
|
target="services.example.org">
|
||||||
|
|
||||||
# Protocol:
|
# Protocol:
|
||||||
|
<module name="banexception">
|
||||||
<module name="botmode">
|
<module name="botmode">
|
||||||
<module name="cap">
|
<module name="cap">
|
||||||
|
<module name="inviteexception">
|
||||||
<module name="ircv3">
|
<module name="ircv3">
|
||||||
<module name="ircv3_accounttag">
|
<module name="ircv3_accounttag">
|
||||||
<module name="ircv3_batch">
|
<module name="ircv3_batch">
|
||||||
@ -68,15 +68,22 @@ TEMPLATE_CONFIG = """
|
|||||||
<module name="ircv3_invitenotify">
|
<module name="ircv3_invitenotify">
|
||||||
<module name="ircv3_labeledresponse">
|
<module name="ircv3_labeledresponse">
|
||||||
<module name="ircv3_msgid">
|
<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="ircv3_servertime">
|
||||||
<module name="monitor">
|
<module name="monitor">
|
||||||
<module name="m_muteban"> # for testing mute extbans
|
<module name="m_muteban"> # for testing mute extbans
|
||||||
<module name="namesx"> # For multi-prefix
|
<module name="namesx"> # For multi-prefix
|
||||||
<module name="sasl">
|
<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:
|
# 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="testnet" id="000" network="testnet">
|
<server name="My.Little.Server" description="test server" id="000" network="testnet">
|
||||||
"""
|
"""
|
||||||
|
|
||||||
TEMPLATE_SSL_CONFIG = """
|
TEMPLATE_SSL_CONFIG = """
|
||||||
@ -85,6 +92,17 @@ TEMPLATE_SSL_CONFIG = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@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 = {"PLAIN"}
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
@ -104,19 +122,13 @@ class InspircdController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str] = None,
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
restricted_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.port = port
|
self.port = port
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.create_config()
|
self.create_config()
|
||||||
(services_hostname, services_port) = find_hostname_and_port()
|
(services_hostname, services_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
password_field = 'password="{}"'.format(password) if password else ""
|
password_field = 'password="{}"'.format(password) if password else ""
|
||||||
|
|
||||||
@ -128,6 +140,13 @@ class InspircdController(BaseServerController, DirectoryBasedController):
|
|||||||
else:
|
else:
|
||||||
ssl_config = ""
|
ssl_config = ""
|
||||||
|
|
||||||
|
if installed_version() == 3:
|
||||||
|
help_module_name = "helpop"
|
||||||
|
elif installed_version() == 4:
|
||||||
|
help_module_name = "help"
|
||||||
|
else:
|
||||||
|
assert False, f"unexpected version: {installed_version()}"
|
||||||
|
|
||||||
with self.open_file("server.conf") as fd:
|
with self.open_file("server.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
TEMPLATE_CONFIG.format(
|
TEMPLATE_CONFIG.format(
|
||||||
@ -137,15 +156,24 @@ class InspircdController(BaseServerController, DirectoryBasedController):
|
|||||||
services_port=services_port,
|
services_port=services_port,
|
||||||
password_field=password_field,
|
password_field=password_field,
|
||||||
ssl_config=ssl_config,
|
ssl_config=ssl_config,
|
||||||
|
help_module_name=help_module_name,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert self.directory
|
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(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
*faketime_cmd,
|
||||||
"inspircd",
|
"inspircd",
|
||||||
"--nofork",
|
"--nofork",
|
||||||
"--config",
|
"--config",
|
||||||
os.path.join(self.directory, "server.conf"),
|
self.directory / "server.conf",
|
||||||
],
|
],
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import os
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import (
|
||||||
BaseServerController,
|
BaseServerController,
|
||||||
@ -10,7 +10,7 @@ from irctest.basecontrollers import (
|
|||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
# M:<Server NAME>:<YOUR Internet IP#>:<Geographic Location>:<Port>:<SID>:
|
# M:<Server NAME>:<YOUR Internet IP#>:<Geographic Location>:<Port>:<SID>:
|
||||||
M:My.Little.Server:{hostname}:Somewhere:{port}:0042:
|
M:My.Little.Server:{hostname}:test server:{port}:0042:
|
||||||
|
|
||||||
# A:<Your Name/Location>:<Your E-Mail Addr>:<other info>::<network name>:
|
# A:<Your Name/Location>:<Your E-Mail Addr>:<other info>::<network name>:
|
||||||
A:Organization, IRC dept.:Daemon <ircd@example.irc.org>:Client Server::IRCnet:
|
A:Organization, IRC dept.:Daemon <ircd@example.irc.org>:Client Server::IRCnet:
|
||||||
@ -29,8 +29,8 @@ O:*:operpassword:operuser::::
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
class Irc2Controller(BaseServerController, DirectoryBasedController):
|
||||||
binary_name: str
|
software_name = "irc2"
|
||||||
services_protocol: str
|
services_protocol: str
|
||||||
|
|
||||||
supports_sts = False
|
supports_sts = False
|
||||||
@ -49,13 +49,8 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
if ssl:
|
if ssl:
|
||||||
raise NotImplementedByController("TLS")
|
raise NotImplementedByController("TLS")
|
||||||
if run_services:
|
if run_services:
|
||||||
@ -66,7 +61,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
|||||||
self.create_config()
|
self.create_config()
|
||||||
password_field = password if password else ""
|
password_field = password if password else ""
|
||||||
assert self.directory
|
assert self.directory
|
||||||
pidfile = os.path.join(self.directory, "ircd.pid")
|
pidfile = self.directory / "ircd.pid"
|
||||||
with self.open_file("server.conf") as fd:
|
with self.open_file("server.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
TEMPLATE_CONFIG.format(
|
TEMPLATE_CONFIG.format(
|
||||||
@ -76,18 +71,26 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
|||||||
pidfile=pidfile,
|
pidfile=pidfile,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
*faketime_cmd,
|
||||||
"ircd",
|
"ircd",
|
||||||
"-s", # no iauth
|
"-s", # no iauth
|
||||||
"-p",
|
"-p",
|
||||||
"on",
|
"on",
|
||||||
"-f",
|
"-f",
|
||||||
os.path.join(self.directory, "server.conf"),
|
self.directory / "server.conf",
|
||||||
],
|
],
|
||||||
# stderr=subprocess.DEVNULL,
|
# stderr=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_irctest_controller_class() -> Type[Ircu2Controller]:
|
def get_irctest_controller_class() -> Type[Irc2Controller]:
|
||||||
return Ircu2Controller
|
return Irc2Controller
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import os
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import (
|
||||||
BaseServerController,
|
BaseServerController,
|
||||||
@ -51,6 +51,7 @@ features {{
|
|||||||
|
|
||||||
|
|
||||||
class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
||||||
|
software_name = "ircu2"
|
||||||
supports_sts = False
|
supports_sts = False
|
||||||
extban_mute_char = None
|
extban_mute_char = None
|
||||||
|
|
||||||
@ -67,13 +68,8 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
if ssl:
|
if ssl:
|
||||||
raise NotImplementedByController("TLS")
|
raise NotImplementedByController("TLS")
|
||||||
if run_services:
|
if run_services:
|
||||||
@ -84,7 +80,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
|||||||
self.create_config()
|
self.create_config()
|
||||||
password_field = 'password = "{}";'.format(password) if password else ""
|
password_field = 'password = "{}";'.format(password) if password else ""
|
||||||
assert self.directory
|
assert self.directory
|
||||||
pidfile = os.path.join(self.directory, "ircd.pid")
|
pidfile = self.directory / "ircd.pid"
|
||||||
with self.open_file("server.conf") as fd:
|
with self.open_file("server.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
TEMPLATE_CONFIG.format(
|
TEMPLATE_CONFIG.format(
|
||||||
@ -94,12 +90,20 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
|
|||||||
pidfile=pidfile,
|
pidfile=pidfile,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
*faketime_cmd,
|
||||||
"ircd",
|
"ircd",
|
||||||
"-n", # don't detach
|
"-n", # don't detach
|
||||||
"-f",
|
"-f",
|
||||||
os.path.join(self.directory, "server.conf"),
|
self.directory / "server.conf",
|
||||||
"-x",
|
"-x",
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
],
|
],
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import os
|
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
@ -55,13 +54,19 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
|
|||||||
# 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()
|
||||||
|
|
||||||
|
username = password = ""
|
||||||
|
mechanisms = ""
|
||||||
if auth:
|
if auth:
|
||||||
mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms)
|
mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms)
|
||||||
if auth.ecdsa_key:
|
if auth.ecdsa_key:
|
||||||
with self.open_file("ecdsa_key.pem") as fd:
|
with self.open_file("ecdsa_key.pem") as fd:
|
||||||
fd.write(auth.ecdsa_key)
|
fd.write(auth.ecdsa_key)
|
||||||
else:
|
|
||||||
mechanisms = ""
|
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:
|
with self.open_file("bot.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
TEMPLATE_CONFIG.format(
|
TEMPLATE_CONFIG.format(
|
||||||
@ -69,8 +74,8 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
|
|||||||
loglevel="CRITICAL",
|
loglevel="CRITICAL",
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
port=port,
|
port=port,
|
||||||
username=auth.username if auth else "",
|
username=username,
|
||||||
password=auth.password if auth else "",
|
password=password,
|
||||||
mechanisms=mechanisms.lower(),
|
mechanisms=mechanisms.lower(),
|
||||||
enable_tls=tls_config.enable if tls_config else "False",
|
enable_tls=tls_config.enable if tls_config else "False",
|
||||||
trusted_fingerprints=" ".join(tls_config.trusted_fingerprints)
|
trusted_fingerprints=" ".join(tls_config.trusted_fingerprints)
|
||||||
@ -79,9 +84,7 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
assert self.directory
|
assert self.directory
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(["supybot", self.directory / "bot.conf"])
|
||||||
["supybot", os.path.join(self.directory, "bot.conf")]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_irctest_controller_class() -> Type[LimnoriaController]:
|
def get_irctest_controller_class() -> Type[LimnoriaController]:
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import os
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
from typing import Optional, Set, Type
|
||||||
|
|
||||||
@ -33,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:
|
||||||
@ -89,9 +89,7 @@ class MammonController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
restricted_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if password is not None:
|
if password is not None:
|
||||||
raise NotImplementedByController("PASS command")
|
raise NotImplementedByController("PASS command")
|
||||||
@ -106,19 +104,25 @@ class MammonController(BaseServerController, DirectoryBasedController):
|
|||||||
directory=self.directory,
|
directory=self.directory,
|
||||||
hostname=hostname,
|
hostname=hostname,
|
||||||
port=port,
|
port=port,
|
||||||
authorized_keys=make_list(valid_metadata_keys or set()),
|
|
||||||
restricted_keys=make_list(restricted_metadata_keys or set()),
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# with self.open_file('server.yml', 'r') as fd:
|
# with self.open_file('server.yml', 'r') as fd:
|
||||||
# print(fd.read())
|
# print(fd.read())
|
||||||
assert self.directory
|
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(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
*faketime_cmd,
|
||||||
"mammond",
|
"mammond",
|
||||||
"--nofork", # '--debug',
|
"--nofork", # '--debug',
|
||||||
"--config",
|
"--config",
|
||||||
os.path.join(self.directory, "server.yml"),
|
self.directory / "server.yml",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
@ -1,18 +1,13 @@
|
|||||||
import os
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
from typing import Optional, Set, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
BaseServerController,
|
|
||||||
DirectoryBasedController,
|
|
||||||
NotImplementedByController,
|
|
||||||
)
|
|
||||||
from irctest.irc_utils.junkdrawer import find_hostname_and_port
|
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
[Global]
|
[Global]
|
||||||
Name = My.Little.Server
|
Name = My.Little.Server
|
||||||
Info = ExampleNET Server
|
Info = test server
|
||||||
Bind = {hostname}
|
Bind = {hostname}
|
||||||
Ports = {port}
|
Ports = {port}
|
||||||
AdminInfo1 = Bob Smith
|
AdminInfo1 = Bob Smith
|
||||||
@ -26,6 +21,9 @@ TEMPLATE_CONFIG = """
|
|||||||
Passive = yes # don't connect to it
|
Passive = yes # don't connect to it
|
||||||
ServiceMask = *Serv
|
ServiceMask = *Serv
|
||||||
|
|
||||||
|
[Options]
|
||||||
|
MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
|
||||||
|
|
||||||
[Operator]
|
[Operator]
|
||||||
Name = operuser
|
Name = operuser
|
||||||
Password = operpassword
|
Password = operpassword
|
||||||
@ -50,19 +48,13 @@ class NgircdController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
restricted_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.port = port
|
self.port = port
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.create_config()
|
self.create_config()
|
||||||
(unused_hostname, unused_port) = find_hostname_and_port()
|
(unused_hostname, unused_port) = self.get_hostname_and_port()
|
||||||
|
|
||||||
password_field = "Password = {}".format(password) if password else ""
|
password_field = "Password = {}".format(password) if password else ""
|
||||||
|
|
||||||
@ -78,6 +70,7 @@ class NgircdController(BaseServerController, DirectoryBasedController):
|
|||||||
fd.write("\n")
|
fd.write("\n")
|
||||||
|
|
||||||
assert self.directory
|
assert self.directory
|
||||||
|
|
||||||
with self.open_file("server.conf") as fd:
|
with self.open_file("server.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
TEMPLATE_CONFIG.format(
|
TEMPLATE_CONFIG.format(
|
||||||
@ -88,15 +81,23 @@ class NgircdController(BaseServerController, DirectoryBasedController):
|
|||||||
password_field=password_field,
|
password_field=password_field,
|
||||||
key_path=self.key_path,
|
key_path=self.key_path,
|
||||||
pem_path=self.pem_path,
|
pem_path=self.pem_path,
|
||||||
empty_file=os.path.join(self.directory, "empty.txt"),
|
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(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
*faketime_cmd,
|
||||||
"ngircd",
|
"ngircd",
|
||||||
"--nodaemon",
|
"--nodaemon",
|
||||||
"--config",
|
"--config",
|
||||||
os.path.join(self.directory, "server.conf"),
|
self.directory / "server.conf",
|
||||||
],
|
],
|
||||||
# stdout=subprocess.DEVNULL,
|
# stdout=subprocess.DEVNULL,
|
||||||
)
|
)
|
||||||
|
@ -45,7 +45,7 @@ class {{
|
|||||||
}};
|
}};
|
||||||
connect {{
|
connect {{
|
||||||
name = "services.example.org";
|
name = "services.example.org";
|
||||||
host = "localhost"; # Used to validate incoming connection
|
host = "127.0.0.1"; # Used to validate incoming connection
|
||||||
port = 0; # We don't need servers to connect to services
|
port = 0; # We don't need servers to connect to services
|
||||||
send_password = "password";
|
send_password = "password";
|
||||||
accept_password = "password";
|
accept_password = "password";
|
||||||
@ -74,7 +74,7 @@ operator {{
|
|||||||
|
|
||||||
|
|
||||||
class Plexus4Controller(BaseHybridController):
|
class Plexus4Controller(BaseHybridController):
|
||||||
software_name = "Hybrid"
|
software_name = "Plexus4"
|
||||||
binary_name = "ircd"
|
binary_name = "ircd"
|
||||||
services_protocol = "plexus"
|
services_protocol = "plexus"
|
||||||
|
|
||||||
|
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
|
@ -1,6 +1,6 @@
|
|||||||
import os
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
from typing import Optional, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import (
|
||||||
BaseServerController,
|
BaseServerController,
|
||||||
@ -67,13 +67,8 @@ class SnircdController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
if ssl:
|
if ssl:
|
||||||
raise NotImplementedByController("TLS")
|
raise NotImplementedByController("TLS")
|
||||||
if run_services:
|
if run_services:
|
||||||
@ -84,7 +79,7 @@ class SnircdController(BaseServerController, DirectoryBasedController):
|
|||||||
self.create_config()
|
self.create_config()
|
||||||
password_field = 'password = "{}";'.format(password) if password else ""
|
password_field = 'password = "{}";'.format(password) if password else ""
|
||||||
assert self.directory
|
assert self.directory
|
||||||
pidfile = os.path.join(self.directory, "ircd.pid")
|
pidfile = self.directory / "ircd.pid"
|
||||||
with self.open_file("server.conf") as fd:
|
with self.open_file("server.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
TEMPLATE_CONFIG.format(
|
TEMPLATE_CONFIG.format(
|
||||||
@ -94,12 +89,20 @@ class SnircdController(BaseServerController, DirectoryBasedController):
|
|||||||
pidfile=pidfile,
|
pidfile=pidfile,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if faketime and shutil.which("faketime"):
|
||||||
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
|
self.faketime_enabled = True
|
||||||
|
else:
|
||||||
|
faketime_cmd = []
|
||||||
|
|
||||||
self.proc = subprocess.Popen(
|
self.proc = subprocess.Popen(
|
||||||
[
|
[
|
||||||
|
*faketime_cmd,
|
||||||
"ircd",
|
"ircd",
|
||||||
"-n", # don't detach
|
"-n", # don't detach
|
||||||
"-f",
|
"-f",
|
||||||
os.path.join(self.directory, "server.conf"),
|
self.directory / "server.conf",
|
||||||
"-x",
|
"-x",
|
||||||
"DEBUG",
|
"DEBUG",
|
||||||
],
|
],
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import os
|
from pathlib import Path
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
from typing import Optional, TextIO, Type, cast
|
from typing import Optional, TextIO, Type, cast
|
||||||
@ -38,14 +38,14 @@ class SopelController(BaseClientController):
|
|||||||
super().kill()
|
super().kill()
|
||||||
if self.filename:
|
if self.filename:
|
||||||
try:
|
try:
|
||||||
os.unlink(os.path.join(os.path.expanduser("~/.sopel/"), self.filename))
|
(Path("~/.sopel/").expanduser() / self.filename).unlink()
|
||||||
except OSError: # File does not exist
|
except OSError: # File does not exist
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def open_file(self, filename: str, mode: str = "a") -> TextIO:
|
def open_file(self, filename: str, mode: str = "a") -> TextIO:
|
||||||
dir_path = os.path.expanduser("~/.sopel/")
|
dir_path = Path("~/.sopel/").expanduser()
|
||||||
os.makedirs(dir_path, exist_ok=True)
|
dir_path.mkdir(parents=True, exist_ok=True)
|
||||||
return cast(TextIO, open(os.path.join(dir_path, filename), mode))
|
return cast(TextIO, (dir_path / filename).open(mode))
|
||||||
|
|
||||||
def create_config(self) -> None:
|
def create_config(self) -> None:
|
||||||
with self.open_file(self.filename):
|
with self.open_file(self.filename):
|
||||||
@ -73,7 +73,7 @@ class SopelController(BaseClientController):
|
|||||||
auth_method="auth_method = sasl" 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() -> Type[SopelController]:
|
def get_irctest_controller_class() -> Type[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
|
@ -1,21 +1,23 @@
|
|||||||
import os
|
import contextlib
|
||||||
|
import fcntl
|
||||||
|
import functools
|
||||||
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from typing import Optional, Set, Type
|
import textwrap
|
||||||
|
from typing import Callable, ContextManager, Iterator, Optional, Type
|
||||||
|
|
||||||
from irctest.basecontrollers import (
|
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
|
||||||
BaseServerController,
|
|
||||||
DirectoryBasedController,
|
|
||||||
NotImplementedByController,
|
|
||||||
)
|
|
||||||
from irctest.irc_utils.junkdrawer import find_hostname_and_port
|
|
||||||
|
|
||||||
TEMPLATE_CONFIG = """
|
TEMPLATE_CONFIG = """
|
||||||
include "modules.default.conf";
|
include "modules.default.conf";
|
||||||
include "operclass.default.conf";
|
include "operclass.default.conf";
|
||||||
|
{extras}
|
||||||
|
include "help/help.conf";
|
||||||
|
|
||||||
me {{
|
me {{
|
||||||
name "My.Little.Server";
|
name "My.Little.Server";
|
||||||
info "ExampleNET Server";
|
info "test server";
|
||||||
sid "001";
|
sid "001";
|
||||||
}}
|
}}
|
||||||
admin {{
|
admin {{
|
||||||
@ -87,11 +89,15 @@ set {{
|
|||||||
// Prevent throttling, especially test_buffering.py which
|
// Prevent throttling, especially test_buffering.py which
|
||||||
// triggers anti-flood with its very long lines
|
// triggers anti-flood with its very long lines
|
||||||
unknown-users {{
|
unknown-users {{
|
||||||
|
nick-flood 255:10;
|
||||||
lag-penalty 1;
|
lag-penalty 1;
|
||||||
lag-penalty-bytes 10000;
|
lag-penalty-bytes 10000;
|
||||||
}}
|
}}
|
||||||
}}
|
}}
|
||||||
modes-on-join "+H 100:1d"; // Enables CHATHISTORY
|
modes-on-join "+H 100:1d"; // Enables CHATHISTORY
|
||||||
|
|
||||||
|
{set_v6only}
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
tld {{
|
tld {{
|
||||||
@ -101,21 +107,84 @@ tld {{
|
|||||||
rules "{empty_file}";
|
rules "{empty_file}";
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
files {{
|
||||||
|
tunefile "{empty_file}";
|
||||||
|
}}
|
||||||
|
|
||||||
oper "operuser" {{
|
oper "operuser" {{
|
||||||
password = "operpassword";
|
password "operpassword";
|
||||||
mask *;
|
mask *;
|
||||||
class clients;
|
class clients;
|
||||||
operclass netadmin;
|
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):
|
class UnrealircdController(BaseServerController, DirectoryBasedController):
|
||||||
software_name = "UnrealIRCd"
|
software_name = "UnrealIRCd"
|
||||||
supported_sasl_mechanisms = {"PLAIN"}
|
supported_sasl_mechanisms = {"PLAIN"}
|
||||||
supports_sts = False
|
supports_sts = False
|
||||||
|
|
||||||
extban_mute_char = "q"
|
extban_mute_char = "quiet" if installed_version() >= 6 else "q"
|
||||||
|
software_version = installed_version()
|
||||||
|
|
||||||
def create_config(self) -> None:
|
def create_config(self) -> None:
|
||||||
super().create_config()
|
super().create_config()
|
||||||
@ -130,23 +199,33 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
|
|||||||
password: Optional[str],
|
password: Optional[str],
|
||||||
ssl: bool,
|
ssl: bool,
|
||||||
run_services: bool,
|
run_services: bool,
|
||||||
valid_metadata_keys: Optional[Set[str]] = None,
|
faketime: Optional[str],
|
||||||
invalid_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
restricted_metadata_keys: Optional[Set[str]] = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
if valid_metadata_keys or invalid_metadata_keys:
|
|
||||||
raise NotImplementedByController(
|
|
||||||
"Defining valid and invalid METADATA keys."
|
|
||||||
)
|
|
||||||
assert self.proc is None
|
assert self.proc is None
|
||||||
self.port = port
|
self.port = port
|
||||||
self.hostname = hostname
|
self.hostname = hostname
|
||||||
self.create_config()
|
self.create_config()
|
||||||
(unused_hostname, unused_port) = find_hostname_and_port()
|
|
||||||
(services_hostname, services_port) = find_hostname_and_port()
|
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 ""
|
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()
|
self.gen_ssl()
|
||||||
if ssl:
|
if ssl:
|
||||||
(tls_hostname, tls_port) = (hostname, port)
|
(tls_hostname, tls_port) = (hostname, port)
|
||||||
@ -155,10 +234,8 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
|
|||||||
# Unreal refuses to start without TLS enabled
|
# Unreal refuses to start without TLS enabled
|
||||||
(tls_hostname, tls_port) = (unused_hostname, unused_port)
|
(tls_hostname, tls_port) = (unused_hostname, unused_port)
|
||||||
|
|
||||||
with self.open_file("empty.txt") as fd:
|
|
||||||
fd.write("\n")
|
|
||||||
|
|
||||||
assert self.directory
|
assert self.directory
|
||||||
|
|
||||||
with self.open_file("unrealircd.conf") as fd:
|
with self.open_file("unrealircd.conf") as fd:
|
||||||
fd.write(
|
fd.write(
|
||||||
TEMPLATE_CONFIG.format(
|
TEMPLATE_CONFIG.format(
|
||||||
@ -171,22 +248,33 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
|
|||||||
password_field=password_field,
|
password_field=password_field,
|
||||||
key_path=self.key_path,
|
key_path=self.key_path,
|
||||||
pem_path=self.pem_path,
|
pem_path=self.pem_path,
|
||||||
empty_file=os.path.join(self.directory, "empty.txt"),
|
empty_file=self.directory / "empty.txt",
|
||||||
|
set_v6only=set_v6only,
|
||||||
|
extras=extras,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.proc = subprocess.Popen(
|
|
||||||
[
|
if faketime and shutil.which("faketime"):
|
||||||
"unrealircd",
|
faketime_cmd = ["faketime", "-f", faketime]
|
||||||
"-t",
|
self.faketime_enabled = True
|
||||||
"-F", # BOOT_NOFORK
|
else:
|
||||||
"-f",
|
faketime_cmd = []
|
||||||
os.path.join(self.directory, "unrealircd.conf"),
|
|
||||||
],
|
with _STARTSTOP_LOCK():
|
||||||
# stdout=subprocess.DEVNULL,
|
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:
|
if run_services:
|
||||||
self.wait_for_port()
|
|
||||||
self.services_controller = self.services_controller_class(
|
self.services_controller = self.services_controller_class(
|
||||||
self.test_config, self
|
self.test_config, self
|
||||||
)
|
)
|
||||||
@ -196,6 +284,14 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
|
|||||||
server_port=services_port,
|
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]:
|
def get_irctest_controller_class() -> Type[UnrealircdController]:
|
||||||
return 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;
|
||||||
|
}
|
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()
|
@ -13,7 +13,7 @@ def ircv3_timestamp_to_unixtime(timestamp: str) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def random_name(base: str) -> str:
|
def random_name(base: str) -> str:
|
||||||
return base + "-" + secrets.token_hex(8)
|
return base + "-" + secrets.token_hex(5)
|
||||||
|
|
||||||
|
|
||||||
def find_hostname_and_port() -> Tuple[str, int]:
|
def find_hostname_and_port() -> Tuple[str, int]:
|
||||||
|
@ -66,6 +66,7 @@ RPL_WHOISIDLE = "317"
|
|||||||
RPL_ENDOFWHOIS = "318"
|
RPL_ENDOFWHOIS = "318"
|
||||||
RPL_WHOISCHANNELS = "319"
|
RPL_WHOISCHANNELS = "319"
|
||||||
RPL_WHOISSPECIAL = "320"
|
RPL_WHOISSPECIAL = "320"
|
||||||
|
RPL_LISTSTART = "321"
|
||||||
RPL_LIST = "322"
|
RPL_LIST = "322"
|
||||||
RPL_LISTEND = "323"
|
RPL_LISTEND = "323"
|
||||||
RPL_CHANNELMODEIS = "324"
|
RPL_CHANNELMODEIS = "324"
|
||||||
@ -86,6 +87,7 @@ RPL_ENDOFEXCEPTLIST = "349"
|
|||||||
RPL_VERSION = "351"
|
RPL_VERSION = "351"
|
||||||
RPL_WHOREPLY = "352"
|
RPL_WHOREPLY = "352"
|
||||||
RPL_NAMREPLY = "353"
|
RPL_NAMREPLY = "353"
|
||||||
|
RPL_WHOSPCRPL = "354"
|
||||||
RPL_LINKS = "364"
|
RPL_LINKS = "364"
|
||||||
RPL_ENDOFLINKS = "365"
|
RPL_ENDOFLINKS = "365"
|
||||||
RPL_ENDOFNAMES = "366"
|
RPL_ENDOFNAMES = "366"
|
||||||
@ -140,6 +142,7 @@ ERR_USERONCHANNEL = "443"
|
|||||||
ERR_NOLOGIN = "444"
|
ERR_NOLOGIN = "444"
|
||||||
ERR_SUMMONDISABLED = "445"
|
ERR_SUMMONDISABLED = "445"
|
||||||
ERR_USERSDISABLED = "446"
|
ERR_USERSDISABLED = "446"
|
||||||
|
ERR_FORBIDDENCHANNEL = "448"
|
||||||
ERR_NOTREGISTERED = "451"
|
ERR_NOTREGISTERED = "451"
|
||||||
ERR_NEEDMOREPARAMS = "461"
|
ERR_NEEDMOREPARAMS = "461"
|
||||||
ERR_ALREADYREGISTRED = "462"
|
ERR_ALREADYREGISTRED = "462"
|
||||||
@ -201,5 +204,11 @@ ERR_ACCOUNT_INVALID_VERIFY_CODE = "925"
|
|||||||
RPL_REG_VERIFICATION_REQUIRED = "927"
|
RPL_REG_VERIFICATION_REQUIRED = "927"
|
||||||
ERR_REG_INVALID_CRED_TYPE = "928"
|
ERR_REG_INVALID_CRED_TYPE = "928"
|
||||||
ERR_REG_INVALID_CALLBACK = "929"
|
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_TOOMANYLANGUAGES = "981"
|
||||||
ERR_NOLANGUAGE = "982"
|
ERR_NOLANGUAGE = "982"
|
||||||
|
@ -13,18 +13,18 @@ class Operator:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class AnyStr(Operator):
|
class _AnyStr(Operator):
|
||||||
"""Wildcard matching any string"""
|
"""Wildcard matching any string"""
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "AnyStr"
|
return "ANYSTR"
|
||||||
|
|
||||||
|
|
||||||
class AnyOptStr(Operator):
|
class _AnyOptStr(Operator):
|
||||||
"""Wildcard matching any string as well as None"""
|
"""Wildcard matching any string as well as None"""
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "AnyOptStr"
|
return "ANYOPTSTR"
|
||||||
|
|
||||||
|
|
||||||
@dataclasses.dataclass(frozen=True)
|
@dataclasses.dataclass(frozen=True)
|
||||||
@ -43,6 +43,14 @@ class NotStrRe(Operator):
|
|||||||
return f"NotStrRe(r'{self.regexp}')"
|
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)
|
@dataclasses.dataclass(frozen=True)
|
||||||
class RemainingKeys(Operator):
|
class RemainingKeys(Operator):
|
||||||
"""Used in a dict pattern to match all remaining keys.
|
"""Used in a dict pattern to match all remaining keys.
|
||||||
@ -51,30 +59,42 @@ class RemainingKeys(Operator):
|
|||||||
key: Operator
|
key: Operator
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return f"Keys({self.key!r})"
|
return f"RemainingKeys({self.key!r})"
|
||||||
|
|
||||||
|
|
||||||
ANYSTR = AnyStr()
|
ANYSTR = _AnyStr()
|
||||||
"""Singleton, spares two characters"""
|
"""Singleton, spares two characters"""
|
||||||
|
|
||||||
ANYDICT = {RemainingKeys(ANYSTR): AnyOptStr()}
|
ANYOPTSTR = _AnyOptStr()
|
||||||
|
"""Singleton, spares two characters"""
|
||||||
|
|
||||||
|
ANYDICT = {RemainingKeys(ANYSTR): ANYOPTSTR}
|
||||||
"""Matches any dictionary; useful to compare tags dict, eg.
|
"""Matches any dictionary; useful to compare tags dict, eg.
|
||||||
`match_dict(got_tags, {"label": "foo", **ANYDICT})`"""
|
`match_dict(got_tags, {"label": "foo", **ANYDICT})`"""
|
||||||
|
|
||||||
|
|
||||||
class _AnyListRemainder:
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class ListRemainder:
|
||||||
|
item: Operator
|
||||||
|
min_length: int = 0
|
||||||
|
|
||||||
def __repr__(self) -> str:
|
def __repr__(self) -> str:
|
||||||
return "*ANYLIST"
|
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 = [_AnyListRemainder()]
|
ANYLIST = [ListRemainder(ANYSTR)]
|
||||||
"""Matches any list remainder"""
|
"""Matches any list remainder"""
|
||||||
|
|
||||||
|
|
||||||
def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool:
|
def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool:
|
||||||
if isinstance(expected, AnyOptStr):
|
if isinstance(expected, _AnyOptStr):
|
||||||
return True
|
return True
|
||||||
elif isinstance(expected, AnyStr) and got is not None:
|
elif isinstance(expected, _AnyStr) and got is not None:
|
||||||
return True
|
return True
|
||||||
elif isinstance(expected, StrRe):
|
elif isinstance(expected, StrRe):
|
||||||
if got is None or not re.match(expected.regexp, got):
|
if got is None or not re.match(expected.regexp, got):
|
||||||
@ -82,6 +102,9 @@ def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bo
|
|||||||
elif isinstance(expected, NotStrRe):
|
elif isinstance(expected, NotStrRe):
|
||||||
if got is None or re.match(expected.regexp, got):
|
if got is None or re.match(expected.regexp, got):
|
||||||
return False
|
return False
|
||||||
|
elif isinstance(expected, InsensitiveStr):
|
||||||
|
if got is None or got.lower() != expected.string.lower():
|
||||||
|
return False
|
||||||
elif isinstance(expected, Operator):
|
elif isinstance(expected, Operator):
|
||||||
raise NotImplementedError(f"Unsupported operator: {expected}")
|
raise NotImplementedError(f"Unsupported operator: {expected}")
|
||||||
elif got != expected:
|
elif got != expected:
|
||||||
@ -98,9 +121,13 @@ def match_list(
|
|||||||
The ANYSTR operator can be used on the 'expected' side as a wildcard,
|
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
|
matching any *single* value; and StrRe("<regexp>") can be used to match regular
|
||||||
expressions"""
|
expressions"""
|
||||||
if expected[-1] is ANYLIST[0]:
|
if expected and isinstance(expected[-1], ListRemainder):
|
||||||
expected = expected[0:-1]
|
# Expand the 'expected' list to have as many items as the 'got' list
|
||||||
got = got[0 : len(expected)] # Ignore remaining
|
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):
|
if len(got) != len(expected):
|
||||||
return False
|
return False
|
||||||
return all(
|
return all(
|
||||||
@ -125,21 +152,23 @@ def match_dict(
|
|||||||
# Set to not-None if we find a Keys() operator in the dict keys
|
# Set to not-None if we find a Keys() operator in the dict keys
|
||||||
remaining_keys_wildcard = None
|
remaining_keys_wildcard = None
|
||||||
|
|
||||||
for (expected_key, expected_value) in expected.items():
|
for expected_key, expected_value in expected.items():
|
||||||
if isinstance(expected_key, RemainingKeys):
|
if isinstance(expected_key, RemainingKeys):
|
||||||
remaining_keys_wildcard = (expected_key.key, expected_value)
|
remaining_keys_wildcard = (expected_key.key, expected_value)
|
||||||
elif isinstance(expected_key, Operator):
|
|
||||||
raise NotImplementedError(f"Unsupported operator: {expected_key}")
|
|
||||||
else:
|
else:
|
||||||
if expected_key not in got:
|
for key in got:
|
||||||
return False
|
if match_string(key, expected_key) and match_string(
|
||||||
got_value = got.pop(expected_key)
|
got[key], expected_value
|
||||||
if not match_string(got_value, expected_value):
|
):
|
||||||
|
got.pop(key)
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
# Found no (key, value) pair matching the request
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if remaining_keys_wildcard:
|
if remaining_keys_wildcard:
|
||||||
(expected_key, expected_value) = remaining_keys_wildcard
|
(expected_key, expected_value) = remaining_keys_wildcard
|
||||||
for (key, value) in got.items():
|
for key, value in got.items():
|
||||||
if not match_string(key, expected_key):
|
if not match_string(key, expected_key):
|
||||||
return False
|
return False
|
||||||
if not match_string(value, expected_value):
|
if not match_string(value, expected_value):
|
||||||
|
@ -14,6 +14,11 @@ class ImplementationChoice(unittest.SkipTest):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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) -> str:
|
def __str__(self) -> str:
|
||||||
return "Unsupported extension: {}".format(self.args[0])
|
return "Unsupported extension: {}".format(self.args[0])
|
||||||
|
@ -1,13 +1,24 @@
|
|||||||
|
"""Internal checks of assertion implementations."""
|
||||||
|
|
||||||
from typing import Dict, List, Tuple
|
from typing import Dict, List, Tuple
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.irc_utils.message_parser import parse_message
|
from irctest.irc_utils.message_parser import parse_message
|
||||||
from irctest.patma import ANYDICT, ANYSTR, AnyOptStr, NotStrRe, RemainingKeys, StrRe
|
from irctest.patma import (
|
||||||
|
ANYDICT,
|
||||||
|
ANYLIST,
|
||||||
|
ANYOPTSTR,
|
||||||
|
ANYSTR,
|
||||||
|
ListRemainder,
|
||||||
|
NotStrRe,
|
||||||
|
RemainingKeys,
|
||||||
|
StrRe,
|
||||||
|
)
|
||||||
|
|
||||||
# fmt: off
|
# fmt: off
|
||||||
MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
|
||||||
(
|
(
|
||||||
# the specification:
|
# the specification:
|
||||||
dict(
|
dict(
|
||||||
@ -27,6 +38,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
|||||||
[
|
[
|
||||||
"PRIVMSG #chan hello2",
|
"PRIVMSG #chan hello2",
|
||||||
"PRIVMSG #chan2 hello",
|
"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']",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -49,6 +65,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
|||||||
[
|
[
|
||||||
"PRIVMSG #chan :hi",
|
"PRIVMSG #chan :hi",
|
||||||
"PRIVMSG #chan2 hello",
|
"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']",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -67,6 +88,12 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
|||||||
"PRIVMSG #chan :hi",
|
"PRIVMSG #chan :hi",
|
||||||
":foo2!baz@qux PRIVMSG #chan hello",
|
":foo2!baz@qux PRIVMSG #chan hello",
|
||||||
"@tag1=bar :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",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -87,6 +114,13 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
|||||||
"@tag1=value1 PRIVMSG #chan :hello",
|
"@tag1=value1 PRIVMSG #chan :hello",
|
||||||
"PRIVMSG #chan hello",
|
"PRIVMSG #chan hello",
|
||||||
":foo!baz@qux 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 {}",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -107,6 +141,12 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
|||||||
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
"PRIVMSG #chan hello",
|
"PRIVMSG #chan hello",
|
||||||
":foo!baz@qux 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 {}",
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -129,12 +169,53 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
|||||||
"PRIVMSG #chan hello2",
|
"PRIVMSG #chan hello2",
|
||||||
"PRIVMSG #chan2 hello",
|
"PRIVMSG #chan2 hello",
|
||||||
":foo!baz@qux PRIVMSG #chan 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:
|
# the specification:
|
||||||
dict(
|
dict(
|
||||||
tags={"tag1": "bar", RemainingKeys(NotStrRe("tag2")): AnyOptStr()},
|
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",
|
command="PRIVMSG",
|
||||||
params=["#chan", "hello"],
|
params=["#chan", "hello"],
|
||||||
),
|
),
|
||||||
@ -150,6 +231,98 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
|
|||||||
"@tag1=value1 PRIVMSG #chan :hello",
|
"@tag1=value1 PRIVMSG #chan :hello",
|
||||||
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
"@tag1=bar;tag2= PRIVMSG #chan :hello",
|
||||||
"@tag1=bar;tag2=baz 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"
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
@ -161,7 +334,7 @@ class IrcTestCaseTestCase(cases._IrcTestCase):
|
|||||||
"spec,msg",
|
"spec,msg",
|
||||||
[
|
[
|
||||||
pytest.param(spec, msg, id=f"{spec}-{msg}")
|
pytest.param(spec, msg, id=f"{spec}-{msg}")
|
||||||
for (spec, positive_matches, _) in MESSAGE_SPECS
|
for (spec, positive_matches, _, _) in MESSAGE_SPECS
|
||||||
for msg in positive_matches
|
for msg in positive_matches
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -174,7 +347,7 @@ class IrcTestCaseTestCase(cases._IrcTestCase):
|
|||||||
"spec,msg",
|
"spec,msg",
|
||||||
[
|
[
|
||||||
pytest.param(spec, msg, id=f"{spec}-{msg}")
|
pytest.param(spec, msg, id=f"{spec}-{msg}")
|
||||||
for (spec, _, negative_matches) in MESSAGE_SPECS
|
for (spec, _, negative_matches, _) in MESSAGE_SPECS
|
||||||
for msg in negative_matches
|
for msg in negative_matches
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -183,3 +356,14 @@ class IrcTestCaseTestCase(cases._IrcTestCase):
|
|||||||
assert not self.messageEqual(parse_message(msg), **spec), msg
|
assert not self.messageEqual(parse_message(msg), **spec), msg
|
||||||
with pytest.raises(AssertionError):
|
with pytest.raises(AssertionError):
|
||||||
self.assertMessageMatch(parse_message(msg), **spec), msg
|
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))
|
||||||
|
@ -1,9 +1,15 @@
|
|||||||
|
"""
|
||||||
|
`Draft IRCv3 account-registration
|
||||||
|
<https://ircv3.net/specs/extensions/account-registration>`_
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.patma import ANYSTR
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
REGISTER_CAP_NAME = "draft/account-registration"
|
REGISTER_CAP_NAME = "draft/account-registration"
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
|
class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -28,6 +34,7 @@ class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
|
|||||||
self.assertMessageMatch(register_response, params=["SUCCESS", ANYSTR, ANYSTR])
|
self.assertMessageMatch(register_response, params=["SUCCESS", ANYSTR, ANYSTR])
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
|
class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -55,6 +62,7 @@ class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
|
class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -105,6 +113,7 @@ class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
@cases.mark_specifications("IRCv3", "Ergo")
|
@cases.mark_specifications("IRCv3", "Ergo")
|
||||||
class RegisterNoLandGrabsTestCase(cases.BaseServerTestCase):
|
class RegisterNoLandGrabsTestCase(cases.BaseServerTestCase):
|
||||||
@staticmethod
|
@staticmethod
|
@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
<http://ircv3.net/specs/extensions/account-tag-3.2.html>
|
`IRCv3 account-tag <https://ircv3.net/specs/extensions/account-tag>`_
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
|
|
||||||
|
|
||||||
@cases.mark_services
|
@cases.mark_services
|
||||||
class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
class AccountTagTestCase(cases.BaseServerTestCase):
|
||||||
def connectRegisteredClient(self, nick):
|
def connectRegisteredClient(self, nick):
|
||||||
self.addClient()
|
self.addClient()
|
||||||
self.sendLine(2, "CAP LS 302")
|
self.sendLine(2, "CAP LS 302")
|
||||||
@ -40,7 +40,7 @@ class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.skipToWelcome(2)
|
self.skipToWelcome(2)
|
||||||
|
|
||||||
@cases.mark_capabilities("account-tag")
|
@cases.mark_capabilities("account-tag")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
def testPrivmsg(self):
|
def testPrivmsg(self):
|
||||||
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
|
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
|
||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
@ -54,7 +54,10 @@ class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_capabilities("account-tag")
|
@cases.mark_capabilities("account-tag")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Charybdis"], "https://github.com/solanum-ircd/solanum/issues/166"
|
||||||
|
)
|
||||||
def testInvite(self):
|
def testInvite(self):
|
||||||
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
|
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
|
||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
|
@ -1,12 +1,24 @@
|
|||||||
|
"""
|
||||||
|
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 import cases
|
||||||
from irctest.numerics import RPL_AWAY, RPL_NOWAWAY, RPL_UNAWAY, RPL_USERHOST
|
from irctest.numerics import (
|
||||||
from irctest.patma import StrRe
|
RPL_AWAY,
|
||||||
|
RPL_NOWAWAY,
|
||||||
|
RPL_UNAWAY,
|
||||||
|
RPL_USERHOST,
|
||||||
|
RPL_WHOISUSER,
|
||||||
|
)
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
class AwayTestCase(cases.BaseServerTestCase):
|
class AwayTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_specifications("RFC2812", "Modern")
|
@cases.mark_specifications("RFC2812", "Modern")
|
||||||
def testAway(self):
|
def testAway(self):
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
|
self.getMessages(1)
|
||||||
self.sendLine(1, "AWAY :I'm not here right now")
|
self.sendLine(1, "AWAY :I'm not here right now")
|
||||||
replies = self.getMessages(1)
|
replies = self.getMessages(1)
|
||||||
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
||||||
@ -18,6 +30,7 @@ class AwayTestCase(cases.BaseServerTestCase):
|
|||||||
command=RPL_AWAY,
|
command=RPL_AWAY,
|
||||||
params=["qux", "bar", "I'm not here right now"],
|
params=["qux", "bar", "I'm not here right now"],
|
||||||
)
|
)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
self.sendLine(1, "AWAY")
|
self.sendLine(1, "AWAY")
|
||||||
replies = self.getMessages(1)
|
replies = self.getMessages(1)
|
||||||
@ -32,23 +45,27 @@ class AwayTestCase(cases.BaseServerTestCase):
|
|||||||
"""
|
"""
|
||||||
"The server acknowledges the change in away status by returning the
|
"The server acknowledges the change in away status by returning the
|
||||||
`RPL_NOWAWAY` and `RPL_UNAWAY` numerics."
|
`RPL_NOWAWAY` and `RPL_UNAWAY` numerics."
|
||||||
-- https://github.com/ircdocs/modern-irc/pull/100
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
"""
|
"""
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
self.sendLine(1, "AWAY :I'm not here right now")
|
self.sendLine(1, "AWAY :I'm not here right now")
|
||||||
replies = self.getMessages(1)
|
self.assertMessageMatch(
|
||||||
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
|
self.getMessage(1), command=RPL_NOWAWAY, params=["bar", ANYSTR]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [])
|
||||||
|
|
||||||
self.sendLine(1, "AWAY")
|
self.sendLine(1, "AWAY")
|
||||||
replies = self.getMessages(1)
|
self.assertMessageMatch(
|
||||||
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
|
self.getMessage(1), command=RPL_UNAWAY, params=["bar", ANYSTR]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [])
|
||||||
|
|
||||||
@cases.mark_specifications("Modern")
|
@cases.mark_specifications("Modern")
|
||||||
def testAwayPrivmsg(self):
|
def testAwayPrivmsg(self):
|
||||||
"""
|
"""
|
||||||
"Servers SHOULD notify clients when a user they're interacting with
|
"Servers SHOULD notify clients when a user they're interacting with
|
||||||
is away when relevant"
|
is away when relevant"
|
||||||
-- https://github.com/ircdocs/modern-irc/pull/100
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
|
||||||
"<client> <nick> :<message>"
|
"<client> <nick> :<message>"
|
||||||
-- https://modern.ircdocs.horse/#rplaway-301
|
-- https://modern.ircdocs.horse/#rplaway-301
|
||||||
@ -75,7 +92,7 @@ class AwayTestCase(cases.BaseServerTestCase):
|
|||||||
"""
|
"""
|
||||||
"Servers SHOULD notify clients when a user they're interacting with
|
"Servers SHOULD notify clients when a user they're interacting with
|
||||||
is away when relevant"
|
is away when relevant"
|
||||||
-- https://github.com/ircdocs/modern-irc/pull/100
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
|
||||||
"<client> <nick> :<message>"
|
"<client> <nick> :<message>"
|
||||||
-- https://modern.ircdocs.horse/#rplaway-301
|
-- https://modern.ircdocs.horse/#rplaway-301
|
||||||
@ -113,7 +130,7 @@ class AwayTestCase(cases.BaseServerTestCase):
|
|||||||
"""
|
"""
|
||||||
"Servers SHOULD notify clients when a user they're interacting with
|
"Servers SHOULD notify clients when a user they're interacting with
|
||||||
is away when relevant"
|
is away when relevant"
|
||||||
-- https://github.com/ircdocs/modern-irc/pull/100
|
-- https://modern.ircdocs.horse/#away-message
|
||||||
|
|
||||||
"<client> <nick> :<message>"
|
"<client> <nick> :<message>"
|
||||||
-- https://modern.ircdocs.horse/#rplaway-301
|
-- https://modern.ircdocs.horse/#rplaway-301
|
||||||
@ -134,3 +151,33 @@ class AwayTestCase(cases.BaseServerTestCase):
|
|||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=-.*")]
|
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])
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
<https://ircv3.net/specs/extensions/away-notify-3.1>
|
`IRCv3 away-notify <https://ircv3.net/specs/extensions/away-notify>`_
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
|
from irctest.numerics import RPL_NOWAWAY, RPL_UNAWAY
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
class AwayNotifyTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_capabilities("away-notify")
|
@cases.mark_capabilities("away-notify")
|
||||||
def testAwayNotify(self):
|
def testAwayNotify(self):
|
||||||
"""Basic away-notify test."""
|
"""Basic away-notify test."""
|
||||||
@ -20,13 +22,28 @@ class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
|
|
||||||
self.sendLine(2, "AWAY :i'm going away")
|
self.sendLine(2, "AWAY :i'm going away")
|
||||||
self.getMessages(2)
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(2), command=RPL_NOWAWAY, params=["bar", ANYSTR]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(2), [])
|
||||||
|
|
||||||
awayNotify = self.getMessage(1)
|
awayNotify = self.getMessage(1)
|
||||||
self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm going away"])
|
self.assertMessageMatch(
|
||||||
self.assertTrue(
|
awayNotify,
|
||||||
awayNotify.prefix.startswith("bar!"),
|
prefix=StrRe("bar!.*"),
|
||||||
"Unexpected away-notify source: %s" % (awayNotify.prefix,),
|
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")
|
@cases.mark_capabilities("away-notify")
|
||||||
@ -45,7 +62,11 @@ class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.getMessages(2)
|
self.getMessages(2)
|
||||||
|
|
||||||
self.joinChannel(2, "#chan")
|
self.joinChannel(2, "#chan")
|
||||||
self.getMessages(2)
|
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"]
|
messages = [msg for msg in self.getMessages(1) if msg.command == "AWAY"]
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Draft bot mode specification, as defined in
|
`IRCv3 bot mode <https://ircv3.net/specs/extensions/bot-mode>`_
|
||||||
<https://ircv3.net/specs/extensions/bot-mode>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases, runner
|
from irctest import cases, runner
|
||||||
@ -68,6 +67,10 @@ class BotModeTestCase(cases.BaseServerTestCase):
|
|||||||
message, command=RPL_WHOISBOT, params=["usernick", "botnick", ANYSTR]
|
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):
|
def testBotPrivateMessage(self):
|
||||||
self._initBot()
|
self._initBot()
|
||||||
|
|
||||||
@ -82,9 +85,13 @@ class BotModeTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessage("user"),
|
self.getMessage("user"),
|
||||||
command="PRIVMSG",
|
command="PRIVMSG",
|
||||||
params=["usernick", "beep boop"],
|
params=["usernick", "beep boop"],
|
||||||
tags={"draft/bot": None, **ANYDICT},
|
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):
|
def testBotChannelMessage(self):
|
||||||
self._initBot()
|
self._initBot()
|
||||||
|
|
||||||
@ -104,7 +111,7 @@ class BotModeTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessage("user"),
|
self.getMessage("user"),
|
||||||
command="PRIVMSG",
|
command="PRIVMSG",
|
||||||
params=["#chan", "beep boop"],
|
params=["#chan", "beep boop"],
|
||||||
tags={"draft/bot": None, **ANYDICT},
|
tags={StrRe("(draft/)?bot"): None, **ANYDICT},
|
||||||
)
|
)
|
||||||
|
|
||||||
def testBotWhox(self):
|
def testBotWhox(self):
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
`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 import cases
|
||||||
from irctest.irc_utils.sasl import sasl_plain_blob
|
from irctest.irc_utils.sasl import sasl_plain_blob
|
||||||
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
|
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
"""Sends packets with various length to check the server reassembles them
|
"""
|
||||||
correctly. Also checks truncation"""
|
Sends packets with various length to check the server reassembles them
|
||||||
|
correctly. Also checks truncation.
|
||||||
|
"""
|
||||||
|
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
@ -30,6 +32,16 @@ def _sendBytePerByte(self, line):
|
|||||||
|
|
||||||
|
|
||||||
class BufferingTestCase(cases.BaseServerTestCase):
|
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(
|
@pytest.mark.parametrize(
|
||||||
"sender_function,colon",
|
"sender_function,colon",
|
||||||
[
|
[
|
||||||
|
@ -1,9 +1,35 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 Capability negotiation
|
||||||
|
<https://ircv3.net/specs/extensions/capability-negotiation>`_
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.patma import ANYSTR
|
from irctest.patma import ANYSTR, StrRe
|
||||||
from irctest.runner import CapabilityNotSupported, ImplementationChoice
|
from irctest.runner import CapabilityNotSupported, ImplementationChoice
|
||||||
|
|
||||||
|
|
||||||
class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
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")
|
@cases.mark_specifications("IRCv3")
|
||||||
def testNoReq(self):
|
def testNoReq(self):
|
||||||
"""Test the server handles gracefully clients which do not send
|
"""Test the server handles gracefully clients which do not send
|
||||||
@ -18,12 +44,210 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.getCapLs(1)
|
self.getCapLs(1)
|
||||||
self.sendLine(1, "USER foo foo foo :foo")
|
self.sendLine(1, "USER foo foo foo :foo")
|
||||||
self.sendLine(1, "NICK 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")
|
self.sendLine(1, "CAP END")
|
||||||
m = self.getRegistrationMessage(1)
|
m = self.getRegistrationMessage(1)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
|
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")
|
@cases.mark_specifications("IRCv3")
|
||||||
def testReqUnavailable(self):
|
def testReqUnavailable(self):
|
||||||
"""Test the server handles gracefully clients which request
|
"""Test the server handles gracefully clients which request
|
||||||
@ -40,7 +264,7 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m,
|
m,
|
||||||
command="CAP",
|
command="CAP",
|
||||||
params=[ANYSTR, "NAK", "foo"],
|
params=[ANYSTR, "NAK", StrRe("foo ?")],
|
||||||
fail_msg="Expected CAP NAK after requesting non-existing "
|
fail_msg="Expected CAP NAK after requesting non-existing "
|
||||||
"capability, got {msg}.",
|
"capability, got {msg}.",
|
||||||
)
|
)
|
||||||
@ -80,7 +304,8 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
""" # noqa
|
""" # noqa
|
||||||
self.addClient(1)
|
self.addClient(1)
|
||||||
self.sendLine(1, "CAP LS 302")
|
self.sendLine(1, "CAP LS 302")
|
||||||
self.assertIn("multi-prefix", self.getCapLs(1))
|
if "multi-prefix" not in self.getCapLs(1):
|
||||||
|
raise CapabilityNotSupported("multi-prefix")
|
||||||
self.sendLine(1, "CAP REQ :foo multi-prefix bar")
|
self.sendLine(1, "CAP REQ :foo multi-prefix bar")
|
||||||
m = self.getRegistrationMessage(1)
|
m = self.getRegistrationMessage(1)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
@ -114,7 +339,7 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m,
|
m,
|
||||||
command="CAP",
|
command="CAP",
|
||||||
params=[ANYSTR, "ACK", "multi-prefix"],
|
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
|
||||||
fail_msg="Expected “CAP ACK :multi-prefix” after "
|
fail_msg="Expected “CAP ACK :multi-prefix” after "
|
||||||
"sending “CAP REQ :multi-prefix”, but got {msg}.",
|
"sending “CAP REQ :multi-prefix”, but got {msg}.",
|
||||||
)
|
)
|
||||||
@ -127,8 +352,13 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.addClient(1)
|
self.addClient(1)
|
||||||
self.connectClient("sender")
|
self.connectClient("sender")
|
||||||
self.sendLine(1, "CAP LS 302")
|
self.sendLine(1, "CAP LS 302")
|
||||||
m = self.getRegistrationMessage(1)
|
caps = set()
|
||||||
if not ({cap1, cap2} <= set(m.params[2].split())):
|
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}")
|
raise CapabilityNotSupported(f"{cap1} or {cap2}")
|
||||||
self.sendLine(1, f"CAP REQ :{cap1} {cap2}")
|
self.sendLine(1, f"CAP REQ :{cap1} {cap2}")
|
||||||
self.sendLine(1, "nick bar")
|
self.sendLine(1, "nick bar")
|
||||||
@ -154,17 +384,19 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
self.assertIn("time", m.tags, m)
|
self.assertIn("time", m.tags, m)
|
||||||
|
|
||||||
# remove the server-time cap
|
# remove the multi-prefix cap
|
||||||
self.sendLine(1, f"CAP REQ :-{cap2}")
|
self.sendLine(1, f"CAP REQ :-{cap2}")
|
||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
# Must be either ACK or NAK
|
# Must be either ACK or NAK
|
||||||
if self.messageDiffers(m, command="CAP", params=[ANYSTR, "ACK", f"-{cap2}"]):
|
if self.messageDiffers(
|
||||||
|
m, command="CAP", params=[ANYSTR, "ACK", StrRe(f"-{cap2} ?")]
|
||||||
|
):
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m, command="CAP", params=[ANYSTR, "NAK", f"-{cap2}"]
|
m, command="CAP", params=[ANYSTR, "NAK", StrRe(f"-{cap2} ?")]
|
||||||
)
|
)
|
||||||
raise ImplementationChoice(f"Does not support CAP REQ -{cap2}")
|
raise ImplementationChoice(f"Does not support CAP REQ -{cap2}")
|
||||||
|
|
||||||
# server-time should be disabled
|
# multi-prefix should be disabled
|
||||||
self.sendLine(1, "CAP LIST")
|
self.sendLine(1, "CAP LIST")
|
||||||
messages = self.getMessages(1)
|
messages = self.getMessages(1)
|
||||||
cap_list = [m for m in messages if m.command == "CAP"][0]
|
cap_list = [m for m in messages if m.command == "CAP"][0]
|
||||||
@ -172,3 +404,88 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
enabled_caps.discard("cap-notify") # implicitly added by some impls
|
enabled_caps.discard("cap-notify") # implicitly added by some impls
|
||||||
self.assertEqual(enabled_caps, {cap1})
|
self.assertEqual(enabled_caps, {cap1})
|
||||||
self.assertNotIn("time", cap_list.tags)
|
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)
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Channel casemapping
|
||||||
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from irctest import cases, client_mock, runner
|
from irctest import cases, client_mock, runner
|
||||||
@ -18,7 +22,7 @@ class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase):
|
|||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
if self.server_support["CASEMAPPING"] != casemapping:
|
if self.server_support["CASEMAPPING"] != casemapping:
|
||||||
raise runner.NotImplementedByController(
|
raise runner.ImplementationChoice(
|
||||||
"Casemapping {} not implemented".format(casemapping)
|
"Casemapping {} not implemented".format(casemapping)
|
||||||
)
|
)
|
||||||
self.joinClient(1, name1)
|
self.joinClient(1, name1)
|
||||||
@ -43,7 +47,7 @@ class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase):
|
|||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
if self.server_support["CASEMAPPING"] != casemapping:
|
if self.server_support["CASEMAPPING"] != casemapping:
|
||||||
raise runner.NotImplementedByController(
|
raise runner.ImplementationChoice(
|
||||||
"Casemapping {} not implemented".format(casemapping)
|
"Casemapping {} not implemented".format(casemapping)
|
||||||
)
|
)
|
||||||
self.joinClient(1, name1)
|
self.joinClient(1, name1)
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
`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 import cases
|
||||||
from irctest.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL
|
from irctest.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL
|
||||||
|
|
||||||
|
@ -1,24 +1,22 @@
|
|||||||
|
"""
|
||||||
|
`Draft IRCv3 channel-rename <https://ircv3.net/specs/extensions/channel-rename>`_
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.numerics import ERR_CHANOPRIVSNEEDED
|
from irctest.numerics import ERR_CHANOPRIVSNEEDED
|
||||||
|
|
||||||
MODERN_CAPS = [
|
|
||||||
"server-time",
|
|
||||||
"message-tags",
|
|
||||||
"batch",
|
|
||||||
"labeled-response",
|
|
||||||
"echo-message",
|
|
||||||
"account-tag",
|
|
||||||
]
|
|
||||||
RENAME_CAP = "draft/channel-rename"
|
RENAME_CAP = "draft/channel-rename"
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
class ChannelRenameTestCase(cases.BaseServerTestCase):
|
class ChannelRenameTestCase(cases.BaseServerTestCase):
|
||||||
"""Basic tests for channel-rename."""
|
"""Basic tests for channel-rename."""
|
||||||
|
|
||||||
@cases.mark_specifications("Ergo")
|
|
||||||
def testChannelRename(self):
|
def testChannelRename(self):
|
||||||
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS + [RENAME_CAP])
|
self.connectClient(
|
||||||
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
|
"bar", name="bar", capabilities=[RENAME_CAP], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
self.connectClient("baz", name="baz")
|
||||||
self.joinChannel("bar", "#bar")
|
self.joinChannel("bar", "#bar")
|
||||||
self.joinChannel("baz", "#bar")
|
self.joinChannel("baz", "#bar")
|
||||||
self.getMessages("bar")
|
self.getMessages("bar")
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
|
"""
|
||||||
|
`IRCv3 draft chathistory <https://ircv3.net/specs/extensions/chathistory>`_
|
||||||
|
"""
|
||||||
|
|
||||||
|
import functools
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases, runner
|
||||||
from irctest.irc_utils.junkdrawer import random_name
|
from irctest.irc_utils.junkdrawer import random_name
|
||||||
from irctest.patma import ANYSTR
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
CHATHISTORY_CAP = "draft/chathistory"
|
CHATHISTORY_CAP = "draft/chathistory"
|
||||||
EVENT_PLAYBACK_CAP = "draft/event-playback"
|
EVENT_PLAYBACK_CAP = "draft/event-playback"
|
||||||
@ -16,35 +21,44 @@ SUBCOMMANDS = ["LATEST", "BEFORE", "AFTER", "BETWEEN", "AROUND"]
|
|||||||
MYSQL_PASSWORD = ""
|
MYSQL_PASSWORD = ""
|
||||||
|
|
||||||
|
|
||||||
def validate_chathistory_batch(msgs):
|
def skip_ngircd(f):
|
||||||
batch_tag = None
|
@functools.wraps(f)
|
||||||
closed_batch_tag = None
|
def newf(self, *args, **kwargs):
|
||||||
result = []
|
if self.controller.software_name == "ngIRCd":
|
||||||
for msg in msgs:
|
raise runner.OptionalExtensionNotSupported("nicks longer 9 characters")
|
||||||
if msg.command == "BATCH":
|
return f(self, *args, **kwargs)
|
||||||
batch_param = msg.params[0]
|
|
||||||
if batch_tag is None and batch_param[0] == "+":
|
return newf
|
||||||
batch_tag = batch_param[1:]
|
|
||||||
elif batch_param[0] == "-":
|
|
||||||
closed_batch_tag = batch_param[1:]
|
|
||||||
elif (
|
|
||||||
msg.command == "PRIVMSG"
|
|
||||||
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())
|
|
||||||
assert batch_tag == closed_batch_tag
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.mark_services
|
@cases.mark_services
|
||||||
class ChathistoryTestCase(cases.BaseServerTestCase):
|
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
|
@staticmethod
|
||||||
def config() -> cases.TestCaseControllerConfig:
|
def config() -> cases.TestCaseControllerConfig:
|
||||||
return cases.TestCaseControllerConfig(chathistory=True)
|
return cases.TestCaseControllerConfig(chathistory=True)
|
||||||
|
|
||||||
|
@skip_ngircd
|
||||||
def testInvalidTargets(self):
|
def testInvalidTargets(self):
|
||||||
bar, pw = random_name("bar"), random_name("pw")
|
bar, pw = random_name("bar"), random_name("pw")
|
||||||
self.controller.registerUser(self, bar, pw)
|
self.controller.registerUser(self, bar, pw)
|
||||||
@ -90,6 +104,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.private_chathistory
|
@pytest.mark.private_chathistory
|
||||||
|
@skip_ngircd
|
||||||
def testMessagesToSelf(self):
|
def testMessagesToSelf(self):
|
||||||
bar, pw = random_name("bar"), random_name("pw")
|
bar, pw = random_name("bar"), random_name("pw")
|
||||||
self.controller.registerUser(self, bar, pw)
|
self.controller.registerUser(self, bar, pw)
|
||||||
@ -162,7 +177,19 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
self.assertEqual(len(set(msg.time 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)
|
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
|
||||||
|
@skip_ngircd
|
||||||
def testChathistory(self, subcommand):
|
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(
|
self.connectClient(
|
||||||
"bar",
|
"bar",
|
||||||
capabilities=[
|
capabilities=[
|
||||||
@ -193,7 +220,49 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
||||||
self.validate_chathistory(subcommand, echo_messages, 1, chname)
|
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)
|
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
|
||||||
|
@skip_ngircd
|
||||||
def testChathistoryEventPlayback(self, subcommand):
|
def testChathistoryEventPlayback(self, subcommand):
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
"bar",
|
"bar",
|
||||||
@ -216,20 +285,27 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
NUM_MESSAGES = 10
|
NUM_MESSAGES = 10
|
||||||
echo_messages = []
|
echo_messages = []
|
||||||
for i in range(NUM_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))
|
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
|
||||||
echo_messages.extend(
|
echo_messages.extend(
|
||||||
msg.to_history_message() for msg in self.getMessages(1)
|
msg.to_history_message() for msg in self.getMessages(1)
|
||||||
)
|
)
|
||||||
time.sleep(0.002)
|
time.sleep(0.002)
|
||||||
|
|
||||||
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages)
|
||||||
self.validate_chathistory(subcommand, echo_messages, 1, chname)
|
self.validate_chathistory(subcommand, echo_messages, 1, chname)
|
||||||
|
|
||||||
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
|
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
|
||||||
@pytest.mark.private_chathistory
|
@pytest.mark.private_chathistory
|
||||||
|
@skip_ngircd
|
||||||
def testChathistoryDMs(self, subcommand):
|
def testChathistoryDMs(self, subcommand):
|
||||||
c1 = "foo" + secrets.token_hex(12)
|
c1 = random_name("foo")
|
||||||
c2 = "bar" + secrets.token_hex(12)
|
c2 = random_name("bar")
|
||||||
self.controller.registerUser(self, c1, "sesame1")
|
self.controller.registerUser(self, c1, "sesame1")
|
||||||
self.controller.registerUser(self, c2, "sesame2")
|
self.controller.registerUser(self, c2, "sesame2")
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
@ -277,11 +353,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
time.sleep(0.002)
|
time.sleep(0.002)
|
||||||
|
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
|
||||||
self.validate_chathistory(subcommand, echo_messages, 1, c2)
|
self.validate_chathistory(subcommand, echo_messages, 1, c2)
|
||||||
self.validate_chathistory(subcommand, echo_messages, 2, c1)
|
self.validate_chathistory(subcommand, echo_messages, 2, c1)
|
||||||
|
|
||||||
c3 = "baz" + secrets.token_hex(12)
|
c3 = random_name("baz")
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
c3,
|
c3,
|
||||||
capabilities=[
|
capabilities=[
|
||||||
@ -370,15 +449,15 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
|
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
|
||||||
INCLUSIVE_LIMIT = len(echo_messages) * 2
|
INCLUSIVE_LIMIT = len(echo_messages) * 2
|
||||||
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
|
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages, result)
|
self.assertEqual(echo_messages, result)
|
||||||
|
|
||||||
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
|
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[-5:], result)
|
self.assertEqual(echo_messages[-5:], result)
|
||||||
|
|
||||||
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
|
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[-1:], result)
|
self.assertEqual(echo_messages[-1:], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -386,7 +465,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY LATEST %s msgid=%s %d"
|
"CHATHISTORY LATEST %s msgid=%s %d"
|
||||||
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[5:], result)
|
self.assertEqual(echo_messages[5:], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -394,7 +473,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY LATEST %s timestamp=%s %d"
|
"CHATHISTORY LATEST %s timestamp=%s %d"
|
||||||
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[5:], result)
|
self.assertEqual(echo_messages[5:], result)
|
||||||
|
|
||||||
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
|
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
|
||||||
@ -404,7 +483,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY BEFORE %s msgid=%s %d"
|
"CHATHISTORY BEFORE %s msgid=%s %d"
|
||||||
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[:6], result)
|
self.assertEqual(echo_messages[:6], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -412,7 +491,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY BEFORE %s timestamp=%s %d"
|
"CHATHISTORY BEFORE %s timestamp=%s %d"
|
||||||
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[:6], result)
|
self.assertEqual(echo_messages[:6], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -420,7 +499,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY BEFORE %s timestamp=%s %d"
|
"CHATHISTORY BEFORE %s timestamp=%s %d"
|
||||||
% (chname, echo_messages[6].time, 2),
|
% (chname, echo_messages[6].time, 2),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[4:6], result)
|
self.assertEqual(echo_messages[4:6], result)
|
||||||
|
|
||||||
def _validate_chathistory_AFTER(self, echo_messages, user, chname):
|
def _validate_chathistory_AFTER(self, echo_messages, user, chname):
|
||||||
@ -430,7 +509,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY AFTER %s msgid=%s %d"
|
"CHATHISTORY AFTER %s msgid=%s %d"
|
||||||
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[4:], result)
|
self.assertEqual(echo_messages[4:], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -438,14 +517,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY AFTER %s timestamp=%s %d"
|
"CHATHISTORY AFTER %s timestamp=%s %d"
|
||||||
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[4:], result)
|
self.assertEqual(echo_messages[4:], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
user,
|
user,
|
||||||
"CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3),
|
"CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[4:7], result)
|
self.assertEqual(echo_messages[4:7], result)
|
||||||
|
|
||||||
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
|
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
|
||||||
@ -461,7 +540,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
INCLUSIVE_LIMIT,
|
INCLUSIVE_LIMIT,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[1:-1], result)
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -474,7 +553,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
INCLUSIVE_LIMIT,
|
INCLUSIVE_LIMIT,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[1:-1], result)
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
|
|
||||||
# BETWEEN forwards and backwards with a limit, should get
|
# BETWEEN forwards and backwards with a limit, should get
|
||||||
@ -484,7 +563,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
||||||
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
|
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[1:4], result)
|
self.assertEqual(echo_messages[1:4], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -492,7 +571,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
|
||||||
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
|
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[-4:-1], result)
|
self.assertEqual(echo_messages[-4:-1], result)
|
||||||
|
|
||||||
# same stuff again but with timestamps
|
# same stuff again but with timestamps
|
||||||
@ -501,28 +580,28 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
% (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[1:-1], result)
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
user,
|
user,
|
||||||
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
% (chname, echo_messages[-1].time, echo_messages[0].time, INCLUSIVE_LIMIT),
|
% (chname, echo_messages[-1].time, echo_messages[0].time, INCLUSIVE_LIMIT),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[1:-1], result)
|
self.assertEqual(echo_messages[1:-1], result)
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
user,
|
user,
|
||||||
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
% (chname, echo_messages[0].time, echo_messages[-1].time, 3),
|
% (chname, echo_messages[0].time, echo_messages[-1].time, 3),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[1:4], result)
|
self.assertEqual(echo_messages[1:4], result)
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
user,
|
user,
|
||||||
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
|
||||||
% (chname, echo_messages[-1].time, echo_messages[0].time, 3),
|
% (chname, echo_messages[-1].time, echo_messages[0].time, 3),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[-4:-1], result)
|
self.assertEqual(echo_messages[-4:-1], result)
|
||||||
|
|
||||||
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
|
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
|
||||||
@ -530,14 +609,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
user,
|
user,
|
||||||
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1),
|
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual([echo_messages[7]], result)
|
self.assertEqual([echo_messages[7]], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
user,
|
user,
|
||||||
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3),
|
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertEqual(echo_messages[6:9], result)
|
self.assertEqual(echo_messages[6:9], result)
|
||||||
|
|
||||||
self.sendLine(
|
self.sendLine(
|
||||||
@ -545,13 +624,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
"CHATHISTORY AROUND %s timestamp=%s %d"
|
"CHATHISTORY AROUND %s timestamp=%s %d"
|
||||||
% (chname, echo_messages[7].time, 3),
|
% (chname, echo_messages[7].time, 3),
|
||||||
)
|
)
|
||||||
result = validate_chathistory_batch(self.getMessages(user))
|
result = self.validate_chathistory_batch(self.getMessages(user), chname)
|
||||||
self.assertIn(echo_messages[7], result)
|
self.assertIn(echo_messages[7], result)
|
||||||
|
|
||||||
@pytest.mark.arbitrary_client_tags
|
@pytest.mark.arbitrary_client_tags
|
||||||
|
@skip_ngircd
|
||||||
def testChathistoryTagmsg(self):
|
def testChathistoryTagmsg(self):
|
||||||
c1 = "foo" + secrets.token_hex(12)
|
c1 = random_name("foo")
|
||||||
c2 = "bar" + secrets.token_hex(12)
|
c2 = random_name("bar")
|
||||||
chname = "#chan" + secrets.token_hex(12)
|
chname = "#chan" + secrets.token_hex(12)
|
||||||
self.controller.registerUser(self, c1, "sesame1")
|
self.controller.registerUser(self, c1, "sesame1")
|
||||||
self.controller.registerUser(self, c2, "sesame2")
|
self.controller.registerUser(self, c2, "sesame2")
|
||||||
@ -647,10 +727,11 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
@pytest.mark.arbitrary_client_tags
|
@pytest.mark.arbitrary_client_tags
|
||||||
@pytest.mark.private_chathistory
|
@pytest.mark.private_chathistory
|
||||||
|
@skip_ngircd
|
||||||
def testChathistoryDMClientOnlyTags(self):
|
def testChathistoryDMClientOnlyTags(self):
|
||||||
# regression test for Ergo #1411
|
# regression test for Ergo #1411
|
||||||
c1 = "foo" + secrets.token_hex(12)
|
c1 = random_name("foo")
|
||||||
c2 = "bar" + secrets.token_hex(12)
|
c2 = random_name("bar")
|
||||||
self.controller.registerUser(self, c1, "sesame1")
|
self.controller.registerUser(self, c1, "sesame1")
|
||||||
self.controller.registerUser(self, c2, "sesame2")
|
self.controller.registerUser(self, c2, "sesame2")
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`_-specific tests of auditorium mode
|
||||||
|
|
||||||
|
TODO: Should be extended to other servers, once a specification is written.
|
||||||
|
"""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
@ -1,9 +1,18 @@
|
|||||||
from irctest import cases
|
"""
|
||||||
from irctest.numerics import ERR_BANNEDFROMCHAN
|
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):
|
class BanModeTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812")
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
def testBan(self):
|
def testBan(self):
|
||||||
"""Basic ban operation"""
|
"""Basic ban operation"""
|
||||||
self.connectClient("chanop", name="chanop")
|
self.connectClient("chanop", name="chanop")
|
||||||
@ -23,6 +32,111 @@ class BanModeTestCase(cases.BaseServerTestCase):
|
|||||||
self.sendLine("bar", "JOIN #chan")
|
self.sendLine("bar", "JOIN #chan")
|
||||||
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
|
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")
|
@cases.mark_specifications("Ergo")
|
||||||
def testCaseInsensitive(self):
|
def testCaseInsensitive(self):
|
||||||
"""Some clients allow unsetting modes if their argument matches
|
"""Some clients allow unsetting modes if their argument matches
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Various Ergo-specific channel modes
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
|
||||||
|
|
||||||
|
@ -1,3 +1,10 @@
|
|||||||
|
"""
|
||||||
|
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
|
import pytest
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
@ -20,10 +27,16 @@ class KeyTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
self.connectClient("qux")
|
self.connectClient("qux")
|
||||||
self.getMessages(2)
|
self.getMessages(2)
|
||||||
|
# JOIN with a missing key MUST receive ERR_BADCHANNELKEY:
|
||||||
self.sendLine(2, "JOIN #chan")
|
self.sendLine(2, "JOIN #chan")
|
||||||
reply = self.getMessages(2)
|
reply_cmds = {msg.command for msg in self.getMessages(2)}
|
||||||
self.assertNotIn("JOIN", {msg.command for msg in reply})
|
self.assertNotIn("JOIN", reply_cmds)
|
||||||
self.assertIn(ERR_BADCHANNELKEY, {msg.command for msg in reply})
|
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")
|
self.sendLine(2, "JOIN #chan beer")
|
||||||
reply = self.getMessages(2)
|
reply = self.getMessages(2)
|
||||||
@ -31,8 +44,8 @@ class KeyTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"key",
|
"key",
|
||||||
["passphrase with spaces", "long" * 100, ""],
|
["passphrase with spaces", "long" * 100, "", " "],
|
||||||
ids=["spaces", "long", "empty"],
|
ids=["spaces", "long", "empty", "only-space"],
|
||||||
)
|
)
|
||||||
@cases.mark_specifications("RFC2812", "Modern")
|
@cases.mark_specifications("RFC2812", "Modern")
|
||||||
def testKeyValidation(self, key):
|
def testKeyValidation(self, key):
|
||||||
@ -57,6 +70,23 @@ class KeyTestCase(cases.BaseServerTestCase):
|
|||||||
-- https://modern.ircdocs.horse/#key-channel-mode
|
-- https://modern.ircdocs.horse/#key-channel-mode
|
||||||
-- https://github.com/ircdocs/modern-irc/pull/111
|
-- 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.connectClient("bar")
|
||||||
self.joinChannel(1, "#chan")
|
self.joinChannel(1, "#chan")
|
||||||
self.sendLine(1, f"MODE #chan +k :{key}")
|
self.sendLine(1, f"MODE #chan +k :{key}")
|
||||||
|
@ -1,3 +1,9 @@
|
|||||||
|
"""
|
||||||
|
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 import cases
|
||||||
from irctest.numerics import ERR_CANNOTSENDTOCHAN
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN
|
||||||
|
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Mute extban, currently no specifications or ways to discover it.
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases, runner
|
from irctest import cases, runner
|
||||||
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
|
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
|
||||||
from irctest.patma import ANYLIST, StrRe
|
from irctest.patma import ANYLIST, StrRe
|
||||||
@ -16,7 +20,7 @@ class MuteExtbanTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
@cases.mark_specifications("Ergo")
|
@cases.mark_specifications("Ergo")
|
||||||
def testISupport(self):
|
def testISupport(self):
|
||||||
self.connectClient(1) # Fetches ISUPPORT
|
self.connectClient("chk") # Fetches ISUPPORT
|
||||||
isupport = self.server_support
|
isupport = self.server_support
|
||||||
token = isupport["EXTBAN"]
|
token = isupport["EXTBAN"]
|
||||||
prefix, comma, types = token.partition(",")
|
prefix, comma, types = token.partition(",")
|
||||||
@ -194,7 +198,7 @@ class MuteExtbanTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessages(client)
|
self.getMessages(client)
|
||||||
|
|
||||||
# +e grants an exemption to +b
|
# +e grants an exemption to +b
|
||||||
self.sendLine("chanop", f"MODE #chan +e {prefix}{self.char()}:*!~evan@*")
|
self.sendLine("chanop", f"MODE #chan +e {prefix}{self.char()}:*!*evan@*")
|
||||||
replies = {msg.command for msg in self.getMessages("chanop")}
|
replies = {msg.command for msg in self.getMessages("chanop")}
|
||||||
self.assertIn("MODE", replies)
|
self.assertIn("MODE", replies)
|
||||||
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
|
||||||
|
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"})
|
@ -1,3 +1,8 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`_-specific tests for nick collisions based on Unicode
|
||||||
|
confusable characters
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
|
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
|
||||||
|
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
"""
|
"""
|
||||||
Tests section 4.1 of RFC 1459.
|
Tests section 4.1 of RFC 1459.
|
||||||
<https://tools.ietf.org/html/rfc1459#section-4.1>
|
<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 import cases
|
||||||
from irctest.client_mock import ConnectionClosed
|
from irctest.client_mock import ConnectionClosed
|
||||||
from irctest.numerics import ERR_NEEDMOREPARAMS
|
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH
|
||||||
from irctest.patma import ANYSTR, StrRe
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
@ -36,8 +40,14 @@ class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
|||||||
m.command, "001", msg="Got 001 after NICK+USER but missing PASS"
|
m.command, "001", msg="Got 001 after NICK+USER but missing PASS"
|
||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812")
|
@cases.mark_specifications("Modern")
|
||||||
def testWrongPassword(self):
|
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.addClient()
|
||||||
self.sendLine(1, "PASS {}".format(self.password + "garbage"))
|
self.sendLine(1, "PASS {}".format(self.password + "garbage"))
|
||||||
self.sendLine(1, "NICK foo")
|
self.sendLine(1, "NICK foo")
|
||||||
@ -46,6 +56,13 @@ class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
|||||||
self.assertNotEqual(
|
self.assertNotEqual(
|
||||||
m.command, "001", msg="Got 001 after NICK+USER but incorrect PASS"
|
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)
|
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
|
||||||
def testPassAfterNickuser(self):
|
def testPassAfterNickuser(self):
|
||||||
@ -82,6 +99,10 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC2812")
|
@cases.mark_specifications("RFC2812")
|
||||||
|
@cases.xfailIfSoftware(["Charybdis", "Solanum"], "very flaky")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2", "Nefarious", "snircd"], "ircu2 does not send ERROR"
|
||||||
|
)
|
||||||
def testQuitErrors(self):
|
def testQuitErrors(self):
|
||||||
"""“A client session is terminated with a quit message. The server
|
"""“A client session is terminated with a quit message. The server
|
||||||
acknowledges this by sending an ERROR message to the client.”
|
acknowledges this by sending an ERROR message to the client.”
|
||||||
@ -114,7 +135,7 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
|||||||
self.assertNotEqual(
|
self.assertNotEqual(
|
||||||
m.command,
|
m.command,
|
||||||
"001",
|
"001",
|
||||||
"Received 001 after registering with the nick of a " "registered user.",
|
"Received 001 after registering with the nick of a registered user.",
|
||||||
)
|
)
|
||||||
|
|
||||||
def testEarlyNickCollision(self):
|
def testEarlyNickCollision(self):
|
||||||
@ -162,6 +183,10 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
|||||||
"neither got 001.",
|
"neither got 001.",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2", "Nefarious", "ngIRCd"],
|
||||||
|
"uses a default value instead of ERR_NEEDMOREPARAMS",
|
||||||
|
)
|
||||||
def testEmptyRealname(self):
|
def testEmptyRealname(self):
|
||||||
"""
|
"""
|
||||||
Syntax:
|
Syntax:
|
||||||
@ -184,59 +209,57 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
|
|||||||
params=[StrRe(r"(\*|foo)"), "USER", ANYSTR],
|
params=[StrRe(r"(\*|foo)"), "USER", ANYSTR],
|
||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
def testNonutf8Realname(self):
|
||||||
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.addClient()
|
||||||
self.sendLine(1, "CAP LS")
|
self.sendLine(1, "NICK foo")
|
||||||
m = self.getRegistrationMessage(1)
|
line = b"USER username * * :i\xe8rc\xe9\r\n"
|
||||||
self.assertNotEqual(
|
print("1 -> S (repr): " + repr(line))
|
||||||
m.params[2],
|
self.clients[1].conn.sendall(line)
|
||||||
"*",
|
for _ in range(10):
|
||||||
m,
|
time.sleep(1)
|
||||||
fail_msg="Server replied with multi-line CAP LS to a "
|
d = self.clients[1].conn.recv(10000)
|
||||||
"“CAP LS” (ie. IRCv3.1) request: {msg}",
|
self.assertTrue(d, "Server closed connection")
|
||||||
)
|
print("S -> 1 (repr): " + repr(d))
|
||||||
self.assertFalse(
|
if b" 001 " in d:
|
||||||
any("=" in cap for cap in m.params[2].split()),
|
break
|
||||||
"Server replied with a name-value capability in "
|
if b"ERROR " in d or b" FAIL " in d:
|
||||||
"CAP LS reply as a response to “CAP LS” (ie. IRCv3.1) "
|
# Rejected; nothing more to test.
|
||||||
"request: {}".format(m),
|
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)
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
def testNonutf8Username(self):
|
||||||
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.addClient()
|
||||||
self.sendLine(1, "CAP LIST")
|
self.sendLine(1, "NICK foo")
|
||||||
m = self.getRegistrationMessage(1)
|
self.sendLine(1, "USER 😊😊😊😊😊😊😊😊😊😊 * * :realname")
|
||||||
self.assertMessageMatch(
|
for _ in range(10):
|
||||||
m,
|
time.sleep(1)
|
||||||
command="CAP",
|
d = self.clients[1].conn.recv(10000)
|
||||||
params=["*", "LIST", ""],
|
self.assertTrue(d, "Server closed connection")
|
||||||
fail_msg="Sending “CAP LIST” as first message got a reply "
|
print("S -> 1 (repr): " + repr(d))
|
||||||
"that is not “CAP * LIST :”: {msg}",
|
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)
|
||||||
|
@ -1,11 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
<http://ircv3.net/specs/extensions/echo-message-3.2.html>
|
`IRCv3 echo-message <https://ircv3.net/specs/extensions/echo-message>`_
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.basecontrollers import NotImplementedByController
|
|
||||||
from irctest.irc_utils.junkdrawer import random_name
|
from irctest.irc_utils.junkdrawer import random_name
|
||||||
from irctest.patma import ANYDICT
|
from irctest.patma import ANYDICT
|
||||||
|
|
||||||
@ -23,36 +22,20 @@ class EchoMessageTestCase(cases.BaseServerTestCase):
|
|||||||
@cases.mark_capabilities("echo-message")
|
@cases.mark_capabilities("echo-message")
|
||||||
def testEchoMessage(self, command, solo, server_time):
|
def testEchoMessage(self, command, solo, server_time):
|
||||||
"""<http://ircv3.net/specs/extensions/echo-message-3.2.html>"""
|
"""<http://ircv3.net/specs/extensions/echo-message-3.2.html>"""
|
||||||
self.addClient()
|
capabilities = ["server-time"] if server_time else []
|
||||||
self.sendLine(1, "CAP LS 302")
|
|
||||||
capabilities = self.getCapLs(1)
|
|
||||||
if "echo-message" not in capabilities:
|
|
||||||
raise NotImplementedByController("echo-message")
|
|
||||||
if server_time and "server-time" not in capabilities:
|
|
||||||
raise NotImplementedByController("server-time")
|
|
||||||
|
|
||||||
# TODO: check also without this
|
self.connectClient(
|
||||||
self.sendLine(
|
"baz",
|
||||||
1,
|
capabilities=["echo-message", *capabilities],
|
||||||
"CAP REQ :echo-message{}".format(" server-time" if server_time else ""),
|
skip_if_cap_nak=True,
|
||||||
)
|
)
|
||||||
self.getRegistrationMessage(1)
|
|
||||||
# TODO: Remove this one the trailing space issue is fixed in Charybdis
|
|
||||||
# and Mammon:
|
|
||||||
# self.assertMessageMatch(m, command='CAP',
|
|
||||||
# params=['*', 'ACK', 'echo-message'] +
|
|
||||||
# (['server-time'] if server_time else []),
|
|
||||||
# fail_msg='Did not ACK advertised capabilities: {msg}')
|
|
||||||
self.sendLine(1, "USER f * * :foo")
|
|
||||||
self.sendLine(1, "NICK baz")
|
|
||||||
self.sendLine(1, "CAP END")
|
|
||||||
self.skipToWelcome(1)
|
|
||||||
self.getMessages(1)
|
|
||||||
|
|
||||||
self.sendLine(1, "JOIN #chan")
|
self.sendLine(1, "JOIN #chan")
|
||||||
|
|
||||||
|
# Synchronize
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
if not solo:
|
if not solo:
|
||||||
capabilities = ["server-time"] if server_time else None
|
|
||||||
self.connectClient("qux", capabilities=capabilities)
|
self.connectClient("qux", capabilities=capabilities)
|
||||||
self.sendLine(2, "JOIN #chan")
|
self.sendLine(2, "JOIN #chan")
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
@ -1,12 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
<http://ircv3.net/specs/extensions/extended-join-3.1.html>
|
`IRCv3 extended-join <https://ircv3.net/specs/extensions/extended-join>`_
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
|
|
||||||
|
|
||||||
@cases.mark_services
|
@cases.mark_services
|
||||||
class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
class MetadataTestCase(cases.BaseServerTestCase):
|
||||||
def connectRegisteredClient(self, nick):
|
def connectRegisteredClient(self, nick):
|
||||||
self.addClient()
|
self.addClient()
|
||||||
self.sendLine(2, "CAP LS 302")
|
self.sendLine(2, "CAP LS 302")
|
||||||
@ -50,7 +50,7 @@ class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_capabilities("extended-join")
|
@cases.mark_capabilities("extended-join")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
def testLoggedIn(self):
|
def testLoggedIn(self):
|
||||||
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
|
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
|
||||||
self.joinChannel(1, "#chan")
|
self.joinChannel(1, "#chan")
|
||||||
|
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],
|
||||||
|
)
|
@ -1,9 +1,18 @@
|
|||||||
|
"""
|
||||||
|
The INVITE command (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.7>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#invite-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases, runner
|
||||||
from irctest.numerics import (
|
from irctest.numerics import (
|
||||||
|
ERR_BANNEDFROMCHAN,
|
||||||
ERR_CHANOPRIVSNEEDED,
|
ERR_CHANOPRIVSNEEDED,
|
||||||
ERR_INVITEONLYCHAN,
|
ERR_INVITEONLYCHAN,
|
||||||
|
ERR_NEEDMOREPARAMS,
|
||||||
ERR_NOSUCHNICK,
|
ERR_NOSUCHNICK,
|
||||||
ERR_NOTONCHANNEL,
|
ERR_NOTONCHANNEL,
|
||||||
ERR_USERONCHANNEL,
|
ERR_USERONCHANNEL,
|
||||||
@ -109,7 +118,7 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
"got this instead: {msg}",
|
"got this instead: {msg}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _testInvite(self, opped, invite_only, modern):
|
def _testInvite(self, opped, invite_only):
|
||||||
"""
|
"""
|
||||||
"Only the user inviting and the user being invited will receive
|
"Only the user inviting and the user being invited will receive
|
||||||
notification of the invitation."
|
notification of the invitation."
|
||||||
@ -162,23 +171,14 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.sendLine(1, "INVITE bar #chan")
|
self.sendLine(1, "INVITE bar #chan")
|
||||||
if modern:
|
self.assertMessageMatch(
|
||||||
self.assertMessageMatch(
|
self.getMessage(1),
|
||||||
self.getMessage(1),
|
command=RPL_INVITING,
|
||||||
command=RPL_INVITING,
|
params=["foo", "bar", "#chan"],
|
||||||
params=["foo", "bar", "#chan"],
|
fail_msg=f"After “foo” invited “bar” to a channel, “foo” should have "
|
||||||
fail_msg=f"After “foo” invited “bar” to a channel, “foo” should have "
|
f"received “{RPL_INVITING} foo #chan bar” but got this instead: "
|
||||||
f"received “{RPL_INVITING} foo #chan bar” but got this instead: "
|
f"{{msg}}",
|
||||||
f"{{msg}}",
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.assertMessageMatch(
|
|
||||||
self.getMessage(1),
|
|
||||||
command=RPL_INVITING,
|
|
||||||
params=["#chan", "bar"],
|
|
||||||
fail_msg=f"After “foo” invited “bar” to a channel, “foo” should have "
|
|
||||||
f"received “{RPL_INVITING} #chan bar” but got this instead: {{msg}}",
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = self.getMessages(2)
|
messages = self.getMessages(2)
|
||||||
self.assertNotEqual(
|
self.assertNotEqual(
|
||||||
@ -196,24 +196,17 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@pytest.mark.parametrize("invite_only", [True, False])
|
@pytest.mark.parametrize("invite_only", [True, False])
|
||||||
@cases.mark_specifications("Modern")
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
def testInviteModern(self, invite_only):
|
def testInvite(self, invite_only):
|
||||||
self._testInvite(opped=True, invite_only=invite_only, modern=True)
|
self._testInvite(opped=True, invite_only=invite_only)
|
||||||
|
|
||||||
@pytest.mark.parametrize("invite_only", [True, False])
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern", strict=True)
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
|
@cases.xfailIfSoftware(
|
||||||
def testInviteRfc(self, invite_only):
|
["Hybrid", "Plexus4"], "the only strict test that Hybrid fails"
|
||||||
self._testInvite(opped=True, invite_only=invite_only, modern=False)
|
)
|
||||||
|
def testInviteUnopped(self):
|
||||||
@cases.mark_specifications("Modern", strict=True)
|
|
||||||
def testInviteUnoppedModern(self):
|
|
||||||
"""Tests invites from unopped users on not-invite-only chans."""
|
"""Tests invites from unopped users on not-invite-only chans."""
|
||||||
self._testInvite(opped=False, invite_only=False, modern=True)
|
self._testInvite(opped=False, invite_only=False)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True, strict=True)
|
|
||||||
def testInviteUnoppedRfc(self, opped, invite_only):
|
|
||||||
"""Tests invites from unopped users on not-invite-only chans."""
|
|
||||||
self._testInvite(opped=False, invite_only=False, modern=False)
|
|
||||||
|
|
||||||
@cases.mark_specifications("RFC2812", "Modern")
|
@cases.mark_specifications("RFC2812", "Modern")
|
||||||
def testInviteNoNotificationForOtherMembers(self):
|
def testInviteNoNotificationForOtherMembers(self):
|
||||||
@ -247,7 +240,13 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
"were notified: {got}",
|
"were notified: {got}",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _testInviteInviteOnly(self, modern):
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Plexus4"],
|
||||||
|
"Plexus4 allows non-op to invite if (and only if) the channel is not "
|
||||||
|
"invite-only",
|
||||||
|
)
|
||||||
|
def testInviteInviteOnly(self):
|
||||||
"""
|
"""
|
||||||
"To invite a user to a channel which is invite only (MODE
|
"To invite a user to a channel which is invite only (MODE
|
||||||
+i), the client sending the invite must be recognised as being a
|
+i), the client sending the invite must be recognised as being a
|
||||||
@ -287,35 +286,17 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.sendLine(1, "INVITE bar #chan")
|
self.sendLine(1, "INVITE bar #chan")
|
||||||
if modern:
|
self.assertMessageMatch(
|
||||||
self.assertMessageMatch(
|
self.getMessage(1),
|
||||||
self.getMessage(1),
|
command=ERR_CHANOPRIVSNEEDED,
|
||||||
command=ERR_CHANOPRIVSNEEDED,
|
params=["foo", "#chan", ANYSTR],
|
||||||
params=["foo", "#chan", ANYSTR],
|
fail_msg=f"After “foo” invited “bar” to a channel to an invite-only "
|
||||||
fail_msg=f"After “foo” invited “bar” to a channel to an invite-only "
|
f"channel without being opped, “foo” should have received "
|
||||||
f"channel without being opped, “foo” should have received "
|
f"“{ERR_CHANOPRIVSNEEDED} foo #chan :*” but got this instead: {{msg}}",
|
||||||
f"“{ERR_CHANOPRIVSNEEDED} foo #chan :*” but got this instead: {{msg}}",
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.assertMessageMatch(
|
|
||||||
self.getMessage(1),
|
|
||||||
command=ERR_CHANOPRIVSNEEDED,
|
|
||||||
params=["#chan", ANYSTR],
|
|
||||||
fail_msg=f"After “foo” invited “bar” to a channel to an invite-only "
|
|
||||||
f"channel without being opped, “foo” should have received "
|
|
||||||
f"“{ERR_CHANOPRIVSNEEDED} #chan :*” but got this instead: {{msg}}",
|
|
||||||
)
|
|
||||||
|
|
||||||
@cases.mark_specifications("Modern")
|
|
||||||
def testInviteInviteOnlyModern(self):
|
|
||||||
self._testInviteInviteOnly(modern=True)
|
|
||||||
|
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
|
|
||||||
def testInviteInviteOnlyRfc(self):
|
|
||||||
self._testInviteInviteOnly(modern=False)
|
|
||||||
|
|
||||||
@cases.mark_specifications("RFC2812", "Modern")
|
@cases.mark_specifications("RFC2812", "Modern")
|
||||||
def _testInviteOnlyFromUsersInChannel(self, modern):
|
def testInviteOnlyFromUsersInChannel(self):
|
||||||
"""
|
"""
|
||||||
"if the channel exists, only members of the channel are allowed
|
"if the channel exists, only members of the channel are allowed
|
||||||
to invite other users"
|
to invite other users"
|
||||||
@ -348,26 +329,15 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessages(3)
|
self.getMessages(3)
|
||||||
|
|
||||||
self.sendLine(1, "INVITE bar #chan")
|
self.sendLine(1, "INVITE bar #chan")
|
||||||
if modern:
|
self.assertMessageMatch(
|
||||||
self.assertMessageMatch(
|
self.getMessage(1),
|
||||||
self.getMessage(1),
|
command=ERR_NOTONCHANNEL,
|
||||||
command=ERR_NOTONCHANNEL,
|
params=["foo", "#chan", ANYSTR],
|
||||||
params=["foo", "#chan", ANYSTR],
|
fail_msg=f"After “foo” invited “bar” to a channel it is not on "
|
||||||
fail_msg=f"After “foo” invited “bar” to a channel it is not on "
|
f"#chan, “foo” should have received "
|
||||||
f"#chan, “foo” should have received "
|
f"“ERR_NOTONCHANNEL ({ERR_NOTONCHANNEL}) foo #chan :*” but "
|
||||||
f"“ERR_NOTONCHANNEL ({ERR_NOTONCHANNEL}) foo #chan :*” but "
|
f"got this instead: {{msg}}",
|
||||||
f"got this instead: {{msg}}",
|
)
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.assertMessageMatch(
|
|
||||||
self.getMessage(1),
|
|
||||||
command=ERR_NOTONCHANNEL,
|
|
||||||
params=["#chan", ANYSTR],
|
|
||||||
fail_msg=f"After “foo” invited “bar” to a channel it is not on "
|
|
||||||
f"#chan, “foo” should have received "
|
|
||||||
f"“ERR_NOTONCHANNEL ({ERR_NOTONCHANNEL}) #chan :*” but "
|
|
||||||
f"got this instead: {{msg}}",
|
|
||||||
)
|
|
||||||
|
|
||||||
messages = self.getMessages(2)
|
messages = self.getMessages(2)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
@ -377,14 +347,6 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
"not in #chan, “bar” received something.",
|
"not in #chan, “bar” received something.",
|
||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("Modern")
|
|
||||||
def testInviteOnlyFromUsersInChannelModern(self):
|
|
||||||
self._testInviteOnlyFromUsersInChannel(modern=True)
|
|
||||||
|
|
||||||
@cases.mark_specifications("RFC2812", deprecated=True)
|
|
||||||
def testInviteOnlyFromUsersInChannelRfc(self):
|
|
||||||
self._testInviteOnlyFromUsersInChannel(modern=False)
|
|
||||||
|
|
||||||
@cases.mark_specifications("Modern")
|
@cases.mark_specifications("Modern")
|
||||||
def testInviteAlreadyInChannel(self):
|
def testInviteAlreadyInChannel(self):
|
||||||
"""
|
"""
|
||||||
@ -398,8 +360,8 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessages(2)
|
self.getMessages(2)
|
||||||
|
|
||||||
self.sendLine(1, "JOIN #chan")
|
self.sendLine(1, "JOIN #chan")
|
||||||
self.sendLine(2, "JOIN #chan")
|
|
||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
|
self.sendLine(2, "JOIN #chan")
|
||||||
self.getMessages(2)
|
self.getMessages(2)
|
||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
|
|
||||||
@ -410,3 +372,124 @@ class InviteTestCase(cases.BaseServerTestCase):
|
|||||||
command=ERR_USERONCHANNEL,
|
command=ERR_USERONCHANNEL,
|
||||||
params=["foo", "bar", "#chan", ANYSTR],
|
params=["foo", "bar", "#chan", ANYSTR],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC2812", "Modern")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2"],
|
||||||
|
"Uses 346/347 instead of 336/337 to reply to INVITE "
|
||||||
|
"https://github.com/UndernetIRC/ircu2/pull/20",
|
||||||
|
)
|
||||||
|
def testInviteList(self):
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #chan")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "INVITE bar #chan")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.sendLine(2, "INVITE")
|
||||||
|
m = self.getMessage(2)
|
||||||
|
if m.command == ERR_NEEDMOREPARAMS:
|
||||||
|
raise runner.OptionalExtensionNotSupported("INVITE with no parameter")
|
||||||
|
if m.command != "337":
|
||||||
|
# Hybrid always sends an empty list; so skip this.
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="336",
|
||||||
|
params=["bar", "#chan"],
|
||||||
|
)
|
||||||
|
m = self.getMessage(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="337",
|
||||||
|
params=["bar", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_isupport("INVEX")
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testInvexList(self):
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
if "INVEX" in self.server_support:
|
||||||
|
invex = self.server_support.get("INVEX") or "I"
|
||||||
|
else:
|
||||||
|
raise runner.IsupportTokenNotSupported("INVEX")
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #chan")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, f"MODE #chan +{invex} bar!*@*")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, f"MODE #chan +{invex}")
|
||||||
|
m = self.getMessage(1)
|
||||||
|
if len(m.params) == 3:
|
||||||
|
# Old format
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="346",
|
||||||
|
params=["foo", "#chan", "bar!*@*"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
m,
|
||||||
|
command="346",
|
||||||
|
params=[
|
||||||
|
"foo",
|
||||||
|
"#chan",
|
||||||
|
"bar!*@*",
|
||||||
|
StrRe("foo(!.*@.*)?"),
|
||||||
|
StrRe("[0-9]+"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command="347",
|
||||||
|
params=["foo", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Ergo")
|
||||||
|
def testInviteExemptsFromBan(self):
|
||||||
|
# regression test for ergochat/ergo#1876;
|
||||||
|
# INVITE should override a +b ban
|
||||||
|
self.connectClient("alice", name="alice")
|
||||||
|
self.joinChannel("alice", "#alice")
|
||||||
|
self.sendLine("alice", "MODE #alice +b bob!*@*")
|
||||||
|
result = {msg.command for msg in self.getMessages("alice")}
|
||||||
|
self.assertIn("MODE", result)
|
||||||
|
|
||||||
|
self.connectClient("bob", name="bob")
|
||||||
|
self.sendLine("bob", "JOIN #alice")
|
||||||
|
result = {msg.command for msg in self.getMessages("bob")}
|
||||||
|
self.assertIn(ERR_BANNEDFROMCHAN, result)
|
||||||
|
self.assertNotIn("JOIN", result)
|
||||||
|
|
||||||
|
self.sendLine("alice", "INVITE bob #alice")
|
||||||
|
result = {msg.command for msg in self.getMessages("alice")}
|
||||||
|
self.assertIn(RPL_INVITING, result)
|
||||||
|
self.assertNotIn(ERR_USERONCHANNEL, result)
|
||||||
|
|
||||||
|
result = {msg.command for msg in self.getMessages("bob")}
|
||||||
|
self.assertIn("INVITE", result)
|
||||||
|
|
||||||
|
self.sendLine("bob", "JOIN #alice")
|
||||||
|
result = {msg.command for msg in self.getMessages("bob")}
|
||||||
|
self.assertNotIn(ERR_BANNEDFROMCHAN, result)
|
||||||
|
self.assertIn("JOIN", result)
|
||||||
|
|
||||||
|
self.sendLine("alice", "KICK #alice bob")
|
||||||
|
self.getMessages("alice")
|
||||||
|
result = {msg.command for msg in self.getMessages("bob")}
|
||||||
|
self.assertIn("KICK", result)
|
||||||
|
|
||||||
|
# INVITE gets "used up" after one JOIN
|
||||||
|
self.sendLine("bob", "JOIN #alice")
|
||||||
|
result = {msg.command for msg in self.getMessages("bob")}
|
||||||
|
self.assertIn(ERR_BANNEDFROMCHAN, result)
|
||||||
|
self.assertNotIn("JOIN", result)
|
||||||
|
@ -1,9 +1,72 @@
|
|||||||
|
"""
|
||||||
|
RPL_ISUPPORT: `format <https://modern.ircdocs.horse/#rplisupport-005>`__
|
||||||
|
and various `tokens <https://modern.ircdocs.horse/#rplisupport-parameters>`__
|
||||||
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from irctest import cases, runner
|
from irctest import cases, runner
|
||||||
|
|
||||||
|
|
||||||
class IsupportTestCase(cases.BaseServerTestCase):
|
class IsupportTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@cases.mark_isupport("PREFIX")
|
||||||
|
def testPrefix(self):
|
||||||
|
"""https://modern.ircdocs.horse/#prefix-parameter"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
|
||||||
|
if "PREFIX" not in self.server_support:
|
||||||
|
raise runner.IsupportTokenNotSupported("PREFIX")
|
||||||
|
|
||||||
|
if self.server_support["PREFIX"] == "":
|
||||||
|
# "The value is OPTIONAL and when it is not specified indicates that no
|
||||||
|
# prefixes are supported."
|
||||||
|
return
|
||||||
|
|
||||||
|
m = re.match(
|
||||||
|
r"\((?P<modes>[a-zA-Z]+)\)(?P<prefixes>\S+)", self.server_support["PREFIX"]
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
m,
|
||||||
|
f"PREFIX={self.server_support['PREFIX']} does not have the expected "
|
||||||
|
f"format.",
|
||||||
|
)
|
||||||
|
|
||||||
|
modes = m.group("modes")
|
||||||
|
prefixes = m.group("prefixes")
|
||||||
|
|
||||||
|
# "There is a one-to-one mapping between prefixes and channel modes."
|
||||||
|
self.assertEqual(
|
||||||
|
len(modes), len(prefixes), "Mismatched length of prefix and channel modes."
|
||||||
|
)
|
||||||
|
|
||||||
|
# "The prefixes in this parameter are in descending order, from the prefix
|
||||||
|
# that gives the most privileges to the prefix that gives the least."
|
||||||
|
self.assertLess(modes.index("o"), modes.index("v"), "'o' is not before 'v'")
|
||||||
|
if "h" in modes:
|
||||||
|
self.assertLess(modes.index("o"), modes.index("h"), "'o' is not before 'h'")
|
||||||
|
self.assertLess(modes.index("h"), modes.index("v"), "'h' is not before 'v'")
|
||||||
|
if "q" in modes:
|
||||||
|
self.assertLess(modes.index("q"), modes.index("o"), "'q' is not before 'o'")
|
||||||
|
|
||||||
|
# Not technically in the spec, but it would be very confusing not to follow
|
||||||
|
# these conventions.
|
||||||
|
mode_to_prefix = dict(zip(modes, prefixes))
|
||||||
|
self.assertEqual(mode_to_prefix["o"], "@", "Prefix char for mode +o is not @")
|
||||||
|
self.assertEqual(mode_to_prefix["v"], "+", "Prefix char for mode +v is not +")
|
||||||
|
if "h" in modes:
|
||||||
|
self.assertEqual(
|
||||||
|
mode_to_prefix["h"], "%", "Prefix char for mode +h is not %"
|
||||||
|
)
|
||||||
|
if "q" in modes:
|
||||||
|
self.assertEqual(
|
||||||
|
mode_to_prefix["q"], "~", "Prefix char for mode +q is not ~"
|
||||||
|
)
|
||||||
|
if "a" in modes:
|
||||||
|
self.assertEqual(
|
||||||
|
mode_to_prefix["a"], "&", "Prefix char for mode +a is not &"
|
||||||
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("Modern", "ircdocs")
|
@cases.mark_specifications("Modern", "ircdocs")
|
||||||
@cases.mark_isupport("TARGMAX")
|
@cases.mark_isupport("TARGMAX")
|
||||||
def testTargmax(self):
|
def testTargmax(self):
|
||||||
@ -17,7 +80,7 @@ class IsupportTestCase(cases.BaseServerTestCase):
|
|||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
|
|
||||||
if "TARGMAX" not in self.server_support:
|
if "TARGMAX" not in self.server_support:
|
||||||
raise runner.NotImplementedByController("TARGMAX")
|
raise runner.IsupportTokenNotSupported("TARGMAX")
|
||||||
|
|
||||||
parts = self.server_support["TARGMAX"].split(",")
|
parts = self.server_support["TARGMAX"].split(",")
|
||||||
for part in parts:
|
for part in parts:
|
||||||
|
@ -1,5 +1,30 @@
|
|||||||
from irctest import cases
|
"""
|
||||||
|
The JOIN command (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.1>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.1>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#join-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
from irctest.irc_utils import ambiguities
|
from irctest.irc_utils import ambiguities
|
||||||
|
from irctest.numerics import (
|
||||||
|
ERR_BADCHANMASK,
|
||||||
|
ERR_FORBIDDENCHANNEL,
|
||||||
|
ERR_NOSUCHCHANNEL,
|
||||||
|
RPL_ENDOFNAMES,
|
||||||
|
RPL_NAMREPLY,
|
||||||
|
)
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
ERR_BADCHANNAME = "479" # Hybrid only, and conflicts with others
|
||||||
|
|
||||||
|
|
||||||
|
JOIN_ERROR_NUMERICS = {
|
||||||
|
ERR_BADCHANMASK,
|
||||||
|
ERR_NOSUCHCHANNEL,
|
||||||
|
ERR_FORBIDDENCHANNEL,
|
||||||
|
ERR_BADCHANNAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class JoinTestCase(cases.BaseServerTestCase):
|
class JoinTestCase(cases.BaseServerTestCase):
|
||||||
@ -19,13 +44,22 @@ class JoinTestCase(cases.BaseServerTestCase):
|
|||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.sendLine(1, "JOIN #chan")
|
self.sendLine(1, "JOIN #chan")
|
||||||
received_commands = {m.command for m in self.getMessages(1)}
|
received_commands = {m.command for m in self.getMessages(1)}
|
||||||
expected_commands = {"353", "366"} # RPL_NAMREPLY # RPL_ENDOFNAMES
|
expected_commands = {RPL_NAMREPLY, RPL_ENDOFNAMES, "JOIN"}
|
||||||
self.assertTrue(
|
acceptable_commands = expected_commands | {"MODE"}
|
||||||
expected_commands.issubset(received_commands),
|
self.assertLessEqual( # set inclusion
|
||||||
|
expected_commands,
|
||||||
|
received_commands,
|
||||||
"Server sent {} commands, but at least {} were expected.".format(
|
"Server sent {} commands, but at least {} were expected.".format(
|
||||||
received_commands, expected_commands
|
received_commands, expected_commands
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
self.assertLessEqual( # ditto
|
||||||
|
received_commands,
|
||||||
|
acceptable_commands,
|
||||||
|
"Server sent {} commands, but only {} were expected.".format(
|
||||||
|
received_commands, acceptable_commands
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC2812")
|
@cases.mark_specifications("RFC2812")
|
||||||
def testJoinNamreply(self):
|
def testJoinNamreply(self):
|
||||||
@ -110,3 +144,95 @@ class JoinTestCase(cases.BaseServerTestCase):
|
|||||||
'"foo" with an optional "+" or "@" prefix, but got: '
|
'"foo" with an optional "+" or "@" prefix, but got: '
|
||||||
"{msg}",
|
"{msg}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def testJoinPartiallyInvalid(self):
|
||||||
|
"""TODO: specify this in Modern"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
if int(self.targmax.get("JOIN") or "4") < 2:
|
||||||
|
raise runner.OptionalExtensionNotSupported("multi-channel JOIN")
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #valid,inv@lid")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
received_commands = {m.command for m in messages}
|
||||||
|
expected_commands = {RPL_NAMREPLY, RPL_ENDOFNAMES, "JOIN"}
|
||||||
|
acceptable_commands = expected_commands | JOIN_ERROR_NUMERICS | {"MODE"}
|
||||||
|
self.assertLessEqual(
|
||||||
|
expected_commands,
|
||||||
|
received_commands,
|
||||||
|
"Server sent {} commands, but at least {} were expected.".format(
|
||||||
|
received_commands, expected_commands
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertLessEqual(
|
||||||
|
received_commands,
|
||||||
|
acceptable_commands,
|
||||||
|
"Server sent {} commands, but only {} were expected.".format(
|
||||||
|
received_commands, acceptable_commands
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
nb_errors = 0
|
||||||
|
for m in messages:
|
||||||
|
if m.command in JOIN_ERROR_NUMERICS:
|
||||||
|
nb_errors += 1
|
||||||
|
self.assertMessageMatch(m, params=["foo", "inv@lid", ANYSTR])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
nb_errors,
|
||||||
|
1,
|
||||||
|
fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', "
|
||||||
|
"got {got}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("batch", "labeled-response")
|
||||||
|
def testJoinPartiallyInvalidLabeledResponse(self):
|
||||||
|
"""TODO: specify this in Modern"""
|
||||||
|
self.connectClient(
|
||||||
|
"foo", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
if int(self.targmax.get("JOIN") or "4") < 2:
|
||||||
|
raise runner.OptionalExtensionNotSupported("multi-channel JOIN")
|
||||||
|
|
||||||
|
self.sendLine(1, "@label=label1 JOIN #valid,inv@lid")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
|
||||||
|
first_msg = messages.pop(0)
|
||||||
|
last_msg = messages.pop(-1)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
first_msg, command="BATCH", params=[StrRe(r"\+.*"), "labeled-response"]
|
||||||
|
)
|
||||||
|
batch_id = first_msg.params[0][1:]
|
||||||
|
self.assertMessageMatch(last_msg, command="BATCH", params=["-" + batch_id])
|
||||||
|
|
||||||
|
received_commands = {m.command for m in messages}
|
||||||
|
expected_commands = {RPL_NAMREPLY, RPL_ENDOFNAMES, "JOIN"}
|
||||||
|
acceptable_commands = expected_commands | JOIN_ERROR_NUMERICS | {"MODE"}
|
||||||
|
self.assertLessEqual(
|
||||||
|
expected_commands,
|
||||||
|
received_commands,
|
||||||
|
"Server sent {} commands, but at least {} were expected.".format(
|
||||||
|
received_commands, expected_commands
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.assertLessEqual(
|
||||||
|
received_commands,
|
||||||
|
acceptable_commands,
|
||||||
|
"Server sent {} commands, but only {} were expected.".format(
|
||||||
|
received_commands, acceptable_commands
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
nb_errors = 0
|
||||||
|
for m in messages:
|
||||||
|
self.assertIn("batch", m.tags)
|
||||||
|
self.assertEqual(m.tags["batch"], batch_id)
|
||||||
|
if m.command in JOIN_ERROR_NUMERICS:
|
||||||
|
nb_errors += 1
|
||||||
|
self.assertMessageMatch(m, params=["foo", "inv@lid", ANYSTR])
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
nb_errors,
|
||||||
|
1,
|
||||||
|
fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', "
|
||||||
|
"got {got}",
|
||||||
|
)
|
||||||
|
@ -1,3 +1,10 @@
|
|||||||
|
"""
|
||||||
|
The KICK command (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.1>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#kick-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from irctest import cases, client_mock, runner
|
from irctest import cases, client_mock, runner
|
||||||
@ -89,6 +96,10 @@ class KickTestCase(cases.BaseServerTestCase):
|
|||||||
self.assertMessageMatch(m3, command="KICK", params=["#chan", "bar", ANYSTR])
|
self.assertMessageMatch(m3, command="KICK", params=["#chan", "bar", ANYSTR])
|
||||||
|
|
||||||
@cases.mark_specifications("RFC2812")
|
@cases.mark_specifications("RFC2812")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Charybdis", "ircu2", "irc2", "Solanum"],
|
||||||
|
"uses the nick of the kickee rather than the kicker.",
|
||||||
|
)
|
||||||
def testKickDefaultComment(self):
|
def testKickDefaultComment(self):
|
||||||
"""
|
"""
|
||||||
"If a "comment" is
|
"If a "comment" is
|
||||||
@ -219,13 +230,8 @@ class KickTestCase(cases.BaseServerTestCase):
|
|||||||
self.connectClient("qux")
|
self.connectClient("qux")
|
||||||
self.joinChannel(4, "#chan")
|
self.joinChannel(4, "#chan")
|
||||||
|
|
||||||
targmax = dict(
|
if self.targmax.get("KICK", "1") == "1":
|
||||||
item.split(":", 1)
|
raise runner.OptionalExtensionNotSupported("Multi-target KICK")
|
||||||
for item in self.server_support.get("TARGMAX", "").split(",")
|
|
||||||
if item
|
|
||||||
)
|
|
||||||
if targmax.get("KICK", "1") == "1":
|
|
||||||
raise runner.NotImplementedByController("Multi-target KICK")
|
|
||||||
|
|
||||||
# TODO: check foo is an operator
|
# TODO: check foo is an operator
|
||||||
|
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
|
`IRCv3 labeled-response <https://ircv3.net/specs/extensions/labeled-response>`_
|
||||||
|
|
||||||
This specification is a little hard to test because all labels are optional;
|
This specification is a little hard to test because all labels are optional;
|
||||||
so there may be many false positives.
|
so there may be many false positives.
|
||||||
|
|
||||||
<https://ircv3.net/specs/extensions/labeled-response.html>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re
|
import re
|
||||||
@ -10,10 +10,12 @@ import re
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.patma import ANYDICT, AnyOptStr, NotStrRe, RemainingKeys, StrRe
|
from irctest.numerics import ERR_UNKNOWNCOMMAND
|
||||||
|
from irctest.patma import ANYDICT, ANYOPTSTR, NotStrRe, RemainingKeys, StrRe
|
||||||
|
from irctest.runner import OptionalExtensionNotSupported
|
||||||
|
|
||||||
|
|
||||||
class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
class LabeledResponsesTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
|
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
|
||||||
def testLabeledPrivmsgResponsesToMultipleClients(self):
|
def testLabeledPrivmsgResponsesToMultipleClients(self):
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
@ -21,7 +23,10 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper
|
|||||||
capabilities=["echo-message", "batch", "labeled-response"],
|
capabilities=["echo-message", "batch", "labeled-response"],
|
||||||
skip_if_cap_nak=True,
|
skip_if_cap_nak=True,
|
||||||
)
|
)
|
||||||
|
if int(self.targmax.get("PRIVMSG", "1") or "4") < 3:
|
||||||
|
raise OptionalExtensionNotSupported("PRIVMSG to multiple targets")
|
||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
|
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
"bar",
|
"bar",
|
||||||
capabilities=["echo-message", "batch", "labeled-response"],
|
capabilities=["echo-message", "batch", "labeled-response"],
|
||||||
@ -298,7 +303,7 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper
|
|||||||
tags={
|
tags={
|
||||||
"+draft/reply": msgid,
|
"+draft/reply": msgid,
|
||||||
"+draft/react": "l😃l",
|
"+draft/react": "l😃l",
|
||||||
RemainingKeys(NotStrRe("label")): AnyOptStr(),
|
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
@ -366,7 +371,7 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper
|
|||||||
tags={
|
tags={
|
||||||
"+draft/reply": msgid,
|
"+draft/reply": msgid,
|
||||||
"+draft/react": "l😃l",
|
"+draft/react": "l😃l",
|
||||||
RemainingKeys(NotStrRe("label")): AnyOptStr(),
|
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
|
||||||
},
|
},
|
||||||
fail_msg="No TAGMSG received by the target after sending one out",
|
fail_msg="No TAGMSG received by the target after sending one out",
|
||||||
)
|
)
|
||||||
@ -502,3 +507,19 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper
|
|||||||
ack = ms[0]
|
ack = ms[0]
|
||||||
|
|
||||||
self.assertMessageMatch(ack, command="ACK", tags={"label": "98765"})
|
self.assertMessageMatch(ack, command="ACK", tags={"label": "98765"})
|
||||||
|
|
||||||
|
@cases.mark_capabilities("labeled-response")
|
||||||
|
def testUnknownCommand(self):
|
||||||
|
self.connectClient(
|
||||||
|
"bar", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# this command doesn't exist, but the error response should still
|
||||||
|
# be labeled:
|
||||||
|
self.sendLine(1, "@label=deadbeef NONEXISTENT_COMMAND")
|
||||||
|
ms = self.getMessages(1)
|
||||||
|
self.assertEqual(len(ms), 1)
|
||||||
|
unknowncommand = ms[0]
|
||||||
|
self.assertMessageMatch(
|
||||||
|
unknowncommand, command=ERR_UNKNOWNCOMMAND, tags={"label": "deadbeef"}
|
||||||
|
)
|
||||||
|
136
irctest/server_tests/links.py
Normal file
136
irctest/server_tests/links.py
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import ERR_UNKNOWNCOMMAND, RPL_ENDOFLINKS, RPL_LINKS
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
|
class LinksTestCase(cases.BaseServerTestCase):
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
def testLinksSingleServer(self):
|
||||||
|
"""
|
||||||
|
Only testing the parameter-less case.
|
||||||
|
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.3
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.5
|
||||||
|
https://github.com/ircdocs/modern-irc/pull/175
|
||||||
|
|
||||||
|
"
|
||||||
|
364 RPL_LINKS
|
||||||
|
"<mask> <server> :<hopcount> <server info>"
|
||||||
|
365 RPL_ENDOFLINKS
|
||||||
|
"<mask> :End of /LINKS list"
|
||||||
|
|
||||||
|
- In replying to the LINKS message, a server must send
|
||||||
|
replies back using the RPL_LINKS numeric and mark the
|
||||||
|
end of the list using an RPL_ENDOFLINKS reply.
|
||||||
|
"
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc1459#page-51
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc2812#page-48
|
||||||
|
|
||||||
|
RPL_LINKS: "<client> * <server> :<hopcount> <server info>"
|
||||||
|
RPL_ENDOFLINKS: "<client> * :End of /LINKS list"
|
||||||
|
-- https://github.com/ircdocs/modern-irc/pull/175/files
|
||||||
|
"""
|
||||||
|
self.connectClient("nick")
|
||||||
|
self.sendLine(1, "LINKS")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
if messages[0].command == ERR_UNKNOWNCOMMAND:
|
||||||
|
raise runner.OptionalCommandNotSupported("LINKS")
|
||||||
|
|
||||||
|
# Ignore '/LINKS has been disabled' from ircu2
|
||||||
|
messages = [m for m in messages if m.command != "NOTICE"]
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages.pop(-1),
|
||||||
|
command=RPL_ENDOFLINKS,
|
||||||
|
params=["nick", "*", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
# This server probably redacts links
|
||||||
|
return
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages[0],
|
||||||
|
command=RPL_LINKS,
|
||||||
|
params=[
|
||||||
|
"nick",
|
||||||
|
"My.Little.Server",
|
||||||
|
"My.Little.Server",
|
||||||
|
StrRe("0 (0042 )?test server"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
class ServicesLinksTestCase(cases.BaseServerTestCase):
|
||||||
|
# On every IRCd but Ergo, services are linked.
|
||||||
|
# Ergo does not implement LINKS at all, so this test is skipped.
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
def testLinksWithServices(self):
|
||||||
|
"""
|
||||||
|
Only testing the parameter-less case.
|
||||||
|
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.3
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.5
|
||||||
|
|
||||||
|
"
|
||||||
|
364 RPL_LINKS
|
||||||
|
"<mask> <server> :<hopcount> <server info>"
|
||||||
|
365 RPL_ENDOFLINKS
|
||||||
|
"<mask> :End of /LINKS list"
|
||||||
|
|
||||||
|
- In replying to the LINKS message, a server must send
|
||||||
|
replies back using the RPL_LINKS numeric and mark the
|
||||||
|
end of the list using an RPL_ENDOFLINKS reply.
|
||||||
|
"
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc1459#page-51
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc2812#page-48
|
||||||
|
|
||||||
|
RPL_LINKS: "<client> * <server> :<hopcount> <server info>"
|
||||||
|
RPL_ENDOFLINKS: "<client> * :End of /LINKS list"
|
||||||
|
-- https://github.com/ircdocs/modern-irc/pull/175/files
|
||||||
|
"""
|
||||||
|
self.connectClient("nick")
|
||||||
|
self.sendLine(1, "LINKS")
|
||||||
|
messages = self.getMessages(1)
|
||||||
|
|
||||||
|
if messages[0].command == ERR_UNKNOWNCOMMAND:
|
||||||
|
raise runner.OptionalCommandNotSupported("LINKS")
|
||||||
|
|
||||||
|
# Ignore '/LINKS has been disabled' from ircu2
|
||||||
|
messages = [m for m in messages if m.command != "NOTICE"]
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages.pop(-1),
|
||||||
|
command=RPL_ENDOFLINKS,
|
||||||
|
params=["nick", "*", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
if not messages:
|
||||||
|
# This server redacts links
|
||||||
|
return
|
||||||
|
|
||||||
|
messages.sort(key=lambda m: m.params[-1])
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages.pop(0),
|
||||||
|
command=RPL_LINKS,
|
||||||
|
params=[
|
||||||
|
"nick",
|
||||||
|
"My.Little.Server",
|
||||||
|
"My.Little.Server",
|
||||||
|
StrRe("0 (0042 )?test server"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
messages.pop(0),
|
||||||
|
command=RPL_LINKS,
|
||||||
|
params=[
|
||||||
|
"nick",
|
||||||
|
"services.example.org",
|
||||||
|
"My.Little.Server",
|
||||||
|
StrRe("1 .+"), # SID instead of description for Anope...
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(messages, [])
|
@ -1,40 +1,71 @@
|
|||||||
from irctest import cases
|
"""
|
||||||
|
The LIST command (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.6>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.6>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#list-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import RPL_LIST, RPL_LISTEND, RPL_LISTSTART
|
||||||
|
|
||||||
|
|
||||||
class ListTestCase(cases.BaseServerTestCase):
|
class _BasedListTestCase(cases.BaseServerTestCase):
|
||||||
|
def _parseChanList(self, client):
|
||||||
|
channels = set()
|
||||||
|
while True:
|
||||||
|
m = self.getMessage(client)
|
||||||
|
if m.command == RPL_LISTEND:
|
||||||
|
break
|
||||||
|
if m.command == RPL_LIST:
|
||||||
|
if m.params[1].startswith("&"):
|
||||||
|
# skip local pseudo-channels listed by ngircd and ircu
|
||||||
|
continue
|
||||||
|
channels.add(m.params[1])
|
||||||
|
|
||||||
|
return channels
|
||||||
|
|
||||||
|
|
||||||
|
class ListTestCase(_BasedListTestCase):
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812")
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
@cases.xfailIfSoftware(["irc2"], "irc2 deprecated LIST")
|
||||||
def testListEmpty(self):
|
def testListEmpty(self):
|
||||||
"""<https://tools.ietf.org/html/rfc1459#section-4.2.6>
|
"""<https://tools.ietf.org/html/rfc1459#section-4.2.6>
|
||||||
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
|
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
|
||||||
|
<https://modern.ircdocs.horse/#list-message>
|
||||||
"""
|
"""
|
||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
self.sendLine(2, "LIST")
|
self.sendLine(2, "LIST")
|
||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
if m.command == "321":
|
if m.command == RPL_LISTSTART:
|
||||||
# skip RPL_LISTSTART
|
# skip
|
||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
while m.command == "322" and m.params[1] == "&SERVER":
|
# skip local pseudo-channels listed by ngircd and ircu
|
||||||
# ngircd adds this pseudo-channel
|
while m.command == RPL_LIST and m.params[1].startswith("&"):
|
||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
self.assertNotEqual(
|
self.assertNotEqual(
|
||||||
m.command,
|
m.command,
|
||||||
"322", # RPL_LIST
|
RPL_LIST,
|
||||||
"LIST response gives (at least) one channel, whereas there " "is none.",
|
"LIST response gives (at least) one channel, whereas there " "is none.",
|
||||||
)
|
)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m,
|
m,
|
||||||
command="323", # RPL_LISTEND
|
command=RPL_LISTEND,
|
||||||
fail_msg="Second reply to LIST is not 322 (RPL_LIST) "
|
fail_msg="Second reply to LIST is not 322 (RPL_LIST) "
|
||||||
"or 323 (RPL_LISTEND), or but: {msg}",
|
"or 323 (RPL_LISTEND), or but: {msg}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812")
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
@cases.xfailIfSoftware(["irc2"], "irc2 deprecated LIST")
|
||||||
def testListOne(self):
|
def testListOne(self):
|
||||||
"""When a channel exists, LIST should get it in a reply.
|
"""When a channel exists, LIST should get it in a reply.
|
||||||
<https://tools.ietf.org/html/rfc1459#section-4.2.6>
|
<https://tools.ietf.org/html/rfc1459#section-4.2.6>
|
||||||
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
|
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
|
||||||
|
|
||||||
|
<https://modern.ircdocs.horse/#list-message>
|
||||||
"""
|
"""
|
||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
@ -42,34 +73,331 @@ class ListTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
self.sendLine(2, "LIST")
|
self.sendLine(2, "LIST")
|
||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
if m.command == "321":
|
if m.command == RPL_LISTSTART:
|
||||||
# skip RPL_LISTSTART
|
# skip
|
||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
self.assertNotEqual(
|
self.assertNotEqual(
|
||||||
m.command,
|
m.command,
|
||||||
"323", # RPL_LISTEND
|
RPL_LISTEND,
|
||||||
fail_msg="LIST response ended (ie. 323, aka RPL_LISTEND) "
|
fail_msg="LIST response ended (ie. 323, aka RPL_LISTEND) "
|
||||||
"without listing any channel, whereas there is one.",
|
"without listing any channel, whereas there is one.",
|
||||||
)
|
)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m,
|
m,
|
||||||
command="322", # RPL_LIST
|
command=RPL_LIST,
|
||||||
fail_msg="Second reply to LIST is not 322 (RPL_LIST), "
|
fail_msg="Second reply to LIST is not 322 (RPL_LIST), "
|
||||||
"nor 323 (RPL_LISTEND) but: {msg}",
|
"nor 323 (RPL_LISTEND) but: {msg}",
|
||||||
)
|
)
|
||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
while m.command == "322" and m.params[1] == "&SERVER":
|
# skip local pseudo-channels listed by ngircd and ircu
|
||||||
# ngircd adds this pseudo-channel
|
while m.command == RPL_LIST and m.params[1].startswith("&"):
|
||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
self.assertNotEqual(
|
self.assertNotEqual(
|
||||||
m.command,
|
m.command,
|
||||||
"322", # RPL_LIST
|
RPL_LIST,
|
||||||
fail_msg="LIST response gives (at least) two channels, "
|
fail_msg="LIST response gives (at least) two channels, "
|
||||||
"whereas there is only one.",
|
"whereas there is only one.",
|
||||||
)
|
)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m,
|
m,
|
||||||
command="323", # RPL_LISTEND
|
command=RPL_LISTEND,
|
||||||
fail_msg="Third reply to LIST is not 322 (RPL_LIST) "
|
fail_msg="Third reply to LIST is not 322 (RPL_LIST) "
|
||||||
"or 323 (RPL_LISTEND), or but: {msg}",
|
"or 323 (RPL_LISTEND), or but: {msg}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Charybdis", "Solanum"],
|
||||||
|
"Charybdis and Solanum insert ERR_NOSUCHNICK reply in LIST",
|
||||||
|
)
|
||||||
|
def testListNonexistent(self):
|
||||||
|
"""LIST on a nonexistent channel does not send an error
|
||||||
|
response.
|
||||||
|
<https://tools.ietf.org/html/rfc1459#section-4.2.6>
|
||||||
|
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
|
||||||
|
"""
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(1, "LIST #nonexistent")
|
||||||
|
responses = {msg.command for msg in self.getMessages(1)}
|
||||||
|
# successful response MUST include RPL_LISTEND:
|
||||||
|
self.assertIn(RPL_LISTEND, responses)
|
||||||
|
# and MUST NOT include RPL_LIST (since there is no matching channel)
|
||||||
|
# or any error numerics:
|
||||||
|
self.assertLessEqual(responses, {RPL_LISTSTART, RPL_LISTEND})
|
||||||
|
|
||||||
|
@cases.mark_isupport("ELIST")
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testListMask(self):
|
||||||
|
"""
|
||||||
|
"M: Searching based on mask."
|
||||||
|
-- <https://modern.ircdocs.horse/#elist-parameter>
|
||||||
|
-- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
|
||||||
|
if "M" not in self.server_support.get("ELIST", ""):
|
||||||
|
raise runner.OptionalExtensionNotSupported("ELIST=M")
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "JOIN #chan2")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST *an1")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST *an2")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST #c*n2")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST *an3")
|
||||||
|
self.assertEqual(self._parseChanList(2), set())
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST #ch*")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
@cases.mark_isupport("ELIST")
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testListNotMask(self):
|
||||||
|
"""
|
||||||
|
" N: Searching based on a non-matching mask. i.e., the opposite of M."
|
||||||
|
-- <https://modern.ircdocs.horse/#elist-parameter>
|
||||||
|
-- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
|
||||||
|
if "N" not in self.server_support.get("ELIST", ""):
|
||||||
|
raise runner.OptionalExtensionNotSupported("ELIST=N")
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "JOIN #chan2")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST !*an1")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST !*an2")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST !#c*n2")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST !*an3")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST !#ch*")
|
||||||
|
self.assertEqual(self._parseChanList(2), set())
|
||||||
|
|
||||||
|
@cases.mark_isupport("ELIST")
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
def testListUsers(self):
|
||||||
|
"""
|
||||||
|
"U: Searching based on user count within the channel, via the "<val" and
|
||||||
|
">val" modifiers to search for a channel that has less or more than val users,
|
||||||
|
respectively."
|
||||||
|
-- <https://modern.ircdocs.horse/#elist-parameter>
|
||||||
|
-- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
|
||||||
|
if "U" not in self.server_support.get("ELIST", ""):
|
||||||
|
raise runner.OptionalExtensionNotSupported("ELIST=U")
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "JOIN #chan2")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(2, "JOIN #chan2")
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.connectClient("baz")
|
||||||
|
|
||||||
|
self.sendLine(3, "LIST >0")
|
||||||
|
self.assertEqual(self._parseChanList(3), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(3, "LIST <1")
|
||||||
|
self.assertEqual(self._parseChanList(3), set())
|
||||||
|
|
||||||
|
self.sendLine(3, "LIST <100")
|
||||||
|
self.assertEqual(self._parseChanList(3), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(3, "LIST >1")
|
||||||
|
self.assertEqual(self._parseChanList(3), {"#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(3, "LIST <2")
|
||||||
|
self.assertEqual(self._parseChanList(3), {"#chan1"})
|
||||||
|
|
||||||
|
self.sendLine(3, "LIST <100")
|
||||||
|
self.assertEqual(self._parseChanList(3), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
|
||||||
|
class FaketimeListTestCase(_BasedListTestCase):
|
||||||
|
faketime = "+1y x30" # for every wall clock second, 1 minute passed for the server
|
||||||
|
|
||||||
|
def _sleep_minutes(self, n):
|
||||||
|
for _ in range(n):
|
||||||
|
if self.controller.faketime_enabled:
|
||||||
|
# From the server's point of view, 1 min will pass
|
||||||
|
time.sleep(2)
|
||||||
|
else:
|
||||||
|
time.sleep(60)
|
||||||
|
|
||||||
|
# reply to pings
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
@cases.mark_isupport("ELIST")
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Plexus4", "Hybrid"],
|
||||||
|
"Hybrid and Plexus4 filter on ELIST=C with the opposite meaning",
|
||||||
|
)
|
||||||
|
@cases.xfailIf(
|
||||||
|
lambda self: bool(
|
||||||
|
self.controller.software_name == "UnrealIRCd"
|
||||||
|
and self.controller.software_version == 5
|
||||||
|
),
|
||||||
|
"UnrealIRCd <6.0.3 filters on ELIST=C with the opposite meaning",
|
||||||
|
)
|
||||||
|
def testListCreationTime(self):
|
||||||
|
"""
|
||||||
|
" C: Searching based on channel creation time, via the "C<val" and "C>val"
|
||||||
|
modifiers to search for a channel creation time that is higher or lower
|
||||||
|
than val."
|
||||||
|
-- <https://modern.ircdocs.horse/#elist-parameter>
|
||||||
|
-- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8
|
||||||
|
|
||||||
|
Unfortunately, this is ambiguous, because "val" is a time delta (in minutes),
|
||||||
|
not a timestamp.
|
||||||
|
|
||||||
|
On InspIRCd and Charybdis/Solanum, "C<val" is interpreted as "the channel was
|
||||||
|
created less than <val> minutes ago
|
||||||
|
|
||||||
|
On UnrealIRCd, Plexus, and Hybrid, it is interpreted as "the channel's creation
|
||||||
|
time is a timestamp lower than <val> minutes ago" (ie. the exact opposite)
|
||||||
|
|
||||||
|
"C: Searching based on channel creation time, via the "C<val" and "C>val"
|
||||||
|
modifiers to search for a channel that was created either less than `val`
|
||||||
|
minutes ago, or more than `val` minutes ago, respectively"
|
||||||
|
-- https://github.com/ircdocs/modern-irc/pull/171
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
|
||||||
|
if "C" not in self.server_support.get("ELIST", ""):
|
||||||
|
raise runner.OptionalExtensionNotSupported("ELIST=C")
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
# Helps debugging
|
||||||
|
self.sendLine(1, "TIME")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self._sleep_minutes(2)
|
||||||
|
|
||||||
|
# Helps debugging
|
||||||
|
self.sendLine(1, "TIME")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #chan2")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self._sleep_minutes(1)
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST C>2")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST C<2")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST C<0")
|
||||||
|
if self.controller.software_name == "InspIRCd":
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"})
|
||||||
|
else:
|
||||||
|
self.assertEqual(self._parseChanList(2), set())
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST C>0")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(2, "LIST C<10")
|
||||||
|
self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
@cases.mark_isupport("ELIST")
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@cases.xfailIf(
|
||||||
|
lambda self: bool(
|
||||||
|
self.controller.software_name == "UnrealIRCd"
|
||||||
|
and self.controller.software_version == 5
|
||||||
|
),
|
||||||
|
"UnrealIRCd <6.0.3 advertises ELIST=T but does not implement it",
|
||||||
|
)
|
||||||
|
def testListTopicTime(self):
|
||||||
|
"""
|
||||||
|
"T: Searching based on topic time, via the "T<val" and "T>val"
|
||||||
|
modifiers to search for a topic time that is lower or higher than
|
||||||
|
val respectively."
|
||||||
|
-- <https://modern.ircdocs.horse/#elist-parameter>
|
||||||
|
-- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8
|
||||||
|
|
||||||
|
See testListCreationTime's docstring for comments on this.
|
||||||
|
|
||||||
|
"T: Searching based on topic set time, via the "T<val" and "T>val" modifiers
|
||||||
|
to search for a topic time that was set less than `val` minutes ago, or more
|
||||||
|
than `val` minutes ago, respectively."
|
||||||
|
-- https://github.com/ircdocs/modern-irc/pull/171
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
|
||||||
|
if "T" not in self.server_support.get("ELIST", ""):
|
||||||
|
raise runner.OptionalExtensionNotSupported("ELIST=T")
|
||||||
|
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.sendLine(1, "JOIN #chan2")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "TOPIC #chan1 :First channel")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
# Helps debugging
|
||||||
|
self.sendLine(1, "TIME")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self._sleep_minutes(2)
|
||||||
|
|
||||||
|
# Helps debugging
|
||||||
|
self.sendLine(1, "TIME")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "TOPIC #chan2 :Second channel")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self._sleep_minutes(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "LIST T>2")
|
||||||
|
self.assertEqual(self._parseChanList(1), {"#chan1"})
|
||||||
|
|
||||||
|
self.sendLine(1, "LIST T<2")
|
||||||
|
self.assertEqual(self._parseChanList(1), {"#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(1, "LIST T<0")
|
||||||
|
if self.controller.software_name == "InspIRCd":
|
||||||
|
# Insp internally represents "LIST T>0" like "LIST"
|
||||||
|
self.assertEqual(self._parseChanList(1), {"#chan1", "#chan2"})
|
||||||
|
else:
|
||||||
|
self.assertEqual(self._parseChanList(1), set())
|
||||||
|
|
||||||
|
self.sendLine(1, "LIST T>0")
|
||||||
|
self.assertEqual(self._parseChanList(1), {"#chan1", "#chan2"})
|
||||||
|
|
||||||
|
self.sendLine(1, "LIST T<10")
|
||||||
|
self.assertEqual(self._parseChanList(1), {"#chan1", "#chan2"})
|
||||||
|
@ -1,3 +1,11 @@
|
|||||||
|
"""
|
||||||
|
The LUSERS command (`RFC 2812
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.2>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#lusers-message>`__),
|
||||||
|
which provides statistics on user counts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
@ -14,6 +22,7 @@ from irctest.numerics import (
|
|||||||
RPL_LUSERUNKNOWN,
|
RPL_LUSERUNKNOWN,
|
||||||
RPL_YOUREOPER,
|
RPL_YOUREOPER,
|
||||||
)
|
)
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
# 3 numbers, delimited by spaces, possibly negative (eek)
|
# 3 numbers, delimited by spaces, possibly negative (eek)
|
||||||
LUSERCLIENT_REGEX = re.compile(r"^.*( [-0-9]* ).*( [-0-9]* ).*( [-0-9]* ).*$")
|
LUSERCLIENT_REGEX = re.compile(r"^.*( [-0-9]* ).*( [-0-9]* ).*( [-0-9]* ).*$")
|
||||||
@ -50,10 +59,11 @@ class LusersTestCase(cases.BaseServerTestCase):
|
|||||||
self.assertIn(lusers.LocalTotal, (total, None))
|
self.assertIn(lusers.LocalTotal, (total, None))
|
||||||
self.assertIn(lusers.LocalMax, (max_, None))
|
self.assertIn(lusers.LocalMax, (max_, None))
|
||||||
|
|
||||||
def getLusers(self, client):
|
def getLusers(self, client, allow_missing_265_266):
|
||||||
self.sendLine(client, "LUSERS")
|
self.sendLine(client, "LUSERS")
|
||||||
messages = self.getMessages(client)
|
messages = self.getMessages(client)
|
||||||
by_numeric = dict((msg.command, msg) for msg in messages)
|
by_numeric = dict((msg.command, msg) for msg in messages)
|
||||||
|
self.assertEqual(len(by_numeric), len(messages), "Duplicated numerics")
|
||||||
|
|
||||||
result = LusersResult()
|
result = LusersResult()
|
||||||
|
|
||||||
@ -73,12 +83,31 @@ class LusersTestCase(cases.BaseServerTestCase):
|
|||||||
raise ValueError("corrupt reply for 251 RPL_LUSERCLIENT", luserclient_param)
|
raise ValueError("corrupt reply for 251 RPL_LUSERCLIENT", luserclient_param)
|
||||||
|
|
||||||
if RPL_LUSEROP in by_numeric:
|
if RPL_LUSEROP in by_numeric:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
by_numeric[RPL_LUSEROP], params=[client, StrRe("[0-9]+"), ANYSTR]
|
||||||
|
)
|
||||||
result.Opers = int(by_numeric[RPL_LUSEROP].params[1])
|
result.Opers = int(by_numeric[RPL_LUSEROP].params[1])
|
||||||
if RPL_LUSERUNKNOWN in by_numeric:
|
if RPL_LUSERUNKNOWN in by_numeric:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
by_numeric[RPL_LUSERUNKNOWN], params=[client, StrRe("[0-9]+"), ANYSTR]
|
||||||
|
)
|
||||||
result.Unregistered = int(by_numeric[RPL_LUSERUNKNOWN].params[1])
|
result.Unregistered = int(by_numeric[RPL_LUSERUNKNOWN].params[1])
|
||||||
if RPL_LUSERCHANNELS in by_numeric:
|
if RPL_LUSERCHANNELS in by_numeric:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
by_numeric[RPL_LUSERCHANNELS], params=[client, StrRe("[0-9]+"), ANYSTR]
|
||||||
|
)
|
||||||
result.Channels = int(by_numeric[RPL_LUSERCHANNELS].params[1])
|
result.Channels = int(by_numeric[RPL_LUSERCHANNELS].params[1])
|
||||||
|
|
||||||
|
self.assertMessageMatch(by_numeric[RPL_LUSERCLIENT], params=[client, ANYSTR])
|
||||||
|
self.assertMessageMatch(by_numeric[RPL_LUSERME], params=[client, ANYSTR])
|
||||||
|
|
||||||
|
if (
|
||||||
|
allow_missing_265_266
|
||||||
|
and RPL_LOCALUSERS not in by_numeric
|
||||||
|
and RPL_GLOBALUSERS not in by_numeric
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
# FIXME: RPL_LOCALUSERS and RPL_GLOBALUSERS are only in Modern, not in RFC2812
|
# FIXME: RPL_LOCALUSERS and RPL_GLOBALUSERS are only in Modern, not in RFC2812
|
||||||
localusers = by_numeric[RPL_LOCALUSERS]
|
localusers = by_numeric[RPL_LOCALUSERS]
|
||||||
globalusers = by_numeric[RPL_GLOBALUSERS]
|
globalusers = by_numeric[RPL_GLOBALUSERS]
|
||||||
@ -114,23 +143,55 @@ class BasicLusersTestCase(LusersTestCase):
|
|||||||
@cases.mark_specifications("RFC2812")
|
@cases.mark_specifications("RFC2812")
|
||||||
def testLusers(self):
|
def testLusers(self):
|
||||||
self.connectClient("bar", name="bar")
|
self.connectClient("bar", name="bar")
|
||||||
lusers = self.getLusers("bar")
|
self.getLusers("bar", True)
|
||||||
|
|
||||||
|
self.connectClient("qux", name="qux")
|
||||||
|
self.getLusers("qux", True)
|
||||||
|
|
||||||
|
self.sendLine("qux", "QUIT")
|
||||||
|
self.assertDisconnected("qux")
|
||||||
|
self.getLusers("bar", True)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2", "Nefarious", "snircd"],
|
||||||
|
"test depends on Modern behavior, not just RFC2812",
|
||||||
|
)
|
||||||
|
def testLusersFull(self):
|
||||||
|
self.connectClient("bar", name="bar")
|
||||||
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
||||||
|
|
||||||
self.connectClient("qux", name="qux")
|
self.connectClient("qux", name="qux")
|
||||||
lusers = self.getLusers("qux")
|
lusers = self.getLusers("qux", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
||||||
|
|
||||||
self.sendLine("qux", "QUIT")
|
self.sendLine("qux", "QUIT")
|
||||||
self.assertDisconnected("qux")
|
self.assertDisconnected("qux")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
|
||||||
|
|
||||||
|
|
||||||
class LusersUnregisteredTestCase(LusersTestCase):
|
class LusersUnregisteredTestCase(LusersTestCase):
|
||||||
@cases.mark_specifications("RFC2812")
|
@cases.mark_specifications("RFC2812")
|
||||||
def testLusers(self):
|
@cases.xfailIfSoftware(
|
||||||
self.doLusersTest()
|
["Nefarious"],
|
||||||
|
"Nefarious doesn't seem to distinguish unregistered users from normal ones",
|
||||||
|
)
|
||||||
|
def testLusersRfc2812(self):
|
||||||
|
self.doLusersTest(True)
|
||||||
|
|
||||||
|
@cases.mark_specifications("Modern")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Nefarious"],
|
||||||
|
"Nefarious doesn't seem to distinguish unregistered users from normal ones",
|
||||||
|
)
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["ircu2", "Nefarious", "snircd"],
|
||||||
|
"test depends on Modern behavior, not just RFC2812",
|
||||||
|
)
|
||||||
|
def testLusersFull(self):
|
||||||
|
self.doLusersTest(False)
|
||||||
|
|
||||||
def _synchronize(self, client_name):
|
def _synchronize(self, client_name):
|
||||||
"""Synchronizes using a PING, but accept ERR_NOTREGISTERED as a response."""
|
"""Synchronizes using a PING, but accept ERR_NOTREGISTERED as a response."""
|
||||||
@ -145,34 +206,39 @@ class LusersUnregisteredTestCase(LusersTestCase):
|
|||||||
"got neither PONG or ERR_NOTREGISTERED"
|
"got neither PONG or ERR_NOTREGISTERED"
|
||||||
)
|
)
|
||||||
|
|
||||||
def doLusersTest(self):
|
def doLusersTest(self, allow_missing_265_266):
|
||||||
self.connectClient("bar", name="bar")
|
self.connectClient("bar", name="bar")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", allow_missing_265_266)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
if lusers:
|
||||||
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
||||||
|
|
||||||
self.addClient("qux")
|
self.addClient("qux")
|
||||||
self.sendLine("qux", "NICK qux")
|
self.sendLine("qux", "NICK qux")
|
||||||
self._synchronize("qux")
|
self._synchronize("qux")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", allow_missing_265_266)
|
||||||
self.assertLusersResult(lusers, unregistered=1, total=1, max_=1)
|
if lusers:
|
||||||
|
self.assertLusersResult(lusers, unregistered=1, total=1, max_=1)
|
||||||
|
|
||||||
self.addClient("bat")
|
self.addClient("bat")
|
||||||
self.sendLine("bat", "NICK bat")
|
self.sendLine("bat", "NICK bat")
|
||||||
self._synchronize("bat")
|
self._synchronize("bat")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", allow_missing_265_266)
|
||||||
self.assertLusersResult(lusers, unregistered=2, total=1, max_=1)
|
if lusers:
|
||||||
|
self.assertLusersResult(lusers, unregistered=2, total=1, max_=1)
|
||||||
|
|
||||||
# complete registration on one client
|
# complete registration on one client
|
||||||
self.sendLine("qux", "USER u s e r")
|
self.sendLine("qux", "USER u s e r")
|
||||||
self.getRegistrationMessage("qux")
|
self.getRegistrationMessage("qux")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", allow_missing_265_266)
|
||||||
self.assertLusersResult(lusers, unregistered=1, total=2, max_=2)
|
if lusers:
|
||||||
|
self.assertLusersResult(lusers, unregistered=1, total=2, max_=2)
|
||||||
|
|
||||||
# QUIT the other without registering
|
# QUIT the other without registering
|
||||||
self.sendLine("bat", "QUIT")
|
self.sendLine("bat", "QUIT")
|
||||||
self.assertDisconnected("bat")
|
self.assertDisconnected("bat")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", allow_missing_265_266)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
if lusers:
|
||||||
|
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
||||||
|
|
||||||
|
|
||||||
class LusersUnregisteredDefaultInvisibleTestCase(LusersUnregisteredTestCase):
|
class LusersUnregisteredDefaultInvisibleTestCase(LusersUnregisteredTestCase):
|
||||||
@ -187,9 +253,13 @@ class LusersUnregisteredDefaultInvisibleTestCase(LusersUnregisteredTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("Ergo")
|
@cases.mark_specifications("Ergo")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["Nefarious"],
|
||||||
|
"Nefarious doesn't seem to distinguish unregistered users from normal ones",
|
||||||
|
)
|
||||||
def testLusers(self):
|
def testLusers(self):
|
||||||
self.doLusersTest()
|
self.doLusersTest(False)
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
||||||
self.assertEqual(lusers.GlobalInvisible, 2)
|
self.assertEqual(lusers.GlobalInvisible, 2)
|
||||||
self.assertEqual(lusers.GlobalVisible, 0)
|
self.assertEqual(lusers.GlobalVisible, 0)
|
||||||
@ -199,7 +269,7 @@ class LuserOpersTestCase(LusersTestCase):
|
|||||||
@cases.mark_specifications("Ergo")
|
@cases.mark_specifications("Ergo")
|
||||||
def testLuserOpers(self):
|
def testLuserOpers(self):
|
||||||
self.connectClient("bar", name="bar")
|
self.connectClient("bar", name="bar")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
||||||
self.assertIn(lusers.Opers, (0, None))
|
self.assertIn(lusers.Opers, (0, None))
|
||||||
|
|
||||||
@ -207,7 +277,7 @@ class LuserOpersTestCase(LusersTestCase):
|
|||||||
self.sendLine("bar", "OPER operuser operpassword")
|
self.sendLine("bar", "OPER operuser operpassword")
|
||||||
msgs = self.getMessages("bar")
|
msgs = self.getMessages("bar")
|
||||||
self.assertIn(RPL_YOUREOPER, {msg.command for msg in msgs})
|
self.assertIn(RPL_YOUREOPER, {msg.command for msg in msgs})
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
||||||
self.assertEqual(lusers.Opers, 1)
|
self.assertEqual(lusers.Opers, 1)
|
||||||
|
|
||||||
@ -215,7 +285,7 @@ class LuserOpersTestCase(LusersTestCase):
|
|||||||
self.connectClient("qux", name="qux")
|
self.connectClient("qux", name="qux")
|
||||||
self.sendLine("qux", "OPER operuser operpassword")
|
self.sendLine("qux", "OPER operuser operpassword")
|
||||||
self.getMessages("qux")
|
self.getMessages("qux")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
||||||
self.assertEqual(lusers.Opers, 2)
|
self.assertEqual(lusers.Opers, 2)
|
||||||
|
|
||||||
@ -223,14 +293,14 @@ class LuserOpersTestCase(LusersTestCase):
|
|||||||
self.sendLine("bar", "MODE bar -o")
|
self.sendLine("bar", "MODE bar -o")
|
||||||
msgs = self.getMessages("bar")
|
msgs = self.getMessages("bar")
|
||||||
self.assertIn("MODE", {msg.command for msg in msgs})
|
self.assertIn("MODE", {msg.command for msg in msgs})
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
||||||
self.assertEqual(lusers.Opers, 1)
|
self.assertEqual(lusers.Opers, 1)
|
||||||
|
|
||||||
# remove oper by quit
|
# remove oper by quit
|
||||||
self.sendLine("qux", "QUIT")
|
self.sendLine("qux", "QUIT")
|
||||||
self.assertDisconnected("qux")
|
self.assertDisconnected("qux")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
|
||||||
self.assertEqual(lusers.Opers, 0)
|
self.assertEqual(lusers.Opers, 0)
|
||||||
|
|
||||||
@ -247,13 +317,13 @@ class ErgoInvisibleDefaultTestCase(LusersTestCase):
|
|||||||
@cases.mark_specifications("Ergo")
|
@cases.mark_specifications("Ergo")
|
||||||
def testLusers(self):
|
def testLusers(self):
|
||||||
self.connectClient("bar", name="bar")
|
self.connectClient("bar", name="bar")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
|
||||||
self.assertEqual(lusers.GlobalInvisible, 1)
|
self.assertEqual(lusers.GlobalInvisible, 1)
|
||||||
self.assertEqual(lusers.GlobalVisible, 0)
|
self.assertEqual(lusers.GlobalVisible, 0)
|
||||||
|
|
||||||
self.connectClient("qux", name="qux")
|
self.connectClient("qux", name="qux")
|
||||||
lusers = self.getLusers("qux")
|
lusers = self.getLusers("qux", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
||||||
self.assertEqual(lusers.GlobalInvisible, 2)
|
self.assertEqual(lusers.GlobalInvisible, 2)
|
||||||
self.assertEqual(lusers.GlobalVisible, 0)
|
self.assertEqual(lusers.GlobalVisible, 0)
|
||||||
@ -261,7 +331,7 @@ class ErgoInvisibleDefaultTestCase(LusersTestCase):
|
|||||||
# remove +i with MODE
|
# remove +i with MODE
|
||||||
self.sendLine("bar", "MODE bar -i")
|
self.sendLine("bar", "MODE bar -i")
|
||||||
msgs = self.getMessages("bar")
|
msgs = self.getMessages("bar")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertIn("MODE", {msg.command for msg in msgs})
|
self.assertIn("MODE", {msg.command for msg in msgs})
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
|
||||||
self.assertEqual(lusers.GlobalInvisible, 1)
|
self.assertEqual(lusers.GlobalInvisible, 1)
|
||||||
@ -270,7 +340,7 @@ class ErgoInvisibleDefaultTestCase(LusersTestCase):
|
|||||||
# disconnect invisible user
|
# disconnect invisible user
|
||||||
self.sendLine("qux", "QUIT")
|
self.sendLine("qux", "QUIT")
|
||||||
self.assertDisconnected("qux")
|
self.assertDisconnected("qux")
|
||||||
lusers = self.getLusers("bar")
|
lusers = self.getLusers("bar", False)
|
||||||
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
|
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
|
||||||
self.assertEqual(lusers.GlobalInvisible, 0)
|
self.assertEqual(lusers.GlobalInvisible, 0)
|
||||||
self.assertEqual(lusers.GlobalVisible, 1)
|
self.assertEqual(lusers.GlobalVisible, 1)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
https://ircv3.net/specs/extensions/message-tags.html
|
`IRCv3 message-tags <https://ircv3.net/specs/extensions/message-tags>`_
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -10,7 +10,7 @@ from irctest.numerics import ERR_INPUTTOOLONG
|
|||||||
from irctest.patma import ANYDICT, ANYSTR, StrRe
|
from irctest.patma import ANYDICT, ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
class MessageTagsTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
class MessageTagsTestCase(cases.BaseServerTestCase):
|
||||||
@pytest.mark.arbitrary_client_tags
|
@pytest.mark.arbitrary_client_tags
|
||||||
@cases.mark_capabilities("message-tags")
|
@cases.mark_capabilities("message-tags")
|
||||||
def testBasic(self):
|
def testBasic(self):
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
"""
|
"""
|
||||||
Section 3.2 of RFC 2812
|
The PRIVMSG and NOTICE commands.
|
||||||
<https://tools.ietf.org/html/rfc2812#section-3.3>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.numerics import ERR_INPUTTOOLONG
|
from irctest.numerics import ERR_INPUTTOOLONG
|
||||||
|
from irctest.patma import ANYSTR
|
||||||
|
|
||||||
|
|
||||||
class PrivmsgTestCase(cases.BaseServerTestCase):
|
class PrivmsgTestCase(cases.BaseServerTestCase):
|
||||||
@ -13,6 +13,7 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
|
|||||||
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
|
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
|
||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.sendLine(1, "JOIN #chan")
|
self.sendLine(1, "JOIN #chan")
|
||||||
|
self.getMessages(1) # synchronize
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
self.sendLine(2, "JOIN #chan")
|
self.sendLine(2, "JOIN #chan")
|
||||||
self.getMessages(2) # synchronize
|
self.getMessages(2) # synchronize
|
||||||
@ -33,6 +34,48 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
|
|||||||
# ERR_NOSUCHNICK, ERR_NOSUCHCHANNEL, or ERR_CANNOTSENDTOCHAN
|
# ERR_NOSUCHNICK, ERR_NOSUCHCHANNEL, or ERR_CANNOTSENDTOCHAN
|
||||||
self.assertIn(msg.command, ("401", "403", "404"))
|
self.assertIn(msg.command, ("401", "403", "404"))
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
def testPrivmsgToUser(self):
|
||||||
|
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(1, "PRIVMSG bar :hey there!")
|
||||||
|
self.getMessages(1)
|
||||||
|
pms = [msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"]
|
||||||
|
self.assertEqual(len(pms), 1)
|
||||||
|
self.assertMessageMatch(pms[0], command="PRIVMSG", params=["bar", "hey there!"])
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
def testPrivmsgNonexistentUser(self):
|
||||||
|
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.sendLine(1, "PRIVMSG bar :hey there!")
|
||||||
|
msg = self.getMessage(1)
|
||||||
|
# ERR_NOSUCHNICK: 401 <sender> <recipient> :No such nick
|
||||||
|
self.assertMessageMatch(msg, command="401", params=["foo", "bar", ANYSTR])
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["irc2"],
|
||||||
|
"replies with ERR_NEEDMOREPARAMS instead of ERR_NOTEXTTOSEND",
|
||||||
|
)
|
||||||
|
def testEmptyPrivmsg(self):
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.sendLine(1, "JOIN #chan")
|
||||||
|
self.getMessages(1) # synchronize
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(2, "JOIN #chan")
|
||||||
|
self.getMessages(2) # synchronize
|
||||||
|
self.getMessages(1) # synchronize
|
||||||
|
self.sendLine(1, "PRIVMSG #chan :")
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command="412", # ERR_NOTEXTTOSEND
|
||||||
|
params=["foo", ANYSTR],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(2), [])
|
||||||
|
|
||||||
|
|
||||||
class NoticeTestCase(cases.BaseServerTestCase):
|
class NoticeTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812")
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
@ -52,6 +95,15 @@ class NoticeTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812")
|
@cases.mark_specifications("RFC1459", "RFC2812")
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["InspIRCd"],
|
||||||
|
"replies with ERR_NOSUCHCHANNEL to NOTICE to non-existent channels",
|
||||||
|
)
|
||||||
|
@cases.xfailIfSoftware(
|
||||||
|
["UnrealIRCd"],
|
||||||
|
"replies with ERR_NOSUCHCHANNEL to NOTICE to non-existent channels: "
|
||||||
|
"https://bugs.unrealircd.org/view.php?id=5949",
|
||||||
|
)
|
||||||
def testNoticeNonexistentChannel(self):
|
def testNoticeNonexistentChannel(self):
|
||||||
"""
|
"""
|
||||||
"automatic replies must never be
|
"automatic replies must never be
|
||||||
@ -72,6 +124,14 @@ class NoticeTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
class TagsTestCase(cases.BaseServerTestCase):
|
class TagsTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_capabilities("message-tags")
|
@cases.mark_capabilities("message-tags")
|
||||||
|
@cases.xfailIf(
|
||||||
|
lambda self: bool(
|
||||||
|
self.controller.software_name == "UnrealIRCd"
|
||||||
|
and self.controller.software_version == 5
|
||||||
|
),
|
||||||
|
"UnrealIRCd <6.0.7 dropped messages with excessively large tags: "
|
||||||
|
"https://bugs.unrealircd.org/view.php?id=5947",
|
||||||
|
)
|
||||||
def testLineTooLong(self):
|
def testLineTooLong(self):
|
||||||
self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True)
|
self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True)
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
Tests METADATA features.
|
`Deprecated IRCv3 Metadata <https://ircv3.net/specs/core/metadata-3.2>`_
|
||||||
<http://ircv3.net/specs/core/metadata-3.2.html>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
|
|
||||||
|
|
||||||
class MetadataTestCase(cases.BaseServerTestCase):
|
class MetadataTestCase(cases.BaseServerTestCase):
|
||||||
valid_metadata_keys = {"valid_key1", "valid_key2"}
|
valid_metadata_keys = {"display-name", "avatar"}
|
||||||
invalid_metadata_keys = {"invalid_key1", "invalid_key2"}
|
invalid_metadata_keys = {"indisplay-name", "inavatar"}
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3", deprecated=True)
|
@cases.mark_specifications("IRCv3", deprecated=True)
|
||||||
def testInIsupport(self):
|
def testInIsupport(self):
|
||||||
@ -37,7 +36,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
def testGetOneUnsetValid(self):
|
def testGetOneUnsetValid(self):
|
||||||
"""<http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>"""
|
"""<http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>"""
|
||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.sendLine(1, "METADATA * GET valid_key1")
|
self.sendLine(1, "METADATA * GET display-name")
|
||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m,
|
m,
|
||||||
@ -53,7 +52,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>
|
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>
|
||||||
"""
|
"""
|
||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.sendLine(1, "METADATA * GET valid_key1 valid_key2")
|
self.sendLine(1, "METADATA * GET display-name avatar")
|
||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
m,
|
m,
|
||||||
@ -63,10 +62,10 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
m.params[1],
|
m.params[1],
|
||||||
"valid_key1",
|
"display-name",
|
||||||
m,
|
m,
|
||||||
fail_msg="Response to “METADATA * GET valid_key1 valid_key2” "
|
fail_msg="Response to “METADATA * GET display-name avatar” "
|
||||||
"did not respond to valid_key1 first: {msg}",
|
"did not respond to display-name first: {msg}",
|
||||||
)
|
)
|
||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
self.assertMessageMatch(
|
self.assertMessageMatch(
|
||||||
@ -77,10 +76,10 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
m.params[1],
|
m.params[1],
|
||||||
"valid_key2",
|
"avatar",
|
||||||
m,
|
m,
|
||||||
fail_msg="Response to “METADATA * GET valid_key1 valid_key2” "
|
fail_msg="Response to “METADATA * GET display-name avatar” "
|
||||||
"did not respond to valid_key2 as second response: {msg}",
|
"did not respond to avatar as second response: {msg}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3", deprecated=True)
|
@cases.mark_specifications("IRCv3", deprecated=True)
|
||||||
@ -136,7 +135,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
m.params[1],
|
m.params[1],
|
||||||
"valid_key1",
|
"display-name",
|
||||||
m,
|
m,
|
||||||
fail_msg="Second param of 761 after setting “{expects}” to "
|
fail_msg="Second param of 761 after setting “{expects}” to "
|
||||||
"“{}” is not “{expects}”: {msg}.",
|
"“{}” is not “{expects}”: {msg}.",
|
||||||
@ -191,7 +190,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
def testSetGetValid(self):
|
def testSetGetValid(self):
|
||||||
"""<http://ircv3.net/specs/core/metadata-3.2.html>"""
|
"""<http://ircv3.net/specs/core/metadata-3.2.html>"""
|
||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.assertSetGetValue("*", "valid_key1", "myvalue")
|
self.assertSetGetValue("*", "display-name", "myvalue")
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3", deprecated=True)
|
@cases.mark_specifications("IRCv3", deprecated=True)
|
||||||
def testSetGetZeroCharInValue(self):
|
def testSetGetZeroCharInValue(self):
|
||||||
@ -199,7 +198,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-restrictions>
|
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-restrictions>
|
||||||
"""
|
"""
|
||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.assertSetGetValue("*", "valid_key1", "zero->\0<-zero", "zero->\\0<-zero")
|
self.assertSetGetValue("*", "display-name", "zero->\0<-zero", "zero->\\0<-zero")
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3", deprecated=True)
|
@cases.mark_specifications("IRCv3", deprecated=True)
|
||||||
def testSetGetHeartInValue(self):
|
def testSetGetHeartInValue(self):
|
||||||
@ -210,7 +209,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
self.connectClient("foo")
|
self.connectClient("foo")
|
||||||
self.assertSetGetValue(
|
self.assertSetGetValue(
|
||||||
"*",
|
"*",
|
||||||
"valid_key1",
|
"display-name",
|
||||||
"->{}<-".format(heart),
|
"->{}<-".format(heart),
|
||||||
"zero->{}<-zero".format(heart.encode()),
|
"zero->{}<-zero".format(heart.encode()),
|
||||||
)
|
)
|
||||||
@ -224,7 +223,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
# Sending directly because it is not valid UTF-8 so Python would
|
# Sending directly because it is not valid UTF-8 so Python would
|
||||||
# not like it
|
# not like it
|
||||||
self.clients[1].conn.sendall(
|
self.clients[1].conn.sendall(
|
||||||
b"METADATA * SET valid_key1 " b":invalid UTF-8 ->\xc3<-\r\n"
|
b"METADATA * SET display-name " b":invalid UTF-8 ->\xc3<-\r\n"
|
||||||
)
|
)
|
||||||
commands = {m.command for m in self.getMessages(1)}
|
commands = {m.command for m in self.getMessages(1)}
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
@ -234,7 +233,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
|
|||||||
"UTF-8 was answered with 761 (RPL_KEYVALUE)",
|
"UTF-8 was answered with 761 (RPL_KEYVALUE)",
|
||||||
)
|
)
|
||||||
self.clients[1].conn.sendall(
|
self.clients[1].conn.sendall(
|
||||||
b"METADATA * SET valid_key1 " b":invalid UTF-8: \xc3\r\n"
|
b"METADATA * SET display-name " b":invalid UTF-8: \xc3\r\n"
|
||||||
)
|
)
|
||||||
commands = {m.command for m in self.getMessages(1)}
|
commands = {m.command for m in self.getMessages(1)}
|
||||||
self.assertNotIn(
|
self.assertNotIn(
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
<http://ircv3.net/specs/core/monitor-3.2.html>
|
`IRCv3 MONITOR <https://ircv3.net/specs/extensions/monitor>`_
|
||||||
|
and `IRCv3 extended-monitor` <https://ircv3.net/specs/extensions/extended-monitor>`_
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
import pytest
|
||||||
from irctest.basecontrollers import NotImplementedByController
|
|
||||||
|
from irctest import cases, runner
|
||||||
from irctest.client_mock import NoMessageException
|
from irctest.client_mock import NoMessageException
|
||||||
from irctest.numerics import (
|
from irctest.numerics import (
|
||||||
RPL_ENDOFMONLIST,
|
RPL_ENDOFMONLIST,
|
||||||
@ -14,10 +16,10 @@ from irctest.numerics import (
|
|||||||
from irctest.patma import ANYSTR, StrRe
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
class MonitorTestCase(cases.BaseServerTestCase):
|
class _BaseMonitorTestCase(cases.BaseServerTestCase):
|
||||||
def check_server_support(self):
|
def check_server_support(self):
|
||||||
if "MONITOR" not in self.server_support:
|
if "MONITOR" not in self.server_support:
|
||||||
raise NotImplementedByController("MONITOR")
|
raise runner.IsupportTokenNotSupported("MONITOR")
|
||||||
|
|
||||||
def assertMononline(self, client, nick, m=None):
|
def assertMononline(self, client, nick, m=None):
|
||||||
if not m:
|
if not m:
|
||||||
@ -43,6 +45,8 @@ class MonitorTestCase(cases.BaseServerTestCase):
|
|||||||
extra_format=(nick,),
|
extra_format=(nick,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorTestCase(_BaseMonitorTestCase):
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.mark_isupport("MONITOR")
|
@cases.mark_isupport("MONITOR")
|
||||||
def testMonitorOneDisconnected(self):
|
def testMonitorOneDisconnected(self):
|
||||||
@ -188,15 +192,12 @@ class MonitorTestCase(cases.BaseServerTestCase):
|
|||||||
self.sendLine(1, "MONITOR + *!username@127.0.0.1")
|
self.sendLine(1, "MONITOR + *!username@127.0.0.1")
|
||||||
try:
|
try:
|
||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
self.assertNotEqual(
|
self.assertMessageMatch(m, command="731")
|
||||||
m.command,
|
|
||||||
"731",
|
|
||||||
m,
|
|
||||||
fail_msg="Got 731 (RPL_MONOFFLINE) after adding a monitor "
|
|
||||||
"on a mask: {msg}",
|
|
||||||
)
|
|
||||||
except NoMessageException:
|
except NoMessageException:
|
||||||
pass
|
pass
|
||||||
|
else:
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertMessageMatch(m, command="731")
|
||||||
self.connectClient("bar")
|
self.connectClient("bar")
|
||||||
try:
|
try:
|
||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
@ -248,6 +249,23 @@ class MonitorTestCase(cases.BaseServerTestCase):
|
|||||||
extra_format=(messages,),
|
extra_format=(messages,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_isupport("MONITOR")
|
||||||
|
def testMonitorClear(self):
|
||||||
|
"""“Clears the list of targets being monitored. No output will be returned
|
||||||
|
for use of this command.“
|
||||||
|
-- <https://ircv3.net/specs/extensions/monitor#monitor-c>
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.check_server_support()
|
||||||
|
self.sendLine(1, "MONITOR + bar")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "MONITOR C")
|
||||||
|
self.sendLine(1, "MONITOR L")
|
||||||
|
m = self.getMessage(1)
|
||||||
|
self.assertEqual(m.command, RPL_ENDOFMONLIST)
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.mark_isupport("MONITOR")
|
@cases.mark_isupport("MONITOR")
|
||||||
def testMonitorList(self):
|
def testMonitorList(self):
|
||||||
@ -283,6 +301,35 @@ class MonitorTestCase(cases.BaseServerTestCase):
|
|||||||
self.sendLine(1, "MONITOR L")
|
self.sendLine(1, "MONITOR L")
|
||||||
checkMonitorSubjects(self.getMessages(1), "bar", {"bazbat"})
|
checkMonitorSubjects(self.getMessages(1), "bar", {"bazbat"})
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_isupport("MONITOR")
|
||||||
|
def testMonitorStatus(self):
|
||||||
|
"""“Outputs for each target in the list being monitored, whether
|
||||||
|
the client is online or offline. All targets that are online will
|
||||||
|
be sent using RPL_MONONLINE, all targets that are offline will be
|
||||||
|
sent using RPL_MONOFFLINE.“
|
||||||
|
-- <https://ircv3.net/specs/extensions/monitor#monitor-s>
|
||||||
|
"""
|
||||||
|
self.connectClient("foo")
|
||||||
|
self.check_server_support()
|
||||||
|
self.connectClient("bar")
|
||||||
|
self.sendLine(1, "MONITOR + bar,baz")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "MONITOR S")
|
||||||
|
msgs = self.getMessages(1)
|
||||||
|
self.assertEqual(
|
||||||
|
len(msgs),
|
||||||
|
2,
|
||||||
|
fail_msg="Expected one RPL_MONONLINE (730) and one RPL_MONOFFLINE (731), got: {}",
|
||||||
|
extra_format=(msgs,),
|
||||||
|
)
|
||||||
|
|
||||||
|
msgs.sort(key=lambda m: m.command)
|
||||||
|
|
||||||
|
self.assertMononline(1, "bar", m=msgs[0])
|
||||||
|
self.assertMonoffline(1, "baz", m=msgs[1])
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.mark_isupport("MONITOR")
|
@cases.mark_isupport("MONITOR")
|
||||||
def testNickChange(self):
|
def testNickChange(self):
|
||||||
@ -299,10 +346,11 @@ class MonitorTestCase(cases.BaseServerTestCase):
|
|||||||
self.sendLine(2, "NICK qux")
|
self.sendLine(2, "NICK qux")
|
||||||
self.getMessages(2)
|
self.getMessages(2)
|
||||||
mononline = self.getMessages(1)[0]
|
mononline = self.getMessages(1)[0]
|
||||||
self.assertEqual(mononline.command, RPL_MONONLINE)
|
self.assertMessageMatch(
|
||||||
self.assertEqual(len(mononline.params), 2, mononline.params)
|
mononline,
|
||||||
self.assertIn(mononline.params[0], ("bar", "*"))
|
command=RPL_MONONLINE,
|
||||||
self.assertEqual(mononline.params[1].split("!")[0], "qux")
|
params=[StrRe(r"(bar|\*)"), StrRe("qux(!.*)?")],
|
||||||
|
)
|
||||||
|
|
||||||
# no numerics for a case change
|
# no numerics for a case change
|
||||||
self.sendLine(2, "NICK QUX")
|
self.sendLine(2, "NICK QUX")
|
||||||
@ -313,7 +361,246 @@ class MonitorTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessages(2)
|
self.getMessages(2)
|
||||||
monoffline = self.getMessages(1)[0]
|
monoffline = self.getMessages(1)[0]
|
||||||
# should get RPL_MONOFFLINE with the current unfolded nick
|
# should get RPL_MONOFFLINE with the current unfolded nick
|
||||||
self.assertEqual(monoffline.command, RPL_MONOFFLINE)
|
self.assertMessageMatch(
|
||||||
self.assertEqual(len(monoffline.params), 2, monoffline.params)
|
monoffline,
|
||||||
self.assertIn(monoffline.params[0], ("bar", "*"))
|
command=RPL_MONOFFLINE,
|
||||||
self.assertEqual(monoffline.params[1].split("!")[0], "QUX")
|
params=[StrRe(r"(bar|\*)"), "QUX"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _BaseExtendedMonitorTestCase(_BaseMonitorTestCase):
|
||||||
|
def _setupExtendedMonitor(self, monitor_before_connect, watcher_caps, watched_caps):
|
||||||
|
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html"""
|
||||||
|
self.connectClient(
|
||||||
|
"foo",
|
||||||
|
capabilities=["extended-monitor", *watcher_caps],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if monitor_before_connect:
|
||||||
|
self.sendLine(1, "MONITOR + bar")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.connectClient("bar", capabilities=watched_caps, skip_if_cap_nak=True)
|
||||||
|
self.getMessages(2)
|
||||||
|
else:
|
||||||
|
self.connectClient("bar", capabilities=watched_caps, skip_if_cap_nak=True)
|
||||||
|
self.getMessages(2)
|
||||||
|
self.sendLine(1, "MONITOR + bar")
|
||||||
|
|
||||||
|
self.assertMononline(1, "bar")
|
||||||
|
self.assertEqual(self.getMessages(1), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ExtendedMonitorTestCase(_BaseExtendedMonitorTestCase):
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_capabilities("extended-monitor", "away-notify")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"monitor_before_connect,cap",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
monitor_before_connect,
|
||||||
|
cap,
|
||||||
|
id=("monitor_before_connect" if monitor_before_connect else "")
|
||||||
|
+ "-"
|
||||||
|
+ ("with-cap" if cap else ""),
|
||||||
|
)
|
||||||
|
for monitor_before_connect in [True, False]
|
||||||
|
for cap in [True, False]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def testExtendedMonitorAway(self, monitor_before_connect, cap):
|
||||||
|
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html
|
||||||
|
with https://ircv3.net/specs/extensions/away-notify
|
||||||
|
"""
|
||||||
|
if cap:
|
||||||
|
self._setupExtendedMonitor(
|
||||||
|
monitor_before_connect, ["away-notify"], ["away-notify"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._setupExtendedMonitor(monitor_before_connect, ["away-notify"], [])
|
||||||
|
|
||||||
|
self.sendLine(2, "AWAY :afk")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1), nick="bar", command="AWAY", params=["afk"]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
|
||||||
|
self.sendLine(2, "AWAY")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1), nick="bar", command="AWAY", params=[]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_capabilities("extended-monitor", "away-notify")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"monitor_before_connect,cap",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
monitor_before_connect,
|
||||||
|
cap,
|
||||||
|
id=("monitor_before_connect" if monitor_before_connect else "")
|
||||||
|
+ "-"
|
||||||
|
+ ("with-cap" if cap else ""),
|
||||||
|
)
|
||||||
|
for monitor_before_connect in [True, False]
|
||||||
|
for cap in [True, False]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def testExtendedMonitorAwayNoCap(self, monitor_before_connect, cap):
|
||||||
|
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html
|
||||||
|
does nothing when ``away-notify`` is not enabled by the watcher
|
||||||
|
"""
|
||||||
|
if cap:
|
||||||
|
self._setupExtendedMonitor(monitor_before_connect, [], ["away-notify"])
|
||||||
|
else:
|
||||||
|
self._setupExtendedMonitor(monitor_before_connect, [], [])
|
||||||
|
|
||||||
|
self.sendLine(2, "AWAY :afk")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
|
||||||
|
self.sendLine(2, "AWAY")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_capabilities("extended-monitor", "setname")
|
||||||
|
@pytest.mark.parametrize("monitor_before_connect", [True, False])
|
||||||
|
def testExtendedMonitorSetName(self, monitor_before_connect):
|
||||||
|
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html
|
||||||
|
with https://ircv3.net/specs/extensions/setname
|
||||||
|
"""
|
||||||
|
self._setupExtendedMonitor(monitor_before_connect, ["setname"], ["setname"])
|
||||||
|
|
||||||
|
self.sendLine(2, "SETNAME :new name")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1), nick="bar", command="SETNAME", params=["new name"]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_capabilities("extended-monitor", "setname")
|
||||||
|
@pytest.mark.parametrize("monitor_before_connect", [True, False])
|
||||||
|
def testExtendedMonitorSetNameNoCap(self, monitor_before_connect):
|
||||||
|
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html
|
||||||
|
does nothing when ``setname`` is not enabled by the watcher
|
||||||
|
"""
|
||||||
|
self._setupExtendedMonitor(monitor_before_connect, [], ["setname"])
|
||||||
|
|
||||||
|
self.sendLine(2, "SETNAME :new name")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
|
||||||
|
|
||||||
|
@cases.mark_services
|
||||||
|
class AuthenticatedExtendedMonitorTestCase(_BaseExtendedMonitorTestCase):
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_capabilities("extended-monitor", "account-notify")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"monitor_before_connect,cap",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
monitor_before_connect,
|
||||||
|
cap,
|
||||||
|
id=("monitor_before_connect" if monitor_before_connect else "")
|
||||||
|
+ "-"
|
||||||
|
+ ("with-cap" if cap else ""),
|
||||||
|
)
|
||||||
|
for monitor_before_connect in [True, False]
|
||||||
|
for cap in [True, False]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def testExtendedMonitorAccountNotify(self, monitor_before_connect, cap):
|
||||||
|
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html
|
||||||
|
does nothing when ``account-notify`` is not enabled by the watcher
|
||||||
|
"""
|
||||||
|
self.controller.registerUser(self, "jilles", "sesame")
|
||||||
|
|
||||||
|
if cap:
|
||||||
|
self._setupExtendedMonitor(
|
||||||
|
monitor_before_connect,
|
||||||
|
["account-notify"],
|
||||||
|
["account-notify", "sasl", "cap-notify"],
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._setupExtendedMonitor(
|
||||||
|
monitor_before_connect, ["account-notify"], ["sasl", "cap-notify"]
|
||||||
|
)
|
||||||
|
|
||||||
|
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.getMessages(2)
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1), nick="bar", command="ACCOUNT", params=["jilles"]
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
@cases.mark_capabilities("extended-monitor", "account-notify")
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"monitor_before_connect,cap",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
monitor_before_connect,
|
||||||
|
cap,
|
||||||
|
id=("monitor_before_connect" if monitor_before_connect else "")
|
||||||
|
+ "-"
|
||||||
|
+ ("with-cap" if cap else ""),
|
||||||
|
)
|
||||||
|
for monitor_before_connect in [True, False]
|
||||||
|
for cap in [True, False]
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def testExtendedMonitorAccountNotifyNoCap(self, monitor_before_connect, cap):
|
||||||
|
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html
|
||||||
|
does nothing when ``account-notify`` is not enabled by the watcher
|
||||||
|
"""
|
||||||
|
self.controller.registerUser(self, "jilles", "sesame")
|
||||||
|
|
||||||
|
if cap:
|
||||||
|
self._setupExtendedMonitor(
|
||||||
|
monitor_before_connect, [], ["account-notify", "sasl", "cap-notify"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._setupExtendedMonitor(
|
||||||
|
monitor_before_connect, [], ["sasl", "cap-notify"]
|
||||||
|
)
|
||||||
|
|
||||||
|
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.getMessages(2)
|
||||||
|
|
||||||
|
self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages")
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Tests multi-prefix.
|
`IRCv3 multi-prefix <https://ircv3.net/specs/extensions/multi-prefix>`_
|
||||||
<http://ircv3.net/specs/extensions/multi-prefix-3.1.html>
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
@ -16,7 +15,7 @@ class MultiPrefixTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
These prefixes MUST be in order of ‘rank’, from highest to lowest.
|
These prefixes MUST be in order of ‘rank’, from highest to lowest.
|
||||||
"""
|
"""
|
||||||
self.connectClient("foo", capabilities=["multi-prefix"])
|
self.connectClient("foo", capabilities=["multi-prefix"], skip_if_cap_nak=True)
|
||||||
self.joinChannel(1, "#chan")
|
self.joinChannel(1, "#chan")
|
||||||
self.sendLine(1, "MODE #chan +v foo")
|
self.sendLine(1, "MODE #chan +v foo")
|
||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
draft/multiline
|
`Draft IRCv3 multiline <https://ircv3.net/specs/extensions/multiline>`_
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
@ -12,7 +12,7 @@ CONCAT_TAG = "draft/multiline-concat"
|
|||||||
base_caps = ["message-tags", "batch", "echo-message", "server-time", "labeled-response"]
|
base_caps = ["message-tags", "batch", "echo-message", "server-time", "labeled-response"]
|
||||||
|
|
||||||
|
|
||||||
class MultilineTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
class MultilineTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_capabilities("draft/multiline")
|
@cases.mark_capabilities("draft/multiline")
|
||||||
def testBasic(self):
|
def testBasic(self):
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
|
505
irctest/server_tests/named_modes.py
Normal file
505
irctest/server_tests/named_modes.py
Normal file
@ -0,0 +1,505 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
from irctest import cases
|
||||||
|
from irctest.numerics import (
|
||||||
|
ERR_BANNEDFROMCHAN,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
ERR_INVITEONLYCHAN,
|
||||||
|
RPL_CHMODELIST,
|
||||||
|
RPL_ENDOFLISTPROPLIST,
|
||||||
|
RPL_ENDOFPROPLIST,
|
||||||
|
RPL_LISTPROPLIST,
|
||||||
|
RPL_PROPLIST,
|
||||||
|
RPL_UMODELIST,
|
||||||
|
)
|
||||||
|
from irctest.patma import ANYLIST, ANYSTR, ListRemainder, StrRe
|
||||||
|
from irctest.runner import NotImplementedByController
|
||||||
|
|
||||||
|
CHMODES = {
|
||||||
|
"op",
|
||||||
|
"voice",
|
||||||
|
"ban",
|
||||||
|
"inviteonly",
|
||||||
|
"limit",
|
||||||
|
"moderated",
|
||||||
|
"noextmsg",
|
||||||
|
"key",
|
||||||
|
"private",
|
||||||
|
"topiclock",
|
||||||
|
"secret",
|
||||||
|
"banex",
|
||||||
|
"invex",
|
||||||
|
"admin",
|
||||||
|
"halfop",
|
||||||
|
"noctcp",
|
||||||
|
"owner",
|
||||||
|
"permanent",
|
||||||
|
"regonly",
|
||||||
|
"secureonly",
|
||||||
|
"mute",
|
||||||
|
}
|
||||||
|
|
||||||
|
UMODES = {
|
||||||
|
"invisible",
|
||||||
|
"oper",
|
||||||
|
"snomask",
|
||||||
|
"wallops",
|
||||||
|
"bot",
|
||||||
|
"hidechans",
|
||||||
|
"cloak",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _NamedModeTestMixin:
|
||||||
|
ALLOW_MODE_REPLY: bool
|
||||||
|
|
||||||
|
def assertNewBans(self, msgs, expected_masks):
|
||||||
|
"""Checks ``msgs`` is a set of PROP messages (and/or MODE if
|
||||||
|
``self.ALLOW_MODE_REPLY`` is True) that ban exactly the given set of masks."""
|
||||||
|
banned_masks = set()
|
||||||
|
for msg in msgs:
|
||||||
|
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command="MODE", params=["#chan", StrRe(r"\+?b+"), *ANYLIST]
|
||||||
|
)
|
||||||
|
(_, chars, *args) = msg.params
|
||||||
|
chars = chars.lstrip("+")
|
||||||
|
self.assertEqual(
|
||||||
|
len(chars), len(args), "Mismatched number of +b and args"
|
||||||
|
)
|
||||||
|
banned_masks.update(args)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg,
|
||||||
|
command="PROP",
|
||||||
|
params=["#chan", ListRemainder(StrRe(r"\+ban=.+"), min_length=1)],
|
||||||
|
)
|
||||||
|
banned_masks.update(param.split("=")[1] for param in msg.params[1:])
|
||||||
|
|
||||||
|
self.assertEqual(banned_masks, expected_masks)
|
||||||
|
|
||||||
|
def assertNewUnbans(self, msgs, expected_masks):
|
||||||
|
"""Same as ``assertNewBans`` but for unbans."""
|
||||||
|
banned_masks = set()
|
||||||
|
for msg in msgs:
|
||||||
|
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command="MODE", params=["#chan", StrRe(r"-b+"), *ANYLIST]
|
||||||
|
)
|
||||||
|
(_, chars, *args) = msg.params
|
||||||
|
chars = chars.lstrip("-")
|
||||||
|
self.assertEqual(
|
||||||
|
len(chars), len(args), "Mismatched number of -b and args"
|
||||||
|
)
|
||||||
|
banned_masks.update(args)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg,
|
||||||
|
command="PROP",
|
||||||
|
params=["#chan", ListRemainder(StrRe(r"-ban=.+"), min_length=1)],
|
||||||
|
)
|
||||||
|
banned_masks.update(param.split("=")[1] for param in msg.params[1:])
|
||||||
|
|
||||||
|
self.assertEqual(banned_masks, expected_masks)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("draft/named-modes")
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testListMode(self):
|
||||||
|
"""Checks list modes (type 1), using 'ban' as example."""
|
||||||
|
self.connectClient(
|
||||||
|
"foo", name="user", capabilities=["draft/named-modes"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
|
||||||
|
# Set ban
|
||||||
|
self.sendLine("chanop", "PROP #chan +ban=foo!*@*")
|
||||||
|
self.assertNewBans(self.getMessages("chanop"), {"foo!*@*"})
|
||||||
|
|
||||||
|
# Should not appear in the main list
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertNotIn("ban", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check banned
|
||||||
|
self.sendLine("chanop", "PROP #chan ban")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_LISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", "foo!*@*", *ANYLIST],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFLISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("user"), command=ERR_BANNEDFROMCHAN)
|
||||||
|
|
||||||
|
# Unset ban
|
||||||
|
self.sendLine("chanop", "PROP #chan -ban=foo!*@*")
|
||||||
|
self.assertNewUnbans(self.getMessages("chanop"), {"foo!*@*"})
|
||||||
|
|
||||||
|
# Check unbanned
|
||||||
|
self.sendLine("chanop", "PROP #chan ban")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFLISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("user"), command="JOIN", params=["#chan"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("draft/named-modes")
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testFlagModeDefaultOn(self):
|
||||||
|
"""Checks flag modes (type 4), using 'noextmsg' as example."""
|
||||||
|
self.connectClient(
|
||||||
|
"foo", name="user", capabilities=["draft/named-modes"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
|
||||||
|
# Check set
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertIn("noextmsg", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "PRIVMSG #chan :hi")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("user"),
|
||||||
|
command=ERR_CANNOTSENDTOCHAN,
|
||||||
|
params=["foo", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
# Unset
|
||||||
|
self.sendLine("chanop", "PROP #chan -noextmsg")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
|
||||||
|
self.assertMessageMatch(msg, command="MODE", params=["#chan", "-noextmsg"])
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(msg, command="PROP", params=["#chan", "-noextmsg"])
|
||||||
|
|
||||||
|
# Check unset
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertNotIn("noextmsg", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "PRIVMSG #chan :hi")
|
||||||
|
self.assertEqual(self.getMessages("user"), [])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"), command="PRIVMSG", params=["#chan", "hi"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set
|
||||||
|
self.sendLine("chanop", "PROP #chan +noextmsg")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
|
||||||
|
self.assertMessageMatch(msg, command="MODE", params=["#chan", "+noextmsg"])
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(msg, command="PROP", params=["#chan", "+noextmsg"])
|
||||||
|
|
||||||
|
# Check set again
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertIn("noextmsg", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "PRIVMSG #chan :hi")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("user"),
|
||||||
|
command=ERR_CANNOTSENDTOCHAN,
|
||||||
|
params=["foo", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.assertEqual(self.getMessages("chanop"), [])
|
||||||
|
|
||||||
|
@cases.mark_capabilities("draft/named-modes")
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testFlagModeDefaultOff(self):
|
||||||
|
"""Checks flag modes (type 4), using 'inviteonly' as example."""
|
||||||
|
self.connectClient(
|
||||||
|
"foo", name="user", capabilities=["draft/named-modes"], skip_if_cap_nak=True
|
||||||
|
)
|
||||||
|
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
|
||||||
|
# Check unset
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertNotIn("inviteonly", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "JOIn #chan")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("user"), command="JOIN", params=["#chan"]
|
||||||
|
)
|
||||||
|
self.sendLine("user", "PART #chan :bye")
|
||||||
|
self.getMessages("user")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
|
||||||
|
# Set
|
||||||
|
self.sendLine("chanop", "PROP #chan +inviteonly")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command="MODE", params=["#chan", "+inviteonly"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command="PROP", params=["#chan", "+inviteonly"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check set
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertIn("inviteonly", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "JOIN #chan")
|
||||||
|
self.assertMessageMatch(self.getMessage("user"), command=ERR_INVITEONLYCHAN)
|
||||||
|
|
||||||
|
# Unset
|
||||||
|
self.sendLine("chanop", "PROP #chan -inviteonly")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command="MODE", params=["#chan", "-inviteonly"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command="PROP", params=["#chan", "-inviteonly"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check unset again
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertNotIn("inviteonly", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
self.sendLine("user", "JOIn #chan")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("user"), command="JOIN", params=["#chan"]
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_capabilities("draft/named-modes")
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testManyListModes(self):
|
||||||
|
"""Checks setting three list modes (type 1) at once, using 'ban' as example."""
|
||||||
|
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
|
||||||
|
self.joinChannel("chanop", "#chan")
|
||||||
|
self.getMessages("chanop")
|
||||||
|
|
||||||
|
if int(self.server_support.get("MAXMODES") or "1") < 3:
|
||||||
|
raise NotImplementedByController("MAXMODES is not >= 3.")
|
||||||
|
|
||||||
|
# Set ban
|
||||||
|
self.sendLine("chanop", "PROP #chan +ban=foo1!*@* +ban=foo2!*@* +ban=foo3!*@*")
|
||||||
|
msgs = self.getMessages("chanop")
|
||||||
|
self.assertNewBans(msgs, {"foo1!*@*", "foo2!*@*", "foo3!*@*"})
|
||||||
|
|
||||||
|
# Should not appear in the main list
|
||||||
|
self.sendLine("chanop", "PROP #chan")
|
||||||
|
msg = self.getMessage("chanop")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
|
||||||
|
)
|
||||||
|
self.assertNotIn("ban", msg.params[2:])
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFPROPLIST,
|
||||||
|
params=["chanop", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check banned
|
||||||
|
self.sendLine("chanop", "PROP #chan ban")
|
||||||
|
# TODO: make it so the order doesn't matter
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_LISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", "foo1!*@*", *ANYLIST],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_LISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", "foo2!*@*", *ANYLIST],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_LISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", "foo3!*@*", *ANYLIST],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFLISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unset two bans
|
||||||
|
self.sendLine("chanop", "PROP #chan -ban=foo2!*@* -ban=foo3!*@*")
|
||||||
|
msgs = self.getMessages("chanop")
|
||||||
|
self.assertNewUnbans(msgs, {"foo2!*@*", "foo3!*@*"})
|
||||||
|
|
||||||
|
# Check unbanned
|
||||||
|
self.sendLine("chanop", "PROP #chan ban")
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_LISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", "foo1!*@*", *ANYLIST],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage("chanop"),
|
||||||
|
command=RPL_ENDOFLISTPROPLIST,
|
||||||
|
params=["chanop", "#chan", "ban", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NamedModesTestCase(_NamedModeTestMixin, cases.BaseServerTestCase):
|
||||||
|
"""Normal testing of the named-modes spec."""
|
||||||
|
|
||||||
|
ALLOW_MODE_REPLY = True
|
||||||
|
|
||||||
|
@cases.mark_capabilities("draft/named-modes")
|
||||||
|
@cases.mark_specifications("IRCv3")
|
||||||
|
def testConnectionNumerics(self):
|
||||||
|
"""Tests RPL_CHMODELIST and RPL_UMODELIST."""
|
||||||
|
self.connectClient(
|
||||||
|
"capchk",
|
||||||
|
name="capchk",
|
||||||
|
capabilities=["draft/named-modes"],
|
||||||
|
skip_if_cap_nak=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.addClient(1)
|
||||||
|
self.sendLine(1, "CAP LS 302")
|
||||||
|
self.getCapLs(1)
|
||||||
|
self.sendLine(1, "USER user user user :user")
|
||||||
|
self.sendLine(1, "NICK user")
|
||||||
|
self.sendLine(1, "CAP END")
|
||||||
|
self.skipToWelcome(1)
|
||||||
|
msgs = self.getMessages(1)
|
||||||
|
|
||||||
|
seen_chmodes = set()
|
||||||
|
seen_umodes = set()
|
||||||
|
|
||||||
|
got_last_chmode = False
|
||||||
|
got_last_umode = False
|
||||||
|
capturing_re = r"[12345]:(?P<name>(\S+/)?[a-zA-Z0-9-]+)(=[a-zA-Z]+)?"
|
||||||
|
# fmt: off
|
||||||
|
chmode_re = r"^[12345]:(\S+/)?[a-zA-Z0-9-]+(=[a-zA-Z]+)?$"
|
||||||
|
umode_re = r"^[34]:(\S+/)?[a-zA-Z0-9-]+(=[a-zA-Z]+)?$" # noqa
|
||||||
|
# fmt: on
|
||||||
|
chmode_pat = [ListRemainder(StrRe(chmode_re), min_length=1)]
|
||||||
|
umode_pat = [ListRemainder(StrRe(umode_re), min_length=1)]
|
||||||
|
for msg in msgs:
|
||||||
|
if msg.command == RPL_CHMODELIST:
|
||||||
|
self.assertFalse(
|
||||||
|
got_last_chmode, "Got RPL_CHMODELIST after the list ended."
|
||||||
|
)
|
||||||
|
if msg.params[1] == "*":
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_CHMODELIST, params=["user", "*", *chmode_pat]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_CHMODELIST, params=["user", *chmode_pat]
|
||||||
|
)
|
||||||
|
got_last_chmode = True
|
||||||
|
|
||||||
|
for token in msg.params[-1].split(" "):
|
||||||
|
name = re.match(capturing_re, token).group("name")
|
||||||
|
self.assertNotIn(name, seen_chmodes, f"Duplicate chmode {name}")
|
||||||
|
seen_chmodes.add(name)
|
||||||
|
|
||||||
|
elif msg.command == RPL_UMODELIST:
|
||||||
|
self.assertFalse(
|
||||||
|
got_last_umode, "Got RPL_UMODELIST after the list ended."
|
||||||
|
)
|
||||||
|
if msg.params[1] == "*":
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_UMODELIST, params=["user", "*", *umode_pat]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.assertMessageMatch(
|
||||||
|
msg, command=RPL_UMODELIST, params=["user", *umode_pat]
|
||||||
|
)
|
||||||
|
got_last_umode = True
|
||||||
|
|
||||||
|
for token in msg.params[-1].split(" "):
|
||||||
|
name = re.match(capturing_re, token).group("name")
|
||||||
|
self.assertNotIn(name, seen_umodes, f"Duplicate umode {name}")
|
||||||
|
seen_umodes.add(name)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
"noextmsg", seen_chmodes, "'noextmsg' chmode not supported/advertised"
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"invisible", seen_umodes, "'invisible' umode not supported/advertised"
|
||||||
|
)
|
||||||
|
|
||||||
|
unknown_chmodes = {m for m in seen_chmodes if "/" not in m} - CHMODES
|
||||||
|
unknown_umodes = {m for m in seen_umodes if "/" not in m} - UMODES
|
||||||
|
self.assertFalse(
|
||||||
|
unknown_chmodes, fail_msg="Got unknown unvendored chmodes: {got}"
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
unknown_umodes, fail_msg="Got unknown unvendored umodes: {got}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OverlyStrictNamedModesTestCase(_NamedModeTestMixin, cases.BaseServerTestCase):
|
||||||
|
"""Stronger tests, that assert the server only sends PROP and never MODE.
|
||||||
|
Passing these tests is not required to"""
|
||||||
|
|
||||||
|
ALLOW_MODE_REPLY = False
|
@ -1,9 +1,114 @@
|
|||||||
from irctest import cases
|
"""
|
||||||
from irctest.numerics import RPL_ENDOFNAMES
|
The NAMES command (`RFC 1459
|
||||||
from irctest.patma import ANYSTR
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#names-message>`__)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import RPL_ENDOFNAMES, RPL_NAMREPLY
|
||||||
|
from irctest.patma import ANYSTR, StrRe
|
||||||
|
|
||||||
|
|
||||||
class NamesTestCase(cases.BaseServerTestCase):
|
class NamesTestCase(cases.BaseServerTestCase):
|
||||||
|
def _testNames(self, symbol):
|
||||||
|
self.connectClient("nick1")
|
||||||
|
self.sendLine(1, "JOIN #chan")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.connectClient("nick2")
|
||||||
|
self.sendLine(2, "JOIN #chan")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "NAMES #chan")
|
||||||
|
|
||||||
|
# TODO: It is technically allowed to have one line for each;
|
||||||
|
# but noone does that.
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_NAMREPLY,
|
||||||
|
params=[
|
||||||
|
"nick1",
|
||||||
|
*(["="] if symbol else []),
|
||||||
|
"#chan",
|
||||||
|
StrRe("(nick2 @nick1|@nick1 nick2)"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_ENDOFNAMES,
|
||||||
|
params=["nick1", "#chan", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", deprecated=True)
|
||||||
|
def testNames1459(self):
|
||||||
|
"""
|
||||||
|
https://modern.ircdocs.horse/#names-message
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNames(symbol=False)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
def testNames2812(self):
|
||||||
|
"""
|
||||||
|
https://modern.ircdocs.horse/#names-message
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNames(symbol=True)
|
||||||
|
|
||||||
|
def _testNamesMultipleChannels(self, symbol):
|
||||||
|
self.connectClient("nick1")
|
||||||
|
|
||||||
|
if self.targmax.get("NAMES", "1") == "1":
|
||||||
|
raise runner.OptionalExtensionNotSupported("Multi-target NAMES")
|
||||||
|
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.sendLine(1, "JOIN #chan2")
|
||||||
|
self.getMessages(1)
|
||||||
|
|
||||||
|
self.sendLine(1, "NAMES #chan1,#chan2")
|
||||||
|
|
||||||
|
# TODO: order is unspecified
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_NAMREPLY,
|
||||||
|
params=["nick1", *(["="] if symbol else []), "#chan1", "@nick1"],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_NAMREPLY,
|
||||||
|
params=["nick1", *(["="] if symbol else []), "#chan2", "@nick1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_ENDOFNAMES,
|
||||||
|
params=["nick1", "#chan1,#chan2", ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_isupport("TARGMAX")
|
||||||
|
@cases.mark_specifications("RFC1459", deprecated=True)
|
||||||
|
def testNamesMultipleChannels1459(self):
|
||||||
|
"""
|
||||||
|
https://modern.ircdocs.horse/#names-message
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNamesMultipleChannels(symbol=False)
|
||||||
|
|
||||||
|
@cases.mark_isupport("TARGMAX")
|
||||||
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
|
def testNamesMultipleChannels2812(self):
|
||||||
|
"""
|
||||||
|
https://modern.ircdocs.horse/#names-message
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNamesMultipleChannels(symbol=True)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
|
||||||
def testNamesInvalidChannel(self):
|
def testNamesInvalidChannel(self):
|
||||||
"""
|
"""
|
||||||
@ -47,3 +152,101 @@ class NamesTestCase(cases.BaseServerTestCase):
|
|||||||
command=RPL_ENDOFNAMES,
|
command=RPL_ENDOFNAMES,
|
||||||
params=["foo", "#nonexisting", ANYSTR],
|
params=["foo", "#nonexisting", ANYSTR],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _testNamesNoArgumentPublic(self, symbol):
|
||||||
|
self.connectClient("nick1")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.connectClient("nick2")
|
||||||
|
self.sendLine(2, "JOIN #chan2")
|
||||||
|
self.sendLine(2, "MODE #chan2 -sp")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.sendLine(1, "NAMES")
|
||||||
|
|
||||||
|
# TODO: order is unspecified
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_NAMREPLY,
|
||||||
|
params=["nick1", *(["="] if symbol else []), "#chan1", "@nick1"],
|
||||||
|
)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_NAMREPLY,
|
||||||
|
params=["nick1", *(["="] if symbol else []), "#chan2", "@nick2"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_ENDOFNAMES,
|
||||||
|
params=["nick1", ANYSTR, ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", deprecated=True)
|
||||||
|
def testNamesNoArgumentPublic1459(self):
|
||||||
|
"""
|
||||||
|
"If no <channel> parameter is given, a list of all channels and their
|
||||||
|
occupants is returned."
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNamesNoArgumentPublic(symbol=False)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC2812", deprecated=True)
|
||||||
|
def testNamesNoArgumentPublic2812(self):
|
||||||
|
"""
|
||||||
|
"If no <channel> parameter is given, a list of all channels and their
|
||||||
|
occupants is returned."
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNamesNoArgumentPublic(symbol=True)
|
||||||
|
|
||||||
|
def _testNamesNoArgumentPrivate(self, symbol):
|
||||||
|
self.connectClient("nick1")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.sendLine(1, "JOIN #chan1")
|
||||||
|
self.connectClient("nick2")
|
||||||
|
self.sendLine(2, "JOIN #chan2")
|
||||||
|
self.sendLine(2, "MODE #chan2 +sp")
|
||||||
|
self.getMessages(1)
|
||||||
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.sendLine(1, "NAMES")
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_NAMREPLY,
|
||||||
|
params=["nick1", *(["="] if symbol else []), "#chan1", "@nick1"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1),
|
||||||
|
command=RPL_ENDOFNAMES,
|
||||||
|
params=["nick1", ANYSTR, ANYSTR],
|
||||||
|
)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC1459", deprecated=True)
|
||||||
|
def testNamesNoArgumentPrivate1459(self):
|
||||||
|
"""
|
||||||
|
"If no <channel> parameter is given, a list of all channels and their
|
||||||
|
occupants is returned. At the end of this list, a list of users who
|
||||||
|
are visible but either not on any channel or not on a visible channel
|
||||||
|
are listed as being on `channel' "*"."
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNamesNoArgumentPrivate(symbol=False)
|
||||||
|
|
||||||
|
@cases.mark_specifications("RFC2812", deprecated=True)
|
||||||
|
def testNamesNoArgumentPrivate2812(self):
|
||||||
|
"""
|
||||||
|
"If no <channel> parameter is given, a list of all channels and their
|
||||||
|
occupants is returned. At the end of this list, a list of users who
|
||||||
|
are visible but either not on any channel or not on a visible channel
|
||||||
|
are listed as being on `channel' "*"."
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
|
||||||
|
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
|
||||||
|
"""
|
||||||
|
self._testNamesNoArgumentPrivate(symbol=True)
|
||||||
|
@ -1,6 +1,16 @@
|
|||||||
|
"""
|
||||||
|
The PART command (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-6.1>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-5.2>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#part-message>`__)
|
||||||
|
|
||||||
|
TODO: cross-reference Modern
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
|
from irctest.numerics import RPL_NAMREPLY
|
||||||
|
|
||||||
|
|
||||||
class PartTestCase(cases.BaseServerTestCase):
|
class PartTestCase(cases.BaseServerTestCase):
|
||||||
@ -75,6 +85,12 @@ class PartTestCase(cases.BaseServerTestCase):
|
|||||||
self.getMessages(1)
|
self.getMessages(1)
|
||||||
self.getMessages(2)
|
self.getMessages(2)
|
||||||
|
|
||||||
|
self.sendLine(2, "PRIVMSG #chan :hi everyone")
|
||||||
|
self.getMessages(2)
|
||||||
|
self.assertMessageMatch(
|
||||||
|
self.getMessage(1), command="PRIVMSG", params=["#chan", "hi everyone"]
|
||||||
|
)
|
||||||
|
|
||||||
self.sendLine(1, "PART #chan")
|
self.sendLine(1, "PART #chan")
|
||||||
# both the PART'ing client and the other channel member should receive
|
# both the PART'ing client and the other channel member should receive
|
||||||
# a PART line:
|
# a PART line:
|
||||||
@ -83,6 +99,21 @@ class PartTestCase(cases.BaseServerTestCase):
|
|||||||
m = self.getMessage(2)
|
m = self.getMessage(2)
|
||||||
self.assertMessageMatch(m, command="PART")
|
self.assertMessageMatch(m, command="PART")
|
||||||
|
|
||||||
|
self.sendLine(2, "PRIVMSG #chan :hi again everyone")
|
||||||
|
self.getMessages(2)
|
||||||
|
# client 1 has PART'ed and should not receive channel messages:
|
||||||
|
self.assertEqual(self.getMessages(1), [])
|
||||||
|
|
||||||
|
# client 1 should no longer appear in NAMES responses:
|
||||||
|
names = set()
|
||||||
|
self.sendLine(2, "NAMES #chan")
|
||||||
|
for reply in self.getMessages(2):
|
||||||
|
if reply.command != RPL_NAMREPLY:
|
||||||
|
continue
|
||||||
|
names.update(reply.params[-1].replace("@", "").split())
|
||||||
|
self.assertNotIn("bar", names)
|
||||||
|
self.assertIn("baz", names)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC2812")
|
@cases.mark_specifications("RFC2812")
|
||||||
def testBasicPartRfc2812(self):
|
def testBasicPartRfc2812(self):
|
||||||
"""
|
"""
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
The PING and PONG commands
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_NOORIGIN
|
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_NOORIGIN
|
||||||
from irctest.patma import ANYSTR
|
from irctest.patma import ANYSTR
|
||||||
|
@ -1,12 +1,21 @@
|
|||||||
|
"""
|
||||||
|
The QUITcommand (`RFC 1459
|
||||||
|
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.1.6>`__,
|
||||||
|
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.1>`__,
|
||||||
|
`Modern <https://modern.ircdocs.horse/#quit-message>`__)
|
||||||
|
|
||||||
|
TODO: cross-reference RFC 1459 and Modern
|
||||||
|
"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.numerics import ERR_CANNOTSENDTOCHAN
|
|
||||||
from irctest.patma import StrRe
|
from irctest.patma import StrRe
|
||||||
|
|
||||||
|
|
||||||
class ChannelQuitTestCase(cases.BaseServerTestCase):
|
class ChannelQuitTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_specifications("RFC2812")
|
@cases.mark_specifications("RFC2812")
|
||||||
|
@cases.xfailIfSoftware(["ircu2", "Nefarious", "snircd"], "ircu2 does not echo QUIT")
|
||||||
def testQuit(self):
|
def testQuit(self):
|
||||||
"""“Once a user has joined a channel, he receives information about
|
"""“Once a user has joined a channel, he receives information about
|
||||||
all commands his server receives affecting the channel. This
|
all commands his server receives affecting the channel. This
|
||||||
@ -30,31 +39,3 @@ class ChannelQuitTestCase(cases.BaseServerTestCase):
|
|||||||
m = self.getMessage(1)
|
m = self.getMessage(1)
|
||||||
self.assertMessageMatch(m, command="QUIT", params=[StrRe(".*qux out.*")])
|
self.assertMessageMatch(m, command="QUIT", params=[StrRe(".*qux out.*")])
|
||||||
self.assertTrue(m.prefix.startswith("qux")) # nickmask of quitter
|
self.assertTrue(m.prefix.startswith("qux")) # nickmask of quitter
|
||||||
|
|
||||||
|
|
||||||
class NoCTCPTestCase(cases.BaseServerTestCase):
|
|
||||||
@cases.mark_specifications("Ergo")
|
|
||||||
def testQuit(self):
|
|
||||||
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, [])
|
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
|
"""
|
||||||
|
`Ergo <https://ergo.chat/>`_-specific tests of responses to DoS attacks
|
||||||
|
using long lines.
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
|
|
||||||
|
|
||||||
class ReadqTestCase(cases.BaseServerTestCase):
|
class ReadqTestCase(cases.BaseServerTestCase):
|
||||||
"""Test responses to DoS attacks using long lines."""
|
|
||||||
|
|
||||||
@cases.mark_specifications("Ergo")
|
@cases.mark_specifications("Ergo")
|
||||||
@cases.mark_capabilities("message-tags")
|
@cases.mark_capabilities("message-tags")
|
||||||
def testReadqTags(self):
|
def testReadqTags(self):
|
||||||
|
@ -1,11 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
Regression tests for bugs in oragono.
|
Regression tests for bugs in `Ergo <https://ergo.chat/>`_.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import time
|
from irctest import cases, runner
|
||||||
|
from irctest.numerics import (
|
||||||
from irctest import cases
|
ERR_ERRONEUSNICKNAME,
|
||||||
from irctest.numerics import ERR_ERRONEUSNICKNAME, ERR_NICKNAMEINUSE, RPL_WELCOME
|
ERR_NICKNAMEINUSE,
|
||||||
|
RPL_HELLO,
|
||||||
|
RPL_WELCOME,
|
||||||
|
)
|
||||||
from irctest.patma import ANYDICT
|
from irctest.patma import ANYDICT
|
||||||
|
|
||||||
|
|
||||||
@ -57,6 +60,12 @@ class RegressionsTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
@cases.mark_capabilities("message-tags", "batch", "echo-message", "server-time")
|
@cases.mark_capabilities("message-tags", "batch", "echo-message", "server-time")
|
||||||
def testTagCap(self):
|
def testTagCap(self):
|
||||||
|
if self.controller.software_name == "UnrealIRCd":
|
||||||
|
raise runner.ImplementationChoice(
|
||||||
|
"Arbitrary +draft/reply values (TODO: adapt this test to use real "
|
||||||
|
"values so their pass Unreal's validation) "
|
||||||
|
"https://bugs.unrealircd.org/view.php?id=5948"
|
||||||
|
)
|
||||||
# regression test for oragono #754
|
# regression test for oragono #754
|
||||||
self.connectClient(
|
self.connectClient(
|
||||||
"alice",
|
"alice",
|
||||||
@ -99,13 +108,13 @@ class RegressionsTestCase(cases.BaseServerTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("RFC1459")
|
@cases.mark_specifications("RFC1459")
|
||||||
|
@cases.xfailIfSoftware(["ngIRCd"], "wat")
|
||||||
def testStarNick(self):
|
def testStarNick(self):
|
||||||
self.addClient(1)
|
self.addClient(1)
|
||||||
self.sendLine(1, "NICK *")
|
self.sendLine(1, "NICK *")
|
||||||
self.sendLine(1, "USER u s e r")
|
self.sendLine(1, "USER u s e r")
|
||||||
replies = {"NOTICE"}
|
replies = {"NOTICE"}
|
||||||
time.sleep(2) # give time to slow servers, like irc2 to reply
|
while replies <= {"NOTICE", RPL_HELLO}:
|
||||||
while replies == {"NOTICE"}:
|
|
||||||
replies = set(msg.command for msg in self.getMessages(1, synchronize=False))
|
replies = set(msg.command for msg in self.getMessages(1, synchronize=False))
|
||||||
self.assertIn(ERR_ERRONEUSNICKNAME, replies)
|
self.assertIn(ERR_ERRONEUSNICKNAME, replies)
|
||||||
self.assertNotIn(RPL_WELCOME, replies)
|
self.assertNotIn(RPL_WELCOME, replies)
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
RELAYMSG command of `Ergo <https://ergo.chat/>`_
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.irc_utils.junkdrawer import random_name
|
from irctest.irc_utils.junkdrawer import random_name
|
||||||
from irctest.patma import ANYSTR
|
from irctest.patma import ANYSTR
|
||||||
|
@ -1,3 +1,7 @@
|
|||||||
|
"""
|
||||||
|
Roleplay features of `Ergo <https://ergo.chat/>`_
|
||||||
|
"""
|
||||||
|
|
||||||
from irctest import cases
|
from irctest import cases
|
||||||
from irctest.irc_utils.junkdrawer import random_name
|
from irctest.irc_utils.junkdrawer import random_name
|
||||||
from irctest.numerics import ERR_CANNOTSENDRP
|
from irctest.numerics import ERR_CANNOTSENDRP
|
||||||
|
@ -12,9 +12,9 @@ class RegistrationTestCase(cases.BaseServerTestCase):
|
|||||||
|
|
||||||
|
|
||||||
@cases.mark_services
|
@cases.mark_services
|
||||||
class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
class SaslTestCase(cases.BaseServerTestCase):
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
def testPlain(self):
|
def testPlain(self):
|
||||||
"""PLAIN authentication with correct username/password."""
|
"""PLAIN authentication with correct username/password."""
|
||||||
self.controller.registerUser(self, "foo", "sesame")
|
self.controller.registerUser(self, "foo", "sesame")
|
||||||
@ -54,7 +54,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
def testPlainNonAscii(self):
|
def testPlainNonAscii(self):
|
||||||
password = "é" * 100
|
password = "é" * 100
|
||||||
authstring = base64.b64encode(
|
authstring = base64.b64encode(
|
||||||
@ -82,7 +82,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
def testPlainNoAuthzid(self):
|
def testPlainNoAuthzid(self):
|
||||||
"""“message = [authzid] UTF8NUL authcid UTF8NUL passwd
|
"""“message = [authzid] UTF8NUL authcid UTF8NUL passwd
|
||||||
|
|
||||||
@ -170,7 +170,22 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
@cases.xfailIf(
|
||||||
|
lambda self: (
|
||||||
|
self.controller.services_controller is not None
|
||||||
|
and self.controller.services_controller.software_name == "Anope"
|
||||||
|
),
|
||||||
|
"Anope does not handle split AUTHENTICATE (reported on IRC)",
|
||||||
|
)
|
||||||
|
@cases.xfailIf(
|
||||||
|
lambda self: (
|
||||||
|
self.controller.services_controller is not None
|
||||||
|
and self.controller.services_controller.software_name == "Dlk-Services"
|
||||||
|
),
|
||||||
|
"Dlk does not handle split AUTHENTICATE "
|
||||||
|
"https://github.com/DalekIRC/Dalek-Services/issues/28",
|
||||||
|
)
|
||||||
def testPlainLarge(self):
|
def testPlainLarge(self):
|
||||||
"""Test the client splits large AUTHENTICATE messages whose payload
|
"""Test the client splits large AUTHENTICATE messages whose payload
|
||||||
is not a multiple of 400.
|
is not a multiple of 400.
|
||||||
@ -232,7 +247,14 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
# message's length too big for it to be valid.
|
# message's length too big for it to be valid.
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
|
@cases.skipUnlessHasMechanism("PLAIN")
|
||||||
|
@cases.xfailIf(
|
||||||
|
lambda self: (
|
||||||
|
self.controller.services_controller is not None
|
||||||
|
and self.controller.services_controller.software_name == "Anope"
|
||||||
|
),
|
||||||
|
"Anope does not handle split AUTHENTICATE (reported on IRC)",
|
||||||
|
)
|
||||||
def testPlainLargeEquals400(self):
|
def testPlainLargeEquals400(self):
|
||||||
"""Test the client splits large AUTHENTICATE messages whose payload
|
"""Test the client splits large AUTHENTICATE messages whose payload
|
||||||
is not a multiple of 400.
|
is not a multiple of 400.
|
||||||
@ -277,7 +299,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
# message's length too big for it to be valid.
|
# message's length too big for it to be valid.
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
|
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
|
||||||
def testScramSha256Success(self):
|
def testScramSha256Success(self):
|
||||||
self.controller.registerUser(self, "Scramtest", "sesame")
|
self.controller.registerUser(self, "Scramtest", "sesame")
|
||||||
|
|
||||||
@ -333,7 +355,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
|
|||||||
self.confirmSuccessfulAuth()
|
self.confirmSuccessfulAuth()
|
||||||
|
|
||||||
@cases.mark_specifications("IRCv3")
|
@cases.mark_specifications("IRCv3")
|
||||||
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
|
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
|
||||||
def testScramSha256Failure(self):
|
def testScramSha256Failure(self):
|
||||||
self.controller.registerUser(self, "Scramtest", "sesame")
|
self.controller.registerUser(self, "Scramtest", "sesame")
|
||||||
|
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user