56 Commits

Author SHA1 Message Date
cab27be1f5 fix incorrect type for empty tags 2021-02-15 21:36:55 +01:00
fc6bd4968d add a test for case changes 2021-02-15 21:36:55 +01:00
c2adc33109 remove starttls test 2021-02-15 21:36:55 +01:00
4519927265 add a test for PART messages 2021-02-15 21:36:55 +01:00
42f3a1f2fe add an away test 2021-02-15 21:36:55 +01:00
f3ff2a424b deflake another test 2021-02-15 21:13:12 +01:00
0c03a8e1c7 expand pyflakes list 2021-02-15 21:12:06 +01:00
7a05081960 add makefile 2021-02-15 21:11:56 +01:00
ee2e77b1e6 deflake registration tests by waiting for quit 2021-02-15 21:11:15 +01:00
9ff2239943 test RPL_ENDOFMONLIST responses 2021-02-15 21:06:18 +01:00
5f75f231a4 add list of numerics, start using them 2021-02-15 21:06:05 +01:00
b3a4a18885 kick privileges test 2021-02-15 21:05:19 +01:00
24df4e4496 fix lots of pyflakes3 failures 2021-02-15 21:00:51 +01:00
2b8aeacd4a tests for RPL_NOTOPIC 2021-02-15 20:59:03 +01:00
fc15ff09c0 fix testWhoisUser for charybdis. 2021-02-15 20:58:52 +01:00
7bf7df8ad1 basic whois test 2021-02-15 20:58:30 +01:00
6abc5b4f98 channel quit test case 2021-02-15 20:53:08 +01:00
cefbabf5c3 test incorrect PASS passwords 2021-02-15 20:52:49 +01:00
9b6f65b622 add regression tests 2021-02-15 20:52:00 +01:00
034fe5c51c framework enhancements 2021-02-15 20:47:08 +01:00
911c0ded04 add a test for INVITE 2021-02-15 20:45:08 +01:00
9b99c6ce20 Fix compatibility with return value of SSLSocket.sendall in python >= 3.6.
https://bugs.python.org/issue25951
2021-02-14 23:18:51 +01:00
e6b1ca5521 remove psutil 2021-02-14 22:59:19 +01:00
ac2671acb0 limnoria: add support for STS. 2021-02-14 22:22:01 +01:00
8a81224ba8 Fix ecdsa tests to use the same protocol as Atheme.
Which requires not hashing the challenge.
2019-12-26 12:10:45 +01:00
442c57e6c6 Temporarily disabling sts on Limnoria until it's released. 2019-12-09 22:13:55 +01:00
f8faec77f1 Ignore return value of sendall; it's not None on py < 3.6.
https://bugs.python.org/issue25951
2019-12-08 22:36:56 +01:00
71d9315813 Add STS tests. 2019-12-08 21:26:21 +01:00
857e8d195e Fix exception handling in testUntrustedCertificate on closed connection. 2019-10-22 18:28:39 +02:00
19a0623e91 echo-message: Check server-time more accurately, and handle slight timing differences due to late application 2018-07-12 15:19:07 +02:00
de4c51e744 oragono: Fix ACC command 2018-07-12 15:19:07 +02:00
1198504f74 Add tests for labeled-responses 2018-07-12 15:17:00 +02:00
5b319a46ee Make message tests less fragile 2018-07-12 15:17:00 +02:00
5950d97926 Bump version number to 0.1.2. 2017-12-10 20:21:29 +01:00
b35a7f7a60 Fix merge. 2017-12-10 20:21:02 +01:00
f47584589b Add tests for nonexistent channels
Inspired by oragono issue #165.
2017-12-09 09:44:45 +01:00
0804b78572 Add multi-prefix testcase 2017-12-09 09:43:58 +01:00
aecfc26a63 tls -> starttls, to match feature name better 2017-12-09 09:43:46 +01:00
f9f13961eb controllers: Add hybrid controller 2017-12-09 09:43:34 +01:00
2bc3cafd25 Make openssl binary configurable, for OSX 2017-12-09 09:43:19 +01:00
86d26d6121 charybdis: New releases name the binary 'charybdis' rather than 'ircd' 2017-12-09 09:42:58 +01:00
3d0b493a11 Don't send empty CAP REQ 2017-12-09 09:41:53 +01:00
711de43b22 Fix channel deterministic joining s'more 2017-12-09 09:41:45 +01:00
8b52ceeee3 Make tests around joining channels more deterministic 2017-12-09 09:41:22 +01:00
6d9c06096b channels: Check server casemapping before doing mapping checks 2017-12-09 09:38:26 +01:00
9575987555 For SCRAM, check clients send an empty response at the end.
https://github.com/ircv3/ircv3-specifications/pull/326
2017-11-15 17:11:44 +01:00
15a92ccf0b oragono: Allow TLS tests 2017-10-01 13:15:46 +02:00
0c12e0ed20 oragono: Use new registration command 2017-07-28 20:19:46 +02:00
f71badbbc1 oragono: Fix config so it loads 2017-07-28 20:19:38 +02:00
41f0418df7 Add gIRC controller 2017-01-11 00:35:22 +01:00
2a55c85c5a gitignore: Use gitignore.io 2017-01-11 00:35:07 +01:00
0c7358c0a5 test_sasl: Unify successful auth checking a bit more 2017-01-11 00:29:14 +01:00
6326af34cc Add Oragono IRCd 2017-01-11 00:29:03 +01:00
ba1fe57248 Bump version number. 2017-01-11 00:07:55 +01:00
6baee70852 Add tests for SCRAM. 2017-01-11 00:07:25 +01:00
2bdcba3da5 Update README. 2016-11-20 09:18:25 +01:00
156 changed files with 3772 additions and 22286 deletions

View File

@ -1,120 +0,0 @@
#!/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()

View File

@ -1,41 +0,0 @@
name: Lint
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Cache dependencies
uses: actions/cache@v2
with:
path: |
~/.cache
key: ${{ runner.os }}-lint
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit pytest
pip install -r requirements.txt
- name: Lint
run: |
pre-commit run -a
- name: Check generated workflows are in sync
run: |
python make_workflows.py
git diff --exit-code

File diff suppressed because it is too large Load Diff

View File

@ -1,218 +0,0 @@
# This file was auto-generated by make_workflows.py.
# Do not edit it manually, modifications will be lost.
jobs:
build-anope:
runs-on: ubuntu-20.04
steps:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- name: Cache dependencies
uses: actions/cache@v2
with:
key: 3-${{ runner.os }}-anope-devel_release
path: '~/.cache
${ github.workspace }/anope
'
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Checkout Anope
uses: actions/checkout@v2
with:
path: anope
ref: 2.0.9
repository: anope/anope
- name: Build Anope
run: |
cd $GITHUB_WORKSPACE/anope/
cp $GITHUB_WORKSPACE/data/anope/* .
CFLAGS=-O0 ./Config -quick
make -C build -j 4
make -C build install
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v2
with:
name: installed-anope
path: ~/artefacts-*.tar.gz
retention-days: 1
build-inspircd:
runs-on: ubuntu-20.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
with:
python-version: 3.7
- name: Checkout InspIRCd
uses: actions/checkout@v2
with:
path: inspircd
ref: insp3
repository: inspircd/inspircd
- name: Build InspIRCd
run: |
cd $GITHUB_WORKSPACE/inspircd/
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
./configure --prefix=$HOME/.local/inspircd --development
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
with:
name: installed-inspircd
path: ~/artefacts-*.tar.gz
retention-days: 1
publish-test-results:
if: success() || failure()
name: Publish Dashboard
needs:
- test-inspircd
- test-inspircd-anope
- test-inspircd-atheme
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Download Artifacts
uses: actions/download-artifact@v2
with:
path: artifacts
- name: Install dashboard dependencies
run: |-
python -m pip install --upgrade pip
pip install defusedxml docutils -r requirements.txt
- name: Generate dashboard
run: |-
shopt -s globstar
python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml
echo '/ /index.xhtml' > dashboard/_redirects
- name: Install netlify-cli
run: npm i -g netlify-cli
- env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
name: Deploy to Netlify
run: ./.github/deploy_to_netlify.py
test-inspircd:
needs:
- build-inspircd
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-inspircd
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH
make inspircd
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
with:
name: pytest-results_inspircd_devel_release
path: pytest.xml
test-inspircd-anope:
needs:
- build-inspircd
- build-anope
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-inspircd
path: '~'
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-anope
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH make
inspircd-anope
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
with:
name: pytest-results_inspircd-anope_devel_release
path: pytest.xml
test-inspircd-atheme:
needs:
- build-inspircd
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-inspircd
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install system dependencies
run: sudo apt-get install atheme-services faketime
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH
make inspircd-atheme
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
with:
name: pytest-results_inspircd-atheme_devel_release
path: pytest.xml
name: irctest with devel_release versions
'on':
schedule:
- cron: 51 8 * * 6
- cron: 51 8 * * 0
- cron: 51 17 * * *
workflow_dispatch: null

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
exclude: ^irctest/scram
repos:
- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black
language_version: python3
- repo: https://github.com/PyCQA/isort
rev: 5.5.2
hooks:
- id: isort
- repo: https://github.com/PyCQA/flake8
rev: 5.0.4
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.812
hooks:
- id: mypy

View File

@ -1,92 +0,0 @@
# Contributing
## Code style
### Syntax
Any color you like as long as it's [Black](https://github.com/psf/black).
In short:
* 88 columns
* double quotes
* avoid backslashes at line breaks (use parentheses)
* closing brackets/parentheses/... go on the same indent level as the line
that opened them
We also use `isort` to order imports (in short: just
[follow PEP 8](https://www.python.org/dev/peps/pep-0008/#imports))
You can use [pre-commit](https://pre-commit.com/) to automatically run them
when you create a git commit.
Alternatively, run `pre-commit run -a`
### Naming
[Follow PEP 8](https://www.python.org/dev/peps/pep-0008/#naming-conventions),
with these exceptions:
* assertion methods (eg. `assertMessageMatch` are mixedCase to be consistent
with the unittest module)
* other methods defined in `cases.py` are also mixedCase for consistency with
the former, for now
* test names are also mixedCase for the same reason
Additionally:
* test module names should be snake\_case and match the name of the
specification they are testing (if IRCv3), or the feature they are
testing (if RFC or just common usage)
## What to test
**All tests should have a docstring** pointing to a reference document
(eg. RFC, IRCv3 specification, or modern.ircdocs.horse).
If there is no reference, documentation can do.
If the behavior being tested is not documented, then **please document it
outside** this repository (eg. at modern.ircdocs.horse),
and/or get it specified through IRCv3.
"That's just how everyone does it" is not good justification.
Linking to an external document saying "Here is how everyone does it" is.
If reference documents / documentations are long or not trivial,
**try to quote the specific part being tested**.
See `irctest/server_tests/kick.py` for example.
Tests for **pending/draft specifications are welcome**.
Note that irctest also welcomes implementation-specific tests for
functional testing; for now only Ergo.
This does not relax the requirement on documentating tests.
## Writing tests
**Use unittest-style assertions** (`self.assertEqual(x, y)` instead of
pytest-style (`assert x == y`). This allows consistency with the assertion
methods we define, such as `assertMessageMatch`.
Always **add an error message in assertions**.
`irctest` should show readable errors to people unfamiliar with the
codebase.
Ideally, explain what happened and what should have happened instead.
All tests should be tagged with
`@cases.mark_specifications`.
## Continuous integration
We run automated tests on all commits and pull requests, to check that tests
accept existing implementations.
Scripts to run the tests are defined in `workflows.yml`, and the
`make_workflows.py` script reads this configuration to generate files
in `.github/workflows/` that are used by the CI.
If an implementation cannot pass a test, that test should be excluded via
a definition in the Makefile.
If it is a bug, please open a bug report to the affected software if possible,
and link to the bug report in a comment.

286
Makefile
View File

@ -1,283 +1,5 @@
PYTEST ?= python3 -m pytest
.PHONY: oragono
# Extra arguments to pass to pytest (eg. `-n 4` to run in parallel if
# pytest-xdist is installed)
PYTEST_ARGS ?=
# Will be appended at the end of the -k argument to pytest
EXTRA_SELECTORS ?=
BAHAMUT_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_SELECTORS)
CHARYBDIS_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
ERGO_SELECTORS := \
not deprecated \
$(EXTRA_SELECTORS)
HYBRID_SELECTORS := \
not Ergo \
and not deprecated \
$(EXTRA_SELECTORS)
INSPIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
# HelpTestCase fails because it returns NOTICEs instead of numerics
IRCU2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
# same justification as ircu2
# lusers "unregistered" tests fail because
NEFARIOUS_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
# same justification as ircu2
SNIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
IRC2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
MAMMON_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
NGIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
PLEXUS4_SELECTORS := \
not Ergo \
and not deprecated \
$(EXTRA_SELECTORS)
# Limnoria can actually pass all the test so there is none to exclude.
# `(foo or not foo)` serves as a `true` value so it doesn't break when
# $(EXTRA_SELECTORS) is non-empty
LIMNORIA_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
SOLANUM_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
# Same as Limnoria
SOPEL_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# Tests marked with arbitrary_client_tags can't pass because Unreal whitelists which tags it relays
# Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149
# Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs
UNREALIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not arbitrary_client_tags \
and not react_tag \
and not private_chathistory \
$(EXTRA_SELECTORS)
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sopel solanum unrealircd
all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sopel solanum unrealircd
flakes:
find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3
bahamut:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
-m 'not services' \
-n 4 \
-vv -s \
-k '$(BAHAMUT_SELECTORS)'
bahamut-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-k '$(BAHAMUT_SELECTORS)'
bahamut-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k '$(BAHAMUT_SELECTORS)'
charybdis:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.charybdis \
--services-controller=irctest.controllers.atheme_services \
-k '$(CHARYBDIS_SELECTORS)'
ergo:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ergo \
-k "$(ERGO_SELECTORS)"
hybrid:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.hybrid \
--services-controller=irctest.controllers.anope_services \
-k "$(HYBRID_SELECTORS)"
inspircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
-m 'not services' \
-k '$(INSPIRCD_SELECTORS)'
inspircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-k '$(INSPIRCD_SELECTORS)'
inspircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k '$(INSPIRCD_SELECTORS)'
ircu2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.ircu2 \
-m 'not services and not IRCv3' \
-n 4 \
-k '$(IRCU2_SELECTORS)'
nefarious:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.nefarious \
-m 'not services' \
-n 4 \
-k '$(NEFARIOUS_SELECTORS)'
snircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.snircd \
-m 'not services and not IRCv3' \
-n 4 \
-k '$(SNIRCD_SELECTORS)'
irc2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.irc2 \
-m 'not services and not IRCv3' \
-n 4 \
-k '$(IRC2_SELECTORS)'
limnoria:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.limnoria \
-k '$(LIMNORIA_SELECTORS)'
mammon:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.mammon \
-k '$(MAMMON_SELECTORS)'
plexus4:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.plexus4 \
--services-controller=irctest.controllers.anope_services \
-k "$(PLEXUS4_SELECTORS)"
ngircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
-m 'not services' \
-n 4 \
-k "$(NGIRCD_SELECTORS)"
ngircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k "$(NGIRCD_SELECTORS)"
ngircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-k "$(NGIRCD_SELECTORS)"
solanum:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.solanum \
--services-controller=irctest.controllers.atheme_services \
-k '$(SOLANUM_SELECTORS)'
sopel:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sopel \
-k '$(SOPEL_SELECTORS)'
unrealircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
-m 'not services' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-5: unrealircd
unrealircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-dlk:
pifpaf run mysql -- $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.dlk_services \
-m 'services' \
-k '$(UNREALIRCD_SELECTORS)'
oragono:
pyflakes3 ./irctest/cases.py ./irctest/client_mock.py ./irctest/controllers/oragono.py irctest/server_tests/*.py
./test.py irctest.controllers.oragono

182
README.md
View File

@ -1,7 +1,10 @@
# irctest
This project aims at testing interoperability of software using the
IRC protocol, by running them against common test suites.
IRC protocol, by running them against test suites and making different
software communicate with each other.
It is very young and does not contain a lot of test cases yet.
## The big picture
@ -11,193 +14,95 @@ This project contains:
* small wrappers around existing software to run tests on them
Wrappers run software in temporary directories, so running `irctest` should
have no side effect.
have no side effect, with [the exception of Sopel](https://github.com/sopel-irc/sopel/issues/946).
## Prerequisites
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)
to your `PATH` if it is not.
Add `~/.local/bin/` to your `PATH` if it is not.
```
export PATH=$HOME/.local/bin/:$HOME/go/bin/:$PATH
export PATH=$HOME/.local/bin/:$PATH
```
## Using pytest
## Run tests
irctest is invoked using the pytest test runner / CLI.
You can usually invoke it with `python3 -m pytest` command; which can often
be called by the `pytest` or `pytest-3` commands (if not, alias them if you
are planning to use them often).
The rest of this README assumes `pytest` works.
## Test selection
A major feature of pytest that irctest heavily relies on is test selection.
Using the `-k` option, you can select and deselect tests based on their names
and/or markers (listed in `pytest.ini`).
For example, you can run `LUSERS`-related tests with `-k lusers`.
Or only tests based on RFC1459 with `-k rfc1459`.
By default, all tests run; even niche ones. So you probably always want to
use these options: `-k 'not Ergo and not deprecated and not strict`.
This excludes:
* `Ergo`-specific tests (included as Ergo uses irctest as its official
integration test suite)
* tests for deprecated specifications, such as the IRCv3 METADATA
specification
* tests that check for a strict interpretation of a specification, when
the specification is ambiguous.
## Running tests
### Servers
#### Ergo:
To run (client) tests on Limnoria:
```
cd /tmp/
git clone https://github.com/ergochat/ergo.git
cd ergo/
make install
cd ~/irctest
pytest --controller irctest.controllers.ergo -k 'not deprecated'
pip3 install --user limnoria
python3 -m irctest irctest.controllers.limnoria
```
#### Solanum:
To run (client) tests on Sopel:
```
cd /tmp/
git clone https://github.com/solanum-ircd/solanum.git
cd solanum
./autogen.sh
./configure --prefix=$HOME/.local/
make -j 4
make install
pytest --controller irctest.controllers.solanum -k 'not Ergo and not deprecated and not strict'
pip3 install --user sopel
mkdir ~/.sopel/
python3 -m irctest irctest.controllers.sopel
```
#### Charybdis:
```
cd /tmp/
git clone https://github.com/atheme/charybdis.git
cd charybdis
./autogen.sh
./configure --prefix=$HOME/.local/
make -j 4
make install
cd ~/irctest
pytest --controller irctest.controllers.charybdis -k 'not Ergo and not deprecated and not strict'
```
#### InspIRCd:
To run (server) tests on InspIRCd:
```
cd /tmp/
git clone https://github.com/inspircd/inspircd.git
cd inspircd
# optional, makes tests run considerably faster
patch src/inspircd.cpp < ~/irctest/patches/inspircd_mainloop.patch
./configure --prefix=$HOME/.local/ --development
make -j 4
make install
cd ~/irctest
pytest --controller irctest.controllers.inspircd -k 'not Ergo and not deprecated and not strict'
python3 -m irctest irctest.controllers.inspircd
```
#### Mammon:
To run (server) tests on Mammon:
```
pip3 install --user git+https://github.com/mammon-ircd/mammon.git
cd ~/irctest
pytest --controller irctest.controllers.mammon -k 'not Ergo and not deprecated and not strict'
python3 -m irctest irctest.controllers.mammon
```
#### UnrealIRCd:
To run (server) tests on Charybdis::
```
cd /tmp/
git clone https://github.com/unrealircd/unrealircd.git
cd unrealircd
./Config # This will ask a few questions, answer them.
git clone https://github.com/atheme/charybdis.git
cd charybdis
./configure --prefix=$HOME/.local/
make -j 4
make install
cd ~/irctest
pytest --controller irctest.controllers.unreal -k 'not Ergo and not deprecated and not strict'
python3 -m irctest irctest.controllers.charybdis
```
### Servers with services
Besides Ergo (that has built-in services), most server controllers can optionally run
service packages.
#### Atheme:
You can install it with
## Full help
```
sudo apt install atheme-services
```
usage: python3 -m irctest [-h] [--show-io] [-v] [-s SPECIFICATION] [-l] module
and add this to the `pytest` call:
positional arguments:
module The module used to run the tested program.
```
--services-controller irctest.controllers.atheme_services
```
#### Anope:
Build with:
```
cd /tmp/
git clone https://github.com/anope/anope.git
cd anope
./Config # This will ask a few questions, answer them.
make -C build -j 4
make -C build install
```
and add this to the `pytest` call:
```
--services-controller irctest.controllers.anope_services
```
### Clients
#### Limnoria:
```
pip3 install --user limnoria pyxmpp2-scram
cd ~/irctest
pytest --controller irctest.controllers.limnoria
```
#### Sopel:
```
pip3 install --user sopel
mkdir ~/.sopel/
cd ~/irctest
pytest --controller irctest.controllers.sopel
optional arguments:
-h, --help show this help message and exit
--show-io Show input/outputs with the tested program.
-v, --verbose Verbosity. Give this option multiple times to make it
even more verbose.
-s SPECIFICATION, --specification SPECIFICATION
The set of specifications to test the program with.
Valid values: RFC1459, RFC2812, IRCv3.1, IRCv3.2. Use
this option multiple times to test with multiple
specifications. If it is not given, defaults to all.
-l, --loose Disables strict checks of conformity to the
specification. Strict means the specification is
unclear, and the most restrictive interpretation is
choosen.
```
## What `irctest` is not
@ -209,4 +114,3 @@ At best, `irctest` can help you find issues in your software, but it may
still have false positives (because it does not implement itself a
full-featured client/server, so it supports only “usual” behavior).
Bug reports for false positives are welcome.

View File

@ -1,132 +0,0 @@
import importlib
import _pytest.unittest
import pytest
# Must be called before importing irctest.cases.
pytest.register_assert_rewrite("irctest.cases")
from irctest.basecontrollers import ( # noqa: E402
BaseClientController,
BaseServerController,
)
from irctest.cases import ( # noqa: E402
BaseClientTestCase,
BaseServerTestCase,
_IrcTestCase,
)
def pytest_addoption(parser):
"""Called by pytest, registers CLI options passed to the pytest command."""
parser.addoption(
"--controller", help="Which module to use to run the tested software."
)
parser.addoption(
"--services-controller", help="Which module to use to run a services package."
)
parser.addoption(
"--openssl-bin", type=str, default="openssl", help="The openssl binary to use"
)
def pytest_configure(config):
"""Called by pytest, after it parsed the command-line."""
module_name = config.getoption("controller")
services_module_name = config.getoption("services_controller")
if module_name is None:
print("Missing --controller option, errors may occur.")
_IrcTestCase.controllerClass = None
_IrcTestCase.show_io = True # TODO
return
try:
module = importlib.import_module(module_name)
except ImportError:
pytest.exit("Cannot import module {}".format(module_name), 1)
controller_class = module.get_irctest_controller_class()
if issubclass(controller_class, BaseClientController):
from irctest import client_tests as module
if services_module_name is not None:
pytest.exit("You may not use --services-controller for client tests.")
elif issubclass(controller_class, BaseServerController):
from irctest import server_tests as module
else:
pytest.exit(
r"{}.Controller should be a subclass of "
r"irctest.basecontroller.Base{{Client,Server}}Controller".format(
module_name
),
1,
)
if services_module_name is not None:
try:
services_module = importlib.import_module(services_module_name)
except ImportError:
pytest.exit("Cannot import module {}".format(services_module_name), 1)
controller_class.services_controller_class = (
services_module.get_irctest_controller_class()
)
_IrcTestCase.controllerClass = controller_class
_IrcTestCase.controllerClass.openssl_bin = config.getoption("openssl_bin")
_IrcTestCase.show_io = True # TODO
def pytest_collection_modifyitems(session, config, items):
"""Called by pytest after finishing the test collection,
and before actually running the tests.
This function filters out client tests if running with a server controller,
and vice versa.
"""
# First, check if we should run server tests or client tests
server_tests = client_tests = False
if _IrcTestCase.controllerClass is None:
pass
elif issubclass(_IrcTestCase.controllerClass, BaseServerController):
server_tests = True
elif issubclass(_IrcTestCase.controllerClass, BaseClientController):
client_tests = True
else:
assert False, (
f"{_IrcTestCase.controllerClass} inherits neither "
f"BaseClientController or BaseServerController"
)
filtered_items = []
# Iterate over each of the test functions (they are pytest "Nodes")
for item in items:
assert isinstance(item, _pytest.python.Function)
# unittest-style test functions have the node of UnitTest class as parent
assert isinstance(
item.parent,
(
_pytest.python.Class, # pytest >= 7.0.0
_pytest.python.Instance, # pytest < 7.0.0
),
)
# and that node references the UnitTest class
assert issubclass(item.parent.cls, _IrcTestCase)
# and in this project, TestCase classes all inherit either from
# BaseClientController or BaseServerController.
if issubclass(item.parent.cls, BaseServerTestCase):
if server_tests:
filtered_items.append(item)
elif issubclass(item.parent.cls, BaseClientTestCase):
if client_tests:
filtered_items.append(item)
else:
filtered_items.append(item)
# Finally, rewrite in-place the list of tests pytest will run
items[:] = filtered_items

View File

@ -1,8 +0,0 @@
INSTDIR="$HOME/.local/"
RUNGROUP=""
UMASK=077
DEBUG="yes"
USE_PCH="yes"
EXTRA_INCLUDE_DIRS=""
EXTRA_LIB_DIRS=""
EXTRA_CONFIG_ARGS=""

View File

@ -1,83 +0,0 @@
-----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

@ -1,3 +0,0 @@
Boilerplate files so Unreal can be built non-interactively.
Obviously, you shouldn't use the .pem in a production environment!

View File

@ -1,24 +0,0 @@
BASEPATH="$HOME/.local/unrealircd"
BINDIR="$HOME/.local/unrealircd/bin"
DATADIR="$HOME/.local/unrealircd/data"
CONFDIR="$HOME/.local/unrealircd/conf"
MODULESDIR="$HOME/.local/unrealircd/modules"
LOGDIR="$HOME/.local/unrealircd/logs"
CACHEDIR="$HOME/.local/unrealircd/cache"
DOCDIR="$HOME/.local/unrealircd/doc"
TMPDIR="$HOME/.local/unrealircd/tmp"
PRIVATELIBDIR="$HOME/.local/unrealircd/lib"
PREFIXAQ="1"
MAXCONNECTIONS_REQUEST="auto"
NICKNAMEHISTORYLENGTH="2000"
DEFPERM="0600"
SSLDIR=""
REMOTEINC=""
CURLDIR=""
SHOWLISTMODES="1"
NOOPEROVERRIDE=""
OPEROVERRIDEVERIFY=""
GENCERTIFICATE="1"
EXTRAPARA=""
ADVANCED=""

View File

@ -1,14 +0,0 @@
-----BEGIN CERTIFICATE-----
MIICGDCCAZ6gAwIBAgIUeHAOQnvT7N9kCmUuIklelkzz8SUwCgYIKoZIzj0EAwIw
QzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRIwEAYDVQQKDAlJUkMg
Z2Vla3MxDTALBgNVBAsMBElSQ2QwHhcNMjEwNzAyMTk1MTM5WhcNMzEwNjMwMTk1
MTM5WjBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQBgNVBAoM
CUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuBBAAiA2IA
BHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBCUbISsk/C
Txru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTpEAqMSnrc
zKNTMFEwHQYDVR0OBBYEFFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMB8GA1UdIwQYMBaA
FFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0E
AwIDaAAwZQIwAo29xUEAzqOMgPAWtMifHFLuPQPuWcNGbaI5S4W81NO8uIcNv/kM
mFocuITr76p0AjEApzGjc5wM+KydwoVTP+fg1aGQA13Ba2nCzN3R5XwR/USCigjv
na1QtWAKjpvR/rsp
-----END CERTIFICATE-----

View File

@ -1,9 +0,0 @@
-----BEGIN EC PARAMETERS-----
BgUrgQQAIg==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDCWkDHktJiTqC7im+Ni37fbXxtMBqIKPwkAItpKMeuh28QrXWwNE1a5
wSa38C1nd8igBwYFK4EEACKhZANiAARwOoqi0IJEuNsR4P3RD6vXSo5S4tB1rwjO
5zglwMhctQ465mxaEgkAQlGyErJPwk8a7t2CH13uo0gkvFE29eZu9Jq5polK/7ps
YqowDorKNOdYQq88VZo06RAKjEp63Mw=
-----END EC PRIVATE KEY-----

View File

@ -1,9 +0,0 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBOjCBwgIBADBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQ
BgNVBAoMCUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuB
BAAiA2IABHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBC
UbISsk/CTxru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTp
EAqMSnrczKAAMAoGCCqGSM49BAMCA2cAMGQCMEL5ezlauGUaxh+pXt897ffmzqci
fqYm3FgVW5x6EdtCxtcwwAwnR84LKcd/YRKOygIwNmZiRVKeSeC7Ess1PxuzT1Mu
Cw3bBqkE5LmO1hu/+0lK+QoFPEeLDrygIh+SDdGH
-----END CERTIFICATE REQUEST-----

87
irctest/__main__.py Normal file
View File

@ -0,0 +1,87 @@
import sys
import unittest
import argparse
import unittest
import functools
import importlib
from .cases import _IrcTestCase
from .runner import TextTestRunner
from .specifications import Specifications
from .basecontrollers import BaseClientController, BaseServerController
def main(args):
try:
module = importlib.import_module(args.module)
except ImportError:
print('Cannot import module {}'.format(args.module), file=sys.stderr)
exit(1)
controller_class = module.get_irctest_controller_class()
if issubclass(controller_class, BaseClientController):
import irctest.client_tests as module
elif issubclass(controller_class, BaseServerController):
import irctest.server_tests as module
else:
print(r'{}.Controller should be a subclass of '
r'irctest.basecontroller.Base{{Client,Server}}Controller'
.format(args.module),
file=sys.stderr)
exit(1)
_IrcTestCase.controllerClass = controller_class
_IrcTestCase.controllerClass.openssl_bin = args.openssl_bin
_IrcTestCase.show_io = args.show_io
_IrcTestCase.strictTests = not args.loose
if args.specification:
try:
_IrcTestCase.testedSpecifications = frozenset(
Specifications.of_name(x) for x in args.specification
)
except ValueError:
print('Invalid set of specifications: {}'
.format(', '.join(args.specification)))
exit(1)
else:
_IrcTestCase.testedSpecifications = frozenset(
Specifications)
print('Testing {} on specification(s): {}'.format(
controller_class.software_name,
', '.join(sorted(map(lambda x:x.value,
_IrcTestCase.testedSpecifications)))))
ts = module.discover()
testRunner = TextTestRunner(
verbosity=args.verbose,
descriptions=True,
)
testLoader = unittest.loader.defaultTestLoader
result = testRunner.run(ts)
if result.failures or result.errors:
exit(1)
else:
exit(0)
parser = argparse.ArgumentParser(
description='A script to test interoperability of IRC software.')
parser.add_argument('module', type=str,
help='The module used to run the tested program.')
parser.add_argument('--openssl-bin', type=str, default='openssl',
help='The openssl binary to use')
parser.add_argument('--show-io', action='store_true',
help='Show input/outputs with the tested program.')
parser.add_argument('-v', '--verbose', action='count', default=1,
help='Verbosity. Give this option multiple times to make '
'it even more verbose.')
parser.add_argument('-s', '--specification', type=str, action='append',
help=('The set of specifications to test the program with. '
'Valid values: {}. '
'Use this option multiple times to test with multiple '
'specifications. If it is not given, defaults to all.')
.format(', '.join(x.value for x in Specifications)))
parser.add_argument('-l', '--loose', action='store_true',
help='Disables strict checks of conformity to the specification. '
'Strict means the specification is unclear, and the most restrictive '
'interpretation is choosen.')
args = parser.parse_args()
main(args)

View File

@ -1,23 +1,19 @@
import dataclasses
import enum
from typing import Optional, Tuple
import collections
@enum.unique
class Mechanisms(enum.Enum):
"""Enumeration for representing possible mechanisms."""
def to_string(self) -> str:
return self.name.upper().replace("_", "-")
@classmethod
def as_string(cls, mech):
return {cls.plain: 'PLAIN',
cls.ecdsa_nist256p_challenge: 'ECDSA-NIST256P-CHALLENGE',
cls.scram_sha_256: 'SCRAM-SHA-256',
}[mech]
plain = 1
ecdsa_nist256p_challenge = 2
scram_sha_256 = 3
@dataclasses.dataclass
class Authentication:
mechanisms: Tuple[Mechanisms] = (Mechanisms.plain,)
username: Optional[str] = None
password: Optional[str] = None
ecdsa_key: Optional[str] = None
Authentication = collections.namedtuple('Authentication',
'mechanisms username password ecdsa_key')
Authentication.__new__.__defaults__ = ([Mechanisms.plain], None, None, None)

View File

@ -1,380 +1,102 @@
from __future__ import annotations
import dataclasses
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
import subprocess
import irctest
from . import authentication, tls
from .client_mock import ClientMock
from .irc_utils.junkdrawer import find_hostname_and_port
from .irc_utils.message_parser import Message
from .runner import NotImplementedByController
class ProcessStopped(Exception):
"""Raised when the controlled process stopped unexpectedly"""
pass
@dataclasses.dataclass
class TestCaseControllerConfig:
"""Test-case-specific configuration passed to the controller.
This is usually used to ask controllers to enable a feature;
but should not be an issue if controllers enable it all the time."""
chathistory: bool = False
"""Whether to enable chathistory features."""
ergo_roleplay: bool = False
"""Whether to enable the Ergo role-play commands."""
ergo_config: Optional[Callable[[Dict], Any]] = None
"""Oragono-specific configuration function that alters the dict in-place
This should be used as little as possible, using the other attributes instead;
as they are work with any controller."""
class _BaseController:
"""Base class for software controllers.
A software controller is an object that handles configuring and running
a process (eg. a server or a client), as well as sending it instructions
that are not part of the IRC specification."""
pass
# set by conftest.py
openssl_bin: str
supports_sts: bool
supported_sasl_mechanisms: Set[str]
proc: Optional[subprocess.Popen]
def __init__(self, test_config: TestCaseControllerConfig):
self.test_config = test_config
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
arbitrary directory."""
def __init__(self):
super().__init__()
self.directory = None
self.proc = None
def check_is_alive(self) -> None:
assert self.proc
self.proc.poll()
if self.proc.returncode is not None:
raise ProcessStopped()
def kill_proc(self) -> None:
def kill_proc(self):
"""Terminates the controlled process, waits for it to exit, and
eventually kills it."""
assert self.proc
self.proc.terminate()
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc = None
def kill(self) -> None:
def kill(self):
"""Calls `kill_proc` and cleans the configuration."""
if self.proc:
self.kill_proc()
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
arbitrary directory."""
directory: Optional[Path]
def __init__(self, test_config: TestCaseControllerConfig):
super().__init__(test_config)
self.directory = None
def kill(self) -> None:
"""Calls `kill_proc` and cleans the configuration."""
super().kill()
if self.directory:
shutil.rmtree(self.directory)
def terminate(self) -> None:
def terminate(self):
"""Stops the process gracefully, and does not clean its config."""
assert self.proc
self.proc.terminate()
self.proc.wait()
self.proc = None
def open_file(self, name: str, mode: str = "a") -> IO:
def open_file(self, name, mode='a'):
"""Open a file in the configuration directory."""
assert self.directory
if os.sep in name:
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 = Path(tempfile.mkdtemp())
def gen_ssl(self) -> None:
assert self.directory
self.csr_path = self.directory / "ssl.csr"
self.key_path = self.directory / "ssl.key"
self.pem_path = self.directory / "ssl.pem"
self.dh_path = self.directory / "dh.pem"
subprocess.check_output(
[
self.openssl_bin,
"req",
"-new",
"-newkey",
"rsa",
"-nodes",
"-out",
self.csr_path,
"-keyout",
self.key_path,
"-batch",
],
stderr=subprocess.DEVNULL,
)
subprocess.check_output(
[
self.openssl_bin,
"x509",
"-req",
"-in",
self.csr_path,
"-signkey",
self.key_path,
"-out",
self.pem_path,
],
stderr=subprocess.DEVNULL,
)
with self.dh_path.open("w") as fd:
fd.write(
textwrap.dedent(
"""
-----BEGIN DH PARAMETERS-----
MIGHAoGBAJICSyQAiLj1fw8b5xELcnpqBQ+wvOyKgim4IetWOgZnRQFkTgOeoRZD
HksACRFJL/EqHxDKcy/2Ghwr2axhNxSJ+UOBmraP3WfodV/fCDPnZ+XnI9fjHsIr
rjisPMqomjXeiTB1UeAHvLUmCK4yx6lpAJsCYwJjsqkycUfHiy1bAgEC
-----END DH PARAMETERS-----
"""
)
)
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)
def create_config(self):
"""If there is no config dir, creates it and returns True.
Else returns False."""
if self.directory:
return False
else:
self.directory = tempfile.mkdtemp()
return True
def gen_ssl(self):
self.csr_path = os.path.join(self.directory, 'ssl.csr')
self.key_path = os.path.join(self.directory, 'ssl.key')
self.pem_path = os.path.join(self.directory, 'ssl.pem')
self.dh_path = os.path.join(self.directory, 'dh.pem')
subprocess.check_output([self.openssl_bin, 'req', '-new', '-newkey', 'rsa',
'-nodes', '-out', self.csr_path, '-keyout', self.key_path,
'-batch'],
stderr=subprocess.DEVNULL)
subprocess.check_output([self.openssl_bin, 'x509', '-req',
'-in', self.csr_path, '-signkey', self.key_path,
'-out', self.pem_path],
stderr=subprocess.DEVNULL)
subprocess.check_output([self.openssl_bin, 'dhparam',
'-out', self.dh_path, '128'],
stderr=subprocess.DEVNULL)
class BaseClientController(_BaseController):
"""Base controller for IRC clients."""
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
def run(self, hostname, port, auth):
raise NotImplementedError()
class BaseServerController(_BaseController):
"""Base controller for IRC server."""
software_name: str # Class property
_port_wait_interval = 0.1
port_open = False
port: int
hostname: str
services_controller: Optional[BaseServicesController] = None
services_controller_class: Type[BaseServicesController]
extban_mute_char: Optional[str] = None
"""Character used for the 'mute' extban"""
nickserv = "NickServ"
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.faketime_enabled = False
def get_hostname_and_port(self) -> Tuple[str, int]:
return find_hostname_and_port()
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]],
invalid_metadata_keys: Optional[Set[str]],
faketime: Optional[str],
) -> None:
def run(self, hostname, port, password,
valid_metadata_keys, invalid_metadata_keys):
raise NotImplementedError()
def registerUser(
self,
case: irctest.cases.BaseServerTestCase, # type: ignore
username: str,
password: Optional[str] = None,
) -> None:
if self.services_controller is not None:
self.services_controller.registerUser(case, username, password)
else:
raise NotImplementedByController("account registration")
def wait_for_port(self) -> None:
started_at = time.time()
def registerUser(self, case, username, password=None):
raise NotImplementedByController('account registration')
def wait_for_port(self):
while not self.port_open:
self.check_is_alive()
time.sleep(self._port_wait_interval)
time.sleep(0.1)
try:
c = socket.create_connection(("localhost", self.port), timeout=1.0)
c.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
# Make sure the server properly processes the disconnect.
# Otherwise, it may still count it in LUSER and fail tests in
# test_lusers.py (eg. this happens with Charybdis 3.5.0)
c.sendall(b"QUIT :chkport\r\n")
data = b""
try:
while b"chkport" not in data and b"ERROR" not in data:
data += c.recv(4096)
time.sleep(0.01)
c.send(b" ") # Triggers BrokenPipeError
except BrokenPipeError:
# ircu2 cuts the connection without a message if registration
# is not complete.
pass
except socket.timeout:
# irc2 just keeps it open
pass
c = socket.create_connection(('localhost', self.port), timeout=1.0)
c.close()
self.port_open = True
except ConnectionRefusedError:
if time.time() - started_at >= 60:
# waited for 60 seconds, giving up
raise
def wait_for_services(self) -> None:
assert self.services_controller
self.services_controller.wait_for_services()
def terminate(self) -> None:
if self.services_controller is not None:
self.services_controller.terminate() # type: ignore
super().terminate() # type: ignore
def kill(self) -> None:
if self.services_controller is not None:
self.services_controller.kill() # type: ignore
super().kill()
class BaseServicesController(_BaseController):
def __init__(
self,
test_config: TestCaseControllerConfig,
server_controller: BaseServerController,
):
super().__init__(test_config)
self.test_config = test_config
self.server_controller = server_controller
self.services_up = False
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
raise NotImplementedError("BaseServerController.run()")
def wait_for_services(self) -> None:
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkNS", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkNS")
c.sendLine("USER chk chk chk chk")
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
# Hi Unreal
c.sendLine("PONG :" + msg.params[0])
c.getMessages()
timeout = time.time() + 3
while True:
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :help")
msgs = self.getNickServResponse(c, timeout=1)
for msg in msgs:
if msg.command == "401":
# NickServ not available yet
pass
elif msg.command == "NOTICE":
# NickServ is available
assert "nickserv" in (msg.prefix or "").lower(), msg
print("breaking")
break
else:
assert False, f"unexpected reply from NickServ: {msg}"
else:
if time.time() > timeout:
raise Exception("Timeout while waiting for NickServ")
except Exception as e:
continue
# If we're here, it means we broke from the for loop, so NickServ
# is available and we can break again
break
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
self.services_up = True
def getNickServResponse(self, client: Any, timeout: int = 0) -> List[Message]:
"""Wrapper aroung getMessages() that waits longer, because NickServ
is queried asynchronously."""
msgs: List[Message] = []
start_time = time.time()
while not msgs and (not timeout or start_time + timeout > time.time()):
time.sleep(0.05)
msgs = client.getMessages()
return msgs
def registerUser(
self,
case: irctest.cases.BaseServerTestCase, # type: ignore
username: str,
password: Optional[str] = None,
) -> None:
if not case.run_services:
raise ValueError(
"Attempted to register a nick, but `run_services` it not True."
)
assert password
client = case.addClient(show_io=True)
case.sendLine(client, "NICK " + username)
case.sendLine(client, "USER r e g :user")
while case.getRegistrationMessage(client).command != "001":
pass
case.getMessages(client)
case.sendLine(
client,
f"PRIVMSG {self.server_controller.nickserv} "
f":REGISTER {password} foo@example.org",
)
msgs = self.getNickServResponse(case.clients[client])
if self.server_controller.software_name == "inspircd":
assert "900" in {msg.command for msg in msgs}, msgs
assert "NOTICE" in {msg.command for msg in msgs}, msgs
case.sendLine(client, "QUIT")
case.assertDisconnected(client)

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +1,36 @@
import socket
import ssl
import sys
import time
from typing import Any, Callable, List, Optional, Union
from .exceptions import ConnectionClosed, NoMessageException
import socket
from .irc_utils import message_parser
from .exceptions import NoMessageException, ConnectionClosed
class ClientMock:
def __init__(self, name: Any, show_io: bool):
def __init__(self, name, show_io):
self.name = name
self.show_io = show_io
self.inbuffer: List[message_parser.Message] = []
self.inbuffer = []
self.ssl = False
def connect(self, hostname: str, port: int) -> None:
def connect(self, hostname, port):
self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# probably useful for test_buffering, as it relies on chunking
# the packets to be useful
self.conn.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
self.conn.settimeout(1) # TODO: configurable
self.conn.settimeout(1) # TODO: configurable
self.conn.connect((hostname, port))
if self.show_io:
print("{:.3f} {}: connects to server.".format(time.time(), self.name))
def disconnect(self) -> None:
print('{:.3f} {}: connects to server.'.format(time.time(), self.name))
def disconnect(self):
if self.show_io:
print("{:.3f} {}: disconnects from server.".format(time.time(), self.name))
print('{:.3f} {}: disconnects from server.'.format(time.time(), self.name))
self.conn.close()
def starttls(self) -> None:
assert not self.ssl, "SSL already active."
def starttls(self):
assert not self.ssl, 'SSL already active.'
self.conn = ssl.wrap_socket(self.conn)
self.ssl = True
def getMessages(
self, synchronize: bool = True, assert_get_one: bool = False, raw: bool = False
) -> List[message_parser.Message]:
"""actually returns List[str] in the rare case where raw=True."""
__tracebackhide__ = True # Hide from pytest tracebacks on test failure.
token: Optional[str]
def getMessages(self, synchronize=True, assert_get_one=False):
if synchronize:
token = "synchronize{}".format(time.monotonic())
self.sendLine("PING {}".format(token))
else:
token = None
token = 'synchronize{}'.format(time.monotonic())
self.sendLine('PING {}'.format(token))
got_pong = False
data = b""
data = b''
(self.inbuffer, messages) = ([], self.inbuffer)
conn = self.conn
try:
@ -57,11 +38,12 @@ class ClientMock:
try:
new_data = conn.recv(4096)
except socket.timeout:
if not assert_get_one and not synchronize and data == b"":
if not assert_get_one and not synchronize and data == b'':
# Received nothing
return []
if self.show_io:
print("{:.3f} {}: waiting…".format(time.time(), self.name))
print('{:.3f} waiting…'.format(time.time()))
time.sleep(0.1)
continue
except ConnectionResetError:
raise ConnectionClosed()
@ -70,38 +52,26 @@ class ClientMock:
# Connection closed
raise ConnectionClosed()
data += new_data
if not new_data.endswith(b"\r\n"):
if not new_data.endswith(b'\r\n'):
time.sleep(0.1)
continue
if not synchronize:
got_pong = True
for line in data.decode().split("\r\n"):
for line in data.decode().split('\r\n'):
if line:
if self.show_io:
print(
"{time:.3f}{ssl} S -> {client}: {line}".format(
time=time.time(),
ssl=" (ssl)" if self.ssl else "",
client=self.name,
line=line,
)
)
message = message_parser.parse_message(line)
if message.command == "PONG" and token in message.params:
print('{time:.3f}{ssl} S -> {client}: {line}'.format(
time=time.time(),
ssl=' (ssl)' if self.ssl else '',
client=self.name,
line=line))
message = message_parser.parse_message(line + '\r\n')
if message.command == 'PONG' and \
token in message.params:
got_pong = True
elif (
synchronize
and message.command == "451"
and message.params[1] == "PING"
):
raise ValueError(
"Got '451 * PONG'. Did you forget synchronize=False?"
)
else:
if raw:
messages.append(line) # type: ignore
else:
messages.append(message)
data = b""
messages.append(message)
data = b''
except ConnectionClosed:
if messages:
return messages
@ -109,58 +79,31 @@ class ClientMock:
raise
else:
return messages
def getMessage(
self,
filter_pred: Optional[Callable[[message_parser.Message], bool]] = None,
synchronize: bool = True,
raw: bool = False,
) -> message_parser.Message:
"""Returns str in the rare case where raw=True"""
__tracebackhide__ = True # Hide from pytest tracebacks on test failure.
def getMessage(self, filter_pred=None, synchronize=True):
while True:
if not self.inbuffer:
self.inbuffer = self.getMessages(
synchronize=synchronize, assert_get_one=True, raw=raw
)
synchronize=synchronize, assert_get_one=True)
if not self.inbuffer:
raise NoMessageException()
message = self.inbuffer.pop(0) # TODO: use dequeue
message = self.inbuffer.pop(0) # TODO: use dequeue
if not filter_pred or filter_pred(message):
return message
def sendLine(self, line: Union[str, bytes]) -> None:
if isinstance(line, str):
encoded_line = line.encode()
elif isinstance(line, bytes):
encoded_line = line
else:
raise ValueError(line)
if not encoded_line.endswith(b"\r\n"):
encoded_line += b"\r\n"
def sendLine(self, line):
if not line.endswith('\r\n'):
line += '\r\n'
encoded_line = line.encode()
try:
ret = self.conn.sendall(encoded_line) # type: ignore
ret = self.conn.sendall(encoded_line)
except BrokenPipeError:
raise ConnectionClosed()
if (
sys.version_info <= (3, 6) and self.ssl
): # https://bugs.python.org/issue25951
if sys.version_info <= (3, 6) and self.ssl: # https://bugs.python.org/issue25951
assert ret == len(encoded_line), (ret, repr(encoded_line))
else:
assert ret is None, ret
if self.show_io:
if isinstance(line, str):
escaped_line = line
escaped = ""
else:
escaped_line = repr(line)
escaped = " (escaped)"
print(
"{time:.3f}{escaped}{ssl} {client} -> S: {line}".format(
time=time.time(),
escaped=escaped,
ssl=" (ssl)" if self.ssl else "",
client=self.name,
line=escaped_line.strip("\r\n"),
)
)
print('{time:.3f}{ssl} {client} -> S: {line}'.format(
time=time.time(),
ssl=' (ssl)' if self.ssl else '',
client=self.name,
line=line.strip('\r\n')))

View File

@ -1,284 +0,0 @@
"""SASL authentication from clients, for all known mechanisms.
For now, only `SASLv3.1 <https://ircv3.net/specs/extensions/sasl-3.1>`_
is tested, not `SASLv3.2 <https://ircv3.net/specs/extensions/sasl-3.2>`_."""
import base64
import pytest
try:
import ecdsa
from ecdsa.util import sigdecode_der
except ImportError:
ecdsa = None
from irctest import authentication, cases, scram
from irctest.irc_utils.message_parser import Message
ECDSA_KEY = """
-----BEGIN EC PARAMETERS-----
BggqhkjOPQMBBw==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
-----END EC PRIVATE KEY-----
"""
CHALLENGE = bytes(range(32))
assert len(CHALLENGE) == 32
class IdentityHash:
def __init__(self, data):
self._data = data
def digest(self):
return self._data
class SaslTestCase(cases.BaseClientTestCase):
@cases.skipUnlessHasMechanism("PLAIN")
def testPlain(self):
"""Test PLAIN authentication with correct username/password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="jilles",
password="sesame",
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(
m, Message({}, None, "AUTHENTICATE", ["amlsbGVzAGppbGxlcwBzZXNhbWU="])
)
self.sendLine("900 * * jilles :You are now logged in.")
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.skipUnlessHasMechanism("PLAIN")
@cases.xfailIfSoftware(["Sopel"], "Sopel requests SASL PLAIN even if not available")
def testPlainNotAvailable(self):
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
to use PLAIN.
A client implementing sasl-3.2 can give up authentication immediately.
A client not implementing it will try authenticating, and will get
a 904.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="jilles",
password="sesame",
)
m = self.negotiateCapabilities(["sasl=EXTERNAL"], auth=auth)
self.assertEqual(self.acked_capabilities, {"sasl"})
if m == Message({}, None, "CAP", ["END"]):
# IRCv3.2-style, for clients that skip authentication
# when unavailable (eg. Limnoria)
return
elif m.command == "QUIT":
# IRCv3.2-style, for clients that quit when unavailable
# (eg. Sopel)
return
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("904 {} :SASL auth failed".format(self.nick))
m = self.getMessage()
self.assertMessageMatch(m, command="CAP")
@pytest.mark.parametrize("pattern", ["barbaz", "éèà"])
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainLarge(self, pattern):
"""Test the client splits large AUTHENTICATE messages whose payload
is not a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="foo",
password=pattern * 100,
)
authstring = base64.b64encode(
b"\x00".join([b"foo", b"foo", pattern.encode() * 100])
).decode()
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[800:]]))
self.sendLine("900 * * {} :You are now logged in.".format("foo"))
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.skipUnlessHasMechanism("PLAIN")
@pytest.mark.parametrize("pattern", ["quux", "éè"])
def testPlainLargeMultiple(self, pattern):
"""Test the client splits large AUTHENTICATE messages whose payload
is a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="foo",
password=pattern * 148,
)
authstring = base64.b64encode(
b"\x00".join([b"foo", b"foo", pattern.encode() * 148])
).decode()
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["+"]))
self.sendLine("900 * * {} :You are now logged in.".format("foo"))
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@pytest.mark.skipif(ecdsa is None, reason="python3-ecdsa is not available")
@cases.skipUnlessHasMechanism("ECDSA-NIST256P-CHALLENGE")
def testEcdsa(self):
"""Test ECDSA authentication."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.ecdsa_nist256p_challenge],
username="jilles",
ecdsa_key=ECDSA_KEY,
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(
m, Message({}, None, "AUTHENTICATE", ["ECDSA-NIST256P-CHALLENGE"])
)
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["amlsbGVz"])) # jilles
self.sendLine(
"AUTHENTICATE {}".format(base64.b64encode(CHALLENGE).decode("ascii"))
)
m = self.getMessage()
self.assertMessageMatch(m, command="AUTHENTICATE")
sk = ecdsa.SigningKey.from_pem(ECDSA_KEY)
vk = sk.get_verifying_key()
signature = base64.b64decode(m.params[0])
try:
vk.verify(
signature, CHALLENGE, hashfunc=IdentityHash, sigdecode=sigdecode_der
)
except ecdsa.BadSignatureError:
raise AssertionError("Bad signature")
self.sendLine("900 * * foo :You are now logged in.")
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScram(self):
"""Test SCRAM-SHA-256 authentication."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username="jilles",
password="sesame",
)
class PasswdDb:
def get_password(self, *args):
return ("sesame", "plain")
authenticator = scram.SCRAMServerAuthenticator(
"SHA-256", channel_binding=False, password_database=PasswdDb()
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["SCRAM-SHA-256"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
msg = base64.b64decode(m.params[0])
r = authenticator.response(msg)
assert isinstance(r, tuple), r
assert len(r) == 2, r
(properties, response) = r
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
self.assertEqual(properties, {"authzid": None, "username": "jilles"})
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
self.assertEqual(m.params, ["+"], m)
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScramBadPassword(self):
"""Test SCRAM-SHA-256 authentication with a bad password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username="jilles",
password="sesame",
)
class PasswdDb:
def get_password(self, *args):
return ("notsesame", "plain")
authenticator = scram.SCRAMServerAuthenticator(
"SHA-256", channel_binding=False, password_database=PasswdDb()
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["SCRAM-SHA-256"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
msg = base64.b64decode(m.params[0])
with self.assertRaises(scram.NotAuthorizedException):
authenticator.response(msg)
class Irc302SaslTestCase(cases.BaseClientTestCase):
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainNotAvailable(self):
"""Test the client does not try to authenticate using a mechanism the
server does not advertise.
Actually, this is optional."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="jilles",
password="sesame",
)
m = self.negotiateCapabilities(["sasl=EXTERNAL"], auth=auth)
self.assertEqual(self.acked_capabilities, {"sasl"})
if m.command == "QUIT":
# Some clients quit when it can't authenticate (eg. Sopel)
pass
else:
# Others will just skip authentication (eg. Limnoria)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))

View File

@ -1,17 +1,14 @@
"""Format of ``CAP LS`` sent by IRCv3 clients."""
from irctest import cases
from irctest.irc_utils.message_parser import Message
class CapTestCase(cases.BaseClientTestCase):
@cases.mark_specifications("IRCv3")
class CapTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper):
@cases.SpecificationSelector.requiredBySpecification('IRCv3.1', 'IRCv3.2')
def testSendCap(self):
"""Send CAP LS 302 and read the result."""
self.readCapLs()
@cases.mark_specifications("IRCv3")
@cases.SpecificationSelector.requiredBySpecification('IRCv3.1', 'IRCv3.2')
def testEmptyCapLs(self):
"""Empty result to CAP LS. Client should send CAP END."""
m = self.negotiateCapabilities([])
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
self.assertEqual(m, Message([], None, 'CAP', ['END']))

View File

@ -0,0 +1,257 @@
import hashlib
import ecdsa
from ecdsa.util import sigencode_der, sigdecode_der
import base64
import pyxmpp2_scram as scram
from irctest import cases
from irctest import authentication
from irctest.irc_utils.message_parser import Message
ECDSA_KEY = """
-----BEGIN EC PARAMETERS-----
BggqhkjOPQMBBw==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
-----END EC PRIVATE KEY-----
"""
CHALLENGE = bytes(range(32))
assert len(CHALLENGE) == 32
class IdentityHash:
def __init__(self, data):
self._data = data
def digest(self):
return self._data
class SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlain(self):
"""Test PLAIN authentication with correct username/password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='jilles',
password='sesame',
)
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
['amlsbGVzAGppbGxlcwBzZXNhbWU=']))
self.sendLine('900 * * jilles :You are now logged in.')
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainNotAvailable(self):
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
to use PLAIN.
A client implementing sasl-3.2 can give up authentication immediately.
A client not implementing it will try authenticating, and will get
a 904.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='jilles',
password='sesame',
)
m = self.negotiateCapabilities(['sasl=EXTERNAL'], auth=auth)
self.assertEqual(self.acked_capabilities, {'sasl'})
if m == Message([], None, 'CAP', ['END']):
# IRCv3.2-style
return
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('904 {} :SASL auth failed'.format(self.nick))
m = self.getMessage()
self.assertMessageEqual(m, command='CAP')
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainLarge(self):
"""Test the client splits large AUTHENTICATE messages whose payload
is not a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='foo',
password='bar'*200,
)
authstring = base64.b64encode(b'\x00'.join(
[b'foo', b'foo', b'bar'*200])).decode()
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[800:]]))
self.sendLine('900 * * {} :You are now logged in.'.format('foo'))
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainLargeMultiple(self):
"""Test the client splits large AUTHENTICATE messages whose payload
is a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='foo',
password='quux'*148,
)
authstring = base64.b64encode(b'\x00'.join(
[b'foo', b'foo', b'quux'*148])).decode()
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
['+']))
self.sendLine('900 * * {} :You are now logged in.'.format('foo'))
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('ECDSA-NIST256P-CHALLENGE')
def testEcdsa(self):
"""Test ECDSA authentication.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.ecdsa_nist256p_challenge],
username='jilles',
ecdsa_key=ECDSA_KEY,
)
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['ECDSA-NIST256P-CHALLENGE']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
['amlsbGVz'])) # jilles
self.sendLine('AUTHENTICATE {}'.format(base64.b64encode(CHALLENGE).decode('ascii')))
m = self.getMessage()
self.assertMessageEqual(m, command='AUTHENTICATE')
sk = ecdsa.SigningKey.from_pem(ECDSA_KEY)
vk = sk.get_verifying_key()
signature = base64.b64decode(m.params[0])
try:
vk.verify(signature, CHALLENGE, hashfunc=IdentityHash, sigdecode=sigdecode_der)
except ecdsa.BadSignatureError:
raise AssertionError('Bad signature')
self.sendLine('900 * * foo :You are now logged in.')
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256')
def testScram(self):
"""Test SCRAM-SHA-256 authentication.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username='jilles',
password='sesame',
)
class PasswdDb:
def get_password(self, *args):
return ('sesame', 'plain')
authenticator = scram.SCRAMServerAuthenticator('SHA-256',
channel_binding=False, password_database=PasswdDb())
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['SCRAM-SHA-256']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
msg = base64.b64decode(m.params[0])
r = authenticator.response(msg)
assert isinstance(r, tuple), r
assert len(r) == 2, r
(properties, response) = r
self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode())
self.assertEqual(properties, {'authzid': None, 'username': 'jilles'})
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
self.assertEqual(m.params, ['+'], m)
@cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256')
def testScramBadPassword(self):
"""Test SCRAM-SHA-256 authentication with a bad password.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username='jilles',
password='sesame',
)
class PasswdDb:
def get_password(self, *args):
return ('notsesame', 'plain')
authenticator = scram.SCRAMServerAuthenticator('SHA-256',
channel_binding=False, password_database=PasswdDb())
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['SCRAM-SHA-256']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
msg = base64.b64decode(m.params[0])
with self.assertRaises(scram.NotAuthorizedException):
authenticator.response(msg)
class Irc302SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainNotAvailable(self):
"""Test the client does not try to authenticate using a mechanism the
server does not advertise.
Actually, this is optional."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='jilles',
password='sesame',
)
m = self.negotiateCapabilities(['sasl=EXTERNAL'], auth=auth)
self.assertEqual(self.acked_capabilities, {'sasl'})
self.assertEqual(m, Message([], None, 'CAP', ['END']))

View File

@ -1,13 +1,10 @@
"""Clients should validate certificates; either with a CA or fingerprints."""
import socket
import ssl
import pytest
from irctest import cases, runner, tls
from irctest import tls
from irctest import cases
from irctest.exceptions import ConnectionClosed
from irctest.patma import ANYSTR
from irctest.irc_utils.message_parser import Message
BAD_CERT = """
-----BEGIN CERTIFICATE-----
@ -63,7 +60,7 @@ h4WuPDAI4yh24GjaCZYGR5xcqPCy5CNjMLxdA7HsP+Gcr3eY5XS7noBrbC6IaA0j
-----END PRIVATE KEY-----
"""
GOOD_FINGERPRINT = "E1EE6DE2DBC0D43E3B60407B5EE389AEC9D2C53178E0FB14CD51C3DFD544AA2B"
GOOD_FINGERPRINT = 'E1EE6DE2DBC0D43E3B60407B5EE389AEC9D2C53178E0FB14CD51C3DFD544AA2B'
GOOD_CERT = """
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKtD9XMC1R0vMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
@ -118,78 +115,83 @@ El9iqRlAhgqaXc4Iz/Zxxhs=
-----END PRIVATE KEY-----
"""
class TlsTestCase(cases.BaseClientTestCase):
def testTrustedCertificate(self):
tls_config = tls.TlsConfig(enable=True, trusted_fingerprints=[GOOD_FINGERPRINT])
def testTrustedCertificate(self):
tls_config = tls.TlsConfig(
enable=True,
trusted_fingerprints=[GOOD_FINGERPRINT])
(hostname, port) = self.server.getsockname()
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
self.getMessage()
m = self.getMessage()
def testUntrustedCertificate(self):
tls_config = tls.TlsConfig(enable=True, trusted_fingerprints=[GOOD_FINGERPRINT])
def testUntrustedCertificate(self):
tls_config = tls.TlsConfig(
enable=True,
trusted_fingerprints=[GOOD_FINGERPRINT])
(hostname, port) = self.server.getsockname()
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
self.acceptClient(tls_cert=BAD_CERT, tls_key=BAD_KEY)
with self.assertRaises((ConnectionClosed, ConnectionResetError)):
self.getMessage()
m = self.getMessage()
class StsTestCase(cases.BaseClientTestCase):
class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
def setUp(self):
super().setUp()
self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.insecure_server.bind(("", 0)) # Bind any free port
self.insecure_server.bind(('', 0)) # Bind any free port
self.insecure_server.listen(1)
def tearDown(self):
self.insecure_server.close()
super().tearDown()
@cases.mark_capabilities("sts")
@pytest.mark.parametrize("portOnSecure", [False, True])
def testSts(self, portOnSecure):
if not self.controller.supports_sts:
raise runner.CapabilityNotSupported("sts")
@cases.OptionalityHelper.skipUnlessSupportsCapability('sts')
def testSts(self):
tls_config = tls.TlsConfig(
enable=False, trusted_fingerprints=[GOOD_FINGERPRINT]
)
enable=False,
trusted_fingerprints=[GOOD_FINGERPRINT])
# Connect client to insecure server
(hostname, port) = self.insecure_server.getsockname()
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
self.acceptClient(server=self.insecure_server)
# Send STS policy to client
self.assertMessageMatch(
self.getMessage(),
command="CAP",
params=["LS", ANYSTR],
fail_msg="First message is not CAP LS: {got}",
)
self.sendLine("CAP * LS :sts=port={}".format(self.server.getsockname()[1]))
m = self.getMessage()
self.assertEqual(m.command, 'CAP',
'First message is not CAP LS.')
self.assertEqual(m.params[0], 'LS',
'First message is not CAP LS.')
self.sendLine('CAP * LS :sts=port={}'.format(self.server.getsockname()[1]))
# "If the client is not already connected securely to the server
# at the requested hostname, it MUST close the insecure connection
# and reconnect securely on the stated port."
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
# Send the STS policy, over secure connection this time.
if portOnSecure:
# Should be ignored
self.sendLine("CAP * LS :sts=duration=10,port=12345")
else:
self.sendLine("CAP * LS :sts=duration=10")
# Send the STS policy, over secure connection this time
self.sendLine('CAP * LS :sts=duration=10,port={}'.format(
self.server.getsockname()[1]))
# Make the client reconnect. It should reconnect to the secure server.
self.sendLine("ERROR :closing link")
self.sendLine('ERROR :closing link')
self.acceptClient()
# Kill the client
@ -197,31 +199,34 @@ class StsTestCase(cases.BaseClientTestCase):
# Run the client, still configured to connect to the insecure server
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
# The client should remember the STS policy and connect to the secure
# server
self.acceptClient()
@cases.mark_capabilities("sts")
@cases.OptionalityHelper.skipUnlessSupportsCapability('sts')
def testStsInvalidCertificate(self):
if not self.controller.supports_sts:
raise runner.CapabilityNotSupported("sts")
# Connect client to insecure server
(hostname, port) = self.insecure_server.getsockname()
self.controller.run(hostname=hostname, port=port, auth=None)
self.controller.run(
hostname=hostname,
port=port,
auth=None,
)
self.acceptClient(server=self.insecure_server)
# Send STS policy to client
self.assertMessageMatch(
self.getMessage(),
command="CAP",
params=["LS", ANYSTR],
fail_msg="First message is not CAP LS: {got}",
)
self.sendLine("CAP * LS :sts=port={}".format(self.server.getsockname()[1]))
m = self.getMessage()
self.assertEqual(m.command, 'CAP',
'First message is not CAP LS.')
self.assertEqual(m.params[0], 'LS',
'First message is not CAP LS.')
self.sendLine('CAP * LS :sts=port={}'.format(self.server.getsockname()[1]))
# The client will reconnect to the TLS port. Unfortunately, it does
# not trust its fingerprint.

View File

@ -1,125 +0,0 @@
from pathlib import Path
import shutil
import subprocess
from typing import Type
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
TEMPLATE_CONFIG = """
serverinfo {{
name = "services.example.org"
description = "Anope IRC Services"
numeric = "00A"
pid = "services.pid"
motd = "conf/empty_file"
}}
uplink {{
host = "{server_hostname}"
port = {server_port}
password = "password"
}}
module {{
name = "{protocol}"
}}
networkinfo {{
networkname = "testnet"
nicklen = 31
userlen = 10
hostlen = 64
chanlen = 32
}}
mail {{
usemail = no
}}
service {{
nick = "NickServ"
user = "services"
host = "services.host"
gecos = "Nickname Registration Service"
}}
module {{
name = "nickserv"
client = "NickServ"
forceemail = no
passlen = 1000 # Some tests need long passwords
}}
command {{ service = "NickServ"; name = "HELP"; command = "generic/help"; }}
module {{
name = "ns_register"
registration = "none"
}}
command {{ service = "NickServ"; name = "REGISTER"; command = "nickserv/register"; }}
options {{
casemap = "ascii"
readtimeout = 5s
warningtimeout = 4h
}}
module {{ name = "m_sasl" }}
module {{ name = "enc_sha256" }}
module {{ name = "ns_cert" }}
"""
class AnopeController(BaseServicesController, DirectoryBasedController):
"""Collaborator for server controllers that rely on Anope"""
software_name = "Anope"
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
self.create_config()
assert protocol in (
"bahamut",
"inspircd3",
"charybdis",
"hybrid",
"plexus",
"unreal4",
"ngircd",
)
with self.open_file("conf/services.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
protocol=protocol,
server_hostname=server_hostname,
server_port=server_port,
)
)
with self.open_file("conf/empty_file") as fd:
pass
assert self.directory
services_path = shutil.which("services")
assert services_path
# Config and code need to be in the same directory, *obviously*
(self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib")
self.proc = subprocess.Popen(
[
"services",
"-n", # don't fork
"--config=services.conf", # can't be an absolute path
# "--logdir",
# f"/tmp/services-{server_port}.log",
],
cwd=self.directory,
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[AnopeController]:
return AnopeController

View File

@ -1,111 +0,0 @@
import subprocess
from typing import Optional, Type
import irctest
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
import irctest.cases
import irctest.runner
TEMPLATE_CONFIG = """
loadmodule "modules/protocol/{protocol}";
loadmodule "modules/backend/opensex";
loadmodule "modules/crypto/pbkdf2";
loadmodule "modules/nickserv/main";
loadmodule "modules/nickserv/cert";
loadmodule "modules/nickserv/register";
loadmodule "modules/nickserv/verify";
loadmodule "modules/saslserv/main";
loadmodule "modules/saslserv/authcookie";
#loadmodule "modules/saslserv/ecdh-x25519-challenge";
loadmodule "modules/saslserv/ecdsa-nist256p-challenge";
loadmodule "modules/saslserv/external";
loadmodule "modules/saslserv/plain";
#loadmodule "modules/saslserv/scram";
serverinfo {{
name = "services.example.org";
desc = "Atheme IRC Services";
numeric = "00A";
netname = "testnet";
adminname = "no admin";
adminemail = "no-admin@example.org";
registeremail = "registration@example.org";
auth = none; // Disable email check
}};
general {{
commit_interval = 5;
}};
uplink "My.Little.Server" {{
host = "{server_hostname}";
port = {server_port};
send_password = "password";
receive_password = "password";
}};
saslserv {{
nick = "SaslServ";
}};
"""
class AthemeController(BaseServicesController, DirectoryBasedController):
"""Mixin for server controllers that rely on Atheme"""
software_name = "Atheme"
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
self.create_config()
if protocol == "inspircd3":
# That's the name used by Anope
protocol = "inspircd"
assert protocol in ("bahamut", "inspircd", "charybdis", "unreal4", "ngircd")
with self.open_file("services.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
protocol=protocol,
server_hostname=server_hostname,
server_port=server_port,
)
)
assert self.directory
self.proc = subprocess.Popen(
[
"atheme-services",
"-n", # don't fork
"-c",
self.directory / "services.conf",
"-l",
f"/tmp/services-{server_port}.log",
"-p",
self.directory / "services.pid",
"-D",
self.directory,
],
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
)
def registerUser(
self,
case: irctest.cases.BaseServerTestCase,
username: str,
password: Optional[str] = None,
) -> None:
assert password
if len(password.encode()) > 288:
# It's hardcoded at compile-time :(
# https://github.com/atheme/atheme/blob/4fa0e03bd3ce2cb6041a339f308616580c5aac29/include/atheme/constants.h#L51
raise irctest.runner.NotImplementedByController("Passwords over 288 bytes")
super().registerUser(case, username, password)
def get_irctest_controller_class() -> Type[AthemeController]:
return AthemeController

View File

@ -1,188 +0,0 @@
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
TEMPLATE_CONFIG = """
global {{
name My.Little.Server; # IRC name of the server
info "located on earth"; # A short info line
}};
options {{
network_name unconfigured;
allow_split_ops; # Give ops in empty channels
services_name services.example.org;
// if you need to link more than 1 server, uncomment the following line
servtype hub;
}};
/* where to listen for connections */
port {{
port {port};
bind {hostname};
}};
/* allow clients to connect */
allow {{
host *@*; # Allow anyone
class users; # Place them in the users class
flags T; # No throttling
{password_field}
}};
/* connection class for users */
class {{
name users; # Class name
maxusers 100; # Maximum connections
pingfreq 1000; # Check idle connections every N seconds
maxsendq 100000; # 100KB send buffer limit
}};
/* for services */
super {{
"services.example.org";
}};
/* class for services */
class {{
name services;
pingfreq 60; # Idle check every minute
maxsendq 5000000; # 5MB backlog buffer
}};
/* our services */
connect {{
name services.example.org;
host *@127.0.0.1; # unfortunately, masks aren't allowed here
apasswd password;
cpasswd password;
class services;
}};
oper {{
name operuser;
host *@*;
passwd operpassword;
access *Aa;
class users;
}};
"""
def initialize_entropy(directory: Path) -> None:
# https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/include/dh.h#L35-L38
nb_rand_bytes = 512 // 8
# https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/src/dh.c#L186
entropy_file_size = nb_rand_bytes * 4
# Not actually random; but we don't care.
entropy = b"\x00" * entropy_file_size
with (directory / ".ircd.entropy").open("wb") as fd:
fd.write(entropy)
class BahamutController(BaseServerController, DirectoryBasedController):
software_name = "Bahamut"
supported_sasl_mechanisms: Set[str] = set()
supports_sts = False
nickserv = "NickServ@services.example.org"
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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()
password_field = "passwd {};".format(password) if password else ""
self.gen_ssl()
assert self.directory
# Bahamut reads some bytes from /dev/urandom on startup, which causes
# GitHub Actions to sometimes freeze and timeout.
# This initializes the entropy file so Bahamut does not need to do it itself.
initialize_entropy(self.directory)
# they are hardcoded... thankfully Bahamut reads them from the CWD.
shutil.copy(self.pem_path, self.directory / "ircd.crt")
shutil.copy(self.key_path, self.directory / "ircd.key")
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
password_field=password_field,
# key_path=self.key_path,
# pem_path=self.pem_path,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ircd",
"-t", # don't fork
"-f",
self.directory / "server.conf",
],
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="bahamut",
server_hostname=hostname,
server_port=port,
)
def get_irctest_controller_class() -> Type[BahamutController]:
return BahamutController

View File

@ -1,106 +0,0 @@
import shutil
import subprocess
from typing import Optional, Set
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
TEMPLATE_SSL_CONFIG = """
ssl_private_key = "{key_path}";
ssl_cert = "{pem_path}";
ssl_dh_params = "{dh_path}";
"""
class BaseHybridController(BaseServerController, DirectoryBasedController):
"""A base class for all controllers derived from ircd-hybrid (Hybrid itself,
Charybdis, Solanum, ...)"""
binary_name: str
services_protocol: str
supports_sts = False
extban_mute_char = None
template_config: str
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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()
password_field = 'password = "{}";'.format(password) if password else ""
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path, pem_path=self.pem_path, dh_path=self.dh_path
)
else:
ssl_config = ""
with self.open_file("server.conf") as fd:
fd.write(
(self.template_config).format(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
)
)
assert self.directory
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
self.binary_name,
"-foreground",
"-configfile",
self.directory / "server.conf",
"-pidfile",
self.directory / "server.pid",
],
# stderr=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol=self.services_protocol,
server_hostname=hostname,
server_port=services_port,
)

View File

@ -1,6 +1,13 @@
from typing import Type
import os
import time
import shutil
import tempfile
import subprocess
from .base_hybrid import BaseHybridController
from irctest import client_mock
from irctest import authentication
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
serverinfo {{
@ -9,79 +16,73 @@ serverinfo {{
description = "test server";
{ssl_config}
}};
general {{
throttle_count = 100; # We need to connect lots of clients quickly
# disable throttling for LIST and similar:
pace_wait_simple = 0 second;
pace_wait = 0 second;
sasl_service = "SaslServ";
}};
class "server" {{
ping_time = 5 minutes;
connectfreq = 5 minutes;
}};
listen {{
defer_accept = yes;
host = "{hostname}";
port = {port};
port = {services_port};
}};
auth {{
user = "*";
flags = exceed_limit;
{password_field}
}};
channel {{
disable_local_channels = no;
no_create_on_split = no;
no_join_on_split = no;
displayed_usercount = 0;
}};
"""
connect "services.example.org" {{
host = "localhost"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
accept_password = "password";
class = "server";
flags = topicburst;
}};
service {{
name = "services.example.org";
}};
privset "omnioper" {{
privs = oper:general, oper:privs, oper:testline, oper:kill, oper:operwall, oper:message,
oper:routing, oper:kline, oper:unkline, oper:xline,
oper:resv, oper:cmodes, oper:mass_notice, oper:wallops,
oper:remoteban,
usermode:servnotice, auspex:oper, auspex:hostname, auspex:umodes, auspex:cmodes,
oper:admin, oper:die, oper:rehash, oper:spy, oper:grant;
}};
operator "operuser" {{
user = "*@*";
password = "operpassword";
privset = "omnioper";
flags = ~encrypted;
}};
TEMPLATE_SSL_CONFIG = """
ssl_private_key = "{key_path}";
ssl_cert = "{pem_path}";
ssl_dh_params = "{dh_path}";
"""
class CharybdisController(BaseHybridController):
software_name = "Charybdis"
binary_name = "charybdis"
services_protocol = "charybdis"
class CharybdisController(BaseServerController, DirectoryBasedController):
software_name = 'Charybdis'
supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
def create_config(self):
super().create_config()
with self.open_file('server.conf'):
pass
supported_sasl_mechanisms = {"PLAIN"}
template_config = TEMPLATE_CONFIG
def run(self, hostname, port, password=None, ssl=False,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
'Defining valid and invalid METADATA keys.')
assert self.proc is None
self.create_config()
self.port = port
password_field = 'password = "{}";'.format(password) if password else ''
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path,
pem_path=self.pem_path,
dh_path=self.dh_path,
)
else:
ssl_config = ''
with self.open_file('server.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
ssl_config=ssl_config,
))
self.proc = subprocess.Popen(['charybdis', '-foreground',
'-configfile', os.path.join(self.directory, 'server.conf'),
'-pidfile', os.path.join(self.directory, 'server.pid'),
],
stderr=subprocess.DEVNULL
)
def get_irctest_controller_class() -> Type[CharybdisController]:
def get_irctest_controller_class():
return CharybdisController

View File

@ -1,245 +0,0 @@
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,312 +0,0 @@
import copy
import json
import os
import shutil
import subprocess
from typing import Any, Dict, Optional, Set, Type, Union
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.cases import BaseServerTestCase
BASE_CONFIG = {
"network": {"name": "ErgoTest"},
"server": {
"name": "My.Little.Server",
"listeners": {},
"max-sendq": "16k",
"connection-limits": {
"enabled": True,
"cidr-len-ipv4": 32,
"cidr-len-ipv6": 64,
"ips-per-subnet": 1,
"exempted": ["localhost"],
},
"connection-throttling": {
"enabled": True,
"cidr-len-ipv4": 32,
"cidr-len-ipv6": 64,
"ips-per-subnet": 16,
"duration": "10m",
"max-connections": 1,
"ban-duration": "10m",
"ban-message": "Try again later",
"exempted": ["localhost"],
},
"lookup-hostnames": False,
"enforce-utf8": True,
"relaymsg": {"enabled": True, "separators": "/", "available-to-chanops": True},
"compatibility": {
"allow-truncation": False,
},
},
"accounts": {
"authentication-enabled": True,
"advertise-scram": True,
"multiclient": {
"allowed-by-default": True,
"enabled": True,
"always-on": "disabled",
},
"registration": {
"bcrypt-cost": 4,
"enabled": True,
"enabled-callbacks": ["none"],
"verify-timeout": "120h",
},
"nick-reservation": {
"enabled": True,
"method": "strict",
},
},
"channels": {"registration": {"enabled": True}},
"datastore": {"path": None},
"limits": {
"awaylen": 200,
"chan-list-modes": 60,
"channellen": 64,
"kicklen": 390,
"linelen": {"rest": 2048},
"monitor-entries": 100,
"nicklen": 32,
"topiclen": 390,
"whowas-entries": 100,
"multiline": {"max-bytes": 4096, "max-lines": 32},
},
"history": {
"enabled": True,
"channel-length": 128,
"client-length": 128,
"chathistory-maxmessages": 100,
"tagmsg-storage": {
"default": False,
"whitelist": ["+draft/persist", "+persist"],
},
},
"oper-classes": {
"server-admin": {
"title": "Server Admin",
"capabilities": [
"oper:local_kill",
"oper:local_ban",
"oper:local_unban",
"nofakelag",
"oper:remote_kill",
"oper:remote_ban",
"oper:remote_unban",
"oper:rehash",
"oper:die",
"accreg",
"sajoin",
"samode",
"vhosts",
"chanreg",
"relaymsg",
],
}
},
"opers": {
"operuser": {
"class": "server-admin",
"whois-line": "is a server admin",
# "operpassword"
"password": "$2a$04$bKb6k5A6yuFA2wx.iJtxcuT2dojHQAjHd5ZPK/I2sjJml7p4spxjG",
}
},
}
LOGGING_CONFIG = {"logging": [{"method": "stderr", "level": "debug", "type": "*"}]}
def hash_password(password: Union[str, bytes]) -> str:
if isinstance(password, str):
password = password.encode("utf-8")
# simulate entry of password and confirmation:
input_ = password + b"\n" + password + b"\n"
p = subprocess.Popen(
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
out, _ = p.communicate(input_)
return out.decode("utf-8")
class ErgoController(BaseServerController, DirectoryBasedController):
software_name = "Ergo"
_port_wait_interval = 0.01
supported_sasl_mechanisms = {"PLAIN", "SCRAM-SHA-256"}
supports_sts = True
extban_mute_char = "m"
def create_config(self) -> None:
super().create_config()
with self.open_file("ircd.yaml"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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)
assert self.directory
enable_chathistory = self.test_config.chathistory
enable_roleplay = self.test_config.ergo_roleplay
if enable_chathistory or enable_roleplay:
config = self.addMysqlToConfig(config)
if enable_roleplay:
config["roleplay"] = {"enabled": True}
if self.test_config.ergo_config:
self.test_config.ergo_config(config)
self.port = port
bind_address = "127.0.0.1:%s" % (port,)
listener_conf = None # plaintext
if ssl:
self.key_path = self.directory / "ssl.key"
self.pem_path = self.directory / "ssl.pem"
listener_conf = {"tls": {"cert": self.pem_path, "key": self.key_path}}
config["server"]["listeners"][bind_address] = listener_conf # type: ignore
config["datastore"]["path"] = str(self.directory / "ircd.db") # type: ignore
if password is not None:
config["server"]["password"] = hash_password(password) # type: ignore
assert self.proc is None
self._config_path = self.directory / "server.yml"
self._config = config
self._write_config()
subprocess.call(["ergo", "initdb", "--conf", self._config_path, "--quiet"])
subprocess.call(["ergo", "mkcerts", "--conf", self._config_path, "--quiet"])
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
)
def wait_for_services(self) -> None:
# Nothing to wait for, they start at the same time as Ergo.
pass
def registerUser(
self,
case: BaseServerTestCase,
username: str,
password: Optional[str] = None,
) -> None:
# 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
# controllers.
raise ValueError(
"Attempted to register a nick, but `run_services` it not True."
)
client = case.addClient(show_io=False)
case.sendLine(client, "CAP LS 302")
case.sendLine(client, "NICK " + username)
case.sendLine(client, "USER r e g :user")
case.sendLine(client, "CAP END")
while case.getRegistrationMessage(client).command != "001":
pass
case.getMessages(client)
assert password
case.sendLine(client, "NS REGISTER " + password)
msg = case.getMessage(client)
assert msg.params == [username, "Account created"]
case.sendLine(client, "QUIT")
case.assertDisconnected(client)
def _write_config(self) -> None:
with open(self._config_path, "w") as fd:
json.dump(self._config, fd)
def baseConfig(self) -> Dict:
return copy.deepcopy(BASE_CONFIG)
def getConfig(self) -> Dict:
return copy.deepcopy(self._config)
def addLoggingToConfig(self, config: Optional[Dict] = None) -> Dict:
if config is None:
config = self.baseConfig()
config.update(LOGGING_CONFIG)
return config
def addMysqlToConfig(self, config: Optional[Dict] = None) -> Dict:
mysql_password = os.getenv("MYSQL_PASSWORD")
if config is None:
config = self.baseConfig()
if not mysql_password:
return config
config["datastore"]["mysql"] = {
"enabled": True,
"host": "localhost",
"user": "ergo",
"password": mysql_password,
"history-database": "ergo_history",
"timeout": "3s",
}
config["accounts"]["multiclient"] = {
"enabled": True,
"allowed-by-default": True,
"always-on": "disabled",
}
config["history"]["persistent"] = {
"enabled": True,
"unregistered-channels": True,
"registered-channels": "opt-out",
"direct-messages": "opt-out",
}
return config
def rehash(self, case: BaseServerTestCase, config: Dict) -> None:
self._config = config
self._write_config()
client = "operator_for_rehash"
case.connectClient(nick=client, name=client)
case.sendLine(client, "OPER operuser operpassword")
case.sendLine(client, "REHASH")
case.getMessages(client)
case.sendLine(client, "QUIT")
case.assertDisconnected(client)
def enable_debug_logging(self, case: BaseServerTestCase) -> None:
config = self.getConfig()
config.update(LOGGING_CONFIG)
self.rehash(case, config)
def get_irctest_controller_class() -> Type[ErgoController]:
return ErgoController

View File

@ -1,51 +0,0 @@
import os
from typing import Optional, Set, Tuple, Type
from irctest.basecontrollers import BaseServerController
class ExternalServerController(BaseServerController):
"""Dummy controller that doesn't run a server.
Instead, it allows connecting to servers ran outside irctest."""
software_name = "unknown external server"
supported_sasl_mechanisms = set(
os.environ.get("IRCTEST_SERVER_SASL_MECHS", "").split()
)
def check_is_alive(self) -> None:
pass
def kill_proc(self) -> None:
pass
def wait_for_port(self) -> None:
pass
def get_hostname_and_port(self) -> Tuple[str, int]:
hostname = os.environ.get("IRCTEST_SERVER_HOSTNAME")
port = os.environ.get("IRCTEST_SERVER_PORT")
if not hostname or not port:
raise RuntimeError(
"Please set IRCTEST_SERVER_HOSTNAME and IRCTEST_SERVER_PORT."
)
return (hostname, int(port))
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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
def get_irctest_controller_class() -> Type[ExternalServerController]:
return ExternalServerController

View File

@ -1,38 +1,45 @@
import subprocess
from typing import Optional, Type
from irctest import authentication, tls
from irctest.basecontrollers import (
BaseClientController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.basecontrollers import BaseClientController, NotImplementedByController
class GircController(BaseClientController):
software_name = 'gIRC'
supported_sasl_mechanisms = ['PLAIN']
supported_capabilities = set() # Not exhaustive
class GircController(BaseClientController, DirectoryBasedController):
software_name = "gIRC"
supported_sasl_mechanisms = {"PLAIN"}
def __init__(self):
super().__init__()
self.directory = None
self.proc = None
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
def kill(self):
if self.proc:
self.proc.terminate()
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc = None
def __del__(self):
if self.proc:
self.proc.kill()
if self.directory:
self.directory.cleanup()
def run(self, hostname, port, auth, tls_config):
if tls_config:
print(tls_config)
raise NotImplementedByController("TLS options")
args = ["--host", hostname, "--port", str(port), "--quiet"]
raise NotImplementedByController('TLS options')
args = ['--host', hostname, '--port', str(port), '--quiet']
if auth and auth.username and auth.password:
args += ["--sasl-name", auth.username]
args += ["--sasl-pass", auth.password]
args += ["--sasl-fail-is-ok"]
args += ['--sasl-name', auth.username]
args += ['--sasl-pass', auth.password]
args += ['--sasl-fail-is-ok']
# Runs a client with the config given as arguments
self.proc = subprocess.Popen(["girc_test", "connect"] + args)
self.proc = subprocess.Popen(['girc_test', 'connect'] + args)
def get_irctest_controller_class() -> Type[GircController]:
def get_irctest_controller_class():
return GircController

View File

@ -1,82 +1,88 @@
from typing import Set, Type
import os
import time
import shutil
import tempfile
import subprocess
from .base_hybrid import BaseHybridController
from irctest import client_mock
from irctest import authentication
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
serverinfo {{
name = "My.Little.Server";
sid = "42X";
description = "test server";
# Hybrid defaults to 9
max_nick_length = 20;
{ssl_config}
}};
general {{
throttle_count = 100; # We need to connect lots of clients quickly
sasl_service = "SaslServ";
# Allow PART/QUIT reasons quickly
anti_spam_exit_message_time = 0;
# Allow all commands quickly
pace_wait_simple = 0;
pace_wait = 0;
}};
listen {{
defer_accept = yes;
host = "{hostname}";
port = {port};
port = {services_port};
}};
class {{
name = "server";
ping_time = 5 minutes;
connectfreq = 5 minutes;
general {{
disable_auth = yes;
anti_nick_flood = no;
max_nick_changes = 256;
throttle_count = 512;
}};
connect {{
name = "services.example.org";
host = "127.0.0.1"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
accept_password = "password";
class = "server";
}};
service {{
name = "services.example.org";
}};
auth {{
user = "*";
flags = exceed_limit;
{password_field}
}};
"""
operator {{
name = "operuser";
user = "*@*";
password = "operpassword";
encrypted = no;
umodes = locops, servnotice, wallop;
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
kline, module, rehash, restart, set, unkline, unxline, wallops, xline;
}};
TEMPLATE_SSL_CONFIG = """
rsa_private_key_file = "{key_path}";
ssl_certificate_file = "{pem_path}";
ssl_dh_param_file = "{dh_path}";
"""
class HybridController(BaseHybridController):
software_name = "Hybrid"
binary_name = "ircd"
services_protocol = "hybrid"
class HybridController(BaseServerController, DirectoryBasedController):
software_name = 'Hybrid'
supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
supported_sasl_mechanisms: Set[str] = set()
def create_config(self):
super().create_config()
with self.open_file('server.conf'):
pass
template_config = TEMPLATE_CONFIG
def run(self, hostname, port, password=None, ssl=False,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
'Defining valid and invalid METADATA keys.')
assert self.proc is None
self.create_config()
self.port = port
password_field = 'password = "{}";'.format(password) if password else ''
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path,
pem_path=self.pem_path,
dh_path=self.dh_path,
)
else:
ssl_config = ''
with self.open_file('server.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
ssl_config=ssl_config,
))
self.proc = subprocess.Popen(['ircd', '-foreground',
'-configfile', os.path.join(self.directory, 'server.conf'),
'-pidfile', os.path.join(self.directory, 'server.pid'),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
def get_irctest_controller_class() -> Type[HybridController]:
def get_irctest_controller_class():
return HybridController

View File

@ -1,92 +1,25 @@
import functools
import os
import time
import shutil
import tempfile
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 import authentication
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
# Clients:
<bind address="{hostname}" port="{port}" type="clients">
{ssl_config}
<module name="cap">
<module name="ircv3">
<module name="ircv3_capnotify">
<module name="ircv3_echomessage">
<module name="namesx"> # For multi-prefix
<connect allow="*"
resolvehostnames="no" # Faster
recvq="40960" # Needs to be larger than a valid message with tags
timeout="10" # So tests don't hang too long
localmax="1000"
globalmax="1000"
{password_field}>
<class
name="ServerOperators"
commands="WALLOPS GLOBOPS"
privs="channels/auspex users/auspex channels/auspex servers/auspex"
>
<type
name="NetAdmin"
classes="ServerOperators"
>
<oper name="operuser"
password="operpassword"
host="*@*"
type="NetAdmin"
class="ServerOperators"
>
<options casemapping="ascii">
# Disable 'NOTICE #chan :*** foo invited bar into the channel-
<security announceinvites="none">
# Services:
<bind address="{services_hostname}" port="{services_port}" type="servers">
<link name="services.example.org"
ipaddr="{services_hostname}"
port="{services_port}"
allowmask="*"
recvpass="password"
sendpass="password"
>
<module name="spanningtree">
<module name="services_account">
<module name="hidechans"> # Anope errors when missing
<module name="svshold"> # Atheme raises a warning when missing
<sasl requiressl="no"
target="services.example.org">
# Protocol:
<module name="banexception">
<module name="botmode">
<module name="cap">
<module name="inviteexception">
<module name="ircv3">
<module name="ircv3_accounttag">
<module name="ircv3_batch">
<module name="ircv3_capnotify">
<module name="ircv3_ctctags">
<module name="ircv3_echomessage">
<module name="ircv3_invitenotify">
<module name="ircv3_labeledresponse">
<module name="ircv3_msgid">
<module name="ircv3_servertime">
<module name="monitor">
<module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl">
# HELP/HELPOP
<module name="alias"> # for the HELP alias
<module name="{help_module_name}">
<include file="examples/{help_module_name}.conf.example">
# Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
<server name="My.Little.Server" description="test server" id="000" network="testnet">
"""
TEMPLATE_SSL_CONFIG = """
@ -94,111 +27,47 @@ TEMPLATE_SSL_CONFIG = """
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
"""
@functools.lru_cache()
def installed_version() -> int:
output = subprocess.check_output(["inspircd", "--version"], universal_newlines=True)
if output.startswith("InspIRCd-3"):
return 3
if output.startswith("InspIRCd-4"):
return 4
else:
assert False, f"unexpected version: {output}"
class InspircdController(BaseServerController, DirectoryBasedController):
software_name = "InspIRCd"
supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False
extban_mute_char = "m"
software_name = 'InspIRCd'
supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
def create_config(self) -> None:
def create_config(self):
super().create_config()
with self.open_file("server.conf"):
with self.open_file('server.conf'):
pass
def run(
self,
hostname: str,
port: int,
*,
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:
def run(self, hostname, port, password=None, ssl=False,
restricted_metadata_keys=None,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
'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()
password_field = 'password="{}"'.format(password) if password else ""
password_field = 'password="{}"'.format(password) if password else ''
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path, pem_path=self.pem_path, dh_path=self.dh_path
)
key_path=self.key_path,
pem_path=self.pem_path,
dh_path=self.dh_path,
)
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(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
help_module_name=help_module_name,
)
)
assert self.directory
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"inspircd",
"--nofork",
"--config",
self.directory / "server.conf",
],
stdout=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="inspircd3",
server_hostname=services_hostname,
server_port=services_port,
ssl_config = ''
with self.open_file('server.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
ssl_config=ssl_config
))
self.proc = subprocess.Popen(['inspircd', '--nofork', '--config',
os.path.join(self.directory, 'server.conf')],
stdout=subprocess.DEVNULL
)
def get_irctest_controller_class() -> Type[InspircdController]:
def get_irctest_controller_class():
return InspircdController

View File

@ -1,102 +0,0 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
TEMPLATE_CONFIG = """
# M:<Server NAME>:<YOUR Internet IP#>:<Geographic Location>:<Port>:<SID>:
M:My.Little.Server:{hostname}:test server:{port}:0042:
# A:<Your Name/Location>:<Your E-Mail Addr>:<other info>::<network name>:
A:Organization, IRC dept.:Daemon <ircd@example.irc.org>:Client Server::IRCnet:
# P:<YOUR Internet IP#>:<*>::<Port>:<Flags>
P::::{port}::
# Y:<Class>:<Ping Frequency>::<Max Links>:<SendQ>:<Local Limit>:<Global Limit>:
Y:10:90::100:512000:100.100:100.100:
# I:<TARGET Host Addr>:<Password>:<TARGET Hosts NAME>:<Port>:<Class>:<Flags>:
I::{password_field}:::10::
# O:<TARGET Host NAME>:<Password>:<Nickname>:<Port>:<Class>:<Flags>:
O:*:operpassword:operuser::::
"""
class Irc2Controller(BaseServerController, DirectoryBasedController):
software_name = "irc2"
services_protocol: str
supports_sts = False
extban_mute_char = None
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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:
raise NotImplementedByController("Services")
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
password_field = password if password else ""
assert self.directory
pidfile = self.directory / "ircd.pid"
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
pidfile=pidfile,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ircd",
"-s", # no iauth
"-p",
"on",
"-f",
self.directory / "server.conf",
],
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[Irc2Controller]:
return Irc2Controller

View File

@ -1,121 +0,0 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
TEMPLATE_CONFIG = """
General {{
name = "My.Little.Server";
numeric = 42;
description = "test server";
}};
Port {{
vhost = "{hostname}";
port = {port};
}};
Class {{
name = "Client";
pingfreq = 5 minutes;
sendq = 160000;
maxlinks = 1024;
}};
Client {{
username = "*";
class = "Client";
{password_field}
}};
Operator {{
local = no;
host = "*@*";
password = "$PLAIN$operpassword";
name = "operuser";
class = "Client";
}};
features {{
"PPATH" = "{pidfile}";
# workaround for whois tests, checking the server name
"HIS_SERVERNAME" = "My.Little.Server";
}};
"""
class Ircu2Controller(BaseServerController, DirectoryBasedController):
software_name = "ircu2"
supports_sts = False
extban_mute_char = None
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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:
raise NotImplementedByController("Services")
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
password_field = 'password = "{}";'.format(password) if password else ""
assert self.directory
pidfile = self.directory / "ircd.pid"
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
pidfile=pidfile,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ircd",
"-n", # don't detach
"-f",
self.directory / "server.conf",
"-x",
"DEBUG",
],
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[Ircu2Controller]:
return Ircu2Controller

View File

@ -1,7 +1,9 @@
import os
import subprocess
from typing import Optional, Type
from irctest import authentication, tls
from irctest import authentication
from irctest import tls
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseClientController, DirectoryBasedController
TEMPLATE_CONFIG = """
@ -24,68 +26,50 @@ supybot.networks.testnet.sasl.ecdsa_key: {directory}/ecdsa_key.pem
supybot.networks.testnet.sasl.mechanisms: {mechanisms}
"""
class LimnoriaController(BaseClientController, DirectoryBasedController):
software_name = "Limnoria"
software_name = 'Limnoria'
supported_sasl_mechanisms = {
"PLAIN",
"ECDSA-NIST256P-CHALLENGE",
"SCRAM-SHA-256",
"EXTERNAL",
}
supports_sts = True
'PLAIN', 'ECDSA-NIST256P-CHALLENGE', 'SCRAM-SHA-256', 'EXTERNAL',
}
supported_capabilities = set(['sts']) # Not exhaustive
def create_config(self) -> None:
super().create_config()
with self.open_file("bot.conf"):
pass
with self.open_file("conf/users.conf"):
pass
def create_config(self):
create_config = super().create_config()
if 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:
def run(self, hostname, port, auth, tls_config=None):
if tls_config is None:
tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[])
# 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)
mechanisms = ' '.join(map(authentication.Mechanisms.as_string,
auth.mechanisms))
if auth.ecdsa_key:
with self.open_file("ecdsa_key.pem") as fd:
with self.open_file('ecdsa_key.pem') as fd:
fd.write(auth.ecdsa_key)
else:
mechanisms = ''
with self.open_file('bot.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
directory=self.directory,
loglevel='CRITICAL',
hostname=hostname,
port=port,
username=auth.username if auth else '',
password=auth.password if auth else '',
mechanisms=mechanisms.lower(),
enable_tls=tls_config.enable if tls_config else 'False',
trusted_fingerprints=' '.join(tls_config.trusted_fingerprints) if tls_config else '',
))
self.proc = subprocess.Popen(['supybot',
os.path.join(self.directory, 'bot.conf')],
stderr=subprocess.STDOUT)
if auth.username:
username = auth.username.encode("unicode_escape").decode()
if auth.password:
password = auth.password.encode("unicode_escape").decode()
with self.open_file("bot.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
directory=self.directory,
loglevel="CRITICAL",
hostname=hostname,
port=port,
username=username,
password=password,
mechanisms=mechanisms.lower(),
enable_tls=tls_config.enable if tls_config else "False",
trusted_fingerprints=" ".join(tls_config.trusted_fingerprints)
if tls_config
else "",
)
)
assert self.directory
self.proc = subprocess.Popen(["supybot", self.directory / "bot.conf"])
def get_irctest_controller_class() -> Type[LimnoriaController]:
def get_irctest_controller_class():
return LimnoriaController

View File

@ -1,13 +1,9 @@
import shutil
import os
import time
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.cases import BaseServerTestCase
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
clients:
@ -62,98 +58,66 @@ server:
recvq_len: 20
"""
def make_list(list_: Set[str]) -> str:
return "\n".join(map(" - {}".format, list_))
def make_list(l):
return '\n'.join(map(' - {}'.format, l))
class MammonController(BaseServerController, DirectoryBasedController):
software_name = "Mammon"
supported_sasl_mechanisms = {"PLAIN", "ECDSA-NIST256P-CHALLENGE"}
software_name = 'Mammon'
supported_sasl_mechanisms = {
'PLAIN', 'ECDSA-NIST256P-CHALLENGE',
}
supported_capabilities = set() # Not exhaustive
def create_config(self) -> None:
def create_config(self):
super().create_config()
with self.open_file("server.conf"):
with self.open_file('server.conf'):
pass
def kill_proc(self) -> None:
def kill_proc(self):
# Mammon does not seem to handle SIGTERM very well
assert self.proc
self.proc.kill()
def run(
self,
hostname: str,
port: int,
*,
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:
def run(self, hostname, port, password=None, ssl=False,
restricted_metadata_keys=(),
valid_metadata_keys=(), invalid_metadata_keys=()):
if password is not None:
raise NotImplementedByController("PASS command")
raise NotImplementedByController('PASS command')
if ssl:
raise NotImplementedByController("SSL")
raise NotImplementedByController('SSL')
assert self.proc is None
self.port = port
self.create_config()
with self.open_file("server.yml") as fd:
fd.write(
TEMPLATE_CONFIG.format(
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:
with self.open_file('server.yml') as fd:
fd.write(TEMPLATE_CONFIG.format(
directory=self.directory,
hostname=hostname,
port=port,
authorized_keys=make_list(valid_metadata_keys),
restricted_keys=make_list(restricted_metadata_keys),
))
#with self.open_file('server.yml', 'r') as fd:
# print(fd.read())
assert self.directory
self.proc = subprocess.Popen(['mammond', '--nofork', #'--debug',
'--config', os.path.join(self.directory, 'server.yml')])
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"mammond",
"--nofork", # '--debug',
"--config",
self.directory / "server.yml",
]
)
def registerUser(
self,
case: BaseServerTestCase,
username: str,
password: Optional[str] = None,
) -> None:
def registerUser(self, case, username, password=None):
# XXX: Move this somewhere else when
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
# part of the specification
client = case.addClient(show_io=False)
case.sendLine(client, "CAP LS 302")
case.sendLine(client, "NICK registration_user")
case.sendLine(client, "USER r e g :user")
case.sendLine(client, "CAP END")
while case.getRegistrationMessage(client).command != "001":
case.sendLine(client, 'CAP LS 302')
case.sendLine(client, 'NICK registration_user')
case.sendLine(client, 'USER r e g :user')
case.sendLine(client, 'CAP END')
while case.getRegistrationMessage(client).command != '001':
pass
list(case.getMessages(client))
case.sendLine(client, "REG CREATE {} passphrase {}".format(username, password))
case.sendLine(client, 'REG CREATE {} passphrase {}'.format(
username, password))
msg = case.getMessage(client)
assert msg.command == "920", msg
assert msg.command == '920', msg
list(case.getMessages(client))
case.removeClient(client)
def get_irctest_controller_class() -> Type[MammonController]:
def get_irctest_controller_class():
return MammonController

View File

@ -1,11 +0,0 @@
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,130 +0,0 @@
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
TEMPLATE_CONFIG = """
[Global]
Name = My.Little.Server
Info = test server
Bind = {hostname}
Ports = {port}
AdminInfo1 = Bob Smith
AdminEMail = email@example.org
{password_field}
[Server]
Name = services.example.org
MyPassword = password
PeerPassword = password
Passive = yes # don't connect to it
ServiceMask = *Serv
[Options]
MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
[Operator]
Name = operuser
Password = operpassword
"""
class NgircdController(BaseServerController, DirectoryBasedController):
software_name = "ngIRCd"
supported_sasl_mechanisms: Set[str] = set()
supports_sts = False
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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()
password_field = "Password = {}".format(password) if password else ""
self.gen_ssl()
if ssl:
(tls_hostname, tls_port) = (hostname, port)
(hostname, port) = (unused_hostname, unused_port)
else:
# Unreal refuses to start without TLS enabled
(tls_hostname, tls_port) = (unused_hostname, unused_port)
with self.open_file("empty.txt") as fd:
fd.write("\n")
assert self.directory
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
tls_hostname=tls_hostname,
tls_port=tls_port,
password_field=password_field,
key_path=self.key_path,
pem_path=self.pem_path,
empty_file=self.directory / "empty.txt",
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ngircd",
"--nodaemon",
"--config",
self.directory / "server.conf",
],
# stdout=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="ngircd",
server_hostname=hostname,
server_port=port,
)
def get_irctest_controller_class() -> Type[NgircdController]:
return NgircdController

View File

@ -0,0 +1,144 @@
import os
import subprocess
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
network:
name: OragonoTest
server:
name: oragono.test
listen:
- "{hostname}:{port}"
{tls}
check-ident: false
max-sendq: 16k
connection-limits:
cidr-len-ipv4: 24
cidr-len-ipv6: 120
ips-per-subnet: 16
exempted:
- "127.0.0.1/8"
- "::1/128"
connection-throttling:
enabled: true
cidr-len-ipv4: 32
cidr-len-ipv6: 128
duration: 10m
max-connections: 12
ban-duration: 10m
ban-message: You have attempted to connect too many times within a short duration. Wait a while, and you will be able to connect.
exempted:
- "127.0.0.1/8"
- "::1/128"
accounts:
registration:
enabled: true
verify-timeout: "120h"
enabled-callbacks:
- none # no verification needed, will instantly register successfully
allow-multiple-per-connection: true
authentication-enabled: true
channels:
registration:
enabled: true
datastore:
path: {directory}/ircd.db
limits:
nicklen: 32
channellen: 64
awaylen: 200
kicklen: 390
topiclen: 390
monitor-entries: 100
whowas-entries: 100
chan-list-modes: 60
linelen:
tags: 2048
rest: 2048
"""
class OragonoController(BaseServerController, DirectoryBasedController):
software_name = 'Oragono'
supported_sasl_mechanisms = {
'PLAIN',
}
supported_capabilities = set() # Not exhaustive
def create_config(self):
super().create_config()
with self.open_file('ircd.yaml'):
pass
def kill_proc(self):
self.proc.kill()
def run(self, hostname, port, password=None, ssl=False,
restricted_metadata_keys=None,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
'Defining valid and invalid METADATA keys.')
if password is not None:
#TODO(dan): fix dis
raise NotImplementedByController('PASS command')
self.create_config()
tls_config = ""
if ssl:
self.key_path = os.path.join(self.directory, 'ssl.key')
self.pem_path = os.path.join(self.directory, 'ssl.pem')
tls_config = 'tls-listeners:\n ":{port}":\n key: {key}\n cert: {pem}'.format(
port=port,
key=self.key_path,
pem=self.pem_path,
)
assert self.proc is None
self.port = port
with self.open_file('server.yml') as fd:
fd.write(TEMPLATE_CONFIG.format(
directory=self.directory,
hostname=hostname,
port=port,
tls=tls_config,
))
subprocess.call(['oragono', 'initdb',
'--conf', os.path.join(self.directory, 'server.yml'), '--quiet'])
subprocess.call(['oragono', 'mkcerts',
'--conf', os.path.join(self.directory, 'server.yml'), '--quiet'])
self.proc = subprocess.Popen(['oragono', 'run',
'--conf', os.path.join(self.directory, 'server.yml'), '--quiet'])
def registerUser(self, case, username, password=None):
# XXX: Move this somewhere else when
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
# part of the specification
client = case.addClient(show_io=False)
case.sendLine(client, 'CAP LS 302')
case.sendLine(client, 'NICK registration_user')
case.sendLine(client, 'USER r e g :user')
case.sendLine(client, 'CAP END')
while case.getRegistrationMessage(client).command != '001':
pass
case.getMessages(client)
case.sendLine(client, 'ACC REGISTER {} * {}'.format(
username, password))
msg = case.getMessage(client)
assert msg.command == '920', msg
case.sendLine(client, 'QUIT')
case.assertDisconnected(client)
def get_irctest_controller_class():
return OragonoController

View File

@ -1,87 +0,0 @@
from typing import Set, Type
from .base_hybrid import BaseHybridController
TEMPLATE_CONFIG = """
serverinfo {{
name = "My.Little.Server";
sid = "42X";
description = "test server";
# Hybrid defaults to 9
max_nick_length = 20;
{ssl_config}
}};
general {{
throttle_count = 100; # We need to connect lots of clients quickly
sasl_service = "SaslServ";
# Allow connections quickly
throttle_num = 100;
# Allow PART/QUIT reasons quickly
anti_spam_exit_message_time = 0;
# Allow all commands quickly
pace_wait_simple = 0;
pace_wait = 0;
}};
listen {{
defer_accept = yes;
host = "{hostname}";
port = {port};
flags = server;
port = {services_port};
}};
class {{
name = "server";
ping_time = 5 minutes;
connectfreq = 5 minutes;
}};
connect {{
name = "services.example.org";
host = "127.0.0.1"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
accept_password = "password";
class = "server";
}};
service {{
name = "services.example.org";
}};
auth {{
user = "*";
flags = exceed_limit;
{password_field}
}};
operator {{
name = "operuser";
user = "*@*";
password = "operpassword";
encrypted = no;
umodes = locops, servnotice, wallop;
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
kline, module, rehash, restart, set, unkline, unxline, wallops, xline;
}};
"""
class Plexus4Controller(BaseHybridController):
software_name = "Plexus4"
binary_name = "ircd"
services_protocol = "plexus"
supported_sasl_mechanisms: Set[str] = set()
template_config = TEMPLATE_CONFIG
def get_irctest_controller_class() -> Type[Plexus4Controller]:
return Plexus4Controller

View File

@ -1,120 +0,0 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
TEMPLATE_CONFIG = """
General {{
name = "My.Little.Server";
numeric = 42;
description = "test server";
}};
Port {{
vhost = "{hostname}";
port = {port};
}};
Class {{
name = "Client";
pingfreq = 5 minutes;
sendq = 160000;
maxlinks = 1024;
}};
Client {{
username = "*";
class = "Client";
{password_field}
}};
Operator {{
local = no;
host = "*@*";
password = "$PLAIN$operpassword";
name = "operuser";
class = "Client";
}};
features {{
"PPATH" = "{pidfile}";
# don't block notices by default, wtf
"AUTOCHANMODES_LIST" = "+tnC";
}};
"""
class SnircdController(BaseServerController, DirectoryBasedController):
supports_sts = False
extban_mute_char = None
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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:
raise NotImplementedByController("Services")
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
password_field = 'password = "{}";'.format(password) if password else ""
assert self.directory
pidfile = self.directory / "ircd.pid"
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
pidfile=pidfile,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"ircd",
"-n", # don't detach
"-f",
self.directory / "server.conf",
"-x",
"DEBUG",
],
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[SnircdController]:
return SnircdController

View File

@ -1,12 +0,0 @@
from typing import Type
from .charybdis import CharybdisController
class SolanumController(CharybdisController):
software_name = "Solanum"
binary_name = "solanum"
def get_irctest_controller_class() -> Type[SolanumController]:
return SolanumController

View File

@ -1,14 +1,9 @@
from pathlib import Path
import subprocess
import os
import tempfile
from typing import Optional, TextIO, Type, cast
import subprocess
from irctest import authentication, tls
from irctest.basecontrollers import (
BaseClientController,
NotImplementedByController,
TestCaseControllerConfig,
)
from irctest.basecontrollers import BaseClientController
from irctest.basecontrollers import NotImplementedByController
TEMPLATE_CONFIG = """
[core]
@ -17,64 +12,59 @@ host = {hostname}
use_ssl = false
port = {port}
owner = me
channels =
channels =
timeout = 5
auth_username = {username}
auth_password = {password}
{auth_method}
"""
class SopelController(BaseClientController):
software_name = "Sopel"
supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False
software_name = 'Sopel'
supported_sasl_mechanisms = {
'PLAIN',
}
supported_capabilities = set() # Not exhaustive
def __init__(self, test_config: TestCaseControllerConfig):
super().__init__(test_config)
self.filename = next(tempfile._get_candidate_names()) + ".cfg" # type: ignore
def kill(self) -> None:
super().kill()
def __init__(self):
super().__init__()
self.filename = next(tempfile._get_candidate_names()) + '.cfg'
self.proc = None
def kill(self):
if self.proc:
self.proc.kill()
if self.filename:
try:
(Path("~/.sopel/").expanduser() / self.filename).unlink()
except OSError: # File does not exist
os.unlink(os.path.join(os.path.expanduser('~/.sopel/'),
self.filename))
except OSError: # File does not exist
pass
def open_file(self, filename: str, mode: str = "a") -> TextIO:
dir_path = Path("~/.sopel/").expanduser()
dir_path.mkdir(parents=True, exist_ok=True)
return cast(TextIO, (dir_path / filename).open(mode))
def open_file(self, filename, mode='a'):
return open(os.path.join(os.path.expanduser('~/.sopel/'), filename),
mode)
def create_config(self) -> None:
with self.open_file(self.filename):
def create_config(self):
with self.open_file(self.filename) as fd:
pass
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
def run(self, hostname, port, auth, tls_config):
# Runs a client with the config given as arguments
if tls_config is not None:
raise NotImplementedByController("TLS configuration")
raise NotImplementedByController(
'TLS configuration')
assert self.proc is None
self.create_config()
with self.open_file(self.filename) as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
username=auth.username if auth else "",
password=auth.password if auth else "",
auth_method="auth_method = sasl" if auth else "",
)
)
self.proc = subprocess.Popen(["sopel", "-c", self.filename])
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
username=auth.username if auth else '',
password=auth.password if auth else '',
auth_method='auth_method = sasl' if auth else '',
))
self.proc = subprocess.Popen(['sopel', '--quiet', '-c', self.filename])
def get_irctest_controller_class() -> Type[SopelController]:
def get_irctest_controller_class():
return SopelController

View File

@ -1,301 +0,0 @@
import contextlib
import fcntl
import functools
from pathlib import Path
import shutil
import subprocess
import textwrap
from typing import Callable, ContextManager, Iterator, Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
TEMPLATE_CONFIG = """
include "modules.default.conf";
include "operclass.default.conf";
{extras}
include "help/help.conf";
me {{
name "My.Little.Server";
info "test server";
sid "001";
}}
admin {{
"Bob Smith";
"bob";
"email@example.org";
}}
class clients {{
pingfreq 90;
maxclients 1000;
sendq 200k;
recvq 8000;
}}
class servers {{
pingfreq 60;
connfreq 15; /* try to connect every 15 seconds */
maxclients 10; /* max servers */
sendq 20M;
}}
allow {{
mask *;
class clients;
maxperip 50;
{password_field}
}}
listen {{
ip {hostname};
port {port};
}}
listen {{
ip {tls_hostname};
port {tls_port};
options {{ tls; }}
tls-options {{
certificate "{pem_path}";
key "{key_path}";
}};
}}
/* Special SSL/TLS servers-only port for linking */
listen {{
ip {services_hostname};
port {services_port};
options {{ serversonly; }}
}}
link services.example.org {{
incoming {{
mask *;
}}
password "password";
class servers;
}}
ulines {{
services.example.org;
}}
set {{
sasl-server services.example.org;
kline-address "example@example.org";
network-name "ExampleNET";
default-server "irc.example.org";
help-channel "#Help";
cloak-keys {{ "aaaA1"; "bbbB2"; "cccC3"; }}
options {{
identd-check; // Disable it, so it doesn't prefix idents with a tilde
}}
anti-flood {{
// Prevent throttling, especially test_buffering.py which
// triggers anti-flood with its very long lines
unknown-users {{
nick-flood 255:10;
lag-penalty 1;
lag-penalty-bytes 10000;
}}
}}
modes-on-join "+H 100:1d"; // Enables CHATHISTORY
{set_extras}
}}
tld {{
mask *;
motd "{empty_file}";
botmotd "{empty_file}";
rules "{empty_file}";
}}
files {{
tunefile "{empty_file}";
}}
oper "operuser" {{
password = "operpassword";
mask *;
class clients;
operclass netadmin;
}}
"""
def _filelock(path: Path) -> Callable[[], ContextManager]:
"""Alternative to :cls:`multiprocessing.Lock` that works with pytest-xdist"""
@contextlib.contextmanager
def f() -> Iterator[None]:
with open(path, "a") as fd:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
return f
_UNREALIRCD_BIN = shutil.which("unrealircd")
if _UNREALIRCD_BIN:
_UNREALIRCD_PREFIX = Path(_UNREALIRCD_BIN).parent.parent
# Try to keep that lock file specific to this Unrealircd instance
_LOCK_PATH = _UNREALIRCD_PREFIX / "irctest-unrealircd-startstop.lock"
else:
# unrealircd not found; we are probably going to crash later anyway...
_LOCK_PATH = Path("/tmp/irctest-unrealircd-startstop.lock")
_STARTSTOP_LOCK = _filelock(_LOCK_PATH)
"""
Unreal cleans its tmp/ directory after each run, which prevents
multiple processes from starting/stopping at the same time.
"""
@functools.lru_cache()
def installed_version() -> int:
output = subprocess.check_output(["unrealircd", "-v"], universal_newlines=True)
if output.startswith("UnrealIRCd-5."):
return 5
elif output.startswith("UnrealIRCd-6."):
return 6
else:
assert False, f"unexpected version: {output}"
class UnrealircdController(BaseServerController, DirectoryBasedController):
software_name = "UnrealIRCd"
supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False
extban_mute_char = "quiet" if installed_version() >= 6 else "q"
software_version = installed_version()
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
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()
if installed_version() >= 6:
extras = textwrap.dedent(
"""
include "snomasks.default.conf";
loadmodule "cloak_md5";
"""
)
set_extras = textwrap.indent(
textwrap.dedent(
"""
// Remove RPL_WHOISSPECIAL used to advertise security groups
whois-details {
security-groups { everyone none; self none; oper none; }
}
"""
),
" ",
)
else:
extras = ""
set_extras = ""
with self.open_file("empty.txt") as fd:
fd.write("\n")
password_field = 'password "{}";'.format(password) if password else ""
with _STARTSTOP_LOCK():
(services_hostname, services_port) = find_hostname_and_port()
(unused_hostname, unused_port) = find_hostname_and_port()
self.gen_ssl()
if ssl:
(tls_hostname, tls_port) = (hostname, port)
(hostname, port) = (unused_hostname, unused_port)
else:
# Unreal refuses to start without TLS enabled
(tls_hostname, tls_port) = (unused_hostname, unused_port)
assert self.directory
with self.open_file("unrealircd.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
tls_hostname=tls_hostname,
tls_port=tls_port,
password_field=password_field,
key_path=self.key_path,
pem_path=self.pem_path,
empty_file=self.directory / "empty.txt",
extras=extras,
set_extras=set_extras,
)
)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"unrealircd",
"-t",
"-F", # BOOT_NOFORK
"-f",
self.directory / "unrealircd.conf",
],
# stdout=subprocess.DEVNULL,
)
self.wait_for_port()
if run_services:
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="unreal4",
server_hostname=services_hostname,
server_port=services_port,
)
def kill_proc(self) -> None:
assert self.proc
with _STARTSTOP_LOCK():
self.proc.kill()
self.proc.wait(5) # wait for it to actually die
self.proc = None
def get_irctest_controller_class() -> Type[UnrealircdController]:
return UnrealircdController

View File

@ -1,449 +0,0 @@
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,
)
import xml.etree.ElementTree as ET
from defusedxml.ElementTree import parse as parse_xml
import docutils.core
NETLIFY_CHAR_BLACKLIST = frozenset('":<>|*?\r\n#')
"""Characters not allowed in output filenames"""
@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):
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 = {}
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"]
htmltree = ET.fromstring(html)
return htmltree
def append_docstring(element: ET.Element, obj: object) -> None:
if obj.__doc__ is None:
return
element.append(rst_to_element(obj.__doc__))
def build_job_html(job: str, results: List[CaseResult]) -> ET.Element:
jobs = sorted({result.job for result in results})
root = ET.Element("html")
head = ET.SubElement(root, "head")
ET.SubElement(head, "title").text = job
ET.SubElement(head, "link", rel="stylesheet", type="text/css", href="./style.css")
body = ET.SubElement(root, "body")
ET.SubElement(body, "h1").text = job
table = build_test_table(jobs, results)
table.set("class", "job-results test-matrix")
body.append(table)
return root
def build_module_html(
jobs: List[str], results: List[CaseResult], module_name: str
) -> ET.Element:
module = importlib.import_module(module_name)
root = ET.Element("html")
head = ET.SubElement(root, "head")
ET.SubElement(head, "title").text = module_name
ET.SubElement(head, "link", rel="stylesheet", type="text/css", href="./style.css")
body = ET.SubElement(root, "body")
ET.SubElement(body, "h1").text = module_name
append_docstring(body, module)
table = build_test_table(jobs, results)
table.set("class", "module-results test-matrix")
body.append(table)
return root
def build_test_table(jobs: List[str], results: List[CaseResult]) -> 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)
)
table = ET.Element("table")
job_row = ET.Element("tr")
ET.SubElement(job_row, "th") # column of case name
for job in jobs:
cell = ET.SubElement(job_row, "th")
ET.SubElement(ET.SubElement(cell, "div"), "span").text = job
cell.set("class", "job-name")
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
header_row = ET.SubElement(table, "tr")
th = ET.SubElement(header_row, "th", colspan=str(len(jobs) + 1))
row_anchor = f"{qualified_class_name}"
section_header = ET.SubElement(
ET.SubElement(th, "h2"),
"a",
href=f"#{row_anchor}",
id=row_anchor,
)
section_header.text = qualified_class_name
append_docstring(th, getattr(module, class_name))
# Header row: one column for each implementation
table.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 = ET.SubElement(table, "tr", id=row_anchor)
cell = ET.SubElement(row, "th")
cell.set("class", "test-name")
cell_link = ET.SubElement(cell, "a", href=f"#{row_anchor}")
cell_link.text = test_name
results_by_job = group_by(test_results, key=lambda r: r.job)
for job_name in jobs:
cell = ET.SubElement(row, "td")
try:
(result,) = results_by_job[job_name]
except KeyError:
cell.set("class", "deselected")
cell.text = "d"
continue
text: Optional[str]
if result.skipped:
cell.set("class", "skipped")
if result.type == "pytest.skip":
text = "s"
elif result.type == "pytest.xfail":
text = "X"
cell.set("class", "expected-failure")
else:
text = result.type
elif result.success:
cell.set("class", "success")
if result.type:
# dead code?
text = result.type
else:
text = "."
else:
cell.set("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.
a = ET.SubElement(cell, "a", href=f"./{result.output_filename()}")
a.text = text or "?"
else:
cell.text = text or "?"
if result.message:
cell.set("title", result.message)
return table
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:
root = ET.Element("html")
head = ET.SubElement(root, "head")
ET.SubElement(head, "title").text = "irctest dashboard"
ET.SubElement(head, "link", rel="stylesheet", type="text/css", href="./style.css")
body = ET.SubElement(root, "body")
ET.SubElement(body, "h1").text = "irctest dashboard"
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
ET.SubElement(body, "h2").text = "Tests by command/specification"
dl = ET.SubElement(body, "dl")
dl.set("class", "module-index")
for (module_name, file_name) in sorted(module_pages):
module = importlib.import_module(module_name)
link = ET.SubElement(ET.SubElement(dl, "dt"), "a", href=f"./{file_name}")
link.text = module_name
append_docstring(ET.SubElement(dl, "dd"), module)
ET.SubElement(body, "h2").text = "Tests by implementation"
ul = ET.SubElement(body, "ul")
ul.set("class", "job-index")
for (job, file_name) in sorted(job_pages):
link = ET.SubElement(ET.SubElement(ul, "li"), "a", href=f"./{file_name}")
link.text = job
write_xml_file(output_dir / "index.xhtml", root)
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:
# Hacky: ET expects the namespace to be present in every tag we create instead;
# but it would be excessively verbose.
root.set("xmlns", "http://www.w3.org/1999/xhtml")
# Serialize
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

@ -1,87 +0,0 @@
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):
# 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

@ -1,67 +0,0 @@
@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

@ -1,6 +1,6 @@
class NoMessageException(AssertionError):
pass
class ConnectionClosed(Exception):
pass

View File

@ -2,10 +2,7 @@
Handles ambiguities of RFCs.
"""
from typing import List
def normalize_namreply_params(params: List[str]) -> List[str]:
def normalize_namreply_params(params):
# So… RFC 2812 says:
# "( "=" / "*" / "@" ) <channel>
# :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )
@ -14,10 +11,9 @@ def normalize_namreply_params(params: List[str]) -> List[str]:
# prefix.
# So let's normalize this to “with space”, and strip spaces at the
# end of the nick list.
params = list(params) # copy the list
if len(params) == 3:
assert params[1][0] in "=*@", params
params.insert(1, params[1][0])
assert params[1][0] in '=*@', params
params.insert(1), params[1][0]
params[2] = params[2][1:]
params[3] = params[3].rstrip()
return params

View File

@ -1,12 +1,10 @@
from typing import Dict, List, Optional
def cap_list_to_dict(caps: List[str]) -> Dict[str, Optional[str]]:
d: Dict[str, Optional[str]] = {}
for cap in caps:
if "=" in cap:
(key, value) = cap.split("=", 1)
d[key] = value
def cap_list_to_dict(l):
d = {}
for cap in l:
if '=' in cap:
(key, value) = cap.split('=', 1)
else:
d[cap] = None
key = cap
value = None
d[key] = value
return d

View File

@ -1,46 +0,0 @@
import datetime
import re
import secrets
import socket
from typing import Dict, Tuple
# thanks jess!
IRCV3_FORMAT_STRFTIME = "%Y-%m-%dT%H:%M:%S.%f%z"
def ircv3_timestamp_to_unixtime(timestamp: str) -> float:
return datetime.datetime.strptime(timestamp, IRCV3_FORMAT_STRFTIME).timestamp()
def random_name(base: str) -> str:
return base + "-" + secrets.token_hex(8)
def find_hostname_and_port() -> Tuple[str, int]:
"""Find available hostname/port to listen on."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
(hostname, port) = s.getsockname()
s.close()
return (hostname, port)
"""
Stolen from supybot:
"""
class MultipleReplacer:
"""Return a callable that replaces all dict keys by the associated
value. More efficient than multiple .replace()."""
# We use an object instead of a lambda function because it avoids the
# need for using the staticmethod() on the lambda function if assigning
# it to a class in Python 3.
def __init__(self, dict_: Dict[str, str]):
self._dict = dict_
dict_ = dict([(re.escape(key), val) for key, val in dict_.items()])
self._matcher = re.compile("|".join(dict_.keys()))
def __call__(self, s: str) -> str:
return self._matcher.sub(lambda m: self._dict[m.group(0)], s)

View File

@ -1,76 +1,62 @@
import dataclasses
import re
from typing import Any, Dict, List, Optional
from .junkdrawer import MultipleReplacer
import collections
import supybot.utils
# http://ircv3.net/specs/core/message-tags-3.2.html#escaping-values
TAG_ESCAPE = [
("\\", "\\\\"), # \ -> \\
(" ", r"\s"),
(";", r"\:"),
("\r", r"\r"),
("\n", r"\n"),
]
unescape_tag_value = MultipleReplacer(dict(map(lambda x: (x[1], x[0]), TAG_ESCAPE)))
('\\', '\\\\'), # \ -> \\
(' ', r'\s'),
(';', r'\:'),
('\r', r'\r'),
('\n', r'\n'),
]
unescape_tag_value = supybot.utils.str.MultipleReplacer(
dict(map(lambda x:(x[1],x[0]), TAG_ESCAPE)))
# TODO: validate host
tag_key_validator = re.compile(r"\+?(\S+/)?[a-zA-Z0-9-]+")
tag_key_validator = re.compile('(\S+/)?[a-zA-Z0-9-]+')
def parse_tags(s: str) -> Dict[str, Optional[str]]:
tags: Dict[str, Optional[str]] = {}
for tag in s.split(";"):
if "=" not in tag:
def parse_tags(s):
tags = {}
for tag in s.split(';'):
if '=' not in tag:
tags[tag] = None
else:
(key, value) = tag.split("=", 1)
assert tag_key_validator.match(key), "Invalid tag key: {}".format(key)
(key, value) = tag.split('=', 1)
assert tag_key_validator.match(key), \
'Invalid tag key: {}'.format(key)
tags[key] = unescape_tag_value(value)
return tags
Message = collections.namedtuple('Message',
'tags prefix command params')
@dataclasses.dataclass(frozen=True)
class HistoryMessage:
time: Any
msgid: Optional[str]
target: str
text: str
@dataclasses.dataclass(frozen=True)
class Message:
tags: Dict[str, Optional[str]]
prefix: Optional[str]
command: str
params: List[str]
def to_history_message(self) -> HistoryMessage:
return HistoryMessage(
time=self.tags.get("time"),
msgid=self.tags.get("msgid"),
target=self.params[0],
text=self.params[1],
)
def parse_message(s: str) -> Message:
def parse_message(s):
"""Parse a message according to
http://tools.ietf.org/html/rfc1459#section-2.3.1
and
http://ircv3.net/specs/core/message-tags-3.2.html"""
s = s.rstrip("\r\n")
if s.startswith("@"):
(tags_str, s) = s.split(" ", 1)
tags = parse_tags(tags_str[1:])
assert s.endswith('\r\n'), 'Message does not end with CR LF: {!r}'.format(s)
s = s[0:-2]
if s.startswith('@'):
(tags, s) = s.split(' ', 1)
tags = parse_tags(tags[1:])
else:
tags = {}
if " :" in s:
(other_tokens, trailing_param) = s.split(" :", 1)
tokens = list(filter(bool, other_tokens.split(" "))) + [trailing_param]
if ' :' in s:
(other_tokens, trailing_param) = s.split(' :', 1)
tokens = list(filter(bool, other_tokens.split(' '))) + [trailing_param]
else:
tokens = list(filter(bool, s.split(" ")))
prefix = prefix = tokens.pop(0)[1:] if tokens[0].startswith(":") else None
tokens = list(filter(bool, s.split(' ')))
if tokens[0].startswith(':'):
prefix = tokens.pop(0)[1:]
else:
prefix = None
command = tokens.pop(0)
params = tokens
return Message(tags=tags, prefix=prefix, command=command, params=params)
return Message(
tags=tags,
prefix=prefix,
command=command,
params=params,
)

View File

@ -1,15 +0,0 @@
import base64
def sasl_plain_blob(username: str, passphrase: str) -> str:
blob = base64.b64encode(
b"\x00".join(
(
username.encode("utf-8"),
username.encode("utf-8"),
passphrase.encode("utf-8"),
)
)
)
blobstr = blob.decode("ascii")
return f"AUTHENTICATE {blobstr}"

View File

@ -9,200 +9,185 @@
# They're intended to represent a relatively-standard cross-section of the IRC
# server ecosystem out there. Custom numerics will be marked as such.
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
RPL_CREATED = "003"
RPL_MYINFO = "004"
RPL_ISUPPORT = "005"
RPL_SNOMASKIS = "008"
RPL_BOUNCE = "010"
RPL_HELLO = "020"
RPL_TRACELINK = "200"
RPL_TRACECONNECTING = "201"
RPL_TRACEHANDSHAKE = "202"
RPL_TRACEUNKNOWN = "203"
RPL_TRACEOPERATOR = "204"
RPL_TRACEUSER = "205"
RPL_TRACESERVER = "206"
RPL_TRACESERVICE = "207"
RPL_TRACENEWTYPE = "208"
RPL_TRACECLASS = "209"
RPL_TRACERECONNECT = "210"
RPL_STATSLINKINFO = "211"
RPL_STATSCOMMANDS = "212"
RPL_ENDOFSTATS = "219"
RPL_UMODEIS = "221"
RPL_SERVLIST = "234"
RPL_SERVLISTEND = "235"
RPL_STATSUPTIME = "242"
RPL_STATSOLINE = "243"
RPL_LUSERCLIENT = "251"
RPL_LUSEROP = "252"
RPL_LUSERUNKNOWN = "253"
RPL_LUSERCHANNELS = "254"
RPL_LUSERME = "255"
RPL_ADMINME = "256"
RPL_ADMINLOC1 = "257"
RPL_ADMINLOC2 = "258"
RPL_ADMINEMAIL = "259"
RPL_TRACELOG = "261"
RPL_TRACEEND = "262"
RPL_TRYAGAIN = "263"
RPL_LOCALUSERS = "265"
RPL_GLOBALUSERS = "266"
RPL_WHOISCERTFP = "276"
RPL_AWAY = "301"
RPL_USERHOST = "302"
RPL_ISON = "303"
RPL_UNAWAY = "305"
RPL_NOWAWAY = "306"
RPL_WHOISREGNICK = "307"
RPL_WHOISUSER = "311"
RPL_WHOISSERVER = "312"
RPL_WHOISOPERATOR = "313"
RPL_WHOWASUSER = "314"
RPL_ENDOFWHO = "315"
RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_WHOISSPECIAL = "320"
RPL_LISTSTART = "321"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
RPL_UNIQOPIS = "325"
RPL_CHANNELCREATED = "329"
RPL_WHOISACCOUNT = "330"
RPL_NOTOPIC = "331"
RPL_TOPIC = "332"
RPL_TOPICTIME = "333"
RPL_WHOISBOT = "335"
RPL_WHOISACTUALLY = "338"
RPL_INVITING = "341"
RPL_SUMMONING = "342"
RPL_INVITELIST = "346"
RPL_ENDOFINVITELIST = "347"
RPL_EXCEPTLIST = "348"
RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351"
RPL_WHOREPLY = "352"
RPL_NAMREPLY = "353"
RPL_WHOSPCRPL = "354"
RPL_LINKS = "364"
RPL_ENDOFLINKS = "365"
RPL_ENDOFNAMES = "366"
RPL_BANLIST = "367"
RPL_ENDOFBANLIST = "368"
RPL_ENDOFWHOWAS = "369"
RPL_INFO = "371"
RPL_MOTD = "372"
RPL_ENDOFINFO = "374"
RPL_MOTDSTART = "375"
RPL_ENDOFMOTD = "376"
RPL_WHOISHOST = "378"
RPL_WHOISMODES = "379"
RPL_YOUREOPER = "381"
RPL_REHASHING = "382"
RPL_YOURESERVICE = "383"
RPL_TIME = "391"
RPL_USERSSTART = "392"
RPL_USERS = "393"
RPL_ENDOFUSERS = "394"
RPL_NOUSERS = "395"
ERR_UNKNOWNERROR = "400"
ERR_NOSUCHNICK = "401"
ERR_NOSUCHSERVER = "402"
ERR_NOSUCHCHANNEL = "403"
ERR_CANNOTSENDTOCHAN = "404"
ERR_TOOMANYCHANNELS = "405"
ERR_WASNOSUCHNICK = "406"
ERR_TOOMANYTARGETS = "407"
ERR_NOSUCHSERVICE = "408"
ERR_NOORIGIN = "409"
ERR_INVALIDCAPCMD = "410"
ERR_NORECIPIENT = "411"
ERR_NOTEXTTOSEND = "412"
ERR_NOTOPLEVEL = "413"
ERR_WILDTOPLEVEL = "414"
ERR_BADMASK = "415"
ERR_INPUTTOOLONG = "417"
ERR_UNKNOWNCOMMAND = "421"
ERR_NOMOTD = "422"
ERR_NOADMININFO = "423"
ERR_FILEERROR = "424"
ERR_NONICKNAMEGIVEN = "431"
ERR_ERRONEUSNICKNAME = "432"
ERR_NICKNAMEINUSE = "433"
ERR_NICKCOLLISION = "436"
ERR_UNAVAILRESOURCE = "437"
ERR_REG_UNAVAILABLE = "440"
ERR_USERNOTINCHANNEL = "441"
ERR_NOTONCHANNEL = "442"
ERR_USERONCHANNEL = "443"
ERR_NOLOGIN = "444"
ERR_SUMMONDISABLED = "445"
ERR_USERSDISABLED = "446"
ERR_FORBIDDENCHANNEL = "448"
ERR_NOTREGISTERED = "451"
ERR_NEEDMOREPARAMS = "461"
ERR_ALREADYREGISTRED = "462"
ERR_NOPERMFORHOST = "463"
ERR_PASSWDMISMATCH = "464"
ERR_YOUREBANNEDCREEP = "465"
ERR_YOUWILLBEBANNED = "466"
ERR_KEYSET = "467"
ERR_INVALIDUSERNAME = "468"
ERR_LINKCHANNEL = "470"
ERR_CHANNELISFULL = "471"
ERR_UNKNOWNMODE = "472"
ERR_INVITEONLYCHAN = "473"
ERR_BANNEDFROMCHAN = "474"
ERR_BADCHANNELKEY = "475"
ERR_BADCHANMASK = "476"
ERR_NOCHANMODES = "477"
ERR_NEEDREGGEDNICK = "477"
ERR_BANLISTFULL = "478"
ERR_NOPRIVILEGES = "481"
ERR_CHANOPRIVSNEEDED = "482"
ERR_CANTKILLSERVER = "483"
ERR_RESTRICTED = "484"
ERR_UNIQOPPRIVSNEEDED = "485"
ERR_NOOPERHOST = "491"
ERR_UMODEUNKNOWNFLAG = "501"
ERR_USERSDONTMATCH = "502"
ERR_HELPNOTFOUND = "524"
ERR_INVALIDKEY = "525"
ERR_CANNOTSENDRP = "573"
RPL_WHOISSECURE = "671"
RPL_YOURLANGUAGESARE = "687"
RPL_WHOISLANGUAGE = "690"
ERR_INVALIDMODEPARAM = "696"
RPL_HELPSTART = "704"
RPL_HELPTXT = "705"
RPL_ENDOFHELP = "706"
ERR_NOPRIVS = "723"
RPL_MONONLINE = "730"
RPL_MONOFFLINE = "731"
RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734"
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902"
RPL_SASLSUCCESS = "903"
ERR_SASLFAIL = "904"
ERR_SASLTOOLONG = "905"
ERR_SASLABORTED = "906"
ERR_SASLALREADY = "907"
RPL_SASLMECHS = "908"
RPL_REGISTRATION_SUCCESS = "920"
ERR_ACCOUNT_ALREADY_EXISTS = "921"
ERR_REG_UNSPECIFIED_ERROR = "922"
RPL_VERIFYSUCCESS = "923"
ERR_ACCOUNT_ALREADY_VERIFIED = "924"
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
RPL_CREATED = "003"
RPL_MYINFO = "004"
RPL_ISUPPORT = "005"
RPL_SNOMASKIS = "008"
RPL_BOUNCE = "010"
RPL_TRACELINK = "200"
RPL_TRACECONNECTING = "201"
RPL_TRACEHANDSHAKE = "202"
RPL_TRACEUNKNOWN = "203"
RPL_TRACEOPERATOR = "204"
RPL_TRACEUSER = "205"
RPL_TRACESERVER = "206"
RPL_TRACESERVICE = "207"
RPL_TRACENEWTYPE = "208"
RPL_TRACECLASS = "209"
RPL_TRACERECONNECT = "210"
RPL_STATSLINKINFO = "211"
RPL_STATSCOMMANDS = "212"
RPL_ENDOFSTATS = "219"
RPL_UMODEIS = "221"
RPL_SERVLIST = "234"
RPL_SERVLISTEND = "235"
RPL_STATSUPTIME = "242"
RPL_STATSOLINE = "243"
RPL_LUSERCLIENT = "251"
RPL_LUSEROP = "252"
RPL_LUSERUNKNOWN = "253"
RPL_LUSERCHANNELS = "254"
RPL_LUSERME = "255"
RPL_ADMINME = "256"
RPL_ADMINLOC1 = "257"
RPL_ADMINLOC2 = "258"
RPL_ADMINEMAIL = "259"
RPL_TRACELOG = "261"
RPL_TRACEEND = "262"
RPL_TRYAGAIN = "263"
RPL_WHOISCERTFP = "276"
RPL_AWAY = "301"
RPL_USERHOST = "302"
RPL_ISON = "303"
RPL_UNAWAY = "305"
RPL_NOWAWAY = "306"
RPL_WHOISUSER = "311"
RPL_WHOISSERVER = "312"
RPL_WHOISOPERATOR = "313"
RPL_WHOWASUSER = "314"
RPL_ENDOFWHO = "315"
RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
RPL_UNIQOPIS = "325"
RPL_CHANNELCREATED = "329"
RPL_WHOISACCOUNT = "330"
RPL_NOTOPIC = "331"
RPL_TOPIC = "332"
RPL_TOPICTIME = "333"
RPL_WHOISBOT = "335"
RPL_WHOISACTUALLY = "338"
RPL_INVITING = "341"
RPL_SUMMONING = "342"
RPL_INVITELIST = "346"
RPL_ENDOFINVITELIST = "347"
RPL_EXCEPTLIST = "348"
RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351"
RPL_WHOREPLY = "352"
RPL_NAMREPLY = "353"
RPL_LINKS = "364"
RPL_ENDOFLINKS = "365"
RPL_ENDOFNAMES = "366"
RPL_BANLIST = "367"
RPL_ENDOFBANLIST = "368"
RPL_ENDOFWHOWAS = "369"
RPL_INFO = "371"
RPL_MOTD = "372"
RPL_ENDOFINFO = "374"
RPL_MOTDSTART = "375"
RPL_ENDOFMOTD = "376"
RPL_YOUREOPER = "381"
RPL_REHASHING = "382"
RPL_YOURESERVICE = "383"
RPL_TIME = "391"
RPL_USERSSTART = "392"
RPL_USERS = "393"
RPL_ENDOFUSERS = "394"
RPL_NOUSERS = "395"
ERR_UNKNOWNERROR = "400"
ERR_NOSUCHNICK = "401"
ERR_NOSUCHSERVER = "402"
ERR_NOSUCHCHANNEL = "403"
ERR_CANNOTSENDTOCHAN = "404"
ERR_TOOMANYCHANNELS = "405"
ERR_WASNOSUCHNICK = "406"
ERR_TOOMANYTARGETS = "407"
ERR_NOSUCHSERVICE = "408"
ERR_NOORIGIN = "409"
ERR_INVALIDCAPCMD = "410"
ERR_NORECIPIENT = "411"
ERR_NOTEXTTOSEND = "412"
ERR_NOTOPLEVEL = "413"
ERR_WILDTOPLEVEL = "414"
ERR_BADMASK = "415"
ERR_UNKNOWNCOMMAND = "421"
ERR_NOMOTD = "422"
ERR_NOADMININFO = "423"
ERR_FILEERROR = "424"
ERR_NONICKNAMEGIVEN = "431"
ERR_ERRONEUSNICKNAME = "432"
ERR_NICKNAMEINUSE = "433"
ERR_NICKCOLLISION = "436"
ERR_UNAVAILRESOURCE = "437"
ERR_REG_UNAVAILABLE = "440"
ERR_USERNOTINCHANNEL = "441"
ERR_NOTONCHANNEL = "442"
ERR_USERONCHANNEL = "443"
ERR_NOLOGIN = "444"
ERR_SUMMONDISABLED = "445"
ERR_USERSDISABLED = "446"
ERR_NOTREGISTERED = "451"
ERR_NEEDMOREPARAMS = "461"
ERR_ALREADYREGISTRED = "462"
ERR_NOPERMFORHOST = "463"
ERR_PASSWDMISMATCH = "464"
ERR_YOUREBANNEDCREEP = "465"
ERR_YOUWILLBEBANNED = "466"
ERR_KEYSET = "467"
ERR_INVALIDUSERNAME = "468"
ERR_CHANNELISFULL = "471"
ERR_UNKNOWNMODE = "472"
ERR_INVITEONLYCHAN = "473"
ERR_BANNEDFROMCHAN = "474"
ERR_BADCHANNELKEY = "475"
ERR_BADCHANMASK = "476"
ERR_NOCHANMODES = "477"
ERR_BANLISTFULL = "478"
ERR_NOPRIVILEGES = "481"
ERR_CHANOPRIVSNEEDED = "482"
ERR_CANTKILLSERVER = "483"
ERR_RESTRICTED = "484"
ERR_UNIQOPPRIVSNEEDED = "485"
ERR_NOOPERHOST = "491"
ERR_UMODEUNKNOWNFLAG = "501"
ERR_USERSDONTMATCH = "502"
ERR_HELPNOTFOUND = "524"
ERR_CANNOTSENDRP = "573"
RPL_WHOISSECURE = "671"
RPL_YOURLANGUAGESARE = "687"
RPL_WHOISLANGUAGE = "690"
RPL_HELPSTART = "704"
RPL_HELPTXT = "705"
RPL_ENDOFHELP = "706"
ERR_NOPRIVS = "723"
RPL_MONONLINE = "730"
RPL_MONOFFLINE = "731"
RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734"
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902"
RPL_SASLSUCCESS = "903"
ERR_SASLFAIL = "904"
ERR_SASLTOOLONG = "905"
ERR_SASLABORTED = "906"
ERR_SASLALREADY = "907"
RPL_SASLMECHS = "908"
RPL_REGISTRATION_SUCCESS = "920"
ERR_ACCOUNT_ALREADY_EXISTS = "921"
ERR_REG_UNSPECIFIED_ERROR = "922"
RPL_VERIFYSUCCESS = "923"
ERR_ACCOUNT_ALREADY_VERIFIED = "924"
ERR_ACCOUNT_INVALID_VERIFY_CODE = "925"
RPL_REG_VERIFICATION_REQUIRED = "927"
ERR_REG_INVALID_CRED_TYPE = "928"
ERR_REG_INVALID_CALLBACK = "929"
ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982"
RPL_REG_VERIFICATION_REQUIRED = "927"
ERR_REG_INVALID_CRED_TYPE = "928"
ERR_REG_INVALID_CALLBACK = "929"
ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982"

View File

@ -1,180 +0,0 @@
"""Pattern-matching utilities"""
import dataclasses
import re
from typing import Dict, List, Optional, Union
class Operator:
"""Used as a wildcards and operators when matching message arguments
(see assertMessageMatch and match_list)"""
def __init__(self) -> None:
pass
class _AnyStr(Operator):
"""Wildcard matching any string"""
def __repr__(self) -> str:
return "ANYSTR"
class _AnyOptStr(Operator):
"""Wildcard matching any string as well as None"""
def __repr__(self) -> str:
return "ANYOPTSTR"
@dataclasses.dataclass(frozen=True)
class StrRe(Operator):
regexp: str
def __repr__(self) -> str:
return f"StrRe(r'{self.regexp}')"
@dataclasses.dataclass(frozen=True)
class NotStrRe(Operator):
regexp: str
def __repr__(self) -> str:
return f"NotStrRe(r'{self.regexp}')"
@dataclasses.dataclass(frozen=True)
class InsensitiveStr(Operator):
string: str
def __repr__(self) -> str:
return f"InsensitiveStr({self.string!r})"
@dataclasses.dataclass(frozen=True)
class RemainingKeys(Operator):
"""Used in a dict pattern to match all remaining keys.
May only be present once."""
key: Operator
def __repr__(self) -> str:
return f"RemainingKeys({self.key!r})"
ANYSTR = _AnyStr()
"""Singleton, spares two characters"""
ANYOPTSTR = _AnyOptStr()
"""Singleton, spares two characters"""
ANYDICT = {RemainingKeys(ANYSTR): ANYOPTSTR}
"""Matches any dictionary; useful to compare tags dict, eg.
`match_dict(got_tags, {"label": "foo", **ANYDICT})`"""
@dataclasses.dataclass(frozen=True)
class ListRemainder:
item: Operator
min_length: int = 0
def __repr__(self) -> str:
if self.min_length:
return f"ListRemainder({self.item!r}, min_length={self.min_length})"
elif self.item is ANYSTR:
return "*ANYLIST"
else:
return f"ListRemainder({self.item!r})"
ANYLIST = [ListRemainder(ANYSTR)]
"""Matches any list remainder"""
def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool:
if isinstance(expected, _AnyOptStr):
return True
elif isinstance(expected, _AnyStr) and got is not None:
return True
elif isinstance(expected, StrRe):
if got is None or not re.match(expected.regexp, got):
return False
elif isinstance(expected, NotStrRe):
if got is None or re.match(expected.regexp, got):
return False
elif isinstance(expected, InsensitiveStr):
if got is None or got.lower() != expected.string.lower():
return False
elif isinstance(expected, Operator):
raise NotImplementedError(f"Unsupported operator: {expected}")
elif got != expected:
return False
return True
def match_list(
got: List[Optional[str]], expected: List[Union[str, None, Operator]]
) -> bool:
"""Returns True iff the list are equal.
The ANYSTR operator can be used on the 'expected' side as a wildcard,
matching any *single* value; and StrRe("<regexp>") can be used to match regular
expressions"""
if expected and isinstance(expected[-1], ListRemainder):
# Expand the 'expected' list to have as many items as the 'got' list
expected = list(expected) # copy
remainder = expected.pop()
nb_remaining_items = len(got) - len(expected)
expected += [remainder.item] * max(nb_remaining_items, remainder.min_length)
if len(got) != len(expected):
return False
return all(
match_string(got_value, expected_value)
for (got_value, expected_value) in zip(got, expected)
)
def match_dict(
got: Dict[str, Optional[str]],
expected: Dict[Union[str, Operator], Union[str, Operator, None]],
) -> bool:
"""Returns True iff the list are equal.
The ANYSTR operator can be used on the 'expected' side as a wildcard,
matching any *single* value; and StrRe("<regexp>") can be used to match regular
expressions
Additionally, the Keys() operator can be used to match remaining keys, and
ANYDICT to match any remaining dict"""
got = dict(got) # shallow copy, as we will remove keys
# Set to not-None if we find a Keys() operator in the dict keys
remaining_keys_wildcard = None
for (expected_key, expected_value) in expected.items():
if isinstance(expected_key, RemainingKeys):
remaining_keys_wildcard = (expected_key.key, expected_value)
else:
for key in got:
if match_string(key, expected_key) and match_string(
got[key], expected_value
):
got.pop(key)
break
else:
# Found no (key, value) pair matching the request
return False
if remaining_keys_wildcard:
(expected_key, expected_value) = remaining_keys_wildcard
for (key, value) in got.items():
if not match_string(key, expected_key):
return False
if not match_string(value, expected_value):
return False
return True
else:
# There should be nothing left unmatched in the dict
return got == {}

View File

@ -1,59 +1,60 @@
import unittest
import operator
import collections
class NotImplementedByController(unittest.SkipTest, NotImplementedError):
def __str__(self) -> str:
return "Not implemented by controller: {}".format(self.args[0])
def __str__(self):
return 'Not implemented by controller: {}'.format(self.args[0])
class ImplementationChoice(unittest.SkipTest):
def __str__(self) -> str:
return (
"Choice in the implementation makes it impossible to "
"perform a test: {}".format(self.args[0])
)
class OptionalCommandNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported command: {}".format(self.args[0])
def __str__(self):
return 'Choice in the implementation makes it impossible to ' \
'perform a test: {}'.format(self.args[0])
class OptionalExtensionNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported extension: {}".format(self.args[0])
def __str__(self):
return 'Unsupported extension: {}'.format(self.args[0])
class OptionalSaslMechanismNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported SASL mechanism: {}".format(self.args[0])
def __str__(self):
return 'Unsupported SASL mechanism: {}'.format(self.args[0])
class CapabilityNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported capability: {}".format(self.args[0])
class IsupportTokenNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported ISUPPORT token: {}".format(self.args[0])
class ChannelModeNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported channel mode: {} ({})".format(self.args[0], self.args[1])
class ExtbanNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported extban: {} ({})".format(self.args[0], self.args[1])
def __str__(self):
return 'Unsupported capability: {}'.format(self.args[0])
class NotRequiredBySpecifications(unittest.SkipTest):
def __str__(self) -> str:
return "Tests not required by the set of tested specification(s)."
def __str__(self):
return 'Tests not required by the set of tested specification(s).'
class SkipStrictTest(unittest.SkipTest):
def __str__(self) -> str:
return "Tests not required because strict tests are disabled."
def __str__(self):
return 'Tests not required because strict tests are disabled.'
class TextTestResult(unittest.TextTestResult):
def getDescription(self, test):
if hasattr(test, 'description'):
doc_first_lines = test.description()
else:
doc_first_lines = test.shortDescription()
return '\n'.join((str(test), doc_first_lines or ''))
class TextTestRunner(unittest.TextTestRunner):
"""Small wrapper around unittest.TextTestRunner that reports the
number of tests that were skipped because the software does not support
an optional feature."""
resultclass = TextTestResult
def run(self, test):
result = super().run(test)
assert self.resultclass is TextTestResult
if result.skipped:
print()
print('Some tests were skipped because the following optional '
'specifications/mechanisms are not supported:')
msg_to_count = collections.defaultdict(lambda: 0)
for (test, msg) in result.skipped:
msg_to_count[msg] += 1
for (msg, count) in sorted(msg_to_count.items()):
print('\t{} ({} test(s))'.format(msg, count))
return result

View File

@ -1,2 +0,0 @@
from .scram import *
from .exceptions import *

View File

@ -1,9 +0,0 @@
import uuid
def default_nonce_factory():
"""Generate a random string for digest authentication challenges.
The string should be cryptographicaly secure random pattern.
:return: the string generated.
:returntype: `bytes`
"""
return uuid.uuid4().hex.encode("us-ascii")

View File

@ -1,17 +0,0 @@
class ScramException(Exception):
pass
class BadChallengeException(ScramException):
pass
class ExtraChallengeException(ScramException):
pass
class ServerScramError(ScramException):
pass
class BadSuccessException(ScramException):
pass
class NotAuthorizedException(ScramException):
pass

View File

@ -1,561 +0,0 @@
#
# (C) Copyright 2011 Jacek Konieczny <jajcus@jajcus.net>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License Version
# 2.1 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program; if not, write to the Free Software
# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#
"""SCRAM authentication mechanisms for PyXMPP SASL implementation.
Normative reference:
- :RFC:`5802`
"""
from __future__ import absolute_import, division, unicode_literals
__docformat__ = "restructuredtext en"
import sys
import re
import logging
import hashlib
import hmac
from binascii import a2b_base64
from base64 import standard_b64encode
from .core import default_nonce_factory
from .exceptions import BadChallengeException, \
ExtraChallengeException, ServerScramError, BadSuccessException, \
NotAuthorizedException
logger = logging.getLogger("pyxmpp2_scram")
HASH_FACTORIES = {
"SHA-1": hashlib.sha1, # pylint: disable=E1101
"SHA-224": hashlib.sha224, # pylint: disable=E1101
"SHA-256": hashlib.sha256, # pylint: disable=E1101
"SHA-384": hashlib.sha384, # pylint: disable=E1101
"SHA-512": hashlib.sha512, # pylint: disable=E1101
"MD-5": hashlib.md5, # pylint: disable=E1101
}
VALUE_CHARS_RE = re.compile(br"^[\x21-\x2B\x2D-\x7E]+$")
_QUOTED_VALUE_RE = br"(?:[\x21-\x2B\x2D-\x7E]|=2C|=3D)+"
CLIENT_FIRST_MESSAGE_RE = re.compile(
br"^(?P<gs2_header>(?:y|n|p=(?P<cb_name>[a-zA-z0-9.-]+)),"
br"(?:a=(?P<authzid>" + _QUOTED_VALUE_RE + br"))?,)"
br"(?P<client_first_bare>(?P<mext>m=[^\000=]+,)?"
br"n=(?P<username>" + _QUOTED_VALUE_RE + br"),"
br"r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+)"
br"(?:,.*)?)$"
)
SERVER_FIRST_MESSAGE_RE = re.compile(
br"^(?P<mext>m=[^\000=]+,)?"
br"r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+),"
br"s=(?P<salt>[a-zA-Z0-9/+=]+),"
br"i=(?P<iteration_count>\d+)"
br"(?:,.*)?$"
)
CLIENT_FINAL_MESSAGE_RE = re.compile(
br"(?P<without_proof>c=(?P<cb>[a-zA-Z0-9/+=]+),"
br"(?:r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+))"
br"(?:,.*)?)"
br",p=(?P<proof>[a-zA-Z0-9/+=]+)$"
)
SERVER_FINAL_MESSAGE_RE = re.compile(
br"^(?:e=(?P<error>[^,]+)|v=(?P<verifier>[a-zA-Z0-9/+=]+)(?:,.*)?)$")
class SCRAMOperations(object):
"""Functions used during SCRAM authentication and defined in the RFC.
"""
def __init__(self, hash_function_name):
self.hash_function_name = hash_function_name
self.hash_factory = HASH_FACTORIES[hash_function_name]
self.digest_size = self.hash_factory().digest_size
@staticmethod
def Normalize(str_):
"""The Normalize(str) function.
This one also accepts Unicode string input (in the RFC only UTF-8
strings are used).
"""
# pylint: disable=C0103
if isinstance(str_, bytes):
str_ = str_.decode("utf-8")
return str_.encode("utf-8")
def HMAC(self, key, str_):
"""The HMAC(key, str) function."""
# pylint: disable=C0103
return hmac.new(key, str_, self.hash_factory).digest()
def H(self, str_):
"""The H(str) function."""
# pylint: disable=C0103
return self.hash_factory(str_).digest()
if sys.version_info.major >= 3:
@staticmethod
# pylint: disable=C0103
def XOR(str1, str2):
"""The XOR operator for two byte strings."""
return bytes(a ^ b for a, b in zip(str1, str2))
else:
@staticmethod
# pylint: disable=C0103
def XOR(str1, str2):
"""The XOR operator for two byte strings."""
return "".join(chr(ord(a) ^ ord(b)) for a, b in zip(str1, str2))
def Hi(self, str_, salt, i):
"""The Hi(str, salt, i) function."""
# pylint: disable=C0103
Uj = self.HMAC(str_, salt + b"\000\000\000\001") # U1
result = Uj
for _ in range(2, i + 1):
Uj = self.HMAC(str_, Uj) # Uj = HMAC(str, Uj-1)
result = self.XOR(result, Uj) # ... XOR Uj-1 XOR Uj
return result
@staticmethod
def escape(data):
"""Escape the ',' and '=' characters for 'a=' and 'n=' attributes.
Replaces '=' with '=3D' and ',' with '=2C'.
:Parameters:
- `data`: string to escape
:Types:
- `data`: `bytes`
"""
return data.replace(b'=', b'=3D').replace(b',', b'=2C')
@staticmethod
def unescape(data):
"""Unescape the ',' and '=' characters for 'a=' and 'n=' attributes.
Reverse of `escape`.
:Parameters:
- `data`: string to unescape
:Types:
- `data`: `bytes`
"""
return data.replace(b'=2C', b',').replace(b'=3D', b'=')
class SCRAMClientAuthenticator(SCRAMOperations):
"""Provides SCRAM SASL authentication for a client.
:Ivariables:
- `password`: current authentication password
- `pformat`: current authentication password format
- `realm`: current authentication realm
"""
# pylint: disable-msg=R0902
def __init__(self, hash_name, channel_binding):
"""Initialize a `SCRAMClientAuthenticator` object.
:Parameters:
- `hash_function_name`: hash function name, e.g. ``"SHA-1"``
- `channel_binding`: `True` to enable channel binding
:Types:
- `hash_function_name`: `unicode`
- `channel_binding`: `bool`
"""
SCRAMOperations.__init__(self, hash_name)
self.name = "SCRAM-{0}".format(hash_name)
if channel_binding:
self.name += "-PLUS"
self.channel_binding = channel_binding
self.username = None
self.password = None
self.authzid = None
self._c_nonce = None
self._server_first_message = False
self._client_first_message_bare = False
self._gs2_header = None
self._finished = False
self._auth_message = None
self._salted_password = None
self._cb_data = None
@classmethod
def are_properties_sufficient(cls, properties):
return "username" in properties and "password" in properties
def start(self, properties):
self.username = properties["username"]
self.password = properties["password"]
self.authzid = properties.get("authzid", "")
c_nonce = properties.get("nonce_factory", default_nonce_factory)()
if not VALUE_CHARS_RE.match(c_nonce):
c_nonce = standard_b64encode(c_nonce)
self._c_nonce = c_nonce
if self.channel_binding:
cb_data = properties.get("channel-binding")
if not cb_data:
raise ValueError("No channel binding data provided")
if "tls-unique" in cb_data:
cb_type = "tls-unique"
elif "tls-server-end-point" in cb_data:
cb_type = "tls-server-end-point"
elif cb_data:
cb_type = cb_data.keys()[0]
self._cb_data = cb_data[cb_type]
cb_flag = b"p=" + cb_type.encode("utf-8")
else:
plus_name = self.name + "-PLUS"
if plus_name in properties.get("enabled_mechanisms", []):
# -PLUS is enabled (supported) on our side,
# but was not selected - that means it was not included
# in the server features
cb_flag = b"y"
else:
cb_flag = b"n"
if self.authzid:
authzid = b"a=" + self.escape(self.authzid.encode("utf-8"))
else:
authzid = b""
gs2_header = cb_flag + b"," + authzid + b","
self._gs2_header = gs2_header
nonce = b"r=" + c_nonce
client_first_message_bare = (b"n=" +
self.escape(self.username.encode("utf-8")) + b"," + nonce)
self._client_first_message_bare = client_first_message_bare
client_first_message = gs2_header + client_first_message_bare
return client_first_message
def challenge(self, challenge):
"""Process a challenge and return the response.
:Parameters:
- `challenge`: the challenge from server.
:Types:
- `challenge`: `bytes`
:return: the response
:returntype: bytes
:raises: `BadChallengeException`
"""
# pylint: disable=R0911
if not challenge:
raise BadChallengeException('Empty challenge')
if self._server_first_message:
return self._final_challenge(challenge)
match = SERVER_FIRST_MESSAGE_RE.match(challenge)
if not match:
raise BadChallengeException("Bad challenge syntax: {0!r}".format(challenge))
self._server_first_message = challenge
mext = match.group("mext")
if mext:
raise BadChallengeException("Unsupported extension received: {0!r}".format(mext))
nonce = match.group("nonce")
if not nonce.startswith(self._c_nonce):
raise BadChallengeException("Nonce does not start with our nonce")
salt = match.group("salt")
try:
salt = a2b_base64(salt)
except ValueError:
raise BadChallengeException("Bad base64 encoding for salt: {0!r}".format(salt))
iteration_count = match.group("iteration_count")
try:
iteration_count = int(iteration_count)
except ValueError:
raise BadChallengeException("Bad iteration_count: {0!r}".format(iteration_count))
return self._make_response(nonce, salt, iteration_count)
def _make_response(self, nonce, salt, iteration_count):
"""Make a response for the first challenge from the server.
:return: the response
:returntype: bytes
"""
self._salted_password = self.Hi(self.Normalize(self.password), salt,
iteration_count)
self.password = None # not needed any more
if self.channel_binding:
channel_binding = b"c=" + standard_b64encode(self._gs2_header +
self._cb_data)
else:
channel_binding = b"c=" + standard_b64encode(self._gs2_header)
# pylint: disable=C0103
client_final_message_without_proof = (channel_binding + b",r=" + nonce)
client_key = self.HMAC(self._salted_password, b"Client Key")
stored_key = self.H(client_key)
auth_message = ( self._client_first_message_bare + b"," +
self._server_first_message + b"," +
client_final_message_without_proof )
self._auth_message = auth_message
client_signature = self.HMAC(stored_key, auth_message)
client_proof = self.XOR(client_key, client_signature)
proof = b"p=" + standard_b64encode(client_proof)
client_final_message = (client_final_message_without_proof + b"," +
proof)
return client_final_message
def _final_challenge(self, challenge):
"""Process the second challenge from the server and return the
response.
:Parameters:
- `challenge`: the challenge from server.
:Types:
- `challenge`: `bytes`
:raises: `ExtraChallengeException`, `BadChallengeException`, `ServerScramError`, or `BadSuccessException`
"""
if self._finished:
return ExtraChallengeException()
match = SERVER_FINAL_MESSAGE_RE.match(challenge)
if not match:
raise BadChallengeException("Bad final message syntax: {0!r}".format(challenge))
error = match.group("error")
if error:
raise ServerScramError("{0!r}".format(error))
verifier = match.group("verifier")
if not verifier:
raise BadSuccessException("No verifier value in the final message")
server_key = self.HMAC(self._salted_password, b"Server Key")
server_signature = self.HMAC(server_key, self._auth_message)
if server_signature != a2b_base64(verifier):
raise BadSuccessException("Server verifier does not match")
self._finished = True
def finish(self, data):
"""Process success indicator from the server.
Process any addiitional data passed with the success.
Fail if the server was not authenticated.
:Parameters:
- `data`: an optional additional data with success.
:Types:
- `data`: `bytes`
:return: username and authzid
:returntype: `dict`
:raises: `BadSuccessException`"""
if not self._server_first_message:
raise BadSuccessException("Got success too early")
if self._finished:
return {"username": self.username, "authzid": self.authzid}
else:
self._final_challenge(data)
if self._finished:
return {"username": self.username,
"authzid": self.authzid}
else:
raise BadSuccessException("Something went wrong when processing additional"
" data with success?")
class SCRAMServerAuthenticator(SCRAMOperations):
"""Provides SCRAM SASL authentication for a server.
"""
def __init__(self, hash_name, channel_binding, password_database):
"""Initialize a `SCRAMClientAuthenticator` object.
:Parameters:
- `hash_function_name`: hash function name, e.g. ``"SHA-1"``
- `channel_binding`: `True` to enable channel binding
:Types:
- `hash_function_name`: `unicode`
- `channel_binding`: `bool`
"""
SCRAMOperations.__init__(self, hash_name)
self.name = "SCRAM-{0}".format(hash_name)
if channel_binding:
self.name += "-PLUS"
self.channel_binding = channel_binding
self.properties = None
self.out_properties = None
self.password_database = password_database
self._client_first_message_bare = None
self._stored_key = None
self._server_key = None
def start(self, properties, initial_response):
self.properties = properties
self._client_first_message_bare = None
self.out_properties = {}
if not initial_response:
return b""
return self.response(initial_response)
def response(self, response):
if self._client_first_message_bare:
logger.debug("Client final message: {0!r}".format(response))
return self._handle_final_response(response)
else:
logger.debug("Client first message: {0!r}".format(response))
return self._handle_first_response(response)
def _handle_first_response(self, response):
match = CLIENT_FIRST_MESSAGE_RE.match(response)
if not match:
raise NotAuthorizedException("Bad response syntax: {0!r}".format(response))
mext = match.group("mext")
if mext:
raise NotAuthorizedException("Unsupported extension received: {0!r}".format(mext))
gs2_header = match.group("gs2_header")
cb_name = match.group("cb_name")
if self.channel_binding:
if not cb_name:
raise NotAuthorizedException("{0!r} used with no channel-binding"
.format(self.name))
cb_name = cb_name.decode("utf-8")
if cb_name not in self.properties["channel-binding"]:
raise NotAuthorizedException("Channel binding data type {0!r} not available"
.format(cb_name))
else:
if gs2_header.startswith(b'y'):
plus_name = self.name + "-PLUS"
if plus_name in self.properties.get("enabled_mechanisms", []):
raise NotAuthorizedException("Channel binding downgrade attack detected")
elif gs2_header.startswith(b'p'):
# is this really an error?
raise NotAuthorizedException("Channel binding requested for {0!r}"
.format(self.name))
authzid = match.group("authzid")
if authzid:
self.out_properties['authzid'] = self.unescape(authzid
).decode("utf-8")
else:
self.out_properties['authzid'] = None
username = self.unescape(match.group("username")).decode("utf-8")
self.out_properties['username'] = username
nonce_factory = self.properties.get("nonce_factory",
default_nonce_factory)
properties = dict(self.properties)
properties.update(self.out_properties)
s_pformat = "SCRAM-{0}-SaltedPassword".format(self.hash_function_name)
k_pformat = "SCRAM-{0}-Keys".format(self.hash_function_name)
password, pformat = self.password_database.get_password(username,
(s_pformat, "plain"), properties)
if pformat == s_pformat:
if password is not None:
salt, iteration_count, salted_password = password
else:
logger.debug("No password for user {0!r}".format(username))
elif pformat != k_pformat:
salt = self.properties.get("SCRAM-salt")
if not salt:
salt = nonce_factory()
iteration_count = self.properties.get("SCRAM-iteration-count", 4096)
if pformat == "plain" and password is not None:
salted_password = self.Hi(self.Normalize(password), salt,
iteration_count)
else:
logger.debug("No password for user {0!r}".format(username))
password = None
# to prevent timing attack, compute the key anyway
salted_password = self.Hi(self.Normalize(""), salt,
iteration_count)
if pformat == k_pformat:
salt, iteration_count, stored_key, server_key = password
else:
client_key = self.HMAC(salted_password, b"Client Key")
stored_key = self.H(client_key)
server_key = self.HMAC(salted_password, b"Server Key")
if password is not None:
self._stored_key = stored_key
self._server_key = server_key
else:
self._stored_key = None
self._server_key = None
c_nonce = match.group("nonce")
s_nonce = nonce_factory()
if not VALUE_CHARS_RE.match(s_nonce):
s_nonce = standard_b64encode(s_nonce)
nonce = c_nonce + s_nonce
server_first_message = (
b"r=" + nonce
+ b",s=" + standard_b64encode(salt)
+ b",i=" + str(iteration_count).encode("utf-8")
)
self._nonce = nonce
self._cb_name = cb_name
self._gs2_header = gs2_header
self._client_first_message_bare = match.group("client_first_bare")
self._server_first_message = server_first_message
return server_first_message
def _handle_final_response(self, response):
match = CLIENT_FINAL_MESSAGE_RE.match(response)
if not match:
raise NotAuthorizedException("Bad response syntax: {0!r}".format(response))
if match.group("nonce") != self._nonce:
raise NotAuthorizedException("Bad nonce in the final client response")
cb_input = a2b_base64(match.group("cb"))
if not cb_input.startswith(self._gs2_header):
raise NotAuthorizedException("GS2 header in the final response ({0!r}) doesn't"
" match the one sent in the first message ({1!r})"
.format(cb_input, self._gs2_header))
if self._cb_name:
cb_data = cb_input[len(self._gs2_header):]
if cb_data != self.properties["channel-binding"][self._cb_name]:
raise NotAuthorizedException("Channel binding data doesn't match")
proof = a2b_base64(match.group("proof"))
auth_message = (self._client_first_message_bare + b"," +
self._server_first_message + b"," +
match.group("without_proof"))
if self._stored_key is None:
# compute something to prevent timing attack
client_signature = self.HMAC(b"", auth_message)
client_key = self.XOR(client_signature, proof)
self.H(client_key)
raise NotAuthorizedException("Authentication failed (bad username)")
client_signature = self.HMAC(self._stored_key, auth_message)
client_key = self.XOR(client_signature, proof)
if self.H(client_key) != self._stored_key:
raise NotAuthorizedException("Authentication failed")
server_signature = self.HMAC(self._server_key, auth_message)
server_final_message = b"v=" + standard_b64encode(server_signature)
return (self.out_properties, server_final_message)

View File

@ -1,369 +0,0 @@
"""Internal checks of assertion implementations."""
from typing import Dict, List, Tuple
import pytest
from irctest import cases
from irctest.irc_utils.message_parser import parse_message
from irctest.patma import (
ANYDICT,
ANYLIST,
ANYOPTSTR,
ANYSTR,
ListRemainder,
NotStrRe,
RemainingKeys,
StrRe,
)
# fmt: off
MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
(
# the specification:
dict(
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"PRIVMSG #chan hello",
"PRIVMSG #chan :hello",
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
":foo!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMSG #chan hello2",
"PRIVMSG #chan2 hello",
],
# and they each error with:
[
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
]
),
(
# the specification:
dict(
command="PRIVMSG",
params=["#chan", StrRe("hello.*")],
),
# matches:
[
"PRIVMSG #chan hello",
"PRIVMSG #chan :hello",
"PRIVMSG #chan hello2",
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
":foo!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMSG #chan :hi",
"PRIVMSG #chan2 hello",
],
# and they each error with:
[
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan', 'hi']",
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan2', 'hello']",
]
),
(
# the specification:
dict(
nick="foo",
command="PRIVMSG",
),
# matches:
[
":foo!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMSG #chan :hi",
":foo2!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo2!baz@qux PRIVMSG #chan :hello",
],
# and they each error with:
[
"expected nick to be foo, got None instead",
"expected nick to be foo, got foo2 instead",
"expected nick to be foo, got foo2 instead",
]
),
(
# the specification:
dict(
tags={"tag1": "bar"},
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"PRIVMSG #chan hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'value1'}",
"expected tags to match {'tag1': 'bar'}, got {}",
"expected tags to match {'tag1': 'bar'}, got {}",
]
),
(
# the specification:
dict(
tags={"tag1": ANYSTR},
command="PRIVMSG",
params=["#chan", ANYSTR],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"PRIVMSG #chan hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected tags to match {'tag1': ANYSTR}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': ANYSTR}, got {}",
"expected tags to match {'tag1': ANYSTR}, got {}",
]
),
(
# the specification:
dict(
tags={"tag1": "bar", **ANYDICT},
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"PRIVMSG #chan hello2",
"PRIVMSG #chan2 hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}",
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}",
]
),
(
# the specification:
dict(
tags={StrRe("tag[12]"): "bar", **ANYDICT},
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
"@tag2=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=;tag2=bar PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"PRIVMSG #chan hello2",
"PRIVMSG #chan2 hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {StrRe(r'tag[12]'): 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}",
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
"expected tags to match {StrRe(r'tag[12]'): 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}",
]
),
(
# the specification:
dict(
tags={"tag1": "bar", RemainingKeys(NotStrRe("tag2")): ANYOPTSTR},
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
"@tag1=bar;tag3= PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=bar;tag2=baz PRIVMSG #chan :hello",
],
# and they each error with:
[
"expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'value1'}",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': 'baz'}",
]
),
(
# the specification:
dict(
command="005",
params=["nick", "FOO=1", *ANYLIST],
),
# matches:
[
"005 nick FOO=1",
"005 nick FOO=1 BAR=2",
],
# and does not match:
[
"005 nick",
"005 nick BAR=2",
],
# and they each error with:
[
"expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick']",
"expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick', 'BAR=2']",
]
),
(
# the specification:
dict(
command="005",
params=["nick", ListRemainder(ANYSTR, min_length=1)],
),
# matches:
[
"005 nick FOO=1",
"005 nick FOO=1 BAR=2",
"005 nick BAR=2",
],
# and does not match:
[
"005 nick",
],
# and they each error with:
[
"expected params to match ['nick', ListRemainder(ANYSTR, min_length=1)], got ['nick']",
]
),
(
# the specification:
dict(
command="005",
params=["nick", ListRemainder(StrRe("[A-Z]+=.*"), min_length=1)],
),
# matches:
[
"005 nick FOO=1",
"005 nick FOO=1 BAR=2",
"005 nick BAR=2",
],
# and does not match:
[
"005 nick",
"005 nick foo=1",
],
# and they each error with:
[
"expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick']",
"expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick', 'foo=1']",
]
),
(
# the specification:
dict(
command="PING",
params=["abc"]
),
# matches:
[
"PING abc",
],
# and does not match:
[
"PONG def"
],
# and they each error with:
[
"expected command to be PING, got PONG"
]
),
]
# fmt: on
class IrcTestCaseTestCase(cases._IrcTestCase):
@pytest.mark.parametrize(
"spec,msg",
[
pytest.param(spec, msg, id=f"{spec}-{msg}")
for (spec, positive_matches, _, _) in MESSAGE_SPECS
for msg in positive_matches
],
)
def test_message_matching_positive(self, spec, msg):
assert not self.messageDiffers(parse_message(msg), **spec), msg
assert self.messageEqual(parse_message(msg), **spec), msg
self.assertMessageMatch(parse_message(msg), **spec), msg
@pytest.mark.parametrize(
"spec,msg",
[
pytest.param(spec, msg, id=f"{spec}-{msg}")
for (spec, _, negative_matches, _) in MESSAGE_SPECS
for msg in negative_matches
],
)
def test_message_matching_negative(self, spec, msg):
assert self.messageDiffers(parse_message(msg), **spec), msg
assert not self.messageEqual(parse_message(msg), **spec), msg
with pytest.raises(AssertionError):
self.assertMessageMatch(parse_message(msg), **spec), msg
@pytest.mark.parametrize(
"spec,msg,error_string",
[
pytest.param(spec, msg, error_string, id=error_string)
for (spec, _, negative_matches, error_stringgexps) in MESSAGE_SPECS
for (msg, error_string) in zip(negative_matches, error_stringgexps)
],
)
def test_message_matching_negative_message(self, spec, msg, error_string):
self.assertIn(error_string, self.messageDiffers(parse_message(msg), **spec))

View File

@ -1,141 +0,0 @@
"""
`Draft IRCv3 account-registration
<https://ircv3.net/specs/extensions/account-registration>`_
"""
from irctest import cases
from irctest.patma import ANYSTR
REGISTER_CAP_NAME = "draft/account-registration"
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["accounts"]["registration"].update(
{"allow-before-connect": True}
)
)
def testBeforeConnect(self):
self.addClient("bar")
self.requestCapabilities("bar", [REGISTER_CAP_NAME], skip_if_cap_nak=True)
self.sendLine("bar", "CAP LS 302")
caps = self.getCapLs("bar")
self.assertIn(REGISTER_CAP_NAME, caps)
self.assertIn("before-connect", caps[REGISTER_CAP_NAME])
self.sendLine("bar", "NICK bar")
self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar")
register_response = [msg for msg in msgs if msg.command == "REGISTER"][0]
self.assertMessageMatch(register_response, params=["SUCCESS", ANYSTR, ANYSTR])
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["accounts"]["registration"].update(
{"allow-before-connect": False}
)
)
def testBeforeConnect(self):
self.addClient("bar")
self.requestCapabilities("bar", [REGISTER_CAP_NAME], skip_if_cap_nak=True)
self.sendLine("bar", "CAP LS 302")
caps = self.getCapLs("bar")
self.assertIn(REGISTER_CAP_NAME, caps)
self.assertEqual(caps[REGISTER_CAP_NAME], None)
self.sendLine("bar", "NICK bar")
self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar")
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
self.assertMessageMatch(
fail_response,
params=["REGISTER", "COMPLETE_CONNECTION_REQUIRED", ANYSTR, ANYSTR],
)
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["accounts"]["registration"].update(
{
"email-verification": {
"enabled": True,
"sender": "test@example.com",
"require-tls": True,
"helo-domain": "example.com",
},
"allow-before-connect": True,
}
)
)
def testBeforeConnect(self):
self.addClient("bar")
self.requestCapabilities(
"bar", capabilities=[REGISTER_CAP_NAME], skip_if_cap_nak=True
)
self.sendLine("bar", "CAP LS 302")
caps = self.getCapLs("bar")
self.assertIn(REGISTER_CAP_NAME, caps)
self.assertEqual(
set(caps[REGISTER_CAP_NAME].split(",")),
{"before-connect", "email-required"},
)
self.sendLine("bar", "NICK bar")
self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar")
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
self.assertMessageMatch(
fail_response, params=["REGISTER", "INVALID_EMAIL", ANYSTR, ANYSTR]
)
def testAfterConnect(self):
self.connectClient(
"bar", name="bar", capabilities=[REGISTER_CAP_NAME], skip_if_cap_nak=True
)
self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar")
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
self.assertMessageMatch(
fail_response, params=["REGISTER", "INVALID_EMAIL", ANYSTR, ANYSTR]
)
@cases.mark_services
@cases.mark_specifications("IRCv3", "Ergo")
class RegisterNoLandGrabsTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["accounts"]["registration"].update(
{"allow-before-connect": True}
)
)
def testBeforeConnect(self):
# have an anonymous client take the 'root' username:
self.connectClient(
"root", name="root", capabilities=[REGISTER_CAP_NAME], skip_if_cap_nak=True
)
# cannot register it out from under the anonymous nick holder:
self.addClient("bar")
self.sendLine("bar", "NICK root")
self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar")
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
self.assertMessageMatch(
fail_response, params=["REGISTER", "USERNAME_EXISTS", ANYSTR, ANYSTR]
)

View File

@ -1,73 +0,0 @@
"""
`IRCv3 account-tag <https://ircv3.net/specs/extensions/account-tag>`_
"""
from irctest import cases
@cases.mark_services
class AccountTagTestCase(cases.BaseServerTestCase):
def connectRegisteredClient(self, nick):
self.addClient()
self.sendLine(2, "CAP LS 302")
capabilities = self.getCapLs(2)
assert "sasl" in capabilities
self.sendLine(2, "USER f * * :Realname")
self.sendLine(2, "NICK {}".format(nick))
self.sendLine(2, "CAP REQ :sasl")
self.getRegistrationMessage(2)
self.sendLine(2, "AUTHENTICATE PLAIN")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="AUTHENTICATE",
params=["+"],
fail_msg="Sent “AUTHENTICATE PLAIN”, server should have "
"replied with “AUTHENTICATE +”, but instead sent: {msg}",
)
self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="900",
fail_msg="Did not send 900 after correct SASL authentication.",
)
self.sendLine(2, "USER f * * :Realname")
self.sendLine(2, "NICK {}".format(nick))
self.sendLine(2, "CAP END")
self.skipToWelcome(2)
@cases.mark_capabilities("account-tag")
@cases.skipUnlessHasMechanism("PLAIN")
def testPrivmsg(self):
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
self.getMessages(1)
self.controller.registerUser(self, "jilles", "sesame")
self.connectRegisteredClient("bar")
self.sendLine(2, "PRIVMSG foo :hi")
self.getMessages(2)
m = self.getMessage(1)
self.assertMessageMatch(
m, command="PRIVMSG", params=["foo", "hi"], tags={"account": "jilles"}
)
@cases.mark_capabilities("account-tag")
@cases.skipUnlessHasMechanism("PLAIN")
@cases.xfailIfSoftware(
["Charybdis"], "https://github.com/solanum-ircd/solanum/issues/166"
)
def testInvite(self):
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
self.getMessages(1)
self.controller.registerUser(self, "jilles", "sesame")
self.connectRegisteredClient("bar")
self.sendLine(2, "JOIN #chan")
self.getMessages(2)
self.sendLine(2, "INVITE foo #chan")
self.getMessages(2)
m = self.getMessage(1)
self.assertMessageMatch(
m, command="INVITE", params=["foo", "#chan"], tags={"account": "jilles"}
)

View File

@ -1,177 +0,0 @@
"""
AWAY command (`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-4.1>`__,
`Modern <https://modern.ircdocs.horse/#away-message>`__)
"""
from irctest import cases
from irctest.numerics import (
RPL_AWAY,
RPL_NOWAWAY,
RPL_UNAWAY,
RPL_USERHOST,
RPL_WHOISUSER,
)
from irctest.patma import StrRe
class AwayTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC2812", "Modern")
def testAway(self):
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.connectClient("qux")
self.sendLine(2, "PRIVMSG bar :what's up")
self.assertMessageMatch(
self.getMessage(2),
command=RPL_AWAY,
params=["qux", "bar", "I'm not here right now"],
)
self.sendLine(1, "AWAY")
replies = self.getMessages(1)
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
self.sendLine(2, "PRIVMSG bar :what's up")
replies = self.getMessages(2)
self.assertEqual(len(replies), 0)
@cases.mark_specifications("Modern")
def testAwayAck(self):
"""
"The server acknowledges the change in away status by returning the
`RPL_NOWAWAY` and `RPL_UNAWAY` numerics."
-- https://modern.ircdocs.horse/#away-message
"""
self.connectClient("bar")
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.sendLine(1, "AWAY")
replies = self.getMessages(1)
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
@cases.mark_specifications("Modern")
def testAwayPrivmsg(self):
"""
"Servers SHOULD notify clients when a user they're interacting with
is away when relevant"
-- https://modern.ircdocs.horse/#away-message
"<client> <nick> :<message>"
-- https://modern.ircdocs.horse/#rplaway-301
"""
self.connectClient("bar")
self.connectClient("qux")
self.sendLine(2, "PRIVMSG bar :what's up")
self.assertEqual(self.getMessages(2), [])
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.sendLine(2, "PRIVMSG bar :what's up")
self.assertMessageMatch(
self.getMessage(2),
command=RPL_AWAY,
params=["qux", "bar", "I'm not here right now"],
)
@cases.mark_specifications("Modern")
def testAwayWhois(self):
"""
"Servers SHOULD notify clients when a user they're interacting with
is away when relevant"
-- https://modern.ircdocs.horse/#away-message
"<client> <nick> :<message>"
-- https://modern.ircdocs.horse/#rplaway-301
"""
self.connectClient("bar")
self.connectClient("qux")
self.sendLine(2, "WHOIS bar")
msgs = [msg for msg in self.getMessages(2) if msg.command == RPL_AWAY]
self.assertEqual(
len(msgs),
0,
fail_msg="Expected no RPL_AWAY (301), got: {}",
extra_format=(msgs,),
)
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.sendLine(2, "WHOIS bar")
msgs = [msg for msg in self.getMessages(2) if msg.command == RPL_AWAY]
self.assertEqual(
len(msgs),
1,
fail_msg="Expected one RPL_AWAY (301), got: {}",
extra_format=(msgs,),
)
self.assertMessageMatch(
msgs[0], command=RPL_AWAY, params=["qux", "bar", "I'm not here right now"]
)
@cases.mark_specifications("Modern")
def testAwayUserhost(self):
"""
"Servers SHOULD notify clients when a user they're interacting with
is away when relevant"
-- https://modern.ircdocs.horse/#away-message
"<client> <nick> :<message>"
-- https://modern.ircdocs.horse/#rplaway-301
"""
self.connectClient("bar")
self.connectClient("qux")
self.sendLine(2, "USERHOST bar")
self.assertMessageMatch(
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=\+.*")]
)
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.sendLine(2, "USERHOST bar")
self.assertMessageMatch(
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=-.*")]
)
@cases.mark_specifications("Modern")
def testAwayEmptyMessage(self):
"""
"If [AWAY] is sent with a nonempty parameter (the 'away message')
then the user is set to be away. If this command is sent with no
parameters, or with the empty string as the parameter, the user is no
longer away."
-- https://modern.ircdocs.horse/#away-message
"""
self.connectClient("bar", name="bar")
self.connectClient("qux", name="qux")
self.sendLine("bar", "AWAY :I'm not here right now")
replies = self.getMessages("bar")
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.sendLine("qux", "WHOIS bar")
replies = self.getMessages("qux")
self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies])
self.assertIn(RPL_AWAY, [msg.command for msg in replies])
# empty final parameter to AWAY is treated the same as no parameter,
# i.e., the client is considered to be no longer away
self.sendLine("bar", "AWAY :")
replies = self.getMessages("bar")
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
self.sendLine("qux", "WHOIS bar")
replies = self.getMessages("qux")
self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies])
self.assertNotIn(RPL_AWAY, [msg.command for msg in replies])

View File

@ -1,62 +0,0 @@
"""
`IRCv3 away-notify <https://ircv3.net/specs/extensions/away-notify>`_
"""
from irctest import cases
class AwayNotifyTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("away-notify")
def testAwayNotify(self):
"""Basic away-notify test."""
self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True)
self.getMessages(1)
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.getMessages(2)
self.joinChannel(2, "#chan")
self.getMessages(2)
self.getMessages(1)
self.sendLine(2, "AWAY :i'm going away")
self.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,),
)
@cases.mark_capabilities("away-notify")
def testAwayNotifyOnJoin(self):
"""The away-notify specification states:
"Clients will be sent an AWAY message [...] when a user joins
and has an away message set."
"""
self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True)
self.getMessages(1)
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.getMessages(2)
self.sendLine(2, "AWAY :i'm already away")
self.getMessages(2)
self.joinChannel(2, "#chan")
self.getMessages(2)
messages = [msg for msg in self.getMessages(1) if msg.command == "AWAY"]
self.assertEqual(
len(messages),
1,
"Someone away joined a channel, "
"but users in the channel did not get AWAY messages.",
)
awayNotify = messages[0]
self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm already away"])
self.assertTrue(
awayNotify.prefix.startswith("bar!"),
"Unexpected away-notify source: %s" % (awayNotify.prefix,),
)

View File

@ -1,160 +0,0 @@
"""
`IRCv3 bot mode <https://ircv3.net/specs/extensions/bot-mode>`_
"""
from irctest import cases, runner
from irctest.numerics import RPL_WHOISBOT
from irctest.patma import ANYDICT, ANYSTR, StrRe
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("BOT")
class BotModeTestCase(cases.BaseServerTestCase):
def setUp(self):
super().setUp()
self.connectClient("modegettr")
if "BOT" not in self.server_support:
raise runner.IsupportTokenNotSupported("BOT")
self._mode_char = self.server_support["BOT"]
def _initBot(self):
self.assertEqual(
len(self._mode_char),
1,
fail_msg=(
f"BOT ISUPPORT token should be exactly one character, "
f"but is: {self._mode_char!r}"
),
)
self.connectClient("botnick", "bot")
self.sendLine("bot", f"MODE botnick +{self._mode_char}")
# Check echoed mode
while True:
msg = self.getMessage("bot")
if msg.command != "NOTICE":
# Unreal sends the BOTMOTD here
self.assertMessageMatch(
msg,
command="MODE",
params=["botnick", StrRe(r"\+?" + self._mode_char)],
)
break
def testBotMode(self):
self._initBot()
def testBotWhois(self):
self._initBot()
self.connectClient("usernick", "user")
self.sendLine("user", "WHOIS botnick")
messages = self.getMessages("user")
messages = [msg for msg in messages if msg.command == RPL_WHOISBOT]
self.assertEqual(
len(messages),
1,
msg=(
f"Expected exactly one RPL_WHOISBOT ({RPL_WHOISBOT}), "
f"got: {messages}"
),
)
(message,) = messages
self.assertMessageMatch(
message, command=RPL_WHOISBOT, params=["usernick", "botnick", ANYSTR]
)
@cases.xfailIfSoftware(
["InspIRCd"],
"Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910",
)
def testBotPrivateMessage(self):
self._initBot()
self.connectClient(
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
)
self.sendLine("bot", "PRIVMSG usernick :beep boop")
self.getMessages("bot") # Synchronizes
self.assertMessageMatch(
self.getMessage("user"),
command="PRIVMSG",
params=["usernick", "beep boop"],
tags={StrRe("(draft/)?bot"): None, **ANYDICT},
)
@cases.xfailIfSoftware(
["InspIRCd"],
"Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910",
)
def testBotChannelMessage(self):
self._initBot()
self.connectClient(
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
)
self.sendLine("bot", "JOIN #chan")
self.sendLine("user", "JOIN #chan")
self.getMessages("bot")
self.getMessages("user")
self.sendLine("bot", "PRIVMSG #chan :beep boop")
self.getMessages("bot") # Synchronizes
self.assertMessageMatch(
self.getMessage("user"),
command="PRIVMSG",
params=["#chan", "beep boop"],
tags={StrRe("(draft/)?bot"): None, **ANYDICT},
)
def testBotWhox(self):
self._initBot()
self.connectClient(
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
)
self.sendLine("bot", "JOIN #chan")
self.sendLine("user", "JOIN #chan")
self.getMessages("bot")
self.getMessages("user")
self.sendLine("user", "WHO #chan")
msg1 = self.getMessage("user")
self.assertMessageMatch(
msg1, command="352", fail_msg="Expected WHO response (352), got: {msg}"
)
msg2 = self.getMessage("user")
self.assertMessageMatch(
msg2, command="352", fail_msg="Expected WHO response (352), got: {msg}"
)
if msg1.params[5] == "botnick":
msg = msg1
elif msg2.params[5] == "botnick":
msg = msg2
else:
assert False, "No WHO response contained botnick"
self.assertMessageMatch(
msg,
command="352",
params=[
"usernick",
"#chan",
ANYSTR, # ident
ANYSTR, # hostname
ANYSTR, # server
"botnick",
StrRe(f".*{self._mode_char}.*"),
ANYSTR, # realname
],
fail_msg="Expected WHO response with bot flag, got: {msg}",
)

View File

@ -1,166 +0,0 @@
"""
`Ergo <https://ergo.chat/>`_-specific tests of
`multiclient features
<https://github.com/ergochat/ergo/blob/master/docs/MANUAL.md#multiclient-bouncer>`_
"""
from irctest import cases
from irctest.irc_utils.sasl import sasl_plain_blob
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
from irctest.patma import ANYSTR, StrRe
@cases.mark_services
class BouncerTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testBouncer(self):
"""Test basic bouncer functionality."""
self.controller.registerUser(self, "observer", "observerpassword")
self.controller.registerUser(self, "testuser", "mypassword")
self.connectClient(
"observer", password="observerpassword", capabilities=["sasl"]
)
self.joinChannel(1, "#chan")
self.sendLine(1, "CAP REQ :message-tags server-time")
self.getMessages(1)
self.addClient()
self.sendLine(2, "CAP LS 302")
self.sendLine(2, "AUTHENTICATE PLAIN")
self.sendLine(2, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(2, "NICK testnick")
self.sendLine(2, "USER a 0 * a")
self.sendLine(2, "CAP REQ :server-time message-tags")
self.sendLine(2, "CAP END")
messages = self.getMessages(2)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
# should see a regburst for testnick
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
self.joinChannel(2, "#chan")
self.addClient()
self.sendLine(3, "CAP LS 302")
self.sendLine(3, "AUTHENTICATE PLAIN")
self.sendLine(3, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(3, "NICK testnick")
self.sendLine(3, "USER a 0 * a")
self.sendLine(3, "CAP REQ :server-time message-tags account-tag")
self.sendLine(3, "CAP END")
messages = self.getMessages(3)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
# should see the *same* regburst for testnick
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
joins = [message for message in messages if message.command == "JOIN"]
# we should be automatically joined to #chan
self.assertMessageMatch(joins[0], params=["#chan"])
# disable multiclient in nickserv
self.sendLine(3, "NS SET MULTICLIENT OFF")
self.getMessages(3)
self.addClient()
self.sendLine(4, "CAP LS 302")
self.sendLine(4, "AUTHENTICATE PLAIN")
self.sendLine(4, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(4, "NICK testnick")
self.sendLine(4, "USER a 0 * a")
self.sendLine(4, "CAP REQ :server-time message-tags")
self.sendLine(4, "CAP END")
# with multiclient disabled, we should not be able to attach to the nick
messages = self.getMessages(4)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 0)
errors = [
message for message in messages if message.command == ERR_NICKNAMEINUSE
]
self.assertEqual(len(errors), 1)
self.sendLine(3, "NS SET MULTICLIENT ON")
self.getMessages(3)
self.addClient()
self.sendLine(5, "CAP LS 302")
self.sendLine(5, "AUTHENTICATE PLAIN")
self.sendLine(5, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(5, "NICK testnick")
self.sendLine(5, "USER a 0 * a")
self.sendLine(5, "CAP REQ server-time")
self.sendLine(5, "CAP END")
messages = self.getMessages(5)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
self.sendLine(1, "@+clientOnlyTag=Value PRIVMSG #chan :hey")
self.getMessages(1)
messagesfortwo = [
msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"
]
messagesforthree = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
]
self.assertEqual(len(messagesfortwo), 1)
self.assertEqual(len(messagesforthree), 1)
messagefortwo = messagesfortwo[0]
messageforthree = messagesforthree[0]
messageforfive = self.getMessage(5)
self.assertMessageMatch(messagefortwo, params=["#chan", "hey"])
self.assertMessageMatch(messageforthree, params=["#chan", "hey"])
self.assertMessageMatch(messageforfive, params=["#chan", "hey"])
self.assertIn("time", messagefortwo.tags)
self.assertIn("time", messageforthree.tags)
self.assertIn("time", messageforfive.tags)
# 3 has account-tag
self.assertIn("account", messageforthree.tags)
# should get same msgid
self.assertEqual(messagefortwo.tags["msgid"], messageforthree.tags["msgid"])
# 5 only has server-time, shouldn't get account or msgid tags
self.assertNotIn("account", messageforfive.tags)
self.assertNotIn("msgid", messageforfive.tags)
# test that copies of sent messages go out to other sessions
self.sendLine(2, "PRIVMSG observer :this is a direct message")
self.getMessages(2)
messageForRecipient = [
msg for msg in self.getMessages(1) if msg.command == "PRIVMSG"
][0]
copyForOtherSession = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
][0]
self.assertEqual(messageForRecipient.params, copyForOtherSession.params)
self.assertEqual(
messageForRecipient.tags["msgid"], copyForOtherSession.tags["msgid"]
)
self.sendLine(2, "QUIT :two out")
quitLines = [msg for msg in self.getMessages(2) if msg.command == "QUIT"]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*two out.*")])
# neither the observer nor the other attached session should see a quit here
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
self.assertEqual(quitLines, [])
quitLines = [msg for msg in self.getMessages(3) if msg.command == "QUIT"]
self.assertEqual(quitLines, [])
# session 3 should be untouched at this point
self.sendLine(1, "@+clientOnlyTag=Value PRIVMSG #chan :hey again")
self.getMessages(1)
messagesforthree = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
]
self.assertEqual(len(messagesforthree), 1)
self.assertMessageMatch(
messagesforthree[0], command="PRIVMSG", params=["#chan", "hey again"]
)
self.sendLine(5, "QUIT :five out")
self.getMessages(5)
self.sendLine(3, "QUIT :three out")
quitLines = [msg for msg in self.getMessages(3) if msg.command == "QUIT"]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])
# observer should see *this* quit
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])

View File

@ -1,146 +0,0 @@
"""
Sends packets with various length to check the server reassembles them
correctly. Also checks truncation.
"""
import socket
import time
import pytest
from irctest import cases
from irctest.irc_utils import message_parser
from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYSTR
def _sendWhole(self, line):
print("(repr) 1 -> S", repr(line.encode()))
self.clients[1].conn.sendall(line.encode())
def _sendCharPerChar(self, line):
print("(repr) 1 -> S", repr(line.encode()))
for char in line:
self.clients[1].conn.sendall(char.encode())
def _sendBytePerByte(self, line):
print("(repr) 1 -> S", repr(line.encode()))
for byte in line.encode():
self.clients[1].conn.sendall(bytes([byte]))
class BufferingTestCase(cases.BaseServerTestCase):
@cases.xfailIfSoftware(
["Bahamut"],
"cannot pass because of issues with UTF-8 handling: "
"https://github.com/DALnet/bahamut/issues/196",
)
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "snircd"],
"ircu2 discards the whole buffer on long lines "
"(TODO: refine how we exclude these tests)",
)
@pytest.mark.parametrize(
"sender_function,colon",
[
pytest.param(_sendWhole, "", id="whole-no colon"),
pytest.param(_sendCharPerChar, "", id="charperchar-no colon"),
pytest.param(_sendBytePerByte, "", id="byteperbyte-no colon"),
pytest.param(_sendWhole, ":", id="whole-colon"),
pytest.param(_sendCharPerChar, ":", id="charperchar-colon"),
pytest.param(_sendBytePerByte, ":", id="byteperbyte-colon"),
],
)
def testNoTags(self, sender_function, colon):
self.connectClient("nick1")
self.connectClient("nick2")
overhead = self.get_overhead(1, 2, colon=colon)
print(f"overhead is {overhead}")
line = f"PRIVMSG nick2 {colon}"
remaining_size = 512 - len(line) - len("\r\n")
emoji_size = len("😃".encode())
payloads = [
# one byte:
"a",
# one multi-byte char:
"😃",
# full payload, will be truncated
"a" * remaining_size,
"a" * (remaining_size - emoji_size) + "😃",
# full payload to recipient:
"a" * (remaining_size - overhead),
"a" * (remaining_size - emoji_size - overhead) + "😃",
# full payload to recipient plus one byte:
"a" * (remaining_size - overhead + 1),
"a" * (remaining_size - emoji_size - overhead + 1) + "😃",
# full payload to recipient plus two bytes:
"a" * (remaining_size - emoji_size - overhead + 1) + "😃",
]
for payload in payloads:
sender_function(self, line + payload + "\r\n")
messages = self.getMessages(1)
if messages and ERR_INPUTTOOLONG in (m.command for m in messages):
# https://defs.ircdocs.horse/defs/numerics.html#err-inputtoolong-417
self.assertGreater(
len(line + payload + "\r\n"),
512 - overhead,
"Got ERR_INPUTTOOLONG for a messag that should fit "
"withing 512 characters.",
)
continue
received_line = self._getLine(2)
print("(repr) S -> 2", repr(received_line))
try:
decoded_line = received_line.decode()
except UnicodeDecodeError:
# server truncated a byte off the emoji at the end
if "UTF8ONLY" in self.server_support:
# https://github.com/ircv3/ircv3-specifications/pull/432
raise self.failureException(
f"Server advertizes UTF8ONLY, but sent an invalid UTF8 "
f"message: {received_line!r}"
)
payload_intact = False
else:
msg = message_parser.parse_message(decoded_line)
self.assertMessageMatch(
msg, command="PRIVMSG", params=["nick2", ANYSTR]
)
payload_intact = msg.params[1] == payload
if not payload_intact:
# truncated
self.assertLessEqual(len(received_line), 512, received_line)
if received_line.endswith(b"[CUT]\r\n"):
# ngircd
received_line = received_line[0:-7] + b"\r\n"
self.assertTrue(
payload.encode().startswith(
received_line.split(b" ")[-1].strip().lstrip(b":")
),
f"expected payload to be a prefix of {payload!r}, "
f"but got {payload!r}",
)
def get_overhead(self, client1, client2, colon):
self.sendLine(client1, f"PRIVMSG nick2 {colon}a\r\n")
line = self._getLine(client2)
return len(line) - len(f"PRIVMSG nick2 {colon}a\r\n")
def _getLine(self, client) -> bytes:
line = b""
for _ in range(30):
try:
data = self.clients[client].conn.recv(4096)
except socket.timeout:
data = b""
line += data
if data.endswith(b"\r\n"):
return line
time.sleep(0.1)
print(f"{client}: Waiting...")
return line

View File

@ -1,244 +0,0 @@
"""
`IRCv3 Capability negotiation
<https://ircv3.net/specs/extensions/capability-negotiation>`_
"""
from irctest import cases
from irctest.patma import ANYSTR
from irctest.runner import CapabilityNotSupported, ImplementationChoice
class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
def testNoReq(self):
"""Test the server handles gracefully clients which do not send
REQs.
“Clients that support capabilities but do not wish to enter
negotiation SHOULD send CAP END upon connection to the server.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-end-subcommand>
""" # noqa
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
def testReqUnavailable(self):
"""Test the server handles gracefully clients which request
capabilities that are not available.
<http://ircv3.net/specs/core/capability-negotiation-3.1.html>
"""
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :foo")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo"],
fail_msg="Expected CAP NAK after requesting non-existing "
"capability, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
def testNakExactString(self):
"""“The argument of the NAK subcommand MUST consist of at least the
first 100 characters of the capability list in the REQ subcommand which
triggered the NAK.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-nak-subcommand>
""" # noqa
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
# Five should be enough to check there is no reordering, even
# alphabetical
self.sendLine(1, "CAP REQ :foo qux bar baz qux quux")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo qux bar baz qux quux"],
fail_msg="Expected “CAP NAK :foo qux bar baz qux quux” after "
"sending “CAP REQ :foo qux bar baz qux quux”, but got {msg}.",
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["UnrealIRCd"],
"UnrealIRCd sends a trailing space on CAP NAK: https://github.com/unrealircd/unrealircd/pull/148",
)
def testNakWhole(self):
"""“The capability identifier set must be accepted as a whole, or
rejected entirely.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-req-subcommand>
""" # noqa
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.assertIn("multi-prefix", self.getCapLs(1))
self.sendLine(1, "CAP REQ :foo multi-prefix bar")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo multi-prefix bar"],
fail_msg="Expected “CAP NAK :foo multi-prefix bar” after "
"sending “CAP REQ :foo multi-prefix bar”, but got {msg}.",
)
self.sendLine(1, "CAP REQ :multi-prefix bar")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "multi-prefix bar"],
fail_msg="Expected “CAP NAK :multi-prefix bar” after "
"sending “CAP REQ :multi-prefix bar”, but got {msg}.",
)
self.sendLine(1, "CAP REQ :foo multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo multi-prefix"],
fail_msg="Expected “CAP NAK :foo multi-prefix” after "
"sending “CAP REQ :foo multi-prefix”, but got {msg}.",
)
# TODO: make sure multi-prefix is not enabled at this point
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", "multi-prefix"],
fail_msg="Expected “CAP ACK :multi-prefix” after "
"sending “CAP REQ :multi-prefix”, but got {msg}.",
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["UnrealIRCd"],
"UnrealIRCd sends a trailing space on CAP NAK: https://github.com/unrealircd/unrealircd/pull/148",
)
def testCapRemovalByClient(self):
"""Test CAP LIST and removal of caps via CAP REQ :-tagname."""
cap1 = "echo-message"
cap2 = "server-time"
self.addClient(1)
self.connectClient("sender")
self.sendLine(1, "CAP LS 302")
m = self.getRegistrationMessage(1)
if not ({cap1, cap2} <= set(m.params[2].split())):
raise CapabilityNotSupported(f"{cap1} or {cap2}")
self.sendLine(1, f"CAP REQ :{cap1} {cap2}")
self.sendLine(1, "nick bar")
self.sendLine(1, "user user 0 * realname")
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(m, command="CAP", params=[ANYSTR, "ACK", ANYSTR])
self.assertEqual(
set(m.params[2].split()), {cap1, cap2}, "Didn't ACK both REQed caps"
)
self.skipToWelcome(1)
self.sendLine(1, "CAP LIST")
messages = self.getMessages(1)
cap_list = [m for m in messages if m.command == "CAP"][0]
enabled_caps = set(cap_list.params[2].split())
enabled_caps.discard("cap-notify") # implicitly added by some impls
self.assertEqual(enabled_caps, {cap1, cap2})
self.sendLine(2, "PRIVMSG bar :hi")
self.getMessages(2) # Synchronize
m = self.getMessage(1)
self.assertIn("time", m.tags, m)
# remove the server-time 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}"]):
self.assertMessageMatch(
m, command="CAP", params=[ANYSTR, "NAK", f"-{cap2}"]
)
raise ImplementationChoice(f"Does not support CAP REQ -{cap2}")
# server-time should be disabled
self.sendLine(1, "CAP LIST")
messages = self.getMessages(1)
cap_list = [m for m in messages if m.command == "CAP"][0]
enabled_caps = set(cap_list.params[2].split())
enabled_caps.discard("cap-notify") # implicitly added by some impls
self.assertEqual(enabled_caps, {cap1})
self.assertNotIn("time", cap_list.tags)
@cases.mark_specifications("IRCv3")
def testIrc301CapLs(self):
"""
Current version:
"The LS subcommand is used to list the capabilities supported by the server.
The client should send an LS subcommand with no other arguments to solicit
a list of all capabilities."
"If a client has not indicated support for CAP LS 302 features,
the server MUST NOT send these new features to the client."
-- <https://ircv3.net/specs/core/capability-negotiation.html>
Before the v3.1 / v3.2 merge:
IRCv3.1: “The LS subcommand is used to list the capabilities
supported by the server. The client should send an LS subcommand with
no other arguments to solicit a list of all capabilities.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-ls-subcommand>
IRCv3.2: “Servers MUST NOT send messages described by this document if
the client only supports version 3.1.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.2.html#version-in-cap-ls>
""" # noqa
self.addClient()
self.sendLine(1, "CAP LS")
m = self.getRegistrationMessage(1)
self.assertNotEqual(
m.params[2],
"*",
m,
fail_msg="Server replied with multi-line CAP LS to a "
"“CAP LS” (ie. IRCv3.1) request: {msg}",
)
self.assertFalse(
any("=" in cap for cap in m.params[2].split()),
"Server replied with a name-value capability in "
"CAP LS reply as a response to “CAP LS” (ie. IRCv3.1) "
"request: {}".format(m),
)
@cases.mark_specifications("IRCv3")
def testEmptyCapList(self):
"""“If no capabilities are active, an empty parameter must be sent.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-list-subcommand>
""" # noqa
self.addClient()
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=["*", "LIST", ""],
fail_msg="Sending “CAP LIST” as first message got a reply "
"that is not “CAP * LIST :”: {msg}",
)

View File

@ -1,65 +0,0 @@
"""
Channel casemapping
"""
import pytest
from irctest import cases, client_mock, runner
class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize(
"casemapping,name1,name2",
[
("ascii", "#Foo", "#foo"),
("rfc1459", "#Foo", "#foo"),
("rfc1459", "#F]|oo{", "#f}\\oo["),
("rfc1459", "#F}o\\o[", "#f]o|o{"),
],
)
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testChannelsEquivalent(self, casemapping, name1, name2):
self.connectClient("foo")
self.connectClient("bar")
if self.server_support["CASEMAPPING"] != casemapping:
raise runner.ImplementationChoice(
"Casemapping {} not implemented".format(casemapping)
)
self.joinClient(1, name1)
self.joinClient(2, name2)
try:
m = self.getMessage(1)
self.assertMessageMatch(m, command="JOIN", nick="bar")
except client_mock.NoMessageException:
raise AssertionError(
"Channel names {} and {} are not equivalent.".format(name1, name2)
)
@pytest.mark.parametrize(
"casemapping,name1,name2",
[
("ascii", "#Foo", "#fooa"),
("rfc1459", "#Foo", "#fooa"),
],
)
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testChannelsNotEquivalent(self, casemapping, name1, name2):
self.connectClient("foo")
self.connectClient("bar")
if self.server_support["CASEMAPPING"] != casemapping:
raise runner.ImplementationChoice(
"Casemapping {} not implemented".format(casemapping)
)
self.joinClient(1, name1)
self.joinClient(2, name2)
try:
m = self.getMessage(1)
except client_mock.NoMessageException:
pass
else:
self.assertMessageMatch(
m, command="JOIN", nick="bar"
) # This should always be true
raise AssertionError(
"Channel names {} and {} are equivalent.".format(name1, name2)
)

View File

@ -1,58 +0,0 @@
"""
`Ergo <https://ergo.chat/>`_-specific tests of channel forwarding
TODO: Should be extended to other servers, once a specification is written.
"""
from irctest import cases
from irctest.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
class ChannelForwardingTestCase(cases.BaseServerTestCase):
"""Test the +f channel forwarding mode."""
@cases.mark_specifications("Ergo")
def testChannelForwarding(self):
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS)
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
self.joinChannel("bar", "#bar")
self.joinChannel("bar", "#bar_two")
self.joinChannel("baz", "#baz")
self.sendLine("bar", "MODE #bar +f #nonexistent")
msg = self.getMessage("bar")
self.assertMessageMatch(msg, command=ERR_INVALIDMODEPARAM)
# need chanops in the target channel as well
self.sendLine("bar", "MODE #bar +f #baz")
responses = set(msg.command for msg in self.getMessages("bar"))
self.assertIn(ERR_CHANOPRIVSNEEDED, responses)
self.sendLine("bar", "MODE #bar +f #bar_two")
msg = self.getMessage("bar")
self.assertMessageMatch(msg, command="MODE", params=["#bar", "+f", "#bar_two"])
# can still join the channel fine
self.joinChannel("baz", "#bar")
self.sendLine("baz", "PART #bar")
self.getMessages("baz")
# now make it invite-only, which should cause forwarding
self.sendLine("bar", "MODE #bar +i")
self.getMessages("bar")
self.sendLine("baz", "JOIN #bar")
msgs = self.getMessages("baz")
forward = [msg for msg in msgs if msg.command == ERR_LINKCHANNEL]
self.assertEqual(forward[0].params[:3], ["baz", "#bar", "#bar_two"])
join = [msg for msg in msgs if msg.command == "JOIN"]
self.assertMessageMatch(join[0], params=["#bar_two"])

View File

@ -1,57 +0,0 @@
"""
`Draft IRCv3 channel-rename <https://ircv3.net/specs/extensions/channel-rename>`_
"""
from irctest import cases
from irctest.numerics import ERR_CHANOPRIVSNEEDED
RENAME_CAP = "draft/channel-rename"
@cases.mark_specifications("IRCv3")
class ChannelRenameTestCase(cases.BaseServerTestCase):
"""Basic tests for channel-rename."""
def testChannelRename(self):
self.connectClient(
"bar", name="bar", capabilities=[RENAME_CAP], skip_if_cap_nak=True
)
self.connectClient("baz", name="baz")
self.joinChannel("bar", "#bar")
self.joinChannel("baz", "#bar")
self.getMessages("bar")
self.getMessages("baz")
self.sendLine("bar", "RENAME #bar #qux :no reason")
self.assertMessageMatch(
self.getMessage("bar"),
command="RENAME",
params=["#bar", "#qux", "no reason"],
)
legacy_responses = self.getMessages("baz")
self.assertEqual(
1,
len(
[
msg
for msg in legacy_responses
if msg.command == "PART" and msg.params[0] == "#bar"
]
),
)
self.assertEqual(
1,
len(
[
msg
for msg in legacy_responses
if msg.command == "JOIN" and msg.params == ["#qux"]
]
),
)
self.joinChannel("baz", "#bar")
self.sendLine("baz", "MODE #bar +k beer")
self.assertNotIn(
ERR_CHANOPRIVSNEEDED, [msg.command for msg in self.getMessages("baz")]
)

View File

@ -1,740 +0,0 @@
"""
`IRCv3 draft chathistory <https://ircv3.net/specs/extensions/chathistory>`_
"""
import functools
import secrets
import time
import pytest
from irctest import cases, runner
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYSTR
CHATHISTORY_CAP = "draft/chathistory"
EVENT_PLAYBACK_CAP = "draft/event-playback"
# Keep this in sync with validate_chathistory()
SUBCOMMANDS = ["LATEST", "BEFORE", "AFTER", "BETWEEN", "AROUND"]
MYSQL_PASSWORD = ""
def 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):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(chathistory=True)
@skip_ngircd
def testInvalidTargets(self):
bar, pw = random_name("bar"), random_name("pw")
self.controller.registerUser(self, bar, pw)
self.connectClient(
bar,
name=bar,
capabilities=[
"batch",
"labeled-response",
"message-tags",
"server-time",
"sasl",
CHATHISTORY_CAP,
],
password=pw,
skip_if_cap_nak=True,
)
self.getMessages(bar)
qux = random_name("qux")
real_chname = random_name("#real_channel")
self.connectClient(qux, name=qux)
self.joinChannel(qux, real_chname)
self.getMessages(qux)
# test a nonexistent channel
self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10")
msgs = self.getMessages(bar)
msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r
self.assertMessageMatch(
msgs[0],
command="FAIL",
params=["CHATHISTORY", "INVALID_TARGET", "LATEST", ANYSTR, ANYSTR],
)
# as should a real channel to which one is not joined:
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,))
msgs = self.getMessages(bar)
self.assertMessageMatch(
msgs[0],
command="FAIL",
params=["CHATHISTORY", "INVALID_TARGET", "LATEST", ANYSTR, ANYSTR],
)
@pytest.mark.private_chathistory
@skip_ngircd
def testMessagesToSelf(self):
bar, pw = random_name("bar"), random_name("pw")
self.controller.registerUser(self, bar, pw)
self.connectClient(
bar,
name=bar,
capabilities=[
"batch",
"labeled-response",
"message-tags",
"sasl",
"server-time",
CHATHISTORY_CAP,
],
password=pw,
skip_if_cap_nak=True,
)
self.getMessages(bar)
messages = []
self.sendLine(bar, "PRIVMSG %s :this is a privmsg sent to myself" % (bar,))
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual(len(replies), 1)
msg = replies[0]
self.assertMessageMatch(msg, params=[bar, "this is a privmsg sent to myself"])
messages.append(msg.to_history_message())
self.sendLine(bar, "CAP REQ echo-message")
self.getMessages(bar)
self.sendLine(
bar, "PRIVMSG %s :this is a second privmsg sent to myself" % (bar,)
)
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
# two messages, the echo and the delivery
self.assertEqual(len(replies), 2)
self.assertMessageMatch(
replies[0], params=[bar, "this is a second privmsg sent to myself"]
)
messages.append(replies[0].to_history_message())
# messages should be otherwise identical
self.assertEqual(
replies[0].to_history_message(), replies[1].to_history_message()
)
self.sendLine(
bar,
"@label=xyz PRIVMSG %s :this is a third privmsg sent to myself" % (bar,),
)
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual(len(replies), 2)
# exactly one of the replies MUST be labeled
echo = [msg for msg in replies if msg.tags.get("label") == "xyz"][0]
delivery = [msg for msg in replies if msg.tags.get("label") is None][0]
self.assertMessageMatch(
echo, params=[bar, "this is a third privmsg sent to myself"]
)
messages.append(echo.to_history_message())
self.assertEqual(echo.to_history_message(), delivery.to_history_message())
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,))
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual([msg.to_history_message() for msg in replies], messages)
def validate_echo_messages(self, num_messages, echo_messages):
# sanity checks: should have received the correct number of echo messages,
# all with distinct time tags (because we slept) and msgids
self.assertEqual(len(echo_messages), num_messages)
self.assertEqual(len(set(msg.msgid for msg in echo_messages)), num_messages)
self.assertEqual(len(set(msg.time for msg in echo_messages)), num_messages)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@skip_ngircd
def testChathistory(self, subcommand):
if subcommand == "BETWEEN" and self.controller.software_name == "UnrealIRCd":
pytest.xfail(
"CHATHISTORY BETWEEN does not apply bounds correct "
"https://bugs.unrealircd.org/view.php?id=5952"
)
if subcommand == "AROUND" and self.controller.software_name == "UnrealIRCd":
pytest.xfail(
"CHATHISTORY AROUND excludes 'central' messages "
"https://bugs.unrealircd.org/view.php?id=5953"
)
self.connectClient(
"bar",
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
skip_if_cap_nak=True,
)
chname = "#chan" + secrets.token_hex(12)
self.joinChannel(1, chname)
self.getMessages(1)
self.getMessages(1)
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@skip_ngircd
def testChathistoryEventPlayback(self, subcommand):
self.connectClient(
"bar",
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
EVENT_PLAYBACK_CAP,
],
skip_if_cap_nak=True,
)
chname = "#chan" + secrets.token_hex(12)
self.joinChannel(1, chname)
self.getMessages(1)
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@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)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
c1,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame1",
skip_if_cap_nak=True,
)
self.connectClient(
c2,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame2",
)
self.getMessages(1)
self.getMessages(2)
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
user = (i % 2) + 1
if user == 1:
target = c2
else:
target = c1
self.getMessages(user)
self.sendLine(user, "PRIVMSG %s :this is message %d" % (target, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(user)
)
time.sleep(0.002)
self.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)
self.connectClient(
c3,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
CHATHISTORY_CAP,
],
skip_if_cap_nak=True,
)
self.sendLine(
1, "PRIVMSG %s :this is a message in a separate conversation" % (c3,)
)
self.getMessages(1)
self.sendLine(
3, "PRIVMSG %s :i agree that this is a separate conversation" % (c1,)
)
# 3 received the first message as a delivery and the second as an echo
new_convo = [
msg.to_history_message()
for msg in self.getMessages(3)
if msg.command == "PRIVMSG"
]
self.assertEqual(
[msg.text for msg in new_convo],
[
"this is a message in a separate conversation",
"i agree that this is a separate conversation",
],
)
# messages should be stored and retrievable by c1,
# even though c3 is not registered
self.getMessages(1)
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (c3,))
results = [
msg.to_history_message()
for msg in self.getMessages(1)
if msg.command == "PRIVMSG"
]
self.assertEqual(results, new_convo)
# additional messages with c3 should not show up in the c1-c2 history:
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
self.validate_chathistory(subcommand, echo_messages, 2, c1.upper())
# regression test for #833
self.sendLine(3, "QUIT")
self.assertDisconnected(3)
# register c3 as an account, then attempt to retrieve
# the conversation history with c1
self.controller.registerUser(self, c3, "sesame3")
self.connectClient(
c3,
name=c3,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame3",
skip_if_cap_nak=True,
)
self.getMessages(c3)
self.sendLine(c3, "CHATHISTORY LATEST %s * 10" % (c1,))
results = [
msg.to_history_message()
for msg in self.getMessages(c3)
if msg.command == "PRIVMSG"
]
# should get nothing
self.assertEqual(results, [])
def validate_chathistory(self, subcommand, echo_messages, user, chname):
# Keep this list of subcommands in sync with the SUBCOMMANDS global
method = getattr(self, f"_validate_chathistory_{subcommand}")
method(echo_messages, user, chname)
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages, result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[-5:], result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[-1:], result)
self.sendLine(
user,
"CHATHISTORY LATEST %s msgid=%s %d"
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[5:], result)
self.sendLine(
user,
"CHATHISTORY LATEST %s timestamp=%s %d"
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[5:], result)
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(
user,
"CHATHISTORY BEFORE %s msgid=%s %d"
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[:6], result)
self.sendLine(
user,
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[:6], result)
self.sendLine(
user,
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, 2),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[4:6], result)
def _validate_chathistory_AFTER(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(
user,
"CHATHISTORY AFTER %s msgid=%s %d"
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[4:], result)
self.sendLine(
user,
"CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
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))
self.assertEqual(echo_messages[4:7], result)
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
# BETWEEN forwards and backwards
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (
chname,
echo_messages[0].msgid,
echo_messages[-1].msgid,
INCLUSIVE_LIMIT,
),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (
chname,
echo_messages[-1].msgid,
echo_messages[0].msgid,
INCLUSIVE_LIMIT,
),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[1:-1], result)
# BETWEEN forwards and backwards with a limit, should get
# different results this time
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[1:4], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[-4:-1], result)
# same stuff again but with timestamps
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
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))
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))
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))
self.assertEqual(echo_messages[-4:-1], result)
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
self.sendLine(
user,
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1),
)
result = validate_chathistory_batch(self.getMessages(user))
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))
self.assertEqual(echo_messages[6:9], result)
self.sendLine(
user,
"CHATHISTORY AROUND %s timestamp=%s %d"
% (chname, echo_messages[7].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
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)
chname = "#chan" + secrets.token_hex(12)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
c1,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
EVENT_PLAYBACK_CAP,
],
password="sesame1",
skip_if_cap_nak=True,
)
self.connectClient(
c2,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame2",
)
self.joinChannel(1, chname)
self.joinChannel(2, chname)
self.getMessages(1)
self.getMessages(2)
self.sendLine(
1, "@+client-only-tag-test=success;+draft/persist TAGMSG %s" % (chname,)
)
echo = self.getMessages(1)[0]
msgid = echo.tags["msgid"]
def validate_tagmsg(msg, target, msgid):
self.assertMessageMatch(msg, command="TAGMSG", params=[target])
self.assertEqual(msg.tags["+client-only-tag-test"], "success")
self.assertEqual(msg.tags["msgid"], msgid)
validate_tagmsg(echo, chname, msgid)
relay = self.getMessages(2)
self.assertEqual(len(relay), 1)
validate_tagmsg(relay[0], chname, msgid)
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,))
history_tagmsgs = [
msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 1)
validate_tagmsg(history_tagmsgs[0], chname, msgid)
# c2 doesn't have event-playback and MUST NOT receive replayed tagmsg
self.sendLine(2, "CHATHISTORY LATEST %s * 10" % (chname,))
history_tagmsgs = [
msg for msg in self.getMessages(2) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 0)
# now try a DM
self.sendLine(
1, "@+client-only-tag-test=success;+draft/persist TAGMSG %s" % (c2,)
)
echo = self.getMessages(1)[0]
msgid = echo.tags["msgid"]
validate_tagmsg(echo, c2, msgid)
relay = self.getMessages(2)
self.assertEqual(len(relay), 1)
validate_tagmsg(relay[0], c2, msgid)
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (c2,))
history_tagmsgs = [
msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 1)
validate_tagmsg(history_tagmsgs[0], c2, msgid)
# c2 doesn't have event-playback and MUST NOT receive replayed tagmsg
self.sendLine(2, "CHATHISTORY LATEST %s * 10" % (c1,))
history_tagmsgs = [
msg for msg in self.getMessages(2) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 0)
@pytest.mark.arbitrary_client_tags
@pytest.mark.private_chathistory
@skip_ngircd
def testChathistoryDMClientOnlyTags(self):
# regression test for Ergo #1411
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
c1,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame1",
skip_if_cap_nak=True,
)
self.connectClient(
c2,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame2",
)
self.getMessages(1)
self.getMessages(2)
echo_msgid = None
def validate_msg(msg):
self.assertMessageMatch(msg, command="PRIVMSG", params=[c2, "hi"])
self.assertEqual(msg.tags["+client-only-tag-test"], "success")
self.assertEqual(msg.tags["msgid"], echo_msgid)
self.sendLine(
1, "@+client-only-tag-test=success;+draft/persist PRIVMSG %s hi" % (c2,)
)
echo = self.getMessage(1)
echo_msgid = echo.tags["msgid"]
validate_msg(echo)
relay = self.getMessage(2)
validate_msg(relay)
assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == {
meth_name
for meth_name in dir(ChathistoryTestCase)
if meth_name.startswith("_validate_chathistory_")
}, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync"

View File

@ -1,133 +0,0 @@
"""
`Ergo <https://ergo.chat/>`_-specific tests of auditorium mode
TODO: Should be extended to other servers, once a specification is written.
"""
import math
import time
from irctest import cases
from irctest.irc_utils.junkdrawer import ircv3_timestamp_to_unixtime
from irctest.numerics import RPL_NAMREPLY
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
class AuditoriumTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testAuditorium(self):
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS)
self.joinChannel("bar", "#auditorium")
self.getMessages("bar")
self.sendLine("bar", "MODE #auditorium +u")
modelines = [msg for msg in self.getMessages("bar") if msg.command == "MODE"]
self.assertEqual(len(modelines), 1)
self.assertMessageMatch(modelines[0], params=["#auditorium", "+u"])
self.connectClient("guest1", name="guest1", capabilities=MODERN_CAPS)
self.joinChannel("guest1", "#auditorium")
self.getMessages("guest1")
# chanop should get a JOIN message
join_msgs = [msg for msg in self.getMessages("bar") if msg.command == "JOIN"]
self.assertEqual(len(join_msgs), 1)
self.assertMessageMatch(join_msgs[0], nick="guest1", params=["#auditorium"])
self.connectClient("guest2", name="guest2", capabilities=MODERN_CAPS)
self.joinChannel("guest2", "#auditorium")
self.getMessages("guest2")
# chanop should get a JOIN message
join_msgs = [msg for msg in self.getMessages("bar") if msg.command == "JOIN"]
self.assertEqual(len(join_msgs), 1)
join_msg = join_msgs[0]
self.assertMessageMatch(join_msg, nick="guest2", params=["#auditorium"])
# oragono/oragono#1642 ; msgid should be populated,
# and the time tag should be sane
self.assertTrue(join_msg.tags.get("msgid"))
self.assertLessEqual(
math.fabs(time.time() - ircv3_timestamp_to_unixtime(join_msg.tags["time"])),
60.0,
)
# fellow unvoiced participant should not
unvoiced_join_msgs = [
msg for msg in self.getMessages("guest1") if msg.command == "JOIN"
]
self.assertEqual(len(unvoiced_join_msgs), 0)
self.connectClient("guest3", name="guest3", capabilities=MODERN_CAPS)
self.joinChannel("guest3", "#auditorium")
self.getMessages("guest3")
self.sendLine("bar", "PRIVMSG #auditorium hi")
echo_message = [
msg for msg in self.getMessages("bar") if msg.command == "PRIVMSG"
][0]
self.assertEqual(echo_message, self.getMessages("guest1")[0])
self.assertEqual(echo_message, self.getMessages("guest2")[0])
self.assertEqual(echo_message, self.getMessages("guest3")[0])
# unvoiced users can speak
self.sendLine("guest1", "PRIVMSG #auditorium :hi you")
echo_message = [
msg for msg in self.getMessages("guest1") if msg.command == "PRIVMSG"
][0]
self.assertEqual(self.getMessages("bar"), [echo_message])
self.assertEqual(self.getMessages("guest2"), [echo_message])
self.assertEqual(self.getMessages("guest3"), [echo_message])
def names(client):
self.sendLine(client, "NAMES #auditorium")
result = set()
for msg in self.getMessages(client):
if msg.command == RPL_NAMREPLY:
result.update(msg.params[-1].split())
return result
self.assertEqual(names("bar"), {"@bar", "guest1", "guest2", "guest3"})
self.assertEqual(names("guest1"), {"@bar"})
self.assertEqual(names("guest2"), {"@bar"})
self.assertEqual(names("guest3"), {"@bar"})
self.sendLine("bar", "MODE #auditorium +v guest1")
modeLine = [msg for msg in self.getMessages("bar") if msg.command == "MODE"][0]
self.assertEqual(self.getMessages("guest1"), [modeLine])
self.assertEqual(self.getMessages("guest2"), [modeLine])
self.assertEqual(self.getMessages("guest3"), [modeLine])
self.assertEqual(names("bar"), {"@bar", "+guest1", "guest2", "guest3"})
self.assertEqual(names("guest2"), {"@bar", "+guest1"})
self.assertEqual(names("guest3"), {"@bar", "+guest1"})
self.sendLine("guest1", "PART #auditorium")
part = [msg for msg in self.getMessages("guest1") if msg.command == "PART"][0]
# everyone should see voiced PART
self.assertEqual(self.getMessages("bar")[0], part)
self.assertEqual(self.getMessages("guest2")[0], part)
self.assertEqual(self.getMessages("guest3")[0], part)
self.joinChannel("guest1", "#auditorium")
self.getMessages("guest1")
self.getMessages("bar")
self.sendLine("guest2", "PART #auditorium")
part = [msg for msg in self.getMessages("guest2") if msg.command == "PART"][0]
self.assertEqual(self.getMessages("bar"), [part])
# part should be hidden from unvoiced participants
self.assertEqual(self.getMessages("guest1"), [])
self.assertEqual(self.getMessages("guest3"), [])
self.sendLine("guest3", "QUIT")
self.assertDisconnected("guest3")
# quit should be hidden from unvoiced participants
self.assertEqual(
len([msg for msg in self.getMessages("bar") if msg.command == "QUIT"]), 1
)
self.assertEqual(
len([msg for msg in self.getMessages("guest1") if msg.command == "QUIT"]), 0
)

View File

@ -1,159 +0,0 @@
"""
Channel ban (`RFC 1459
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.3.1>`__,
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.3>`__,
`Modern <https://modern.ircdocs.horse/#ban-channel-mode>`__)
and ban exception (`Modern <https://modern.ircdocs.horse/#exception-channel-mode>`__)
"""
from irctest import cases, runner
from irctest.numerics import ERR_BANNEDFROMCHAN, RPL_BANLIST, RPL_ENDOFBANLIST
from irctest.patma import ANYSTR, StrRe
class BanModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testBan(self):
"""Basic ban operation"""
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.connectClient("Bar", name="bar")
self.getMessages("bar")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
self.sendLine("chanop", "MODE #chan -b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
@cases.mark_specifications("Modern")
def testBanList(self):
"""`RPL_BANLIST <https://modern.ircdocs.horse/#rplbanlist-367>`_"""
self.connectClient("chanop")
self.joinChannel(1, "#chan")
self.getMessages(1)
self.sendLine(1, "MODE #chan +b bar!*@*")
self.assertMessageMatch(self.getMessage(1), command="MODE")
self.sendLine(1, "MODE #chan +b")
m = self.getMessage(1)
if len(m.params) == 3:
# Old format
self.assertMessageMatch(
m,
command=RPL_BANLIST,
params=[
"chanop",
"#chan",
"bar!*@*",
],
)
else:
self.assertMessageMatch(
m,
command=RPL_BANLIST,
params=[
"chanop",
"#chan",
"bar!*@*",
StrRe("chanop(!.*@.*)?"),
StrRe("[0-9]+"),
],
)
self.assertMessageMatch(
self.getMessage(1),
command=RPL_ENDOFBANLIST,
params=[
"chanop",
"#chan",
ANYSTR,
],
)
@cases.mark_specifications("Modern")
def testBanException(self):
"""`Exception mode <https://modern.ircdocs.horse/#exception-channel-mode`_,
detected using `ISUPPORT EXCEPTS
<https://modern.ircdocs.horse/#excepts-parameter>`_ and checked against
`ISUPPORT CHANMODES <https://modern.ircdocs.horse/#chanmodes-parameter>`_"""
self.connectClient("chanop", name="chanop")
if "EXCEPTS" in self.server_support:
mode = self.server_support["EXCEPTS"] or "e"
if "CHANMODES" in self.server_support:
self.assertIn(
mode,
self.server_support["CHANMODES"],
fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is missing "
"from 'CHANMODES={list}'",
)
self.assertIn(
mode,
self.server_support["CHANMODES"].split(",")[0],
fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is not "
"in group A",
)
else:
mode = "e"
if "CHANMODES" in self.server_support:
if "e" not in self.server_support["CHANMODES"]:
raise runner.OptionalExtensionNotSupported(
"Ban exception (or mode letter is not +e)"
)
self.assertIn(
mode,
self.server_support["CHANMODES"].split(",")[0],
fail_msg="Mode +e (assumed to be ban exception) is present, "
"but 'e' is not in group A",
)
else:
raise runner.OptionalExtensionNotSupported("ISUPPORT CHANMODES")
self.sendLine("chanop", "JOIN #chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +b ba*!*@*")
self.getMessages("chanop")
# banned client cannot join
self.connectClient("Bar", name="bar")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
# chanop sets exception
self.sendLine("chanop", "MODE #chan +e *ar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
# client can now join
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
# TODO: Add testBanExceptionList, once the numerics are specified in Modern
@cases.mark_specifications("Ergo")
def testCaseInsensitive(self):
"""Some clients allow unsetting modes if their argument matches
up to normalization"""
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +b BAR!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.connectClient("Bar", name="bar")
self.getMessages("bar")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
self.sendLine("chanop", "MODE #chan -b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")

View File

@ -1,128 +0,0 @@
"""
Various Ergo-specific channel modes
"""
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
@cases.mark_services
class RegisteredOnlySpeakModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testRegisteredOnlySpeakMode(self):
self.controller.registerUser(self, "evan", "sesame")
# test the +M (only registered users and ops can speak) channel mode
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +M")
replies = self.getMessages("chanop")
modeLines = [line for line in replies if line.command == "MODE"]
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+M"])
self.connectClient("baz", name="baz")
self.joinChannel("baz", "#chan")
self.getMessages("chanop")
# this message should be suppressed completely by +M
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
replies = self.getMessages("baz")
reply_cmds = {reply.command for reply in replies}
self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds)
self.assertEqual(self.getMessages("chanop"), [])
# +v exempts users from the registration requirement:
self.sendLine("chanop", "MODE #chan +v baz")
self.getMessages("chanop")
self.getMessages("baz")
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
replies = self.getMessages("baz")
# baz should not receive an error (or an echo)
self.assertEqual(replies, [])
replies = self.getMessages("chanop")
self.assertMessageMatch(
replies[0], command="PRIVMSG", params=["#chan", "hi again from baz"]
)
self.connectClient(
"evan",
name="evan",
account="evan",
password="sesame",
capabilities=["sasl"],
)
self.joinChannel("evan", "#chan")
self.getMessages("baz")
self.sendLine("evan", "PRIVMSG #chan :hi from evan")
replies = self.getMessages("evan")
# evan should not receive an error (or an echo)
self.assertEqual(replies, [])
replies = self.getMessages("baz")
self.assertMessageMatch(
replies[0], command="PRIVMSG", params=["#chan", "hi from evan"]
)
class OpModeratedTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testOpModerated(self):
# test the +U channel mode
self.connectClient("chanop", name="chanop", capabilities=MODERN_CAPS)
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +U")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
self.joinChannel("baz", "#chan")
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
echo = self.getMessages("baz")[0]
self.assertMessageMatch(
echo, command="PRIVMSG", params=["#chan", "hi from baz"]
)
self.assertEqual(
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
[echo],
)
self.connectClient("qux", name="qux", capabilities=MODERN_CAPS)
self.joinChannel("qux", "#chan")
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
echo = self.getMessages("qux")[0]
self.assertMessageMatch(
echo, command="PRIVMSG", params=["#chan", "hi from qux"]
)
# message is relayed to chanop but not to unprivileged
self.assertEqual(
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
[echo],
)
self.assertEqual(
[msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], []
)
self.sendLine("chanop", "MODE #chan +v qux")
self.getMessages("chanop")
self.sendLine("qux", "PRIVMSG #chan :hi again from qux")
echo = [msg for msg in self.getMessages("qux") if msg.command == "PRIVMSG"][0]
self.assertMessageMatch(
echo, command="PRIVMSG", params=["#chan", "hi again from qux"]
)
self.assertEqual(
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
[echo],
)
self.assertEqual(
[msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], [echo]
)

View File

@ -1,162 +0,0 @@
"""
Channel key (`RFC 1459
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.3.1>`__,
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.3>`__,
`Modern <https://modern.ircdocs.horse/#key-channel-mode>`__)
"""
import pytest
from irctest import cases
from irctest.numerics import (
ERR_BADCHANNELKEY,
ERR_INVALIDKEY,
ERR_INVALIDMODEPARAM,
ERR_UNKNOWNERROR,
)
from irctest.patma import ANYSTR
class KeyTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testKeyNormal(self):
self.connectClient("bar")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +k beer")
self.getMessages(1)
self.connectClient("qux")
self.getMessages(2)
# JOIN with a missing key MUST receive ERR_BADCHANNELKEY:
self.sendLine(2, "JOIN #chan")
reply_cmds = {msg.command for msg in self.getMessages(2)}
self.assertNotIn("JOIN", reply_cmds)
self.assertIn(ERR_BADCHANNELKEY, reply_cmds)
# similarly for JOIN with an incorrect key:
self.sendLine(2, "JOIN #chan bees")
reply_cmds = {msg.command for msg in self.getMessages(2)}
self.assertNotIn("JOIN", reply_cmds)
self.assertIn(ERR_BADCHANNELKEY, reply_cmds)
self.sendLine(2, "JOIN #chan beer")
reply = self.getMessages(2)
self.assertMessageMatch(reply[0], command="JOIN", params=["#chan"])
@pytest.mark.parametrize(
"key",
["passphrase with spaces", "long" * 100, ""],
ids=["spaces", "long", "empty"],
)
@cases.mark_specifications("RFC2812", "Modern")
def testKeyValidation(self, key):
"""
key = 1*23( %x01-05 / %x07-08 / %x0C / %x0E-1F / %x21-7F )
; any 7-bit US_ASCII character,
; except NUL, CR, LF, FF, h/v TABs, and " "
-- https://tools.ietf.org/html/rfc2812#page-8
"Servers may validate the value (eg. to forbid spaces, as they make it harder
to use the key in `JOIN` messages). If the value is invalid, they SHOULD
return [`ERR_INVALIDMODEPARAM`](#errinvalidmodeparam-696).
However, clients MUST be able to handle any of the following:
* [`ERR_INVALIDMODEPARAM`](#errinvalidmodeparam-696)
* [`ERR_INVALIDKEY`](#errinvalidkey-525)
* `MODE` echoed with a different key (eg. truncated or stripped of invalid
characters)
* the key changed ignored, and no `MODE` echoed if no other mode change
was valid.
"
-- https://modern.ircdocs.horse/#key-channel-mode
-- https://github.com/ircdocs/modern-irc/pull/111
"""
if key == "" and self.controller.software_name in (
"ircu2",
"Nefarious",
"snircd",
):
pytest.xfail(
"ircu2 returns ERR_NEEDMOREPARAMS on empty keys: "
"https://github.com/UndernetIRC/ircu2/issues/13"
)
if (key == "" or " " in key) and self.controller.software_name == "ngIRCd":
pytest.xfail(
"ngIRCd does not validate channel keys: "
"https://github.com/ngircd/ngircd/issues/290"
)
self.connectClient("bar")
self.joinChannel(1, "#chan")
self.sendLine(1, f"MODE #chan +k :{key}")
# The spec requires no space; but doesn't say what to do
# if there is one.
# Let's check the various alternatives
replies = self.getMessages(1)
self.assertNotIn(
ERR_UNKNOWNERROR,
{msg.command for msg in replies},
fail_msg="Sending an invalid key caused an "
"ERR_UNKNOWNERROR instead of being handled explicitly "
"(eg. ERR_INVALIDMODEPARAM or truncation): {msg}",
)
commands = {msg.command for msg in replies}
if {ERR_INVALIDMODEPARAM, ERR_INVALIDKEY} & commands:
# First option: ERR_INVALIDMODEPARAM (eg. Ergo) or ERR_INVALIDKEY
# (eg. ircu2)
if ERR_INVALIDMODEPARAM in commands:
command = [
msg for msg in replies if msg.command == ERR_INVALIDMODEPARAM
]
self.assertEqual(len(command), 1, command)
self.assertMessageMatch(
command[0],
command=ERR_INVALIDMODEPARAM,
params=["bar", "#chan", "k", "*", ANYSTR],
)
return
if not replies:
# MODE was ignored entirely
self.connectClient("foo")
self.sendLine(2, "JOIN #chan")
self.assertMessageMatch(
self.getMessage(2), command="JOIN", params=["#chan"]
)
return
# Second and third options: truncating the key (eg. UnrealIRCd)
# or replacing spaces (eg. Charybdis)
mode_commands = [msg for msg in replies if msg.command == "MODE"]
self.assertGreaterEqual(
len(mode_commands),
1,
fail_msg="Sending an invalid key (with a space) triggered "
"neither ERR_UNKNOWNERROR, ERR_INVALIDMODEPARAM, ERR_INVALIDKEY, "
" or a MODE. Only these: {}",
extra_format=(replies,),
)
self.assertLessEqual(
len(mode_commands),
1,
fail_msg="Sending an invalid key (with a space) triggered "
"multiple MODE responses: {}",
extra_format=(replies,),
)
mode_command = mode_commands[0]
if mode_command.params == ["#chan", "+k", "passphrase"]:
key = "passphrase"
elif mode_command.params == ["#chan", "+k", "passphrasewithspaces"]:
key = "passphrasewithspaces"
elif mode_command.params[2].startswith("longlonglong"):
key = mode_command.params[2]
assert mode_command.params == ["#chan", "+k", key]
elif mode_command.params == ["#chan", "+k", "passphrase with spaces"]:
raise self.failureException("Invalid key (with a space) was not rejected.")
self.connectClient("foo")
self.sendLine(2, f"JOIN #chan {key}")
self.assertMessageMatch(self.getMessage(2), command="JOIN", params=["#chan"])

View File

@ -1,43 +0,0 @@
"""
Channel moderation mode (`RFC 2812
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.3>`__,
`Modern <https://modern.ircdocs.horse/#ban-channel-mode>`__)
"""
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN
class ModeratedModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC2812")
def testModeratedMode(self):
# test the +m channel mode
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +m")
replies = self.getMessages("chanop")
modeLines = [line for line in replies if line.command == "MODE"]
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+m"])
self.connectClient("baz", name="baz")
self.joinChannel("baz", "#chan")
self.getMessages("chanop")
# this message should be suppressed completely by +m
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
replies = self.getMessages("baz")
reply_cmds = {reply.command for reply in replies}
self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds)
self.assertEqual(self.getMessages("chanop"), [])
# grant +v, user should be able to send messages
self.sendLine("chanop", "MODE #chan +v baz")
self.getMessages("chanop")
self.getMessages("baz")
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
self.getMessages("baz")
relays = self.getMessages("chanop")
relay = relays[0]
self.assertMessageMatch(
relay, command="PRIVMSG", params=["#chan", "hi again from baz"]
)

View File

@ -1,271 +0,0 @@
"""
Mute extban, currently no specifications or ways to discover it.
"""
from irctest import cases, runner
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
from irctest.patma import ANYLIST, StrRe
class MuteExtbanTestCase(cases.BaseServerTestCase):
"""https://defs.ircdocs.horse/defs/isupport.html#extban
It magically guesses what char the IRCd uses for mutes."""
def char(self):
if self.controller.extban_mute_char is None:
raise runner.ExtbanNotSupported("", "mute")
else:
return self.controller.extban_mute_char
@cases.mark_specifications("Ergo")
def testISupport(self):
self.connectClient("chk") # Fetches ISUPPORT
isupport = self.server_support
token = isupport["EXTBAN"]
prefix, comma, types = token.partition(",")
self.assertIn(self.char(), types, f"Missing '{self.char()}' in ISUPPORT EXTBAN")
self.assertEqual(prefix, "")
self.assertEqual(comma, ",")
@cases.mark_specifications("ircdocs")
def testMuteExtban(self):
"""Basic usage of mute"""
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
if self.char() not in types:
raise runner.ExtbanNotSupported(self.char(), "mute")
clients = ("chanop", "bar")
# Mute "bar"
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:bar!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient("bar", name="bar", capabilities=["echo-message"])
self.joinChannel("bar", "#chan")
for client in clients:
self.getMessages(client)
# "bar" sees the MODE too
self.sendLine("bar", "MODE #chan +b")
self.assertMessageMatch(
self.getMessage("bar"),
command="367",
params=[
"bar",
"#chan",
f"{prefix}{self.char()}:bar!*@*",
StrRe("chanop(!.*)?"),
*ANYLIST,
],
)
self.getMessages("bar")
# "bar" talks: rejected
self.sendLine("bar", "PRIVMSG #chan :hi from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
# remove mute on "bar" with -b
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan -b {prefix}{self.char()}:bar!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
# "bar" can now talk
self.sendLine("bar", "PRIVMSG #chan :hi again from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)
@cases.mark_specifications("ircdocs")
def testMuteExtbanVoiced(self):
"""Checks +v overrides the mute"""
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
if self.char() not in types:
raise runner.ExtbanNotSupported(self.char(), "mute")
clients = ("chanop", "qux")
# Mute "qux"
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:qux!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient(
"qux", name="qux", ident="evan", capabilities=["echo-message"]
)
self.joinChannel("qux", "#chan")
for client in clients:
self.getMessages(client)
# "qux" talks: rejected
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
for client in clients:
self.getMessages(client)
# +v grants an exemption to +b
self.sendLine("chanop", "MODE #chan +v qux")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
# so "qux" can now talk
self.sendLine("qux", "PRIVMSG #chan :hi again from qux")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)
@cases.mark_specifications("ircdocs")
def testMuteExtbanExempt(self):
"""Checks +e overrides the mute
<https://defs.ircdocs.horse/defs/chanmodes.html#e-ban-exception>"""
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
if self.char() not in types:
raise runner.ExtbanNotSupported(self.char(), "mute")
if "e" not in self.server_support["CHANMODES"]:
raise runner.ChannelModeNotSupported(self.char(), "mute")
clients = ("chanop", "qux")
# Mute "qux"
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:qux!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient(
"qux", name="qux", ident="evan", capabilities=["echo-message"]
)
self.joinChannel("qux", "#chan")
for client in clients:
self.getMessages(client)
# "qux" talks: rejected
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
for client in clients:
self.getMessages(client)
# +e grants an exemption to +b
self.sendLine("chanop", f"MODE #chan +e {prefix}{self.char()}:*!*evan@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.getMessages("qux")
# so "qux" can now talk
self.sendLine("qux", "PRIVMSG #chan :thanks for mute-excepting me")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)
@cases.mark_specifications("Ergo")
def testCapitalization(self):
"""
Regression test for oragono #1370: mutes not correctly enforced against
users with capital letters in their NUH
For consistency with regular -b, which allows unsetting up to
normalization
"""
clients = ("chanop", "bar")
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:BAR!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient("Bar", name="bar", capabilities=["echo-message"])
self.joinChannel("bar", "#chan")
for client in clients:
self.getMessages(client)
self.sendLine("bar", "PRIVMSG #chan :hi from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
# remove mute with -b
self.sendLine("chanop", f"MODE #chan -b {prefix}{self.char()}:bar!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
# "bar" can talk again
self.sendLine("bar", "PRIVMSG #chan :hi again from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)

View File

@ -1,62 +0,0 @@
"""
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,40 +0,0 @@
"""
`Ergo <https://ergo.chat/>`_-specific tests for nick collisions based on Unicode
confusable characters
"""
from irctest import cases
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
@cases.mark_services
class ConfusablesTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["accounts"].update(
{"nick-reservation": {"enabled": True, "method": "strict"}}
)
)
@cases.mark_specifications("Ergo")
def testConfusableNicks(self):
self.controller.registerUser(self, "evan", "sesame")
self.addClient(1)
# U+0435 in place of e:
self.sendLine(1, "NICK еvan")
self.sendLine(1, "USER a 0 * a")
messages = self.getMessages(1)
commands = set(msg.command for msg in messages)
self.assertNotIn(RPL_WELCOME, commands)
self.assertIn(ERR_NICKNAMEINUSE, commands)
self.connectClient(
"evan", name="evan", password="sesame", capabilities=["sasl"]
)
# should be able to switch to the confusable nick
self.sendLine("evan", "NICK еvan")
messages = self.getMessages("evan")
commands = set(msg.command for msg in messages)
self.assertIn("NICK", commands)

View File

@ -1,208 +0,0 @@
"""
Tests section 4.1 of RFC 1459.
<https://tools.ietf.org/html/rfc1459#section-4.1>
TODO: cross-reference Modern and RFC 2812 too
"""
from irctest import cases
from irctest.client_mock import ConnectionClosed
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH
from irctest.patma import ANYSTR, StrRe
class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
password = "testpassword"
@cases.mark_specifications("RFC1459", "RFC2812")
def testPassBeforeNickuser(self):
self.addClient()
self.sendLine(1, "PASS {}".format(self.password))
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="001",
fail_msg="Did not get 001 after correct PASS+NICK+USER: {msg}",
)
@cases.mark_specifications("RFC1459", "RFC2812")
def testNoPassword(self):
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
m = self.getRegistrationMessage(1)
self.assertNotEqual(
m.command, "001", msg="Got 001 after NICK+USER but missing PASS"
)
@cases.mark_specifications("Modern")
def testWrongPassword(self):
"""
"If the password supplied does not match the password expected by the server,
then the server SHOULD send ERR_PASSWDMISMATCH and MUST close the connection
with ERROR."
-- https://github.com/ircdocs/modern-irc/pull/172
"""
self.addClient()
self.sendLine(1, "PASS {}".format(self.password + "garbage"))
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
m = self.getRegistrationMessage(1)
self.assertNotEqual(
m.command, "001", msg="Got 001 after NICK+USER but incorrect PASS"
)
self.assertIn(m.command, {ERR_PASSWDMISMATCH, "ERROR"})
if m.command == "ERR_PASSWDMISMATCH":
m = self.getRegistrationMessage(1)
self.assertEqual(
m.command, "ERROR", msg="ERR_PASSWDMISMATCH not followed by ERROR."
)
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testPassAfterNickuser(self):
"""“The password can and must be set before any attempt to register
the connection is made.”
-- <https://tools.ietf.org/html/rfc1459#section-4.1.1>
“The optional password can and MUST be set before any attempt to
register the connection is made.
Currently this requires that user send a PASS command before
sending the NICK/USER combination.”
-- <https://tools.ietf.org/html/rfc2812#section-3.1.1>
"""
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
self.sendLine(1, "PASS {}".format(self.password))
m = self.getRegistrationMessage(1)
self.assertNotEqual(m.command, "001", "Got 001 after PASS sent after NICK+USER")
class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459")
def testQuitDisconnects(self):
"""“The server must close the connection to a client which sends a
QUIT message.”
-- <https://tools.ietf.org/html/rfc1459#section-4.1.3>
"""
self.connectClient("foo")
self.getMessages(1)
self.sendLine(1, "QUIT")
with self.assertRaises(ConnectionClosed):
self.getMessages(1) # Fetch remaining messages
self.getMessages(1)
@cases.mark_specifications("RFC2812")
@cases.xfailIfSoftware(["Charybdis", "Solanum"], "very flaky")
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "snircd"], "ircu2 does not send ERROR"
)
def testQuitErrors(self):
"""“A client session is terminated with a quit message. The server
acknowledges this by sending an ERROR message to the client.”
-- <https://tools.ietf.org/html/rfc2812#section-3.1.7>
"""
self.connectClient("foo")
self.getMessages(1)
self.sendLine(1, "QUIT")
while True:
try:
new_messages = self.getMessages(1)
if not new_messages:
break
commands = {m.command for m in new_messages}
except ConnectionClosed:
break
self.assertIn(
"ERROR", commands, fail_msg="Did not receive ERROR as a reply to QUIT."
)
def testNickCollision(self):
"""A user connects and requests the same nickname as an already
registered user.
"""
self.connectClient("foo")
self.addClient()
self.sendLine(2, "NICK foo")
self.sendLine(2, "USER username * * :Realname")
m = self.getRegistrationMessage(2)
self.assertNotEqual(
m.command,
"001",
"Received 001 after registering with the nick of a " "registered user.",
)
def testEarlyNickCollision(self):
"""Two users register simultaneously with the same nick."""
self.addClient()
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(2, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
try:
self.sendLine(2, "USER username * * :Realname")
except (ConnectionClosed, ConnectionResetError):
# Bahamut closes the connection here
pass
try:
m1 = self.getRegistrationMessage(1)
except (ConnectionClosed, ConnectionResetError):
# Unreal closes the connection, see
# https://bugs.unrealircd.org/view.php?id=5950
command1 = None
else:
command1 = m1.command
try:
m2 = self.getRegistrationMessage(2)
except (ConnectionClosed, ConnectionResetError):
# ditto
command2 = None
else:
command2 = m2.command
self.assertNotEqual(
(command1, command2),
("001", "001"),
"Two concurrently registering requesting the same nickname "
"both got 001.",
)
self.assertIn(
"001",
(command1, command2),
"Two concurrently registering requesting the same nickname "
"neither got 001.",
)
@cases.xfailIfSoftware(
["ircu2", "Nefarious", "ngIRCd"],
"uses a default value instead of ERR_NEEDMOREPARAMS",
)
def testEmptyRealname(self):
"""
Syntax:
"<client> <command> :Not enough parameters"
-- https://defs.ircdocs.horse/defs/numerics.html#err-needmoreparams-461
-- https://modern.ircdocs.horse/#errneedmoreparams-461
Use of this numeric:
"The minimum length of `<username>` is 1, ie. it MUST not be empty.
If it is empty, the server SHOULD reject the command with ERR_NEEDMOREPARAMS
(even an empty parameter is provided)"
https://github.com/ircdocs/modern-irc/issues/85
"""
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :")
self.assertMessageMatch(
self.getRegistrationMessage(1),
command=ERR_NEEDMOREPARAMS,
params=[StrRe(r"(\*|foo)"), "USER", ANYSTR],
)

View File

@ -1,135 +0,0 @@
"""
`IRCv3 echo-message <https://ircv3.net/specs/extensions/echo-message>`_
"""
import pytest
from irctest import cases
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYDICT
class EchoMessageTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize(
"command,solo,server_time",
[
("PRIVMSG", False, False),
("PRIVMSG", True, True),
("PRIVMSG", False, True),
("NOTICE", False, True),
],
)
@cases.mark_capabilities("echo-message")
def testEchoMessage(self, command, solo, server_time):
"""<http://ircv3.net/specs/extensions/echo-message-3.2.html>"""
if server_time:
self.connectClient(
"baz",
capabilities=["echo-message", "server-time"],
skip_if_cap_nak=True,
)
else:
self.connectClient(
"baz",
capabilities=["echo-message", "server-time"],
skip_if_cap_nak=True,
)
self.sendLine(1, "JOIN #chan")
if not solo:
capabilities = ["server-time"] if server_time else None
self.connectClient("qux", capabilities=capabilities)
self.sendLine(2, "JOIN #chan")
# Synchronize and clean
self.getMessages(1)
if not solo:
self.getMessages(2)
self.getMessages(1)
self.sendLine(1, "{} #chan :hello everyone".format(command))
m1 = self.getMessage(1)
self.assertMessageMatch(
m1,
command=command,
params=["#chan", "hello everyone"],
fail_msg="Did not echo “{} #chan :hello everyone”: {msg}",
extra_format=(command,),
)
if not solo:
m2 = self.getMessage(2)
self.assertMessageMatch(
m2,
command=command,
params=["#chan", "hello everyone"],
fail_msg="Did not propagate “{} #chan :hello everyone”: "
"after echoing it to the author: {msg}",
extra_format=(command,),
)
self.assertEqual(
m1.params,
m2.params,
fail_msg="Parameters of forwarded and echoed " "messages differ: {} {}",
extra_format=(m1, m2),
)
if server_time:
self.assertIn(
"time",
m1.tags,
fail_msg="Echoed message is missing server time: {}",
extra_format=(m1,),
)
self.assertIn(
"time",
m2.tags,
fail_msg="Forwarded message is missing server time: {}",
extra_format=(m2,),
)
@pytest.mark.arbitrary_client_tags
@cases.mark_capabilities(
"batch", "labeled-response", "echo-message", "message-tags"
)
def testDirectMessageEcho(self):
bar = random_name("bar")
self.connectClient(
bar,
name=bar,
capabilities=["batch", "labeled-response", "echo-message", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(bar)
qux = random_name("qux")
self.connectClient(
qux,
name=qux,
capabilities=["batch", "labeled-response", "echo-message", "message-tags"],
)
self.getMessages(qux)
self.sendLine(
bar,
"@label=xyz;+example-client-tag=example-value PRIVMSG %s :hi there"
% (qux,),
)
echo = self.getMessages(bar)[0]
delivery = self.getMessages(qux)[0]
self.assertMessageMatch(
echo,
command="PRIVMSG",
params=[qux, "hi there"],
tags={"label": "xyz", "+example-client-tag": "example-value", **ANYDICT},
)
self.assertMessageMatch(
delivery,
command="PRIVMSG",
params=[qux, "hi there"],
tags={"+example-client-tag": "example-value", **ANYDICT},
)
# Either both messages have a msgid, or neither does
self.assertEqual(delivery.tags.get("msgid"), echo.tags.get("msgid"))

View File

@ -1,29 +0,0 @@
"""
`Ergo <https://ergo.chat/>`-specific tests of NickServ.
"""
from irctest import cases
from irctest.numerics import RPL_YOUREOPER
class NickservTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def test_saregister(self):
self.connectClient("root", name="root")
self.sendLine("root", "OPER operuser operpassword")
self.assertIn(RPL_YOUREOPER, {msg.command for msg in self.getMessages("root")})
self.sendLine(
"root",
"PRIVMSG NickServ :SAREGISTER saregister_test saregistertestpassphrase",
)
self.getMessages("root")
# test that the account was registered
self.connectClient(
name="saregister_test",
nick="saregister_test",
capabilities=["sasl"],
account="saregister_test",
password="saregistertestpassphrase",
)

View File

@ -1,69 +0,0 @@
"""
`IRCv3 extended-join <https://ircv3.net/specs/extensions/extended-join>`_
"""
from irctest import cases
@cases.mark_services
class MetadataTestCase(cases.BaseServerTestCase):
def connectRegisteredClient(self, nick):
self.addClient()
self.sendLine(2, "CAP LS 302")
capabilities = self.getCapLs(2)
assert "sasl" in capabilities
self.requestCapabilities(2, ["sasl"], skip_if_cap_nak=False)
self.sendLine(2, "AUTHENTICATE PLAIN")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="AUTHENTICATE",
params=["+"],
fail_msg="Sent “AUTHENTICATE PLAIN”, server should have "
"replied with “AUTHENTICATE +”, but instead sent: {msg}",
)
self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="900",
fail_msg="Did not send 900 after correct SASL authentication.",
)
self.sendLine(2, "USER f * * :Realname")
self.sendLine(2, "NICK {}".format(nick))
self.sendLine(2, "CAP END")
self.skipToWelcome(2)
@cases.mark_capabilities("extended-join")
def testNotLoggedIn(self):
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="JOIN",
params=["#chan", "*", "Realname"],
fail_msg="Expected “JOIN #chan * :Realname” after "
"unregistered user joined, got: {msg}",
)
@cases.mark_capabilities("extended-join")
@cases.skipUnlessHasMechanism("PLAIN")
def testLoggedIn(self):
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
self.joinChannel(1, "#chan")
self.controller.registerUser(self, "jilles", "sesame")
self.connectRegisteredClient("bar")
self.joinChannel(2, "#chan")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="JOIN",
params=["#chan", "jilles", "Realname"],
fail_msg="Expected “JOIN #chan * :Realname” after "
"nick “bar” logged in as “jilles” joined, got: {msg}",
)

View File

@ -1,126 +0,0 @@
"""
The HELP and HELPOP command (`Modern <https://modern.ircdocs.horse/#help-message>`__)
"""
import functools
import re
import pytest
from irctest import cases, runner
from irctest.numerics import (
ERR_HELPNOTFOUND,
ERR_UNKNOWNCOMMAND,
RPL_ENDOFHELP,
RPL_HELPSTART,
RPL_HELPTXT,
)
from irctest.patma import ANYSTR, StrRe
def with_xfails(f):
@functools.wraps(f)
def newf(self, command, *args, **kwargs):
if command == "HELP" and self.controller.software_name == "Bahamut":
raise runner.ImplementationChoice(
"fail because Bahamut forwards /HELP to HelpServ (but not /HELPOP)"
)
if self.controller.software_name in ("irc2", "ircu2", "ngIRCd"):
raise runner.ImplementationChoice(
"numerics in reply to /HELP and /HELPOP (uses NOTICE instead)"
)
if self.controller.software_name == "UnrealIRCd":
raise runner.ImplementationChoice(
"fails because Unreal uses custom numerics "
"https://github.com/unrealircd/unrealircd/pull/184"
)
return f(self, command, *args, **kwargs)
return newf
class HelpTestCase(cases.BaseServerTestCase):
def _assertValidHelp(self, messages, subject):
if subject != ANYSTR:
subject = StrRe("(?i)" + re.escape(subject))
self.assertMessageMatch(
messages[0],
command=RPL_HELPSTART,
params=["nick", subject, ANYSTR],
fail_msg=f"Expected {RPL_HELPSTART} (RPL_HELPSTART), got: {{msg}}",
)
self.assertMessageMatch(
messages[-1],
command=RPL_ENDOFHELP,
params=["nick", subject, ANYSTR],
fail_msg=f"Expected {RPL_ENDOFHELP} (RPL_ENDOFHELP), got: {{msg}}",
)
for i in range(1, len(messages) - 1):
self.assertMessageMatch(
messages[i],
command=RPL_HELPTXT,
params=["nick", subject, ANYSTR],
fail_msg=f"Expected {RPL_HELPTXT} (RPL_HELPTXT), got: {{msg}}",
)
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
@cases.mark_specifications("Modern")
@with_xfails
def testHelpNoArg(self, command):
self.connectClient("nick")
self.sendLine(1, f"{command}")
messages = self.getMessages(1)
if messages[0].command == ERR_UNKNOWNCOMMAND:
raise runner.OptionalCommandNotSupported(command)
self._assertValidHelp(messages, ANYSTR)
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
@cases.mark_specifications("Modern")
@with_xfails
def testHelpPrivmsg(self, command):
self.connectClient("nick")
self.sendLine(1, f"{command} PRIVMSG")
messages = self.getMessages(1)
if messages[0].command == ERR_UNKNOWNCOMMAND:
raise runner.OptionalCommandNotSupported(command)
self._assertValidHelp(messages, "PRIVMSG")
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
@cases.mark_specifications("Modern")
@with_xfails
def testHelpUnknownSubject(self, command):
self.connectClient("nick")
self.sendLine(1, f"{command} THISISNOTACOMMAND")
messages = self.getMessages(1)
if messages[0].command == ERR_UNKNOWNCOMMAND:
raise runner.OptionalCommandNotSupported(command)
if messages[0].command == ERR_HELPNOTFOUND:
# Inspircd, Hybrid et al
self.assertEqual(len(messages), 1)
self.assertMessageMatch(
messages[0],
command=ERR_HELPNOTFOUND,
params=[
"nick",
StrRe(
"(?i)THISISNOTACOMMAND"
), # case-insensitive, for Hybrid and Plexus4 (but not Chary et al)
ANYSTR,
],
)
else:
# Unrealircd
self._assertValidHelp(messages, ANYSTR)

View File

@ -1,118 +0,0 @@
"""
The INFO command (`RFC 1459
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>`__,
`RFC 2812 <https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>`__,
`Modern <https://modern.ircdocs.horse/#info-message>`__)
"""
import pytest
from irctest import cases
from irctest.numerics import ERR_NOSUCHSERVER, RPL_ENDOFINFO, RPL_INFO, RPL_YOUREOPER
from irctest.patma import ANYSTR
class InfoTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testInfo(self):
"""
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
"Upon receiving an INFO command, the given server will respond with zero or
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
-- <https://modern.ircdocs.horse/#info-message>
"""
self.connectClient("nick")
# Remote /INFO is oper-only on Unreal and ircu2
self.sendLine(1, "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages(1)],
fail_msg="OPER failed",
)
self.sendLine(1, "INFO")
messages = self.getMessages(1)
last_message = messages.pop()
self.assertMessageMatch(
last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR]
)
for message in messages:
self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR])
@pytest.mark.parametrize(
"target",
["My.Little.Server", "*Little*", "nick"],
ids=["target-server", "target-wildcard", "target-nick"],
)
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInfoTarget(self, target):
"""
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
"Upon receiving an INFO command, the given server will respond with zero or
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
-- <https://modern.ircdocs.horse/#info-message>
"""
self.connectClient("nick")
# Remote /INFO is oper-only on Unreal and ircu2
self.sendLine(1, "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages(1)],
fail_msg="OPER failed",
)
if target:
self.sendLine(1, "INFO My.Little.Server")
else:
self.sendLine(1, "INFO")
messages = self.getMessages(1)
last_message = messages.pop()
self.assertMessageMatch(
last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR]
)
for message in messages:
self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR])
@pytest.mark.parametrize("target", ["invalid.server.example", "invalidserver"])
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
@cases.xfailIfSoftware(
["Ergo"], "does not apply to Ergo, which ignores the optional <target> argument"
)
def testInfoNosuchserver(self, target):
"""
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
"Upon receiving an INFO command, the given server will respond with zero or
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
-- <https://modern.ircdocs.horse/#info-message>
"""
self.connectClient("nick")
# Remote /INFO is oper-only on Unreal and ircu2
self.sendLine(1, "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages(1)],
fail_msg="OPER failed",
)
self.sendLine(1, f"INFO {target}")
self.assertMessageMatch(
self.getMessage(1),
command=ERR_NOSUCHSERVER,
params=["nick", target, ANYSTR],
)

View File

@ -1,495 +0,0 @@
"""
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, runner
from irctest.numerics import (
ERR_BANNEDFROMCHAN,
ERR_CHANOPRIVSNEEDED,
ERR_INVITEONLYCHAN,
ERR_NEEDMOREPARAMS,
ERR_NOSUCHNICK,
ERR_NOTONCHANNEL,
ERR_USERONCHANNEL,
RPL_INVITING,
)
from irctest.patma import ANYSTR, StrRe
class InviteTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
def testInvites(self):
"""Test some basic functionality related to INVITE and the +i mode.
https://modern.ircdocs.horse/#invite-only-channel-mode
https://modern.ircdocs.horse/#rplinviting-341
"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +i")
self.getMessages(1)
self.sendLine(1, "INVITE bar #chan")
m = self.getMessage(1)
self.assertEqual(m.command, ERR_NOSUCHNICK)
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
m = self.getMessage(2)
self.assertEqual(m.command, ERR_INVITEONLYCHAN)
self.sendLine(1, "INVITE bar #chan")
m = self.getMessage(1)
# modern/ircv3 param order: inviter, invitee, channel
self.assertMessageMatch(m, command=RPL_INVITING, params=["foo", "bar", "#chan"])
m = self.getMessage(2)
self.assertMessageMatch(m, command="INVITE", params=["bar", "#chan"])
self.assertTrue(m.prefix.startswith("foo")) # nickmask of inviter
# we were invited, so join should succeed now
self.joinChannel(2, "#chan")
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInviteNonExistingChannelTransmitted(self):
"""“There is no requirement that the channel the target user is being
invited to must exist or be a valid channel.”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.7>
and <https://tools.ietf.org/html/rfc2812#section-3.2.7>
“Only the user inviting and the user being invited will receive
notification of the invitation.”
-- <https://tools.ietf.org/html/rfc2812#section-3.2.7>
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "INVITE #chan bar")
self.getMessages(1)
messages = self.getMessages(2)
self.assertNotEqual(
messages,
[],
fail_msg="After using “INVITE #chan bar” while #chan does "
"not exist, “bar” received nothing.",
)
self.assertMessageMatch(
messages[0],
command="INVITE",
params=["#chan", "bar"],
fail_msg="After “foo” invited “bar” do non-existing channel "
"#chan, “bar” should have received “INVITE #chan bar” but "
"got this instead: {msg}",
)
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInviteNonExistingChannelEchoed(self):
"""“There is no requirement that the channel the target user is being
invited to must exist or be a valid channel.”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.7>
and <https://tools.ietf.org/html/rfc2812#section-3.2.7>
“Only the user inviting and the user being invited will receive
notification of the invitation.”
-- <https://tools.ietf.org/html/rfc2812#section-3.2.7>
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "INVITE #chan bar")
messages = self.getMessages(1)
self.assertNotEqual(
messages,
[],
fail_msg="After using “INVITE #chan bar” while #chan does "
"not exist, the author received nothing.",
)
self.assertMessageMatch(
messages[0],
command="INVITE",
params=["#chan", "bar"],
fail_msg="After “foo” invited “bar” do non-existing channel "
"#chan, “foo” should have received “INVITE #chan bar” but "
"got this instead: {msg}",
)
def _testInvite(self, opped, invite_only):
"""
"Only the user inviting and the user being invited will receive
notification of the invitation."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
" 341 RPL_INVITING
"<channel> <nick>"
- Returned by the server to indicate that the
attempted INVITE message was successful and is
being passed onto the end client."
-- https://datatracker.ietf.org/doc/html/rfc1459
-- https://datatracker.ietf.org/doc/html/rfc2812
"When the invite is successful, the server MUST send a `RPL_INVITING`
numeric to the command issuer, and an `INVITE` message,
with the issuer as prefix, to the target user."
-- https://modern.ircdocs.horse/#invite-message
"### `RPL_INVITING (341)`
<client> <nick> <channel>
Sent as a reply to the [`INVITE`](#invite-message) command to indicate
that the attempt was successful and the client with the nickname `<nick>`
has been invited to `<channel>`.
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
if invite_only:
self.sendLine(1, "MODE #chan +i")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "+i"],
)
if not opped:
self.sendLine(1, "MODE #chan -o foo")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "-o", "foo"],
)
self.sendLine(1, "INVITE bar #chan")
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(
messages,
[],
fail_msg="After using “INVITE #chan bar”, “bar” received nothing.",
)
self.assertMessageMatch(
messages[0],
prefix=StrRe("foo!.*"),
command="INVITE",
params=["bar", "#chan"],
fail_msg="After “foo” invited “bar”, “bar” should have received "
"“INVITE bar #chan” but got this instead: {msg}",
)
@pytest.mark.parametrize("invite_only", [True, False])
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testInvite(self, invite_only):
self._testInvite(opped=True, invite_only=invite_only)
@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)
@cases.mark_specifications("RFC2812", "Modern")
def testInviteNoNotificationForOtherMembers(self):
"""
"Other channel members are not notified."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
"Other channel members SHOULD NOT be notified."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.connectClient("baz")
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.sendLine(3, "JOIN #chan")
self.getMessages(3)
self.sendLine(1, "INVITE bar #chan")
self.getMessages(1)
self.assertEqual(
self.getMessages(3),
[],
fail_msg="After foo used “INVITE #chan bar”, other channel members "
"were notified: {got}",
)
@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
channel operator on the given channel."
-- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.7
"When the channel has invite-only
flag set, only channel operators may issue INVITE command."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
"When the channel has [invite-only](#invite-only-channel-mode) mode set,
only channel operators may issue INVITE command.
Otherwise, the server MUST reject the command with the `ERR_CHANOPRIVSNEEDED`
numeric."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.sendLine(1, "MODE #chan +i")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "+i"],
)
self.sendLine(1, "MODE #chan -o foo")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "-o", "foo"],
)
self.sendLine(1, "INVITE bar #chan")
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):
"""
"if the channel exists, only members of the channel are allowed
to invite other users"
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
" 442 ERR_NOTONCHANNEL
"<channel> :You're not on that channel"
- Returned by the server whenever a client tries to
perform a channel affecting command for which the
client isn't a member.
"
-- https://datatracker.ietf.org/doc/html/rfc2812
" Only members of the channel are allowed to invite other users.
Otherwise, the server MUST reject the command with the `ERR_NOTONCHANNEL`
numeric."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.connectClient("baz")
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
# Create the channel
self.sendLine(3, "JOIN #chan")
self.getMessages(3)
self.sendLine(1, "INVITE bar #chan")
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(
messages,
[],
fail_msg="After using “INVITE #chan bar” while the emitter is "
"not in #chan, “bar” received something.",
)
@cases.mark_specifications("Modern")
def testInviteAlreadyInChannel(self):
"""
"If the user is already on the target channel,
the server MUST reject the command with the `ERR_USERONCHANNEL` numeric."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.sendLine(2, "JOIN #chan")
self.getMessages(1)
self.getMessages(2)
self.getMessages(1)
self.sendLine(1, "INVITE bar #chan")
self.assertMessageMatch(
self.getMessage(1),
command=ERR_USERONCHANNEL,
params=["foo", "bar", "#chan", ANYSTR],
)
@cases.mark_specifications("RFC2812", "Modern")
@cases.xfailIfSoftware(
["ircu2"],
"Uses 346/347 instead of 336/337 to reply to INVITE "
"https://github.com/UndernetIRC/ircu2/pull/20",
)
def testInviteList(self):
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.sendLine(1, "INVITE bar #chan")
self.getMessages(1)
self.getMessages(2)
self.sendLine(2, "INVITE")
m = self.getMessage(2)
if m.command == ERR_NEEDMOREPARAMS:
raise runner.OptionalExtensionNotSupported("INVITE with no parameter")
if m.command != "337":
# Hybrid always sends an empty list; so skip this.
self.assertMessageMatch(
m,
command="336",
params=["bar", "#chan"],
)
m = self.getMessage(2)
self.assertMessageMatch(
m,
command="337",
params=["bar", ANYSTR],
)
@cases.mark_isupport("INVEX")
@cases.mark_specifications("Modern")
def testInvexList(self):
self.connectClient("foo")
self.getMessages(1)
if "INVEX" in self.server_support:
invex = self.server_support.get("INVEX") or "I"
else:
raise runner.IsupportTokenNotSupported("INVEX")
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.sendLine(1, f"MODE #chan +{invex} bar!*@*")
self.getMessages(1)
self.sendLine(1, f"MODE #chan +{invex}")
m = self.getMessage(1)
if len(m.params) == 3:
# Old format
self.assertMessageMatch(
m,
command="346",
params=["foo", "#chan", "bar!*@*"],
)
else:
self.assertMessageMatch(
m,
command="346",
params=[
"foo",
"#chan",
"bar!*@*",
StrRe("foo(!.*@.*)?"),
StrRe("[0-9]+"),
],
)
self.assertMessageMatch(
self.getMessage(1),
command="347",
params=["foo", "#chan", ANYSTR],
)
@cases.mark_specifications("Ergo")
def testInviteExemptsFromBan(self):
# regression test for ergochat/ergo#1876;
# INVITE should override a +b ban
self.connectClient("alice", name="alice")
self.joinChannel("alice", "#alice")
self.sendLine("alice", "MODE #alice +b bob!*@*")
result = {msg.command for msg in self.getMessages("alice")}
self.assertIn("MODE", result)
self.connectClient("bob", name="bob")
self.sendLine("bob", "JOIN #alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn(ERR_BANNEDFROMCHAN, result)
self.assertNotIn("JOIN", result)
self.sendLine("alice", "INVITE bob #alice")
result = {msg.command for msg in self.getMessages("alice")}
self.assertIn(RPL_INVITING, result)
self.assertNotIn(ERR_USERONCHANNEL, result)
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn("INVITE", result)
self.sendLine("bob", "JOIN #alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertNotIn(ERR_BANNEDFROMCHAN, result)
self.assertIn("JOIN", result)
self.sendLine("alice", "KICK #alice bob")
self.getMessages("alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn("KICK", result)
# INVITE gets "used up" after one JOIN
self.sendLine("bob", "JOIN #alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn(ERR_BANNEDFROMCHAN, result)
self.assertNotIn("JOIN", result)

View File

@ -1,89 +0,0 @@
"""
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):
"""
"Format: TARGMAX=[<command>:[limit]{,<command>:[limit]}]"
-- https://modern.ircdocs.horse/#targmax-parameter
"TARGMAX=[cmd:[number][,cmd:[number][,...]]]"
-- https://defs.ircdocs.horse/defs/isupport.html#targmax
"""
self.connectClient("foo")
if "TARGMAX" not in self.server_support:
raise runner.IsupportTokenNotSupported("TARGMAX")
parts = self.server_support["TARGMAX"].split(",")
for part in parts:
self.assertTrue(
re.match("[A-Z]+:[0-9]*", part), "Invalid TARGMAX key:value: %r", part
)

View File

@ -1,238 +0,0 @@
"""
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):
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testJoinAllMessages(self):
"""“If a JOIN is successful, the user receives a JOIN message as
confirmation and is then sent the channel's topic (using RPL_TOPIC) and
the list of users who are on the channel (using RPL_NAMREPLY), which
MUST include the user joining.”
-- <https://tools.ietf.org/html/rfc2812#section-3.2.1>
“If a JOIN is successful, the user is then sent the channel's topic
(using RPL_TOPIC) and the list of users who are on the channel (using
RPL_NAMREPLY), which must include the user joining.”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.1>
"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
received_commands = {m.command for m in self.getMessages(1)}
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):
"""“353 RPL_NAMREPLY
"( "=" / "*" / "@" ) <channel>
:[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )”
-- <https://tools.ietf.org/html/rfc2812#section-5.2>
This test makes a user join and check what is sent to them.
"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
for m in self.getMessages(1):
if m.command == "353":
self.assertIn(
len(m.params),
(3, 4),
m,
fail_msg="RPL_NAM_REPLY with number of arguments "
"<3 or >4: {msg}",
)
params = ambiguities.normalize_namreply_params(m.params)
self.assertIn(
params[1],
"=*@",
m,
fail_msg="Bad channel prefix: {item} not in {list}: {msg}",
)
self.assertEqual(
params[2],
"#chan",
m,
fail_msg="Bad channel name: {got} instead of " "{expects}: {msg}",
)
self.assertIn(
params[3],
{"foo", "@foo", "+foo"},
m,
fail_msg="Bad user list: should contain only user "
'"foo" with an optional "+" or "@" prefix, but got: '
"{msg}",
)
def testJoinTwice(self):
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
m = self.getMessage(1)
self.assertMessageMatch(m, command="JOIN", params=["#chan"])
self.getMessages(1)
self.sendLine(1, "JOIN #chan")
# Note that there may be no message. Both RFCs require replies only
# if the join is successful, or has an error among the given set.
for m in self.getMessages(1):
if m.command == "353":
self.assertIn(
len(m.params),
(3, 4),
m,
fail_msg="RPL_NAM_REPLY with number of arguments "
"<3 or >4: {msg}",
)
params = ambiguities.normalize_namreply_params(m.params)
self.assertIn(
params[1],
"=*@",
m,
fail_msg="Bad channel prefix: {item} not in {list}: {msg}",
)
self.assertEqual(
params[2],
"#chan",
m,
fail_msg="Bad channel name: {got} instead of " "{expects}: {msg}",
)
self.assertIn(
params[3],
{"foo", "@foo", "+foo"},
m,
fail_msg='Bad user list after user "foo" joined twice '
"the same channel: should contain only user "
'"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,274 +0,0 @@
"""
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
from irctest.numerics import (
ERR_CHANOPRIVSNEEDED,
ERR_NOSUCHCHANNEL,
ERR_NOTONCHANNEL,
RPL_NAMREPLY,
)
from irctest.patma import ANYSTR
class KickTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testKickSendsMessages(self):
"""“Once a user has joined a channel, he receives information about
all commands his server receives affecting the channel. This
includes […] KICK”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.1>
and <https://tools.ietf.org/html/rfc2812#section-3.2.1>
"If a comment is given, this will be sent instead of the default message,
the nickname of the user targeted by the KICK."
-- https://modern.ircdocs.horse/#kick-message
"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
self.connectClient("baz")
self.joinChannel(3, "#chan")
# TODO: check foo is an operator
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "KICK #chan bar :bye")
try:
m = self.getMessage(1)
if m.command == "482":
raise runner.ImplementationChoice(
"Channel creators are not opped by default."
)
self.assertMessageMatch(m, command="KICK")
except client_mock.NoMessageException:
# The RFCs do not say KICK must be echoed
pass
m = self.getMessage(2)
self.assertMessageMatch(m, command="KICK", params=["#chan", "bar", "bye"])
m = self.getMessage(3)
self.assertMessageMatch(m, command="KICK", params=["#chan", "bar", "bye"])
def _testKickNoComment(self, check_default):
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
self.connectClient("baz")
self.joinChannel(3, "#chan")
# TODO: check foo is an operator
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "KICK #chan bar")
try:
m = self.getMessage(1)
if m.command == "482":
raise runner.ImplementationChoice(
"Channel creators are not opped by default."
)
self.assertMessageMatch(m, command="KICK")
except client_mock.NoMessageException:
# The RFCs do not say KICK must be echoed
pass
m2 = self.getMessage(2)
m3 = self.getMessage(3)
if check_default:
self.assertMessageMatch(m2, command="KICK", params=["#chan", "bar", "foo"])
self.assertMessageMatch(m3, command="KICK", params=["#chan", "bar", "foo"])
else:
self.assertMessageMatch(m2, command="KICK", params=["#chan", "bar", ANYSTR])
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
given, this will be sent instead of the default message, the nickname
of the user issuing the KICK."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.8
"""
self._testKickNoComment(check_default=True)
@cases.mark_specifications("Modern")
def testKickNoComment(self):
"""
"If no comment is given, the server SHOULD use a default message instead."
-- https://modern.ircdocs.horse/#kick-message
"""
self._testKickNoComment(check_default=False)
@cases.mark_specifications("RFC2812")
def testKickPrivileges(self):
"""Test who has the ability to kick / what error codes are sent
for invalid kicks."""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
messages = self.getMessages(2)
names = set()
for message in messages:
if message.command == RPL_NAMREPLY:
names.update(set(message.params[-1].split()))
# assert foo is opped
self.assertIn("@foo", names, f"unexpected names: {names}")
self.connectClient("baz")
self.sendLine(3, "KICK #chan bar")
replies = set(m.command for m in self.getMessages(3))
self.assertTrue(
ERR_NOTONCHANNEL in replies
or ERR_CHANOPRIVSNEEDED in replies
or ERR_NOSUCHCHANNEL in replies,
f"did not receive acceptable error code for kick from outside channel: "
f"{replies}",
)
self.joinChannel(3, "#chan")
self.getMessages(3)
self.sendLine(3, "KICK #chan bar")
replies = set(m.command for m in self.getMessages(3))
# now we're a channel member so we should receive ERR_CHANOPRIVSNEEDED
self.assertIn(ERR_CHANOPRIVSNEEDED, replies)
self.sendLine(1, "MODE #chan +o baz")
self.getMessages(1)
# should be able to kick an unprivileged user:
self.sendLine(3, "KICK #chan bar")
# should be able to kick an operator:
self.sendLine(3, "KICK #chan foo")
baz_replies = set(m.command for m in self.getMessages(3))
self.assertNotIn(ERR_CHANOPRIVSNEEDED, baz_replies)
kick_targets = [m.params[1] for m in self.getMessages(1) if m.command == "KICK"]
# foo should see bar and foo being kicked
self.assertTrue(
any(target.startswith("foo") for target in kick_targets),
f"unexpected kick targets: {kick_targets}",
)
self.assertTrue(
any(target.startswith("bar") for target in kick_targets),
f"unexpected kick targets: {kick_targets}",
)
@cases.mark_specifications("RFC2812")
def testKickNonexistentChannel(self):
"""“Kick command [...] Numeric replies: [...] ERR_NOSUCHCHANNEL."""
self.connectClient("nick")
self.connectClient("foo")
self.sendLine(1, "KICK #chan nick")
m = self.getMessage(1)
# should return ERR_NOSUCHCHANNEL
self.assertMessageMatch(m, command="403")
@pytest.mark.parametrize("multiple_targets", [True, False])
@cases.mark_specifications("RFC2812", "Modern", "ircdocs")
def testDoubleKickMessages(self, multiple_targets):
"""“The server MUST NOT send KICK messages with multiple channels or
users to clients. This is necessarily to maintain backward
compatibility with old client software.”
-- https://tools.ietf.org/html/rfc2812#section-3.2.8
"The server MUST NOT send KICK messages with multiple channels or
users to clients.
This is necessary to maintain backward compatibility with existing
client software."
-- https://modern.ircdocs.horse/#kick-message
"Servers MAY limit the number of target users per `KICK` command
via the [`TARGMAX` parameter of `RPL_ISUPPORT`](#targmax-parameter),
and silently drop targets if the number of targets exceeds the limit."
-- https://modern.ircdocs.horse/#kick-message
"If the "TARGMAX" parameter is not advertised or a value is not sent
then a client SHOULD assume that no commands except the "JOIN" and "PART"
commands accept multiple parameters."
-- https://defs.ircdocs.horse/defs/isupport.html#targmax
"If this parameter is not advertised or a value is not sent then a client
SHOULD assume that no commands except the `JOIN` and `PART` commands
accept multiple parameters."
-- https://github.com/ircdocs/modern-irc/pull/113
"If <limit> is not specified, then there is no maximum number of targets
for that command."
-- https://modern.ircdocs.horse/#targmax-parameter
"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
self.connectClient("baz")
self.joinChannel(3, "#chan")
self.connectClient("qux")
self.joinChannel(4, "#chan")
if self.targmax.get("KICK", "1") == "1":
raise runner.OptionalExtensionNotSupported("Multi-target KICK")
# TODO: check foo is an operator
# Synchronize
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.getMessages(4)
if multiple_targets:
self.sendLine(1, "KICK #chan,#chan bar,baz :bye")
else:
self.sendLine(1, "KICK #chan bar,baz :bye")
try:
m = self.getMessage(1)
if m.command == "482":
raise runner.OptionalExtensionNotSupported(
"Channel creators are not opped by default."
)
except client_mock.NoMessageException:
# The RFCs do not say KICK must be echoed
pass
mgroup = self.getMessages(4)
self.assertGreaterEqual(len(mgroup), 2, mgroup)
m1, m2 = mgroup[:2]
self.assertMessageMatch(m1, command="KICK", params=["#chan", ANYSTR, "bye"])
self.assertMessageMatch(m2, command="KICK", params=["#chan", ANYSTR, "bye"])
if (m1.params[1] == "bar" and m2.params[1] == "baz") or (
m1.params[1] == "baz" and m2.params[1] == "bar"
):
... # success
else:
raise AssertionError(
"Middle params [{}, {}] are not correct.".format(
m1.params[1], m2.params[1]
)
)

View File

@ -1,521 +0,0 @@
"""
`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.
"""
import re
import pytest
from irctest import cases
from irctest.numerics import ERR_UNKNOWNCOMMAND
from irctest.patma import ANYDICT, ANYOPTSTR, NotStrRe, RemainingKeys, StrRe
class LabeledResponsesTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToMultipleClients(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
self.connectClient(
"carl",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(3)
self.connectClient(
"alice",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(4)
self.sendLine(1, "@label=12345 PRIVMSG bar,carl,alice :hi")
m = self.getMessage(1)
m2 = self.getMessage(2)
m3 = self.getMessage(3)
m4 = self.getMessage(4)
# ensure the label isn't sent to recipients
self.assertMessageMatch(m2, command="PRIVMSG", tags={})
self.assertMessageMatch(
m3,
command="PRIVMSG",
tags={},
)
self.assertMessageMatch(m4, command="PRIVMSG", tags={})
self.assertMessageMatch(
m, command="BATCH", fail_msg="No BATCH echo received after sending one out"
)
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToClient(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
self.sendLine(1, "@label=12345 PRIVMSG bar :hi")
m = self.getMessage(1)
m2 = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(m2, command="PRIVMSG", tags={})
self.assertMessageMatch(m, command="PRIVMSG", tags={"label": "12345"})
@pytest.mark.react_tag
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToChannel(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# join channels
self.sendLine(1, "JOIN #test")
self.getMessages(1)
self.sendLine(2, "JOIN #test")
self.getMessages(2)
self.getMessages(1)
self.sendLine(
1, "@label=12345;+draft/reply=123;+draft/react=l😃l PRIVMSG #test :hi"
)
ms = self.getMessage(1)
mt = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(mt, command="PRIVMSG", tags={})
# ensure sender correctly receives msg
self.assertMessageMatch(ms, command="PRIVMSG", tags={"label": "12345"})
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToSelf(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345 PRIVMSG foo :hi")
m1 = self.getMessage(1)
m2 = self.getMessage(1)
number_of_labels = 0
for m in [m1, m2]:
self.assertMessageMatch(
m,
command="PRIVMSG",
fail_msg="Got a message back that wasn't a PRIVMSG",
)
if "label" in m.tags:
number_of_labels += 1
self.assertEqual(
m.tags["label"],
"12345",
m,
fail_msg=(
"Echo'd label doesn't match the label we sent "
"(should be '12345'): {msg}"
),
)
self.assertEqual(
number_of_labels,
1,
m1,
fail_msg=(
"When sending a PRIVMSG to self with echo-message, "
"we only expect one message to contain the label. "
"Instead, {} messages had the label"
).format(number_of_labels),
)
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledNoticeResponsesToClient(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
self.sendLine(1, "@label=12345 NOTICE bar :hi")
m = self.getMessage(1)
m2 = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(m2, command="NOTICE", tags={})
self.assertMessageMatch(m, command="NOTICE", tags={"label": "12345"})
@pytest.mark.react_tag
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledNoticeResponsesToChannel(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# join channels
self.sendLine(1, "JOIN #test")
self.getMessages(1)
self.sendLine(2, "JOIN #test")
self.getMessages(2)
self.getMessages(1)
self.sendLine(
1, "@label=12345;+draft/reply=123;+draft/react=l😃l NOTICE #test :hi"
)
ms = self.getMessage(1)
mt = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(mt, command="NOTICE", tags={})
# ensure sender correctly receives msg
self.assertMessageMatch(ms, command="NOTICE", tags={"label": "12345"})
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledNoticeResponsesToSelf(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345 NOTICE foo :hi")
m1 = self.getMessage(1)
m2 = self.getMessage(1)
number_of_labels = 0
for m in [m1, m2]:
self.assertMessageMatch(
m, command="NOTICE", fail_msg="Got a message back that wasn't a NOTICE"
)
if "label" in m.tags:
number_of_labels += 1
self.assertEqual(
m.tags["label"],
"12345",
m,
fail_msg=(
"Echo'd label doesn't match the label we sent "
"(should be '12345'): {msg}"
),
)
self.assertEqual(
number_of_labels,
1,
m1,
fail_msg=(
"When sending a NOTICE to self with echo-message, "
"we only expect one message to contain the label. "
"Instead, {} messages had the label"
).format(number_of_labels),
)
@pytest.mark.react_tag
@cases.mark_capabilities(
"echo-message", "batch", "labeled-response", "message-tags"
)
def testLabeledTagMsgResponsesToClient(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# Need to get a valid msgid because Unreal validates them
self.sendLine(1, "PRIVMSG bar :hi")
msgid = self.getMessage(1).tags["msgid"]
assert msgid == self.getMessage(2).tags["msgid"]
self.sendLine(
1, f"@label=12345;+draft/reply={msgid};+draft/react=l😃l TAGMSG bar"
)
m = self.getMessage(1)
m2 = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(
m2,
command="TAGMSG",
tags={
"+draft/reply": msgid,
"+draft/react": "l😃l",
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
},
)
self.assertNotIn(
"label",
m2.tags,
m2,
fail_msg=(
"When sending a TAGMSG with a label, "
"the target user shouldn't receive the label "
"(only the sending user should): {msg}"
),
)
self.assertMessageMatch(
m,
command="TAGMSG",
tags={
"label": "12345",
"+draft/reply": msgid,
"+draft/react": "l😃l",
**ANYDICT,
},
)
@pytest.mark.react_tag
@cases.mark_capabilities(
"echo-message", "batch", "labeled-response", "message-tags"
)
def testLabeledTagMsgResponsesToChannel(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# join channels
self.sendLine(1, "JOIN #test")
self.getMessages(1)
self.sendLine(2, "JOIN #test")
self.getMessages(2)
self.getMessages(1)
# Need to get a valid msgid because Unreal validates them
self.sendLine(1, "PRIVMSG #test :hi")
msgid = self.getMessage(1).tags["msgid"]
assert msgid == self.getMessage(2).tags["msgid"]
self.sendLine(
1, f"@label=12345;+draft/reply={msgid};+draft/react=l😃l TAGMSG #test"
)
ms = self.getMessage(1)
mt = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(
mt,
command="TAGMSG",
tags={
"+draft/reply": msgid,
"+draft/react": "l😃l",
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
},
fail_msg="No TAGMSG received by the target after sending one out",
)
self.assertNotIn(
"label",
mt.tags,
mt,
fail_msg=(
"When sending a TAGMSG with a label, "
"the target user shouldn't receive the label "
"(only the sending user should): {msg}"
),
)
# ensure sender correctly receives msg
self.assertMessageMatch(
ms,
command="TAGMSG",
tags={"label": "12345", "+draft/reply": msgid, **ANYDICT},
)
@pytest.mark.react_tag
@cases.mark_capabilities(
"echo-message", "batch", "labeled-response", "message-tags"
)
def testLabeledTagMsgResponsesToSelf(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345;+draft/reply=123;+draft/react=l😃l TAGMSG foo")
m1 = self.getMessage(1)
m2 = self.getMessage(1)
number_of_labels = 0
for m in [m1, m2]:
self.assertMessageMatch(
m, command="TAGMSG", fail_msg="Got a message back that wasn't a TAGMSG"
)
if "label" in m.tags:
number_of_labels += 1
self.assertEqual(
m.tags["label"],
"12345",
m,
fail_msg=(
"Echo'd label doesn't match the label we sent "
"(should be '12345'): {msg}"
),
)
self.assertEqual(
number_of_labels,
1,
m1,
fail_msg=(
"When sending a TAGMSG to self with echo-message, "
"we only expect one message to contain the label. "
"Instead, {} messages had the label"
).format(number_of_labels),
)
@cases.mark_capabilities("batch", "labeled-response", "message-tags", "server-time")
def testBatchedJoinMessages(self):
self.connectClient(
"bar",
capabilities=["batch", "labeled-response", "message-tags", "server-time"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345 JOIN #xyz")
m = self.getMessages(1)
# we expect at least join and names lines, which must be batched
self.assertGreaterEqual(len(m), 3)
# valid BATCH start line:
batch_start = m[0]
self.assertMessageMatch(
batch_start,
command="BATCH",
params=[StrRe(r"\+.*"), "labeled-response"],
)
batch_id = batch_start.params[0][1:]
# batch id MUST be alphanumerics and hyphens
self.assertTrue(
re.match(r"^[A-Za-z0-9\-]+$", batch_id) is not None,
"batch id must be alphanumerics and hyphens, got %r" % (batch_id,),
)
self.assertEqual(batch_start.tags.get("label"), "12345")
# valid BATCH end line
batch_end = m[-1]
self.assertMessageMatch(batch_end, command="BATCH", params=["-" + batch_id])
# messages must have the BATCH tag
for message in m[1:-1]:
self.assertEqual(message.tags.get("batch"), batch_id)
@cases.mark_capabilities("labeled-response")
def testNoBatchForSingleMessage(self):
self.connectClient(
"bar", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
)
self.getMessages(1)
self.sendLine(1, "@label=98765 PING adhoctestline")
# no BATCH should be initiated for a one-line response,
# it should just be labeled
m = self.getMessage(1)
self.assertMessageMatch(m, command="PONG", tags={"label": "98765"})
self.assertEqual(m.params[-1], "adhoctestline")
@cases.mark_capabilities("labeled-response")
def testEmptyBatchForNoResponse(self):
self.connectClient(
"bar", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
)
self.getMessages(1)
# PONG never receives a response
self.sendLine(1, "@label=98765 PONG adhoctestline")
# labeled-response: "Servers MUST respond with a labeled
# `ACK` message when a client sends a labeled command that normally
# produces no response."
ms = self.getMessages(1)
self.assertEqual(len(ms), 1)
ack = ms[0]
self.assertMessageMatch(ack, command="ACK", tags={"label": "98765"})
@cases.mark_capabilities("labeled-response")
def testUnknownCommand(self):
self.connectClient(
"bar", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
)
# this command doesn't exist, but the error response should still
# be labeled:
self.sendLine(1, "@label=deadbeef NONEXISTENT_COMMAND")
ms = self.getMessages(1)
self.assertEqual(len(ms), 1)
unknowncommand = ms[0]
self.assertMessageMatch(
unknowncommand, command=ERR_UNKNOWNCOMMAND, tags={"label": "deadbeef"}
)

View File

@ -1,136 +0,0 @@
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, [])

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