5 Commits

Author SHA1 Message Date
302dd33990 Merge branch 'master' into named-modes 2023-09-24 11:48:38 +02:00
50b9358ed0 whitelist unvendored mode names 2022-02-19 11:55:41 +01:00
2a62040b4f Add testManyListModes. 2022-02-19 11:55:41 +01:00
93f70c54d2 Skip on cap nak 2022-02-19 11:55:41 +01:00
da8a6d1f98 Initial tests for named-modes.
Only tested with my Insp module.
2022-02-19 11:55:41 +01:00
64 changed files with 1558 additions and 2047 deletions

View File

@ -13,10 +13,10 @@ jobs:
- uses: actions/checkout@v2 - uses: actions/checkout@v2
- name: Set up Python 3.11 - name: Set up Python 3.7
uses: actions/setup-python@v2 uses: actions/setup-python@v2
with: with:
python-version: 3.11 python-version: 3.7
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v2 uses: actions/cache@v2

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ jobs:
- name: Create directories - name: Create directories
run: cd ~/; mkdir -p .local/ go/ run: cd ~/; mkdir -p .local/ go/
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
key: 3-${{ runner.os }}-anope-devel_release key: 3-${{ runner.os }}-anope-devel_release
path: '~/.cache path: '~/.cache
@ -16,28 +16,28 @@ jobs:
${ github.workspace }/anope ${ github.workspace }/anope
' '
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v4
with: with:
python-version: 3.11 python-version: 3.11
- name: Checkout Anope - name: Checkout Anope
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
path: anope path: anope
ref: '2.0' ref: 2.0.9
repository: anope/anope repository: anope/anope
- name: Build Anope - name: Build Anope
run: | run: |
cd $GITHUB_WORKSPACE/anope/ cd $GITHUB_WORKSPACE/anope/
sudo apt-get install ninja-build --no-install-recommends cp $GITHUB_WORKSPACE/data/anope/* .
mkdir build && cd build CFLAGS=-O0 ./Config -quick
cmake -DCMAKE_INSTALL_PREFIX=$HOME/.local/ -DPROGRAM_NAME=anope -DUSE_PCH=ON -GNinja .. make -C build -j 4
ninja install make -C build install
- name: Make artefact tarball - name: Make artefact tarball
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/ run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
- name: Upload build artefacts - name: Upload build artefacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: installed-anope name: installed-anope
path: ~/artefacts-*.tar.gz path: ~/artefacts-*.tar.gz
@ -47,13 +47,13 @@ jobs:
steps: steps:
- name: Create directories - name: Create directories
run: cd ~/; mkdir -p .local/ go/ run: cd ~/; mkdir -p .local/ go/
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v4
with: with:
python-version: 3.11 python-version: 3.11
- name: Checkout InspIRCd - name: Checkout InspIRCd
uses: actions/checkout@v4 uses: actions/checkout@v3
with: with:
path: inspircd path: inspircd
ref: insp3 ref: insp3
@ -61,13 +61,19 @@ jobs:
- name: Build InspIRCd - name: Build InspIRCd
run: | run: |
cd $GITHUB_WORKSPACE/inspircd/ cd $GITHUB_WORKSPACE/inspircd/
# Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true
wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp
./configure --prefix=$HOME/.local/inspircd --development ./configure --prefix=$HOME/.local/inspircd --development
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4 CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install make install
- name: Make artefact tarball - name: Make artefact tarball
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/ run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
- name: Upload build artefacts - name: Upload build artefacts
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: installed-inspircd name: installed-inspircd
path: ~/artefacts-*.tar.gz path: ~/artefacts-*.tar.gz
@ -81,9 +87,9 @@ jobs:
- test-inspircd-atheme - test-inspircd-atheme
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Download Artifacts - name: Download Artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
path: artifacts path: artifacts
- name: Install dashboard dependencies - name: Install dashboard dependencies
@ -108,13 +114,13 @@ jobs:
- build-inspircd - build-inspircd
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v4
with: with:
python-version: 3.11 python-version: 3.11
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
name: installed-inspircd name: installed-inspircd
path: '~' path: '~'
@ -125,16 +131,14 @@ jobs:
- name: Install irctest dependencies - name: Install irctest dependencies
run: |- run: |-
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt pip install pytest pytest-xdist -r requirements.txt
- env: - name: Test with pytest
IRCTEST_DEBUG_LOGS: ${{ runner.debug }} run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
make inspircd make inspircd
timeout-minutes: 30 timeout-minutes: 30
- if: always() - if: always()
name: Publish results name: Publish results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: pytest-results_inspircd_devel_release name: pytest-results_inspircd_devel_release
path: pytest.xml path: pytest.xml
@ -144,18 +148,18 @@ jobs:
- build-anope - build-anope
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v4
with: with:
python-version: 3.11 python-version: 3.11
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
name: installed-inspircd name: installed-inspircd
path: '~' path: '~'
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
name: installed-anope name: installed-anope
path: '~' path: '~'
@ -166,16 +170,14 @@ jobs:
- name: Install irctest dependencies - name: Install irctest dependencies
run: |- run: |-
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt pip install pytest pytest-xdist -r requirements.txt
- env: - name: Test with pytest
IRCTEST_DEBUG_LOGS: ${{ runner.debug }} run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make
inspircd-anope inspircd-anope
timeout-minutes: 30 timeout-minutes: 30
- if: always() - if: always()
name: Publish results name: Publish results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: pytest-results_inspircd-anope_devel_release name: pytest-results_inspircd-anope_devel_release
path: pytest.xml path: pytest.xml
@ -184,13 +186,13 @@ jobs:
- build-inspircd - build-inspircd
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v5 uses: actions/setup-python@v4
with: with:
python-version: 3.11 python-version: 3.11
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v3
with: with:
name: installed-inspircd name: installed-inspircd
path: '~' path: '~'
@ -201,16 +203,14 @@ jobs:
- name: Install irctest dependencies - name: Install irctest dependencies
run: |- run: |-
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt pip install pytest pytest-xdist -r requirements.txt
- env: - name: Test with pytest
IRCTEST_DEBUG_LOGS: ${{ runner.debug }} run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
make inspircd-atheme make inspircd-atheme
timeout-minutes: 30 timeout-minutes: 30
- if: always() - if: always()
name: Publish results name: Publish results
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: pytest-results_inspircd-atheme_devel_release name: pytest-results_inspircd-atheme_devel_release
path: pytest.xml path: pytest.xml

File diff suppressed because it is too large Load Diff

View File

@ -83,17 +83,11 @@ LIMNORIA_SELECTORS := \
(foo or not foo) \ (foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
# Tests marked with arbitrary_client_tags or react_tag can't pass because Sable does not support client tags yet
# Tests marked with private_chathistory can't pass because Sable does not implement CHATHISTORY for DMs
SABLE_SELECTORS := \ SABLE_SELECTORS := \
not Ergo \ not Ergo \
and not deprecated \ and not deprecated \
and not strict \ and not strict \
and not arbitrary_client_tags \ and not whowas and not list and not lusers and not userhost and not time and not info \
and not react_tag \
and not private_chathistory \
and not list and not lusers and not time and not info \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
SOLANUM_SELECTORS := \ SOLANUM_SELECTORS := \
@ -262,6 +256,7 @@ sable:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sable \ --controller=irctest.controllers.sable \
-n 20 \ -n 20 \
-m 'not services' \
-k '$(SABLE_SELECTORS)' -k '$(SABLE_SELECTORS)'
solanum: solanum:

View File

@ -3,30 +3,16 @@
This project aims at testing interoperability of software using the This project aims at testing interoperability of software using the
IRC protocol, by running them against common test suites. IRC protocol, by running them against common test suites.
It is also used while editing [the "Modern" specification](https://modern.ircdocs.horse/)
to check behavior of a large selection of servers at once.
## The big picture ## The big picture
This project contains: This project contains:
* IRC protocol test cases, primarily checking conformance to * IRC protocol test cases
[the "Modern" specification](https://modern.ircdocs.horse/) and * small wrappers around existing software to run tests on them
[IRCv3 extensions](https://ircv3.net/irc/), but also
[RFC 1459](https://datatracker.ietf.org/doc/html/rfc1459) and
[RFC 2812](https://datatracker.ietf.org/doc/html/rfc2812).
Most of them are for servers but also some for clients.
Only the client-server protocol is tested; server-server protocols are out of scope.
* Small wrappers around existing software to run tests on them.
So far this is restricted to headless software (servers, service packages,
and clients bots).
Wrappers run software in temporary directories, so running `irctest` should Wrappers run software in temporary directories, so running `irctest` should
have no side effect. have no side effect.
Test results for the latest version of each supported software, and respective logs,
are [published daily](https://dashboard.irctest.limnoria.net/).
## Prerequisites ## Prerequisites
Install irctest and dependencies: Install irctest and dependencies:
@ -34,7 +20,7 @@ Install irctest and dependencies:
``` ```
sudo apt install faketime # Optional, but greatly speeds up irctest/server_tests/list.py sudo apt install faketime # Optional, but greatly speeds up irctest/server_tests/list.py
cd ~ cd ~
git clone https://github.com/progval/irctest.git git clone https://github.com/ProgVal/irctest.git
cd irctest cd irctest
pip3 install --user -r requirements.txt pip3 install --user -r requirements.txt
``` ```
@ -54,23 +40,18 @@ 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 be called by the `pytest` or `pytest-3` commands (if not, alias them if you
are planning to use them often). are planning to use them often).
After installing `pytest-xdist`, you can also pass `pytest` the `-n 10` option
to run `10` tests in parallel.
The rest of this README assumes `pytest` works. The rest of this README assumes `pytest` works.
## Test selection ## Test selection
A major feature of pytest that irctest heavily relies on is 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 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`. For example, you can run `LUSERS`-related tests with `-k lusers`.
Or only tests based on RFC1459 with `-k rfc1459`.
Using the `-m` option, you can select and deselect and them based on their markers
(listed in `pytest.ini`).
For example, you can run only tests based on RFC1459 with `-m rfc1459`.
By default, all tests run; even niche ones. So you probably always want to By default, all tests run; even niche ones. So you probably always want to
use these options: `-m 'not Ergo and not deprecated and not strict`. use these options: `-k 'not Ergo and not deprecated and not strict`.
This excludes: This excludes:
* `Ergo`-specific tests (included as Ergo uses irctest as its official * `Ergo`-specific tests (included as Ergo uses irctest as its official
@ -82,10 +63,6 @@ This excludes:
## Running tests ## Running tests
This list is non-exhaustive, see `workflows.yml` for software not listed here.
If software you want to test is not listed their either, please open an issue
or pull request to add support for it.
### Servers ### Servers
#### Ergo: #### Ergo:
@ -112,6 +89,20 @@ make install
pytest --controller irctest.controllers.solanum -k 'not Ergo and not deprecated and not strict' pytest --controller irctest.controllers.solanum -k 'not Ergo and not deprecated and not strict'
``` ```
#### 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: #### InspIRCd:
``` ```
@ -125,6 +116,9 @@ patch src/inspircd.cpp < ~/irctest/patches/inspircd_mainloop.patch
# on Insp3 >= 3.17.0 and Insp4 >= 4.0.0a22: # on Insp3 >= 3.17.0 and Insp4 >= 4.0.0a22:
export CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP export CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP
# third-party module, used in named-modes tests because the spec is not implemented upstream
wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp
./configure --prefix=$HOME/.local/ --development ./configure --prefix=$HOME/.local/ --development
make -j 4 make -j 4
make install make install
@ -132,6 +126,14 @@ cd ~/irctest
pytest --controller irctest.controllers.inspircd -k 'not Ergo and not deprecated and not strict' pytest --controller irctest.controllers.inspircd -k 'not Ergo and not deprecated and not strict'
``` ```
#### 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'
```
#### UnrealIRCd: #### UnrealIRCd:
``` ```
@ -148,8 +150,8 @@ pytest --controller irctest.controllers.unreal -k 'not Ergo and not deprecated a
### Servers with services ### Servers with services
Besides Ergo (that has built-in services) and Sable (that ships its own services), Besides Ergo (that has built-in services), most server controllers can optionally run
most server controllers can optionally run service packages. service packages.
#### Atheme: #### Atheme:

8
data/anope/config.cache Normal file
View File

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

View File

@ -11,20 +11,7 @@ import subprocess
import tempfile import tempfile
import textwrap import textwrap
import time import time
from typing import ( from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type
IO,
Any,
Callable,
Dict,
Iterator,
List,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
)
import irctest import irctest
@ -51,14 +38,6 @@ class TestCaseControllerConfig:
chathistory: bool = False chathistory: bool = False
"""Whether to enable chathistory features.""" """Whether to enable chathistory features."""
account_registration_before_connect: bool = False
"""Whether draft/account-registration should be allowed before completing
connection registration (NICK + USER + CAP END)"""
account_registration_requires_email: bool = False
"""Whether an email address must be provided when using draft/account-registration.
This does not imply servers must validate it."""
ergo_roleplay: bool = False ergo_roleplay: bool = False
"""Whether to enable the Ergo role-play commands.""" """Whether to enable the Ergo role-play commands."""
@ -87,7 +66,6 @@ class _BaseController:
_port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock") _port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock")
def __init__(self, test_config: TestCaseControllerConfig): def __init__(self, test_config: TestCaseControllerConfig):
self.debug_mode = os.getenv("IRCTEST_DEBUG_LOGS", "0").lower() in ("true", "1")
self.test_config = test_config self.test_config = test_config
self.proc = None self.proc = None
self._own_ports: Set[Tuple[str, int]] = set() self._own_ports: Set[Tuple[str, int]] = set()
@ -144,12 +122,6 @@ class _BaseController:
used_ports.remove((hostname, port)) used_ports.remove((hostname, port))
self._own_ports.remove((hostname, port)) self._own_ports.remove((hostname, port))
def execute(
self, command: Sequence[Union[str, Path]], **kwargs: Any
) -> subprocess.Popen:
output_to = None if self.debug_mode else subprocess.DEVNULL
return subprocess.Popen(command, stderr=output_to, stdout=output_to, **kwargs)
class DirectoryBasedController(_BaseController): class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an """Helper for controllers whose software configuration is based on an
@ -392,8 +364,6 @@ class BaseServicesController(_BaseController):
pass pass
elif msg.command in ("MODE", "221"): # RPL_UMODEIS elif msg.command in ("MODE", "221"): # RPL_UMODEIS
pass pass
elif msg.command == "396": # RPL_VISIBLEHOST
pass
elif msg.command == "NOTICE": elif msg.command == "NOTICE":
assert msg.prefix is not None assert msg.prefix is not None
if "!" not in msg.prefix and "." in msg.prefix: if "!" not in msg.prefix and "." in msg.prefix:

View File

@ -160,7 +160,6 @@ class _IrcTestCase(Generic[TController]):
def messageDiffers( def messageDiffers(
self, self,
msg: Message, msg: Message,
command: Union[str, None, patma.Operator] = None,
params: Optional[List[Union[str, None, patma.Operator]]] = None, params: Optional[List[Union[str, None, patma.Operator]]] = None,
target: Optional[str] = None, target: Optional[str] = None,
tags: Optional[ tags: Optional[
@ -187,14 +186,6 @@ class _IrcTestCase(Generic[TController]):
msg=msg, msg=msg,
) )
if command is not None and not patma.match_string(msg.command, command):
fail_msg = (
fail_msg or "expected command to match {expects}, got {got}: {msg}"
)
return fail_msg.format(
*extra_format, got=msg.command, expects=command, msg=msg
)
if prefix is not None and not patma.match_string(msg.prefix, prefix): if prefix is not None and not patma.match_string(msg.prefix, prefix):
fail_msg = ( fail_msg = (
fail_msg or "expected prefix to match {expects}, got {got}: {msg}" fail_msg or "expected prefix to match {expects}, got {got}: {msg}"
@ -223,7 +214,7 @@ class _IrcTestCase(Generic[TController]):
or "expected nick to be {expects}, got {got} instead: {msg}" or "expected nick to be {expects}, got {got} instead: {msg}"
) )
return fail_msg.format( return fail_msg.format(
*extra_format, got=got_nick, expects=nick, msg=msg *extra_format, got=got_nick, expects=nick, param=key, msg=msg
) )
return None return None

View File

@ -1,8 +1,7 @@
import functools
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess import subprocess
from typing import Tuple, Type from typing import Type
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
@ -49,8 +48,6 @@ module {{
client = "NickServ" client = "NickServ"
forceemail = no forceemail = no
passlen = 1000 # Some tests need long passwords passlen = 1000 # Some tests need long passwords
maxpasslen = 1000
minpasslen = 1
}} }}
command {{ service = "NickServ"; name = "HELP"; command = "generic/help"; }} command {{ service = "NickServ"; name = "HELP"; command = "generic/help"; }}
@ -66,28 +63,17 @@ options {{
warningtimeout = 4h warningtimeout = 4h
}} }}
module {{ name = "{module_prefix}sasl" }} module {{ name = "m_sasl" }}
module {{ name = "enc_bcrypt" }} module {{ name = "enc_sha256" }}
module {{ name = "ns_cert" }} module {{ name = "ns_cert" }}
""" """
@functools.lru_cache()
def installed_version() -> Tuple[int, ...]:
output = subprocess.run(
["anope", "--version"], stdout=subprocess.PIPE, universal_newlines=True
).stdout
(anope, version, *trailing) = output.split()[0].split("-")
assert anope == "Anope"
return tuple(map(int, version.split(".")))
class AnopeController(BaseServicesController, DirectoryBasedController): class AnopeController(BaseServicesController, DirectoryBasedController):
"""Collaborator for server controllers that rely on Anope""" """Collaborator for server controllers that rely on Anope"""
software_name = "Anope" software_name = "Anope"
software_version = None
def run(self, protocol: str, server_hostname: str, server_port: int) -> None: def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
self.create_config() self.create_config()
@ -102,53 +88,36 @@ class AnopeController(BaseServicesController, DirectoryBasedController):
"ngircd", "ngircd",
) )
assert self.directory
services_path = shutil.which("anope")
assert services_path
# Rewrite Anope 2.0 module names for 2.1
if not self.software_version:
self.software_version = installed_version()
if self.software_version >= (2, 1, 0):
if protocol == "charybdis":
protocol = "solanum"
elif protocol == "inspircd3":
protocol = "inspircd"
elif protocol == "unreal4":
protocol = "unrealircd"
with self.open_file("conf/services.conf") as fd: with self.open_file("conf/services.conf") as fd:
fd.write( fd.write(
TEMPLATE_CONFIG.format( TEMPLATE_CONFIG.format(
protocol=protocol, protocol=protocol,
server_hostname=server_hostname, server_hostname=server_hostname,
server_port=server_port, server_port=server_port,
module_prefix="" if self.software_version >= (2, 1, 2) else "m_",
) )
) )
with self.open_file("conf/empty_file") as fd: with self.open_file("conf/empty_file") as fd:
pass pass
assert self.directory
services_path = shutil.which("services")
assert services_path
# Config and code need to be in the same directory, *obviously* # Config and code need to be in the same directory, *obviously*
(self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib") (self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib")
(self.directory / "modules").symlink_to(
Path(services_path).parent.parent / "modules"
)
extra_args = [] self.proc = subprocess.Popen(
if self.debug_mode:
extra_args.append("--debug")
self.proc = self.execute(
[ [
"anope", "services",
"--config=services.conf", # can't be an absolute path in 2.0 "-n", # don't fork
"--nofork", # don't fork "--config=services.conf", # can't be an absolute path
"--nopid", # don't write a pid # "--logdir",
*extra_args, # f"/tmp/services-{server_port}.log",
], ],
cwd=self.directory, cwd=self.directory,
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
) )

View File

@ -1,3 +1,4 @@
import subprocess
from typing import Optional, Type from typing import Optional, Type
import irctest import irctest
@ -74,7 +75,7 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
) )
assert self.directory assert self.directory
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
"atheme-services", "atheme-services",
"-n", # don't fork "-n", # don't fork
@ -87,6 +88,8 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
"-D", "-D",
self.directory, self.directory,
], ],
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
) )
def registerUser( def registerUser(

View File

@ -1,5 +1,6 @@
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess
from typing import Optional, Set, Type from typing import Optional, Set, Type
from irctest.basecontrollers import BaseServerController, DirectoryBasedController from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -149,7 +150,7 @@ class BahamutController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"ircd", "ircd",

View File

@ -1,5 +1,5 @@
from pathlib import Path
import shutil import shutil
import subprocess
from typing import Optional from typing import Optional
from irctest.basecontrollers import BaseServerController, DirectoryBasedController from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -51,8 +51,6 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
) )
else: else:
ssl_config = "" ssl_config = ""
binary_path = shutil.which(self.binary_name)
assert binary_path, f"Could not find '{binary_path}' executable"
with self.open_file("server.conf") as fd: with self.open_file("server.conf") as fd:
fd.write( fd.write(
(self.template_config).format( (self.template_config).format(
@ -62,7 +60,6 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
services_port=services_port, services_port=services_port,
password_field=password_field, password_field=password_field,
ssl_config=ssl_config, ssl_config=ssl_config,
install_prefix=Path(binary_path).parent.parent,
) )
) )
assert self.directory assert self.directory
@ -73,7 +70,7 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
self.binary_name, self.binary_name,
@ -83,6 +80,7 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
"-pidfile", "-pidfile",
self.directory / "server.pid", self.directory / "server.pid",
], ],
# stderr=subprocess.DEVNULL,
) )
if run_services: if run_services:

View File

@ -200,7 +200,7 @@ class DlkController(BaseServicesController, DirectoryBasedController):
fd.write(TEMPLATE_DLK_WP_CONFIG.format(**template_vars)) fd.write(TEMPLATE_DLK_WP_CONFIG.format(**template_vars))
(dlk_conf_dir / "modules.conf").symlink_to(self.dlk_path / "conf/modules.conf") (dlk_conf_dir / "modules.conf").symlink_to(self.dlk_path / "conf/modules.conf")
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
"php", "php",
"src/dalek", "src/dalek",

View File

@ -14,7 +14,6 @@ BASE_CONFIG = {
"name": "My.Little.Server", "name": "My.Little.Server",
"listeners": {}, "listeners": {},
"max-sendq": "16k", "max-sendq": "16k",
"casemapping": "ascii",
"connection-limits": { "connection-limits": {
"enabled": True, "enabled": True,
"cidr-len-ipv4": 32, "cidr-len-ipv4": 32,
@ -58,11 +57,6 @@ BASE_CONFIG = {
"enabled": True, "enabled": True,
"method": "strict", "method": "strict",
}, },
"login-throttling": {
"enabled": True,
"duration": "1m",
"max-attempts": 3,
},
}, },
"channels": {"registration": {"enabled": True}}, "channels": {"registration": {"enabled": True}},
"datastore": {"path": None}, "datastore": {"path": None},
@ -172,16 +166,6 @@ class ErgoController(BaseServerController, DirectoryBasedController):
if enable_roleplay: if enable_roleplay:
config["roleplay"] = {"enabled": True} config["roleplay"] = {"enabled": True}
if self.test_config.account_registration_before_connect:
config["accounts"]["registration"]["allow-before-connect"] = True # type: ignore
if self.test_config.account_registration_requires_email:
config["accounts"]["registration"]["email-verification"] = { # type: ignore
"enabled": True,
"sender": "test@example.com",
"require-tls": True,
"helo-domain": "example.com",
}
if self.test_config.ergo_config: if self.test_config.ergo_config:
self.test_config.ergo_config(config) self.test_config.ergo_config(config)
@ -213,7 +197,7 @@ class ErgoController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"] [*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
) )

View File

@ -1,3 +1,4 @@
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest import authentication, tls from irctest import authentication, tls
@ -30,7 +31,7 @@ class GircController(BaseClientController, DirectoryBasedController):
args += ["--sasl-fail-is-ok"] args += ["--sasl-fail-is-ok"]
# Runs a client with the config given as arguments # Runs a client with the config given as arguments
self.proc = self.execute(["girc_test", "connect"] + args) self.proc = subprocess.Popen(["girc_test", "connect"] + args)
def get_irctest_controller_class() -> Type[GircController]: def get_irctest_controller_class() -> Type[GircController]:

View File

@ -3,9 +3,6 @@ from typing import Set, Type
from .base_hybrid import BaseHybridController from .base_hybrid import BaseHybridController
TEMPLATE_CONFIG = """ TEMPLATE_CONFIG = """
module_base_path = "{install_prefix}/lib/ircd-hybrid/modules";
.include "./reference.modules.conf"
serverinfo {{ serverinfo {{
name = "My.Little.Server"; name = "My.Little.Server";
sid = "42X"; sid = "42X";

View File

@ -48,7 +48,9 @@ TEMPLATE_CONFIG = """
sendpass="password" sendpass="password"
> >
<module name="spanningtree"> <module name="spanningtree">
<module name="services_account">
<module name="hidechans"> # Anope errors when missing <module name="hidechans"> # Anope errors when missing
<module name="svshold"> # Atheme raises a warning when missing
<sasl requiressl="no" <sasl requiressl="no"
target="services.example.org"> target="services.example.org">
@ -66,13 +68,18 @@ TEMPLATE_CONFIG = """
<module name="ircv3_invitenotify"> <module name="ircv3_invitenotify">
<module name="ircv3_labeledresponse"> <module name="ircv3_labeledresponse">
<module name="ircv3_msgid"> <module name="ircv3_msgid">
<module name="ircv3_namedmodes"> # third-party, https://github.com/progval/inspircd-contrib/blob/namedmodes/4.0/m_ircv3_namedmodes.cpp
<module name="ircv3_servertime"> <module name="ircv3_servertime">
<module name="monitor"> <module name="monitor">
<module name="m_muteban"> # for testing mute extbans <module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl"> <module name="sasl">
<module name="uhnames"> # For userhost-in-names <module name="uhnames"> # For userhost-in-names
# HELP/HELPOP
<module name="alias"> # for the HELP alias <module name="alias"> # for the HELP alias
{version_config} <module name="{help_module_name}">
<include file="examples/{help_module_name}.conf.example">
# Misc: # Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log"> <log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
@ -84,26 +91,6 @@ TEMPLATE_SSL_CONFIG = """
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1"> <openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
""" """
TEMPLATE_V3_CONFIG = """
<module name="namesx"> # For multi-prefix
<module name="services_account">
<module name="svshold"> # Atheme raises a warning when missing
# HELP/HELPOP
<module name="helpop">
<include file="examples/helpop.conf.example">
"""
TEMPLATE_V4_CONFIG = """
<module name="account">
<module name="multiprefix"> # For multi-prefix
<module name="services">
# HELP/HELPOP
<module name="help">
<include file="examples/help.example.conf">
"""
@functools.lru_cache() @functools.lru_cache()
def installed_version() -> int: def installed_version() -> int:
@ -112,14 +99,12 @@ def installed_version() -> int:
return 3 return 3
if output.startswith("InspIRCd-4"): if output.startswith("InspIRCd-4"):
return 4 return 4
if output.startswith("InspIRCd-5"): else:
return 5
assert False, f"unexpected version: {output}" assert False, f"unexpected version: {output}"
class InspircdController(BaseServerController, DirectoryBasedController): class InspircdController(BaseServerController, DirectoryBasedController):
software_name = "InspIRCd" software_name = "InspIRCd"
software_version = installed_version()
supported_sasl_mechanisms = {"PLAIN"} supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False supports_sts = False
extban_mute_char = "m" extban_mute_char = "m"
@ -156,9 +141,9 @@ class InspircdController(BaseServerController, DirectoryBasedController):
ssl_config = "" ssl_config = ""
if installed_version() == 3: if installed_version() == 3:
version_config = TEMPLATE_V3_CONFIG help_module_name = "helpop"
elif installed_version() >= 4: elif installed_version() == 4:
version_config = TEMPLATE_V4_CONFIG help_module_name = "help"
else: else:
assert False, f"unexpected version: {installed_version()}" assert False, f"unexpected version: {installed_version()}"
@ -171,7 +156,7 @@ class InspircdController(BaseServerController, DirectoryBasedController):
services_port=services_port, services_port=services_port,
password_field=password_field, password_field=password_field,
ssl_config=ssl_config, ssl_config=ssl_config,
version_config=version_config, help_module_name=help_module_name,
) )
) )
assert self.directory assert self.directory
@ -182,22 +167,15 @@ class InspircdController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
extra_args = [] self.proc = subprocess.Popen(
if self.debug_mode:
if installed_version() >= 4:
extra_args.append("--protocoldebug")
else:
extra_args.append("--debug")
self.proc = self.execute(
[ [
*faketime_cmd, *faketime_cmd,
"inspircd", "inspircd",
"--nofork", "--nofork",
"--config", "--config",
self.directory / "server.conf", self.directory / "server.conf",
*extra_args,
], ],
stdout=subprocess.DEVNULL,
) )
if run_services: if run_services:

View File

@ -1,4 +1,5 @@
import shutil import shutil
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest.basecontrollers import ( from irctest.basecontrollers import (
@ -77,7 +78,7 @@ class Irc2Controller(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"ircd", "ircd",
@ -87,6 +88,7 @@ class Irc2Controller(BaseServerController, DirectoryBasedController):
"-f", "-f",
self.directory / "server.conf", self.directory / "server.conf",
], ],
# stderr=subprocess.DEVNULL,
) )

View File

@ -1,4 +1,5 @@
import shutil import shutil
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest.basecontrollers import ( from irctest.basecontrollers import (
@ -96,7 +97,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"ircd", "ircd",
@ -106,6 +107,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
"-x", "-x",
"DEBUG", "DEBUG",
], ],
# stderr=subprocess.DEVNULL,
) )

View File

@ -1,3 +1,4 @@
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest import authentication, tls from irctest import authentication, tls
@ -83,7 +84,7 @@ class LimnoriaController(BaseClientController, DirectoryBasedController):
) )
) )
assert self.directory assert self.directory
self.proc = self.execute(["supybot", self.directory / "bot.conf"]) self.proc = subprocess.Popen(["supybot", self.directory / "bot.conf"])
def get_irctest_controller_class() -> Type[LimnoriaController]: def get_irctest_controller_class() -> Type[LimnoriaController]:

View File

@ -1,4 +1,5 @@
import shutil import shutil
import subprocess
from typing import Optional, Set, Type from typing import Optional, Set, Type
from irctest.basecontrollers import ( from irctest.basecontrollers import (
@ -115,7 +116,7 @@ class MammonController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"mammond", "mammond",

View File

@ -1,4 +1,5 @@
import shutil import shutil
import subprocess
from typing import Optional, Set, Type from typing import Optional, Set, Type
from irctest.basecontrollers import BaseServerController, DirectoryBasedController from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -22,14 +23,10 @@ TEMPLATE_CONFIG = """
[Options] [Options]
MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
PAM = no
[Operator] [Operator]
Name = operuser Name = operuser
Password = operpassword Password = operpassword
[Limits]
MaxNickLength = 32 # defaults to 9
""" """
@ -94,7 +91,7 @@ class NgircdController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"ngircd", "ngircd",
@ -102,6 +99,7 @@ class NgircdController(BaseServerController, DirectoryBasedController):
"--config", "--config",
self.directory / "server.conf", self.directory / "server.conf",
], ],
# stdout=subprocess.DEVNULL,
) )
if run_services: if run_services:

View File

@ -116,7 +116,20 @@ NETWORK_CONFIG_CONFIG = """
], ],
"alias_users": [ "alias_users": [
%(services_alias_users)s {
"nick": "ChanServ",
"user": "ChanServ",
"host": "services.",
"realname": "Channel services compatibility layer",
"command_alias": "CS"
},
{
"nick": "NickServ",
"user": "NickServ",
"host": "services.",
"realname": "Account services compatibility layer",
"command_alias": "NS"
}
], ],
"default_roles": { "default_roles": {
@ -147,23 +160,6 @@ NETWORK_CONFIG_CONFIG = """
} }
""" """
SERVICES_ALIAS_USERS = """
{
"nick": "ChanServ",
"user": "ChanServ",
"host": "services.",
"realname": "Channel services compatibility layer",
"command_alias": "CS"
},
{
"nick": "NickServ",
"user": "NickServ",
"host": "services.",
"realname": "Account services compatibility layer",
"command_alias": "NS"
}
"""
SERVER_CONFIG = """ SERVER_CONFIG = """
{ {
"server_id": 1, "server_id": 1,
@ -260,13 +256,7 @@ SERVICES_CONFIG = """
"builtin:voice": [ "builtin:voice": [
"always_send", "voice_self", "receive_voice" "always_send", "voice_self", "receive_voice"
] ]
}, }
"password_hash": {
"algorithm": "bcrypt", // Only "bcrypt" is supported for now
"cost": 4, // Exponentially faster than the default 12
},
}, },
"event_log": { "event_log": {
@ -324,11 +314,6 @@ class SableController(BaseServerController, DirectoryBasedController):
raise NotImplementedByController("PASS command") raise NotImplementedByController("PASS command")
if ssl: if ssl:
raise NotImplementedByController("SSL") raise NotImplementedByController("SSL")
if self.test_config.account_registration_before_connect:
raise NotImplementedByController("account-registration with before-connect")
if self.test_config.account_registration_requires_email:
raise NotImplementedByController("account-registration with email-required")
assert self.proc is None assert self.proc is None
self.port = port self.port = port
self.create_config() self.create_config()
@ -378,7 +363,6 @@ class SableController(BaseServerController, DirectoryBasedController):
.strip(), .strip(),
services_management_hostname=services_management_hostname, services_management_hostname=services_management_hostname,
services_management_port=services_management_port, services_management_port=services_management_port,
services_alias_users=SERVICES_ALIAS_USERS if run_services else "",
) )
with self.open_file("configs/network.conf") as fd: with self.open_file("configs/network.conf") as fd:
@ -394,7 +378,7 @@ class SableController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"sable_ircd", "sable_ircd",
@ -408,7 +392,6 @@ class SableController(BaseServerController, DirectoryBasedController):
], ],
cwd=self.directory, cwd=self.directory,
preexec_fn=os.setsid, preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
) )
self.pgroup_id = os.getpgid(self.proc.pid) self.pgroup_id = os.getpgid(self.proc.pid)
@ -475,7 +458,7 @@ class SableServicesController(BaseServicesController):
with self.server_controller.open_file("configs/services.conf") as fd: with self.server_controller.open_file("configs/services.conf") as fd:
fd.write(SERVICES_CONFIG % self.server_controller.template_vars) fd.write(SERVICES_CONFIG % self.server_controller.template_vars)
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
"sable_services", "sable_services",
"--foreground", "--foreground",
@ -486,7 +469,6 @@ class SableServicesController(BaseServicesController):
], ],
cwd=self.server_controller.directory, cwd=self.server_controller.directory,
preexec_fn=os.setsid, preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
) )
self.pgroup_id = os.getpgid(self.proc.pid) self.pgroup_id = os.getpgid(self.proc.pid)

View File

@ -1,4 +1,5 @@
import shutil import shutil
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest.basecontrollers import ( from irctest.basecontrollers import (
@ -95,7 +96,7 @@ class SnircdController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"ircd", "ircd",
@ -105,6 +106,7 @@ class SnircdController(BaseServerController, DirectoryBasedController):
"-x", "-x",
"DEBUG", "DEBUG",
], ],
# stderr=subprocess.DEVNULL,
) )

View File

@ -1,4 +1,5 @@
from pathlib import Path from pathlib import Path
import subprocess
import tempfile import tempfile
from typing import Optional, TextIO, Type, cast from typing import Optional, TextIO, Type, cast
@ -72,7 +73,7 @@ class SopelController(BaseClientController):
auth_method="auth_method = sasl" if auth else "", auth_method="auth_method = sasl" if auth else "",
) )
) )
self.proc = self.execute(["sopel", "-c", self.filename]) self.proc = subprocess.Popen(["sopel", "-c", self.filename])
def get_irctest_controller_class() -> Type[SopelController]: def get_irctest_controller_class() -> Type[SopelController]:

View File

@ -1,5 +1,6 @@
import json import json
import os import os
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest import authentication, tls from irctest import authentication, tls
@ -95,7 +96,7 @@ class TheLoungeController(BaseClientController, DirectoryBasedController):
) )
with self.open_file("users/testuser.json", "r") as fd: with self.open_file("users/testuser.json", "r") as fd:
print("config", json.load(fd)["networks"][0]["saslPassword"]) print("config", json.load(fd)["networks"][0]["saslPassword"])
self.proc = self.execute( self.proc = subprocess.Popen(
[os.environ.get("THELOUNGE_BIN", "thelounge"), "start"], [os.environ.get("THELOUNGE_BIN", "thelounge"), "start"],
env={**os.environ, "THELOUNGE_HOME": str(self.directory)}, env={**os.environ, "THELOUNGE_HOME": str(self.directory)},
) )

View File

@ -261,7 +261,7 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
faketime_cmd = [] faketime_cmd = []
with _STARTSTOP_LOCK(): with _STARTSTOP_LOCK():
self.proc = self.execute( self.proc = subprocess.Popen(
[ [
*faketime_cmd, *faketime_cmd,
"unrealircd", "unrealircd",
@ -270,6 +270,7 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
"-f", "-f",
self.directory / "unrealircd.conf", self.directory / "unrealircd.conf",
], ],
# stdout=subprocess.DEVNULL,
) )
self.wait_for_port() self.wait_for_port()

View File

@ -245,19 +245,8 @@ def build_test_table(
# TODO: only hash test parameter # TODO: only hash test parameter
row_anchor = md5sum(row_anchor) row_anchor = md5sum(row_anchor)
doc = docstring(
getattr(getattr(module, class_name), test_name.split("[")[0])
)
row = HTML.tr( row = HTML.tr(
HTML.th( HTML.th(HTML.a(test_name, href=f"#{row_anchor}"), class_="test-name"),
HTML.details(
HTML.summary(HTML.a(test_name, href=f"#{row_anchor}")),
doc,
)
if doc
else HTML.a(test_name, href=f"#{row_anchor}"),
class_="test-name",
),
id=row_anchor, id=row_anchor,
) )
rows.append(row) rows.append(row)

View File

@ -0,0 +1,23 @@
"""
Handles ambiguities of RFCs.
"""
from typing import List
def normalize_namreply_params(params: List[str]) -> List[str]:
# So… RFC 2812 says:
# "( "=" / "*" / "@" ) <channel>
# :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )
# but spaces seem to be missing (eg. before the colon), so we
# don't know if there should be one before the <channel> and its
# 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])
params[2] = params[2][1:]
params[3] = params[3].rstrip()
return params

View File

@ -4,15 +4,16 @@ commonly packaged by Linux distributions but might not be available
in some environments. in some environments.
""" """
import contextlib
import os import os
from typing import Any, ContextManager from typing import ContextManager
if os.getenv("PYTEST_XDIST_WORKER"): if os.getenv("PYTEST_XDIST_WORKER"):
# running under pytest-xdist; filelock is required for reliability # running under pytest-xdist; filelock is required for reliability
from filelock import FileLock from filelock import FileLock
else: else:
# normal test execution, no port races # normal test execution, no port races
import contextlib
from typing import Any
def FileLock(*args: Any, **kwargs: Any) -> ContextManager[None]: def FileLock(*args: Any, **kwargs: Any) -> ContextManager[None]:
return contextlib.nullcontext() return contextlib.nullcontext()

View File

@ -15,7 +15,7 @@ TAG_ESCAPE = [
unescape_tag_value = MultipleReplacer(dict(map(lambda x: (x[1], x[0]), TAG_ESCAPE))) unescape_tag_value = MultipleReplacer(dict(map(lambda x: (x[1], x[0]), TAG_ESCAPE)))
# TODO: validate host # TODO: validate host
tag_key_validator = re.compile(r"^\+?(\S+/)?[a-zA-Z0-9-]+$") tag_key_validator = re.compile(r"\+?(\S+/)?[a-zA-Z0-9-]+")
def parse_tags(s: str) -> Dict[str, Optional[str]]: def parse_tags(s: str) -> Dict[str, Optional[str]]:

View File

@ -204,5 +204,11 @@ ERR_ACCOUNT_INVALID_VERIFY_CODE = "925"
RPL_REG_VERIFICATION_REQUIRED = "927" RPL_REG_VERIFICATION_REQUIRED = "927"
ERR_REG_INVALID_CRED_TYPE = "928" ERR_REG_INVALID_CRED_TYPE = "928"
ERR_REG_INVALID_CALLBACK = "929" ERR_REG_INVALID_CALLBACK = "929"
RPL_ENDOFPROPLIST = "960"
RPL_PROPLIST = "961"
RPL_ENDOFLISTPROPLIST = "962"
RPL_LISTPROPLIST = "963"
RPL_CHMODELIST = "964"
RPL_UMODELIST = "965"
ERR_TOOMANYLANGUAGES = "981" ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982" ERR_NOLANGUAGE = "982"

View File

@ -1,7 +1,6 @@
"""Pattern-matching utilities""" """Pattern-matching utilities"""
import dataclasses import dataclasses
import itertools
import re import re
from typing import Dict, List, Optional, Union from typing import Dict, List, Optional, Union
@ -28,14 +27,6 @@ class _AnyOptStr(Operator):
return "ANYOPTSTR" return "ANYOPTSTR"
@dataclasses.dataclass(frozen=True)
class OptStrRe(Operator):
regexp: str
def __repr__(self) -> str:
return f"OptStrRe(r'{self.regexp}')"
@dataclasses.dataclass(frozen=True) @dataclasses.dataclass(frozen=True)
class StrRe(Operator): class StrRe(Operator):
regexp: str regexp: str
@ -106,15 +97,10 @@ def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bo
elif isinstance(expected, _AnyStr) and got is not None: elif isinstance(expected, _AnyStr) and got is not None:
return True return True
elif isinstance(expected, StrRe): elif isinstance(expected, StrRe):
if got is None or not re.match(expected.regexp + "$", got): if got is None or not re.match(expected.regexp, got):
return False
elif isinstance(expected, OptStrRe):
if got is None:
return True
if not re.match(expected.regexp + "$", got):
return False return False
elif isinstance(expected, NotStrRe): elif isinstance(expected, NotStrRe):
if got is None or re.match(expected.regexp + "$", got): if got is None or re.match(expected.regexp, got):
return False return False
elif isinstance(expected, InsensitiveStr): elif isinstance(expected, InsensitiveStr):
if got is None or got.lower() != expected.string.lower(): if got is None or got.lower() != expected.string.lower():
@ -142,19 +128,11 @@ def match_list(
nb_remaining_items = len(got) - len(expected) nb_remaining_items = len(got) - len(expected)
expected += [remainder.item] * max(nb_remaining_items, remainder.min_length) expected += [remainder.item] * max(nb_remaining_items, remainder.min_length)
nb_optionals = 0 if len(got) != len(expected):
for expected_value in expected:
if isinstance(expected_value, (_AnyOptStr, OptStrRe)):
nb_optionals += 1
else:
if nb_optionals > 0:
raise NotImplementedError("Optional values in non-final position")
if not (len(expected) - nb_optionals <= len(got) <= len(expected)):
return False return False
return all( return all(
match_string(got_value, expected_value) match_string(got_value, expected_value)
for (got_value, expected_value) in itertools.zip_longest(got, expected) for (got_value, expected_value) in zip(got, expected)
) )

View File

@ -13,7 +13,6 @@ from irctest.patma import (
ANYSTR, ANYSTR,
ListRemainder, ListRemainder,
NotStrRe, NotStrRe,
OptStrRe,
RemainingKeys, RemainingKeys,
StrRe, StrRe,
) )
@ -173,7 +172,7 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
], ],
# and they each error with: # and they each error with:
[ [
"expected command to match PRIVMSG, got PRIVMG", "expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}", "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 ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']", "expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
@ -206,7 +205,7 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
], ],
# and they each error with: # and they each error with:
[ [
"expected command to match PRIVMSG, got PRIVMG", "expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {StrRe(r'tag[12]'): 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}", "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 ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']", "expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
@ -235,34 +234,12 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
], ],
# and they each error with: # and they each error with:
[ [
"expected command to match PRIVMSG, got PRIVMG", "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': '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': ''}",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': 'baz'}", "expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': 'baz'}",
] ]
), ),
(
# the specification:
dict(
command="004",
params=["nick", "...", OptStrRe("[a-zA-Z]+")],
),
# matches:
[
"004 nick ... abc",
"004 nick ...",
],
# and does not match:
[
"004 nick ... 123",
"004 nick ... :",
],
# and they each error with:
[
"expected params to match ['nick', '...', OptStrRe(r'[a-zA-Z]+')], got ['nick', '...', '123']",
"expected params to match ['nick', '...', OptStrRe(r'[a-zA-Z]+')], got ['nick', '...', '']",
]
),
( (
# the specification: # the specification:
dict( dict(
@ -345,7 +322,7 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
], ],
# and they each error with: # and they each error with:
[ [
"expected command to match PING, got PONG" "expected command to be PING, got PONG"
] ]
), ),
] ]

View File

@ -9,75 +9,15 @@ from irctest.patma import ANYSTR
REGISTER_CAP_NAME = "draft/account-registration" REGISTER_CAP_NAME = "draft/account-registration"
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterTestCase(cases.BaseServerTestCase):
def testRegisterDefaultName(self):
"""
"If <account> is *, then this value is the users current nickname."
"""
self.connectClient(
"bar", name="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.assertNotIn("email-required", (caps[REGISTER_CAP_NAME] or "").split(","))
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])
def testRegisterSameName(self):
"""
Requested account name is the same as the nick
"""
self.connectClient(
"bar", name="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.assertNotIn("email-required", (caps[REGISTER_CAP_NAME] or "").split(","))
self.sendLine("bar", "REGISTER bar * 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])
def testRegisterDifferentName(self):
"""
Requested account name differs from the nick
"""
self.connectClient(
"bar", name="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.assertNotIn("email-required", (caps[REGISTER_CAP_NAME] or "").split(","))
self.sendLine("bar", "REGISTER foo * shivarampassphrase")
if "custom-account-name" in (caps[REGISTER_CAP_NAME] or "").split(","):
msgs = self.getMessages("bar")
register_response = [msg for msg in msgs if msg.command == "REGISTER"][0]
self.assertMessageMatch(
register_response, params=["SUCCESS", ANYSTR, ANYSTR]
)
else:
self.assertMessageMatch(
self.getMessage("bar"),
command="FAIL",
params=["REGISTER", "ACCOUNT_NAME_MUST_BE_NICK", "foo", ANYSTR],
)
@cases.mark_services @cases.mark_services
@cases.mark_specifications("IRCv3") @cases.mark_specifications("IRCv3")
class RegisterBeforeConnectTestCase(cases.BaseServerTestCase): class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
@staticmethod @staticmethod
def config() -> cases.TestCaseControllerConfig: def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig( return cases.TestCaseControllerConfig(
account_registration_requires_email=False, ergo_config=lambda config: config["accounts"]["registration"].update(
account_registration_before_connect=True, {"allow-before-connect": True}
)
) )
def testBeforeConnect(self): def testBeforeConnect(self):
@ -86,7 +26,7 @@ class RegisterBeforeConnectTestCase(cases.BaseServerTestCase):
self.sendLine("bar", "CAP LS 302") self.sendLine("bar", "CAP LS 302")
caps = self.getCapLs("bar") caps = self.getCapLs("bar")
self.assertIn(REGISTER_CAP_NAME, caps) self.assertIn(REGISTER_CAP_NAME, caps)
self.assertIn("before-connect", caps[REGISTER_CAP_NAME] or "") self.assertIn("before-connect", caps[REGISTER_CAP_NAME])
self.sendLine("bar", "NICK bar") self.sendLine("bar", "NICK bar")
self.sendLine("bar", "REGISTER * * shivarampassphrase") self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar") msgs = self.getMessages("bar")
@ -100,8 +40,9 @@ class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
@staticmethod @staticmethod
def config() -> cases.TestCaseControllerConfig: def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig( return cases.TestCaseControllerConfig(
account_registration_requires_email=False, ergo_config=lambda config: config["accounts"]["registration"].update(
account_registration_before_connect=False, {"allow-before-connect": False}
)
) )
def testBeforeConnect(self): def testBeforeConnect(self):
@ -110,7 +51,7 @@ class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
self.sendLine("bar", "CAP LS 302") self.sendLine("bar", "CAP LS 302")
caps = self.getCapLs("bar") caps = self.getCapLs("bar")
self.assertIn(REGISTER_CAP_NAME, caps) self.assertIn(REGISTER_CAP_NAME, caps)
self.assertNotIn("before-connect", caps[REGISTER_CAP_NAME] or "") self.assertEqual(caps[REGISTER_CAP_NAME], None)
self.sendLine("bar", "NICK bar") self.sendLine("bar", "NICK bar")
self.sendLine("bar", "REGISTER * * shivarampassphrase") self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar") msgs = self.getMessages("bar")
@ -123,12 +64,21 @@ class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase):
@cases.mark_services @cases.mark_services
@cases.mark_specifications("IRCv3") @cases.mark_specifications("IRCv3")
class RegisterEmailVerifiedBeforeConnectTestCase(cases.BaseServerTestCase): class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase):
@staticmethod @staticmethod
def config() -> cases.TestCaseControllerConfig: def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig( return cases.TestCaseControllerConfig(
account_registration_requires_email=True, ergo_config=lambda config: config["accounts"]["registration"].update(
account_registration_before_connect=True, {
"email-verification": {
"enabled": True,
"sender": "test@example.com",
"require-tls": True,
"helo-domain": "example.com",
},
"allow-before-connect": True,
}
)
) )
def testBeforeConnect(self): def testBeforeConnect(self):
@ -139,8 +89,10 @@ class RegisterEmailVerifiedBeforeConnectTestCase(cases.BaseServerTestCase):
self.sendLine("bar", "CAP LS 302") self.sendLine("bar", "CAP LS 302")
caps = self.getCapLs("bar") caps = self.getCapLs("bar")
self.assertIn(REGISTER_CAP_NAME, caps) self.assertIn(REGISTER_CAP_NAME, caps)
self.assertIn("email-required", caps[REGISTER_CAP_NAME] or "") self.assertEqual(
self.assertIn("before-connect", caps[REGISTER_CAP_NAME] or "") set(caps[REGISTER_CAP_NAME].split(",")),
{"before-connect", "email-required"},
)
self.sendLine("bar", "NICK bar") self.sendLine("bar", "NICK bar")
self.sendLine("bar", "REGISTER * * shivarampassphrase") self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar") msgs = self.getMessages("bar")
@ -149,25 +101,10 @@ class RegisterEmailVerifiedBeforeConnectTestCase(cases.BaseServerTestCase):
fail_response, params=["REGISTER", "INVALID_EMAIL", ANYSTR, ANYSTR] fail_response, params=["REGISTER", "INVALID_EMAIL", ANYSTR, ANYSTR]
) )
@cases.mark_services
@cases.mark_specifications("IRCv3")
class RegisterEmailVerifiedAfterConnectTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
account_registration_before_connect=False,
account_registration_requires_email=True,
)
def testAfterConnect(self): def testAfterConnect(self):
self.connectClient( self.connectClient(
"bar", name="bar", capabilities=[REGISTER_CAP_NAME], skip_if_cap_nak=True "bar", name="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.assertIn("email-required", caps[REGISTER_CAP_NAME] or "")
self.sendLine("bar", "REGISTER * * shivarampassphrase") self.sendLine("bar", "REGISTER * * shivarampassphrase")
msgs = self.getMessages("bar") msgs = self.getMessages("bar")
fail_response = [msg for msg in msgs if msg.command == "FAIL"][0] fail_response = [msg for msg in msgs if msg.command == "FAIL"][0]
@ -182,8 +119,9 @@ class RegisterNoLandGrabsTestCase(cases.BaseServerTestCase):
@staticmethod @staticmethod
def config() -> cases.TestCaseControllerConfig: def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig( return cases.TestCaseControllerConfig(
account_registration_requires_email=False, ergo_config=lambda config: config["accounts"]["registration"].update(
account_registration_before_connect=True, {"allow-before-connect": True}
)
) )
def testBeforeConnect(self): def testBeforeConnect(self):

View File

@ -86,10 +86,10 @@ class BufferingTestCase(cases.BaseServerTestCase):
if messages and ERR_INPUTTOOLONG in (m.command for m in messages): if messages and ERR_INPUTTOOLONG in (m.command for m in messages):
# https://defs.ircdocs.horse/defs/numerics.html#err-inputtoolong-417 # https://defs.ircdocs.horse/defs/numerics.html#err-inputtoolong-417
self.assertGreater( self.assertGreater(
len((line + payload + "\r\n").encode()), len(line + payload + "\r\n"),
512 - overhead, 512 - overhead,
"Got ERR_INPUTTOOLONG for a message that should fit " "Got ERR_INPUTTOOLONG for a messag that should fit "
"within 512 characters.", "withing 512 characters.",
) )
continue continue
@ -125,24 +125,11 @@ class BufferingTestCase(cases.BaseServerTestCase):
f"expected payload to be a prefix of {payload!r}, " f"expected payload to be a prefix of {payload!r}, "
f"but got {payload!r}", f"but got {payload!r}",
) )
if self.controller.software_name == "Ergo":
self.assertTrue(
payload_intact,
f"Ergo should not truncate messages: {repr(line + payload)}, {repr(received_line)}",
)
def get_overhead(self, client1, client2, colon): def get_overhead(self, client1, client2, colon):
"""Compute the overhead added to client1's message: self.sendLine(client1, f"PRIVMSG nick2 {colon}a\r\n")
PRIVMSG nick2 a\r\n
:nick1!~user@host PRIVMSG nick2 :a\r\n
So typically client1's NUH length plus either 2 or 3 bytes
(the initial colon, the space between source and command, and possibly
a colon preceding the trailing).
"""
outgoing = f"PRIVMSG nick2 {colon}a\r\n"
self.sendLine(client1, outgoing)
line = self._getLine(client2) line = self._getLine(client2)
return len(line) - len(outgoing.encode()) return len(line) - len(f"PRIVMSG nick2 {colon}a\r\n")
def _getLine(self, client) -> bytes: def _getLine(self, client) -> bytes:
line = b"" line = b""

View File

@ -56,6 +56,10 @@ class CapTestCase(cases.BaseServerTestCase):
) )
@cases.mark_specifications("IRCv3") @cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["Sable"],
"does not support multi-prefix",
)
def testReqOne(self): def testReqOne(self):
"""Tests requesting a single capability""" """Tests requesting a single capability"""
self.addClient(1) self.addClient(1)
@ -89,7 +93,7 @@ class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3") @cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware( @cases.xfailIfSoftware(
["ngIRCd"], ["ngIRCd", "Sable"],
"does not support userhost-in-names", "does not support userhost-in-names",
) )
def testReqTwo(self): def testReqTwo(self):
@ -131,7 +135,7 @@ class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3") @cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware( @cases.xfailIfSoftware(
["ngIRCd"], ["ngIRCd", "Sable"],
"does not support userhost-in-names", "does not support userhost-in-names",
) )
def testReqOneThenOne(self): def testReqOneThenOne(self):
@ -183,7 +187,7 @@ class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3") @cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware( @cases.xfailIfSoftware(
["ngIRCd"], ["ngIRCd", "Sable"],
"does not support userhost-in-names", "does not support userhost-in-names",
) )
def testReqPostRegistration(self): def testReqPostRegistration(self):

View File

@ -58,16 +58,6 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def config() -> cases.TestCaseControllerConfig: def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(chathistory=True) return cases.TestCaseControllerConfig(chathistory=True)
def _supports_msgid(self):
return "msgid" in self.server_support.get(
"MSGREFTYPES", "msgid,timestamp"
).split(",")
def _supports_timestamp(self):
return "timestamp" in self.server_support.get(
"MSGREFTYPES", "msgid,timestamp"
).split(",")
@skip_ngircd @skip_ngircd
def testInvalidTargets(self): def testInvalidTargets(self):
bar, pw = random_name("bar"), random_name("pw") bar, pw = random_name("bar"), random_name("pw")
@ -470,7 +460,6 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-1:], result) self.assertEqual(echo_messages[-1:], result)
if self._supports_msgid():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY LATEST %s msgid=%s %d" "CHATHISTORY LATEST %s msgid=%s %d"
@ -479,7 +468,6 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[5:], result) self.assertEqual(echo_messages[5:], result)
if self._supports_timestamp():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY LATEST %s timestamp=%s %d" "CHATHISTORY LATEST %s timestamp=%s %d"
@ -490,7 +478,6 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def _validate_chathistory_BEFORE(self, echo_messages, user, chname): def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2 INCLUSIVE_LIMIT = len(echo_messages) * 2
if self._supports_msgid():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY BEFORE %s msgid=%s %d" "CHATHISTORY BEFORE %s msgid=%s %d"
@ -499,7 +486,6 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[:6], result) self.assertEqual(echo_messages[:6], result)
if self._supports_timestamp():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY BEFORE %s timestamp=%s %d" "CHATHISTORY BEFORE %s timestamp=%s %d"
@ -518,7 +504,6 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def _validate_chathistory_AFTER(self, echo_messages, user, chname): def _validate_chathistory_AFTER(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2 INCLUSIVE_LIMIT = len(echo_messages) * 2
if self._supports_msgid():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY AFTER %s msgid=%s %d" "CHATHISTORY AFTER %s msgid=%s %d"
@ -527,7 +512,6 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:], result) self.assertEqual(echo_messages[4:], result)
if self._supports_timestamp():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY AFTER %s timestamp=%s %d" "CHATHISTORY AFTER %s timestamp=%s %d"
@ -538,15 +522,13 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY AFTER %s timestamp=%s %d" "CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3),
% (chname, echo_messages[3].time, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:7], result) self.assertEqual(echo_messages[4:7], result)
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2 INCLUSIVE_LIMIT = len(echo_messages) * 2
if self._supports_msgid():
# BETWEEN forwards and backwards # BETWEEN forwards and backwards
self.sendLine( self.sendLine(
user, user,
@ -592,29 +574,18 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-4:-1], result) self.assertEqual(echo_messages[-4:-1], result)
if self._supports_timestamp():
# same stuff again but with timestamps # same stuff again but with timestamps
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% ( % (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT),
chname,
echo_messages[0].time,
echo_messages[-1].time,
INCLUSIVE_LIMIT,
),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result) self.assertEqual(echo_messages[1:-1], result)
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% ( % (chname, echo_messages[-1].time, echo_messages[0].time, INCLUSIVE_LIMIT),
chname,
echo_messages[-1].time,
echo_messages[0].time,
INCLUSIVE_LIMIT,
),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result) self.assertEqual(echo_messages[1:-1], result)
@ -634,24 +605,20 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.assertEqual(echo_messages[-4:-1], result) self.assertEqual(echo_messages[-4:-1], result)
def _validate_chathistory_AROUND(self, echo_messages, user, chname): def _validate_chathistory_AROUND(self, echo_messages, user, chname):
if self._supports_msgid():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY AROUND %s msgid=%s %d" "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1),
% (chname, echo_messages[7].msgid, 1),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual([echo_messages[7]], result) self.assertEqual([echo_messages[7]], result)
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY AROUND %s msgid=%s %d" "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3),
% (chname, echo_messages[7].msgid, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[6:9], result) self.assertEqual(echo_messages[6:9], result)
if self._supports_timestamp():
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY AROUND %s timestamp=%s %d" "CHATHISTORY AROUND %s timestamp=%s %d"

View File

@ -1,67 +0,0 @@
from irctest import cases
from irctest.numerics import RPL_CHANNELCREATED, RPL_CHANNELMODEIS
from irctest.patma import ANYSTR, ListRemainder, StrRe
class RplChannelModeIsTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
def testChannelModeIs(self):
"""Test RPL_CHANNELMODEIS and RPL_CHANNELCREATED as responses to
`MODE #channel`:
<https://modern.ircdocs.horse/#rplcreationtime-329>
<https://modern.ircdocs.horse/#rplchannelmodeis-324>
"""
expected_numerics = {RPL_CHANNELMODEIS, RPL_CHANNELCREATED}
if self.controller.software_name in ("irc2", "Sable"):
# irc2 and Sable don't use timestamps for conflict resolution,
# consequently they don't store the channel creation timestamp
# and don't send RPL_CHANNELCREATED
expected_numerics = {RPL_CHANNELMODEIS}
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
# i, n, and t are specified by RFC1459; some of them may be on by default,
# but after this, at least those three should be enabled:
self.sendLine("chanop", "MODE #chan +int")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan")
messages = self.getMessages("chanop")
self.assertEqual(expected_numerics, {msg.command for msg in messages})
for message in messages:
if message.command == RPL_CHANNELMODEIS:
# the final parameters are the mode string (e.g. `+int`),
# and then optionally any mode parameters (in case the ircd
# lists a mode that takes a parameter)
self.assertMessageMatch(
message,
command=RPL_CHANNELMODEIS,
params=["chanop", "#chan", ListRemainder(ANYSTR, min_length=1)],
)
final_param = message.params[2]
self.assertEqual(final_param[0], "+")
enabled_modes = list(final_param[1:])
break
self.assertLessEqual({"i", "n", "t"}, set(enabled_modes))
# remove all the modes listed by RPL_CHANNELMODEIS
self.sendLine("chanop", f"MODE #chan -{''.join(enabled_modes)}")
response = self.getMessage("chanop")
# we should get something like: MODE #chan -int
self.assertMessageMatch(
response, command="MODE", params=["#chan", StrRe("^-.*")]
)
self.assertEqual(set(response.params[1][1:]), set(enabled_modes))
self.sendLine("chanop", "MODE #chan")
messages = self.getMessages("chanop")
self.assertEqual(expected_numerics, {msg.command for msg in messages})
# all modes have been disabled; the correct representation of this is `+`
for message in messages:
if message.command == RPL_CHANNELMODEIS:
self.assertMessageMatch(
message,
command=RPL_CHANNELMODEIS,
params=["chanop", "#chan", "+"],
)

View File

@ -1,159 +0,0 @@
from irctest import cases
from irctest.numerics import (
ERR_CHANOPRIVSNEEDED,
ERR_NOSUCHCHANNEL,
ERR_NOSUCHNICK,
ERR_NOTONCHANNEL,
ERR_USERNOTINCHANNEL,
)
class ChannelOperatorModeTestCase(cases.BaseServerTestCase):
"""Test various error and success cases around the channel operator mode:
<https://modern.ircdocs.horse/#channel-operators>
<https://modern.ircdocs.horse/#mode-message>
"""
def setupNicks(self):
"""Set up a standard set of three nicknames and two channels
for testing channel-user MODE interactions."""
# first nick to join the channel is privileged:
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.connectClient("unprivileged", name="unprivileged")
self.joinChannel("unprivileged", "#chan")
self.getMessages("chanop")
self.connectClient("unrelated", name="unrelated")
self.joinChannel("unrelated", "#unrelated")
self.joinChannel("unprivileged", "#unrelated")
self.getMessages("unrelated")
@cases.mark_specifications("Modern")
@cases.xfailIfSoftware(["irc2"], "broken in irc2")
def testChannelOperatorModeSenderPrivsNeeded(self):
"""Test that +o from a channel member without the necessary privileges
fails as expected."""
self.setupNicks()
# sender is a channel member but without the necessary privileges:
self.sendLine("unprivileged", "MODE #chan +o unprivileged")
messages = self.getMessages("unprivileged")
self.assertEqual(len(messages), 1)
self.assertMessageMatch(messages[0], command=ERR_CHANOPRIVSNEEDED)
@cases.mark_specifications("Modern")
def testChannelOperatorModeTargetNotInChannel(self):
"""Test that +o targeting a user not present in the channel fails
as expected."""
self.setupNicks()
# sender is a chanop, but target nick is not in the channel:
self.sendLine("chanop", "MODE #chan +o unrelated")
messages = self.getMessages("chanop")
self.assertEqual(len(messages), 1)
self.assertMessageMatch(messages[0], command=ERR_USERNOTINCHANNEL)
@cases.mark_specifications("Modern")
def testChannelOperatorModeTargetDoesNotExist(self):
"""Test that +o targeting a nonexistent nick fails as expected."""
self.setupNicks()
# sender is a chanop, but target nick does not exist:
self.sendLine("chanop", "MODE #chan +o nobody")
messages = self.getMessages("chanop")
# ERR_NOSUCHNICK is typical, Bahamut additionally sends ERR_USERNOTINCHANNEL
if self.controller.software_name != "Bahamut":
self.assertEqual(len(messages), 1)
self.assertMessageMatch(messages[0], command=ERR_NOSUCHNICK)
else:
self.assertLessEqual(len(messages), 2)
commands = {message.command for message in messages}
self.assertLessEqual({ERR_NOSUCHNICK}, commands)
self.assertLessEqual(commands, {ERR_NOSUCHNICK, ERR_USERNOTINCHANNEL})
@cases.mark_specifications("Modern")
@cases.xfailIf(
lambda self: bool(
self.controller.software_name == "UnrealIRCd"
and self.controller.software_version == 5
),
"UnrealIRCd <6.1.7 returns ERR_NOSUCHNICK on non-existent channel",
)
def testChannelOperatorModeChannelDoesNotExist(self):
"""Test that +o targeting a nonexistent channel fails as expected.
"If <target> is a channel that does not exist on the network,
# the ERR_NOSUCHCHANNEL (403) numeric is returned."
"""
self.setupNicks()
# target channel does not exist, but target nick does:
self.sendLine("chanop", "MODE #nonexistentchan +o chanop")
messages = self.getMessages("chanop")
self.assertEqual(len(messages), 1)
self.assertMessageMatch(messages[0], command=ERR_NOSUCHCHANNEL)
@cases.mark_specifications("Modern")
@cases.xfailIf(
lambda self: bool(
self.controller.software_name == "UnrealIRCd"
and self.controller.software_version == 5
),
"UnrealIRCd <6.1.7 returns ERR_NOSUCHNICK on non-existent channel",
)
def testChannelOperatorModeChannelAndTargetDoNotExist(self):
"""Test that +o targeting a nonexistent channel and nickname
fails as expected."""
self.setupNicks()
# neither target channel nor target nick exist:
self.sendLine("chanop", "MODE #nonexistentchan +o nobody")
messages = self.getMessages("chanop")
self.assertEqual(len(messages), 1)
self.assertIn(
messages[0].command,
[ERR_NOSUCHCHANNEL, ERR_NOTONCHANNEL, ERR_USERNOTINCHANNEL],
)
@cases.mark_specifications("Modern")
def testChannelOperatorModeSenderNonMember(self):
"""Test that +o where the sender is not a channel member
fails as expected."""
self.setupNicks()
# sender is not a channel member, target nick exists and is a channel member:
self.sendLine("chanop", "MODE #unrelated +o unprivileged")
messages = self.getMessages("chanop")
self.assertEqual(len(messages), 1)
self.assertIn(messages[0].command, [ERR_NOTONCHANNEL, ERR_CHANOPRIVSNEEDED])
@cases.mark_specifications("Modern")
def testChannelOperatorModeSenderAndTargetNonMembers(self):
"""Test that +o where neither the sender nor the target is a channel
member fails as expected."""
self.setupNicks()
# sender is not a channel member, target nick exists but is not a channel member:
self.sendLine("chanop", "MODE #unrelated +o chanop")
messages = self.getMessages("chanop")
self.assertEqual(len(messages), 1)
self.assertIn(
messages[0].command,
[ERR_NOTONCHANNEL, ERR_CHANOPRIVSNEEDED, ERR_USERNOTINCHANNEL],
)
@cases.mark_specifications("Modern")
def testChannelOperatorModeSuccess(self):
"""Tests a successful grant of +o in a channel."""
self.setupNicks()
self.sendLine("chanop", "MODE #chan +o unprivileged")
messages = self.getMessages("chanop")
self.assertEqual(len(messages), 1)
self.assertMessageMatch(
messages[0],
command="MODE",
params=["#chan", "+o", "unprivileged"],
)
messages = self.getMessages("unprivileged")
self.assertEqual(len(messages), 1)
self.assertMessageMatch(
messages[0],
command="MODE",
params=["#chan", "+o", "unprivileged"],
)

View File

@ -12,8 +12,8 @@ class ConfusablesTestCase(cases.BaseServerTestCase):
@staticmethod @staticmethod
def config() -> cases.TestCaseControllerConfig: def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig( return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["server"].update( ergo_config=lambda config: config["accounts"].update(
{"casemapping": "precis"}, {"nick-reservation": {"enabled": True, "method": "strict"}}
) )
) )

View File

@ -10,7 +10,7 @@ import time
from irctest import cases from irctest import cases
from irctest.client_mock import ConnectionClosed from irctest.client_mock import ConnectionClosed
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH
from irctest.patma import ANYLIST, ANYSTR, OptStrRe, StrRe from irctest.patma import ANYSTR, StrRe
class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase): class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
@ -85,92 +85,6 @@ class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
class ConnectionRegistrationTestCase(cases.BaseServerTestCase): class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
def testConnectionRegistration(self):
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER foo * * :foo")
for numeric in ("001", "002", "003"):
self.assertMessageMatch(
self.getRegistrationMessage(1),
command=numeric,
params=["foo", ANYSTR],
)
self.assertMessageMatch(
self.getRegistrationMessage(1),
command="004", # RPL_MYINFO
params=[
"foo",
"My.Little.Server",
ANYSTR, # version
StrRe("[a-zA-Z]+"), # user modes
StrRe("[a-zA-Z]+"), # channel modes
OptStrRe("[a-zA-Z]+"), # channel modes with parameter
],
)
# ISUPPORT
m = self.getRegistrationMessage(1)
while True:
self.assertMessageMatch(
m,
command="005",
params=["foo", *ANYLIST],
)
m = self.getRegistrationMessage(1)
if m.command != "005":
break
if m.command in ("042", "396"): # RPL_YOURID / RPL_VISIBLEHOST, non-standard
m = self.getRegistrationMessage(1)
# LUSERS
while m.command in ("250", "251", "252", "253", "254", "255", "265", "266"):
m = self.getRegistrationMessage(1)
if m.command == "375": # RPL_MOTDSTART
self.assertMessageMatch(
m,
command="375",
params=["foo", ANYSTR],
)
while (m := self.getRegistrationMessage(1)).command == "372":
self.assertMessageMatch(
m,
command="372", # RPL_MOTD
params=["foo", ANYSTR],
)
self.assertMessageMatch(
m,
command="376", # RPL_ENDOFMOTD
params=["foo", ANYSTR],
)
else:
self.assertMessageMatch(
m,
command="422", # ERR_NOMOTD
params=["foo", ANYSTR],
)
# User mode
if m.command == "MODE":
self.assertMessageMatch(
m,
command="MODE",
params=["foo", ANYSTR, *ANYLIST],
)
m = self.getRegistrationMessage(1)
elif m.command == "221": # RPL_UMODEIS
self.assertMessageMatch(
m,
command="221",
params=["foo", ANYSTR, *ANYLIST],
)
m = self.getRegistrationMessage(1)
else:
print("Warning: missing MODE")
@cases.mark_specifications("RFC1459") @cases.mark_specifications("RFC1459")
def testQuitDisconnects(self): def testQuitDisconnects(self):
"""“The server must close the connection to a client which sends a """“The server must close the connection to a client which sends a

View File

@ -9,38 +9,6 @@ from irctest import cases, runner
class IsupportTestCase(cases.BaseServerTestCase): class IsupportTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
@cases.mark_isupport("PREFIX")
def testParameters(self):
"""https://modern.ircdocs.horse/#rplisupport-005"""
# <https://modern.ircdocs.horse/#connection-registration>
# "Upon successful completion of the registration process,
# the server MUST send, in this order:
# [...]
# 5. at least one RPL_ISUPPORT (005) numeric to the client."
welcome_005s = [
msg for msg in self.connectClient("foo") if msg.command == "005"
]
self.assertGreaterEqual(len(welcome_005s), 1)
for msg in welcome_005s:
# first parameter is the client's nickname;
# last parameter is a human-readable trailing, typically
# "are supported by this server"
self.assertGreaterEqual(len(msg.params), 3)
self.assertEqual(msg.params[0], "foo")
# "As the maximum number of message parameters to any reply is 15,
# the maximum number of RPL_ISUPPORT tokens that can be advertised
# is 13."
self.assertLessEqual(len(msg.params), 15)
for param in msg.params[1:-1]:
self.validateIsupportParam(param)
def validateIsupportParam(self, param):
if not param.isascii():
raise ValueError("Invalid non-ASCII 005 parameter", param)
# TODO add more validation
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
@cases.mark_isupport("PREFIX") @cases.mark_isupport("PREFIX")
def testPrefix(self): def testPrefix(self):
@ -56,8 +24,7 @@ class IsupportTestCase(cases.BaseServerTestCase):
return return
m = re.match( m = re.match(
r"^\((?P<modes>[a-zA-Z]+)\)(?P<prefixes>\S+)$", r"\((?P<modes>[a-zA-Z]+)\)(?P<prefixes>\S+)", self.server_support["PREFIX"]
self.server_support["PREFIX"],
) )
self.assertTrue( self.assertTrue(
m, m,
@ -118,5 +85,5 @@ class IsupportTestCase(cases.BaseServerTestCase):
parts = self.server_support["TARGMAX"].split(",") parts = self.server_support["TARGMAX"].split(",")
for part in parts: for part in parts:
self.assertTrue( self.assertTrue(
re.match("^[A-Z]+:[0-9]*$", part), "Invalid TARGMAX key:value: %r", part re.match("[A-Z]+:[0-9]*", part), "Invalid TARGMAX key:value: %r", part
) )

View File

@ -6,6 +6,7 @@ The JOIN command (`RFC 1459
""" """
from irctest import cases, runner from irctest import cases, runner
from irctest.irc_utils import ambiguities
from irctest.numerics import ( from irctest.numerics import (
ERR_BADCHANMASK, ERR_BADCHANMASK,
ERR_FORBIDDENCHANNEL, ERR_FORBIDDENCHANNEL,
@ -60,7 +61,6 @@ class JoinTestCase(cases.BaseServerTestCase):
), ),
) )
@cases.xfailIfSoftware(["Bahamut", "irc2"], "trailing space on RPL_NAMREPLY")
@cases.mark_specifications("RFC2812") @cases.mark_specifications("RFC2812")
def testJoinNamreply(self): def testJoinNamreply(self):
"""“353 RPL_NAMREPLY """“353 RPL_NAMREPLY
@ -75,23 +75,33 @@ class JoinTestCase(cases.BaseServerTestCase):
for m in self.getMessages(1): for m in self.getMessages(1):
if m.command == "353": if m.command == "353":
self.assertMessageMatch( self.assertIn(
m, params=["foo", StrRe(r"[=\*@]"), "#chan", StrRe("[@+]?foo")] len(m.params),
) (3, 4),
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
for m in self.getMessages(2):
if m.command == "353":
self.assertMessageMatch(
m, m,
params=[ fail_msg="RPL_NAM_REPLY with number of arguments "
"bar", "<3 or >4: {msg}",
StrRe(r"[=\*@]"), )
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", "#chan",
StrRe("([@+]?foo bar|bar [@+]?foo)"), 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): def testJoinTwice(self):
@ -105,8 +115,34 @@ class JoinTestCase(cases.BaseServerTestCase):
# if the join is successful, or has an error among the given set. # if the join is successful, or has an error among the given set.
for m in self.getMessages(1): for m in self.getMessages(1):
if m.command == "353": if m.command == "353":
self.assertMessageMatch( self.assertIn(
m, params=["foo", StrRe(r"[=\*@]"), "#chan", StrRe("[@+]?foo")] 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): def testJoinPartiallyInvalid(self):
@ -200,78 +236,3 @@ class JoinTestCase(cases.BaseServerTestCase):
fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', " fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', "
"got {got}", "got {got}",
) )
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testJoinKey(self):
"""Joins a single channel with a key"""
self.connectClient("chanop")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +k key")
self.getMessages(1)
self.connectClient("joiner")
self.sendLine(2, "JOIN #chan key")
self.assertMessageMatch(
self.getMessage(2),
command="JOIN",
params=["#chan"],
)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testJoinKeys(self):
"""Joins two channels, both with keys"""
self.connectClient("chanop")
if self.targmax.get("JOIN", "1000") == "1":
raise runner.OptionalExtensionNotSupported("Multi-target JOIN")
self.joinChannel(1, "#chan1")
self.sendLine(1, "MODE #chan1 +k key1")
self.getMessages(1)
self.joinChannel(1, "#chan2")
self.sendLine(1, "MODE #chan2 +k key2")
self.getMessages(1)
self.connectClient("joiner")
self.sendLine(2, "JOIN #chan1,#chan2 key1,key2")
self.assertMessageMatch(
self.getMessage(2),
command="JOIN",
params=["#chan1"],
)
self.assertMessageMatch(
[
msg
for msg in self.getMessages(2)
if msg.command not in {RPL_NAMREPLY, RPL_ENDOFNAMES}
][0],
command="JOIN",
params=["#chan2"],
)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testJoinManySingleKey(self):
"""Joins two channels, the first one has a key."""
self.connectClient("chanop")
if self.targmax.get("JOIN", "1000") == "1":
raise runner.OptionalExtensionNotSupported("Multi-target JOIN")
self.joinChannel(1, "#chan1")
self.sendLine(1, "MODE #chan1 +k key1")
self.getMessages(1)
self.joinChannel(1, "#chan2")
self.getMessages(1)
self.connectClient("joiner")
self.sendLine(2, "JOIN #chan1,#chan2 key1")
self.assertMessageMatch(
self.getMessage(2),
command="JOIN",
params=["#chan1"],
)
self.assertMessageMatch(
[
msg
for msg in self.getMessages(2)
if msg.command not in {RPL_NAMREPLY, RPL_ENDOFNAMES}
][0],
command="JOIN",
params=["#chan2"],
)

View File

@ -2,8 +2,6 @@
The PRIVMSG and NOTICE commands. The PRIVMSG and NOTICE commands.
""" """
import pytest
from irctest import cases from irctest import cases
from irctest.numerics import ERR_INPUTTOOLONG from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYSTR from irctest.patma import ANYSTR
@ -125,30 +123,25 @@ class NoticeTestCase(cases.BaseServerTestCase):
class TagsTestCase(cases.BaseServerTestCase): class TagsTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize("tag_length", [4096, 10000])
@cases.mark_capabilities("message-tags") @cases.mark_capabilities("message-tags")
@cases.xfailIf( @cases.xfailIf(
lambda self, tag_length: bool( lambda self: bool(
self.controller.software_name == "UnrealIRCd" self.controller.software_name == "UnrealIRCd"
and self.controller.software_version == 5 and self.controller.software_version == 5
), ),
"UnrealIRCd <6.0.7 dropped messages with excessively large tags: " "UnrealIRCd <6.0.7 dropped messages with excessively large tags: "
"https://bugs.unrealircd.org/view.php?id=5947", "https://bugs.unrealircd.org/view.php?id=5947",
) )
def testLineTooLong(self, tag_length): def testLineTooLong(self):
self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True) self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True)
self.connectClient( self.connectClient(
"recver", capabilities=["message-tags"], skip_if_cap_nak=True "recver", capabilities=["message-tags"], skip_if_cap_nak=True
) )
self.joinChannel(1, "#xyz") self.joinChannel(1, "#xyz")
monsterMessage = "@+clientOnlyTagExample=" + "a" * 4096 + " PRIVMSG #xyz hi!"
monsterMessage = (
"@+clientOnlyTagExample=" + "a" * tag_length + " PRIVMSG #xyz hi!"
)
self.sendLine(1, monsterMessage) self.sendLine(1, monsterMessage)
replies = self.getMessages(1)
self.assertEqual(self.getMessages(2), [], "overflowing message was relayed") self.assertEqual(self.getMessages(2), [], "overflowing message was relayed")
if len(replies) > 0: replies = self.getMessages(1)
self.assertIn(ERR_INPUTTOOLONG, set(reply.command for reply in replies)) self.assertIn(ERR_INPUTTOOLONG, set(reply.command for reply in replies))

View File

@ -8,7 +8,6 @@ import pytest
from irctest import cases, runner from irctest import cases, runner
from irctest.client_mock import NoMessageException from irctest.client_mock import NoMessageException
from irctest.numerics import ( from irctest.numerics import (
ERR_ERRONEUSNICKNAME,
RPL_ENDOFMONLIST, RPL_ENDOFMONLIST,
RPL_MONLIST, RPL_MONLIST,
RPL_MONOFFLINE, RPL_MONOFFLINE,
@ -191,15 +190,14 @@ class MonitorTestCase(_BaseMonitorTestCase):
self.check_server_support() self.check_server_support()
self.sendLine(1, "MONITOR + *!username@localhost") self.sendLine(1, "MONITOR + *!username@localhost")
self.sendLine(1, "MONITOR + *!username@127.0.0.1") self.sendLine(1, "MONITOR + *!username@127.0.0.1")
expected_command = StrRe(f"({RPL_MONOFFLINE}|{ERR_ERRONEUSNICKNAME})")
try: try:
m = self.getMessage(1) m = self.getMessage(1)
self.assertMessageMatch(m, command=expected_command) self.assertMessageMatch(m, command="731")
except NoMessageException: except NoMessageException:
pass pass
else: else:
m = self.getMessage(1) m = self.getMessage(1)
self.assertMessageMatch(m, command=expected_command) self.assertMessageMatch(m, command="731")
self.connectClient("bar") self.connectClient("bar")
try: try:
m = self.getMessage(1) m = self.getMessage(1)

View File

@ -24,6 +24,11 @@ class MultiPrefixTestCase(cases.BaseServerTestCase):
self.sendLine(1, "NAMES #chan") self.sendLine(1, "NAMES #chan")
reply = self.getMessage(1) reply = self.getMessage(1)
self.assertMessageMatch(
reply,
command="353",
fail_msg="Expected NAMES response (353) with @+foo, got: {msg}",
)
self.assertMessageMatch( self.assertMessageMatch(
reply, reply,
command="353", command="353",
@ -42,57 +47,9 @@ class MultiPrefixTestCase(cases.BaseServerTestCase):
8, 8,
"Expected WHO response (352) with 8 params, got: {msg}".format(msg=msg), "Expected WHO response (352) with 8 params, got: {msg}".format(msg=msg),
) )
self.assertIn( self.assertTrue(
"@+", "@+" in msg.params[6],
msg.params[6],
'Expected WHO response (352) with "@+" in param 7, got: {msg}'.format( 'Expected WHO response (352) with "@+" in param 7, got: {msg}'.format(
msg=msg msg=msg
), ),
) )
@cases.xfailIfSoftware(
["irc2", "Bahamut"], "irc2 and Bahamut send a trailing space"
)
def testNoMultiPrefix(self):
"""When not requested, only the highest prefix should be sent"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +v foo")
self.getMessages(1)
# TODO(dan): Make sure +v is voice
self.sendLine(1, "NAMES #chan")
reply = self.getMessage(1)
self.assertMessageMatch(
reply,
command="353",
params=["foo", ANYSTR, "#chan", "@foo"],
fail_msg="Expected NAMES response (353) with @foo, got: {msg}",
)
self.getMessages(1)
self.sendLine(1, "WHO #chan")
msg = self.getMessage(1)
self.assertEqual(
msg.command, "352", msg, fail_msg="Expected WHO response (352), got: {msg}"
)
self.assertGreaterEqual(
len(msg.params),
8,
"Expected WHO response (352) with 8 params, got: {msg}".format(msg=msg),
)
self.assertIn(
"@",
msg.params[6],
'Expected WHO response (352) with "@" in param 7, got: {msg}'.format(
msg=msg
),
)
self.assertNotIn(
"+",
msg.params[6],
'Expected WHO response (352) with no "+" in param 7, got: {msg}'.format(
msg=msg
),
)

View File

@ -3,7 +3,7 @@
""" """
from irctest import cases from irctest import cases
from irctest.patma import ANYDICT, ANYSTR, StrRe from irctest.patma import ANYDICT, StrRe
CAP_NAME = "draft/multiline" CAP_NAME = "draft/multiline"
BATCH_TYPE = "draft/multiline" BATCH_TYPE = "draft/multiline"
@ -135,86 +135,3 @@ class MultilineTestCase(cases.BaseServerTestCase):
self.assertIn("+client-only-tag", fallback_relay[0].tags) self.assertIn("+client-only-tag", fallback_relay[0].tags)
self.assertIn("+client-only-tag", fallback_relay[1].tags) self.assertIn("+client-only-tag", fallback_relay[1].tags)
self.assertEqual(fallback_relay[0].tags["msgid"], msgid) self.assertEqual(fallback_relay[0].tags["msgid"], msgid)
@cases.mark_capabilities("draft/multiline")
def testInvalidBatchTag(self):
"""Test that an unexpected change of batch tag results in
FAIL BATCH MULTILINE_INVALID."""
self.connectClient(
"alice", capabilities=(base_caps + [CAP_NAME]), skip_if_cap_nak=True
)
self.joinChannel(1, "#test")
# invalid batch tag:
self.sendLine(1, "BATCH +123 %s #test" % (BATCH_TYPE,))
self.sendLine(1, "@batch=231 PRIVMSG #test :hi")
self.assertMessageMatch(
self.getMessage(1),
command="FAIL",
params=["BATCH", "MULTILINE_INVALID", ANYSTR],
)
@cases.mark_capabilities("draft/multiline")
def testInvalidBlankConcatTag(self):
"""Test that the concat tag on a blank message results in
FAIL BATCH MULTILINE_INVALID."""
self.connectClient(
"alice", capabilities=(base_caps + [CAP_NAME]), skip_if_cap_nak=True
)
self.joinChannel(1, "#test")
# cannot send the concat tag with a blank message:
self.sendLine(1, "BATCH +123 %s #test" % (BATCH_TYPE,))
self.sendLine(1, "@batch=123 PRIVMSG #test :hi")
self.sendLine(1, "@batch=123;%s PRIVMSG #test :" % (CONCAT_TAG,))
self.assertMessageMatch(
self.getMessage(1),
command="FAIL",
params=["BATCH", "MULTILINE_INVALID", ANYSTR],
)
@cases.mark_specifications("Ergo")
def testLineLimit(self):
"""This is an Ergo-specific test for line limit enforcement
in multiline messages. Right now it hardcodes the same limits as in
the Ergo controller; we can generalize it in future for other multiline
implementations.
"""
self.connectClient(
"alice", capabilities=(base_caps + [CAP_NAME]), skip_if_cap_nak=False
)
self.joinChannel(1, "#test")
# line limit exceeded
self.sendLine(1, "BATCH +123 %s #test" % (BATCH_TYPE,))
for i in range(33):
self.sendLine(1, "@batch=123 PRIVMSG #test hi")
self.assertMessageMatch(
self.getMessage(1),
command="FAIL",
params=["BATCH", "MULTILINE_MAX_LINES", "32", ANYSTR],
)
@cases.mark_specifications("Ergo")
def testByteLimit(self):
"""This is an Ergo-specific test for line limit enforcement
in multiline messages (see testLineLimit).
"""
self.connectClient(
"alice", capabilities=(base_caps + [CAP_NAME]), skip_if_cap_nak=False
)
self.joinChannel(1, "#test")
# byte limit exceeded
self.sendLine(1, "BATCH +234 %s #test" % (BATCH_TYPE,))
for i in range(11):
self.sendLine(1, "@batch=234 PRIVMSG #test " + ("x" * 400))
self.assertMessageMatch(
self.getMessage(1),
command="FAIL",
params=["BATCH", "MULTILINE_MAX_BYTES", "4096", ANYSTR],
)

View File

@ -0,0 +1,505 @@
import re
from irctest import cases
from irctest.numerics import (
ERR_BANNEDFROMCHAN,
ERR_CANNOTSENDTOCHAN,
ERR_INVITEONLYCHAN,
RPL_CHMODELIST,
RPL_ENDOFLISTPROPLIST,
RPL_ENDOFPROPLIST,
RPL_LISTPROPLIST,
RPL_PROPLIST,
RPL_UMODELIST,
)
from irctest.patma import ANYLIST, ANYSTR, ListRemainder, StrRe
from irctest.runner import NotImplementedByController
CHMODES = {
"op",
"voice",
"ban",
"inviteonly",
"limit",
"moderated",
"noextmsg",
"key",
"private",
"topiclock",
"secret",
"banex",
"invex",
"admin",
"halfop",
"noctcp",
"owner",
"permanent",
"regonly",
"secureonly",
"mute",
}
UMODES = {
"invisible",
"oper",
"snomask",
"wallops",
"bot",
"hidechans",
"cloak",
}
class _NamedModeTestMixin:
ALLOW_MODE_REPLY: bool
def assertNewBans(self, msgs, expected_masks):
"""Checks ``msgs`` is a set of PROP messages (and/or MODE if
``self.ALLOW_MODE_REPLY`` is True) that ban exactly the given set of masks."""
banned_masks = set()
for msg in msgs:
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
self.assertMessageMatch(
msg, command="MODE", params=["#chan", StrRe(r"\+?b+"), *ANYLIST]
)
(_, chars, *args) = msg.params
chars = chars.lstrip("+")
self.assertEqual(
len(chars), len(args), "Mismatched number of +b and args"
)
banned_masks.update(args)
else:
self.assertMessageMatch(
msg,
command="PROP",
params=["#chan", ListRemainder(StrRe(r"\+ban=.+"), min_length=1)],
)
banned_masks.update(param.split("=")[1] for param in msg.params[1:])
self.assertEqual(banned_masks, expected_masks)
def assertNewUnbans(self, msgs, expected_masks):
"""Same as ``assertNewBans`` but for unbans."""
banned_masks = set()
for msg in msgs:
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
self.assertMessageMatch(
msg, command="MODE", params=["#chan", StrRe(r"-b+"), *ANYLIST]
)
(_, chars, *args) = msg.params
chars = chars.lstrip("-")
self.assertEqual(
len(chars), len(args), "Mismatched number of -b and args"
)
banned_masks.update(args)
else:
self.assertMessageMatch(
msg,
command="PROP",
params=["#chan", ListRemainder(StrRe(r"-ban=.+"), min_length=1)],
)
banned_masks.update(param.split("=")[1] for param in msg.params[1:])
self.assertEqual(banned_masks, expected_masks)
@cases.mark_capabilities("draft/named-modes")
@cases.mark_specifications("IRCv3")
def testListMode(self):
"""Checks list modes (type 1), using 'ban' as example."""
self.connectClient(
"foo", name="user", capabilities=["draft/named-modes"], skip_if_cap_nak=True
)
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
# Set ban
self.sendLine("chanop", "PROP #chan +ban=foo!*@*")
self.assertNewBans(self.getMessages("chanop"), {"foo!*@*"})
# Should not appear in the main list
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertNotIn("ban", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
# Check banned
self.sendLine("chanop", "PROP #chan ban")
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_LISTPROPLIST,
params=["chanop", "#chan", "ban", "foo!*@*", *ANYLIST],
)
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFLISTPROPLIST,
params=["chanop", "#chan", "ban", ANYSTR],
)
self.sendLine("user", "JOIN #chan")
self.assertMessageMatch(self.getMessage("user"), command=ERR_BANNEDFROMCHAN)
# Unset ban
self.sendLine("chanop", "PROP #chan -ban=foo!*@*")
self.assertNewUnbans(self.getMessages("chanop"), {"foo!*@*"})
# Check unbanned
self.sendLine("chanop", "PROP #chan ban")
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFLISTPROPLIST,
params=["chanop", "#chan", "ban", ANYSTR],
)
self.sendLine("user", "JOIN #chan")
self.assertMessageMatch(
self.getMessage("user"), command="JOIN", params=["#chan"]
)
@cases.mark_capabilities("draft/named-modes")
@cases.mark_specifications("IRCv3")
def testFlagModeDefaultOn(self):
"""Checks flag modes (type 4), using 'noextmsg' as example."""
self.connectClient(
"foo", name="user", capabilities=["draft/named-modes"], skip_if_cap_nak=True
)
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
# Check set
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertIn("noextmsg", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
self.sendLine("user", "PRIVMSG #chan :hi")
self.assertMessageMatch(
self.getMessage("user"),
command=ERR_CANNOTSENDTOCHAN,
params=["foo", "#chan", ANYSTR],
)
self.assertEqual(self.getMessages("chanop"), [])
# Unset
self.sendLine("chanop", "PROP #chan -noextmsg")
msg = self.getMessage("chanop")
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
self.assertMessageMatch(msg, command="MODE", params=["#chan", "-noextmsg"])
else:
self.assertMessageMatch(msg, command="PROP", params=["#chan", "-noextmsg"])
# Check unset
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertNotIn("noextmsg", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
self.sendLine("user", "PRIVMSG #chan :hi")
self.assertEqual(self.getMessages("user"), [])
self.assertMessageMatch(
self.getMessage("chanop"), command="PRIVMSG", params=["#chan", "hi"]
)
# Set
self.sendLine("chanop", "PROP #chan +noextmsg")
msg = self.getMessage("chanop")
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
self.assertMessageMatch(msg, command="MODE", params=["#chan", "+noextmsg"])
else:
self.assertMessageMatch(msg, command="PROP", params=["#chan", "+noextmsg"])
# Check set again
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertIn("noextmsg", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
self.sendLine("user", "PRIVMSG #chan :hi")
self.assertMessageMatch(
self.getMessage("user"),
command=ERR_CANNOTSENDTOCHAN,
params=["foo", "#chan", ANYSTR],
)
self.assertEqual(self.getMessages("chanop"), [])
@cases.mark_capabilities("draft/named-modes")
@cases.mark_specifications("IRCv3")
def testFlagModeDefaultOff(self):
"""Checks flag modes (type 4), using 'inviteonly' as example."""
self.connectClient(
"foo", name="user", capabilities=["draft/named-modes"], skip_if_cap_nak=True
)
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
# Check unset
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertNotIn("inviteonly", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
self.sendLine("user", "JOIn #chan")
self.assertMessageMatch(
self.getMessage("user"), command="JOIN", params=["#chan"]
)
self.sendLine("user", "PART #chan :bye")
self.getMessages("user")
self.getMessages("chanop")
# Set
self.sendLine("chanop", "PROP #chan +inviteonly")
msg = self.getMessage("chanop")
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
self.assertMessageMatch(
msg, command="MODE", params=["#chan", "+inviteonly"]
)
else:
self.assertMessageMatch(
msg, command="PROP", params=["#chan", "+inviteonly"]
)
# Check set
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertIn("inviteonly", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
self.sendLine("user", "JOIN #chan")
self.assertMessageMatch(self.getMessage("user"), command=ERR_INVITEONLYCHAN)
# Unset
self.sendLine("chanop", "PROP #chan -inviteonly")
msg = self.getMessage("chanop")
if self.ALLOW_MODE_REPLY and msg.command == "MODE":
self.assertMessageMatch(
msg, command="MODE", params=["#chan", "-inviteonly"]
)
else:
self.assertMessageMatch(
msg, command="PROP", params=["#chan", "-inviteonly"]
)
# Check unset again
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertNotIn("inviteonly", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
self.sendLine("user", "JOIn #chan")
self.assertMessageMatch(
self.getMessage("user"), command="JOIN", params=["#chan"]
)
@cases.mark_capabilities("draft/named-modes")
@cases.mark_specifications("IRCv3")
def testManyListModes(self):
"""Checks setting three list modes (type 1) at once, using 'ban' as example."""
self.connectClient("chanop", name="chanop", capabilities=["draft/named-modes"])
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
if int(self.server_support.get("MAXMODES") or "1") < 3:
raise NotImplementedByController("MAXMODES is not >= 3.")
# Set ban
self.sendLine("chanop", "PROP #chan +ban=foo1!*@* +ban=foo2!*@* +ban=foo3!*@*")
msgs = self.getMessages("chanop")
self.assertNewBans(msgs, {"foo1!*@*", "foo2!*@*", "foo3!*@*"})
# Should not appear in the main list
self.sendLine("chanop", "PROP #chan")
msg = self.getMessage("chanop")
self.assertMessageMatch(
msg, command=RPL_PROPLIST, params=["chanop", "#chan", *ANYLIST]
)
self.assertNotIn("ban", msg.params[2:])
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFPROPLIST,
params=["chanop", "#chan", ANYSTR],
)
# Check banned
self.sendLine("chanop", "PROP #chan ban")
# TODO: make it so the order doesn't matter
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_LISTPROPLIST,
params=["chanop", "#chan", "ban", "foo1!*@*", *ANYLIST],
)
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_LISTPROPLIST,
params=["chanop", "#chan", "ban", "foo2!*@*", *ANYLIST],
)
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_LISTPROPLIST,
params=["chanop", "#chan", "ban", "foo3!*@*", *ANYLIST],
)
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFLISTPROPLIST,
params=["chanop", "#chan", "ban", ANYSTR],
)
# Unset two bans
self.sendLine("chanop", "PROP #chan -ban=foo2!*@* -ban=foo3!*@*")
msgs = self.getMessages("chanop")
self.assertNewUnbans(msgs, {"foo2!*@*", "foo3!*@*"})
# Check unbanned
self.sendLine("chanop", "PROP #chan ban")
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_LISTPROPLIST,
params=["chanop", "#chan", "ban", "foo1!*@*", *ANYLIST],
)
self.assertMessageMatch(
self.getMessage("chanop"),
command=RPL_ENDOFLISTPROPLIST,
params=["chanop", "#chan", "ban", ANYSTR],
)
class NamedModesTestCase(_NamedModeTestMixin, cases.BaseServerTestCase):
"""Normal testing of the named-modes spec."""
ALLOW_MODE_REPLY = True
@cases.mark_capabilities("draft/named-modes")
@cases.mark_specifications("IRCv3")
def testConnectionNumerics(self):
"""Tests RPL_CHMODELIST and RPL_UMODELIST."""
self.connectClient(
"capchk",
name="capchk",
capabilities=["draft/named-modes"],
skip_if_cap_nak=True,
)
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "USER user user user :user")
self.sendLine(1, "NICK user")
self.sendLine(1, "CAP END")
self.skipToWelcome(1)
msgs = self.getMessages(1)
seen_chmodes = set()
seen_umodes = set()
got_last_chmode = False
got_last_umode = False
capturing_re = r"[12345]:(?P<name>(\S+/)?[a-zA-Z0-9-]+)(=[a-zA-Z]+)?"
# fmt: off
chmode_re = r"^[12345]:(\S+/)?[a-zA-Z0-9-]+(=[a-zA-Z]+)?$"
umode_re = r"^[34]:(\S+/)?[a-zA-Z0-9-]+(=[a-zA-Z]+)?$" # noqa
# fmt: on
chmode_pat = [ListRemainder(StrRe(chmode_re), min_length=1)]
umode_pat = [ListRemainder(StrRe(umode_re), min_length=1)]
for msg in msgs:
if msg.command == RPL_CHMODELIST:
self.assertFalse(
got_last_chmode, "Got RPL_CHMODELIST after the list ended."
)
if msg.params[1] == "*":
self.assertMessageMatch(
msg, command=RPL_CHMODELIST, params=["user", "*", *chmode_pat]
)
else:
self.assertMessageMatch(
msg, command=RPL_CHMODELIST, params=["user", *chmode_pat]
)
got_last_chmode = True
for token in msg.params[-1].split(" "):
name = re.match(capturing_re, token).group("name")
self.assertNotIn(name, seen_chmodes, f"Duplicate chmode {name}")
seen_chmodes.add(name)
elif msg.command == RPL_UMODELIST:
self.assertFalse(
got_last_umode, "Got RPL_UMODELIST after the list ended."
)
if msg.params[1] == "*":
self.assertMessageMatch(
msg, command=RPL_UMODELIST, params=["user", "*", *umode_pat]
)
else:
self.assertMessageMatch(
msg, command=RPL_UMODELIST, params=["user", *umode_pat]
)
got_last_umode = True
for token in msg.params[-1].split(" "):
name = re.match(capturing_re, token).group("name")
self.assertNotIn(name, seen_umodes, f"Duplicate umode {name}")
seen_umodes.add(name)
self.assertIn(
"noextmsg", seen_chmodes, "'noextmsg' chmode not supported/advertised"
)
self.assertIn(
"invisible", seen_umodes, "'invisible' umode not supported/advertised"
)
unknown_chmodes = {m for m in seen_chmodes if "/" not in m} - CHMODES
unknown_umodes = {m for m in seen_umodes if "/" not in m} - UMODES
self.assertFalse(
unknown_chmodes, fail_msg="Got unknown unvendored chmodes: {got}"
)
self.assertFalse(
unknown_umodes, fail_msg="Got unknown unvendored umodes: {got}"
)
class OverlyStrictNamedModesTestCase(_NamedModeTestMixin, cases.BaseServerTestCase):
"""Stronger tests, that assert the server only sends PROP and never MODE.
Passing these tests is not required to"""
ALLOW_MODE_REPLY = False

View File

@ -11,7 +11,7 @@ from irctest.patma import ANYSTR, StrRe
class NamesTestCase(cases.BaseServerTestCase): class NamesTestCase(cases.BaseServerTestCase):
def _testNames(self, symbol: bool, allow_trailing_space: bool): def _testNames(self, symbol):
self.connectClient("nick1") self.connectClient("nick1")
self.sendLine(1, "JOIN #chan") self.sendLine(1, "JOIN #chan")
self.getMessages(1) self.getMessages(1)
@ -31,10 +31,7 @@ class NamesTestCase(cases.BaseServerTestCase):
"nick1", "nick1",
*(["="] if symbol else []), *(["="] if symbol else []),
"#chan", "#chan",
StrRe( StrRe("(nick2 @nick1|@nick1 nick2)"),
"(nick2 @nick1|@nick1 nick2)"
+ (" ?" if allow_trailing_space else "")
),
], ],
) )
@ -47,59 +44,20 @@ class NamesTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", deprecated=True) @cases.mark_specifications("RFC1459", deprecated=True)
def testNames1459(self): def testNames1459(self):
""" """
https://modern.ircdocs.horse/#names-message
https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
""" """
self._testNames(symbol=False, allow_trailing_space=True) self._testNames(symbol=False)
@cases.mark_specifications("RFC2812", "Modern") @cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testNames2812(self): def testNames2812(self):
"""
https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
"""
self._testNames(symbol=True, allow_trailing_space=True)
@cases.mark_specifications("Modern")
@cases.xfailIfSoftware(
["Bahamut", "irc2"], "Bahamut and irc2 send a trailing space in RPL_NAMREPLY"
)
def testNamesModern(self):
""" """
https://modern.ircdocs.horse/#names-message https://modern.ircdocs.horse/#names-message
""" https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5
self._testNames(symbol=True, allow_trailing_space=False)
@cases.mark_specifications("RFC2812", "Modern")
def testNames2812Secret(self):
"""The symbol sent for a secret channel is `@` instead of `=`:
https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5
https://modern.ircdocs.horse/#rplnamreply-353
""" """
self.connectClient("nick1") self._testNames(symbol=True)
self.sendLine(1, "JOIN #chan")
# enable secret channel mode
self.sendLine(1, "MODE #chan +s")
self.getMessages(1)
self.sendLine(1, "NAMES #chan")
messages = self.getMessages(1)
self.assertMessageMatch(
messages[0],
command=RPL_NAMREPLY,
params=["nick1", "@", "#chan", StrRe("@nick1 ?")],
)
self.assertMessageMatch(
messages[1],
command=RPL_ENDOFNAMES,
params=["nick1", "#chan", ANYSTR],
)
self.connectClient("nick2")
self.sendLine(2, "JOIN #chan")
namreplies = [msg for msg in self.getMessages(2) if msg.command == RPL_NAMREPLY]
self.assertNotEqual(len(namreplies), 0)
for msg in namreplies:
self.assertMessageMatch(
msg, command=RPL_NAMREPLY, params=["nick2", "@", "#chan", ANYSTR]
)
def _testNamesMultipleChannels(self, symbol): def _testNamesMultipleChannels(self, symbol):
self.connectClient("nick1") self.connectClient("nick1")

View File

@ -42,21 +42,14 @@ class RegressionsTestCase(cases.BaseServerTestCase):
self.getMessages(1) self.getMessages(1)
self.getMessages(2) self.getMessages(2)
# 'alice' is claimed, so 'Alice' is reserved and Bob cannot take it: # case change: both alice and bob should get a successful nick line
self.sendLine(2, "NICK Alice")
ms = self.getMessages(2)
self.assertEqual(len(ms), 1)
self.assertMessageMatch(ms[0], command=ERR_NICKNAMEINUSE)
# but alice can change case to 'Alice'; both alice and bob should get
# a successful NICK line
self.sendLine(1, "NICK Alice") self.sendLine(1, "NICK Alice")
ms = self.getMessages(1) ms = self.getMessages(1)
self.assertEqual(len(ms), 1) self.assertEqual(len(ms), 1)
self.assertMessageMatch(ms[0], nick="alice", command="NICK", params=["Alice"]) self.assertMessageMatch(ms[0], command="NICK", params=["Alice"])
ms = self.getMessages(2) ms = self.getMessages(2)
self.assertEqual(len(ms), 1) self.assertEqual(len(ms), 1)
self.assertMessageMatch(ms[0], nick="alice", command="NICK", params=["Alice"]) self.assertMessageMatch(ms[0], command="NICK", params=["Alice"])
# no responses, either to the user or to friends, from a no-op nick change # no responses, either to the user or to friends, from a no-op nick change
self.sendLine(1, "NICK Alice") self.sendLine(1, "NICK Alice")
@ -197,27 +190,3 @@ class RegressionsTestCase(cases.BaseServerTestCase):
self.sendLine(2, "USER u s e r") self.sendLine(2, "USER u s e r")
reply = self.getRegistrationMessage(2) reply = self.getRegistrationMessage(2)
self.assertMessageMatch(reply, command=RPL_WELCOME) self.assertMessageMatch(reply, command=RPL_WELCOME)
@cases.mark_specifications("IRCv3")
def testLabeledNick(self):
"""
InspIRCd up to 3.16.1 used the new nick as source of NICK changes
https://github.com/inspircd/inspircd/issues/2067
https://github.com/inspircd/inspircd/commit/83f01b36a11734fd91a4e7aad99c15463858fe4a
"""
self.connectClient(
"alice",
capabilities=["batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.sendLine(1, "@label=abc NICK alice2")
self.assertMessageMatch(
self.getMessage(1),
nick="alice",
command="NICK",
params=["alice2"],
tags={"label": "abc", **ANYDICT},
)

View File

@ -1,7 +1,7 @@
import base64 import base64
from irctest import cases, runner, scram from irctest import cases, runner, scram
from irctest.numerics import ERR_SASLFAIL, RPL_LOGGEDIN, RPL_SASLMECHS from irctest.numerics import ERR_SASLFAIL
from irctest.patma import ANYSTR from irctest.patma import ANYSTR
@ -48,37 +48,11 @@ class SaslTestCase(cases.BaseServerTestCase):
m = self.getRegistrationMessage(1) m = self.getRegistrationMessage(1)
self.assertMessageMatch( self.assertMessageMatch(
m, m,
command=RPL_LOGGEDIN, command="900",
params=[ANYSTR, ANYSTR, "jilles", ANYSTR], params=[ANYSTR, ANYSTR, "jilles", ANYSTR],
fail_msg="Unexpected reply to correct SASL authentication: {msg}", fail_msg="Unexpected reply to correct SASL authentication: {msg}",
) )
@cases.mark_specifications("IRCv3")
@cases.skipUnlessHasMechanism("PLAIN")
def testPlainFailure(self):
"""PLAIN authentication with incorrect username/password."""
self.controller.registerUser(self, "jilles", "sesame")
self.addClient()
self.requestCapabilities(1, ["sasl"], skip_if_cap_nak=False)
self.sendLine(1, "AUTHENTICATE PLAIN")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="AUTHENTICATE",
params=["+"],
fail_msg="Sent “AUTHENTICATE PLAIN”, server should have "
"replied with “AUTHENTICATE +”, but instead sent: {msg}",
)
# password 'millet'
self.sendLine(1, "AUTHENTICATE amlsbGVzAGppbGxlcwBtaWxsZXQ=")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command=ERR_SASLFAIL,
params=[ANYSTR, ANYSTR],
fail_msg="Unexpected reply to incorrect SASL authentication: {msg}",
)
@cases.mark_specifications("IRCv3") @cases.mark_specifications("IRCv3")
@cases.skipUnlessHasMechanism("PLAIN") @cases.skipUnlessHasMechanism("PLAIN")
def testPlainNonAscii(self): def testPlainNonAscii(self):
@ -187,11 +161,11 @@ class SaslTestCase(cases.BaseServerTestCase):
self.requestCapabilities(1, ["sasl"], skip_if_cap_nak=False) self.requestCapabilities(1, ["sasl"], skip_if_cap_nak=False)
self.sendLine(1, "AUTHENTICATE FOO") self.sendLine(1, "AUTHENTICATE FOO")
m = self.getRegistrationMessage(1) m = self.getRegistrationMessage(1)
while m.command == RPL_SASLMECHS: while m.command == "908": # RPL_SASLMECHS
m = self.getRegistrationMessage(1) m = self.getRegistrationMessage(1)
self.assertMessageMatch( self.assertMessageMatch(
m, m,
command=ERR_SASLFAIL, command="904",
fail_msg="Did not reply with 904 to “AUTHENTICATE FOO”: {msg}", fail_msg="Did not reply with 904 to “AUTHENTICATE FOO”: {msg}",
) )

View File

@ -5,7 +5,6 @@
""" """
from irctest import cases, runner from irctest import cases, runner
from irctest.numerics import ERR_ERRONEUSNICKNAME
from irctest.patma import ANYSTR from irctest.patma import ANYSTR
@ -54,23 +53,15 @@ class Utf8TestCase(cases.BaseServerTestCase):
raise runner.IsupportTokenNotSupported("UTF8ONLY") raise runner.IsupportTokenNotSupported("UTF8ONLY")
self.addClient() self.addClient()
self.sendLine(2, "NICK bar") self.sendLine(2, "NICK foo")
self.clients[2].conn.sendall(b"USER username * * :i\xe8rc\xe9\r\n") self.clients[2].conn.sendall(b"USER username * * :i\xe8rc\xe9\r\n")
d = b"" d = self.clients[2].conn.recv(1024)
while True: if b" FAIL " in d or b" 468 " in d: # ERR_INVALIDUSERNAME
try:
buf = self.clients[2].conn.recv(1024)
except TimeoutError:
break
if d and not buf:
break
d += buf
if b"FAIL " in d or b"ERROR " in d or b"468 " in d: # ERR_INVALIDUSERNAME
return # nothing more to test return # nothing more to test
self.assertIn(b" 001 ", d) self.assertIn(b" 001 ", d)
self.sendLine(2, "WHOIS bar") self.sendLine(2, "WHOIS foo")
self.getMessages(2) self.getMessages(2)
def testNonutf8Username(self): def testNonutf8Username(self):
@ -79,56 +70,14 @@ class Utf8TestCase(cases.BaseServerTestCase):
raise runner.IsupportTokenNotSupported("UTF8ONLY") raise runner.IsupportTokenNotSupported("UTF8ONLY")
self.addClient() self.addClient()
self.sendLine(2, "NICK bar") self.sendLine(2, "NICK foo")
self.clients[2].conn.sendall(b"USER \xe8rc\xe9 * * :readlname\r\n") self.sendLine(2, "USER 😊😊😊😊😊😊😊😊😊😊 * * :realname")
m = self.getRegistrationMessage(2)
d = b"" if m.command in ("FAIL", "468"): # ERR_INVALIDUSERNAME
while True:
try:
buf = self.clients[2].conn.recv(1024)
except TimeoutError:
break
if d and not buf:
break
d += buf
if b"FAIL " in d or b"ERROR " in d or b"468 " in d: # ERR_INVALIDUSERNAME
return # nothing more to test return # nothing more to test
self.assertIn(b"001 ", d)
self.sendLine(2, "WHOIS bar")
self.getMessages(2)
class ErgoUtf8NickEnabledTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["server"].update(
{"casemapping": "precis"},
)
)
@cases.mark_specifications("Ergo")
def testUtf8NonAsciiNick(self):
"""Ergo accepts certain non-ASCII UTF8 nicknames if PRECIS is enabled."""
self.connectClient("ıl")
self.joinChannel(1, "#test")
self.connectClient("Claire")
self.joinChannel(2, "#test")
self.sendLine(1, "PRIVMSG #test :hi there")
self.getMessages(1)
self.assertMessageMatch( self.assertMessageMatch(
self.getMessage(2), nick="ıl", params=["#test", "hi there"] m,
command="001",
) )
self.sendLine(2, "WHOIS foo")
self.getMessages(2)
class ErgoUtf8NickDisabledTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testUtf8NonAsciiNick(self):
"""Ergo rejects non-ASCII nicknames in its default configuration."""
self.addClient(1)
self.sendLine(1, "USER u s e r")
self.sendLine(1, "NICK Işıl")
self.assertMessageMatch(self.getMessage(1), command=ERR_ERRONEUSNICKNAME)

View File

@ -60,7 +60,7 @@ class BaseWhoTestCase:
"*", # no chan "*", # no chan
StrRe("~?" + self.username), StrRe("~?" + self.username),
StrRe(host_re), StrRe(host_re),
StrRe(r"(My.Little.Server|\*)"), "My.Little.Server",
"coolNick", "coolNick",
flags, flags,
StrRe(realname_regexp(self.realname)), StrRe(realname_regexp(self.realname)),
@ -76,7 +76,7 @@ class BaseWhoTestCase:
"#chan", "#chan",
StrRe("~?" + self.username), StrRe("~?" + self.username),
StrRe(host_re), StrRe(host_re),
StrRe(r"(My.Little.Server|\*)"), "My.Little.Server",
"coolNick", "coolNick",
flags + "@", flags + "@",
StrRe(realname_regexp(self.realname)), StrRe(realname_regexp(self.realname)),
@ -87,7 +87,7 @@ class BaseWhoTestCase:
class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase): class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
def testWhoStar(self): def testWhoStar(self):
if self.controller.software_name in ("Bahamut",): if self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self._init() self._init()
@ -118,7 +118,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
) )
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
def testWhoNick(self, mask): def testWhoNick(self, mask):
if "*" in mask and self.controller.software_name in ("Bahamut",): if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self._init() self._init()
@ -148,7 +148,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
ids=["username", "realname-mask", "hostname"], ids=["username", "realname-mask", "hostname"],
) )
def testWhoUsernameRealName(self, mask): def testWhoUsernameRealName(self, mask):
if "*" in mask and self.controller.software_name in ("Bahamut",): if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self._init() self._init()
@ -201,7 +201,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
) )
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
def testWhoNickAway(self, mask): def testWhoNickAway(self, mask):
if "*" in mask and self.controller.software_name in ("Bahamut",): if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self._init() self._init()
@ -235,7 +235,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
) )
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
def testWhoNickOper(self, mask): def testWhoNickOper(self, mask):
if "*" in mask and self.controller.software_name in ("Bahamut",): if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self._init() self._init()
@ -274,7 +274,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
) )
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
def testWhoNickAwayAndOper(self, mask): def testWhoNickAwayAndOper(self, mask):
if "*" in mask and self.controller.software_name in ("Bahamut",): if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self._init() self._init()
@ -308,7 +308,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
@pytest.mark.parametrize("mask", ["#chan", "#CHAN"], ids=["exact", "casefolded"]) @pytest.mark.parametrize("mask", ["#chan", "#CHAN"], ids=["exact", "casefolded"])
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
def testWhoChan(self, mask): def testWhoChan(self, mask):
if "*" in mask and self.controller.software_name in ("Bahamut",): if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self._init() self._init()
@ -336,7 +336,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
"#chan", "#chan",
StrRe("~?" + self.username), StrRe("~?" + self.username),
StrRe(host_re), StrRe(host_re),
StrRe(r"(My.Little.Server|\*)"), "My.Little.Server",
"coolNick", "coolNick",
"G@", "G@",
StrRe(realname_regexp(self.realname)), StrRe(realname_regexp(self.realname)),
@ -351,7 +351,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
"#chan", "#chan",
ANYSTR, ANYSTR,
ANYSTR, ANYSTR,
StrRe(r"(My.Little.Server|\*)"), "My.Little.Server",
"otherNick", "otherNick",
"H", "H",
StrRe("[0-9]+ .*"), StrRe("[0-9]+ .*"),
@ -398,7 +398,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
chan, chan,
ANYSTR, ANYSTR,
ANYSTR, ANYSTR,
StrRe(r"(My.Little.Server|\*)"), "My.Little.Server",
"coolNick", "coolNick",
ANYSTR, ANYSTR,
ANYSTR, ANYSTR,
@ -413,7 +413,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
chan, chan,
ANYSTR, ANYSTR,
ANYSTR, ANYSTR,
StrRe(r"(My.Little.Server|\*)"), "My.Little.Server",
"otherNick", "otherNick",
ANYSTR, ANYSTR,
ANYSTR, ANYSTR,
@ -479,7 +479,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
StrRe("~?myusernam"), StrRe("~?myusernam"),
ANYSTR, ANYSTR,
ANYSTR, ANYSTR,
StrRe(r"(My.Little.Server|\*)"), "My.Little.Server",
"coolNick", "coolNick",
StrRe("H@?"), StrRe("H@?"),
ANYSTR, # hopcount ANYSTR, # hopcount
@ -632,7 +632,7 @@ class WhoServicesTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
class WhoInvisibleTestCase(cases.BaseServerTestCase): class WhoInvisibleTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern") @cases.mark_specifications("Modern")
def testWhoInvisible(self): def testWhoInvisible(self):
if self.controller.software_name in ("Bahamut",): if self.controller.software_name in ("Bahamut", "Sable"):
raise runner.OptionalExtensionNotSupported("WHO mask") raise runner.OptionalExtensionNotSupported("WHO mask")
self.connectClient("evan", name="evan") self.connectClient("evan", name="evan")

View File

@ -8,7 +8,6 @@ import pytest
from irctest import cases from irctest import cases
from irctest.numerics import ( from irctest.numerics import (
ERR_NOSUCHNICK,
RPL_AWAY, RPL_AWAY,
RPL_ENDOFWHOIS, RPL_ENDOFWHOIS,
RPL_WHOISACCOUNT, RPL_WHOISACCOUNT,
@ -57,7 +56,6 @@ class _WhoisTestMixin(cases.BaseServerTestCase):
[m.command for m in self.getMessages(1)], [m.command for m in self.getMessages(1)],
fail_msg="OPER failed", fail_msg="OPER failed",
) )
self.getMessages(1) # make sure we did get all oper-up messages
self.sendLine(1, "WHOIS nick2") self.sendLine(1, "WHOIS nick2")
@ -97,9 +95,7 @@ class _WhoisTestMixin(cases.BaseServerTestCase):
params=[ params=[
"nick1", "nick1",
"nick2", "nick2",
# trailing space was required by the RFCs, and Modern explicitly StrRe("(@#chan1 @#chan2|@#chan2 @#chan1)"),
# allows it
StrRe("(@#chan1 @#chan2|@#chan2 @#chan1) ?"),
], ],
) )
elif m.command == RPL_WHOISSPECIAL: elif m.command == RPL_WHOISSPECIAL:
@ -220,25 +216,6 @@ class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase):
whois_user.params[3], [nick, username, "~" + username, realname] whois_user.params[3], [nick, username, "~" + username, realname]
) )
@cases.mark_specifications("RFC2812")
@cases.xfailIfSoftware(["Sable"], "https://github.com/Libera-Chat/sable/issues/101")
def testWhoisMissingUser(self):
"""Test WHOIS on a nonexistent nickname."""
self.connectClient("qux", name="qux")
self.sendLine("qux", "WHOIS bar")
messages = self.getMessages("qux")
self.assertEqual(len(messages), 2)
self.assertMessageMatch(
messages[0],
command=ERR_NOSUCHNICK,
params=["qux", "bar", ANYSTR],
)
self.assertMessageMatch(
messages[1],
command=RPL_ENDOFWHOIS,
params=["qux", "bar", ANYSTR],
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"away,oper", "away,oper",
[(False, False), (True, False), (False, True)], [(False, False), (True, False), (False, True)],

View File

@ -7,7 +7,6 @@ The WHOSWAS command (`RFC 1459
TODO: cross-reference Modern TODO: cross-reference Modern
""" """
import time
import pytest import pytest
@ -145,8 +144,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
except ConnectionClosed: except ConnectionClosed:
pass pass
time.sleep(1) # Ergo may take a little while to record the nick as free
self.connectClient("nick2", ident="ident3") self.connectClient("nick2", ident="ident3")
self.sendLine(3, "QUIT :bye") self.sendLine(3, "QUIT :bye")
try: try:
@ -154,9 +151,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
except ConnectionClosed: except ConnectionClosed:
pass pass
if self.controller.software_name == "Sable":
time.sleep(1) # may take a little while to record the historical user
self.sendLine(1, whowas_command) self.sendLine(1, whowas_command)
messages = self.getMessages(1) messages = self.getMessages(1)

View File

@ -38,6 +38,7 @@ class Capabilities(enum.Enum):
MESSAGE_TAGS = "message-tags" MESSAGE_TAGS = "message-tags"
MULTILINE = "draft/multiline" MULTILINE = "draft/multiline"
MULTI_PREFIX = "multi-prefix" MULTI_PREFIX = "multi-prefix"
NAMED_MODES = "draft/named-modes"
SERVER_TIME = "server-time" SERVER_TIME = "server-time"
SETNAME = "setname" SETNAME = "setname"
STS = "sts" STS = "sts"

View File

@ -65,7 +65,7 @@ def get_install_steps(*, software_config, software_id, version_flavor):
install_steps = [ install_steps = [
{ {
"name": f"Checkout {name}", "name": f"Checkout {name}",
"uses": "actions/checkout@v4", "uses": "actions/checkout@v3",
"with": { "with": {
"repository": software_config["repository"], "repository": software_config["repository"],
"ref": ref, "ref": ref,
@ -94,7 +94,7 @@ def get_build_job(*, software_config, software_id, version_flavor):
cache = [ cache = [
{ {
"name": "Cache dependencies", "name": "Cache dependencies",
"uses": "actions/cache@v4", "uses": "actions/cache@v3",
"with": { "with": {
"path": f"~/.cache\n${{ github.workspace }}/{path}\n", "path": f"~/.cache\n${{ github.workspace }}/{path}\n",
"key": "3-${{ runner.os }}-" "key": "3-${{ runner.os }}-"
@ -123,10 +123,10 @@ def get_build_job(*, software_config, software_id, version_flavor):
"run": "cd ~/; mkdir -p .local/ go/", "run": "cd ~/; mkdir -p .local/ go/",
}, },
*cache, *cache,
{"uses": "actions/checkout@v4"}, {"uses": "actions/checkout@v3"},
{ {
"name": "Set up Python 3.11", "name": "Set up Python 3.11",
"uses": "actions/setup-python@v5", "uses": "actions/setup-python@v4",
"with": {"python-version": 3.11}, "with": {"python-version": 3.11},
}, },
*install_steps, *install_steps,
@ -160,7 +160,7 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
downloads.append( downloads.append(
{ {
"name": "Download build artefacts", "name": "Download build artefacts",
"uses": "actions/download-artifact@v4", "uses": "actions/download-artifact@v3",
"with": {"name": f"installed-{software_id}", "path": "~"}, "with": {"name": f"installed-{software_id}", "path": "~"},
} }
) )
@ -195,10 +195,10 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
"runs-on": "ubuntu-22.04", "runs-on": "ubuntu-22.04",
"needs": needs, "needs": needs,
"steps": [ "steps": [
{"uses": "actions/checkout@v4"}, {"uses": "actions/checkout@v3"},
{ {
"name": "Set up Python 3.11", "name": "Set up Python 3.11",
"uses": "actions/setup-python@v5", "uses": "actions/setup-python@v4",
"with": {"python-version": 3.11}, "with": {"python-version": 3.11},
}, },
*downloads, *downloads,
@ -212,7 +212,7 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
"name": "Install irctest dependencies", "name": "Install irctest dependencies",
"run": script( "run": script(
"python -m pip install --upgrade pip", "python -m pip install --upgrade pip",
"pip install pytest pytest-xdist pytest-timeout -r requirements.txt", "pip install pytest pytest-xdist -r requirements.txt",
*( *(
software_config["extra_deps"] software_config["extra_deps"]
if "extra_deps" in software_config if "extra_deps" in software_config
@ -223,11 +223,8 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
{ {
"name": "Test with pytest", "name": "Test with pytest",
"timeout-minutes": 30, "timeout-minutes": 30,
"env": {
"IRCTEST_DEBUG_LOGS": "${{ runner.debug }}",
},
"run": ( "run": (
f"PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' " f"PYTEST_ARGS='--junit-xml pytest.xml' "
f"PATH=$HOME/.local/bin:$PATH " f"PATH=$HOME/.local/bin:$PATH "
f"{env}make {test_id}" f"{env}make {test_id}"
), ),
@ -235,7 +232,7 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
{ {
"name": "Publish results", "name": "Publish results",
"if": "always()", "if": "always()",
"uses": "actions/upload-artifact@v4", "uses": "actions/upload-artifact@v3",
"with": { "with": {
"name": f"pytest-results_{test_id}_{version_flavor.value}", "name": f"pytest-results_{test_id}_{version_flavor.value}",
"path": "pytest.xml", "path": "pytest.xml",
@ -254,7 +251,7 @@ def upload_steps(software_id):
}, },
{ {
"name": "Upload build artefacts", "name": "Upload build artefacts",
"uses": "actions/upload-artifact@v4", "uses": "actions/upload-artifact@v3",
"with": { "with": {
"name": f"installed-{software_id}", "name": f"installed-{software_id}",
"path": "~/artefacts-*.tar.gz", "path": "~/artefacts-*.tar.gz",
@ -315,10 +312,10 @@ def generate_workflow(config: dict, version_flavor: VersionFlavor):
# this job then # this job then
"if": "success() || failure()", "if": "success() || failure()",
"steps": [ "steps": [
{"uses": "actions/checkout@v4"}, {"uses": "actions/checkout@v3"},
{ {
"name": "Download Artifacts", "name": "Download Artifacts",
"uses": "actions/download-artifact@v4", "uses": "actions/download-artifact@v3",
"with": {"path": "artifacts"}, "with": {"path": "artifacts"},
}, },
{ {

View File

@ -1,5 +1,5 @@
[mypy] [mypy]
python_version = 3.8 python_version = 3.7
warn_return_any = True warn_return_any = True
warn_unused_configs = True warn_unused_configs = True

View File

@ -0,0 +1,25 @@
When a client registers (ie. sends USER+NICK), InspIRCd does not
immediately answers with 001. Instead it waits for the next iteration
of the main loop to call `DoBackgroundUserStuff`.
However, this main loop executes only once a second. This is usually
fine, but makes irctest considerably slower, as irctest uses hundreds
of very short-lived connections.
This patch removes the frequency limitation of the main loop to make
InspIRCd more responsive.
diff --git a/src/inspircd.cpp b/src/inspircd.cpp
index 5760e631b..1da0285fb 100644
--- a/src/inspircd.cpp
+++ b/src/inspircd.cpp
@@ -680,7 +680,7 @@ void InspIRCd::Run()
* timing using this event, so we dont have to
* time this exactly).
*/
- if (TIME.tv_sec != OLDTIME)
+ if (true)
{
CollectStats();
CheckTimeSkip(OLDTIME, TIME.tv_sec);

View File

@ -1,5 +1,5 @@
[tool.black] [tool.black]
target-version = ['py38'] target-version = ['py37']
exclude = 'irctest/scram/*' exclude = 'irctest/scram/*'
[tool.isort] [tool.isort]

View File

@ -29,6 +29,7 @@ markers =
message-tags message-tags
draft/multiline draft/multiline
multi-prefix multi-prefix
draft/named-modes
server-time server-time
setname setname
sts sts

View File

@ -134,9 +134,9 @@ software:
path: ergo path: ergo
prefix: ~/go prefix: ~/go
pre_deps: pre_deps:
- uses: actions/setup-go@v3 - uses: actions/setup-go@v2
with: with:
go-version: '^1.22.0' go-version: '^1.21.0'
- run: go version - run: go version
separate_build_job: false separate_build_job: false
build_script: | build_script: |
@ -148,7 +148,7 @@ software:
name: InspIRCd name: InspIRCd
repository: inspircd/inspircd repository: inspircd/inspircd
refs: &inspircd_refs refs: &inspircd_refs
stable: v3.17.1 stable: v3.15.0
release: null release: null
devel: master devel: master
devel_release: insp3 devel_release: insp3
@ -158,7 +158,13 @@ software:
separate_build_job: true separate_build_job: true
build_script: &inspircd_build_script | build_script: &inspircd_build_script |
cd $GITHUB_WORKSPACE/inspircd/ cd $GITHUB_WORKSPACE/inspircd/
# Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true
wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp
./configure --prefix=$HOME/.local/inspircd --development ./configure --prefix=$HOME/.local/inspircd --development
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4 CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install make install
irc2: irc2:
@ -230,7 +236,7 @@ software:
name: ngircd name: ngircd
repository: ngircd/ngircd repository: ngircd/ngircd
refs: refs:
stable: acf8409c60ccc96beed0a1f990c4f9374823c0ce # three months ahead of v27 stable: 0714466af88d71d6c395629cd7fb624b099507d4 # two years ahead of rel-26.1
release: null release: null
devel: master devel: master
devel_release: null devel_release: null
@ -249,7 +255,7 @@ software:
name: Sable name: Sable
repository: Libera-Chat/sable repository: Libera-Chat/sable
refs: refs:
stable: e9701e5e8d0c4f278ddd61ce7285f4918ecf99e9 stable: ff1179512a79eba57ca468a5f83af84ecce08a5b
release: null release: null
devel: master devel: master
devel_release: null devel_release: null
@ -300,8 +306,8 @@ software:
name: UnrealIRCd 6 name: UnrealIRCd 6
repository: unrealircd/unrealircd repository: unrealircd/unrealircd
refs: refs:
stable: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1 stable: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
release: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1 release: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
devel: unreal60_dev devel: unreal60_dev
devel_release: null devel_release: null
path: unrealircd path: unrealircd
@ -343,16 +349,16 @@ software:
separate_build_job: true separate_build_job: true
path: anope path: anope
refs: refs:
stable: "2.0.14" stable: "2.0.9"
release: "2.1.1" release: "2.0.9"
devel: "2.1" devel: "2.0.9"
devel_release: "2.0" devel_release: "2.0.9"
build_script: | build_script: |
cd $GITHUB_WORKSPACE/anope/ cd $GITHUB_WORKSPACE/anope/
sudo apt-get install ninja-build --no-install-recommends cp $GITHUB_WORKSPACE/data/anope/* .
mkdir build && cd build CFLAGS=-O0 ./Config -quick
cmake -DCMAKE_INSTALL_PREFIX=$HOME/.local/ -DPROGRAM_NAME=anope -DUSE_PCH=ON -GNinja .. make -C build -j 4
ninja install make -C build install
dlk: dlk:
name: Dlk name: Dlk
@ -389,7 +395,7 @@ software:
run: pip install limnoria cryptography pyxmpp2-scram run: pip install limnoria cryptography pyxmpp2-scram
devel: devel:
- name: Install dependencies - name: Install dependencies
run: pip install git+https://github.com/progval/Limnoria.git@master cryptography pyxmpp2-scram run: pip install git+https://github.com/ProgVal/Limnoria.git@testing cryptography pyxmpp2-scram
devel_release: null devel_release: null
sopel: sopel: