40 Commits

Author SHA1 Message Date
281dca7367 Add questionable test that TOPIC is not echoed/transmitted when not changed 2023-09-16 22:36:54 +02:00
c58167b42d Fix deprecation warning 2023-09-02 16:24:57 +02:00
34c78e5d2f testCapRemovalByClient: Support multiple CAP LS responses (#220) 2023-09-02 15:42:18 +02:00
1c6a7188d6 Add more tests for CAP + allow trailing spaces (#216) 2023-09-02 15:08:29 +02:00
50d3a8e6da echo_message: Simplify code (#219) 2023-09-02 13:43:35 +02:00
fe24e4b8b8 multi_prefix: Skip test on IRCds that don't support it (#218) 2023-09-02 13:43:15 +02:00
360a853bca Skip testLabeledPrivmsgResponsesToMultipleClients if PRIVMSG doesn't support multiple targets (#217) 2023-09-02 13:43:01 +02:00
653d818421 testInviteAlreadyInChannel: Fix synchronization 2023-08-29 20:28:11 +02:00
10e07aa800 Test that WHO with non-existing nick returns RPL_ENDOFWHO (#215) 2023-08-17 20:15:18 +02:00
b28820e562 Bump Go again 2023-08-16 20:12:54 +02:00
cb147f46eb Bump Python to 3.11 on release and devel_release workflows
Sopel dropped support for Python 3.7
2023-08-13 20:09:39 +02:00
a950c724bb Bump Python to 3.11 (#214)
Sopel dropped support for Python 3.7
2023-08-11 20:24:26 +02:00
7255d65514 Test that WHO #chan always returns that channel (#213)
* Test that WHO #chan always returns that channel

@emersion's test from https://github.com/progval/irctest/pull/190

Co-authored-by: Simon Ser <contact@emersion.fr>
Co-authored-by: Val Lorentz <progval+github@progval.net>
2023-08-09 12:16:32 -04:00
61fb287280 fix nonexistent user PRIVMSG test (#212)
* fix nonexistent user PRIVMSG test

* fix single-element tupling issue
* test the ERR_NOSUCHNICK params
* use patma
2023-08-09 12:00:51 -04:00
d190a91960 test that PART actually parts (#211)
Co-authored-by: Val Lorentz <progval+github@progval.net>
2023-08-08 23:19:36 -04:00
59b2cd729b Configure Unreal with --with-system-argon2
Our Github Workflow builds and runs on different machines, causing argon2
to be built sometimes with some CPU instructions that the machine running
it does not support.
2023-07-23 11:40:01 +02:00
e38f29befa Log unexpected exit codes 2023-07-22 22:12:44 +02:00
2e45f7bfdb Fix build against Bahamut's master branch 2023-07-10 20:17:12 +02:00
7bc8a81f8a Fix compat with Unreal > 6.1.1.1
The '=' syntax is an ircd-hybrid-ism, and Unreal will drop support for
it in the next release. More specifically, somewhere between
0af88581d380602bfd58a0cdaa36b714fb7ef3c3 and c8c265790494b908ff397c705855a21e591884de
in its Git history.
2023-07-09 20:30:34 +02:00
4ee9c9c53a Update CI to run on Ubuntu 22.04. (#210)
* Update workflows to run on Ubuntu 22.04.

* Add a patch to fix Bahamut on Ubuntu 22.04.

Source: https://github.com/DALnet/bahamut/pull/219

* Add a patch to fix Charybdis on Ubuntu 22.04.
2023-06-25 23:14:08 +02:00
321e254d15 Add SETNAME tests (#209)
* Add SETNAME tests

* fix race condition

* fix synchronization issue

sendLine does not synchronize by itself; call getMessage to synchronize
and test the message since we have it

* Update irctest/server_tests/setname.py

Co-authored-by: Val Lorentz <progval+github@progval.net>

---------

Co-authored-by: Shivaram Lingamneni <slingamn@cs.stanford.edu>
Co-authored-by: Val Lorentz <progval+github@progval.net>
2023-06-04 17:06:53 -04:00
e5f22e8080 chathistory: Validate BATCH commands more strictly (#208) 2023-06-03 19:32:05 +02:00
5a5dbdb50d Bump Dlk version 2023-06-01 19:17:00 +02:00
52c22236a6 use the ratified extended-monitor name (#206) 2023-06-01 18:22:54 +02:00
22c6743b24 test that CAP LS 301 responses are only one line (#205) 2023-05-31 22:35:59 +02:00
b04db62a9b thelounge: Fix build again 2023-05-31 20:14:17 +02:00
5ec44e1417 thelounge: Build from git repository
'yarn global add https://github.com/thelounge/thelounge.git' doesn't work
because we now need to compile TypeScript to JavaScript when not downloading
from the package manager
2023-05-30 22:20:25 +02:00
2fb8ed4000 dashboard: Use a more concise/readable and tree-like syntax to generate the ASTs (#204) 2023-05-29 14:49:03 +02:00
79bbdd2948 sasl: Add tests for signature failure from the server (#179) 2023-05-29 11:53:08 +02:00
a03e9bb8ea Add support for The Lounge (#132) 2023-05-29 09:50:31 +02:00
9b9cfdb2bf Add tests for MONITOR C and S (#202) 2023-05-26 09:41:47 +02:00
bb8a6b6c3d add a test for channel +n / -n (#201)
* add a test for channel +n / -n

* Update irctest/server_tests/chmodes/nooutside.py

Co-authored-by: Val Lorentz <progval+github@progval.net>

* Update irctest/server_tests/chmodes/nooutside.py

Co-authored-by: Val Lorentz <progval+github@progval.net>

* consistently rename to "no external messages"

---------

Co-authored-by: Val Lorentz <progval+github@progval.net>
2023-05-23 01:18:40 -04:00
297bf2c554 inspircd: Use upstream mainloop hack when available (#200) 2023-05-20 20:06:59 +02:00
05e9b3746e ci: Bump versions of actions we use (#199)
So Github stops complaining about the deprecated Nodejs version
2023-05-20 13:32:42 +02:00
3b7f81e22c strip whitespace from Ergo hashed password output (#198)
Removes the need for some special-casing in `ergo genpasswd`
2023-04-19 02:52:21 -04:00
6edf4e27f1 Remove xfail in WHOWAS as linked PRs have been merged (#197)
* Bump inspircd stable version.

* Remove xfail in WHOWAS as linked PRs have been merged
2023-04-17 18:45:50 +02:00
11dc5b046e unrealircd: Move SSL and port generation out of the critical section (#196) 2023-04-16 09:19:05 +02:00
ddb37d6c3f Use real metadata keys (#194) 2023-04-15 23:04:24 +02:00
aed6478a2c Bump UnrealIRCd to v6.0.7 (#192) 2023-04-05 08:24:34 +02:00
418b526033 Prevent random port collisions between controllers (#191)
This happens from time to time on the CI and is pretty annoying
2023-04-04 22:01:20 +02:00
43 changed files with 2210 additions and 966 deletions

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,12 @@
jobs:
build-anope:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- name: Cache dependencies
uses: actions/cache@v2
uses: actions/cache@v3
with:
key: 3-${{ runner.os }}-anope-devel_release
path: '~/.cache
@ -16,13 +16,13 @@ jobs:
${ github.workspace }/anope
'
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Checkout Anope
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
path: anope
ref: 2.0.9
@ -37,23 +37,23 @@ jobs:
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: installed-anope
path: ~/artefacts-*.tar.gz
retention-days: 1
build-inspircd:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Checkout InspIRCd
uses: actions/checkout@v2
uses: actions/checkout@v3
with:
path: inspircd
ref: insp3
@ -61,14 +61,18 @@ jobs:
- name: Build InspIRCd
run: |
cd $GITHUB_WORKSPACE/inspircd/
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
# Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true
./configure --prefix=$HOME/.local/inspircd --development
make -j 4
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: installed-inspircd
path: ~/artefacts-*.tar.gz
@ -80,11 +84,11 @@ jobs:
- test-inspircd
- test-inspircd-anope
- test-inspircd-atheme
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Download Artifacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Install dashboard dependencies
@ -107,15 +111,15 @@ jobs:
test-inspircd:
needs:
- build-inspircd
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-inspircd
path: '~'
@ -133,7 +137,7 @@ jobs:
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: pytest-results_inspircd_devel_release
path: pytest.xml
@ -141,20 +145,20 @@ jobs:
needs:
- build-inspircd
- build-anope
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-inspircd
path: '~'
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-anope
path: '~'
@ -172,22 +176,22 @@ jobs:
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: pytest-results_inspircd-anope_devel_release
path: pytest.xml
test-inspircd-atheme:
needs:
- build-inspircd
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v3
with:
name: installed-inspircd
path: '~'
@ -205,7 +209,7 @@ jobs:
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v3
with:
name: pytest-results_inspircd-atheme_devel_release
path: pytest.xml

File diff suppressed because it is too large Load Diff

View File

@ -98,6 +98,13 @@ 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_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# Tests marked with arbitrary_client_tags can't pass because Unreal whitelists which tags it relays
# Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149
# Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs
@ -253,6 +260,11 @@ sopel:
--controller=irctest.controllers.sopel \
-k '$(SOPEL_SELECTORS)'
thelounge:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.thelounge \
-k '$(THELOUNGE_SELECTORS)'
unrealircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \

View File

@ -110,8 +110,11 @@ cd /tmp/
git clone https://github.com/inspircd/inspircd.git
cd inspircd
# optional, makes tests run considerably faster
# Optional, makes tests run considerably faster. Pick one depending on the InspIRCd version:
# on Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21:
patch src/inspircd.cpp < ~/irctest/patches/inspircd_mainloop.patch
# on Insp3 >= 3.17.0 and Insp4 >= 4.0.0a22:
export CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP
./configure --prefix=$HOME/.local/ --development
make -j 4

View File

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

View File

@ -19,6 +19,10 @@ SHOWLISTMODES="1"
NOOPEROVERRIDE=""
OPEROVERRIDEVERIFY=""
GENCERTIFICATE="1"
EXTRAPARA=""
# Use system argon to avoid getting SIGILLed if the build machine has a more recent
# CPU than the one running the tests.
EXTRAPARA="--with-system-argon2"
ADVANCED=""

View File

@ -1,6 +1,7 @@
from __future__ import annotations
import dataclasses
import multiprocessing
import os
from pathlib import Path
import shutil
@ -9,7 +10,18 @@ import subprocess
import tempfile
import textwrap
import time
from typing import IO, Any, Callable, Dict, List, Optional, Set, Tuple, Type
from typing import (
IO,
Any,
Callable,
Dict,
List,
MutableMapping,
Optional,
Set,
Tuple,
Type,
)
import irctest
@ -58,15 +70,49 @@ class _BaseController:
supported_sasl_mechanisms: Set[str]
proc: Optional[subprocess.Popen]
_used_ports: Set[Tuple[str, int]]
"""``(hostname, port))`` used by this controller."""
# the following need to be shared between processes in case we are running in
# parallel (with pytest-xdist)
# The dicts are used as a set of (hostname, port), because _manager.set() doesn't
# exist.
_manager = multiprocessing.Manager()
_port_lock = _manager.Lock()
"""Lock for access to ``_all_used_ports`` and ``_available_ports``."""
_all_used_ports: MutableMapping[Tuple[str, int], None] = _manager.dict()
"""``(hostname, port)`` used by all controllers."""
_available_ports: MutableMapping[Tuple[str, int], None] = _manager.dict()
"""``(hostname, port)`` available to any controller."""
def __init__(self, test_config: TestCaseControllerConfig):
self.test_config = test_config
self.proc = None
self._used_ports = set()
def get_hostname_and_port(self) -> Tuple[str, int]:
with self._port_lock:
try:
# try to get a known available port
((hostname, port), _) = self._available_ports.popitem()
except KeyError:
# if there aren't any, iterate while we get a fresh one.
while True:
(hostname, port) = find_hostname_and_port()
if (hostname, port) not in self._all_used_ports:
# double-checking in self._used_ports to prevent collisions
# between controllers starting at the same time.
break
# Make this port unavailable to other processes
self._all_used_ports[(hostname, port)] = None
return (hostname, port)
def check_is_alive(self) -> None:
assert self.proc
self.proc.poll()
if self.proc.returncode is not None:
raise ProcessStopped()
raise ProcessStopped(f"process returned {self.proc.returncode}")
def kill_proc(self) -> None:
"""Terminates the controlled process, waits for it to exit, and
@ -84,6 +130,11 @@ class _BaseController:
if self.proc:
self.kill_proc()
# move this controller's ports from _all_used_ports to _available_ports
for hostname, port in self._used_ports:
del self._all_used_ports[(hostname, port)]
self._available_ports[(hostname, port)] = None
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
@ -202,9 +253,6 @@ class BaseServerController(_BaseController):
super().__init__(*args, **kwargs)
self.faketime_enabled = False
def get_hostname_and_port(self) -> Tuple[str, int]:
return find_hostname_and_port()
def run(
self,
hostname: str,
@ -213,8 +261,6 @@ class BaseServerController(_BaseController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]],
invalid_metadata_keys: Optional[Set[str]],
faketime: Optional[str],
) -> None:
raise NotImplementedError()

View File

@ -529,8 +529,6 @@ class BaseServerTestCase(
password: Optional[str] = None
ssl = False
valid_metadata_keys: Set[str] = set()
invalid_metadata_keys: Set[str] = set()
server_support: Optional[Dict[str, Optional[str]]]
run_services = False
@ -550,8 +548,6 @@ class BaseServerTestCase(
self.hostname,
self.port,
password=self.password,
valid_metadata_keys=self.valid_metadata_keys,
invalid_metadata_keys=self.invalid_metadata_keys,
ssl=self.ssl,
run_services=self.run_services,
faketime=self.faketime,

View File

@ -228,7 +228,7 @@ class SaslTestCase(cases.BaseClientTestCase):
self.assertEqual(m.params, ["+"], m)
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScramBadPassword(self):
def testScramBadPassword(self, server_fakes_success=False, fake_response=None):
"""Test SCRAM-SHA-256 authentication with a bad password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
@ -261,6 +261,36 @@ class SaslTestCase(cases.BaseClientTestCase):
with self.assertRaises(scram.NotAuthorizedException):
authenticator.response(msg)
if server_fakes_success:
self.sendLine(f"AUTHENTICATE :{fake_response}")
m = self.getMessage()
while m.command == "PING":
self.sendLine(f"PONG server. {m.params[-1]}")
m = self.getMessage()
self.assertMessageMatch(
m,
command="AUTHENTICATE",
params=["*"],
fail_msg="Client did not abort: {msg}",
)
@cases.skipUnlessHasMechanism("SCRAM-SHA-256")
@pytest.mark.parametrize(
"fake_response",
[
"",
"AAAA",
"dj1ubU1mM1FIV2NKUWk5cE1ndHFLU0tQclZueUk2c3FOTzZJN3BFLzBveUdjPQ==",
],
)
def testScramMaliciousServer(self, fake_response):
"""Test SCRAM-SHA-256 authentication to a server which pretends to know
the password"""
self.testScramBadPassword(
server_fakes_success=True, fake_response=fake_response
)
class Irc302SaslTestCase(cases.BaseClientTestCase):
@cases.skipUnlessHasMechanism("PLAIN")

View File

@ -3,12 +3,7 @@ import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
global {{
@ -112,21 +107,14 @@ class BahamutController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(unused_hostname, unused_port) = find_hostname_and_port()
(services_hostname, services_port) = find_hostname_and_port()
(unused_hostname, unused_port) = self.get_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port()
password_field = "passwd {};".format(password) if password else ""

View File

@ -1,13 +1,8 @@
import shutil
import subprocess
from typing import Optional, Set
from typing import Optional
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_SSL_CONFIG = """
ssl_private_key = "{key_path}";
@ -41,19 +36,13 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(services_hostname, services_port) = find_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port()
password_field = 'password = "{}";'.format(password) if password else ""
if ssl:
self.gen_ssl()

View File

@ -3,13 +3,9 @@ import json
import os
import shutil
import subprocess
from typing import Any, Dict, Optional, Set, Type, Union
from typing import Any, Dict, Optional, Type, Union
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
from irctest.cases import BaseServerTestCase
BASE_CONFIG = {
@ -130,7 +126,7 @@ def hash_password(password: Union[str, bytes]) -> str:
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
out, _ = p.communicate(input_)
return out.decode("utf-8")
return out.decode("utf-8").strip()
class ErgoController(BaseServerController, DirectoryBasedController):
@ -153,17 +149,9 @@ class ErgoController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
config: Optional[Any] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
self.create_config()
if config is None:
config = copy.deepcopy(BASE_CONFIG)

View File

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

View File

@ -1,14 +1,9 @@
import functools
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
# Clients:
@ -78,6 +73,7 @@ TEMPLATE_CONFIG = """
<module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl">
<module name="uhnames"> # For userhost-in-names
# HELP/HELPOP
<module name="alias"> # for the HELP alias
@ -125,20 +121,13 @@ class InspircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(services_hostname, services_port) = find_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port()
password_field = 'password="{}"'.format(password) if password else ""

View File

@ -1,6 +1,6 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
@ -49,14 +49,8 @@ class Irc2Controller(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:

View File

@ -1,6 +1,6 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
@ -68,14 +68,8 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:

View File

@ -33,10 +33,10 @@ extensions:
- mammon.ext.ircv3.sasl
- mammon.ext.misc.nopost
metadata:
restricted_keys:
{restricted_keys}
restricted_keys: []
whitelist:
{authorized_keys}
- display-name
- avatar
monitor:
limit: 20
motd:
@ -89,9 +89,6 @@ class MammonController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if password is not None:
@ -107,8 +104,6 @@ class MammonController(BaseServerController, DirectoryBasedController):
directory=self.directory,
hostname=hostname,
port=port,
authorized_keys=make_list(valid_metadata_keys or set()),
restricted_keys=make_list(restricted_metadata_keys or set()),
)
)
# with self.open_file('server.yml', 'r') as fd:

View File

@ -2,12 +2,7 @@ import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
[Global]
@ -53,20 +48,13 @@ class NgircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(unused_hostname, unused_port) = find_hostname_and_port()
(unused_hostname, unused_port) = self.get_hostname_and_port()
password_field = "Password = {}".format(password) if password else ""

View File

@ -1,6 +1,6 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
@ -67,14 +67,8 @@ class SnircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:

View File

@ -0,0 +1,106 @@
import json
import os
import subprocess
from typing import Optional, Type
from irctest import authentication, tls
from irctest.basecontrollers import (
BaseClientController,
DirectoryBasedController,
NotImplementedByController,
)
TEMPLATE_CONFIG = """
"use strict";
module.exports = {config};
"""
class TheLoungeController(BaseClientController, DirectoryBasedController):
software_name = "TheLounge"
supported_sasl_mechanisms = {
"PLAIN",
"ECDSA-NIST256P-CHALLENGE",
"SCRAM-SHA-256",
"EXTERNAL",
}
supports_sts = True
def create_config(self) -> None:
super().create_config()
with self.open_file("bot.conf"):
pass
with self.open_file("conf/users.conf"):
pass
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
if tls_config is None:
tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[])
if tls_config and tls_config.trusted_fingerprints:
raise NotImplementedByController("Trusted fingerprints.")
if auth and any(
mech.to_string().startswith(("SCRAM-", "ECDSA-"))
for mech in auth.mechanisms
):
raise NotImplementedByController("ecdsa")
if auth and auth.password and len(auth.password) > 300:
# https://github.com/thelounge/thelounge/pull/4480
# Note that The Lounge truncates on 300 characters, not bytes.
raise NotImplementedByController("Passwords longer than 300 chars")
# Runs a client with the config given as arguments
assert self.proc is None
self.create_config()
if auth:
mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms)
if auth.ecdsa_key:
with self.open_file("ecdsa_key.pem") as fd:
fd.write(auth.ecdsa_key)
else:
mechanisms = ""
assert self.directory
with self.open_file("config.js") as fd:
fd.write(
TEMPLATE_CONFIG.format(
config=json.dumps(
dict(
public=False,
host=f"unix:{self.directory}/sock", # prevents binding
)
)
)
)
with self.open_file("users/testuser.json") as fd:
json.dump(
dict(
networks=[
dict(
name="testnet",
host=hostname,
port=port,
tls=tls_config.enable if tls_config else "False",
sasl=mechanisms.lower(),
saslAccount=auth.username if auth else "",
saslPassword=auth.password if auth else "",
)
]
),
fd,
)
with self.open_file("users/testuser.json", "r") as fd:
print("config", json.load(fd)["networks"][0]["saslPassword"])
self.proc = subprocess.Popen(
[os.environ.get("THELOUNGE_BIN", "thelounge"), "start"],
env={**os.environ, "THELOUNGE_HOME": str(self.directory)},
)
def get_irctest_controller_class() -> Type[TheLoungeController]:
return TheLoungeController

View File

@ -5,14 +5,9 @@ from pathlib import Path
import shutil
import subprocess
import textwrap
from typing import Callable, ContextManager, Iterator, Optional, Set, Type
from typing import Callable, ContextManager, Iterator, Optional, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
include "modules.default.conf";
@ -101,7 +96,7 @@ set {{
}}
modes-on-join "+H 100:1d"; // Enables CHATHISTORY
{set_extras}
{set_v6only}
}}
@ -117,13 +112,31 @@ files {{
}}
oper "operuser" {{
password = "operpassword";
password "operpassword";
mask *;
class clients;
operclass netadmin;
}}
"""
SET_V6ONLY = """
// Remove RPL_WHOISSPECIAL used to advertise security groups
whois-details {
security-groups { everyone none; self none; oper none; }
}
plaintext-policy {
server warn; // https://www.unrealircd.org/docs/FAQ#server-requires-tls
oper warn; // https://www.unrealircd.org/docs/FAQ#oper-requires-tls
}
anti-flood {
everyone {
connect-flood 255:10;
}
}
"""
def _filelock(path: Path) -> Callable[[], ContextManager]:
"""Alternative to :cls:`multiprocessing.Lock` that works with pytest-xdist"""
@ -186,15 +199,8 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
faketime: Optional[str],
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
@ -207,29 +213,18 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
loadmodule "cloak_md5";
"""
)
set_extras = textwrap.indent(
textwrap.dedent(
"""
// Remove RPL_WHOISSPECIAL used to advertise security groups
whois-details {
security-groups { everyone none; self none; oper none; }
}
"""
),
" ",
)
set_v6only = SET_V6ONLY
else:
extras = ""
set_extras = ""
set_v6only = ""
with self.open_file("empty.txt") as fd:
fd.write("\n")
password_field = 'password "{}";'.format(password) if password else ""
with _STARTSTOP_LOCK():
(services_hostname, services_port) = find_hostname_and_port()
(unused_hostname, unused_port) = find_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port()
(unused_hostname, unused_port) = self.get_hostname_and_port()
self.gen_ssl()
if ssl:
@ -254,8 +249,8 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
key_path=self.key_path,
pem_path=self.pem_path,
empty_file=self.directory / "empty.txt",
set_v6only=set_v6only,
extras=extras,
set_extras=set_extras,
)
)
@ -265,6 +260,7 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
with _STARTSTOP_LOCK():
self.proc = subprocess.Popen(
[
*faketime_cmd,

View File

@ -16,16 +16,22 @@ from typing import (
Optional,
Tuple,
TypeVar,
Union,
)
import xml.etree.ElementTree as ET
from defusedxml.ElementTree import parse as parse_xml
import docutils.core
from .shortxml import Namespace
NETLIFY_CHAR_BLACKLIST = frozenset('":<>|*?\r\n#')
"""Characters not allowed in output filenames"""
HTML = Namespace("http://www.w3.org/1999/xhtml")
@dataclasses.dataclass
class CaseResult:
module_name: str
@ -120,33 +126,43 @@ def iter_job_results(job_file_name: Path, job: ET.ElementTree) -> Iterator[CaseR
def rst_to_element(s: str) -> ET.Element:
html = docutils.core.publish_parts(s, writer_name="xhtml")["html_body"]
htmltree = ET.fromstring(html)
# Force the HTML namespace on all elements produced by docutils, which are
# unqualified
tree_builder = ET.TreeBuilder(
element_factory=lambda tag, attrib: ET.Element(
"{%s}%s" % (HTML.uri, tag),
{"{%s}%s" % (HTML.uri, k): v for (k, v) in attrib.items()},
)
)
parser = ET.XMLParser(target=tree_builder)
htmltree = ET.fromstring(html, parser=parser)
return htmltree
def append_docstring(element: ET.Element, obj: object) -> None:
def docstring(obj: object) -> Optional[ET.Element]:
if obj.__doc__ is None:
return
return None
element.append(rst_to_element(obj.__doc__))
return rst_to_element(obj.__doc__)
def build_job_html(job: str, results: List[CaseResult]) -> ET.Element:
jobs = sorted({result.job for result in results})
root = ET.Element("html")
head = ET.SubElement(root, "head")
ET.SubElement(head, "title").text = job
ET.SubElement(head, "link", rel="stylesheet", type="text/css", href="./style.css")
body = ET.SubElement(root, "body")
table = build_test_table(jobs, results, "job-results test-matrix")
ET.SubElement(body, "h1").text = job
table = build_test_table(jobs, results)
table.set("class", "job-results test-matrix")
body.append(table)
return root
return HTML.html(
HTML.head(
HTML.title(job),
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
),
HTML.body(
HTML.h1(job),
table,
),
)
def build_module_html(
@ -154,38 +170,35 @@ def build_module_html(
) -> ET.Element:
module = importlib.import_module(module_name)
root = ET.Element("html")
head = ET.SubElement(root, "head")
ET.SubElement(head, "title").text = module_name
ET.SubElement(head, "link", rel="stylesheet", type="text/css", href="./style.css")
table = build_test_table(jobs, results, "module-results test-matrix")
body = ET.SubElement(root, "body")
ET.SubElement(body, "h1").text = module_name
append_docstring(body, module)
table = build_test_table(jobs, results)
table.set("class", "module-results test-matrix")
body.append(table)
return root
return HTML.html(
HTML.head(
HTML.title(module_name),
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
),
HTML.body(
HTML.h1(module_name),
docstring(module),
table,
),
)
def build_test_table(jobs: List[str], results: List[CaseResult]) -> ET.Element:
def build_test_table(
jobs: List[str], results: List[CaseResult], class_: str
) -> ET.Element:
multiple_modules = len({r.module_name for r in results}) > 1
results_by_module_and_class = group_by(
results, lambda r: (r.module_name, r.class_name)
)
table = ET.Element("table")
job_row = HTML.tr(
HTML.th(), # column of case name
[HTML.th(HTML.div(HTML.span(job)), class_="job-name") for job in jobs],
)
job_row = ET.Element("tr")
ET.SubElement(job_row, "th") # column of case name
for job in jobs:
cell = ET.SubElement(job_row, "th")
ET.SubElement(ET.SubElement(cell, "div"), "span").text = job
cell.set("class", "job-name")
rows = []
for (module_name, class_name), class_results in sorted(
results_by_module_and_class.items()
@ -203,20 +216,25 @@ def build_test_table(jobs: List[str], results: List[CaseResult]) -> ET.Element:
module = importlib.import_module(module_name)
# Header row: class name
header_row = ET.SubElement(table, "tr")
th = ET.SubElement(header_row, "th", colspan=str(len(jobs) + 1))
row_anchor = f"{qualified_class_name}"
section_header = ET.SubElement(
ET.SubElement(th, "h2"),
"a",
rows.append(
HTML.tr(
HTML.th(
HTML.h2(
HTML.a(
qualified_class_name,
href=f"#{row_anchor}",
id=row_anchor,
),
),
docstring(getattr(module, class_name)),
colspan=str(len(jobs) + 1),
)
)
)
section_header.text = qualified_class_name
append_docstring(th, getattr(module, class_name))
# Header row: one column for each implementation
table.append(job_row)
rows.append(job_row)
# One row for each test:
results_by_test = group_by(class_results, key=lambda r: r.test_name)
@ -227,43 +245,41 @@ def build_test_table(jobs: List[str], results: List[CaseResult]) -> ET.Element:
# TODO: only hash test parameter
row_anchor = md5sum(row_anchor)
row = ET.SubElement(table, "tr", id=row_anchor)
cell = ET.SubElement(row, "th")
cell.set("class", "test-name")
cell_link = ET.SubElement(cell, "a", href=f"#{row_anchor}")
cell_link.text = test_name
row = HTML.tr(
HTML.th(HTML.a(test_name, href=f"#{row_anchor}"), class_="test-name"),
id=row_anchor,
)
rows.append(row)
results_by_job = group_by(test_results, key=lambda r: r.job)
for job_name in jobs:
cell = ET.SubElement(row, "td")
try:
(result,) = results_by_job[job_name]
except KeyError:
cell.set("class", "deselected")
cell.text = "d"
row.append(HTML.td("d", class_="deselected"))
continue
text: Optional[str]
text: Union[str, None, ET.Element]
attrib = {}
if result.skipped:
cell.set("class", "skipped")
attrib["class"] = "skipped"
if result.type == "pytest.skip":
text = "s"
elif result.type == "pytest.xfail":
text = "X"
cell.set("class", "expected-failure")
attrib["class"] = "expected-failure"
else:
text = result.type
elif result.success:
cell.set("class", "success")
attrib["class"] = "success"
if result.type:
# dead code?
text = result.type
else:
text = "."
else:
cell.set("class", "failure")
attrib["class"] = "failure"
if result.type:
# dead code?
text = result.type
@ -272,14 +288,15 @@ def build_test_table(jobs: List[str], results: List[CaseResult]) -> ET.Element:
if result.system_out:
# There is a log file; link to it.
a = ET.SubElement(cell, "a", href=f"./{result.output_filename()}")
a.text = text or "?"
text = HTML.a(text or "?", href=f"./{result.output_filename()}")
else:
cell.text = text or "?"
text = text or "?"
if result.message:
cell.set("title", result.message)
attrib["title"] = result.message
return table
row.append(HTML.td(text, attrib))
return HTML.table(*rows, class_=class_)
def write_html_pages(
@ -355,15 +372,6 @@ def write_test_outputs(output_dir: Path, results: List[CaseResult]) -> None:
def write_html_index(output_dir: Path, pages: List[Tuple[str, str, str]]) -> None:
root = ET.Element("html")
head = ET.SubElement(root, "head")
ET.SubElement(head, "title").text = "irctest dashboard"
ET.SubElement(head, "link", rel="stylesheet", type="text/css", href="./style.css")
body = ET.SubElement(root, "body")
ET.SubElement(body, "h1").text = "irctest dashboard"
module_pages = []
job_pages = []
for page_type, title, file_name in sorted(pages):
@ -374,28 +382,36 @@ def write_html_index(output_dir: Path, pages: List[Tuple[str, str, str]]) -> Non
else:
assert False, page_type
ET.SubElement(body, "h2").text = "Tests by command/specification"
page = HTML.html(
HTML.head(
HTML.title("irctest dashboard"),
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
),
HTML.body(
HTML.h1("irctest dashboard"),
HTML.h2("Tests by command/specification"),
HTML.dl(
[
(
HTML.dt(HTML.a(module_name, href=f"./{file_name}")),
HTML.dd(docstring(importlib.import_module(module_name))),
)
for module_name, file_name in sorted(module_pages)
],
class_="module-index",
),
HTML.h2("Tests by implementation"),
HTML.ul(
[
HTML.li(HTML.a(job, href=f"./{file_name}"))
for job, file_name in sorted(job_pages)
],
class_="job-index",
),
),
)
dl = ET.SubElement(body, "dl")
dl.set("class", "module-index")
for module_name, file_name in sorted(module_pages):
module = importlib.import_module(module_name)
link = ET.SubElement(ET.SubElement(dl, "dt"), "a", href=f"./{file_name}")
link.text = module_name
append_docstring(ET.SubElement(dl, "dd"), module)
ET.SubElement(body, "h2").text = "Tests by implementation"
ul = ET.SubElement(body, "ul")
ul.set("class", "job-index")
for job, file_name in sorted(job_pages):
link = ET.SubElement(ET.SubElement(ul, "li"), "a", href=f"./{file_name}")
link.text = job
write_xml_file(output_dir / "index.xhtml", root)
write_xml_file(output_dir / "index.xhtml", page)
def write_assets(output_dir: Path) -> None:
@ -407,11 +423,11 @@ def write_assets(output_dir: Path) -> None:
def write_xml_file(filename: Path, root: ET.Element) -> None:
# Hacky: ET expects the namespace to be present in every tag we create instead;
# but it would be excessively verbose.
root.set("xmlns", "http://www.w3.org/1999/xhtml")
# Serialize
if sys.version_info >= (3, 8):
s = ET.tostring(root, default_namespace=HTML.uri)
else:
# default_namespace not supported
s = ET.tostring(root)
with filename.open("wb") as fd:

View File

@ -0,0 +1,126 @@
# Copyright (c) 2023 Valentin Lorentz
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
"""This module allows writing XML ASTs in a way that is more concise than the default
:mod:`xml.etree.ElementTree` interface.
For example:
.. code-block:: python
from .shortxml import Namespace
HTML = Namespace("http://www.w3.org/1999/xhtml")
page = HTML.html(
HTML.head(
HTML.title("irctest dashboard"),
HTML.link(rel="stylesheet", type="text/css", href="./style.css"),
),
HTML.body(
HTML.h1("irctest dashboard"),
HTML.h2("Tests by command/specification"),
HTML.dl(
[
( # elements can be arbitrarily nested in lists
HTML.dt(HTML.a(title, href=f"./{title}.xhtml")),
HTML.dd(defintion),
)
for title, definition in sorted(definitions)
],
class_="module-index",
),
HTML.h2("Tests by implementation"),
HTML.ul(
[
HTML.li(HTML.a(job, href=f"./{file_name}"))
for job, file_name in sorted(job_pages)
],
class_="job-index",
),
),
)
print(ET.tostring(page, default_namespace=HTML.uri))
Attributes can be passed either as dictionaries or as kwargs, and can be mixed
with child elements.
Trailing underscores are stripped from attributes, which allows passing reserved
Python keywords (eg. ``class_`` instead of ``class``)
Attributes are always qualified, and share the namespace of the element they are
attached to.
Mixed content (elements containing both text and child elements) is not supported.
"""
from typing import Dict, Sequence, Union
import xml.etree.ElementTree as ET
def _namespacify(ns: str, s: str) -> str:
return "{%s}%s" % (ns, s)
_Children = Union[None, Dict[str, str], ET.Element, Sequence["_Children"]]
class ElementFactory:
def __init__(self, namespace: str, tag: str):
self._tag = _namespacify(namespace, tag)
self._namespace = namespace
def __call__(self, *args: Union[str, _Children], **kwargs: str) -> ET.Element:
e = ET.Element(self._tag)
attributes = {k.rstrip("_"): v for (k, v) in kwargs.items()}
children = [*args, attributes]
if args and isinstance(children[0], str):
e.text = children[0]
children.pop(0)
for child in children:
self._append_child(e, child)
return e
def _append_child(self, e: ET.Element, child: _Children) -> None:
if isinstance(child, ET.Element):
e.append(child)
elif child is None:
pass
elif isinstance(child, dict):
for k, v in child.items():
e.set(_namespacify(self._namespace, k), str(v))
elif isinstance(child, str):
raise ValueError("Mixed content is not supported")
else:
for grandchild in child:
self._append_child(e, grandchild)
class Namespace:
def __init__(self, uri: str):
self.uri = uri
def __getattr__(self, tag: str) -> ElementFactory:
return ElementFactory(self.uri, tag)

View File

@ -4,11 +4,32 @@
"""
from irctest import cases
from irctest.patma import ANYSTR
from irctest.patma import ANYSTR, StrRe
from irctest.runner import CapabilityNotSupported, ImplementationChoice
class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
def testInvalidCapSubcommand(self):
"""“If no capabilities are active, an empty parameter must be sent.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-list-subcommand>
""" # noqa
self.addClient()
self.sendLine(1, "CAP NOTACOMMAND")
self.sendLine(1, "PING test123")
m = self.getRegistrationMessage(1)
self.assertTrue(
self.messageDiffers(m, command="PONG", params=[ANYSTR, "test123"]),
"Sending “CAP NOTACOMMAND” as first message got no reply",
)
self.assertMessageMatch(
m,
command="410",
params=["*", "NOTACOMMAND", ANYSTR],
fail_msg="Sending “CAP NOTACOMMAND” as first message got a reply "
"that is not ERR_INVALIDCAPCMD: {msg}",
)
@cases.mark_specifications("IRCv3")
def testNoReq(self):
"""Test the server handles gracefully clients which do not send
@ -23,12 +44,206 @@ class CapTestCase(cases.BaseServerTestCase):
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
# Make sure the server didn't send anything yet
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
def testReqOne(self):
"""Tests requesting a single capability"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "LIST", StrRe("multi-prefix ?")],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd"],
"ngIRCd does not support userhost-in-names",
)
def testReqTwo(self):
"""Tests requesting two capabilities at once"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix userhost-in-names")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix userhost-in-names ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[
ANYSTR,
"LIST",
StrRe(
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
),
],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd"],
"ngIRCd does not support userhost-in-names",
)
def testReqOneThenOne(self):
"""Tests requesting two capabilities in different messages"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP REQ :userhost-in-names")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[
ANYSTR,
"LIST",
StrRe(
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
),
],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd"],
"ngIRCd does not support userhost-in-names",
)
def testReqPostRegistration(self):
"""Tests requesting more capabilities after CAP END"""
self.addClient(1)
self.sendLine(1, "CAP LS")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "LIST", StrRe("multi-prefix ?")],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}."
)
self.getMessages(1)
self.sendLine(1, "CAP REQ :userhost-in-names")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")],
fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.",
)
self.sendLine(1, "CAP LIST")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[
ANYSTR,
"LIST",
StrRe(
"(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?"
),
],
fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.",
)
@cases.mark_specifications("IRCv3")
def testReqUnavailable(self):
"""Test the server handles gracefully clients which request
@ -45,7 +260,7 @@ class CapTestCase(cases.BaseServerTestCase):
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo"],
params=[ANYSTR, "NAK", StrRe("foo ?")],
fail_msg="Expected CAP NAK after requesting non-existing "
"capability, got {msg}.",
)
@ -78,10 +293,6 @@ class CapTestCase(cases.BaseServerTestCase):
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["UnrealIRCd"],
"UnrealIRCd sends a trailing space on CAP NAK: https://github.com/unrealircd/unrealircd/pull/148",
)
def testNakWhole(self):
"""“The capability identifier set must be accepted as a whole, or
rejected entirely.”
@ -123,16 +334,12 @@ class CapTestCase(cases.BaseServerTestCase):
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", "multi-prefix"],
params=[ANYSTR, "ACK", StrRe("multi-prefix ?")],
fail_msg="Expected “CAP ACK :multi-prefix” after "
"sending “CAP REQ :multi-prefix”, but got {msg}.",
)
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["UnrealIRCd"],
"UnrealIRCd sends a trailing space on CAP NAK: https://github.com/unrealircd/unrealircd/pull/148",
)
def testCapRemovalByClient(self):
"""Test CAP LIST and removal of caps via CAP REQ :-tagname."""
cap1 = "echo-message"
@ -140,8 +347,13 @@ class CapTestCase(cases.BaseServerTestCase):
self.addClient(1)
self.connectClient("sender")
self.sendLine(1, "CAP LS 302")
caps = set()
while True:
m = self.getRegistrationMessage(1)
if not ({cap1, cap2} <= set(m.params[2].split())):
caps.update(m.params[-1].split())
if m.params[2] != "*":
break
if not ({cap1, cap2} <= caps):
raise CapabilityNotSupported(f"{cap1} or {cap2}")
self.sendLine(1, f"CAP REQ :{cap1} {cap2}")
self.sendLine(1, "nick bar")
@ -167,17 +379,19 @@ class CapTestCase(cases.BaseServerTestCase):
m = self.getMessage(1)
self.assertIn("time", m.tags, m)
# remove the server-time cap
# remove the multi-prefix cap
self.sendLine(1, f"CAP REQ :-{cap2}")
m = self.getMessage(1)
# Must be either ACK or NAK
if self.messageDiffers(m, command="CAP", params=[ANYSTR, "ACK", f"-{cap2}"]):
if self.messageDiffers(
m, command="CAP", params=[ANYSTR, "ACK", StrRe(f"-{cap2} ?")]
):
self.assertMessageMatch(
m, command="CAP", params=[ANYSTR, "NAK", f"-{cap2}"]
m, command="CAP", params=[ANYSTR, "NAK", StrRe(f"-{cap2} ?")]
)
raise ImplementationChoice(f"Does not support CAP REQ -{cap2}")
# server-time should be disabled
# multi-prefix should be disabled
self.sendLine(1, "CAP LIST")
messages = self.getMessages(1)
cap_list = [m for m in messages if m.command == "CAP"][0]
@ -242,3 +456,31 @@ class CapTestCase(cases.BaseServerTestCase):
fail_msg="Sending “CAP LIST” as first message got a reply "
"that is not “CAP * LIST :”: {msg}",
)
@cases.mark_specifications("IRCv3")
def testNoMultiline301Response(self):
"""
Current version: "If the client supports CAP version 302, the server MAY send
multiple lines in response to CAP LS and CAP LIST." This should be read as
disallowing multiline responses to pre-302 clients.
-- <https://ircv3.net/specs/extensions/capability-negotiation#multiline-replies-to-cap-ls-and-cap-list>
""" # noqa
self.check301ResponsePreRegistration("bar", "CAP LS")
self.check301ResponsePreRegistration("qux", "CAP LS 301")
self.check301ResponsePostRegistration("baz", "CAP LS")
self.check301ResponsePostRegistration("bat", "CAP LS 301")
def check301ResponsePreRegistration(self, nick, cap_ls):
self.addClient(nick)
self.sendLine(nick, cap_ls)
self.sendLine(nick, "NICK " + nick)
self.sendLine(nick, "USER u s e r")
self.sendLine(nick, "CAP END")
responses = [msg for msg in self.skipToWelcome(nick) if msg.command == "CAP"]
self.assertLessEqual(len(responses), 1, responses)
def check301ResponsePostRegistration(self, nick, cap_ls):
self.connectClient(nick, name=nick)
self.sendLine(nick, cap_ls)
responses = [msg for msg in self.getMessages(nick) if msg.command == "CAP"]
self.assertLessEqual(len(responses), 1, responses)

View File

@ -10,7 +10,7 @@ import pytest
from irctest import cases, runner
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYSTR
from irctest.patma import ANYSTR, StrRe
CHATHISTORY_CAP = "draft/chathistory"
EVENT_PLAYBACK_CAP = "draft/event-playback"
@ -21,28 +21,6 @@ SUBCOMMANDS = ["LATEST", "BEFORE", "AFTER", "BETWEEN", "AROUND"]
MYSQL_PASSWORD = ""
def validate_chathistory_batch(msgs):
batch_tag = None
closed_batch_tag = None
result = []
for msg in msgs:
if msg.command == "BATCH":
batch_param = msg.params[0]
if batch_tag is None and batch_param[0] == "+":
batch_tag = batch_param[1:]
elif batch_param[0] == "-":
closed_batch_tag = batch_param[1:]
elif (
msg.command == "PRIVMSG"
and batch_tag is not None
and msg.tags.get("batch") == batch_tag
):
if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific
result.append(msg.to_history_message())
assert batch_tag == closed_batch_tag
return result
def skip_ngircd(f):
@functools.wraps(f)
def newf(self, *args, **kwargs):
@ -56,6 +34,26 @@ def skip_ngircd(f):
@cases.mark_specifications("IRCv3")
@cases.mark_services
class ChathistoryTestCase(cases.BaseServerTestCase):
def validate_chathistory_batch(self, msgs, target):
(start, *inner_msgs, end) = msgs
self.assertMessageMatch(
start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", target]
)
batch_tag = start.params[0][1:]
self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag])
result = []
for msg in inner_msgs:
if (
msg.command == "PRIVMSG"
and batch_tag is not None
and msg.tags.get("batch") == batch_tag
):
if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific
result.append(msg.to_history_message())
return result
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(chathistory=True)
@ -308,6 +306,9 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
)
time.sleep(0.002)
self.getMessages(1)
self.getMessages(2)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
@ -401,15 +402,15 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages, result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-5:], result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-1:], result)
self.sendLine(
@ -417,7 +418,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s msgid=%s %d"
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[5:], result)
self.sendLine(
@ -425,7 +426,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s timestamp=%s %d"
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[5:], result)
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
@ -435,7 +436,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s msgid=%s %d"
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[:6], result)
self.sendLine(
@ -443,7 +444,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[:6], result)
self.sendLine(
@ -451,7 +452,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, 2),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:6], result)
def _validate_chathistory_AFTER(self, echo_messages, user, chname):
@ -461,7 +462,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s msgid=%s %d"
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:], result)
self.sendLine(
@ -469,14 +470,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:], result)
self.sendLine(
user,
"CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:7], result)
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
@ -492,7 +493,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT,
),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
@ -505,7 +506,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT,
),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
# BETWEEN forwards and backwards with a limit, should get
@ -515,7 +516,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:4], result)
self.sendLine(
@ -523,7 +524,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-4:-1], result)
# same stuff again but with timestamps
@ -532,28 +533,28 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[-1].time, echo_messages[0].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[0].time, echo_messages[-1].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:4], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[-1].time, echo_messages[0].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-4:-1], result)
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
@ -561,14 +562,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
user,
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual([echo_messages[7]], result)
self.sendLine(
user,
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[6:9], result)
self.sendLine(
@ -576,7 +577,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s timestamp=%s %d"
% (chname, echo_messages[7].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertIn(echo_messages[7], result)
@pytest.mark.arbitrary_client_tags

View File

@ -0,0 +1,38 @@
"""
Channel "no external messages" mode (`RFC 1459
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.3.1>`__,
`Modern <https://modern.ircdocs.horse/#no-external-messages-mode>`__)
"""
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN
class NoExternalMessagesTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "Modern")
def testNoExternalMessagesMode(self):
# test the +n channel mode
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.sendLine("chanop", "MODE #chan +n")
self.getMessages("chanop")
self.connectClient("baz", name="baz")
# this message should be suppressed completely by +n
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
replies = self.getMessages("baz")
reply_cmds = {reply.command for reply in replies}
self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds)
self.assertEqual(self.getMessages("chanop"), [])
# set the channel to -n: baz should be able to send now
self.sendLine("chanop", "MODE #chan -n")
replies = self.getMessages("chanop")
modeLines = [line for line in replies if line.command == "MODE"]
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "-n"])
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
self.getMessages("baz")
relays = self.getMessages("chanop")
self.assertMessageMatch(
relays[0], command="PRIVMSG", params=["#chan", "hi again from baz"]
)

View File

@ -22,23 +22,17 @@ class EchoMessageTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("echo-message")
def testEchoMessage(self, command, solo, server_time):
"""<http://ircv3.net/specs/extensions/echo-message-3.2.html>"""
if server_time:
capabilities = ["server-time"] if server_time else []
self.connectClient(
"baz",
capabilities=["echo-message", "server-time"],
skip_if_cap_nak=True,
)
else:
self.connectClient(
"baz",
capabilities=["echo-message", "server-time"],
capabilities=["echo-message", *capabilities],
skip_if_cap_nak=True,
)
self.sendLine(1, "JOIN #chan")
if not solo:
capabilities = ["server-time"] if server_time else None
self.connectClient("qux", capabilities=capabilities)
self.sendLine(2, "JOIN #chan")

View File

@ -360,8 +360,8 @@ class InviteTestCase(cases.BaseServerTestCase):
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.sendLine(2, "JOIN #chan")
self.getMessages(1)
self.sendLine(2, "JOIN #chan")
self.getMessages(2)
self.getMessages(1)

View File

@ -12,6 +12,7 @@ import pytest
from irctest import cases
from irctest.numerics import ERR_UNKNOWNCOMMAND
from irctest.patma import ANYDICT, ANYOPTSTR, NotStrRe, RemainingKeys, StrRe
from irctest.runner import OptionalExtensionNotSupported
class LabeledResponsesTestCase(cases.BaseServerTestCase):
@ -22,7 +23,10 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase):
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
if int(self.targmax.get("PRIVMSG", "1") or "4") < 3:
raise OptionalExtensionNotSupported("PRIVMSG to multiple targets")
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],

View File

@ -4,6 +4,7 @@ The PRIVMSG and NOTICE commands.
from irctest import cases
from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYSTR
class PrivmsgTestCase(cases.BaseServerTestCase):
@ -45,12 +46,12 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testPrivmsgNonexistentUser(self):
"""https://tools.ietf.org/html/rfc2812#section-3.3.1"""
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
self.connectClient("foo")
self.sendLine(1, "PRIVMSG bar :hey there!")
msg = self.getMessage(1)
# ERR_NOSUCHNICK
self.assertIn(msg.command, ("401"))
# ERR_NOSUCHNICK: 401 <sender> <recipient> :No such nick
self.assertMessageMatch(msg, command="401", params=["foo", "bar", ANYSTR])
class NoticeTestCase(cases.BaseServerTestCase):
@ -100,8 +101,13 @@ class NoticeTestCase(cases.BaseServerTestCase):
class TagsTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("message-tags")
@cases.xfailIfSoftware(
["UnrealIRCd"], "https://bugs.unrealircd.org/view.php?id=5947"
@cases.xfailIf(
lambda self: bool(
self.controller.software_name == "UnrealIRCd"
and self.controller.software_version == 5
),
"UnrealIRCd <6.0.7 dropped messages with excessively large tags: "
"https://bugs.unrealircd.org/view.php?id=5947",
)
def testLineTooLong(self):
self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True)

View File

@ -6,8 +6,8 @@ from irctest import cases
class MetadataTestCase(cases.BaseServerTestCase):
valid_metadata_keys = {"valid_key1", "valid_key2"}
invalid_metadata_keys = {"invalid_key1", "invalid_key2"}
valid_metadata_keys = {"display-name", "avatar"}
invalid_metadata_keys = {"indisplay-name", "inavatar"}
@cases.mark_specifications("IRCv3", deprecated=True)
def testInIsupport(self):
@ -36,7 +36,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
def testGetOneUnsetValid(self):
"""<http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>"""
self.connectClient("foo")
self.sendLine(1, "METADATA * GET valid_key1")
self.sendLine(1, "METADATA * GET display-name")
m = self.getMessage(1)
self.assertMessageMatch(
m,
@ -52,7 +52,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>
"""
self.connectClient("foo")
self.sendLine(1, "METADATA * GET valid_key1 valid_key2")
self.sendLine(1, "METADATA * GET display-name avatar")
m = self.getMessage(1)
self.assertMessageMatch(
m,
@ -62,10 +62,10 @@ class MetadataTestCase(cases.BaseServerTestCase):
)
self.assertEqual(
m.params[1],
"valid_key1",
"display-name",
m,
fail_msg="Response to “METADATA * GET valid_key1 valid_key2"
"did not respond to valid_key1 first: {msg}",
fail_msg="Response to “METADATA * GET display-name avatar"
"did not respond to display-name first: {msg}",
)
m = self.getMessage(1)
self.assertMessageMatch(
@ -76,10 +76,10 @@ class MetadataTestCase(cases.BaseServerTestCase):
)
self.assertEqual(
m.params[1],
"valid_key2",
"avatar",
m,
fail_msg="Response to “METADATA * GET valid_key1 valid_key2"
"did not respond to valid_key2 as second response: {msg}",
fail_msg="Response to “METADATA * GET display-name avatar"
"did not respond to avatar as second response: {msg}",
)
@cases.mark_specifications("IRCv3", deprecated=True)
@ -135,7 +135,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
)
self.assertEqual(
m.params[1],
"valid_key1",
"display-name",
m,
fail_msg="Second param of 761 after setting “{expects}” to "
"{}” is not “{expects}”: {msg}.",
@ -190,7 +190,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
def testSetGetValid(self):
"""<http://ircv3.net/specs/core/metadata-3.2.html>"""
self.connectClient("foo")
self.assertSetGetValue("*", "valid_key1", "myvalue")
self.assertSetGetValue("*", "display-name", "myvalue")
@cases.mark_specifications("IRCv3", deprecated=True)
def testSetGetZeroCharInValue(self):
@ -198,7 +198,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-restrictions>
"""
self.connectClient("foo")
self.assertSetGetValue("*", "valid_key1", "zero->\0<-zero", "zero->\\0<-zero")
self.assertSetGetValue("*", "display-name", "zero->\0<-zero", "zero->\\0<-zero")
@cases.mark_specifications("IRCv3", deprecated=True)
def testSetGetHeartInValue(self):
@ -209,7 +209,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
self.connectClient("foo")
self.assertSetGetValue(
"*",
"valid_key1",
"display-name",
"->{}<-".format(heart),
"zero->{}<-zero".format(heart.encode()),
)
@ -223,7 +223,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
# Sending directly because it is not valid UTF-8 so Python would
# not like it
self.clients[1].conn.sendall(
b"METADATA * SET valid_key1 " b":invalid UTF-8 ->\xc3<-\r\n"
b"METADATA * SET display-name " b":invalid UTF-8 ->\xc3<-\r\n"
)
commands = {m.command for m in self.getMessages(1)}
self.assertNotIn(
@ -233,7 +233,7 @@ class MetadataTestCase(cases.BaseServerTestCase):
"UTF-8 was answered with 761 (RPL_KEYVALUE)",
)
self.clients[1].conn.sendall(
b"METADATA * SET valid_key1 " b":invalid UTF-8: \xc3\r\n"
b"METADATA * SET display-name " b":invalid UTF-8: \xc3\r\n"
)
commands = {m.command for m in self.getMessages(1)}
self.assertNotIn(

View File

@ -249,6 +249,23 @@ class MonitorTestCase(_BaseMonitorTestCase):
extra_format=(messages,),
)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorClear(self):
"""“Clears the list of targets being monitored. No output will be returned
for use of this command.“
-- <https://ircv3.net/specs/extensions/monitor#monitor-c>
"""
self.connectClient("foo")
self.check_server_support()
self.sendLine(1, "MONITOR + bar")
self.getMessages(1)
self.sendLine(1, "MONITOR C")
self.sendLine(1, "MONITOR L")
m = self.getMessage(1)
self.assertEqual(m.command, RPL_ENDOFMONLIST)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorList(self):
@ -284,6 +301,35 @@ class MonitorTestCase(_BaseMonitorTestCase):
self.sendLine(1, "MONITOR L")
checkMonitorSubjects(self.getMessages(1), "bar", {"bazbat"})
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorStatus(self):
"""“Outputs for each target in the list being monitored, whether
the client is online or offline. All targets that are online will
be sent using RPL_MONONLINE, all targets that are offline will be
sent using RPL_MONOFFLINE.“
-- <https://ircv3.net/specs/extensions/monitor#monitor-s>
"""
self.connectClient("foo")
self.check_server_support()
self.connectClient("bar")
self.sendLine(1, "MONITOR + bar,baz")
self.getMessages(1)
self.sendLine(1, "MONITOR S")
msgs = self.getMessages(1)
self.assertEqual(
len(msgs),
2,
fail_msg="Expected one RPL_MONONLINE (730) and one RPL_MONOFFLINE (731), got: {}",
extra_format=(msgs,),
)
msgs.sort(key=lambda m: m.command)
self.assertMononline(1, "bar", m=msgs[0])
self.assertMonoffline(1, "baz", m=msgs[1])
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testNickChange(self):
@ -327,7 +373,7 @@ class _BaseExtendedMonitorTestCase(_BaseMonitorTestCase):
"""Tests https://ircv3.net/specs/extensions/extended-monitor.html"""
self.connectClient(
"foo",
capabilities=["draft/extended-monitor", *watcher_caps],
capabilities=["extended-monitor", *watcher_caps],
skip_if_cap_nak=True,
)

View File

@ -15,7 +15,7 @@ class MultiPrefixTestCase(cases.BaseServerTestCase):
These prefixes MUST be in order of rank, from highest to lowest.
"""
self.connectClient("foo", capabilities=["multi-prefix"])
self.connectClient("foo", capabilities=["multi-prefix"], skip_if_cap_nak=True)
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +v foo")
self.getMessages(1)

View File

@ -10,6 +10,7 @@ TODO: cross-reference Modern
import time
from irctest import cases
from irctest.numerics import RPL_NAMREPLY
class PartTestCase(cases.BaseServerTestCase):
@ -84,6 +85,12 @@ class PartTestCase(cases.BaseServerTestCase):
self.getMessages(1)
self.getMessages(2)
self.sendLine(2, "PRIVMSG #chan :hi everyone")
self.getMessages(2)
self.assertMessageMatch(
self.getMessage(1), command="PRIVMSG", params=["#chan", "hi everyone"]
)
self.sendLine(1, "PART #chan")
# both the PART'ing client and the other channel member should receive
# a PART line:
@ -92,6 +99,21 @@ class PartTestCase(cases.BaseServerTestCase):
m = self.getMessage(2)
self.assertMessageMatch(m, command="PART")
self.sendLine(2, "PRIVMSG #chan :hi again everyone")
self.getMessages(2)
# client 1 has PART'ed and should not receive channel messages:
self.assertEqual(self.getMessages(1), [])
# client 1 should no longer appear in NAMES responses:
names = set()
self.sendLine(2, "NAMES #chan")
for reply in self.getMessages(2):
if reply.command != RPL_NAMREPLY:
continue
names.update(reply.params[-1].replace("@", "").split())
self.assertNotIn("bar", names)
self.assertIn("baz", names)
@cases.mark_specifications("RFC2812")
def testBasicPartRfc2812(self):
"""

View File

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

View File

@ -46,6 +46,30 @@ class TopicTestCase(cases.BaseServerTestCase):
m = self.getMessage(2)
self.assertMessageMatch(m, command="TOPIC", params=["#chan", "T0P1C"])
@cases.mark_specifications("Modern")
def testTopicUnchanged(self):
""""If the topic of a channel is changed or cleared, every client in that
channel (including the author of the topic change) will receive a TOPIC command"
-- https://modern.ircdocs.horse/#topic-message
"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
# clear waiting msgs about cli 2 joining the channel
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "TOPIC #chan :T0P1C")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "TOPIC #chan :T0P1C")
self.assertEqual(self.getMessages(2), [], "Unchanged topic was transmitted")
self.assertEqual(self.getMessages(1), [], "Unchanged topic was echoed")
@cases.mark_specifications("RFC1459", "RFC2812")
def testTopicMode(self):
"""“Once a user has joined a channel, he receives information about

View File

@ -361,6 +361,87 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
params=["otherNick", InsensitiveStr(mask), ANYSTR],
)
@cases.mark_specifications("Modern")
def testWhoMultiChan(self):
"""
When WHO <#chan> is sent, the second parameter of RPL_WHOREPLY must
be ``#chan``. See discussion on Modern:
<https://github.com/ircdocs/modern-irc/issues/209>
"""
self._init()
self.sendLine(1, "JOIN #otherchan")
self.getMessages(1)
self.sendLine(2, "JOIN #otherchan")
self.getMessages(2)
for chan in ["#chan", "#otherchan"]:
self.sendLine(2, f"WHO {chan}")
messages = self.getMessages(2)
self.assertEqual(len(messages), 3, "Unexpected number of messages")
(*replies, end) = messages
# Get them in deterministic order
replies.sort(key=lambda msg: msg.params[5])
self.assertMessageMatch(
replies[0],
command=RPL_WHOREPLY,
params=[
"otherNick",
chan,
ANYSTR,
ANYSTR,
"My.Little.Server",
"coolNick",
ANYSTR,
ANYSTR,
],
)
self.assertMessageMatch(
replies[1],
command=RPL_WHOREPLY,
params=[
"otherNick",
chan,
ANYSTR,
ANYSTR,
"My.Little.Server",
"otherNick",
ANYSTR,
ANYSTR,
],
)
self.assertMessageMatch(
end,
command=RPL_ENDOFWHO,
params=["otherNick", InsensitiveStr(chan), ANYSTR],
)
@cases.mark_specifications("Modern")
def testWhoNickNotExists(self):
"""
When WHO is sent with a non-existing nickname, the server must reply
with a single RPL_ENDOFWHO. See:
<https://github.com/ircdocs/modern-irc/pull/216>
"""
self._init()
self.sendLine(2, "WHO idontexist")
(end,) = self.getMessages(2)
self.assertMessageMatch(
end,
command=RPL_ENDOFWHO,
params=["otherNick", InsensitiveStr("idontexist"), ANYSTR],
)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("WHOX")
def testWhoxFull(self):

View File

@ -201,10 +201,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1967",
)
def testWhowasMultiple(self):
"""
"The history is searched backward, returning the most recent entry first."
@ -215,10 +211,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2")
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1968",
)
def testWhowasCount1(self):
"""
"If there are multiple entries, up to <count> replies will be returned"
@ -229,10 +221,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
self._testWhowasMultiple(second_result=False, whowas_command="WHOWAS nick2 1")
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1968",
)
def testWhowasCount2(self):
"""
"If there are multiple entries, up to <count> replies will be returned"
@ -243,10 +231,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 2")
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1968",
)
def testWhowasCountNegative(self):
"""
"If a non-positive number is passed as being <count>, then a full search
@ -264,10 +248,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
@cases.xfailIfSoftware(
["ircu2"], "Fix not released yet: https://github.com/UndernetIRC/ircu2/pull/19"
)
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1967",
)
def testWhowasCountZero(self):
"""
"If a non-positive number is passed as being <count>, then a full search

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@v2",
"uses": "actions/checkout@v3",
"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@v2",
"uses": "actions/cache@v3",
"with": {
"path": f"~/.cache\n${{ github.workspace }}/{path}\n",
"key": "3-${{ runner.os }}-"
@ -116,18 +116,18 @@ def get_build_job(*, software_config, software_id, version_flavor):
return None
return {
"runs-on": "ubuntu-20.04",
"runs-on": "ubuntu-22.04",
"steps": [
{
"name": "Create directories",
"run": "cd ~/; mkdir -p .local/ go/",
},
*cache,
{"uses": "actions/checkout@v2"},
{"uses": "actions/checkout@v3"},
{
"name": "Set up Python 3.7",
"uses": "actions/setup-python@v2",
"with": {"python-version": 3.7},
"name": "Set up Python 3.11",
"uses": "actions/setup-python@v4",
"with": {"python-version": 3.11},
},
*install_steps,
*upload_steps(software_id),
@ -159,7 +159,7 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
downloads.append(
{
"name": "Download build artefacts",
"uses": "actions/download-artifact@v2",
"uses": "actions/download-artifact@v3",
"with": {"name": f"installed-{software_id}", "path": "~"},
}
)
@ -191,14 +191,14 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
unpack = []
return {
"runs-on": "ubuntu-20.04",
"runs-on": "ubuntu-22.04",
"needs": needs,
"steps": [
{"uses": "actions/checkout@v2"},
{"uses": "actions/checkout@v3"},
{
"name": "Set up Python 3.7",
"uses": "actions/setup-python@v2",
"with": {"python-version": 3.7},
"name": "Set up Python 3.11",
"uses": "actions/setup-python@v4",
"with": {"python-version": 3.11},
},
*downloads,
*unpack,
@ -231,7 +231,7 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs):
{
"name": "Publish results",
"if": "always()",
"uses": "actions/upload-artifact@v2",
"uses": "actions/upload-artifact@v3",
"with": {
"name": f"pytest-results_{test_id}_{version_flavor.value}",
"path": "pytest.xml",
@ -250,7 +250,7 @@ def upload_steps(software_id):
},
{
"name": "Upload build artefacts",
"uses": "actions/upload-artifact@v2",
"uses": "actions/upload-artifact@v3",
"with": {
"name": f"installed-{software_id}",
"path": "~/artefacts-*.tar.gz",
@ -306,15 +306,15 @@ def generate_workflow(config: dict, version_flavor: VersionFlavor):
jobs["publish-test-results"] = {
"name": "Publish Dashboard",
"needs": sorted({f"test-{test_id}" for test_id in config["tests"]} & set(jobs)),
"runs-on": "ubuntu-20.04",
"runs-on": "ubuntu-22.04",
# the build-and-test job might be skipped, we don't need to run
# this job then
"if": "success() || failure()",
"steps": [
{"uses": "actions/checkout@v2"},
{"uses": "actions/checkout@v3"},
{
"name": "Download Artifacts",
"uses": "actions/download-artifact@v2",
"uses": "actions/download-artifact@v3",
"with": {"path": "artifacts"},
},
{

View File

@ -0,0 +1,342 @@
From 42b67ff7218877934abed2a738e164c0dea171b0 Mon Sep 17 00:00:00 2001
From: "Ned T. Crigler" <RuneB@dal.net>
Date: Sun, 26 Feb 2023 17:42:29 -0800
Subject: [PATCH 1/2] Fix compilation on Ubuntu 22.04
Starting with glibc 2.34 "The symbols __dn_comp, __dn_expand,
__dn_skipname, __res_dnok, __res_hnok, __res_mailok, __res_mkquery,
__res_nmkquery, __res_nquery, __res_nquerydomain, __res_nsearch,
__res_nsend, __res_ownok, __res_query, __res_querydomain, __res_search,
__res_send formerly in libresolv have been renamed and no longer have a
__ prefix. They are now available in libc."
https://sourceware.org/pipermail/libc-alpha/2021-August/129718.html
The hex_to_string array in include/dh.h also conflicts with OpenSSL,
which OpenSSL 3.0 now complains about.
---
configure.in | 4 ++--
include/dh.h | 2 +-
include/resolv.h | 6 +++++-
src/dh.c | 2 +-
4 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/configure.in b/configure.in
index e76dee88..11720419 100644
--- a/configure.in
+++ b/configure.in
@@ -374,8 +374,7 @@ AC_C_INLINE
dnl Checks for libraries.
dnl Replace `main' with a function in -lnsl:
AC_CHECK_LIB(nsl, gethostbyname)
-AC_CHECK_FUNC(res_mkquery,, AC_CHECK_LIB(resolv, res_mkquery))
-AC_CHECK_FUNC(__res_mkquery,, AC_CHECK_LIB(resolv, __res_mkquery))
+AC_SEARCH_LIBS([res_mkquery],[resolv],,AC_SEARCH_LIBS([__res_mkquery],[resolv]))
AC_CHECK_LIB(socket, socket, zlib)
AC_CHECK_FUNC(crypt,, AC_CHECK_LIB(descrypt, crypt,,AC_CHECK_LIB(crypt, crypt,,)))
@@ -406,6 +405,7 @@ AC_CHECK_FUNCS([strcasecmp strchr strdup strerror strncasecmp strrchr strtol])
AC_CHECK_FUNCS([strtoul index strerror strtoken strtok inet_addr inet_netof])
AC_CHECK_FUNCS([inet_aton gettimeofday lrand48 sigaction bzero bcmp bcopy])
AC_CHECK_FUNCS([dn_skipname __dn_skipname getrusage times break])
+AC_CHECK_FUNCS([res_init __res_init res_mkquery __res_mkquery dn_expand __dn_expand])
dnl check for various OSes
diff --git a/include/dh.h b/include/dh.h
index 1ca6996a..1817ce1e 100644
--- a/include/dh.h
+++ b/include/dh.h
@@ -45,7 +45,7 @@ struct session_info
static BIGNUM *ircd_prime;
static BIGNUM *ircd_generator;
-static char *hex_to_string[256] =
+static char *dh_hex_to_string[256] =
{
"00", "01", "02", "03", "04", "05", "06", "07",
"08", "09", "0a", "0b", "0c", "0d", "0e", "0f",
diff --git a/include/resolv.h b/include/resolv.h
index b5a8aaa1..5b042d43 100644
--- a/include/resolv.h
+++ b/include/resolv.h
@@ -106,9 +106,13 @@ extern struct state _res;
extern char *p_cdname(), *p_rr(), *p_type(), *p_class(), *p_time();
-#if ((__GNU_LIBRARY__ == 6) && (__GLIBC__ >=2) && (__GLIBC_MINOR__ >= 2))
+#if !defined(HAVE_RES_INIT) && defined(HAVE___RES_INIT)
#define res_init __res_init
+#endif
+#if !defined(HAVE_RES_MKQUERY) && defined(HAVE___RES_MKQUERY)
#define res_mkquery __res_mkquery
+#endif
+#if !defined(HAVE_DN_EXPAND) && defined(HAVE___DN_EXPAND)
#define dn_expand __dn_expand
#endif
diff --git a/src/dh.c b/src/dh.c
index cb065a4f..4b5da282 100644
--- a/src/dh.c
+++ b/src/dh.c
@@ -223,7 +223,7 @@ static void create_prime()
for(i = 0; i < PRIME_BYTES; i++)
{
- char *x = hex_to_string[dh_prime_1024[i]];
+ char *x = dh_hex_to_string[dh_prime_1024[i]];
while(*x)
buf[bufpos++] = *x++;
}
From 135ebbea4c30e23228d00af762fa7da7ca5016bd Mon Sep 17 00:00:00 2001
From: "Ned T. Crigler" <RuneB@dal.net>
Date: Mon, 22 May 2023 15:31:54 -0700
Subject: [PATCH 2/2] Update the dh code to work with OpenSSL 3.0
---
include/dh.h | 8 ++++
src/dh.c | 120 ++++++++++++++++++++++++++++++++++++++++++++++++---
2 files changed, 123 insertions(+), 5 deletions(-)
diff --git a/include/dh.h b/include/dh.h
index 1817ce1e..705e6dee 100644
--- a/include/dh.h
+++ b/include/dh.h
@@ -22,7 +22,11 @@ extern void rc4_destroystate(void *a);
struct session_info
{
+#if OPENSSL_VERSION_NUMBER < 0x30000000L
DH *dh;
+#else
+ EVP_PKEY *dh;
+#endif
unsigned char *session_shared;
size_t session_shared_length;
};
@@ -45,6 +49,10 @@ struct session_info
static BIGNUM *ircd_prime;
static BIGNUM *ircd_generator;
+#if OPENSSL_VERSION_NUMBER >= 0x30000000L
+static EVP_PKEY *ircd_prime_ossl3;
+#endif
+
static char *dh_hex_to_string[256] =
{
"00", "01", "02", "03", "04", "05", "06", "07",
diff --git a/src/dh.c b/src/dh.c
index 4b5da282..f74d2d76 100644
--- a/src/dh.c
+++ b/src/dh.c
@@ -36,6 +36,11 @@
#include <openssl/dh.h>
#include "libcrypto-compat.h"
+#if OPENSSL_VERSION_NUMBER >= 0x30000000L
+#include <openssl/core_names.h>
+#include <openssl/param_build.h>
+#endif
+
#include "memcount.h"
#define DH_HEADER
@@ -215,7 +220,7 @@ static int init_random()
return 0;
}
-static void create_prime()
+static int create_prime()
{
char buf[PRIME_BYTES_HEX];
int i;
@@ -233,6 +238,34 @@ static void create_prime()
BN_hex2bn(&ircd_prime, buf);
ircd_generator = BN_new();
BN_set_word(ircd_generator, dh_gen_1024);
+
+#if OPENSSL_VERSION_NUMBER >= 0x30000000L
+ OSSL_PARAM_BLD *paramBuild = NULL;
+ OSSL_PARAM *param = NULL;
+ EVP_PKEY_CTX *primeCtx = NULL;
+
+ if(!(paramBuild = OSSL_PARAM_BLD_new()) ||
+ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_P, ircd_prime) ||
+ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_G, ircd_generator) ||
+ !(param = OSSL_PARAM_BLD_to_param(paramBuild)) ||
+ !(primeCtx = EVP_PKEY_CTX_new_from_name(NULL, "DHX", NULL)) ||
+ EVP_PKEY_fromdata_init(primeCtx) <= 0 ||
+ EVP_PKEY_fromdata(primeCtx, &ircd_prime_ossl3,
+ EVP_PKEY_KEY_PARAMETERS, param) <= 0 ||
+ 1)
+ {
+ if(primeCtx)
+ EVP_PKEY_CTX_free(primeCtx);
+ if(param)
+ OSSL_PARAM_free(param);
+ if(paramBuild)
+ OSSL_PARAM_BLD_free(paramBuild);
+ }
+
+ if(!ircd_prime_ossl3)
+ return -1;
+#endif
+ return 0;
}
int dh_init()
@@ -241,8 +274,7 @@ int dh_init()
ERR_load_crypto_strings();
#endif
- create_prime();
- if(init_random() == -1)
+ if(create_prime() == -1 || init_random() == -1)
return -1;
return 0;
}
@@ -250,7 +282,7 @@ int dh_init()
int dh_generate_shared(void *session, char *public_key)
{
BIGNUM *tmp;
- int len;
+ size_t len;
struct session_info *si = (struct session_info *) session;
if(verify_is_hex(public_key) == 0 || !si || si->session_shared)
@@ -261,13 +293,55 @@ int dh_generate_shared(void *session, char *public_key)
if(!tmp)
return 0;
+#if OPENSSL_VERSION_NUMBER < 0x30000000L
si->session_shared_length = DH_size(si->dh);
si->session_shared = (unsigned char *) malloc(DH_size(si->dh));
len = DH_compute_key(si->session_shared, tmp, si->dh);
+#else
+ OSSL_PARAM_BLD *paramBuild = NULL;
+ OSSL_PARAM *param = NULL;
+ EVP_PKEY_CTX *peerPubKeyCtx = NULL;
+ EVP_PKEY *peerPubKey = NULL;
+ EVP_PKEY_CTX *deriveCtx = NULL;
+
+ len = -1;
+ if(!(paramBuild = OSSL_PARAM_BLD_new()) ||
+ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_P, ircd_prime) ||
+ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_G, ircd_generator) ||
+ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_PUB_KEY, tmp) ||
+ !(param = OSSL_PARAM_BLD_to_param(paramBuild)) ||
+ !(peerPubKeyCtx = EVP_PKEY_CTX_new_from_name(NULL, "DHX", NULL)) ||
+ EVP_PKEY_fromdata_init(peerPubKeyCtx) <= 0 ||
+ EVP_PKEY_fromdata(peerPubKeyCtx, &peerPubKey,
+ EVP_PKEY_PUBLIC_KEY, param) <= 0 ||
+ !(deriveCtx = EVP_PKEY_CTX_new(si->dh, NULL)) ||
+ EVP_PKEY_derive_init(deriveCtx) <= 0 ||
+ EVP_PKEY_derive_set_peer(deriveCtx, peerPubKey) <= 0 ||
+ EVP_PKEY_derive(deriveCtx, NULL, &len) <= 0 ||
+ !(si->session_shared = malloc(len)) ||
+ EVP_PKEY_derive(deriveCtx, si->session_shared, &len) <= 0 ||
+ 1)
+ {
+ if(deriveCtx)
+ EVP_PKEY_CTX_free(deriveCtx);
+ if(peerPubKey)
+ EVP_PKEY_free(peerPubKey);
+ if(peerPubKeyCtx)
+ EVP_PKEY_CTX_free(peerPubKeyCtx);
+ if(param)
+ OSSL_PARAM_free(param);
+ if(paramBuild)
+ OSSL_PARAM_BLD_free(paramBuild);
+ }
+#endif
BN_free(tmp);
- if(len < 0)
+ if(len == -1 || !si->session_shared)
+ {
+ if(si->session_shared)
+ free(si->session_shared);
return 0;
+ }
si->session_shared_length = len;
@@ -284,6 +358,7 @@ void *dh_start_session()
memset(si, 0, sizeof(struct session_info));
+#if OPENSSL_VERSION_NUMBER < 0x30000000L
si->dh = DH_new();
if(si->dh == NULL)
return NULL;
@@ -304,7 +379,23 @@ void *dh_start_session()
MyFree(si);
return NULL;
}
+#else
+ EVP_PKEY_CTX *keyGenCtx = NULL;
+ if(!(keyGenCtx = EVP_PKEY_CTX_new_from_pkey(NULL, ircd_prime_ossl3, NULL)) ||
+ EVP_PKEY_keygen_init(keyGenCtx) <= 0 ||
+ EVP_PKEY_generate(keyGenCtx, &si->dh) <= 0 ||
+ 1)
+ {
+ if(keyGenCtx)
+ EVP_PKEY_CTX_free(keyGenCtx);
+ }
+ if(!si->dh)
+ {
+ MyFree(si);
+ return NULL;
+ }
+#endif
return (void *) si;
}
@@ -312,6 +403,7 @@ void dh_end_session(void *session)
{
struct session_info *si = (struct session_info *) session;
+#if OPENSSL_VERSION_NUMBER < 0x30000000L
if(si->dh)
{
DH_free(si->dh);
@@ -324,6 +416,13 @@ void dh_end_session(void *session)
free(si->session_shared);
si->session_shared = NULL;
}
+#else
+ if(si->dh)
+ {
+ EVP_PKEY_free(si->dh);
+ si->dh = NULL;
+ }
+#endif
MyFree(si);
}
@@ -333,6 +432,7 @@ char *dh_get_s_public(char *buf, size_t maxlen, void *session)
struct session_info *si = (struct session_info *) session;
char *tmp;
+#if OPENSSL_VERSION_NUMBER < 0x30000000L
if(!si || !si->dh)
return NULL;
@@ -343,6 +443,16 @@ char *dh_get_s_public(char *buf, size_t maxlen, void *session)
return NULL;
tmp = BN_bn2hex(pub_key);
+#else
+ BIGNUM *pub_key = NULL;
+
+ if(!si || !si->dh)
+ return NULL;
+ if(!EVP_PKEY_get_bn_param(si->dh, OSSL_PKEY_PARAM_PUB_KEY, &pub_key))
+ return NULL;
+ tmp = BN_bn2hex(pub_key);
+ BN_free(pub_key);
+#endif
if(!tmp)
return NULL;

View File

@ -0,0 +1,23 @@
From fa5d445e5e2af735378a1219d2a200ee8aef6561 Mon Sep 17 00:00:00 2001
From: Sadie Powell <sadie@witchery.services>
Date: Sun, 25 Jun 2023 21:50:42 +0100
Subject: [PATCH] Fix Charybdis on Ubuntu 22.04.
---
librb/include/rb_lib.h | 2 ++
1 file changed, 2 insertions(+)
diff --git a/librb/include/rb_lib.h b/librb/include/rb_lib.h
index c02dff68..0dd9c378 100644
--- a/librb/include/rb_lib.h
+++ b/librb/include/rb_lib.h
@@ -258,4 +258,6 @@ pid_t rb_getpid(void);
#include <rb_rawbuf.h>
#include <rb_patricia.h>
+#include <time.h>
+
#endif
--
2.34.1

View File

@ -18,6 +18,7 @@ software:
separate_build_job: true
build_script: |
cd $GITHUB_WORKSPACE/charybdis/
patch -p1 < $GITHUB_WORKSPACE/patches/charybdis_ubuntu22.patch
./autogen.sh
./configure --prefix=$HOME/.local/
make -j 4
@ -106,6 +107,10 @@ software:
cd $GITHUB_WORKSPACE/Bahamut/
patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch
patch src/s_bsd.c < $GITHUB_WORKSPACE/patches/bahamut_mainloop.patch
# <= v2.2.2
patch -p1 < $GITHUB_WORKSPACE/patches/bahamut_ubuntu22.patch || true
echo "#undef THROTTLE_ENABLE" >> include/config.h
libtoolize --force
aclocal
@ -131,7 +136,7 @@ software:
pre_deps:
- uses: actions/setup-go@v2
with:
go-version: '^1.19.0'
go-version: '^1.21.0'
- run: go version
separate_build_job: false
build_script: |
@ -143,7 +148,7 @@ software:
name: InspIRCd
repository: inspircd/inspircd
refs: &inspircd_refs
stable: v3.12.0
stable: v3.15.0
release: null
devel: master
devel_release: insp3
@ -153,9 +158,13 @@ software:
separate_build_job: true
build_script: &inspircd_build_script |
cd $GITHUB_WORKSPACE/inspircd/
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
# Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true
./configure --prefix=$HOME/.local/inspircd --development
make -j 4
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install
irc2:
name: irc2
@ -268,8 +277,8 @@ software:
name: UnrealIRCd 6
repository: unrealircd/unrealircd
refs:
stable: cedd23ae9cdd5985ce16e9869cbdb808479c3fc4 # 6.0.3
release: cedd23ae9cdd5985ce16e9869cbdb808479c3fc4 # 6.0.3
stable: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
release: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
devel: unreal60_dev
devel_release: null
path: unrealircd
@ -328,7 +337,7 @@ software:
separate_build_job: false
path: Dlk-Services
refs:
stable: &dlk_stable "effd18652fc1c847d1959089d9cca9ff9837a8c0"
stable: &dlk_stable "6db51ea03f039c48fd20427c04cec8ff98df7878"
release: *dlk_stable
devel: "main"
devel_release: *dlk_stable
@ -351,7 +360,7 @@ software:
install_steps:
stable:
- name: Install dependencies
run: pip install limnoria==2022.03.17 cryptography pyxmpp2-scram
run: pip install limnoria==2023.5.27 cryptography pyxmpp2-scram
release:
- name: Install dependencies
run: pip install limnoria cryptography pyxmpp2-scram
@ -375,6 +384,23 @@ software:
run: pip install git+https://github.com/sopel-irc/sopel.git
devel_release: null
thelounge:
name: TheLounge
repository: thelounge/thelounge
separate_build_job: false
refs:
stable: "v4.4.0"
release: "v4.4.0"
devel: "master"
devel_release: null
path: thelounge
build_script: |
cd $GITHUB_WORKSPACE/thelounge
yarn install
NODE_ENV=production yarn build
mkdir -p ~/.local/bin/
ln -s $(pwd)/index.js ~/.local/bin/thelounge
tests:
bahamut:
software: [bahamut]
@ -453,3 +479,6 @@ tests:
sopel:
software: [sopel]
thelounge:
software: [thelounge]