37 Commits

Author SHA1 Message Date
6f1f54b9b8 Host is required 2024-12-08 22:09:47 +01:00
622527ea12 Enable debug logs 2024-12-08 21:46:16 +01:00
a1437277d5 Wait for sable_history to be up 2024-12-08 21:44:28 +01:00
b843581e3f sable: Rely on SASL PLAIN instead of NickServ to check for services availability 2024-12-08 17:46:17 +01:00
c97753da4e Don't run DM tests from SablePostgresqlHistoryTestCase 2024-12-08 16:10:50 +01:00
4cfb7665c6 Use -m instead of -k to filter markers
-k uses substring match, so -k Sable matched ErgoUtf8NickDisabledTestCase
2024-12-08 14:54:19 +01:00
e0b4aec24a Bump sable 2024-12-08 09:42:08 +01:00
388506c3d4 Fix typo in Ergo selectors 2024-12-08 09:36:07 +01:00
a0a859d4fa Fix stderr/stdout typo 2024-11-19 18:01:32 +01:00
a1bb20323a Start postgresql 2024-11-17 18:11:38 +01:00
895849ed93 Fix lint 2024-11-17 18:11:38 +01:00
4983db7cad Merge branch 'master' into sable-pg-history 2024-11-17 18:00:47 +01:00
a9c87eae91 Bump Sable 2024-11-17 17:45:05 +01:00
0dc5a0fdda Need fanout > 1 when more than 2 nodes.
And restore faketime config to previous value, as that wasn't the right fix
2024-11-10 18:52:07 +01:00
827c6a6df4 Actually print logs from sable_services and sable_history 2024-11-10 17:08:01 +01:00
2680502dfe Allow any extban format on InspIRCd. (#294)
This should stop the mute extban test failing when extbanformat is set to name (which will soon be the default in git master).
2024-11-03 10:24:05 +01:00
396841ebff Another workaround for Sable's latency with the history server 2024-10-28 20:23:07 +01:00
d090f5455e Update Sable and enable more test (#293) 2024-10-28 18:15:10 +01:00
f350ff0b96 Make tests less flaky on Sable 2024-10-27 18:35:54 +01:00
b274cad65b Add tests for Sable's postgresql chathistory backend 2024-10-27 18:17:57 +01:00
c31aaf4d61 Bump Bahamut (#291)
The patch does not apply to the latest master, so we need to update it
2024-10-10 20:59:59 +02:00
7b0ee7589f sable: Add object_expiry setting to sable config, required since 015bcc4 2024-10-08 22:54:00 +02:00
d202e440bb upgrade ergo's Go version to 1.23 (#289)
https://github.com/ergochat/ergo/pull/2187
2024-08-16 08:30:52 +02:00
03ad671951 testLineTooLong: Ensure server processed incoming message before reading from the other client (#288) 2024-08-15 20:34:16 +02:00
e3485b92b4 Bump UnrealIRCd and ngIRCd (#285) 2024-07-27 18:14:57 +02:00
75d9040d37 Allow enabling debug output via environment variables. (#284) 2024-07-24 20:35:37 +02:00
a132440789 add various channel mode tests (#276) 2024-07-07 08:33:48 +02:00
aaa2e26b6e Fix file name 2024-06-23 20:37:10 +02:00
052198c61b Add support for Hybrid > 8.2.44 (#283)
The module system changed, modules now need to be loaded explicitly
2024-06-21 21:38:16 +02:00
9f33633cc7 Fix the help filename as of the latest commit. (#282) 2024-06-17 19:15:02 +02:00
465f6637ed enable backtraces in sable (#280) 2024-06-15 08:25:14 +02:00
9856317a64 fix buffering test
The test for erroneous ERR_INPUTTOOLONG was counting codepoints instead
of bytes, consequently underestimating the actual relayed size of the message.
2024-06-11 01:37:23 -04:00
af980ed3b6 Remove use of deprecated config settings on InspIRCd 3+. 2024-06-07 18:35:45 +02:00
15c077d511 Update the GitHub Actions dependencies used by make_workflows.
Fixes various CI warnings.
2024-06-07 17:54:40 +02:00
330300eba1 Update for the new InspIRCd development branch. 2024-06-07 15:58:47 +02:00
f265e28702 update multiline tests (#275) 2024-06-04 07:18:16 +02:00
e3ffff6ad4 Add tests for joining channels with keys (#274) 2024-06-01 17:04:01 +02:00
38 changed files with 1594 additions and 566 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ jobs:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- name: Cache dependencies
uses: actions/cache@v3
uses: actions/cache@v4
with:
key: 3-${{ runner.os }}-anope-devel_release
path: '~/.cache
@ -16,13 +16,13 @@ jobs:
${ github.workspace }/anope
'
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Checkout Anope
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
path: anope
ref: '2.0'
@ -37,7 +37,7 @@ jobs:
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: installed-anope
path: ~/artefacts-*.tar.gz
@ -47,13 +47,13 @@ jobs:
steps:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Checkout InspIRCd
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
path: inspircd
ref: insp3
@ -67,7 +67,7 @@ jobs:
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: installed-inspircd
path: ~/artefacts-*.tar.gz
@ -81,9 +81,9 @@ jobs:
- test-inspircd-atheme
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Download Artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Install dashboard dependencies
@ -108,13 +108,13 @@ jobs:
- build-inspircd
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: installed-inspircd
path: '~'
@ -126,13 +126,15 @@ jobs:
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- name: Test with pytest
- env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
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
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: pytest-results_inspircd_devel_release
path: pytest.xml
@ -142,18 +144,18 @@ jobs:
- build-anope
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: installed-inspircd
path: '~'
- name: Download build artefacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: installed-anope
path: '~'
@ -165,13 +167,15 @@ jobs:
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- name: Test with pytest
- env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
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
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: pytest-results_inspircd-anope_devel_release
path: pytest.xml
@ -180,13 +184,13 @@ jobs:
- build-inspircd
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v4
with:
name: installed-inspircd
path: '~'
@ -198,13 +202,15 @@ jobs:
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- name: Test with pytest
- env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
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
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: pytest-results_inspircd-atheme_devel_release
path: pytest.xml

File diff suppressed because it is too large Load Diff

191
Makefile
View File

@ -4,112 +4,161 @@ PYTEST ?= python3 -m pytest
# pytest-xdist is installed)
PYTEST_ARGS ?=
# Will be appended at the end of the -m argument to pytest
EXTRA_MARKERS ?=
# Will be appended at the end of the -k argument to pytest
EXTRA_SELECTORS ?=
BAHAMUT_SELECTORS := \
not Ergo \
BAHAMUT_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_MARKERS)
BAHAMUT_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
CHARYBDIS_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
CHARYBDIS_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
ERGO_MARKERS := \
(Ergo or not implementation-specific) \
and not deprecated \
$(EXTRA_MARKERS)
ERGO_SELECTORS := \
not deprecated \
(foo or not foo) \
$(EXTRA_SELECTORS)
HYBRID_MARKERS := \
not implementation-specific \
and not deprecated \
$(EXTRA_MARKERS)
HYBRID_SELECTORS := \
not Ergo \
and not deprecated \
(foo or not foo) \
$(EXTRA_SELECTORS)
INSPIRCD_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
INSPIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
IRCU2_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_MARKERS)
IRCU2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
NEFARIOUS_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
NEFARIOUS_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
SNIRCD_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_MARKERS)
SNIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
IRC2_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_MARKERS)
IRC2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
MAMMON_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
MAMMON_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
NGIRCD_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
NGIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
(foo or not foo) \
$(EXTRA_SELECTORS)
PLEXUS4_MARKERS := \
not implementation-specific \
and not deprecated \
$(EXTRA_MARKERS)
PLEXUS4_SELECTORS := \
not Ergo \
and not deprecated \
(foo or not foo) \
$(EXTRA_SELECTORS)
# Limnoria can actually pass all the test so there is none to exclude.
# `(foo or not foo)` serves as a `true` value so it doesn't break when
# $(EXTRA_SELECTORS) is non-empty
LIMNORIA_MARKERS := \
not implementation-specific \
$(EXTRA_MARKERS)
LIMNORIA_SELECTORS := \
(foo or not foo) \
$(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 := \
not Ergo \
# 'SablePostgresqlHistoryTestCase and private_chathistory' disabled because Sable does not (yet?) persist private messages to postgresql
SABLE_MARKERS := \
(Sable or not implementation-specific) \
and not deprecated \
and not strict \
and not arbitrary_client_tags \
and not react_tag \
and not private_chathistory \
and not list and not lusers and not time and not info \
$(EXTRA_MARKERS)
SABLE_SELECTORS := \
not list and not lusers and not time and not info \
and not (SablePostgresqlHistoryTestCase and private_chathistory) \
$(EXTRA_SELECTORS)
SOLANUM_SELECTORS := \
not Ergo \
SOLANUM_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
SOLANUM_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# Same as Limnoria
SOPEL_MARKERS := \
not implementation-specific \
$(EXTRA_MARKERS)
SOPEL_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# TheLounge can actually pass all the test so there is none to exclude.
# `(foo or not foo)` serves as a `true` value so it doesn't break when
# $(EXTRA_SELECTORS) is non-empty
THELOUNGE_MARKERS := \
not implementation-specific \
$(EXTRA_MARKERS)
THELOUNGE_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
@ -118,13 +167,16 @@ THELOUNGE_SELECTORS := \
# Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149
# Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs
UNREALIRCD_SELECTORS := \
not Ergo \
UNREALIRCD_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not arbitrary_client_tags \
and not react_tag \
and not private_chathistory \
$(EXTRA_MARKERS)
UNREALIRCD_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd
@ -140,107 +192,114 @@ bahamut:
-m 'not services' \
-n 4 \
-vv -s \
-m 'not services and $(BAHAMUT_MARKERS)'
-k '$(BAHAMUT_SELECTORS)'
bahamut-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-m 'services and $(BAHAMUT_MARKERS)' \
-k '$(BAHAMUT_SELECTORS)'
bahamut-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-m 'services and $(BAHAMUT_MARKERS)' \
-k '$(BAHAMUT_SELECTORS)'
charybdis:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.charybdis \
--services-controller=irctest.controllers.atheme_services \
-m '$(CHARYBDIS_MARKERS)'
-k '$(CHARYBDIS_SELECTORS)'
ergo:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ergo \
-m '$(ERGO_MARKERS)'
-k "$(ERGO_SELECTORS)"
hybrid:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.hybrid \
--services-controller=irctest.controllers.anope_services \
-m '$(HYBRID_MARKERS)'
-k "$(HYBRID_SELECTORS)"
inspircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
-m 'not services' \
-m 'not services and $(INSPIRCD_MARKERS)' \
-k '$(INSPIRCD_SELECTORS)'
inspircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-m 'services and $(INSPIRCD_MARKERS)' \
-k '$(INSPIRCD_SELECTORS)'
inspircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-m 'services and $(INSPIRCD_MARKERS)' \
-k '$(INSPIRCD_SELECTORS)'
ircu2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.ircu2 \
-m 'not services and not IRCv3' \
-m 'not services and $(IRCU2_MARKERS)' \
-n 4 \
-k '$(IRCU2_SELECTORS)'
nefarious:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.nefarious \
-m 'not services' \
-m 'not services and $(NEFARIOUS_MARKERS)' \
-n 4 \
-k '$(NEFARIOUS_SELECTORS)'
snircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.snircd \
-m 'not services and not IRCv3' \
-m 'not services and $(SNIRCD_MARKERS)' \
-n 4 \
-k '$(SNIRCD_SELECTORS)'
irc2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.irc2 \
-m 'not services and not IRCv3' \
-m 'not services and $(IRCU2_MARKERS)' \
-n 4 \
-k '$(IRC2_SELECTORS)'
limnoria:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.limnoria \
-m '$(LIMNORIA_MARKERS)' \
-k '$(LIMNORIA_SELECTORS)'
mammon:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.mammon \
-m '$(MAMMON_MARKERS)' \
-k '$(MAMMON_SELECTORS)'
plexus4:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.plexus4 \
--services-controller=irctest.controllers.anope_services \
-m '$(PLEXUS4_MARKERS)' \
-k "$(PLEXUS4_SELECTORS)"
ngircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
-m 'not services' \
-m 'not services and $(NGIRCD_MARKERS)' \
-n 4 \
-k "$(NGIRCD_SELECTORS)"
@ -248,19 +307,20 @@ ngircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-m 'services and $(NGIRCD_MARKERS)' \
-k "$(NGIRCD_SELECTORS)"
ngircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-m 'services and $(NGIRCD_MARKERS)' \
-k "$(NGIRCD_SELECTORS)"
sable:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sable \
-m '$(SABLE_MARKERS)' \
-n 20 \
-k '$(SABLE_SELECTORS)'
@ -268,22 +328,25 @@ solanum:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.solanum \
--services-controller=irctest.controllers.atheme_services \
-m '$(SOLANUM_MARKERS)' \
-k '$(SOLANUM_SELECTORS)'
sopel:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sopel \
-m '$(SOPEL_MARKERS)' \
-k '$(SOPEL_SELECTORS)'
thelounge:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.thelounge \
-m '$(THELOUNGE_MARKERS)' \
-k '$(THELOUNGE_SELECTORS)'
unrealircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
-m 'not services' \
-m 'not services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-5: unrealircd
@ -292,19 +355,19 @@ unrealircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-m 'services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-m 'services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-dlk:
pifpaf run mysql -- $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.dlk_services \
-m 'services' \
-m 'services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)'

View File

@ -8,10 +8,25 @@ from pathlib import Path
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import threading
import time
from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type
from typing import (
IO,
Any,
Callable,
Dict,
Iterator,
List,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
)
import irctest
@ -54,6 +69,9 @@ class TestCaseControllerConfig:
This should be used as little as possible, using the other attributes instead;
as they are work with any controller."""
sable_history_server: bool = False
"""Whether to start Sable's long-term history server"""
class _BaseController:
"""Base class for software controllers.
@ -74,6 +92,7 @@ class _BaseController:
_port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock")
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.proc = None
self._own_ports: Set[Tuple[str, int]] = set()
@ -130,6 +149,50 @@ class _BaseController:
used_ports.remove((hostname, port))
self._own_ports.remove((hostname, port))
def execute(
self,
command: Sequence[Union[str, Path]],
proc_name: Optional[str] = None,
**kwargs: Any,
) -> subprocess.Popen:
output_to = None if self.debug_mode else subprocess.DEVNULL
proc_name = proc_name or str(command[0])
kwargs.setdefault("stdout", output_to)
kwargs.setdefault("stderr", output_to)
stream_stdout = stream_stderr = None
if kwargs["stdout"] in (None, subprocess.STDOUT):
kwargs["stdout"] = subprocess.PIPE
def stream_stdout() -> None:
assert proc.stdout is not None # for mypy
for line in proc.stdout:
prefix = f"{time.time():.3f} {proc_name} ".encode()
try:
sys.stdout.buffer.write(prefix + line)
except ValueError:
# "I/O operation on closed file"
pass
if kwargs["stderr"] in (subprocess.STDOUT, None):
kwargs["stderr"] = subprocess.PIPE
def stream_stderr() -> None:
assert proc.stderr is not None # for mypy
for line in proc.stderr:
prefix = f"{time.time():.3f} {proc_name} ".encode()
try:
sys.stdout.buffer.write(prefix + line)
except ValueError:
# "I/O operation on closed file"
pass
proc = subprocess.Popen(command, **kwargs)
if stream_stdout is not None:
threading.Thread(target=stream_stdout, name="stream_stdout").start()
if stream_stderr is not None:
threading.Thread(target=stream_stderr, name="stream_stderr").start()
return proc
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
@ -253,6 +316,7 @@ class BaseServerController(_BaseController):
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.faketime_enabled = False
self.services_controller = None
def run(
self,

View File

@ -842,16 +842,22 @@ def mark_services(cls: TClass) -> TClass:
def mark_specifications(
*specifications_str: str, deprecated: bool = False, strict: bool = False
) -> Callable[[TCallable], TCallable]:
specifications = frozenset(
specifications = {
Specifications.from_name(s) if isinstance(s, str) else s
for s in specifications_str
)
}
if None in specifications:
raise ValueError("Invalid set of specifications: {}".format(specifications))
is_implementation_specific = all(
spec.is_implementation_specific() for spec in specifications
)
def decorator(f: TCallable) -> TCallable:
for specification in specifications:
f = getattr(pytest.mark, specification.value)(f)
if is_implementation_specific:
f = getattr(pytest.mark, "implementation-specific")(f)
if strict:
f = pytest.mark.strict(f)
if deprecated:

View File

@ -136,16 +136,19 @@ class AnopeController(BaseServicesController, DirectoryBasedController):
Path(services_path).parent.parent / "modules"
)
self.proc = subprocess.Popen(
extra_args = []
if self.debug_mode:
extra_args.append("--debug")
self.proc = self.execute(
[
"anope",
"--config=services.conf", # can't be an absolute path in 2.0
"--nofork", # don't fork
"--nopid", # don't write a pid
*extra_args,
],
cwd=self.directory,
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
)

View File

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

View File

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

View File

@ -1,5 +1,5 @@
from pathlib import Path
import shutil
import subprocess
from typing import Optional
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -51,6 +51,8 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
)
else:
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:
fd.write(
(self.template_config).format(
@ -60,6 +62,7 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
install_prefix=Path(binary_path).parent.parent,
)
)
assert self.directory
@ -70,7 +73,7 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*faketime_cmd,
self.binary_name,
@ -80,7 +83,6 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
"-pidfile",
self.directory / "server.pid",
],
# stderr=subprocess.DEVNULL,
)
if run_services:

View File

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

View File

@ -58,6 +58,11 @@ BASE_CONFIG = {
"enabled": True,
"method": "strict",
},
"login-throttling": {
"enabled": True,
"duration": "1m",
"max-attempts": 3,
},
},
"channels": {"registration": {"enabled": True}},
"datastore": {"path": None},
@ -208,7 +213,7 @@ class ErgoController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
)

View File

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

View File

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

View File

@ -33,7 +33,8 @@ TEMPLATE_CONFIG = """
class="ServerOperators"
>
<options casemapping="ascii">
<options casemapping="ascii"
extbanformat="any">
# Disable 'NOTICE #chan :*** foo invited bar into the channel-
<security announceinvites="none">
@ -48,9 +49,7 @@ TEMPLATE_CONFIG = """
sendpass="password"
>
<module name="spanningtree">
<module name="services_account">
<module name="hidechans"> # Anope errors when missing
<module name="svshold"> # Atheme raises a warning when missing
<sasl requiressl="no"
target="services.example.org">
@ -71,14 +70,10 @@ TEMPLATE_CONFIG = """
<module name="ircv3_servertime">
<module name="monitor">
<module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl">
<module name="uhnames"> # For userhost-in-names
# HELP/HELPOP
<module name="alias"> # for the HELP alias
<module name="{help_module_name}">
<include file="examples/{help_module_name}.conf.example">
{version_config}
# Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
@ -90,6 +85,26 @@ TEMPLATE_SSL_CONFIG = """
<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()
def installed_version() -> int:
@ -98,8 +113,9 @@ def installed_version() -> int:
return 3
if output.startswith("InspIRCd-4"):
return 4
else:
assert False, f"unexpected version: {output}"
if output.startswith("InspIRCd-5"):
return 5
assert False, f"unexpected version: {output}"
class InspircdController(BaseServerController, DirectoryBasedController):
@ -141,9 +157,9 @@ class InspircdController(BaseServerController, DirectoryBasedController):
ssl_config = ""
if installed_version() == 3:
help_module_name = "helpop"
elif installed_version() == 4:
help_module_name = "help"
version_config = TEMPLATE_V3_CONFIG
elif installed_version() >= 4:
version_config = TEMPLATE_V4_CONFIG
else:
assert False, f"unexpected version: {installed_version()}"
@ -156,7 +172,7 @@ class InspircdController(BaseServerController, DirectoryBasedController):
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
help_module_name=help_module_name,
version_config=version_config,
)
)
assert self.directory
@ -167,15 +183,22 @@ class InspircdController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
extra_args = []
if self.debug_mode:
if installed_version() >= 4:
extra_args.append("--protocoldebug")
else:
extra_args.append("--debug")
self.proc = self.execute(
[
*faketime_cmd,
"inspircd",
"--nofork",
"--config",
self.directory / "server.conf",
*extra_args,
],
stdout=subprocess.DEVNULL,
)
if run_services:

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -28,6 +27,9 @@ TEMPLATE_CONFIG = """
[Operator]
Name = operuser
Password = operpassword
[Limits]
MaxNickLength = 32 # defaults to 9
"""
@ -92,7 +94,7 @@ class NgircdController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*faketime_cmd,
"ngircd",
@ -100,7 +102,6 @@ class NgircdController(BaseServerController, DirectoryBasedController):
"--config",
self.directory / "server.conf",
],
# stdout=subprocess.DEVNULL,
)
if run_services:

View File

@ -4,8 +4,9 @@ import shutil
import signal
import subprocess
import tempfile
import threading
import time
from typing import Optional, Type
from typing import Any, Optional, Sequence, Type
from irctest.basecontrollers import (
BaseServerController,
@ -14,6 +15,7 @@ from irctest.basecontrollers import (
NotImplementedByController,
)
from irctest.cases import BaseServerTestCase
from irctest.client_mock import ClientMock
from irctest.exceptions import NoMessageException
from irctest.patma import ANYSTR
@ -85,7 +87,13 @@ def certs_dir() -> Path:
certs_dir = tempfile.TemporaryDirectory()
(Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS)
subprocess.run(
["bash", "gen_certs.sh", "My.Little.Server", "My.Little.Services"],
[
"bash",
"gen_certs.sh",
"My.Little.Server",
"My.Little.History",
"My.Little.Services",
],
cwd=certs_dir.name,
check=True,
)
@ -95,10 +103,11 @@ def certs_dir() -> Path:
NETWORK_CONFIG = """
{
"fanout": 1,
"fanout": 2,
"ca_file": "%(certs_dir)s/ca_cert.pem",
"peers": [
{ "name": "My.Little.History", "address": "%(history_hostname)s:%(history_port)s", "fingerprint": "%(history_cert_sha1)s" },
{ "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" },
{ "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" }
]
@ -107,6 +116,8 @@ NETWORK_CONFIG = """
NETWORK_CONFIG_CONFIG = """
{
"object_expiry": 60, // 1 minute
"opers": [
{
"name": "operuser",
@ -217,6 +228,58 @@ SERVER_CONFIG = """
}
"""
HISTORY_SERVER_CONFIG = """
{
"server_id": 50,
"server_name": "My.Little.History",
"management": {
"address": "%(history_management_hostname)s:%(history_management_port)s",
"client_ca": "%(certs_dir)s/ca_cert.pem",
"authorised_fingerprints": [
{ "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" }
]
},
"server": {
"database": "%(history_db_url)s",
"auto_run_migrations": true,
},
"event_log": {
"event_expiry": 300, // five minutes, for local testing
},
"tls_config": {
"key_file": "%(certs_dir)s/My.Little.History.key",
"cert_file": "%(certs_dir)s/My.Little.History.pem"
},
"node_config": {
"listen_addr": "%(history_hostname)s:%(history_port)s",
"cert_file": "%(certs_dir)s/My.Little.History.pem",
"key_file": "%(certs_dir)s/My.Little.History.key"
},
"log": {
"dir": "log/services/",
"module-levels": {
"": "debug",
"sable_history": "trace",
},
"targets": [
{
"target": "stdout",
"level": "trace",
"modules": [ "sable" ]
}
]
}
}
"""
SERVICES_CONFIG = """
{
"server_id": 99,
@ -295,7 +358,7 @@ SERVICES_CONFIG = """
{
"target": "stdout",
"level": "debug",
"modules": [ "sable_services" ]
"modules": [ "sable" ]
}
]
}
@ -310,6 +373,12 @@ class SableController(BaseServerController, DirectoryBasedController):
"""Sable processes commands very quickly, but responses for commands changing the
state may be sent after later commands for messages which don't."""
history_controller: Optional[BaseServicesController] = None
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.history_controller = None
def run(
self,
hostname: str,
@ -346,10 +415,11 @@ class SableController(BaseServerController, DirectoryBasedController):
(server1_hostname, server1_port) = self.get_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port()
(history_hostname, history_port) = self.get_hostname_and_port()
# Sable requires inbound connections to match the configured hostname,
# so we can't configure 0.0.0.0
server1_hostname = services_hostname = "127.0.0.1"
server1_hostname = history_hostname = services_hostname = "127.0.0.1"
(
server1_management_hostname,
@ -359,6 +429,10 @@ class SableController(BaseServerController, DirectoryBasedController):
services_management_hostname,
services_management_port,
) = self.get_hostname_and_port()
(
history_management_hostname,
history_management_port,
) = self.get_hostname_and_port()
self.template_vars = dict(
certs_dir=certs_dir(),
@ -379,6 +453,13 @@ class SableController(BaseServerController, DirectoryBasedController):
services_management_hostname=services_management_hostname,
services_management_port=services_management_port,
services_alias_users=SERVICES_ALIAS_USERS if run_services else "",
history_hostname=history_hostname,
history_port=history_port,
history_cert_sha1=(certs_dir() / "My.Little.History.pem.sha1")
.read_text()
.strip(),
history_management_hostname=history_management_hostname,
history_management_port=history_management_port,
)
with self.open_file("configs/network.conf") as fd:
@ -394,7 +475,7 @@ class SableController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*faketime_cmd,
"sable_ircd",
@ -408,17 +489,29 @@ class SableController(BaseServerController, DirectoryBasedController):
],
cwd=self.directory,
preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
proc_name="sable_ircd ",
)
self.pgroup_id = os.getpgid(self.proc.pid)
if run_services:
self.services_controller = SableServicesController(self.test_config, self)
self.services_controller.faketime_cmd = faketime_cmd
self.services_controller.run(
protocol="sable",
server_hostname=services_hostname,
server_port=services_port,
)
if self.test_config.sable_history_server:
self.history_controller = SableHistoryController(self.test_config, self)
self.history_controller.faketime_cmd = faketime_cmd
self.history_controller.run(
protocol="sable",
server_hostname=history_hostname,
server_port=history_port,
)
def kill_proc(self) -> None:
os.killpg(self.pgroup_id, signal.SIGKILL)
super().kill_proc()
@ -462,11 +555,62 @@ class SableController(BaseServerController, DirectoryBasedController):
case.sendLine(client, "QUIT")
case.assertDisconnected(client)
def wait_for_services(self) -> None:
# FIXME: this isn't called when sable_history is enabled but sable_services
# isn't. This doesn't happen with the existing tests so this isn't an issue yet
if self.services_controller is not None:
t1 = threading.Thread(target=self.services_controller.wait_for_services)
t1.start()
if self.history_controller is not None:
t2 = threading.Thread(target=self.history_controller.wait_for_services)
t2.start()
t2.join()
if self.services_controller is not None:
t1.join()
class SableServicesController(BaseServicesController):
server_controller: SableController
software_name = "Sable Services"
faketime_cmd: Sequence[str]
def wait_for_services(self) -> None:
"""Overrides the default implementation, as it relies on
``PRIVMSG NickServ: HELP``, which always succeeds on Sable.
Instead, this relies on SASL PLAIN availability."""
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkSASL", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
def wait() -> None:
while True:
c.sendLine("CAP LS 302")
for msg in c.getMessages(synchronize=False):
if msg.command == "CAP":
assert msg.params[-2] == "LS", msg
for cap in msg.params[-1].split():
if cap.startswith("sasl="):
mechanisms = cap.split("=", 1)[1].split(",")
if "PLAIN" in mechanisms:
return
else:
if msg.params[0] == "*":
# End of CAP LS
time.sleep(self.server_controller.sync_sleep_time)
wait()
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
self.services_up = True
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
assert protocol == "sable"
assert self.server_controller.directory is not None
@ -474,8 +618,9 @@ class SableServicesController(BaseServicesController):
with self.server_controller.open_file("configs/services.conf") as fd:
fd.write(SERVICES_CONFIG % self.server_controller.template_vars)
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*self.faketime_cmd,
"sable_services",
"--foreground",
"--server-conf",
@ -485,6 +630,8 @@ class SableServicesController(BaseServicesController):
],
cwd=self.server_controller.directory,
preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
proc_name="sable_services",
)
self.pgroup_id = os.getpgid(self.proc.pid)
@ -493,5 +640,92 @@ class SableServicesController(BaseServicesController):
super().kill_proc()
class SableHistoryController(BaseServicesController):
server_controller: SableController
software_name = "Sable History Server"
faketime_cmd: Sequence[str]
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
assert protocol == "sable"
assert self.server_controller.directory is not None
history_db_url = os.environ.get("PIFPAF_POSTGRESQL_URL") or os.environ.get(
"IRCTEST_POSTGRESQL_URL"
)
assert history_db_url, (
"Cannot find a postgresql database to use as backend for sable_history. "
"Either set the IRCTEST_POSTGRESQL_URL env var to a libpq URL, or "
"run `pip3 install pifpaf` and wrap irctest in a pifpaf call (ie. "
"pifpaf run postgresql -- pytest --controller=irctest.controllers.sable ...)"
)
with self.server_controller.open_file("configs/history_server.conf") as fd:
vals = dict(self.server_controller.template_vars)
vals["history_db_url"] = history_db_url
fd.write(HISTORY_SERVER_CONFIG % vals)
self.proc = self.execute(
[
*self.faketime_cmd,
"sable_history",
"--foreground",
"--server-conf",
self.server_controller.directory / "configs/history_server.conf",
"--network-conf",
self.server_controller.directory / "configs/network.conf",
],
cwd=self.server_controller.directory,
preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
proc_name="sable_history ",
)
self.pgroup_id = os.getpgid(self.proc.pid)
def wait_for_services(self) -> None:
"""Overrides the default implementation, as it relies on
``PRIVMSG NickServ: HELP``, which always succeeds on Sable.
Instead, this relies on SASL PLAIN availability."""
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkHist", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkHist")
c.sendLine("USER chk chk chk chk")
time.sleep(self.server_controller.sync_sleep_time)
got_end_of_motd = False
while not got_end_of_motd:
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
c.sendLine("PONG :" + msg.params[0])
if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD
got_end_of_motd = True
def wait() -> None:
timeout = time.time() + 10
while time.time() < timeout:
c.sendLine("LINKS")
time.sleep(self.server_controller.sync_sleep_time)
for msg in c.getMessages(synchronize=False):
if msg.command == "364": # RPL_LINKS
if msg.params[2] == "My.Little.History":
return
raise Exception("History server is not available")
wait()
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
self.services_up = True
def kill_proc(self) -> None:
os.killpg(self.pgroup_id, signal.SIGKILL)
super().kill_proc()
def get_irctest_controller_class() -> Type[SableController]:
return SableController

View File

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

View File

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

View File

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

View File

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

View File

@ -86,10 +86,10 @@ class BufferingTestCase(cases.BaseServerTestCase):
if messages and ERR_INPUTTOOLONG in (m.command for m in messages):
# https://defs.ircdocs.horse/defs/numerics.html#err-inputtoolong-417
self.assertGreater(
len(line + payload + "\r\n"),
len((line + payload + "\r\n").encode()),
512 - overhead,
"Got ERR_INPUTTOOLONG for a messag that should fit "
"withing 512 characters.",
"Got ERR_INPUTTOOLONG for a message that should fit "
"within 512 characters.",
)
continue
@ -125,11 +125,24 @@ class BufferingTestCase(cases.BaseServerTestCase):
f"expected payload to be a prefix of {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):
self.sendLine(client1, f"PRIVMSG nick2 {colon}a\r\n")
"""Compute the overhead added to client1's message:
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)
return len(line) - len(f"PRIVMSG nick2 {colon}a\r\n")
return len(line) - len(outgoing.encode())
def _getLine(self, client) -> bytes:
line = b""

View File

@ -2,6 +2,7 @@
`IRCv3 draft chathistory <https://ircv3.net/specs/extensions/chathistory>`_
"""
import dataclasses
import functools
import secrets
import time
@ -31,10 +32,22 @@ def skip_ngircd(f):
return newf
@cases.mark_specifications("IRCv3")
@cases.mark_services
class ChathistoryTestCase(cases.BaseServerTestCase):
def validate_chathistory_batch(self, msgs, target):
class _BaseChathistoryTests(cases.BaseServerTestCase):
def _wait_before_chathistory(self):
"""Hook for the Sable-specific tests that check the postgresql-based
CHATHISTORY implementation is sound. This implementation only kicks in
after the in-memory history is cleared, which happens after a 5 min timeout;
and this gives a chance to :class:``SablePostgresqlHistoryTestCase`` to
wait this timeout.
For other tests, this does nothing.
"""
raise NotImplementedError("_BaseChathistoryTests._wait_before_chathistory")
def validate_chathistory_batch(self, user, target):
# may need to try again for Sable, as it has a pretty high latency here
while not (msgs := self.getMessages(user)):
pass
(start, *inner_msgs, end) = msgs
self.assertMessageMatch(
@ -94,9 +107,13 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.joinChannel(qux, real_chname)
self.getMessages(qux)
self._wait_before_chathistory()
# test a nonexistent channel
self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10")
msgs = self.getMessages(bar)
while not (msgs := self.getMessages(bar)):
# need to retry when Sable has the history server on
pass
msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r
self.assertMessageMatch(
msgs[0],
@ -106,7 +123,9 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
# as should a real channel to which one is not joined:
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,))
msgs = self.getMessages(bar)
while not (msgs := self.getMessages(bar)):
# need to retry when Sable has the history server on
pass
self.assertMessageMatch(
msgs[0],
command="FAIL",
@ -175,6 +194,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
messages.append(echo.to_history_message())
self.assertEqual(echo.to_history_message(), delivery.to_history_message())
self._wait_before_chathistory()
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,))
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual([msg.to_history_message() for msg in replies], messages)
@ -225,9 +246,12 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
time.sleep(0.02)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self._wait_before_chathistory()
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@skip_ngircd
@ -264,6 +288,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
)
time.sleep(0.002)
self._wait_before_chathistory()
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname)
(batch_open, *messages, batch_close) = self.getMessages(1)
@ -308,6 +334,9 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages)
self._wait_before_chathistory()
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@ -367,6 +396,9 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.getMessages(2)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self._wait_before_chathistory()
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
@ -415,6 +447,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
]
self.assertEqual(results, new_convo)
self._wait_before_chathistory()
# additional messages with c3 should not show up in the c1-c2 history:
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
@ -459,15 +493,15 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages, result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-5:], result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-1:], result)
if self._supports_msgid():
@ -476,7 +510,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s msgid=%s %d"
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[5:], result)
if self._supports_timestamp():
@ -485,7 +519,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s timestamp=%s %d"
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[5:], result)
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
@ -496,7 +530,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s msgid=%s %d"
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[:6], result)
if self._supports_timestamp():
@ -505,7 +539,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[:6], result)
self.sendLine(
@ -513,7 +547,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, 2),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:6], result)
def _validate_chathistory_AFTER(self, echo_messages, user, chname):
@ -524,7 +558,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s msgid=%s %d"
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:], result)
if self._supports_timestamp():
@ -533,7 +567,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:], result)
self.sendLine(
@ -541,7 +575,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:7], result)
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
@ -558,7 +592,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
@ -571,7 +605,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result)
# BETWEEN forwards and backwards with a limit, should get
@ -581,7 +615,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:4], result)
self.sendLine(
@ -589,7 +623,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-4:-1], result)
if self._supports_timestamp():
@ -604,7 +638,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
@ -616,21 +650,21 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[0].time, echo_messages[-1].time, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:4], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[-1].time, echo_messages[0].time, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-4:-1], result)
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
@ -640,7 +674,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s msgid=%s %d"
% (chname, echo_messages[7].msgid, 1),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual([echo_messages[7]], result)
self.sendLine(
@ -648,7 +682,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s msgid=%s %d"
% (chname, echo_messages[7].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[6:9], result)
if self._supports_timestamp():
@ -657,7 +691,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s timestamp=%s %d"
% (chname, echo_messages[7].time, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
result = self.validate_chathistory_batch(user, chname)
self.assertIn(echo_messages[7], result)
@pytest.mark.arbitrary_client_tags
@ -718,6 +752,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.assertEqual(len(relay), 1)
validate_tagmsg(relay[0], chname, msgid)
self._wait_before_chathistory()
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,))
history_tagmsgs = [
msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
@ -814,8 +850,95 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
validate_msg(relay)
@cases.mark_specifications("IRCv3")
@cases.mark_services
class ChathistoryTestCase(_BaseChathistoryTests):
def _wait_before_chathistory(self):
"""does nothing"""
pass
assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == {
meth_name
for meth_name in dir(ChathistoryTestCase)
if meth_name.startswith("_validate_chathistory_")
}, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync"
@cases.mark_specifications("Sable")
@cases.mark_services
class SablePostgresqlHistoryTestCase(_BaseChathistoryTests):
# for every wall clock second, 15 seconds pass for the server.
# at x30, links between nodes timeout.
faketime = "+1y x15"
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return dataclasses.replace( # type: ignore[no-any-return]
_BaseChathistoryTests.config(),
sable_history_server=True,
)
def _wait_before_chathistory(self):
"""waits 6 seconds which appears to be a 1.5 min to Sable; which goes over
the 1 min timeout for in-memory history (+ 1 min because the cleanup job
only runs every min)"""
assert self.controller.faketime_enabled, "faketime is not installed"
time.sleep(8)
@cases.mark_specifications("Sable")
@cases.mark_services
class SableExpiringHistoryTestCase(cases.BaseServerTestCase):
faketime = "+1y x15"
def _wait_before_chathistory(self):
"""waits 6 seconds which appears to be a 1.5 min to Sable; which goes over
the 1 min timeout for in-memory history (+ 1 min because the cleanup job
only runs every min)"""
assert self.controller.faketime_enabled, "faketime is not installed"
time.sleep(8)
def testChathistoryExpired(self):
"""Checks that Sable forgets about messages if the history server is not available"""
self.connectClient(
"bar",
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
skip_if_cap_nak=True,
)
chname = "#chan" + secrets.token_hex(12)
self.joinChannel(1, chname)
self.getMessages(1)
self.getMessages(1)
self.sendLine(1, f"PRIVMSG {chname} :this is a message")
self.getMessages(1)
self._wait_before_chathistory()
self.sendLine(1, f"CHATHISTORY LATEST {chname} * 10")
while not (messages := self.getMessages(1)):
# Sable processes CHATHISTORY asynchronously, which can be pretty slow as it
# sends cross-server requests. This means we can't just rely on a PING-PONG
# or the usual time.sleep(self.controller.sync_sleep_time) to make sure
# the ircd replied to us
time.sleep(self.controller.sync_sleep_time)
(start, *middle, end) = messages
self.assertMessageMatch(
start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", chname]
)
batch_tag = start.params[0][1:]
self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag])
self.assertEqual(
len(middle), 0, f"Got messages that should be expired: {middle}"
)

View File

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

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

@ -200,3 +200,78 @@ class JoinTestCase(cases.BaseServerTestCase):
fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', "
"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

@ -140,9 +140,9 @@ class TagsTestCase(cases.BaseServerTestCase):
self.joinChannel(1, "#xyz")
monsterMessage = "@+clientOnlyTagExample=" + "a" * 4096 + " PRIVMSG #xyz hi!"
self.sendLine(1, monsterMessage)
self.assertEqual(self.getMessages(2), [], "overflowing message was relayed")
replies = self.getMessages(1)
self.assertIn(ERR_INPUTTOOLONG, set(reply.command for reply in replies))
self.assertEqual(self.getMessages(2), [], "overflowing message was relayed")
class LengthLimitTestCase(cases.BaseServerTestCase):

View File

@ -3,7 +3,7 @@
"""
from irctest import cases
from irctest.patma import ANYDICT, StrRe
from irctest.patma import ANYDICT, ANYSTR, StrRe
CAP_NAME = "draft/multiline"
BATCH_TYPE = "draft/multiline"
@ -135,3 +135,86 @@ class MultilineTestCase(cases.BaseServerTestCase):
self.assertIn("+client-only-tag", fallback_relay[0].tags)
self.assertIn("+client-only-tag", fallback_relay[1].tags)
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

@ -221,7 +221,6 @@ class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase):
)
@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")

View File

@ -9,6 +9,7 @@ class Specifications(enum.Enum):
RFC2812 = "RFC2812"
IRCv3 = "IRCv3" # Mark with capabilities whenever possible
Ergo = "Ergo"
Sable = "Sable"
Ircdocs = "ircdocs"
"""Any document on ircdocs.horse (especially defs.ircdocs.horse),
@ -24,6 +25,9 @@ class Specifications(enum.Enum):
return spec
raise ValueError(name)
def is_implementation_specific(self) -> bool:
return self in (Specifications.Ergo, Specifications.Sable)
@enum.unique
class Capabilities(enum.Enum):

View File

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

View File

@ -8,13 +8,13 @@ index 317b00e..adfcfcf 100644
dots = 1;
}
- if (!dots)
- if (!dots)
- {
- sendto_realops("Invalid hostname for %s, dumping user %s",
- sptr->hostip, sptr->name);
- return exit_client(cptr, sptr, &me, "Invalid hostname");
- }
-
if (bad_dns)
-
if (bad_dns)
{
sendto_one(sptr, ":%s NOTICE %s :*** Notice -- You have a bad "

View File

@ -7,7 +7,12 @@ markers =
IRCv3
modern
ircdocs
# implementations for which we have specific test get two markers:
# the implementation name and 'implementation-specific'
implementation-specific
Ergo
Sable
# misc marks
strict

View File

@ -97,7 +97,7 @@ software:
name: Bahamut
repository: DALnet/Bahamut
refs:
stable: "v2.2.1"
stable: "v2.2.4"
release: null
devel: "master"
devel_release: null
@ -136,7 +136,7 @@ software:
pre_deps:
- uses: actions/setup-go@v3
with:
go-version: '^1.22.0'
go-version: '^1.23.0'
- run: go version
separate_build_job: false
build_script: |
@ -148,7 +148,7 @@ software:
name: InspIRCd
repository: inspircd/inspircd
refs: &inspircd_refs
stable: v3.17.0
stable: v3.17.1
release: null
devel: master
devel_release: insp3
@ -230,7 +230,7 @@ software:
name: ngircd
repository: ngircd/ngircd
refs:
stable: 3e3f6cbeceefd9357b53b27c2386bb39306ab353 # three years ahead of rel-26.1
stable: acf8409c60ccc96beed0a1f990c4f9374823c0ce # three months ahead of v27
release: null
devel: master
devel_release: null
@ -249,7 +249,7 @@ software:
name: Sable
repository: Libera-Chat/sable
refs:
stable: e9701e5e8d0c4f278ddd61ce7285f4918ecf99e9
stable: 034c4d5dd937774099773238d8d5b8054b015607
release: null
devel: master
devel_release: null
@ -268,6 +268,9 @@ software:
workspaces: "sable -> target"
cache-on-failure: true
- run: rustc --version
- run: start postgresql
run: "sudo systemctl start postgresql.service"
env: "IRCTEST_POSTGRESQL_URL=postgresql://localhost IRCTEST_DEBUG_LOGS=1"
separate_build_job: false
build_script: |
cd $GITHUB_WORKSPACE/sable/
@ -300,8 +303,8 @@ software:
name: UnrealIRCd 6
repository: unrealircd/unrealircd
refs:
stable: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
release: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
stable: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1
release: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1
devel: unreal60_dev
devel_release: null
path: unrealircd