Merge branch 'master' into named-modes

This commit is contained in:
Valentin Lorentz 2023-09-24 11:48:38 +02:00
commit 302dd33990
120 changed files with 8234 additions and 1875 deletions

120
.github/deploy_to_netlify.py vendored Executable file
View 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()

File diff suppressed because it is too large Load Diff

View File

@ -3,28 +3,32 @@
jobs:
build-anope:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- name: Cache Anope
uses: actions/cache@v2
- name: Cache dependencies
uses: actions/cache@v3
with:
key: 3-${{ runner.os }}-anope-2.0.9
key: 3-${{ runner.os }}-anope-devel_release
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
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
path: anope
ref: 2.0.9
repository: anope/anope
- name: Build Anope
run: |-
run: |
cd $GITHUB_WORKSPACE/anope/
cp $GITHUB_WORKSPACE/data/anope/* .
CFLAGS=-O0 ./Config -quick
@ -33,23 +37,23 @@ jobs:
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: installed-anope
path: ~/artefacts-*.tar.gz
retention-days: 1
build-inspircd:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Checkout InspIRCd
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
path: inspircd
ref: insp3
@ -57,155 +61,158 @@ jobs:
- name: Build InspIRCd
run: |
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
make -j 4
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: installed-inspircd
path: ~/artefacts-*.tar.gz
retention-days: 1
publish-test-results:
if: success() || failure()
name: Publish Unit Tests Results
name: Publish Dashboard
needs:
- test-inspircd
- test-inspircd-anope
- test-inspircd-atheme
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Download Artifacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
path: artifacts
- if: github.event_name == 'pull_request'
name: Publish Unit Test Results
uses: actions/github-script@v4
with:
result-encoding: string
script: |
let body = '';
const options = {};
options.listeners = {
stdout: (data) => {
body += data.toString();
}
};
await exec.exec('bash', ['-c', 'shopt -s globstar; python3 report.py artifacts/**/*.xml'], options);
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
return body;
- name: Install dashboard dependencies
run: |-
python -m pip install --upgrade pip
pip install defusedxml docutils -r requirements.txt
- name: Generate dashboard
run: |-
shopt -s globstar
python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml
echo '/ /index.xhtml' > dashboard/_redirects
- name: Install netlify-cli
run: npm i -g netlify-cli
- env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
name: Deploy to Netlify
run: ./.github/deploy_to_netlify.py
test-inspircd:
needs:
- build-inspircd
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-inspircd
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install Atheme
run: sudo apt-get install atheme-services
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$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
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: pytest results inspircd (devel_release)
name: pytest-results_inspircd_devel_release
path: pytest.xml
test-inspircd-anope:
needs:
- build-inspircd
- build-anope
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-inspircd
path: '~'
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-anope
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install Atheme
run: sudo apt-get install atheme-services
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$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
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: pytest results inspircd-anope (devel_release)
name: pytest-results_inspircd-anope_devel_release
path: pytest.xml
test-inspircd-atheme:
needs:
- build-inspircd
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-inspircd
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install Atheme
run: sudo apt-get install atheme-services
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$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
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: pytest results inspircd-atheme (devel_release)
name: pytest-results_inspircd-atheme_devel_release
path: pytest.xml
name: irctest with devel_release versions
'on':

File diff suppressed because it is too large Load Diff

View File

@ -2,22 +2,23 @@ exclude: ^irctest/scram
repos:
- repo: https://github.com/psf/black
rev: 20.8b1
rev: 23.1.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/PyCQA/isort
rev: 5.5.2
rev: 5.11.5
hooks:
- id: isort
- repo: https://gitlab.com/pycqa/flake8
rev: 3.8.3
- repo: https://github.com/PyCQA/flake8
rev: 5.0.4
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.812
rev: v1.0.1
hooks:
- id: mypy
additional_dependencies: [types-PyYAML, types-docutils]

165
Makefile
View File

@ -7,108 +7,56 @@ PYTEST_ARGS ?=
# Will be appended at the end of the -k argument to pytest
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
# mask tests in test_who.py fail because they are not implemented.
# some HelpTestCase::*[HELP] tests fail because Bahamut forwards /HELP to HelpServ (but not /HELPOP)
BAHAMUT_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not IRCv3 \
and not buffering \
and not (testWho and not whois and mask) \
and not testWhoStar \
and (not HelpTestCase or HELPOP) \
$(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 := \
not Ergo \
and not deprecated \
and not strict \
and not testQuitErrors \
and not testKickDefaultComment \
and not (AccountTagTestCase and testInvite) \
and not (testWhoisNumerics and oper) \
$(EXTRA_SELECTORS)
# testInfoNosuchserver does not apply to Ergo: Ergo ignores the optional <target> argument
ERGO_SELECTORS := \
not deprecated \
and not testInfoNosuchserver \
$(EXTRA_SELECTORS)
# testInviteUnoppedModern is the only strict test that Hybrid fails
HYBRID_SELECTORS := \
not Ergo \
and not testInviteUnoppedModern \
and not deprecated \
$(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 := \
not Ergo \
and not deprecated \
and not strict \
and not testNoticeNonexistentChannel \
and not testBotPrivateMessage and not testBotChannelMessage \
and not testNamesInvalidChannel and not testNamesNonexistingChannel \
$(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 "full" tests fail because they depend on Modern behavior, not just RFC2812
# 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.
# HelpTestCase fails because it returns NOTICEs instead of numerics
IRCU2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not buffering \
and not testQuit \
and not (lusers and full) \
and not statusmsg \
and not (testKeyValidation and empty) \
and not testKickDefaultComment \
and not testEmptyRealname \
and not HelpTestCase \
$(EXTRA_SELECTORS)
# same justification as ircu2
NEFARIOUS_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
SNIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not buffering \
and not testQuit \
and not (lusers and full) \
and not statusmsg \
$(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
# HelpTestCase fails because it returns NOTICEs instead of numerics
IRC2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not testListEmpty and not testListOne \
and not testKickDefaultComment \
and not testWallopsPrivileges \
and not HelpTestCase \
$(EXTRA_SELECTORS)
MAMMON_SELECTORS := \
@ -117,28 +65,14 @@ MAMMON_SELECTORS := \
and not strict \
$(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
# HelpTestCase::*[HELP] fails because it returns NOTICEs instead of numerics
NGIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not (testKeyValidation and (spaces or empty)) \
and not testStarNick \
and not testEmptyRealname \
and not chathistory \
and (not HelpTestCase or HELPOP) \
$(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 := \
not Ergo \
and not testInviteUnoppedModern \
and not testInviteInviteOnlyModern \
and not deprecated \
$(EXTRA_SELECTORS)
@ -149,51 +83,47 @@ LIMNORIA_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# testQuitErrors is too flaky for CI
# testKickDefaultComment fails because solanum uses the nick of the kickee rather than the kicker.
SABLE_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not whowas and not list and not lusers and not userhost and not time and not info \
$(EXTRA_SELECTORS)
SOLANUM_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not testQuitErrors \
and not testKickDefaultComment \
$(EXTRA_SELECTORS)
# Same as Limnoria
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)
# 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 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
# testChathistory[BETWEEN] fails: https://bugs.unrealircd.org/view.php?id=5952
# testChathistory[AROUND] fails: https://bugs.unrealircd.org/view.php?id=5953
# testWhoAllOpers fails because Unreal skips results when the mask is too broad
# HELP and HELPOP tests fail because Unreal uses custom numerics https://github.com/unrealircd/unrealircd/pull/184
UNREALIRCD_SELECTORS := \
not Ergo \
and not deprecated \
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 react_tag \
and not private_chathistory \
and not (testChathistory and (between or around)) \
and not testWhoAllOpers \
and not HelpTestCase \
$(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:
find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3
@ -202,7 +132,8 @@ bahamut:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
-m 'not services' \
-n 10 \
-n 4 \
-vv -s \
-k '$(BAHAMUT_SELECTORS)'
bahamut-atheme:
@ -210,7 +141,6 @@ bahamut-atheme:
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-n 10 \
-k '$(BAHAMUT_SELECTORS)'
bahamut-anope:
@ -218,8 +148,7 @@ bahamut-anope:
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-n 10 \
-k '$(BAHAMUT_SELECTORS) $(ANOPE_SELECTORS)'
-k '$(BAHAMUT_SELECTORS)'
charybdis:
$(PYTEST) $(PYTEST_ARGS) \
@ -256,27 +185,34 @@ inspircd-anope:
--controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k '$(INSPIRCD_SELECTORS) $(ANOPE_SELECTORS)'
-k '$(INSPIRCD_SELECTORS)'
ircu2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.ircu2 \
-m 'not services and not IRCv3' \
-n 10 \
-n 4 \
-k '$(IRCU2_SELECTORS)'
nefarious:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.nefarious \
-m 'not services' \
-n 4 \
-k '$(NEFARIOUS_SELECTORS)'
snircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.snircd \
-m 'not services and not IRCv3' \
-n 10 \
-n 4 \
-k '$(SNIRCD_SELECTORS)'
irc2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.irc2 \
-m 'not services and not IRCv3' \
-n 10 \
-n 4 \
-k '$(IRC2_SELECTORS)'
limnoria:
@ -299,7 +235,7 @@ ngircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
-m 'not services' \
-n 10 \
-n 4 \
-k "$(NGIRCD_SELECTORS)"
ngircd-anope:
@ -316,6 +252,13 @@ ngircd-atheme:
-m 'services' \
-k "$(NGIRCD_SELECTORS)"
sable:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sable \
-n 20 \
-m 'not services' \
-k '$(SABLE_SELECTORS)'
solanum:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.solanum \
@ -327,6 +270,11 @@ sopel:
--controller=irctest.controllers.sopel \
-k '$(SOPEL_SELECTORS)'
thelounge:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.thelounge \
-k '$(THELOUNGE_SELECTORS)'
unrealircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
@ -347,4 +295,11 @@ unrealircd-anope:
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.anope_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)'

View File

@ -18,11 +18,11 @@ have no side effect.
Install irctest and dependencies:
```
sudo apt install faketime # Optional, but greatly speeds up irctest/server_tests/list.py
cd ~
git clone https://github.com/ProgVal/irctest.git
cd irctest
pip3 install --user -r requirements.txt
python3 setup.py install --user
```
Add `~/.local/bin/` (and/or `~/go/bin/` for Ergo)
@ -110,8 +110,11 @@ cd /tmp/
git clone https://github.com/inspircd/inspircd.git
cd inspircd
# optional, makes tests run considerably faster
patch src/inspircd.cpp < ~/irctest/inspircd_mainloop.patch
# Optional, makes tests run considerably faster. Pick one depending on the InspIRCd version:
# 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

View File

@ -106,13 +106,10 @@ def pytest_collection_modifyitems(session, config, items):
assert isinstance(item, _pytest.python.Function)
# unittest-style test functions have the node of UnitTest class as parent
assert isinstance(
item.parent,
(
_pytest.python.Class, # pytest >= 7.0.0
_pytest.python.Instance, # pytest < 7.0.0
),
)
if tuple(map(int, _pytest.__version__.split("."))) >= (7,):
assert isinstance(item.parent, _pytest.python.Class)
else:
assert isinstance(item.parent, _pytest.python.Instance)
# and that node references the UnitTest class
assert issubclass(item.parent.cls, _IrcTestCase)

83
data/nefarious/ircd.pem Normal file
View 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-----

View File

@ -19,6 +19,10 @@ SHOWLISTMODES="1"
NOOPEROVERRIDE=""
OPEROVERRIDEVERIFY=""
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=""

View File

@ -1,18 +1,23 @@
from __future__ import annotations
import contextlib
import dataclasses
import json
import os
from pathlib import Path
import shutil
import socket
import subprocess
import tempfile
import textwrap
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
from . import authentication, tls
from .client_mock import ClientMock
from .irc_utils.filelock import FileLock
from .irc_utils.junkdrawer import find_hostname_and_port
from .irc_utils.message_parser import Message
from .runner import NotImplementedByController
@ -54,17 +59,47 @@ class _BaseController:
supports_sts: bool
supported_sasl_mechanisms: Set[str]
proc: Optional[subprocess.Popen]
_used_ports_path = Path(tempfile.gettempdir()) / "irctest_ports.json"
_port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock")
def __init__(self, test_config: TestCaseControllerConfig):
self.test_config = test_config
self.proc = None
self._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:
assert self.proc
self.proc.poll()
if self.proc.returncode is not None:
raise ProcessStopped()
raise ProcessStopped(f"process returned {self.proc.returncode}")
def kill_proc(self) -> None:
"""Terminates the controlled process, waits for it to exit, and
@ -82,12 +117,17 @@ class _BaseController:
if self.proc:
self.kill_proc()
with self._used_ports() as used_ports:
for hostname, port in list(self._own_ports):
used_ports.remove((hostname, port))
self._own_ports.remove((hostname, port))
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
arbitrary directory."""
directory: Optional[str]
directory: Optional[Path]
def __init__(self, test_config: TestCaseControllerConfig):
super().__init__(test_config)
@ -110,22 +150,21 @@ class DirectoryBasedController(_BaseController):
"""Open a file in the configuration directory."""
assert self.directory
if os.sep in name:
dir_ = os.path.join(self.directory, os.path.dirname(name))
if not os.path.isdir(dir_):
os.makedirs(dir_)
assert os.path.isdir(dir_)
return open(os.path.join(self.directory, name), mode)
dir_ = self.directory / os.path.dirname(name)
dir_.mkdir(parents=True, exist_ok=True)
assert dir_.is_dir()
return (self.directory / name).open(mode)
def create_config(self) -> None:
if not self.directory:
self.directory = tempfile.mkdtemp()
self.directory = Path(tempfile.mkdtemp())
def gen_ssl(self) -> None:
assert self.directory
self.csr_path = os.path.join(self.directory, "ssl.csr")
self.key_path = os.path.join(self.directory, "ssl.key")
self.pem_path = os.path.join(self.directory, "ssl.pem")
self.dh_path = os.path.join(self.directory, "dh.pem")
self.csr_path = self.directory / "ssl.csr"
self.key_path = self.directory / "ssl.key"
self.pem_path = self.directory / "ssl.pem"
self.dh_path = self.directory / "dh.pem"
subprocess.check_output(
[
self.openssl_bin,
@ -156,10 +195,18 @@ class DirectoryBasedController(_BaseController):
],
stderr=subprocess.DEVNULL,
)
subprocess.check_output(
[self.openssl_bin, "dhparam", "-out", self.dh_path, "128"],
stderr=subprocess.DEVNULL,
)
with self.dh_path.open("w") as fd:
fd.write(
textwrap.dedent(
"""
-----BEGIN DH PARAMETERS-----
MIGHAoGBAJICSyQAiLj1fw8b5xELcnpqBQ+wvOyKgim4IetWOgZnRQFkTgOeoRZD
HksACRFJL/EqHxDKcy/2Ghwr2axhNxSJ+UOBmraP3WfodV/fCDPnZ+XnI9fjHsIr
rjisPMqomjXeiTB1UeAHvLUmCK4yx6lpAJsCYwJjsqkycUfHiy1bAgEC
-----END DH PARAMETERS-----
"""
)
)
class BaseClientController(_BaseController):
@ -188,9 +235,16 @@ class BaseServerController(_BaseController):
extban_mute_char: Optional[str] = None
"""Character used for the 'mute' extban"""
nickserv = "NickServ"
sync_sleep_time = 0.0
"""How many seconds to sleep before clients synchronously get messages.
def get_hostname_and_port(self) -> Tuple[str, int]:
return find_hostname_and_port()
This can be 0 for servers answering all commands in order (all but Sable as of
this writing), as irctest emits a PING, waits for a PONG, and captures all messages
between the two."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.faketime_enabled = False
def run(
self,
@ -200,8 +254,7 @@ class BaseServerController(_BaseController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]],
invalid_metadata_keys: Optional[Set[str]],
faketime: Optional[str],
) -> None:
raise NotImplementedError()
@ -217,6 +270,7 @@ class BaseServerController(_BaseController):
raise NotImplementedByController("account registration")
def wait_for_port(self) -> None:
started_at = time.time()
while not self.port_open:
self.check_is_alive()
time.sleep(self._port_wait_interval)
@ -235,15 +289,20 @@ class BaseServerController(_BaseController):
time.sleep(0.01)
c.send(b" ") # Triggers BrokenPipeError
except BrokenPipeError:
except (BrokenPipeError, ConnectionResetError):
# ircu2 cuts the connection without a message if registration
# is not complete.
pass
except socket.timeout:
# irc2 just keeps it open
pass
c.close()
self.port_open = True
except Exception:
continue
except ConnectionRefusedError:
if time.time() - started_at >= 60:
# waited for 60 seconds, giving up
raise
def wait_for_services(self) -> None:
assert self.services_controller
@ -284,25 +343,36 @@ class BaseServicesController(_BaseController):
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkNS")
c.sendLine("USER chk chk chk chk")
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
# Hi Unreal
c.sendLine("PONG :" + msg.params[0])
c.getMessages()
time.sleep(self.server_controller.sync_sleep_time)
got_end_of_motd = False
while not got_end_of_motd:
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
# Hi Unreal
c.sendLine("PONG :" + msg.params[0])
if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD
got_end_of_motd = True
timeout = time.time() + 5
timeout = time.time() + 10
while True:
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :HELP")
msgs = self.getNickServResponse(c)
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :help")
msgs = self.getNickServResponse(c, timeout=1)
for msg in msgs:
if msg.command == "401":
# NickServ not available yet
pass
elif msg.command in ("MODE", "221"): # RPL_UMODEIS
pass
elif msg.command == "NOTICE":
# NickServ is available
assert "nickserv" in (msg.prefix or "").lower(), msg
print("breaking")
break
assert msg.prefix is not None
if "!" not in msg.prefix and "." in msg.prefix:
# Server notice
pass
else:
# NickServ is available
assert "nickserv" in (msg.prefix or "").lower(), msg
break
else:
assert False, f"unexpected reply from NickServ: {msg}"
else:
@ -319,11 +389,12 @@ class BaseServicesController(_BaseController):
c.disconnect()
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
is queried asynchronously."""
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)
msgs = client.getMessages()
return msgs

View File

@ -69,6 +69,30 @@ TController = TypeVar("TController", bound=basecontrollers._BaseController)
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):
def __init__(self, code: str, params: List[str]):
super().__init__(f"Failed to join channel ({code}): {params}")
@ -149,7 +173,7 @@ class _IrcTestCase(Generic[TController]):
) -> Optional[str]:
"""Returns an error message if the message doesn't match the given arguments,
or None if it matches."""
for (key, value) in kwargs.items():
for key, value in kwargs.items():
if getattr(msg, key) != value:
fail_msg = (
fail_msg or "expected {param} to be {expects}, got {got}: {msg}"
@ -327,8 +351,8 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]):
nick: Optional[str] = None
user: Optional[List[str]] = None
server: socket.socket
protocol_version = Optional[str]
acked_capabilities = Optional[Set[str]]
protocol_version: Optional[str]
acked_capabilities: Optional[Set[str]]
__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()))
def readCapLs(
self, auth: Optional[Authentication] = None, tls_config: tls.TlsConfig = None
self,
auth: Optional[Authentication] = None,
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
(hostname, port) = self.server.getsockname()
self.controller.run(
@ -434,9 +460,9 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]):
m = self.getMessage()
self.assertEqual(m.command, "CAP", "First message is not CAP LS.")
if m.params == ["LS"]:
self.protocol_version = 301
self.protocol_version = "301"
elif m.params == ["LS", "302"]:
self.protocol_version = 302
self.protocol_version = "302"
elif m.params == ["END"]:
self.protocol_version = None
else:
@ -503,11 +529,15 @@ class BaseServerTestCase(
password: Optional[str] = None
ssl = False
valid_metadata_keys: Set[str] = set()
invalid_metadata_keys: Set[str] = set()
server_support: Optional[Dict[str, Optional[str]]]
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
def setUp(self) -> None:
@ -518,10 +548,9 @@ class BaseServerTestCase(
self.hostname,
self.port,
password=self.password,
valid_metadata_keys=self.valid_metadata_keys,
invalid_metadata_keys=self.invalid_metadata_keys,
ssl=self.ssl,
run_services=self.run_services,
faketime=self.faketime,
)
self.clients: Dict[TClientName, client_mock.ClientMock] = {}
@ -539,13 +568,10 @@ class BaseServerTestCase(
if self.run_services:
self.controller.wait_for_services()
if not name:
new_name: int = (
max(
[int(name) for name in self.clients if isinstance(name, (int, str))]
+ [0]
)
+ 1
)
used_ids: List[int] = [
int(name) for name in self.clients if isinstance(name, (int, str))
]
new_name = max(used_ids + [0]) + 1
name = cast(TClientName, new_name)
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)
@ -559,9 +585,13 @@ class BaseServerTestCase(
del self.clients[name]
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)
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)
def getRegistrationMessage(self, client: TClientName) -> Message:
@ -647,10 +677,21 @@ class BaseServerTestCase(
else:
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(
self,
nick: str,
name: TClientName = None,
name: Optional[TClientName] = None,
capabilities: Optional[List[str]] = None,
skip_if_cap_nak: bool = False,
show_io: Optional[bool] = None,
@ -665,17 +706,12 @@ class BaseServerTestCase(
client = self.addClient(name, show_io=show_io)
if capabilities:
self.sendLine(client, "CAP LS 302")
m = self.getRegistrationMessage(client)
self.getCapLs(client)
self.requestCapabilities(client, capabilities, skip_if_cap_nak)
if password is not None:
if "sasl" not in (capabilities or ()):
raise ValueError("Used 'password' option without sasl capbilitiy")
self.sendLine(client, "AUTHENTICATE PLAIN")
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.authenticateClient(client, account or nick, password)
self.sendLine(client, "NICK {}".format(nick))
self.sendLine(client, "USER %s * * :Realname" % (ident,))
@ -700,6 +736,12 @@ class BaseServerTestCase(
self.server_support[param] = None
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
def joinClient(self, client: TClientName, channel: str) -> None:
@ -730,50 +772,58 @@ class BaseServerTestCase(
raise ChannelJoinException(msg.command, msg.params)
_TSelf = TypeVar("_TSelf", bound="OptionalityHelper")
_TSelf = TypeVar("_TSelf", bound="_IrcTestCase")
_TReturn = TypeVar("_TReturn")
class OptionalityHelper(Generic[TController]):
controller: TController
def checkSaslSupport(self) -> None:
if self.controller.supported_sasl_mechanisms:
return
raise runner.NotImplementedByController("SASL")
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]:
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.checkSaslSupport()
if mech not in self.controller.supported_sasl_mechanisms:
raise runner.OptionalSaslMechanismNotSupported(mech)
return f(self, *args, **kwargs)
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:
cls.run_services = True

View File

@ -1,3 +1,5 @@
"""Format of ``CAP LS`` sent by IRCv3 clients."""
from irctest import cases
from irctest.irc_utils.message_parser import Message

View File

@ -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 pytest
@ -34,8 +39,8 @@ class IdentityHash:
return self._data
class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
class SaslTestCase(cases.BaseClientTestCase):
@cases.skipUnlessHasMechanism("PLAIN")
def testPlain(self):
"""Test PLAIN authentication with correct username/password."""
auth = authentication.Authentication(
@ -55,7 +60,8 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
m = self.negotiateCapabilities(["sasl"], False)
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):
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
to use PLAIN.
@ -84,8 +90,9 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
m = self.getMessage()
self.assertMessageMatch(m, command="CAP")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testPlainLarge(self):
@pytest.mark.parametrize("pattern", ["barbaz", "éèà"])
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainLarge(self, pattern):
"""Test the client splits large AUTHENTICATE messages whose payload
is not a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
@ -94,10 +101,10 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="foo",
password="bar" * 200,
password=pattern * 100,
)
authstring = base64.b64encode(
b"\x00".join([b"foo", b"foo", b"bar" * 200])
b"\x00".join([b"foo", b"foo", pattern.encode() * 100])
).decode()
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
@ -113,8 +120,9 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testPlainLargeMultiple(self):
@cases.skipUnlessHasMechanism("PLAIN")
@pytest.mark.parametrize("pattern", ["quux", "éè"])
def testPlainLargeMultiple(self, pattern):
"""Test the client splits large AUTHENTICATE messages whose payload
is a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
@ -123,10 +131,10 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="foo",
password="quux" * 148,
password=pattern * 148,
)
authstring = base64.b64encode(
b"\x00".join([b"foo", b"foo", b"quux" * 148])
b"\x00".join([b"foo", b"foo", pattern.encode() * 148])
).decode()
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
@ -143,7 +151,7 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@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):
"""Test ECDSA authentication."""
auth = authentication.Authentication(
@ -177,7 +185,7 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScram(self):
"""Test SCRAM-SHA-256 authentication."""
auth = authentication.Authentication(
@ -219,8 +227,8 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
self.assertEqual(m.command, "AUTHENTICATE", m)
self.assertEqual(m.params, ["+"], m)
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScramBadPassword(self):
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScramBadPassword(self, server_fakes_success=False, fake_response=None):
"""Test SCRAM-SHA-256 authentication with a bad password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
@ -253,9 +261,39 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
with self.assertRaises(scram.NotAuthorizedException):
authenticator.response(msg)
if server_fakes_success:
self.sendLine(f"AUTHENTICATE :{fake_response}")
class Irc302SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
m = self.getMessage()
while m.command == "PING":
self.sendLine(f"PONG server. {m.params[-1]}")
m = self.getMessage()
self.assertMessageMatch(
m,
command="AUTHENTICATE",
params=["*"],
fail_msg="Client did not abort: {msg}",
)
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
@pytest.mark.parametrize(
"fake_response",
[
"",
"AAAA",
"dj1ubU1mM1FIV2NKUWk5cE1ndHFLU0tQclZueUk2c3FOTzZJN3BFLzBveUdjPQ==",
],
)
def testScramMaliciousServer(self, fake_response):
"""Test SCRAM-SHA-256 authentication to a server which pretends to know
the password"""
self.testScramBadPassword(
server_fakes_success=True, fake_response=fake_response
)
class Irc302SaslTestCase(cases.BaseClientTestCase):
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainNotAvailable(self):
"""Test the client does not try to authenticate using a mechanism the
server does not advertise.

View File

@ -1,3 +1,5 @@
"""Clients should validate certificates; either with a CA or fingerprints."""
import socket
import ssl
@ -138,7 +140,7 @@ class TlsTestCase(cases.BaseClientTestCase):
self.getMessage()
class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
class StsTestCase(cases.BaseClientTestCase):
def setUp(self):
super().setUp()
self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

View File

@ -1,4 +1,4 @@
import os
from pathlib import Path
import shutil
import subprocess
from typing import Type
@ -73,6 +73,8 @@ module {{ name = "ns_cert" }}
class AnopeController(BaseServicesController, DirectoryBasedController):
"""Collaborator for server controllers that rely on Anope"""
software_name = "Anope"
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
self.create_config()
@ -99,14 +101,11 @@ class AnopeController(BaseServicesController, DirectoryBasedController):
pass
assert self.directory
services_path = shutil.which("services")
assert services_path
# Config and code need to be in the same directory, *obviously*
os.symlink(
os.path.join(
os.path.dirname(shutil.which("services")), "..", "lib" # type: ignore
),
os.path.join(self.directory, "lib"),
)
(self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib")
self.proc = subprocess.Popen(
[

View File

@ -1,4 +1,3 @@
import os
import subprocess
from typing import Optional, Type
@ -56,6 +55,8 @@ saslserv {{
class AthemeController(BaseServicesController, DirectoryBasedController):
"""Mixin for server controllers that rely on Atheme"""
software_name = "Atheme"
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
self.create_config()
@ -79,11 +80,11 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
"atheme-services",
"-n", # don't fork
"-c",
os.path.join(self.directory, "services.conf"),
self.directory / "services.conf",
"-l",
f"/tmp/services-{server_port}.log",
"-p",
os.path.join(self.directory, "services.pid"),
self.directory / "services.pid",
"-D",
self.directory,
],

View File

@ -1,14 +1,9 @@
import os
from pathlib import Path
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
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):
software_name = "Bahamut"
supported_sasl_mechanisms: Set[str] = set()
@ -99,20 +107,14 @@ class BahamutController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(unused_hostname, unused_port) = find_hostname_and_port()
(services_hostname, services_port) = find_hostname_and_port()
(unused_hostname, unused_port) = self.get_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port()
password_field = "passwd {};".format(password) if password else ""
@ -120,9 +122,14 @@ class BahamutController(BaseServerController, DirectoryBasedController):
assert self.directory
# Bahamut reads some bytes from /dev/urandom on startup, which causes
# GitHub Actions to sometimes freeze and timeout.
# This initializes the entropy file so Bahamut does not need to do it itself.
initialize_entropy(self.directory)
# they are hardcoded... thankfully Bahamut reads them from the CWD.
shutil.copy(self.pem_path, os.path.join(self.directory, "ircd.crt"))
shutil.copy(self.key_path, os.path.join(self.directory, "ircd.key"))
shutil.copy(self.pem_path, self.directory / "ircd.crt")
shutil.copy(self.key_path, self.directory / "ircd.key")
with self.open_file("server.conf") as fd:
fd.write(
@ -136,15 +143,21 @@ class BahamutController(BaseServerController, DirectoryBasedController):
# 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(
[
# "strace", "-f", "-e", "file",
*faketime_cmd,
"ircd",
"-t", # don't fork
"-f",
os.path.join(self.directory, "server.conf"),
self.directory / "server.conf",
],
# stdout=subprocess.DEVNULL,
)
if run_services:

View File

@ -1,13 +1,8 @@
import os
import shutil
import subprocess
from typing import Optional, Set
from typing import Optional
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_SSL_CONFIG = """
ssl_private_key = "{key_path}";
@ -41,18 +36,13 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
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 ""
if ssl:
self.gen_ssl()
@ -73,14 +63,22 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
)
)
assert self.directory
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
self.binary_name,
"-foreground",
"-configfile",
os.path.join(self.directory, "server.conf"),
self.directory / "server.conf",
"-pidfile",
os.path.join(self.directory, "server.pid"),
self.directory / "server.pid",
],
# stderr=subprocess.DEVNULL,
)

View File

@ -12,6 +12,9 @@ serverinfo {{
general {{
throttle_count = 100; # We need to connect lots of clients quickly
# disable throttling for LIST and similar:
pace_wait_simple = 0 second;
pace_wait = 0 second;
sasl_service = "SaslServ";
}};

View 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

View File

@ -1,14 +1,11 @@
import copy
import json
import os
import shutil
import subprocess
from typing import Any, Dict, Optional, Set, Type, Union
from typing import Any, Dict, Optional, Type, Union
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
from irctest.cases import BaseServerTestCase
BASE_CONFIG = {
@ -129,7 +126,7 @@ def hash_password(password: Union[str, bytes]) -> str:
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
out, _ = p.communicate(input_)
return out.decode("utf-8")
return out.decode("utf-8").strip()
class ErgoController(BaseServerController, DirectoryBasedController):
@ -152,16 +149,9 @@ class ErgoController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
config: Optional[Any] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
self.create_config()
if config is None:
config = copy.deepcopy(BASE_CONFIG)
@ -183,27 +173,32 @@ class ErgoController(BaseServerController, DirectoryBasedController):
bind_address = "127.0.0.1:%s" % (port,)
listener_conf = None # plaintext
if ssl:
self.key_path = os.path.join(self.directory, "ssl.key")
self.pem_path = os.path.join(self.directory, "ssl.pem")
self.key_path = self.directory / "ssl.key"
self.pem_path = self.directory / "ssl.pem"
listener_conf = {"tls": {"cert": self.pem_path, "key": self.key_path}}
config["server"]["listeners"][bind_address] = listener_conf # type: ignore
config["datastore"]["path"] = os.path.join( # type: ignore
self.directory, "ircd.db"
)
config["datastore"]["path"] = str(self.directory / "ircd.db") # type: ignore
if password is not None:
config["server"]["password"] = hash_password(password) # type: ignore
assert self.proc is None
self._config_path = os.path.join(self.directory, "server.yml")
self._config_path = self.directory / "server.yml"
self._config = config
self._write_config()
subprocess.call(["ergo", "initdb", "--conf", self._config_path, "--quiet"])
subprocess.call(["ergo", "mkcerts", "--conf", self._config_path, "--quiet"])
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
["ergo", "run", "--conf", self._config_path, "--quiet"]
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
)
def wait_for_services(self) -> None:
@ -216,9 +211,6 @@ class ErgoController(BaseServerController, DirectoryBasedController):
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:
# 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

View File

@ -1,5 +1,5 @@
import os
from typing import Optional, Set, Tuple, Type
from typing import Optional, Tuple, Type
from irctest.basecontrollers import BaseServerController
@ -39,9 +39,7 @@ class ExternalServerController(BaseServerController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
pass

View File

@ -1,13 +1,9 @@
import os
import functools
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
# Clients:
@ -59,8 +55,10 @@ TEMPLATE_CONFIG = """
target="services.example.org">
# Protocol:
<module name="banexception">
<module name="botmode">
<module name="cap">
<module name="inviteexception">
<module name="ircv3">
<module name="ircv3_accounttag">
<module name="ircv3_batch">
@ -76,15 +74,16 @@ TEMPLATE_CONFIG = """
<module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl">
<module name="uhnames"> # For userhost-in-names
# HELP/HELPOP
<module name="alias"> # for the HELP alias
<module name="helpop">
<include file="examples/helpop.conf.example">
<module name="{help_module_name}">
<include file="examples/{help_module_name}.conf.example">
# Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
<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 = """
@ -93,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):
software_name = "InspIRCd"
supported_sasl_mechanisms = {"PLAIN"}
@ -112,19 +122,13 @@ class InspircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
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 ""
@ -136,6 +140,13 @@ class InspircdController(BaseServerController, DirectoryBasedController):
else:
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:
fd.write(
TEMPLATE_CONFIG.format(
@ -145,15 +156,24 @@ class InspircdController(BaseServerController, DirectoryBasedController):
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
help_module_name=help_module_name,
)
)
assert self.directory
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"inspircd",
"--nofork",
"--config",
os.path.join(self.directory, "server.conf"),
self.directory / "server.conf",
],
stdout=subprocess.DEVNULL,
)

View File

@ -1,6 +1,6 @@
import os
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
@ -10,7 +10,7 @@ from irctest.basecontrollers import (
TEMPLATE_CONFIG = """
# 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:Organization, IRC dept.:Daemon <ircd@example.irc.org>:Client Server::IRCnet:
@ -29,8 +29,8 @@ O:*:operpassword:operuser::::
"""
class Ircu2Controller(BaseServerController, DirectoryBasedController):
binary_name: str
class Irc2Controller(BaseServerController, DirectoryBasedController):
software_name = "irc2"
services_protocol: str
supports_sts = False
@ -49,13 +49,8 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:
@ -66,7 +61,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
self.create_config()
password_field = password if password else ""
assert self.directory
pidfile = os.path.join(self.directory, "ircd.pid")
pidfile = self.directory / "ircd.pid"
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
@ -76,18 +71,26 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
pidfile=pidfile,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ircd",
"-s", # no iauth
"-p",
"on",
"-f",
os.path.join(self.directory, "server.conf"),
self.directory / "server.conf",
],
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[Ircu2Controller]:
return Ircu2Controller
def get_irctest_controller_class() -> Type[Irc2Controller]:
return Irc2Controller

View File

@ -1,6 +1,6 @@
import os
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
@ -51,6 +51,7 @@ features {{
class Ircu2Controller(BaseServerController, DirectoryBasedController):
software_name = "ircu2"
supports_sts = False
extban_mute_char = None
@ -67,13 +68,8 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:
@ -84,7 +80,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
self.create_config()
password_field = 'password = "{}";'.format(password) if password else ""
assert self.directory
pidfile = os.path.join(self.directory, "ircd.pid")
pidfile = self.directory / "ircd.pid"
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
@ -94,12 +90,20 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
pidfile=pidfile,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ircd",
"-n", # don't detach
"-f",
os.path.join(self.directory, "server.conf"),
self.directory / "server.conf",
"-x",
"DEBUG",
],

View File

@ -1,4 +1,3 @@
import os
import subprocess
from typing import Optional, Type
@ -55,13 +54,19 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
# Runs a client with the config given as arguments
assert self.proc is None
self.create_config()
username = password = ""
mechanisms = ""
if auth:
mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms)
if auth.ecdsa_key:
with self.open_file("ecdsa_key.pem") as fd:
fd.write(auth.ecdsa_key)
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:
fd.write(
TEMPLATE_CONFIG.format(
@ -69,8 +74,8 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
loglevel="CRITICAL",
hostname=hostname,
port=port,
username=auth.username if auth else "",
password=auth.password if auth else "",
username=username,
password=password,
mechanisms=mechanisms.lower(),
enable_tls=tls_config.enable if tls_config else "False",
trusted_fingerprints=" ".join(tls_config.trusted_fingerprints)
@ -79,9 +84,7 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
)
)
assert self.directory
self.proc = subprocess.Popen(
["supybot", os.path.join(self.directory, "bot.conf")]
)
self.proc = subprocess.Popen(["supybot", self.directory / "bot.conf"])
def get_irctest_controller_class() -> Type[LimnoriaController]:

View File

@ -1,4 +1,4 @@
import os
import shutil
import subprocess
from typing import Optional, Set, Type
@ -33,10 +33,10 @@ extensions:
- mammon.ext.ircv3.sasl
- mammon.ext.misc.nopost
metadata:
restricted_keys:
{restricted_keys}
restricted_keys: []
whitelist:
{authorized_keys}
- display-name
- avatar
monitor:
limit: 20
motd:
@ -89,9 +89,7 @@ class MammonController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if password is not None:
raise NotImplementedByController("PASS command")
@ -106,19 +104,25 @@ class MammonController(BaseServerController, DirectoryBasedController):
directory=self.directory,
hostname=hostname,
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:
# print(fd.read())
assert self.directory
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"mammond",
"--nofork", # '--debug',
"--config",
os.path.join(self.directory, "server.yml"),
self.directory / "server.yml",
]
)

View 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

View File

@ -1,18 +1,13 @@
import os
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
[Global]
Name = My.Little.Server
Info = ExampleNET Server
Info = test server
Bind = {hostname}
Ports = {port}
AdminInfo1 = Bob Smith
@ -26,6 +21,9 @@ TEMPLATE_CONFIG = """
Passive = yes # don't connect to it
ServiceMask = *Serv
[Options]
MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
[Operator]
Name = operuser
Password = operpassword
@ -50,19 +48,13 @@ class NgircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
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 ""
@ -78,6 +70,7 @@ class NgircdController(BaseServerController, DirectoryBasedController):
fd.write("\n")
assert self.directory
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
@ -88,15 +81,23 @@ class NgircdController(BaseServerController, DirectoryBasedController):
password_field=password_field,
key_path=self.key_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(
[
*faketime_cmd,
"ngircd",
"--nodaemon",
"--config",
os.path.join(self.directory, "server.conf"),
self.directory / "server.conf",
],
# stdout=subprocess.DEVNULL,
)

View File

@ -74,7 +74,7 @@ operator {{
class Plexus4Controller(BaseHybridController):
software_name = "Hybrid"
software_name = "Plexus4"
binary_name = "ircd"
services_protocol = "plexus"

View 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

View File

@ -1,6 +1,6 @@
import os
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
@ -67,13 +67,8 @@ class SnircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:
@ -84,7 +79,7 @@ class SnircdController(BaseServerController, DirectoryBasedController):
self.create_config()
password_field = 'password = "{}";'.format(password) if password else ""
assert self.directory
pidfile = os.path.join(self.directory, "ircd.pid")
pidfile = self.directory / "ircd.pid"
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
@ -94,12 +89,20 @@ class SnircdController(BaseServerController, DirectoryBasedController):
pidfile=pidfile,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ircd",
"-n", # don't detach
"-f",
os.path.join(self.directory, "server.conf"),
self.directory / "server.conf",
"-x",
"DEBUG",
],

View File

@ -1,4 +1,4 @@
import os
from pathlib import Path
import subprocess
import tempfile
from typing import Optional, TextIO, Type, cast
@ -38,14 +38,14 @@ class SopelController(BaseClientController):
super().kill()
if self.filename:
try:
os.unlink(os.path.join(os.path.expanduser("~/.sopel/"), self.filename))
except OSError: #  File does not exist
(Path("~/.sopel/").expanduser() / self.filename).unlink()
except OSError: # File does not exist
pass
def open_file(self, filename: str, mode: str = "a") -> TextIO:
dir_path = os.path.expanduser("~/.sopel/")
os.makedirs(dir_path, exist_ok=True)
return cast(TextIO, open(os.path.join(dir_path, filename), mode))
dir_path = Path("~/.sopel/").expanduser()
dir_path.mkdir(parents=True, exist_ok=True)
return cast(TextIO, (dir_path / filename).open(mode))
def create_config(self) -> None:
with self.open_file(self.filename):
@ -73,7 +73,7 @@ class SopelController(BaseClientController):
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]:

View 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

View File

@ -1,15 +1,13 @@
import contextlib
import fcntl
import functools
import os
from pathlib import Path
import shutil
import subprocess
import textwrap
from typing import Optional, Set, Type
from typing import Callable, ContextManager, Iterator, Optional, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
include "modules.default.conf";
@ -19,7 +17,7 @@ include "help/help.conf";
me {{
name "My.Little.Server";
info "ExampleNET Server";
info "test server";
sid "001";
}}
admin {{
@ -97,6 +95,9 @@ set {{
}}
}}
modes-on-join "+H 100:1d"; // Enables CHATHISTORY
{set_v6only}
}}
tld {{
@ -106,14 +107,65 @@ tld {{
rules "{empty_file}";
}}
files {{
tunefile "{empty_file}";
}}
oper "operuser" {{
password = "operpassword";
password "operpassword";
mask *;
class clients;
operclass netadmin;
}}
"""
SET_V6ONLY = """
// Remove RPL_WHOISSPECIAL used to advertise security groups
whois-details {
security-groups { everyone none; self none; oper none; }
}
plaintext-policy {
server warn; // https://www.unrealircd.org/docs/FAQ#server-requires-tls
oper warn; // https://www.unrealircd.org/docs/FAQ#oper-requires-tls
}
anti-flood {
everyone {
connect-flood 255:10;
}
}
"""
def _filelock(path: Path) -> Callable[[], ContextManager]:
"""Alternative to :cls:`multiprocessing.Lock` that works with pytest-xdist"""
@contextlib.contextmanager
def f() -> Iterator[None]:
with open(path, "a") as fd:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
return f
_UNREALIRCD_BIN = shutil.which("unrealircd")
if _UNREALIRCD_BIN:
_UNREALIRCD_PREFIX = Path(_UNREALIRCD_BIN).parent.parent
# Try to keep that lock file specific to this Unrealircd instance
_LOCK_PATH = _UNREALIRCD_PREFIX / "irctest-unrealircd-startstop.lock"
else:
# unrealircd not found; we are probably going to crash later anyway...
_LOCK_PATH = Path("/tmp/irctest-unrealircd-startstop.lock")
_STARTSTOP_LOCK = _filelock(_LOCK_PATH)
"""
Unreal cleans its tmp/ directory after each run, which prevents
multiple processes from starting/stopping at the same time.
"""
@functools.lru_cache()
def installed_version() -> int:
@ -132,6 +184,7 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
supports_sts = False
extban_mute_char = "quiet" if installed_version() >= 6 else "q"
software_version = installed_version()
def create_config(self) -> None:
super().create_config()
@ -146,23 +199,33 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
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 ""
(services_hostname, services_port) = self.get_hostname_and_port()
(unused_hostname, unused_port) = self.get_hostname_and_port()
self.gen_ssl()
if ssl:
(tls_hostname, tls_port) = (hostname, port)
@ -171,20 +234,8 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
# Unreal refuses to start without TLS enabled
(tls_hostname, tls_port) = (unused_hostname, unused_port)
if installed_version() >= 6:
extras = textwrap.dedent(
"""
include "snomasks.default.conf";
loadmodule "cloak_md5";
"""
)
else:
extras = ""
with self.open_file("empty.txt") as fd:
fd.write("\n")
assert self.directory
with self.open_file("unrealircd.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
@ -197,23 +248,33 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
password_field=password_field,
key_path=self.key_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(
[
"unrealircd",
"-t",
"-F", # BOOT_NOFORK
"-f",
os.path.join(self.directory, "unrealircd.conf"),
],
# stdout=subprocess.DEVNULL,
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
with _STARTSTOP_LOCK():
self.proc = subprocess.Popen(
[
*faketime_cmd,
"unrealircd",
"-t",
"-F", # BOOT_NOFORK
"-f",
self.directory / "unrealircd.conf",
],
# stdout=subprocess.DEVNULL,
)
self.wait_for_port()
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
@ -223,6 +284,14 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
server_port=services_port,
)
def kill_proc(self) -> None:
assert self.proc
with _STARTSTOP_LOCK():
self.proc.kill()
self.proc.wait(5) # wait for it to actually die
self.proc = None
def get_irctest_controller_class() -> Type[UnrealircdController]:
return UnrealircdController

465
irctest/dashboard/format.py Normal file
View 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))))

View 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)))

View 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)

View 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;
}

View 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()

View File

@ -13,7 +13,7 @@ def ircv3_timestamp_to_unixtime(timestamp: str) -> float:
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]:

View File

@ -66,6 +66,7 @@ RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_WHOISSPECIAL = "320"
RPL_LISTSTART = "321"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
@ -86,6 +87,7 @@ RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351"
RPL_WHOREPLY = "352"
RPL_NAMREPLY = "353"
RPL_WHOSPCRPL = "354"
RPL_LINKS = "364"
RPL_ENDOFLINKS = "365"
RPL_ENDOFNAMES = "366"
@ -140,6 +142,7 @@ ERR_USERONCHANNEL = "443"
ERR_NOLOGIN = "444"
ERR_SUMMONDISABLED = "445"
ERR_USERSDISABLED = "446"
ERR_FORBIDDENCHANNEL = "448"
ERR_NOTREGISTERED = "451"
ERR_NEEDMOREPARAMS = "461"
ERR_ALREADYREGISTRED = "462"

View File

@ -13,18 +13,18 @@ class Operator:
pass
class AnyStr(Operator):
class _AnyStr(Operator):
"""Wildcard matching any string"""
def __repr__(self) -> str:
return "AnyStr"
return "ANYSTR"
class AnyOptStr(Operator):
class _AnyOptStr(Operator):
"""Wildcard matching any string as well as None"""
def __repr__(self) -> str:
return "AnyOptStr"
return "ANYOPTSTR"
@dataclasses.dataclass(frozen=True)
@ -59,13 +59,16 @@ class RemainingKeys(Operator):
key: Operator
def __repr__(self) -> str:
return f"Keys({self.key!r})"
return f"RemainingKeys({self.key!r})"
ANYSTR = AnyStr()
ANYSTR = _AnyStr()
"""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.
`match_dict(got_tags, {"label": "foo", **ANYDICT})`"""
@ -77,9 +80,11 @@ class ListRemainder:
def __repr__(self) -> str:
if self.min_length:
return f"*ListRemainder({self.item!r}, min_length={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})"
return f"ListRemainder({self.item!r})"
ANYLIST = [ListRemainder(ANYSTR)]
@ -87,9 +92,9 @@ ANYLIST = [ListRemainder(ANYSTR)]
def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool:
if isinstance(expected, AnyOptStr):
if isinstance(expected, _AnyOptStr):
return True
elif isinstance(expected, AnyStr) and got is not None:
elif isinstance(expected, _AnyStr) and got is not None:
return True
elif isinstance(expected, StrRe):
if got is None or not re.match(expected.regexp, got):
@ -147,21 +152,23 @@ def match_dict(
# Set to not-None if we find a Keys() operator in the dict keys
remaining_keys_wildcard = None
for (expected_key, expected_value) in expected.items():
for expected_key, expected_value in expected.items():
if isinstance(expected_key, RemainingKeys):
remaining_keys_wildcard = (expected_key.key, expected_value)
elif isinstance(expected_key, Operator):
raise NotImplementedError(f"Unsupported operator: {expected_key}")
else:
if expected_key not in got:
return False
got_value = got.pop(expected_key)
if not match_string(got_value, expected_value):
for key in got:
if match_string(key, expected_key) and match_string(
got[key], expected_value
):
got.pop(key)
break
else:
# Found no (key, value) pair matching the request
return False
if remaining_keys_wildcard:
(expected_key, expected_value) = remaining_keys_wildcard
for (key, value) in got.items():
for key, value in got.items():
if not match_string(key, expected_key):
return False
if not match_string(value, expected_value):

View File

@ -1,3 +1,5 @@
"""Internal checks of assertion implementations."""
from typing import Dict, List, Tuple
import pytest
@ -7,8 +9,8 @@ from irctest.irc_utils.message_parser import parse_message
from irctest.patma import (
ANYDICT,
ANYLIST,
ANYOPTSTR,
ANYSTR,
AnyOptStr,
ListRemainder,
NotStrRe,
RemainingKeys,
@ -16,7 +18,7 @@ from irctest.patma import (
)
# fmt: off
MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
(
# the specification:
dict(
@ -36,6 +38,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
[
"PRIVMSG #chan hello2",
"PRIVMSG #chan2 hello",
],
# and they each error with:
[
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
]
),
(
@ -58,6 +65,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
[
"PRIVMSG #chan :hi",
"PRIVMSG #chan2 hello",
],
# and they each error with:
[
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan', 'hi']",
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan2', 'hello']",
]
),
(
@ -76,6 +88,12 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
"PRIVMSG #chan :hi",
":foo2!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo2!baz@qux PRIVMSG #chan :hello",
],
# and they each error with:
[
"expected nick to be foo, got None instead",
"expected nick to be foo, got foo2 instead",
"expected nick to be foo, got foo2 instead",
]
),
(
@ -96,6 +114,13 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
"@tag1=value1 PRIVMSG #chan :hello",
"PRIVMSG #chan hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'value1'}",
"expected tags to match {'tag1': 'bar'}, got {}",
"expected tags to match {'tag1': 'bar'}, got {}",
]
),
(
@ -116,6 +141,12 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"PRIVMSG #chan hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected tags to match {'tag1': ANYSTR}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': ANYSTR}, got {}",
"expected tags to match {'tag1': ANYSTR}, got {}",
]
),
(
@ -138,12 +169,53 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
"PRIVMSG #chan hello2",
"PRIVMSG #chan2 hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}",
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}",
]
),
(
# the specification:
dict(
tags={"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",
params=["#chan", "hello"],
),
@ -159,6 +231,13 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
"@tag1=value1 PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=bar;tag2=baz PRIVMSG #chan :hello",
],
# and they each error with:
[
"expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'value1'}",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': 'baz'}",
]
),
(
@ -176,6 +255,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
[
"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']",
]
),
(
@ -193,6 +277,10 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
# and does not match:
[
"005 nick",
],
# and they each error with:
[
"expected params to match ['nick', ListRemainder(ANYSTR, min_length=1)], got ['nick']",
]
),
(
@ -211,6 +299,30 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [
[
"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"
]
),
]
@ -222,7 +334,7 @@ class IrcTestCaseTestCase(cases._IrcTestCase):
"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
],
)
@ -235,7 +347,7 @@ class IrcTestCaseTestCase(cases._IrcTestCase):
"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
],
)
@ -244,3 +356,14 @@ class IrcTestCaseTestCase(cases._IrcTestCase):
assert not self.messageEqual(parse_message(msg), **spec), msg
with pytest.raises(AssertionError):
self.assertMessageMatch(parse_message(msg), **spec), msg
@pytest.mark.parametrize(
"spec,msg,error_string",
[
pytest.param(spec, msg, error_string, id=error_string)
for (spec, _, negative_matches, error_stringgexps) in MESSAGE_SPECS
for (msg, error_string) in zip(negative_matches, error_stringgexps)
],
)
def test_message_matching_negative_message(self, spec, msg, error_string):
self.assertIn(error_string, self.messageDiffers(parse_message(msg), **spec))

View File

@ -1,9 +1,15 @@
"""
`Draft IRCv3 account-registration
<https://ircv3.net/specs/extensions/account-registration>`_
"""
from irctest import cases
from irctest.patma import ANYSTR
REGISTER_CAP_NAME = "draft/account-registration"
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
@staticmethod
@ -28,6 +34,7 @@ class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
self.assertMessageMatch(register_response, params=["SUCCESS", ANYSTR, ANYSTR])
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
@staticmethod
@ -55,6 +62,7 @@ class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
)
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
@staticmethod
@ -105,6 +113,7 @@ class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
)
@cases.mark_services
@cases.mark_specifications("IRCv3", "Ergo")
class RegisterNoLandGrabsTestCase(cases.BaseServerTestCase):
@staticmethod

View File

@ -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
@cases.mark_services
class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
class AccountTagTestCase(cases.BaseServerTestCase):
def connectRegisteredClient(self, nick):
self.addClient()
self.sendLine(2, "CAP LS 302")
@ -40,7 +40,7 @@ class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.skipToWelcome(2)
@cases.mark_capabilities("account-tag")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
@cases.skipUnlessHasMechanism("PLAIN")
def testPrivmsg(self):
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
self.getMessages(1)
@ -54,7 +54,10 @@ class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
)
@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):
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
self.getMessages(1)

View File

@ -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.numerics import RPL_AWAY, RPL_NOWAWAY, RPL_UNAWAY, RPL_USERHOST
from irctest.patma import StrRe
from irctest.numerics import (
RPL_AWAY,
RPL_NOWAWAY,
RPL_UNAWAY,
RPL_USERHOST,
RPL_WHOISUSER,
)
from irctest.patma import ANYSTR, StrRe
class AwayTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC2812", "Modern")
def testAway(self):
self.connectClient("bar")
self.getMessages(1)
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
@ -18,6 +30,7 @@ class AwayTestCase(cases.BaseServerTestCase):
command=RPL_AWAY,
params=["qux", "bar", "I'm not here right now"],
)
self.getMessages(1)
self.sendLine(1, "AWAY")
replies = self.getMessages(1)
@ -32,23 +45,27 @@ class AwayTestCase(cases.BaseServerTestCase):
"""
"The server acknowledges the change in away status by returning the
`RPL_NOWAWAY` and `RPL_UNAWAY` numerics."
-- https://github.com/ircdocs/modern-irc/pull/100
-- https://modern.ircdocs.horse/#away-message
"""
self.connectClient("bar")
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.assertMessageMatch(
self.getMessage(1), command=RPL_NOWAWAY, params=["bar", ANYSTR]
)
self.assertEqual(self.getMessages(1), [])
self.sendLine(1, "AWAY")
replies = self.getMessages(1)
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
self.assertMessageMatch(
self.getMessage(1), command=RPL_UNAWAY, params=["bar", ANYSTR]
)
self.assertEqual(self.getMessages(1), [])
@cases.mark_specifications("Modern")
def testAwayPrivmsg(self):
"""
"Servers SHOULD notify clients when a user they're interacting with
is away when relevant"
-- https://github.com/ircdocs/modern-irc/pull/100
-- https://modern.ircdocs.horse/#away-message
"<client> <nick> :<message>"
-- 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
is away when relevant"
-- https://github.com/ircdocs/modern-irc/pull/100
-- https://modern.ircdocs.horse/#away-message
"<client> <nick> :<message>"
-- 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
is away when relevant"
-- https://github.com/ircdocs/modern-irc/pull/100
-- https://modern.ircdocs.horse/#away-message
"<client> <nick> :<message>"
-- https://modern.ircdocs.horse/#rplaway-301
@ -134,3 +151,33 @@ class AwayTestCase(cases.BaseServerTestCase):
self.assertMessageMatch(
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=-.*")]
)
@cases.mark_specifications("Modern")
def testAwayEmptyMessage(self):
"""
"If [AWAY] is sent with a nonempty parameter (the 'away message')
then the user is set to be away. If this command is sent with no
parameters, or with the empty string as the parameter, the user is no
longer away."
-- https://modern.ircdocs.horse/#away-message
"""
self.connectClient("bar", name="bar")
self.connectClient("qux", name="qux")
self.sendLine("bar", "AWAY :I'm not here right now")
replies = self.getMessages("bar")
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.sendLine("qux", "WHOIS bar")
replies = self.getMessages("qux")
self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies])
self.assertIn(RPL_AWAY, [msg.command for msg in replies])
# empty final parameter to AWAY is treated the same as no parameter,
# i.e., the client is considered to be no longer away
self.sendLine("bar", "AWAY :")
replies = self.getMessages("bar")
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
self.sendLine("qux", "WHOIS bar")
replies = self.getMessages("qux")
self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies])
self.assertNotIn(RPL_AWAY, [msg.command for msg in replies])

View File

@ -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.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")
def testAwayNotify(self):
"""Basic away-notify test."""
@ -20,13 +22,28 @@ class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.getMessages(1)
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)
self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm going away"])
self.assertTrue(
awayNotify.prefix.startswith("bar!"),
"Unexpected away-notify source: %s" % (awayNotify.prefix,),
self.assertMessageMatch(
awayNotify,
prefix=StrRe("bar!.*"),
command="AWAY",
params=["i'm going away"],
)
self.sendLine(2, "AWAY")
self.assertMessageMatch(
self.getMessage(2), command=RPL_UNAWAY, params=["bar", ANYSTR]
)
self.assertEqual(self.getMessages(2), [])
awayNotify = self.getMessage(1)
self.assertMessageMatch(
awayNotify, prefix=StrRe("bar!.*"), command="AWAY", params=[]
)
@cases.mark_capabilities("away-notify")
@ -45,7 +62,11 @@ class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.getMessages(2)
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"]
self.assertEqual(

View File

@ -1,6 +1,5 @@
"""
Draft bot mode specification, as defined in
<https://ircv3.net/specs/extensions/bot-mode>
`IRCv3 bot mode <https://ircv3.net/specs/extensions/bot-mode>`_
"""
from irctest import cases, runner
@ -68,6 +67,10 @@ class BotModeTestCase(cases.BaseServerTestCase):
message, command=RPL_WHOISBOT, params=["usernick", "botnick", ANYSTR]
)
@cases.xfailIfSoftware(
["InspIRCd"],
"Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910",
)
def testBotPrivateMessage(self):
self._initBot()
@ -82,9 +85,13 @@ class BotModeTestCase(cases.BaseServerTestCase):
self.getMessage("user"),
command="PRIVMSG",
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):
self._initBot()
@ -104,7 +111,7 @@ class BotModeTestCase(cases.BaseServerTestCase):
self.getMessage("user"),
command="PRIVMSG",
params=["#chan", "beep boop"],
tags={"draft/bot": None, **ANYDICT},
tags={StrRe("(draft/)?bot"): None, **ANYDICT},
)
def testBotWhox(self):

View File

@ -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.irc_utils.sasl import sasl_plain_blob
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME

View File

@ -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 time
@ -30,6 +32,16 @@ def _sendBytePerByte(self, line):
class BufferingTestCase(cases.BaseServerTestCase):
@cases.xfailIfSoftware(
["Bahamut"],
"cannot pass because of issues with UTF-8 handling: "
"https://github.com/DALnet/bahamut/issues/196",
)
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "snircd"],
"ircu2 discards the whole buffer on long lines "
"(TODO: refine how we exclude these tests)",
)
@pytest.mark.parametrize(
"sender_function,colon",
[

View File

@ -1,9 +1,35 @@
"""
`IRCv3 Capability negotiation
<https://ircv3.net/specs/extensions/capability-negotiation>`_
"""
from irctest import cases
from irctest.patma import ANYSTR
from irctest.patma import ANYSTR, StrRe
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")
def testNoReq(self):
"""Test the server handles gracefully clients which do not send
@ -18,12 +44,210 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
# Make sure the server didn't send anything yet
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["Sable"],
"does not support multi-prefix",
)
def testReqOne(self):
"""Tests requesting a single capability"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "LIST", StrRe("multi-prefix ?")],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd", "Sable"],
"does not support userhost-in-names",
)
def testReqTwo(self):
"""Tests requesting two capabilities at once"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix userhost-in-names")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix userhost-in-names ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[
ANYSTR,
"LIST",
StrRe(
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
),
],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd", "Sable"],
"does not support userhost-in-names",
)
def testReqOneThenOne(self):
"""Tests requesting two capabilities in different messages"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP REQ :userhost-in-names")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[
ANYSTR,
"LIST",
StrRe(
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
),
],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd", "Sable"],
"does not support userhost-in-names",
)
def testReqPostRegistration(self):
"""Tests requesting more capabilities after CAP END"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "LIST", StrRe("multi-prefix ?")],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
self.getMessages(1)
self.sendLine(1, "CAP REQ :userhost-in-names")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[
ANYSTR,
"LIST",
StrRe(
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
),
],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
@cases.mark_specifications("IRCv3")
def testReqUnavailable(self):
"""Test the server handles gracefully clients which request
@ -40,7 +264,7 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo"],
params=[ANYSTR, "NAK", StrRe("foo ?")],
fail_msg="Expected CAP NAK after requesting non-existing "
"capability, got {msg}.",
)
@ -80,7 +304,8 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
""" # noqa
self.addClient(1)
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")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
@ -114,7 +339,7 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", "multi-prefix"],
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected “CAP ACK :multi-prefix” after "
"sending “CAP REQ :multi-prefix”, but got {msg}.",
)
@ -127,8 +352,13 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.addClient(1)
self.connectClient("sender")
self.sendLine(1, "CAP LS 302")
m = self.getRegistrationMessage(1)
if not ({cap1, cap2} <= set(m.params[2].split())):
caps = set()
while True:
m = self.getRegistrationMessage(1)
caps.update(m.params[-1].split())
if m.params[2] != "*":
break
if not ({cap1, cap2} <= caps):
raise CapabilityNotSupported(f"{cap1} or {cap2}")
self.sendLine(1, f"CAP REQ :{cap1} {cap2}")
self.sendLine(1, "nick bar")
@ -154,17 +384,19 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
m = self.getMessage(1)
self.assertIn("time", m.tags, m)
# remove the server-time cap
# remove the multi-prefix cap
self.sendLine(1, f"CAP REQ :-{cap2}")
m = self.getMessage(1)
# Must be either ACK or NAK
if self.messageDiffers(m, command="CAP", params=[ANYSTR, "ACK", f"-{cap2}"]):
if self.messageDiffers(
m, command="CAP", params=[ANYSTR, "ACK", StrRe(f"-{cap2} ?")]
):
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}")
# server-time should be disabled
# multi-prefix should be disabled
self.sendLine(1, "CAP LIST")
messages = self.getMessages(1)
cap_list = [m for m in messages if m.command == "CAP"][0]
@ -172,3 +404,88 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
enabled_caps.discard("cap-notify") # implicitly added by some impls
self.assertEqual(enabled_caps, {cap1})
self.assertNotIn("time", cap_list.tags)
@cases.mark_specifications("IRCv3")
def testIrc301CapLs(self):
"""
Current version:
"The LS subcommand is used to list the capabilities supported by the server.
The client should send an LS subcommand with no other arguments to solicit
a list of all capabilities."
"If a client has not indicated support for CAP LS 302 features,
the server MUST NOT send these new features to the client."
-- <https://ircv3.net/specs/core/capability-negotiation.html>
Before the v3.1 / v3.2 merge:
IRCv3.1: The LS subcommand is used to list the capabilities
supported by the server. The client should send an LS subcommand with
no other arguments to solicit a list of all capabilities.
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-ls-subcommand>
IRCv3.2: Servers MUST NOT send messages described by this document if
the client only supports version 3.1.
-- <http://ircv3.net/specs/core/capability-negotiation-3.2.html#version-in-cap-ls>
""" # noqa
self.addClient()
self.sendLine(1, "CAP LS")
m = self.getRegistrationMessage(1)
self.assertNotEqual(
m.params[2],
"*",
m,
fail_msg="Server replied with multi-line CAP LS to a "
"“CAP LS” (ie. IRCv3.1) request: {msg}",
)
self.assertFalse(
any("=" in cap for cap in m.params[2].split()),
"Server replied with a name-value capability in "
"CAP LS reply as a response to “CAP LS” (ie. IRCv3.1) "
"request: {}".format(m),
)
@cases.mark_specifications("IRCv3")
def testEmptyCapList(self):
"""“If no capabilities are active, an empty parameter must be sent.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-list-subcommand>
""" # noqa
self.addClient()
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=["*", "LIST", ""],
fail_msg="Sending “CAP LIST” as first message got a reply "
"that is not “CAP * LIST :”: {msg}",
)
@cases.mark_specifications("IRCv3")
def testNoMultiline301Response(self):
"""
Current version: "If the client supports CAP version 302, the server MAY send
multiple lines in response to CAP LS and CAP LIST." This should be read as
disallowing multiline responses to pre-302 clients.
-- <https://ircv3.net/specs/extensions/capability-negotiation#multiline-replies-to-cap-ls-and-cap-list>
""" # noqa
self.check301ResponsePreRegistration("bar", "CAP LS")
self.check301ResponsePreRegistration("qux", "CAP LS 301")
self.check301ResponsePostRegistration("baz", "CAP LS")
self.check301ResponsePostRegistration("bat", "CAP LS 301")
def check301ResponsePreRegistration(self, nick, cap_ls):
self.addClient(nick)
self.sendLine(nick, cap_ls)
self.sendLine(nick, "NICK " + nick)
self.sendLine(nick, "USER u s e r")
self.sendLine(nick, "CAP END")
responses = [msg for msg in self.skipToWelcome(nick) if msg.command == "CAP"]
self.assertLessEqual(len(responses), 1, responses)
def check301ResponsePostRegistration(self, nick, cap_ls):
self.connectClient(nick, name=nick)
self.sendLine(nick, cap_ls)
responses = [msg for msg in self.getMessages(nick) if msg.command == "CAP"]
self.assertLessEqual(len(responses), 1, responses)

View File

@ -1,3 +1,7 @@
"""
Channel casemapping
"""
import pytest
from irctest import cases, client_mock, runner
@ -18,7 +22,7 @@ class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase):
self.connectClient("foo")
self.connectClient("bar")
if self.server_support["CASEMAPPING"] != casemapping:
raise runner.NotImplementedByController(
raise runner.ImplementationChoice(
"Casemapping {} not implemented".format(casemapping)
)
self.joinClient(1, name1)
@ -43,7 +47,7 @@ class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase):
self.connectClient("foo")
self.connectClient("bar")
if self.server_support["CASEMAPPING"] != casemapping:
raise runner.NotImplementedByController(
raise runner.ImplementationChoice(
"Casemapping {} not implemented".format(casemapping)
)
self.joinClient(1, name1)

View File

@ -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.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL

View File

@ -1,24 +1,22 @@
"""
`Draft IRCv3 channel-rename <https://ircv3.net/specs/extensions/channel-rename>`_
"""
from irctest import cases
from irctest.numerics import ERR_CHANOPRIVSNEEDED
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
RENAME_CAP = "draft/channel-rename"
@cases.mark_specifications("IRCv3")
class ChannelRenameTestCase(cases.BaseServerTestCase):
"""Basic tests for channel-rename."""
@cases.mark_specifications("Ergo")
def testChannelRename(self):
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS + [RENAME_CAP])
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
self.connectClient(
"bar", name="bar", capabilities=[RENAME_CAP], skip_if_cap_nak=True
)
self.connectClient("baz", name="baz")
self.joinChannel("bar", "#bar")
self.joinChannel("baz", "#bar")
self.getMessages("bar")

View File

@ -1,11 +1,16 @@
"""
`IRCv3 draft chathistory <https://ircv3.net/specs/extensions/chathistory>`_
"""
import functools
import secrets
import time
import pytest
from irctest import cases
from irctest import cases, runner
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYSTR
from irctest.patma import ANYSTR, StrRe
CHATHISTORY_CAP = "draft/chathistory"
EVENT_PLAYBACK_CAP = "draft/event-playback"
@ -16,35 +21,44 @@ SUBCOMMANDS = ["LATEST", "BEFORE", "AFTER", "BETWEEN", "AROUND"]
MYSQL_PASSWORD = ""
def validate_chathistory_batch(msgs):
batch_tag = None
closed_batch_tag = None
result = []
for msg in msgs:
if msg.command == "BATCH":
batch_param = msg.params[0]
if batch_tag is None and batch_param[0] == "+":
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
def skip_ngircd(f):
@functools.wraps(f)
def newf(self, *args, **kwargs):
if self.controller.software_name == "ngIRCd":
raise runner.OptionalExtensionNotSupported("nicks longer 9 characters")
return f(self, *args, **kwargs)
return newf
@cases.mark_specifications("IRCv3")
@cases.mark_services
class ChathistoryTestCase(cases.BaseServerTestCase):
def validate_chathistory_batch(self, msgs, target):
(start, *inner_msgs, end) = msgs
self.assertMessageMatch(
start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", target]
)
batch_tag = start.params[0][1:]
self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag])
result = []
for msg in inner_msgs:
if (
msg.command in ("PRIVMSG", "TOPIC")
and batch_tag is not None
and msg.tags.get("batch") == batch_tag
):
if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific
result.append(msg.to_history_message())
return result
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(chathistory=True)
@skip_ngircd
def testInvalidTargets(self):
bar, pw = random_name("bar"), random_name("pw")
self.controller.registerUser(self, bar, pw)
@ -90,6 +104,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
)
@pytest.mark.private_chathistory
@skip_ngircd
def testMessagesToSelf(self):
bar, pw = random_name("bar"), random_name("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)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@skip_ngircd
def testChathistory(self, subcommand):
if subcommand == "BETWEEN" and self.controller.software_name == "UnrealIRCd":
pytest.xfail(
"CHATHISTORY BETWEEN does not apply bounds correct "
"https://bugs.unrealircd.org/view.php?id=5952"
)
if subcommand == "AROUND" and self.controller.software_name == "UnrealIRCd":
pytest.xfail(
"CHATHISTORY AROUND excludes 'central' messages "
"https://bugs.unrealircd.org/view.php?id=5953"
)
self.connectClient(
"bar",
capabilities=[
@ -193,7 +220,49 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@skip_ngircd
def testChathistoryNoEventPlayback(self):
"""Tests that non-messages don't appear in the chat history when event-playback
is not enabled."""
self.connectClient(
"bar",
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
skip_if_cap_nak=True,
)
chname = "#chan" + secrets.token_hex(12)
self.joinChannel(1, chname)
self.getMessages(1)
self.getMessages(1)
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i))
self.getMessages(1)
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname)
(batch_open, *messages, batch_close) = self.getMessages(1)
self.assertMessageMatch(batch_open, command="BATCH")
self.assertMessageMatch(batch_close, command="BATCH")
self.assertEqual([msg for msg in messages if msg.command != "PRIVMSG"], [])
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@skip_ngircd
def testChathistoryEventPlayback(self, subcommand):
self.connectClient(
"bar",
@ -216,20 +285,27 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@pytest.mark.private_chathistory
@skip_ngircd
def testChathistoryDMs(self, subcommand):
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
c1 = random_name("foo")
c2 = random_name("bar")
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
@ -277,11 +353,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
)
time.sleep(0.002)
self.getMessages(1)
self.getMessages(2)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
c3 = "baz" + secrets.token_hex(12)
c3 = random_name("baz")
self.connectClient(
c3,
capabilities=[
@ -370,15 +449,15 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages, result)
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.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.sendLine(
@ -386,7 +465,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s msgid=%s %d"
% (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.sendLine(
@ -394,7 +473,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s timestamp=%s %d"
% (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)
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
@ -404,7 +483,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s msgid=%s %d"
% (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.sendLine(
@ -412,7 +491,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (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.sendLine(
@ -420,7 +499,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (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)
def _validate_chathistory_AFTER(self, echo_messages, user, chname):
@ -430,7 +509,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s msgid=%s %d"
% (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.sendLine(
@ -438,14 +517,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s timestamp=%s %d"
% (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.sendLine(
user,
"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)
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
@ -461,7 +540,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
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.sendLine(
@ -474,7 +553,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
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)
# 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"
% (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.sendLine(
@ -492,7 +571,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (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)
# same stuff again but with timestamps
@ -501,28 +580,28 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (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.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (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.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (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.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (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)
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
@ -530,14 +609,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
user,
"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.sendLine(
user,
"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.sendLine(
@ -545,13 +624,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s timestamp=%s %d"
% (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)
@pytest.mark.arbitrary_client_tags
@skip_ngircd
def testChathistoryTagmsg(self):
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
c1 = random_name("foo")
c2 = random_name("bar")
chname = "#chan" + secrets.token_hex(12)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
@ -647,10 +727,11 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
@pytest.mark.arbitrary_client_tags
@pytest.mark.private_chathistory
@skip_ngircd
def testChathistoryDMClientOnlyTags(self):
# regression test for Ergo #1411
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
c1 = random_name("foo")
c2 = random_name("bar")
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(

View File

@ -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 time

View File

@ -1,4 +1,12 @@
from irctest import cases
"""
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
@ -26,7 +34,7 @@ class BanModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
def testBanList(self):
"""https://github.com/ircdocs/modern-irc/pull/125"""
"""`RPL_BANLIST <https://modern.ircdocs.horse/#rplbanlist-367>`_"""
self.connectClient("chanop")
self.joinChannel(1, "#chan")
self.getMessages(1)
@ -70,6 +78,65 @@ class BanModeTestCase(cases.BaseServerTestCase):
],
)
@cases.mark_specifications("Modern")
def testBanException(self):
"""`Exception mode <https://modern.ircdocs.horse/#exception-channel-mode`_,
detected using `ISUPPORT EXCEPTS
<https://modern.ircdocs.horse/#excepts-parameter>`_ and checked against
`ISUPPORT CHANMODES <https://modern.ircdocs.horse/#chanmodes-parameter>`_"""
self.connectClient("chanop", name="chanop")
if "EXCEPTS" in self.server_support:
mode = self.server_support["EXCEPTS"] or "e"
if "CHANMODES" in self.server_support:
self.assertIn(
mode,
self.server_support["CHANMODES"],
fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is missing "
"from 'CHANMODES={list}'",
)
self.assertIn(
mode,
self.server_support["CHANMODES"].split(",")[0],
fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is not "
"in group A",
)
else:
mode = "e"
if "CHANMODES" in self.server_support:
if "e" not in self.server_support["CHANMODES"]:
raise runner.OptionalExtensionNotSupported(
"Ban exception (or mode letter is not +e)"
)
self.assertIn(
mode,
self.server_support["CHANMODES"].split(",")[0],
fail_msg="Mode +e (assumed to be ban exception) is present, "
"but 'e' is not in group A",
)
else:
raise runner.OptionalExtensionNotSupported("ISUPPORT CHANMODES")
self.sendLine("chanop", "JOIN #chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +b ba*!*@*")
self.getMessages("chanop")
# banned client cannot join
self.connectClient("Bar", name="bar")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
# chanop sets exception
self.sendLine("chanop", "MODE #chan +e *ar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
# client can now join
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
# TODO: Add testBanExceptionList, once the numerics are specified in Modern
@cases.mark_specifications("Ergo")
def testCaseInsensitive(self):
"""Some clients allow unsetting modes if their argument matches

View File

@ -1,3 +1,7 @@
"""
Various Ergo-specific channel modes
"""
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED

View File

@ -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
from irctest import cases
@ -20,10 +27,16 @@ class KeyTestCase(cases.BaseServerTestCase):
self.connectClient("qux")
self.getMessages(2)
# JOIN with a missing key MUST receive ERR_BADCHANNELKEY:
self.sendLine(2, "JOIN #chan")
reply = self.getMessages(2)
self.assertNotIn("JOIN", {msg.command for msg in reply})
self.assertIn(ERR_BADCHANNELKEY, {msg.command for msg in reply})
reply_cmds = {msg.command for msg in self.getMessages(2)}
self.assertNotIn("JOIN", reply_cmds)
self.assertIn(ERR_BADCHANNELKEY, reply_cmds)
# similarly for JOIN with an incorrect key:
self.sendLine(2, "JOIN #chan bees")
reply_cmds = {msg.command for msg in self.getMessages(2)}
self.assertNotIn("JOIN", reply_cmds)
self.assertIn(ERR_BADCHANNELKEY, reply_cmds)
self.sendLine(2, "JOIN #chan beer")
reply = self.getMessages(2)
@ -31,8 +44,8 @@ class KeyTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize(
"key",
["passphrase with spaces", "long" * 100, ""],
ids=["spaces", "long", "empty"],
["passphrase with spaces", "long" * 100, "", " "],
ids=["spaces", "long", "empty", "only-space"],
)
@cases.mark_specifications("RFC2812", "Modern")
def testKeyValidation(self, key):
@ -57,6 +70,23 @@ class KeyTestCase(cases.BaseServerTestCase):
-- https://modern.ircdocs.horse/#key-channel-mode
-- https://github.com/ircdocs/modern-irc/pull/111
"""
if key == "" and self.controller.software_name in (
"ircu2",
"Nefarious",
"snircd",
):
pytest.xfail(
"ircu2 returns ERR_NEEDMOREPARAMS on empty keys: "
"https://github.com/UndernetIRC/ircu2/issues/13"
)
if (key == "" or " " in key) and self.controller.software_name == "ngIRCd":
pytest.xfail(
"ngIRCd does not validate channel keys: "
"https://github.com/ngircd/ngircd/issues/290"
)
if key == " " and self.controller.software_name == "irc2":
pytest.xfail("irc2 rewrites non-empty keys that contain only spaces")
self.connectClient("bar")
self.joinChannel(1, "#chan")
self.sendLine(1, f"MODE #chan +k :{key}")

View File

@ -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.numerics import ERR_CANNOTSENDTOCHAN

View File

@ -1,3 +1,7 @@
"""
Mute extban, currently no specifications or ways to discover it.
"""
from irctest import cases, runner
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
from irctest.patma import ANYLIST, StrRe
@ -194,7 +198,7 @@ class MuteExtbanTestCase(cases.BaseServerTestCase):
self.getMessages(client)
# +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")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)

View 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, [])

View 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"]
)

View 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"})

View File

@ -1,3 +1,8 @@
"""
`Ergo <https://ergo.chat/>`_-specific tests for nick collisions based on Unicode
confusable characters
"""
from irctest import cases
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME

View File

@ -1,11 +1,15 @@
"""
Tests section 4.1 of RFC 1459.
<https://tools.ietf.org/html/rfc1459#section-4.1>
TODO: cross-reference Modern and RFC 2812 too
"""
import time
from irctest import cases
from irctest.client_mock import ConnectionClosed
from irctest.numerics import ERR_NEEDMOREPARAMS
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH
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"
)
@cases.mark_specifications("RFC1459", "RFC2812")
@cases.mark_specifications("Modern")
def testWrongPassword(self):
"""
"If the password supplied does not match the password expected by the server,
then the server SHOULD send ERR_PASSWDMISMATCH and MUST close the connection
with ERROR."
-- https://github.com/ircdocs/modern-irc/pull/172
"""
self.addClient()
self.sendLine(1, "PASS {}".format(self.password + "garbage"))
self.sendLine(1, "NICK foo")
@ -46,6 +56,13 @@ class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
self.assertNotEqual(
m.command, "001", msg="Got 001 after NICK+USER but incorrect PASS"
)
self.assertIn(m.command, {ERR_PASSWDMISMATCH, "ERROR"})
if m.command == "ERR_PASSWDMISMATCH":
m = self.getRegistrationMessage(1)
self.assertEqual(
m.command, "ERROR", msg="ERR_PASSWDMISMATCH not followed by ERROR."
)
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testPassAfterNickuser(self):
@ -82,6 +99,10 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
self.getMessages(1)
@cases.mark_specifications("RFC2812")
@cases.xfailIfSoftware(["Charybdis", "Solanum"], "very flaky")
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "snircd"], "ircu2 does not send ERROR"
)
def testQuitErrors(self):
"""“A client session is terminated with a quit message. The server
acknowledges this by sending an ERROR message to the client.
@ -114,7 +135,7 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
self.assertNotEqual(
m.command,
"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):
@ -162,6 +183,10 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
"neither got 001.",
)
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "ngIRCd"],
"uses a default value instead of ERR_NEEDMOREPARAMS",
)
def testEmptyRealname(self):
"""
Syntax:
@ -184,59 +209,57 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
params=[StrRe(r"(\*|foo)"), "USER", ANYSTR],
)
@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
def testNonutf8Realname(self):
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),
)
self.sendLine(1, "NICK foo")
line = b"USER username * * :i\xe8rc\xe9\r\n"
print("1 -> S (repr): " + repr(line))
self.clients[1].conn.sendall(line)
for _ in range(10):
time.sleep(1)
d = self.clients[1].conn.recv(10000)
self.assertTrue(d, "Server closed connection")
print("S -> 1 (repr): " + repr(d))
if b" 001 " in d:
break
if b"ERROR " in d or b" FAIL " in d:
# Rejected; nothing more to test.
return
for line in d.split(b"\r\n"):
if line.startswith(b"PING "):
line = line.replace(b"PING", b"PONG") + b"\r\n"
print("1 -> S (repr): " + repr(line))
self.clients[1].conn.sendall(line)
else:
self.assertTrue(False, "stuck waiting")
self.sendLine(1, "WHOIS foo")
time.sleep(3) # for ngIRCd
d = self.clients[1].conn.recv(10000)
print("S -> 1 (repr): " + repr(d))
self.assertIn(b"username", d)
@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
def testNonutf8Username(self):
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}",
)
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER 😊😊😊😊😊😊😊😊😊😊 * * :realname")
for _ in range(10):
time.sleep(1)
d = self.clients[1].conn.recv(10000)
self.assertTrue(d, "Server closed connection")
print("S -> 1 (repr): " + repr(d))
if b" 001 " in d:
break
if b" 468" in d or b"ERROR " in d:
# Rejected; nothing more to test.
return
for line in d.split(b"\r\n"):
if line.startswith(b"PING "):
line = line.replace(b"PING", b"PONG") + b"\r\n"
print("1 -> S (repr): " + repr(line))
self.clients[1].conn.sendall(line)
else:
self.assertTrue(False, "stuck waiting")
self.sendLine(1, "WHOIS foo")
d = self.clients[1].conn.recv(10000)
print("S -> 1 (repr): " + repr(d))
self.assertIn(b"realname", d)

View File

@ -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
from irctest import cases
from irctest.basecontrollers import NotImplementedByController
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYDICT
@ -23,36 +22,20 @@ class EchoMessageTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("echo-message")
def testEchoMessage(self, command, solo, server_time):
"""<http://ircv3.net/specs/extensions/echo-message-3.2.html>"""
self.addClient()
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")
capabilities = ["server-time"] if server_time else []
# TODO: check also without this
self.sendLine(
1,
"CAP REQ :echo-message{}".format(" server-time" if server_time else ""),
self.connectClient(
"baz",
capabilities=["echo-message", *capabilities],
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")
# Synchronize
self.getMessages(1)
if not solo:
capabilities = ["server-time"] if server_time else None
self.connectClient("qux", capabilities=capabilities)
self.sendLine(2, "JOIN #chan")

View File

@ -1,3 +1,7 @@
"""
`Ergo <https://ergo.chat/>`-specific tests of NickServ.
"""
from irctest import cases
from irctest.numerics import RPL_YOUREOPER

View File

@ -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
@cases.mark_services
class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
class MetadataTestCase(cases.BaseServerTestCase):
def connectRegisteredClient(self, nick):
self.addClient()
self.sendLine(2, "CAP LS 302")
@ -50,7 +50,7 @@ class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
)
@cases.mark_capabilities("extended-join")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
@cases.skipUnlessHasMechanism("PLAIN")
def testLoggedIn(self):
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
self.joinChannel(1, "#chan")

View File

@ -1,7 +1,8 @@
"""
The HELP and HELPOP command.
The HELP and HELPOP command (`Modern <https://modern.ircdocs.horse/#help-message>`__)
"""
import functools
import re
import pytest
@ -17,6 +18,30 @@ from irctest.numerics import (
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:
@ -46,6 +71,7 @@ class HelpTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
@cases.mark_specifications("Modern")
@with_xfails
def testHelpNoArg(self, command):
self.connectClient("nick")
self.sendLine(1, f"{command}")
@ -59,6 +85,7 @@ class HelpTestCase(cases.BaseServerTestCase):
@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")
@ -71,6 +98,7 @@ class HelpTestCase(cases.BaseServerTestCase):
@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")

View File

@ -1,5 +1,8 @@
"""
The INFO command.
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
@ -84,6 +87,9 @@ class InfoTestCase(cases.BaseServerTestCase):
@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>

View File

@ -1,10 +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
from irctest import cases
from irctest import cases, runner
from irctest.numerics import (
ERR_BANNEDFROMCHAN,
ERR_CHANOPRIVSNEEDED,
ERR_INVITEONLYCHAN,
ERR_NEEDMOREPARAMS,
ERR_NOSUCHNICK,
ERR_NOTONCHANNEL,
ERR_USERONCHANNEL,
@ -110,7 +118,7 @@ class InviteTestCase(cases.BaseServerTestCase):
"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
notification of the invitation."
@ -163,23 +171,14 @@ class InviteTestCase(cases.BaseServerTestCase):
)
self.sendLine(1, "INVITE bar #chan")
if modern:
self.assertMessageMatch(
self.getMessage(1),
command=RPL_INVITING,
params=["foo", "bar", "#chan"],
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"{{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}}",
)
self.assertMessageMatch(
self.getMessage(1),
command=RPL_INVITING,
params=["foo", "bar", "#chan"],
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"{{msg}}",
)
messages = self.getMessages(2)
self.assertNotEqual(
@ -197,24 +196,17 @@ class InviteTestCase(cases.BaseServerTestCase):
)
@pytest.mark.parametrize("invite_only", [True, False])
@cases.mark_specifications("Modern")
def testInviteModern(self, invite_only):
self._testInvite(opped=True, invite_only=invite_only, modern=True)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testInvite(self, invite_only):
self._testInvite(opped=True, invite_only=invite_only)
@pytest.mark.parametrize("invite_only", [True, False])
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInviteRfc(self, invite_only):
self._testInvite(opped=True, invite_only=invite_only, modern=False)
@cases.mark_specifications("Modern", strict=True)
def testInviteUnoppedModern(self):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern", strict=True)
@cases.xfailIfSoftware(
["Hybrid", "Plexus4"], "the only strict test that Hybrid fails"
)
def testInviteUnopped(self):
"""Tests invites from unopped users on not-invite-only chans."""
self._testInvite(opped=False, invite_only=False, modern=True)
@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)
self._testInvite(opped=False, invite_only=False)
@cases.mark_specifications("RFC2812", "Modern")
def testInviteNoNotificationForOtherMembers(self):
@ -248,7 +240,13 @@ class InviteTestCase(cases.BaseServerTestCase):
"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
+i), the client sending the invite must be recognised as being a
@ -288,35 +286,17 @@ class InviteTestCase(cases.BaseServerTestCase):
)
self.sendLine(1, "INVITE bar #chan")
if modern:
self.assertMessageMatch(
self.getMessage(1),
command=ERR_CHANOPRIVSNEEDED,
params=["foo", "#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} 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)
self.assertMessageMatch(
self.getMessage(1),
command=ERR_CHANOPRIVSNEEDED,
params=["foo", "#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} foo #chan :*” but got this instead: {{msg}}",
)
@cases.mark_specifications("RFC2812", "Modern")
def _testInviteOnlyFromUsersInChannel(self, modern):
def testInviteOnlyFromUsersInChannel(self):
"""
"if the channel exists, only members of the channel are allowed
to invite other users"
@ -349,26 +329,15 @@ class InviteTestCase(cases.BaseServerTestCase):
self.getMessages(3)
self.sendLine(1, "INVITE bar #chan")
if modern:
self.assertMessageMatch(
self.getMessage(1),
command=ERR_NOTONCHANNEL,
params=["foo", "#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}) foo #chan :*” but "
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}}",
)
self.assertMessageMatch(
self.getMessage(1),
command=ERR_NOTONCHANNEL,
params=["foo", "#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}) foo #chan :*” but "
f"got this instead: {{msg}}",
)
messages = self.getMessages(2)
self.assertEqual(
@ -378,14 +347,6 @@ class InviteTestCase(cases.BaseServerTestCase):
"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")
def testInviteAlreadyInChannel(self):
"""
@ -399,8 +360,8 @@ class InviteTestCase(cases.BaseServerTestCase):
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.sendLine(2, "JOIN #chan")
self.getMessages(1)
self.sendLine(2, "JOIN #chan")
self.getMessages(2)
self.getMessages(1)
@ -412,6 +373,87 @@ class InviteTestCase(cases.BaseServerTestCase):
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;

View File

@ -1,9 +1,72 @@
"""
RPL_ISUPPORT: `format <https://modern.ircdocs.horse/#rplisupport-005>`__
and various `tokens <https://modern.ircdocs.horse/#rplisupport-parameters>`__
"""
import re
from irctest import cases, runner
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_isupport("TARGMAX")
def testTargmax(self):
@ -17,7 +80,7 @@ class IsupportTestCase(cases.BaseServerTestCase):
self.connectClient("foo")
if "TARGMAX" not in self.server_support:
raise runner.NotImplementedByController("TARGMAX")
raise runner.IsupportTokenNotSupported("TARGMAX")
parts = self.server_support["TARGMAX"].split(",")
for part in parts:

View File

@ -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.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):
@ -19,13 +44,22 @@ class JoinTestCase(cases.BaseServerTestCase):
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
received_commands = {m.command for m in self.getMessages(1)}
expected_commands = {"353", "366"} # RPL_NAMREPLY # RPL_ENDOFNAMES
self.assertTrue(
expected_commands.issubset(received_commands),
expected_commands = {RPL_NAMREPLY, RPL_ENDOFNAMES, "JOIN"}
acceptable_commands = expected_commands | {"MODE"}
self.assertLessEqual( # set inclusion
expected_commands,
received_commands,
"Server sent {} commands, but at least {} were expected.".format(
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")
def testJoinNamreply(self):
@ -110,3 +144,95 @@ class JoinTestCase(cases.BaseServerTestCase):
'"foo" with an optional "+" or "@" prefix, but got: '
"{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}",
)

View File

@ -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
from irctest import cases, client_mock, runner
@ -89,6 +96,10 @@ class KickTestCase(cases.BaseServerTestCase):
self.assertMessageMatch(m3, command="KICK", params=["#chan", "bar", ANYSTR])
@cases.mark_specifications("RFC2812")
@cases.xfailIfSoftware(
["Charybdis", "ircu2", "irc2", "Solanum"],
"uses the nick of the kickee rather than the kicker.",
)
def testKickDefaultComment(self):
"""
"If a "comment" is
@ -219,13 +230,8 @@ class KickTestCase(cases.BaseServerTestCase):
self.connectClient("qux")
self.joinChannel(4, "#chan")
targmax = dict(
item.split(":", 1)
for item in self.server_support.get("TARGMAX", "").split(",")
if item
)
if targmax.get("KICK", "1") == "1":
raise runner.NotImplementedByController("Multi-target KICK")
if self.targmax.get("KICK", "1") == "1":
raise runner.OptionalExtensionNotSupported("Multi-target KICK")
# TODO: check foo is an operator

View File

@ -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;
so there may be many false positives.
<https://ircv3.net/specs/extensions/labeled-response.html>
"""
import re
@ -11,10 +11,11 @@ import pytest
from irctest import cases
from irctest.numerics import ERR_UNKNOWNCOMMAND
from irctest.patma import ANYDICT, AnyOptStr, NotStrRe, RemainingKeys, StrRe
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")
def testLabeledPrivmsgResponsesToMultipleClients(self):
self.connectClient(
@ -22,7 +23,10 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper
capabilities=["echo-message", "batch", "labeled-response"],
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.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
@ -299,7 +303,7 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper
tags={
"+draft/reply": msgid,
"+draft/react": "l😃l",
RemainingKeys(NotStrRe("label")): AnyOptStr(),
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
},
)
self.assertNotIn(
@ -367,7 +371,7 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper
tags={
"+draft/reply": msgid,
"+draft/react": "l😃l",
RemainingKeys(NotStrRe("label")): AnyOptStr(),
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
},
fail_msg="No TAGMSG received by the target after sending one out",
)

View 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, [])

View File

@ -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.xfailIfSoftware(["irc2"], "irc2 deprecated LIST")
def testListEmpty(self):
"""<https://tools.ietf.org/html/rfc1459#section-4.2.6>
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
<https://modern.ircdocs.horse/#list-message>
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.sendLine(2, "LIST")
m = self.getMessage(2)
if m.command == "321":
# skip RPL_LISTSTART
if m.command == RPL_LISTSTART:
# skip
m = self.getMessage(2)
while m.command == "322" and m.params[1] == "&SERVER":
# ngircd adds this pseudo-channel
# skip local pseudo-channels listed by ngircd and ircu
while m.command == RPL_LIST and m.params[1].startswith("&"):
m = self.getMessage(2)
self.assertNotEqual(
m.command,
"322", # RPL_LIST
RPL_LIST,
"LIST response gives (at least) one channel, whereas there " "is none.",
)
self.assertMessageMatch(
m,
command="323", # RPL_LISTEND
command=RPL_LISTEND,
fail_msg="Second reply to LIST is not 322 (RPL_LIST) "
"or 323 (RPL_LISTEND), or but: {msg}",
)
@cases.mark_specifications("RFC1459", "RFC2812")
@cases.xfailIfSoftware(["irc2"], "irc2 deprecated LIST")
def testListOne(self):
"""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/rfc2812#section-3.2.6>
<https://modern.ircdocs.horse/#list-message>
"""
self.connectClient("foo")
self.connectClient("bar")
@ -42,34 +73,331 @@ class ListTestCase(cases.BaseServerTestCase):
self.getMessages(1)
self.sendLine(2, "LIST")
m = self.getMessage(2)
if m.command == "321":
# skip RPL_LISTSTART
if m.command == RPL_LISTSTART:
# skip
m = self.getMessage(2)
self.assertNotEqual(
m.command,
"323", # RPL_LISTEND
RPL_LISTEND,
fail_msg="LIST response ended (ie. 323, aka RPL_LISTEND) "
"without listing any channel, whereas there is one.",
)
self.assertMessageMatch(
m,
command="322", # RPL_LIST
command=RPL_LIST,
fail_msg="Second reply to LIST is not 322 (RPL_LIST), "
"nor 323 (RPL_LISTEND) but: {msg}",
)
m = self.getMessage(2)
while m.command == "322" and m.params[1] == "&SERVER":
# ngircd adds this pseudo-channel
# skip local pseudo-channels listed by ngircd and ircu
while m.command == RPL_LIST and m.params[1].startswith("&"):
m = self.getMessage(2)
self.assertNotEqual(
m.command,
"322", # RPL_LIST
RPL_LIST,
fail_msg="LIST response gives (at least) two channels, "
"whereas there is only one.",
)
self.assertMessageMatch(
m,
command="323", # RPL_LISTEND
command=RPL_LISTEND,
fail_msg="Third reply to LIST is not 322 (RPL_LIST) "
"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"})

View File

@ -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
import re
from typing import Optional
@ -145,6 +153,10 @@ class BasicLusersTestCase(LusersTestCase):
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)
@ -162,10 +174,22 @@ class BasicLusersTestCase(LusersTestCase):
class LusersUnregisteredTestCase(LusersTestCase):
@cases.mark_specifications("RFC2812")
@cases.xfailIfSoftware(
["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)
@ -229,6 +253,10 @@ class LusersUnregisteredDefaultInvisibleTestCase(LusersUnregisteredTestCase):
)
@cases.mark_specifications("Ergo")
@cases.xfailIfSoftware(
["Nefarious"],
"Nefarious doesn't seem to distinguish unregistered users from normal ones",
)
def testLusers(self):
self.doLusersTest(False)
lusers = self.getLusers("bar", False)

View File

@ -1,5 +1,5 @@
"""
https://ircv3.net/specs/extensions/message-tags.html
`IRCv3 message-tags <https://ircv3.net/specs/extensions/message-tags>`_
"""
import pytest
@ -10,7 +10,7 @@ from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYDICT, ANYSTR, StrRe
class MessageTagsTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
class MessageTagsTestCase(cases.BaseServerTestCase):
@pytest.mark.arbitrary_client_tags
@cases.mark_capabilities("message-tags")
def testBasic(self):

View File

@ -1,10 +1,10 @@
"""
Section 3.2 of RFC 2812
<https://tools.ietf.org/html/rfc2812#section-3.3>
The PRIVMSG and NOTICE commands.
"""
from irctest import cases
from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYSTR
class PrivmsgTestCase(cases.BaseServerTestCase):
@ -13,6 +13,7 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
self.getMessages(1) # synchronize
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
self.getMessages(2) # synchronize
@ -33,6 +34,48 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
# ERR_NOSUCHNICK, ERR_NOSUCHCHANNEL, or ERR_CANNOTSENDTOCHAN
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):
@cases.mark_specifications("RFC1459", "RFC2812")
@ -52,6 +95,15 @@ class NoticeTestCase(cases.BaseServerTestCase):
)
@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):
"""
"automatic replies must never be
@ -72,6 +124,14 @@ class NoticeTestCase(cases.BaseServerTestCase):
class TagsTestCase(cases.BaseServerTestCase):
@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):
self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True)
self.connectClient(

View File

@ -1,14 +1,13 @@
"""
Tests METADATA features.
<http://ircv3.net/specs/core/metadata-3.2.html>
`Deprecated IRCv3 Metadata <https://ircv3.net/specs/core/metadata-3.2>`_
"""
from irctest import cases
class MetadataTestCase(cases.BaseServerTestCase):
valid_metadata_keys = {"valid_key1", "valid_key2"}
invalid_metadata_keys = {"invalid_key1", "invalid_key2"}
valid_metadata_keys = {"display-name", "avatar"}
invalid_metadata_keys = {"indisplay-name", "inavatar"}
@cases.mark_specifications("IRCv3", deprecated=True)
def testInIsupport(self):
@ -37,7 +36,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
def testGetOneUnsetValid(self):
"""<http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>"""
self.connectClient("foo")
self.sendLine(1, "METADATA * GET valid_key1")
self.sendLine(1, "METADATA * GET display-name")
m = self.getMessage(1)
self.assertMessageMatch(
m,
@ -53,7 +52,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>
"""
self.connectClient("foo")
self.sendLine(1, "METADATA * GET valid_key1 valid_key2")
self.sendLine(1, "METADATA * GET display-name avatar")
m = self.getMessage(1)
self.assertMessageMatch(
m,
@ -63,10 +62,10 @@ class MetadataTestCase(cases.BaseServerTestCase):
)
self.assertEqual(
m.params[1],
"valid_key1",
"display-name",
m,
fail_msg="Response to “METADATA * GET valid_key1 valid_key2"
"did not respond to valid_key1 first: {msg}",
fail_msg="Response to “METADATA * GET display-name avatar"
"did not respond to display-name first: {msg}",
)
m = self.getMessage(1)
self.assertMessageMatch(
@ -77,10 +76,10 @@ class MetadataTestCase(cases.BaseServerTestCase):
)
self.assertEqual(
m.params[1],
"valid_key2",
"avatar",
m,
fail_msg="Response to “METADATA * GET valid_key1 valid_key2"
"did not respond to valid_key2 as second response: {msg}",
fail_msg="Response to “METADATA * GET display-name avatar"
"did not respond to avatar as second response: {msg}",
)
@cases.mark_specifications("IRCv3", deprecated=True)
@ -136,7 +135,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
)
self.assertEqual(
m.params[1],
"valid_key1",
"display-name",
m,
fail_msg="Second param of 761 after setting “{expects}” to "
"{}” is not “{expects}”: {msg}.",
@ -191,7 +190,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
def testSetGetValid(self):
"""<http://ircv3.net/specs/core/metadata-3.2.html>"""
self.connectClient("foo")
self.assertSetGetValue("*", "valid_key1", "myvalue")
self.assertSetGetValue("*", "display-name", "myvalue")
@cases.mark_specifications("IRCv3", deprecated=True)
def testSetGetZeroCharInValue(self):
@ -199,7 +198,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-restrictions>
"""
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)
def testSetGetHeartInValue(self):
@ -210,7 +209,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
self.connectClient("foo")
self.assertSetGetValue(
"*",
"valid_key1",
"display-name",
"->{}<-".format(heart),
"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
# not like it
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)}
self.assertNotIn(
@ -234,7 +233,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
"UTF-8 was answered with 761 (RPL_KEYVALUE)",
)
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)}
self.assertNotIn(

View File

@ -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
from irctest.basecontrollers import NotImplementedByController
import pytest
from irctest import cases, runner
from irctest.client_mock import NoMessageException
from irctest.numerics import (
RPL_ENDOFMONLIST,
@ -14,10 +16,10 @@ from irctest.numerics import (
from irctest.patma import ANYSTR, StrRe
class MonitorTestCase(cases.BaseServerTestCase):
class _BaseMonitorTestCase(cases.BaseServerTestCase):
def check_server_support(self):
if "MONITOR" not in self.server_support:
raise NotImplementedByController("MONITOR")
raise runner.IsupportTokenNotSupported("MONITOR")
def assertMononline(self, client, nick, m=None):
if not m:
@ -43,6 +45,8 @@ class MonitorTestCase(cases.BaseServerTestCase):
extra_format=(nick,),
)
class MonitorTestCase(_BaseMonitorTestCase):
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorOneDisconnected(self):
@ -245,6 +249,23 @@ class MonitorTestCase(cases.BaseServerTestCase):
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_isupport("MONITOR")
def testMonitorList(self):
@ -280,6 +301,35 @@ class MonitorTestCase(cases.BaseServerTestCase):
self.sendLine(1, "MONITOR L")
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_isupport("MONITOR")
def testNickChange(self):
@ -296,10 +346,11 @@ class MonitorTestCase(cases.BaseServerTestCase):
self.sendLine(2, "NICK qux")
self.getMessages(2)
mononline = self.getMessages(1)[0]
self.assertEqual(mononline.command, RPL_MONONLINE)
self.assertEqual(len(mononline.params), 2, mononline.params)
self.assertIn(mononline.params[0], ("bar", "*"))
self.assertEqual(mononline.params[1].split("!")[0], "qux")
self.assertMessageMatch(
mononline,
command=RPL_MONONLINE,
params=[StrRe(r"(bar|\*)"), StrRe("qux(!.*)?")],
)
# no numerics for a case change
self.sendLine(2, "NICK QUX")
@ -310,7 +361,246 @@ class MonitorTestCase(cases.BaseServerTestCase):
self.getMessages(2)
monoffline = self.getMessages(1)[0]
# should get RPL_MONOFFLINE with the current unfolded nick
self.assertEqual(monoffline.command, RPL_MONOFFLINE)
self.assertEqual(len(monoffline.params), 2, monoffline.params)
self.assertIn(monoffline.params[0], ("bar", "*"))
self.assertEqual(monoffline.params[1].split("!")[0], "QUX")
self.assertMessageMatch(
monoffline,
command=RPL_MONOFFLINE,
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")

View File

@ -1,6 +1,5 @@
"""
Tests multi-prefix.
<http://ircv3.net/specs/extensions/multi-prefix-3.1.html>
`IRCv3 multi-prefix <https://ircv3.net/specs/extensions/multi-prefix>`_
"""
from irctest import cases
@ -16,7 +15,7 @@ class MultiPrefixTestCase(cases.BaseServerTestCase):
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.sendLine(1, "MODE #chan +v foo")
self.getMessages(1)

View File

@ -1,5 +1,5 @@
"""
draft/multiline
`Draft IRCv3 multiline <https://ircv3.net/specs/extensions/multiline>`_
"""
from irctest import cases
@ -12,7 +12,7 @@ CONCAT_TAG = "draft/multiline-concat"
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")
def testBasic(self):
self.connectClient(

View File

@ -1,9 +1,114 @@
from irctest import cases
from irctest.numerics import RPL_ENDOFNAMES
from irctest.patma import ANYSTR
"""
The NAMES command (`RFC 1459
<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):
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")
def testNamesInvalidChannel(self):
"""
@ -47,3 +152,101 @@ class NamesTestCase(cases.BaseServerTestCase):
command=RPL_ENDOFNAMES,
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)

View File

@ -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
from irctest import cases
from irctest.numerics import RPL_NAMREPLY
class PartTestCase(cases.BaseServerTestCase):
@ -75,6 +85,12 @@ class PartTestCase(cases.BaseServerTestCase):
self.getMessages(1)
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")
# both the PART'ing client and the other channel member should receive
# a PART line:
@ -83,6 +99,21 @@ class PartTestCase(cases.BaseServerTestCase):
m = self.getMessage(2)
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")
def testBasicPartRfc2812(self):
"""

View File

@ -1,3 +1,7 @@
"""
The PING and PONG commands
"""
from irctest import cases
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_NOORIGIN
from irctest.patma import ANYSTR

View File

@ -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
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN
from irctest.patma import StrRe
class ChannelQuitTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC2812")
@cases.xfailIfSoftware(["ircu2", "Nefarious", "snircd"], "ircu2 does not echo QUIT")
def testQuit(self):
"""“Once a user has joined a channel, he receives information about
all commands his server receives affecting the channel. This
@ -30,31 +39,3 @@ class ChannelQuitTestCase(cases.BaseServerTestCase):
m = self.getMessage(1)
self.assertMessageMatch(m, command="QUIT", params=[StrRe(".*qux out.*")])
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, [])

View File

@ -1,9 +1,12 @@
"""
`Ergo <https://ergo.chat/>`_-specific tests of responses to DoS attacks
using long lines.
"""
from irctest import cases
class ReadqTestCase(cases.BaseServerTestCase):
"""Test responses to DoS attacks using long lines."""
@cases.mark_specifications("Ergo")
@cases.mark_capabilities("message-tags")
def testReadqTags(self):

View File

@ -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
from irctest.numerics import ERR_ERRONEUSNICKNAME, ERR_NICKNAMEINUSE, RPL_WELCOME
from irctest import cases, runner
from irctest.numerics import (
ERR_ERRONEUSNICKNAME,
ERR_NICKNAMEINUSE,
RPL_HELLO,
RPL_WELCOME,
)
from irctest.patma import ANYDICT
@ -57,6 +60,12 @@ class RegressionsTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("message-tags", "batch", "echo-message", "server-time")
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
self.connectClient(
"alice",
@ -99,13 +108,13 @@ class RegressionsTestCase(cases.BaseServerTestCase):
)
@cases.mark_specifications("RFC1459")
@cases.xfailIfSoftware(["ngIRCd"], "wat")
def testStarNick(self):
self.addClient(1)
self.sendLine(1, "NICK *")
self.sendLine(1, "USER u s e r")
replies = {"NOTICE"}
time.sleep(2) # give time to slow servers, like irc2 to reply
while replies == {"NOTICE"}:
while replies <= {"NOTICE", RPL_HELLO}:
replies = set(msg.command for msg in self.getMessages(1, synchronize=False))
self.assertIn(ERR_ERRONEUSNICKNAME, replies)
self.assertNotIn(RPL_WELCOME, replies)

View File

@ -1,3 +1,7 @@
"""
RELAYMSG command of `Ergo <https://ergo.chat/>`_
"""
from irctest import cases
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYSTR

View File

@ -1,3 +1,7 @@
"""
Roleplay features of `Ergo <https://ergo.chat/>`_
"""
from irctest import cases
from irctest.irc_utils.junkdrawer import random_name
from irctest.numerics import ERR_CANNOTSENDRP

View File

@ -12,9 +12,9 @@ class RegistrationTestCase(cases.BaseServerTestCase):
@cases.mark_services
class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
class SaslTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
@cases.skipUnlessHasMechanism("PLAIN")
def testPlain(self):
"""PLAIN authentication with correct username/password."""
self.controller.registerUser(self, "foo", "sesame")
@ -54,7 +54,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
)
@cases.mark_specifications("IRCv3")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainNonAscii(self):
password = "é" * 100
authstring = base64.b64encode(
@ -82,7 +82,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
)
@cases.mark_specifications("IRCv3")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainNoAuthzid(self):
"""“message = [authzid] UTF8NUL authcid UTF8NUL passwd
@ -170,7 +170,22 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
)
@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):
"""Test the client splits large AUTHENTICATE messages whose payload
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.
@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):
"""Test the client splits large AUTHENTICATE messages whose payload
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.
@cases.mark_specifications("IRCv3")
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScramSha256Success(self):
self.controller.registerUser(self, "Scramtest", "sesame")
@ -333,7 +355,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
self.confirmSuccessfulAuth()
@cases.mark_specifications("IRCv3")
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScramSha256Failure(self):
self.controller.registerUser(self, "Scramtest", "sesame")

View File

@ -0,0 +1,65 @@
"""
`IRCv3 SETNAME<https://ircv3.net/specs/extensions/setname>`_
"""
from irctest import cases
from irctest.numerics import RPL_WHOISUSER
class SetnameMessageTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
@cases.mark_capabilities("setname")
def testSetnameMessage(self):
self.connectClient("foo", capabilities=["setname"], skip_if_cap_nak=True)
self.sendLine(1, "SETNAME bar")
self.assertMessageMatch(
self.getMessage(1),
command="SETNAME",
params=["bar"],
)
self.sendLine(1, "WHOIS foo")
whoisuser = [m for m in self.getMessages(1) if m.command == RPL_WHOISUSER][0]
self.assertEqual(whoisuser.params[-1], "bar")
@cases.mark_specifications("IRCv3")
@cases.mark_capabilities("setname")
def testSetnameChannel(self):
"""“[Servers] MUST send the server-to-client version of the
SETNAME message to all clients in common channels, as well as
to the client from which it originated, to confirm the change
has occurred.
The SETNAME message MUST NOT be sent to clients which do not
have the setname capability negotiated.
"""
self.connectClient("foo", capabilities=["setname"], skip_if_cap_nak=True)
self.connectClient("bar", capabilities=["setname"], skip_if_cap_nak=True)
self.connectClient("baz")
self.joinChannel(1, "#chan")
self.joinChannel(2, "#chan")
self.joinChannel(3, "#chan")
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "SETNAME qux")
self.assertMessageMatch(
self.getMessage(1),
command="SETNAME",
params=["qux"],
)
self.assertMessageMatch(
self.getMessage(2),
command="SETNAME",
params=["qux"],
)
self.assertEqual(
self.getMessages(3),
[],
"Got SETNAME response when it was not negotiated",
)

View File

@ -1,3 +1,10 @@
"""
STATUSMSG ISUPPORT token and related PRIVMSG (`Modern
<https://modern.ircdocs.horse/#statusmsg-parameter>`__)
TODO: cross-reference Modern
"""
from irctest import cases, runner
from irctest.numerics import RPL_NAMREPLY
@ -10,6 +17,11 @@ class StatusmsgTestCase(cases.BaseServerTestCase):
self.assertEqual(self.server_support["STATUSMSG"], "~&@%+")
@cases.mark_isupport("STATUSMSG")
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "snircd"],
"STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG "
"target (only for WALLCOPS/WALLCHOPS/...)",
)
def testStatusmsgFromOp(self):
"""Test that STATUSMSG are sent to the intended recipients,
with the intended prefixes."""
@ -61,6 +73,11 @@ class StatusmsgTestCase(cases.BaseServerTestCase):
self.assertEqual(len(unprivilegedMessages), 0)
@cases.mark_isupport("STATUSMSG")
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "snircd"],
"STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG "
"target (only for WALLCOPS/WALLCHOPS/...)",
)
def testStatusmsgFromRegular(self):
"""Test that STATUSMSG are sent to the intended recipients,
with the intended prefixes."""

View File

@ -0,0 +1,48 @@
import math
import time
from irctest import cases
from irctest.numerics import RPL_TIME
from irctest.patma import ANYSTR, StrRe
class TimeTestCase(cases.BaseServerTestCase):
def testTime(self):
self.connectClient("user")
time_before = math.floor(time.time())
self.sendLine(1, "TIME")
msg = self.getMessage(1)
time_after = math.ceil(time.time())
if len(msg.params) == 5:
# ircu2, snircd
self.assertMessageMatch(
msg,
command=RPL_TIME,
params=["user", "My.Little.Server", StrRe("[0-9]+"), "0", ANYSTR],
)
self.assertIn(
int(msg.params[2]),
range(time_before, time_after + 1),
"Timestamp not in expected range",
)
elif len(msg.params) == 4:
# bahamut
self.assertMessageMatch(
msg,
command=RPL_TIME,
params=["user", "My.Little.Server", StrRe("[0-9]+"), ANYSTR],
)
self.assertIn(
int(msg.params[2]),
range(time_before, time_after + 1),
"Timestamp not in expected range",
)
else:
# Common case
self.assertMessageMatch(
msg, command=RPL_TIME, params=["user", "My.Little.Server", ANYSTR]
)

View File

@ -1,16 +1,39 @@
"""
The TOPIC 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/#topic-message>`__)
"""
from irctest import cases, client_mock, runner
from irctest.numerics import ERR_CHANOPRIVSNEEDED, RPL_NOTOPIC, RPL_TOPIC, RPL_TOPICTIME
class TopicTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testTopic(self):
def testTopicRfc(self):
"""“Once a user has joined a channel, he receives information about
all commands his server receives affecting the channel. This
includes [] TOPIC
-- <https://tools.ietf.org/html/rfc1459#section-4.2.1>
and <https://tools.ietf.org/html/rfc2812#section-3.2.1>
"""
self._testTopic(assert_echo=False)
@cases.mark_specifications("Modern")
def testTopicModern(self):
""" "If the topic of a channel is changed or cleared, every client in that
channel (including the author of the topic change) will receive a TOPIC command
with the new topic as argument (or an empty argument if the topic was cleared)
alerting them to how the topic has changed.
Clients joining the channel in the future will receive a RPL_TOPIC numeric (or
lack thereof) accordingly."
-- https://modern.ircdocs.horse/#topic-message
"""
self._testTopic(assert_echo=True)
def _testTopic(self, assert_echo: bool):
self.connectClient("foo")
self.joinChannel(1, "#chan")
@ -34,6 +57,7 @@ class TopicTestCase(cases.BaseServerTestCase):
)
self.assertMessageMatch(m, command="TOPIC")
except client_mock.NoMessageException:
self.assertFalse(assert_echo, "TOPIC was not echoed back to the author")
# The RFCs do not say TOPIC must be echoed
pass
m = self.getMessage(2)

Some files were not shown because too many files have changed in this diff Show More