70 Commits

Author SHA1 Message Date
a60c5c376b Merge 0816232c1c5159d46ea5013f78f72f6f72024147 into a1324407893b603fe6b55ce7c4ee385938291ae1 2024-07-07 13:16:04 +01:00
a132440789 add various channel mode tests (#276) 2024-07-07 08:33:48 +02:00
aaa2e26b6e Fix file name 2024-06-23 20:37:10 +02:00
052198c61b Add support for Hybrid > 8.2.44 (#283)
The module system changed, modules now need to be loaded explicitly
2024-06-21 21:38:16 +02:00
9f33633cc7 Fix the help filename as of the latest commit. (#282) 2024-06-17 19:15:02 +02:00
465f6637ed enable backtraces in sable (#280) 2024-06-15 08:25:14 +02:00
9856317a64 fix buffering test
The test for erroneous ERR_INPUTTOOLONG was counting codepoints instead
of bytes, consequently underestimating the actual relayed size of the message.
2024-06-11 01:37:23 -04:00
af980ed3b6 Remove use of deprecated config settings on InspIRCd 3+. 2024-06-07 18:35:45 +02:00
15c077d511 Update the GitHub Actions dependencies used by make_workflows.
Fixes various CI warnings.
2024-06-07 17:54:40 +02:00
330300eba1 Update for the new InspIRCd development branch. 2024-06-07 15:58:47 +02:00
f265e28702 update multiline tests (#275) 2024-06-04 07:18:16 +02:00
e3ffff6ad4 Add tests for joining channels with keys (#274) 2024-06-01 17:04:01 +02:00
f4806dcb2b Bump Sable 2024-06-01 15:52:59 +02:00
395482f7b5 Update Limnoria's devel branch name 2024-05-29 20:33:48 +02:00
2dea91e17a Update README (#268)
We barely changed the content since 2015 while irctest changed a lot.
This commit better reflects the current project goals, status, and removes unmaintained
software from the examples.
2024-05-10 15:53:00 +02:00
df626de5ed Enable WHOWAS and USERHOST tests on Sable (#273)
It now implements these commands.
2024-05-04 16:15:54 +02:00
79223d35f1 Enable WHO mask tests on Sable (#272)
* Sable: Hide NickServ/ChanServ when running without services

They interfere with 'WHO *' as they are returned as matches

* Enable WHO mask tests on Sable

* Bump Sable
2024-05-04 13:33:50 +02:00
723991c7ec add test for RPL_NAMREPLY for secret channels (#265)
Ergo and ngIRCd were getting this wrong
2024-05-01 07:53:27 +02:00
1bc8741479 dashboard: Don't use <details> for tests with no docstring 2024-04-20 15:27:51 +02:00
9f8e712776 testNonutf8Realname/testNonutf8Username: Add support for ERROR instead of FAIL/ERR_INVALIDUSERNAME
This is what Sable does, at it fails to decode non-UTF8 data before
it even tries to parse commands.
2024-04-19 15:43:21 +02:00
a1f8fcac49 testNonutf8Username: Actually test a non-UTF8 username 2024-04-19 15:43:21 +02:00
d3c919e0f5 dashboard: Fix for parametrized tests 2024-04-19 15:16:36 +02:00
ce51dddc15 Display method docstrings on the dashboard (#270)
Collapsed with <details> because they can be pretty long and make the table
harder to read.
2024-04-19 15:15:27 +02:00
7f9b4b315f xfail testJoinNamreply on Bahamut and irc2 (#269) 2024-04-17 20:26:18 +02:00
9d43a002c2 Simplify multi-prefix-related tests and add testNoMultiPrefix (#262)
* Simplify RPL_NAMREPLY-on-join tests

* Simplify testMultiPrefix

* Add testNoMultiPrefix
2024-04-16 21:25:35 +02:00
ea66a8f9a4 Make re.match actually check the whole string matches the pattern (#261)
And explicitly allow trailing space in RPL_WHOISCHANNELS
2024-04-16 21:05:25 +02:00
473db1cc5b ngircd: Disable PAM
It breaks irctest when ngircd was compiled with --with-pam
2024-04-16 21:00:24 +02:00
f4a01cfe49 Enable CAP tests for Sable (#267)
It now implements userhost-in-names and multi-prefix, which these tests depend on
2024-04-14 21:07:29 +02:00
e6dfb87759 testMonitorForbidsMasks: Allow ERR_ERRONEUSNICKNAME reply (#266)
This is returned by Sable
2024-04-14 20:01:28 +02:00
2ae612c68f Makefile: Add selectors in preparation for Sable adding message-tags support (#264)
Some tests Sable would fail are currently disabled only because Sable does not
support message-tags; but it probably will in the near future.
2024-04-13 14:41:45 +02:00
d908699674 chathistory: Skip assertions based on MSGREFTYPES (#263)
This will be useful to test Sable, which does not support CHATHISTORY
with msgid= yet
2024-04-13 14:41:13 +02:00
61ae4bcf9e Relink the modules directory as well as the lib directory. (#260) 2024-04-04 17:47:48 +02:00
0c5c91368a Pass --nopid to Anope. (#259) 2024-03-21 21:04:13 +01:00
c0e6ca4dde add a test for WHOIS on nonexistent users (#258)
* add a test for WHOIS on nonexistent users

* skip test in Sable for now
2024-03-19 10:30:44 -04:00
e6d54db9ce Fix chkNS on recent hybrid
Since e66f61f8a0
(which is itself a fix for 79c4eb8d75),
Hybrid sends this numeric right after the MOTD
2024-03-17 10:12:55 +01:00
03b6fbbfc2 Fix support for latest Anope 2.1 (#257)
enc_sha256 cannot be loaded since 6e0f0b8896
2024-03-10 11:20:57 +01:00
ee6c56d84b basic 005 parameter validation test (#255)
* basic 005 parameter validation test

The overall order of the registration burst is covered by
ConnectionRegistrationTestCase.testConnectionRegistration and doesn't need
to be checked here.

* Update irctest/server_tests/isupport.py

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

---------

Co-authored-by: Val Lorentz <progval+github@progval.net>
2024-02-12 23:29:23 -05:00
85b519d93a ci: upgrade actions/setup-go (#254) 2024-02-11 19:48:38 +01:00
56e0565512 Update Go 2024-02-11 19:29:58 +01:00
df2880e379 add an incorrect password test for PLAIN (#253)
* add an incorrect password test for PLAIN

* derace test (hopefully)
2024-02-08 00:45:11 -05:00
61a6f047d2 Add support for '*' in place of server name in RPL_WHOREPLY/RPL_WHOSPCRPL (#252)
Sable users are no longer associated with a server, so it now returns
a blank where their server name used to be:
93ab9afa5c
2024-02-07 19:35:02 +01:00
d75e3fae34 Fix support of Anope 2.1.2 (#251)
It is going to change module names:
7ac1fe5847
2024-01-27 10:32:35 +01:00
0ebfbdf6ab Update the Anope config for the new password length fields. (#250) 2024-01-04 23:18:19 +01:00
0f6a485d7d Fix Anope 2.1 not using the right protocol modules. (#249) 2024-01-04 22:13:16 +01:00
dfd429014a Update Anope. (#248) 2024-01-04 20:59:57 +01:00
d9ad638791 Add regression test for Insp's labeled nick bug (#242)
* Add regression test for Insp's labeled nick bug

* Exclude test from irc2 and ircu2 as they error on CAP REQ
2024-01-04 20:46:50 +01:00
246a259111 Update InspIRCd. (#247) 2024-01-04 20:18:28 +01:00
18d04e8f80 Prevent 433 response for Nonutf8{User/Real}name (#244)
Sending `NICK foo` after connectClient() causes an ERR_NICKNAMEINUSE
response instead of the expected RPL_WELCOME.
2023-12-31 19:47:18 +01:00
6425e707ac fix race condition in #245 (#246) 2023-12-24 07:11:29 +01:00
032d0e32de update ergo unicode tests (#245)
* `casemapping: ascii` is now default
* test that non-ascii nicks are rejected by default
* test that non-ascii nicks are accepted under `casemapping: precis`
2023-12-21 09:52:33 +01:00
62a039498b enhance case change test (#243)
* enhance case change test

* assert that the NICK source is correct
2023-11-10 08:08:33 +01:00
1a48ddb498 Fix flaky test on Sable 2023-10-22 20:08:18 +02:00
17c7ccede9 Add more tests for draft/account-registration (#240) 2023-09-24 17:38:33 +02:00
1548287335 use consistent import style in filelock shim (#241) 2023-09-24 17:05:33 +02:00
4f1a84b5a8 Increase per-test timeout 2023-09-24 15:53:03 +02:00
d88349a403 Sable: Run services tests (#234)
Also add per-test timeout so I could debug why Sable's services test hang
2023-09-24 15:33:36 +02:00
2ee8a0694f Add test for successful connection registration numerics (#233)
And Python version bump so I can use the walrus.
2023-09-24 15:19:59 +02:00
81094a308b Remove Ergo-specific configuration from draft/account-registration (#239) 2023-09-24 13:26:32 +02:00
edf82585af testWhowasMultiple: Avoid random 'Nickname is already in use' on Ergo (#238) 2023-09-24 11:27:26 +02:00
00663f15ec Fix a bunch of synchronization heuristics to work with Sable (#236) 2023-09-24 08:47:22 +02:00
36901c1433 Fix lock on the set of used ports (#235)
pytest-xdist (well, execnet) re-loads modules after forking, so each process
had its own lock, making the lock useless.

Co-authored-by: Shivaram Lingamneni <slingamn@cs.stanford.edu>
2023-09-24 08:47:00 +02:00
558add5229 whox: Add test for individual chars (#227)
It makes it easier to debug missing params
2023-09-22 22:04:27 +02:00
805635c839 Add Sable (#229)
* [WIP] Add support for Sable

* tweak sable controller

* echo_message: Add missing synchronization for Sable

* update sable

* whois: Simplify test

* WHO: Remove test for oper flag from testWhoChan

So it won't fail on Sable, which hides oper status

* WHO: Skip/xfail tests for Sable as needed

* Skip NakWhole when multi-prefix is not supported

* [WIP] Run Sable on CI

* working-directory is not setable on actions

* this isn't ergo

* this really isn't ergo

* minimize rust install and cache cargo deps

* Need to specify packages to install...

* Phony target

* Give up on 'cargo install', it seems to ignore the cache

* try again to cache the target dir

* This isn't Solanum

* Comment out BaseServicesController

* Parallelize Sable tests

* target is relative...

* sigh

* Fix prefix

* Re-add the other software

* chathistory: Test TOPIC is not sent unless event-playable is enabled

* sable: Dynamically generate certificates

This allows using custom server/services names

* sable: Enable services

* sable: Add support for account registration

Sable doesn't support REGISTER via NickServ

* sable: Lower log verbosity

* Fix lint

* Re-add Sable to CI

* Fix/skip tests on Sable

* Kill sable_services' subprocesses

* Bump Sable to include the labeled-response fix

* Bump Sable to the channel-rename downgrade fix
2023-09-21 09:18:23 +02:00
e1ff9fd7fe move no-CTCP channel mode test (#232) 2023-09-20 08:24:26 +02:00
c3aa97c428 Temporary disable daily Dlk tests
They are too flaky and I can't debug them until the PHP 8 warnings are fixed.
2023-09-18 22:32:13 +02:00
3692f2d79d Add various validation tests (#221)
* Add various validation tests

* skip UTF8ONLY tests on servers that don't support it

* Fixes for Ergo

* Fixes for Nefarious and ircu2

* xfail for irc2 and workaround for ngIRCd

* Bump ngIRCd to the ERR_NOTEXTTOSEND fix
2023-09-18 20:31:50 +02:00
04d0c8000f Test TOPIC is echoed on change (#230)
* Test TOPIC is echoed on change

* black
2023-09-16 22:56:13 +02:00
ecc560adeb Make AWAY and away-notify tests stricter (#222)
* Make AWAY and away-notify tests stricter

* Check AWAY is not echoed on JOIN
2023-09-16 13:10:56 +02:00
0816232c1c Fix sync issue 2022-11-16 15:39:33 +01:00
3319920250 Check behavior of PRIVMSG when banned 2022-11-16 14:38:35 +01:00
58 changed files with 2871 additions and 1097 deletions

View File

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

File diff suppressed because it is too large Load Diff

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -35,22 +35,18 @@ INSPIRCD_SELECTORS := \
and not strict \
$(EXTRA_SELECTORS)
# HelpTestCase fails because it returns NOTICEs instead of numerics
IRCU2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
# same justification as ircu2
# lusers "unregistered" tests fail because
NEFARIOUS_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
# same justification as ircu2
SNIRCD_SELECTORS := \
not Ergo \
and not deprecated \
@ -87,6 +83,19 @@ LIMNORIA_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# Tests marked with arbitrary_client_tags or react_tag can't pass because Sable does not support client tags yet
# Tests marked with private_chathistory can't pass because Sable does not implement CHATHISTORY for DMs
SABLE_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not arbitrary_client_tags \
and not react_tag \
and not private_chathistory \
and not list and not lusers and not time and not info \
$(EXTRA_SELECTORS)
SOLANUM_SELECTORS := \
not Ergo \
and not deprecated \
@ -118,9 +127,9 @@ UNREALIRCD_SELECTORS := \
and not private_chathistory \
$(EXTRA_SELECTORS)
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sopel solanum unrealircd
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd
all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sopel solanum unrealircd
all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd
flakes:
find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3
@ -249,6 +258,12 @@ ngircd-atheme:
-m 'services' \
-k "$(NGIRCD_SELECTORS)"
sable:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sable \
-n 20 \
-k '$(SABLE_SELECTORS)'
solanum:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.solanum \

View File

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

View File

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

View File

@ -1,7 +1,8 @@
from __future__ import annotations
import contextlib
import dataclasses
import multiprocessing
import json
import os
from pathlib import Path
import shutil
@ -10,23 +11,13 @@ import subprocess
import tempfile
import textwrap
import time
from typing import (
IO,
Any,
Callable,
Dict,
List,
MutableMapping,
Optional,
Set,
Tuple,
Type,
)
from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type
import irctest
from . import authentication, tls
from .client_mock import ClientMock
from .irc_utils.filelock import FileLock
from .irc_utils.junkdrawer import find_hostname_and_port
from .irc_utils.message_parser import Message
from .runner import NotImplementedByController
@ -47,6 +38,14 @@ class TestCaseControllerConfig:
chathistory: bool = False
"""Whether to enable chathistory features."""
account_registration_before_connect: bool = False
"""Whether draft/account-registration should be allowed before completing
connection registration (NICK + USER + CAP END)"""
account_registration_requires_email: bool = False
"""Whether an email address must be provided when using draft/account-registration.
This does not imply servers must validate it."""
ergo_roleplay: bool = False
"""Whether to enable the Ergo role-play commands."""
@ -68,43 +67,39 @@ class _BaseController:
supports_sts: bool
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."""
_used_ports_path = Path(tempfile.gettempdir()) / "irctest_ports.json"
_port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock")
def __init__(self, test_config: TestCaseControllerConfig):
self.test_config = test_config
self.proc = None
self._used_ports = set()
self._own_ports: Set[Tuple[str, int]] = set()
@contextlib.contextmanager
def _used_ports(self) -> Iterator[Set[Tuple[str, int]]]:
with self._port_lock:
if not self._used_ports_path.exists():
self._used_ports_path.write_text("[]")
used_ports = {
(h, p) for (h, p) in json.loads(self._used_ports_path.read_text())
}
yield used_ports
self._used_ports_path.write_text(json.dumps(list(used_ports)))
def get_hostname_and_port(self) -> Tuple[str, int]:
with self._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
with self._used_ports() as used_ports:
while True:
(hostname, port) = find_hostname_and_port()
if (hostname, port) not in used_ports:
# double-checking in self._used_ports to prevent collisions
# between controllers starting at the same time.
break
# Make this port unavailable to other processes
self._all_used_ports[(hostname, port)] = None
used_ports.add((hostname, port))
self._own_ports.add((hostname, port))
return (hostname, port)
@ -130,10 +125,10 @@ 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
with self._used_ports() as used_ports:
for hostname, port in list(self._own_ports):
used_ports.remove((hostname, port))
self._own_ports.remove((hostname, port))
class DirectoryBasedController(_BaseController):
@ -248,6 +243,12 @@ class BaseServerController(_BaseController):
extban_mute_char: Optional[str] = None
"""Character used for the 'mute' extban"""
nickserv = "NickServ"
sync_sleep_time = 0.0
"""How many seconds to sleep before clients synchronously get messages.
This can be 0 for servers answering all commands in order (all but Sable as of
this writing), as irctest emits a PING, waits for a PONG, and captures all messages
between the two."""
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
@ -296,7 +297,7 @@ class BaseServerController(_BaseController):
time.sleep(0.01)
c.send(b" ") # Triggers BrokenPipeError
except BrokenPipeError:
except (BrokenPipeError, ConnectionResetError):
# ircu2 cuts the connection without a message if registration
# is not complete.
pass
@ -350,13 +351,17 @@ class BaseServicesController(_BaseController):
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkNS")
c.sendLine("USER chk chk chk chk")
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
# Hi Unreal
c.sendLine("PONG :" + msg.params[0])
c.getMessages()
time.sleep(self.server_controller.sync_sleep_time)
got_end_of_motd = False
while not got_end_of_motd:
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
# Hi Unreal
c.sendLine("PONG :" + msg.params[0])
if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD
got_end_of_motd = True
timeout = time.time() + 3
timeout = time.time() + 10
while True:
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :help")
@ -365,11 +370,19 @@ class BaseServicesController(_BaseController):
if msg.command == "401":
# NickServ not available yet
pass
elif msg.command in ("MODE", "221"): # RPL_UMODEIS
pass
elif msg.command == "396": # RPL_VISIBLEHOST
pass
elif msg.command == "NOTICE":
# NickServ is available
assert "nickserv" in (msg.prefix or "").lower(), msg
print("breaking")
break
assert msg.prefix is not None
if "!" not in msg.prefix and "." in msg.prefix:
# Server notice
pass
else:
# NickServ is available
assert "nickserv" in (msg.prefix or "").lower(), msg
break
else:
assert False, f"unexpected reply from NickServ: {msg}"
else:

View File

@ -160,6 +160,7 @@ class _IrcTestCase(Generic[TController]):
def messageDiffers(
self,
msg: Message,
command: Union[str, None, patma.Operator] = None,
params: Optional[List[Union[str, None, patma.Operator]]] = None,
target: Optional[str] = None,
tags: Optional[
@ -186,6 +187,14 @@ class _IrcTestCase(Generic[TController]):
msg=msg,
)
if command is not None and not patma.match_string(msg.command, command):
fail_msg = (
fail_msg or "expected command to match {expects}, got {got}: {msg}"
)
return fail_msg.format(
*extra_format, got=msg.command, expects=command, msg=msg
)
if prefix is not None and not patma.match_string(msg.prefix, prefix):
fail_msg = (
fail_msg or "expected prefix to match {expects}, got {got}: {msg}"
@ -214,7 +223,7 @@ class _IrcTestCase(Generic[TController]):
or "expected nick to be {expects}, got {got} instead: {msg}"
)
return fail_msg.format(
*extra_format, got=got_nick, expects=nick, param=key, msg=msg
*extra_format, got=got_nick, expects=nick, msg=msg
)
return None
@ -585,9 +594,13 @@ class BaseServerTestCase(
del self.clients[name]
def getMessages(self, client: TClientName, **kwargs: Any) -> List[Message]:
if kwargs.get("synchronize", True):
time.sleep(self.controller.sync_sleep_time)
return self.clients[client].getMessages(**kwargs)
def getMessage(self, client: TClientName, **kwargs: Any) -> Message:
if kwargs.get("synchronize", True):
time.sleep(self.controller.sync_sleep_time)
return self.clients[client].getMessage(**kwargs)
def getRegistrationMessage(self, client: TClientName) -> Message:
@ -798,7 +811,7 @@ def xfailIf(
def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]:
@functools.wraps(f)
def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn:
if condition(self):
if condition(self, *args, **kwargs):
try:
return f(self, *args, **kwargs)
except Exception:
@ -815,7 +828,10 @@ def xfailIf(
def xfailIfSoftware(
names: List[str], reason: str
) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]:
return xfailIf(lambda testcase: testcase.controller.software_name in names, reason)
def pred(testcase: _IrcTestCase, *args: Any, **kwargs: Any) -> bool:
return testcase.controller.software_name in names
return xfailIf(pred, reason)
def mark_services(cls: TClass) -> TClass:

View File

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

View File

@ -1,3 +1,4 @@
from pathlib import Path
import shutil
import subprocess
from typing import Optional
@ -51,6 +52,8 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
)
else:
ssl_config = ""
binary_path = shutil.which(self.binary_name)
assert binary_path, f"Could not find '{binary_path}' executable"
with self.open_file("server.conf") as fd:
fd.write(
(self.template_config).format(
@ -60,6 +63,7 @@ class BaseHybridController(BaseServerController, DirectoryBasedController):
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
install_prefix=Path(binary_path).parent.parent,
)
)
assert self.directory

View File

@ -14,6 +14,7 @@ BASE_CONFIG = {
"name": "My.Little.Server",
"listeners": {},
"max-sendq": "16k",
"casemapping": "ascii",
"connection-limits": {
"enabled": True,
"cidr-len-ipv4": 32,
@ -57,6 +58,11 @@ BASE_CONFIG = {
"enabled": True,
"method": "strict",
},
"login-throttling": {
"enabled": True,
"duration": "1m",
"max-attempts": 3,
},
},
"channels": {"registration": {"enabled": True}},
"datastore": {"path": None},
@ -166,6 +172,16 @@ class ErgoController(BaseServerController, DirectoryBasedController):
if enable_roleplay:
config["roleplay"] = {"enabled": True}
if self.test_config.account_registration_before_connect:
config["accounts"]["registration"]["allow-before-connect"] = True # type: ignore
if self.test_config.account_registration_requires_email:
config["accounts"]["registration"]["email-verification"] = { # type: ignore
"enabled": True,
"sender": "test@example.com",
"require-tls": True,
"helo-domain": "example.com",
}
if self.test_config.ergo_config:
self.test_config.ergo_config(config)
@ -211,9 +227,6 @@ class ErgoController(BaseServerController, DirectoryBasedController):
username: str,
password: Optional[str] = None,
) -> None:
# XXX: Move this somewhere else when
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
# part of the specification
if not case.run_services:
# Ergo does not actually need this, but other controllers do, so we
# are checking it here as well for tests that aren't tested with other

View File

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

View File

@ -48,9 +48,7 @@ TEMPLATE_CONFIG = """
sendpass="password"
>
<module name="spanningtree">
<module name="services_account">
<module name="hidechans"> # Anope errors when missing
<module name="svshold"> # Atheme raises a warning when missing
<sasl requiressl="no"
target="services.example.org">
@ -71,14 +69,10 @@ TEMPLATE_CONFIG = """
<module name="ircv3_servertime">
<module name="monitor">
<module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl">
<module name="uhnames"> # For userhost-in-names
# HELP/HELPOP
<module name="alias"> # for the HELP alias
<module name="{help_module_name}">
<include file="examples/{help_module_name}.conf.example">
{version_config}
# Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
@ -90,6 +84,26 @@ TEMPLATE_SSL_CONFIG = """
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
"""
TEMPLATE_V3_CONFIG = """
<module name="namesx"> # For multi-prefix
<module name="services_account">
<module name="svshold"> # Atheme raises a warning when missing
# HELP/HELPOP
<module name="helpop">
<include file="examples/helpop.conf.example">
"""
TEMPLATE_V4_CONFIG = """
<module name="account">
<module name="multiprefix"> # For multi-prefix
<module name="services">
# HELP/HELPOP
<module name="help">
<include file="examples/help.example.conf">
"""
@functools.lru_cache()
def installed_version() -> int:
@ -98,12 +112,14 @@ def installed_version() -> int:
return 3
if output.startswith("InspIRCd-4"):
return 4
else:
assert False, f"unexpected version: {output}"
if output.startswith("InspIRCd-5"):
return 5
assert False, f"unexpected version: {output}"
class InspircdController(BaseServerController, DirectoryBasedController):
software_name = "InspIRCd"
software_version = installed_version()
supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False
extban_mute_char = "m"
@ -140,9 +156,9 @@ class InspircdController(BaseServerController, DirectoryBasedController):
ssl_config = ""
if installed_version() == 3:
help_module_name = "helpop"
elif installed_version() == 4:
help_module_name = "help"
version_config = TEMPLATE_V3_CONFIG
elif installed_version() >= 4:
version_config = TEMPLATE_V4_CONFIG
else:
assert False, f"unexpected version: {installed_version()}"
@ -155,7 +171,7 @@ class InspircdController(BaseServerController, DirectoryBasedController):
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
help_module_name=help_module_name,
version_config=version_config,
)
)
assert self.directory

View File

@ -23,10 +23,14 @@ TEMPLATE_CONFIG = """
[Options]
MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
PAM = no
[Operator]
Name = operuser
Password = operpassword
[Limits]
MaxNickLength = 32 # defaults to 9
"""

View File

@ -0,0 +1,499 @@
import os
from pathlib import Path
import shutil
import signal
import subprocess
import tempfile
import time
from typing import Optional, Type
from irctest.basecontrollers import (
BaseServerController,
BaseServicesController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.cases import BaseServerTestCase
from irctest.exceptions import NoMessageException
from irctest.patma import ANYSTR
GEN_CERTS = """
mkdir -p useless_openssl_data/
cat > openssl.cnf <<EOF
[ ca ]
default_ca = CA_default # The default ca section
[ CA_default ]
new_certs_dir = useless_openssl_data/
database = useless_openssl_data/db
policy = policy_anything
serial = useless_openssl_data/serial
copy_extensions = copy
email_in_dn = no
rand_serial = no
[ policy_anything ]
countryName = optional
stateOrProvinceName = optional
localityName = optional
organizationName = optional
organizationalUnitName = optional
commonName = supplied
emailAddress = optional
[ usr_cert ]
subjectAltName=subject:copy
EOF
rm -f useless_openssl_data/db
touch useless_openssl_data/db
echo 01 > useless_openssl_data/serial
# Generate CA
openssl req -x509 -nodes -newkey rsa:2048 -batch \
-subj "/CN=Test CA" \
-outform PEM -out ca_cert.pem \
-keyout ca_cert.key
for server in $*; do
openssl genrsa -traditional \
-out $server.key \
2048
openssl req -nodes -batch -new \
-addext "subjectAltName = DNS:$server" \
-key $server.key \
-outform PEM -out server_$server.req
openssl ca -config openssl.cnf -days 3650 -md sha512 -batch \
-subj /CN=$server \
-keyfile ca_cert.key -cert ca_cert.pem \
-in server_$server.req \
-out $server.pem
openssl x509 -sha1 -in $server.pem -fingerprint -noout \
| sed "s/.*=//" | sed "s/://g" | tr '[:upper:]' '[:lower:]' > $server.pem.sha1
done
rm -r useless_openssl_data/
"""
_certs_dir = None
def certs_dir() -> Path:
global _certs_dir
if _certs_dir is None:
certs_dir = tempfile.TemporaryDirectory()
(Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS)
subprocess.run(
["bash", "gen_certs.sh", "My.Little.Server", "My.Little.Services"],
cwd=certs_dir.name,
check=True,
)
_certs_dir = certs_dir
return Path(_certs_dir.name)
NETWORK_CONFIG = """
{
"fanout": 1,
"ca_file": "%(certs_dir)s/ca_cert.pem",
"peers": [
{ "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" },
{ "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" }
]
}
"""
NETWORK_CONFIG_CONFIG = """
{
"opers": [
{
"name": "operuser",
// echo -n "operpassword" | openssl passwd -6 -stdin
"hash": "$6$z5yA.OfGliDoi/R2$BgSsguS6bxAsPSCygDisgDw5JZuo5.88eU3Hyc7/4OaNpeKIxWGjOggeHzOl0xLiZg1vfwxXjOTFN14wG5vNI."
}
],
"alias_users": [
%(services_alias_users)s
],
"default_roles": {
"builtin:op": [
"always_send",
"op_self", "op_grant", "voice_self", "voice_grant",
"receive_op", "receive_voice", "receive_opmod",
"topic", "kick", "set_simple_mode", "set_key",
"rename",
"ban_view", "ban_add", "ban_remove_any",
"quiet_view", "quiet_add", "quiet_remove_any",
"exempt_view", "exempt_add", "exempt_remove_any",
"invite_self", "invite_other",
"invex_view", "invex_add", "invex_remove_any"
],
"builtin:voice": [
"always_send",
"voice_self",
"receive_voice",
"ban_view", "quiet_view"
],
"builtin:all": [
"ban_view", "quiet_view"
]
},
"debug_mode": true
}
"""
SERVICES_ALIAS_USERS = """
{
"nick": "ChanServ",
"user": "ChanServ",
"host": "services.",
"realname": "Channel services compatibility layer",
"command_alias": "CS"
},
{
"nick": "NickServ",
"user": "NickServ",
"host": "services.",
"realname": "Account services compatibility layer",
"command_alias": "NS"
}
"""
SERVER_CONFIG = """
{
"server_id": 1,
"server_name": "My.Little.Server",
"management": {
"address": "%(server1_management_hostname)s:%(server1_management_port)s",
"client_ca": "%(certs_dir)s/ca_cert.pem",
"authorised_fingerprints": [
{ "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" },
],
},
"server": {
"listeners": [
{ "address": "%(c2s_hostname)s:%(c2s_port)s" },
],
},
"event_log": {
"event_expiry": 300, // five minutes, for local testing
},
"tls_config": {
"key_file": "%(certs_dir)s/My.Little.Server.key",
"cert_file": "%(certs_dir)s/My.Little.Server.pem",
},
"node_config": {
"listen_addr": "%(server1_hostname)s:%(server1_port)s",
"cert_file": "%(certs_dir)s/My.Little.Server.pem",
"key_file": "%(certs_dir)s/My.Little.Server.key",
},
"log": {
"dir": "log/server1/",
"module-levels": {
"": "debug",
"sable_ircd": "trace",
},
"targets": [
{
"target": "stdout",
"level": "trace",
"modules": [ "sable", "audit", "client_listener" ],
},
],
},
}
"""
SERVICES_CONFIG = """
{
"server_id": 99,
"server_name": "My.Little.Services",
"management": {
"address": "%(services_management_hostname)s:%(services_management_port)s",
"client_ca": "%(certs_dir)s/ca_cert.pem",
"authorised_fingerprints": [
{ "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" }
]
},
"server": {
"database": "test_database.json",
"default_roles": {
"builtin:founder": [
"founder", "access_view", "access_edit", "role_view", "role_edit",
"op_self", "op_grant",
"voice_self", "voice_grant",
"always_send",
"invite_self", "invite_other",
"receive_op", "receive_voice", "receive_opmod",
"topic", "kick", "set_simple_mode", "set_key",
"rename",
"ban_view", "ban_add", "ban_remove_any",
"quiet_view", "quiet_add", "quiet_remove_any",
"exempt_view", "exempt_add", "exempt_remove_any",
"invex_view", "invex_add", "invex_remove_any"
],
"builtin:op": [
"always_send",
"receive_op", "receive_voice", "receive_opmod",
"topic", "kick", "set_simple_mode", "set_key",
"rename",
"ban_view", "ban_add", "ban_remove_any",
"quiet_view", "quiet_add", "quiet_remove_any",
"exempt_view", "exempt_add", "exempt_remove_any",
"invex_view", "invex_add", "invex_remove_any"
],
"builtin:voice": [
"always_send", "voice_self", "receive_voice"
]
},
"password_hash": {
"algorithm": "bcrypt", // Only "bcrypt" is supported for now
"cost": 4, // Exponentially faster than the default 12
},
},
"event_log": {
"event_expiry": 300, // five minutes, for local testing
},
"tls_config": {
"key_file": "%(certs_dir)s/My.Little.Services.key",
"cert_file": "%(certs_dir)s/My.Little.Services.pem"
},
"node_config": {
"listen_addr": "%(services_hostname)s:%(services_port)s",
"cert_file": "%(certs_dir)s/My.Little.Services.pem",
"key_file": "%(certs_dir)s/My.Little.Services.key"
},
"log": {
"dir": "log/services/",
"module-levels": {
"": "debug"
},
"targets": [
{
"target": "stdout",
"level": "debug",
"modules": [ "sable_services" ]
}
]
}
}
"""
class SableController(BaseServerController, DirectoryBasedController):
software_name = "Sable"
supported_sasl_mechanisms = {"PLAIN"}
sync_sleep_time = 0.1
"""Sable processes commands very quickly, but responses for commands changing the
state may be sent after later commands for messages which don't."""
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
faketime: Optional[str],
) -> None:
if password is not None:
raise NotImplementedByController("PASS command")
if ssl:
raise NotImplementedByController("SSL")
if self.test_config.account_registration_before_connect:
raise NotImplementedByController("account-registration with before-connect")
if self.test_config.account_registration_requires_email:
raise NotImplementedByController("account-registration with email-required")
assert self.proc is None
self.port = port
self.create_config()
assert self.directory
(self.directory / "configs").mkdir()
c2s_hostname = hostname
c2s_port = port
del hostname, port
# base controller expects this to check for NickServ presence itself
self.hostname = c2s_hostname
self.port = c2s_port
(server1_hostname, server1_port) = self.get_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port()
# Sable requires inbound connections to match the configured hostname,
# so we can't configure 0.0.0.0
server1_hostname = services_hostname = "127.0.0.1"
(
server1_management_hostname,
server1_management_port,
) = self.get_hostname_and_port()
(
services_management_hostname,
services_management_port,
) = self.get_hostname_and_port()
self.template_vars = dict(
certs_dir=certs_dir(),
c2s_hostname=c2s_hostname,
c2s_port=c2s_port,
server1_hostname=server1_hostname,
server1_port=server1_port,
server1_cert_sha1=(certs_dir() / "My.Little.Server.pem.sha1")
.read_text()
.strip(),
server1_management_hostname=server1_management_hostname,
server1_management_port=server1_management_port,
services_hostname=services_hostname,
services_port=services_port,
services_cert_sha1=(certs_dir() / "My.Little.Services.pem.sha1")
.read_text()
.strip(),
services_management_hostname=services_management_hostname,
services_management_port=services_management_port,
services_alias_users=SERVICES_ALIAS_USERS if run_services else "",
)
with self.open_file("configs/network.conf") as fd:
fd.write(NETWORK_CONFIG % self.template_vars)
with self.open_file("configs/network_config.conf") as fd:
fd.write(NETWORK_CONFIG_CONFIG % self.template_vars)
with self.open_file("configs/server1.conf") as fd:
fd.write(SERVER_CONFIG % self.template_vars)
if faketime and shutil.which("faketime"):
faketime_cmd = ["faketime", "-f", faketime]
self.faketime_enabled = True
else:
faketime_cmd = []
self.proc = subprocess.Popen(
[
*faketime_cmd,
"sable_ircd",
"--foreground",
"--server-conf",
self.directory / "configs/server1.conf",
"--network-conf",
self.directory / "configs/network.conf",
"--bootstrap-network",
self.directory / "configs/network_config.conf",
],
cwd=self.directory,
preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
)
self.pgroup_id = os.getpgid(self.proc.pid)
if run_services:
self.services_controller = SableServicesController(self.test_config, self)
self.services_controller.run(
protocol="sable",
server_hostname=services_hostname,
server_port=services_port,
)
def kill_proc(self) -> None:
os.killpg(self.pgroup_id, signal.SIGKILL)
super().kill_proc()
def registerUser(
self,
case: BaseServerTestCase, # type: ignore
username: str,
password: Optional[str] = None,
) -> None:
# XXX: Move this somewhere else when
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
# part of the specification
if not case.run_services:
raise ValueError(
"Attempted to register a nick, but `run_services` it not True."
)
assert password
client = case.addClient(show_io=True)
case.sendLine(client, "NICK " + username)
case.sendLine(client, "USER r e g :user")
while case.getRegistrationMessage(client).command != "001":
pass
case.getMessages(client)
case.sendLine(
client,
f"REGISTER * * {password}",
)
for _ in range(100):
time.sleep(0.1)
try:
msg = case.getMessage(client)
except NoMessageException:
continue
case.assertMessageMatch(
msg, command="REGISTER", params=["SUCCESS", username, ANYSTR]
)
break
else:
raise NoMessageException()
case.sendLine(client, "QUIT")
case.assertDisconnected(client)
class SableServicesController(BaseServicesController):
server_controller: SableController
software_name = "Sable Services"
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
assert protocol == "sable"
assert self.server_controller.directory is not None
with self.server_controller.open_file("configs/services.conf") as fd:
fd.write(SERVICES_CONFIG % self.server_controller.template_vars)
self.proc = subprocess.Popen(
[
"sable_services",
"--foreground",
"--server-conf",
self.server_controller.directory / "configs/services.conf",
"--network-conf",
self.server_controller.directory / "configs/network.conf",
],
cwd=self.server_controller.directory,
preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
)
self.pgroup_id = os.getpgid(self.proc.pid)
def kill_proc(self) -> None:
os.killpg(self.pgroup_id, signal.SIGKILL)
super().kill_proc()
def get_irctest_controller_class() -> Type[SableController]:
return SableController

View File

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

View File

@ -1,23 +0,0 @@
"""
Handles ambiguities of RFCs.
"""
from typing import List
def normalize_namreply_params(params: List[str]) -> List[str]:
# So… RFC 2812 says:
# "( "=" / "*" / "@" ) <channel>
# :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )
# but spaces seem to be missing (eg. before the colon), so we
# don't know if there should be one before the <channel> and its
# prefix.
# So let's normalize this to “with space”, and strip spaces at the
# end of the nick list.
params = list(params) # copy the list
if len(params) == 3:
assert params[1][0] in "=*@", params
params.insert(1, params[1][0])
params[2] = params[2][1:]
params[3] = params[3].rstrip()
return params

View File

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

View File

@ -13,7 +13,7 @@ def ircv3_timestamp_to_unixtime(timestamp: str) -> float:
def random_name(base: str) -> str:
return base + "-" + secrets.token_hex(8)
return base + "-" + secrets.token_hex(5)
def find_hostname_and_port() -> Tuple[str, int]:

View File

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

View File

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

View File

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

View File

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

View File

@ -11,13 +11,14 @@ from irctest.numerics import (
RPL_USERHOST,
RPL_WHOISUSER,
)
from irctest.patma import StrRe
from irctest.patma import ANYSTR, StrRe
class AwayTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC2812", "Modern")
def testAway(self):
self.connectClient("bar")
self.getMessages(1)
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
@ -29,6 +30,7 @@ class AwayTestCase(cases.BaseServerTestCase):
command=RPL_AWAY,
params=["qux", "bar", "I'm not here right now"],
)
self.getMessages(1)
self.sendLine(1, "AWAY")
replies = self.getMessages(1)
@ -47,12 +49,16 @@ class AwayTestCase(cases.BaseServerTestCase):
"""
self.connectClient("bar")
self.sendLine(1, "AWAY :I'm not here right now")
replies = self.getMessages(1)
self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies])
self.assertMessageMatch(
self.getMessage(1), command=RPL_NOWAWAY, params=["bar", ANYSTR]
)
self.assertEqual(self.getMessages(1), [])
self.sendLine(1, "AWAY")
replies = self.getMessages(1)
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
self.assertMessageMatch(
self.getMessage(1), command=RPL_UNAWAY, params=["bar", ANYSTR]
)
self.assertEqual(self.getMessages(1), [])
@cases.mark_specifications("Modern")
def testAwayPrivmsg(self):

View File

@ -3,6 +3,8 @@
"""
from irctest import cases
from irctest.numerics import RPL_NOWAWAY, RPL_UNAWAY
from irctest.patma import ANYSTR, StrRe
class AwayNotifyTestCase(cases.BaseServerTestCase):
@ -20,13 +22,28 @@ class AwayNotifyTestCase(cases.BaseServerTestCase):
self.getMessages(1)
self.sendLine(2, "AWAY :i'm going away")
self.getMessages(2)
self.assertMessageMatch(
self.getMessage(2), command=RPL_NOWAWAY, params=["bar", ANYSTR]
)
self.assertEqual(self.getMessages(2), [])
awayNotify = self.getMessage(1)
self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm going away"])
self.assertTrue(
awayNotify.prefix.startswith("bar!"),
"Unexpected away-notify source: %s" % (awayNotify.prefix,),
self.assertMessageMatch(
awayNotify,
prefix=StrRe("bar!.*"),
command="AWAY",
params=["i'm going away"],
)
self.sendLine(2, "AWAY")
self.assertMessageMatch(
self.getMessage(2), command=RPL_UNAWAY, params=["bar", ANYSTR]
)
self.assertEqual(self.getMessages(2), [])
awayNotify = self.getMessage(1)
self.assertMessageMatch(
awayNotify, prefix=StrRe("bar!.*"), command="AWAY", params=[]
)
@cases.mark_capabilities("away-notify")
@ -45,7 +62,11 @@ class AwayNotifyTestCase(cases.BaseServerTestCase):
self.getMessages(2)
self.joinChannel(2, "#chan")
self.getMessages(2)
self.assertNotIn(
"AWAY",
[m.command for m in self.getMessages(2)],
"joining user got their own away status when they joined",
)
messages = [msg for msg in self.getMessages(1) if msg.command == "AWAY"]
self.assertEqual(

View File

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

View File

@ -90,7 +90,7 @@ class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd"],
"ngIRCd does not support userhost-in-names",
"does not support userhost-in-names",
)
def testReqTwo(self):
"""Tests requesting two capabilities at once"""
@ -132,7 +132,7 @@ class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd"],
"ngIRCd does not support userhost-in-names",
"does not support userhost-in-names",
)
def testReqOneThenOne(self):
"""Tests requesting two capabilities in different messages"""
@ -184,7 +184,7 @@ class CapTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("IRCv3")
@cases.xfailIfSoftware(
["ngIRCd"],
"ngIRCd does not support userhost-in-names",
"does not support userhost-in-names",
)
def testReqPostRegistration(self):
"""Tests requesting more capabilities after CAP END"""
@ -300,7 +300,8 @@ class CapTestCase(cases.BaseServerTestCase):
""" # noqa
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.assertIn("multi-prefix", self.getCapLs(1))
if "multi-prefix" not in self.getCapLs(1):
raise CapabilityNotSupported("multi-prefix")
self.sendLine(1, "CAP REQ :foo multi-prefix bar")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(

View File

@ -46,7 +46,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
result = []
for msg in inner_msgs:
if (
msg.command == "PRIVMSG"
msg.command in ("PRIVMSG", "TOPIC")
and batch_tag is not None
and msg.tags.get("batch") == batch_tag
):
@ -58,6 +58,16 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(chathistory=True)
def _supports_msgid(self):
return "msgid" in self.server_support.get(
"MSGREFTYPES", "msgid,timestamp"
).split(",")
def _supports_timestamp(self):
return "timestamp" in self.server_support.get(
"MSGREFTYPES", "msgid,timestamp"
).split(",")
@skip_ngircd
def testInvalidTargets(self):
bar, pw = random_name("bar"), random_name("pw")
@ -220,6 +230,47 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@skip_ngircd
def testChathistoryNoEventPlayback(self):
"""Tests that non-messages don't appear in the chat history when event-playback
is not enabled."""
self.connectClient(
"bar",
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
skip_if_cap_nak=True,
)
chname = "#chan" + secrets.token_hex(12)
self.joinChannel(1, chname)
self.getMessages(1)
self.getMessages(1)
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i))
self.getMessages(1)
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname)
(batch_open, *messages, batch_close) = self.getMessages(1)
self.assertMessageMatch(batch_open, command="BATCH")
self.assertMessageMatch(batch_close, command="BATCH")
self.assertEqual([msg for msg in messages if msg.command != "PRIVMSG"], [])
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@skip_ngircd
def testChathistoryEventPlayback(self, subcommand):
@ -244,21 +295,27 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1)
)
time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, chname)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@pytest.mark.private_chathistory
@skip_ngircd
def testChathistoryDMs(self, subcommand):
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
c1 = random_name("foo")
c2 = random_name("bar")
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
@ -313,7 +370,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
c3 = "baz" + secrets.token_hex(12)
c3 = random_name("baz")
self.connectClient(
c3,
capabilities=[
@ -413,178 +470,201 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-1:], result)
self.sendLine(
user,
"CHATHISTORY LATEST %s msgid=%s %d"
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[5:], result)
if self._supports_msgid():
self.sendLine(
user,
"CHATHISTORY LATEST %s msgid=%s %d"
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[5:], result)
self.sendLine(
user,
"CHATHISTORY LATEST %s timestamp=%s %d"
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[5:], result)
if self._supports_timestamp():
self.sendLine(
user,
"CHATHISTORY LATEST %s timestamp=%s %d"
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[5:], result)
def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(
user,
"CHATHISTORY BEFORE %s msgid=%s %d"
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[:6], result)
if self._supports_msgid():
self.sendLine(
user,
"CHATHISTORY BEFORE %s msgid=%s %d"
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[:6], result)
self.sendLine(
user,
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[:6], result)
if self._supports_timestamp():
self.sendLine(
user,
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[:6], result)
self.sendLine(
user,
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, 2),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:6], result)
self.sendLine(
user,
"CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, 2),
)
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):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(
user,
"CHATHISTORY AFTER %s msgid=%s %d"
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:], result)
if self._supports_msgid():
self.sendLine(
user,
"CHATHISTORY AFTER %s msgid=%s %d"
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
)
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, INCLUSIVE_LIMIT),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:], result)
if self._supports_timestamp():
self.sendLine(
user,
"CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
)
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 = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[4:7], result)
self.sendLine(
user,
"CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, 3),
)
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):
INCLUSIVE_LIMIT = len(echo_messages) * 2
# BETWEEN forwards and backwards
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (
chname,
echo_messages[0].msgid,
echo_messages[-1].msgid,
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
if self._supports_msgid():
# BETWEEN forwards and backwards
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (
chname,
echo_messages[0].msgid,
echo_messages[-1].msgid,
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (
chname,
echo_messages[-1].msgid,
echo_messages[0].msgid,
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (
chname,
echo_messages[-1].msgid,
echo_messages[0].msgid,
INCLUSIVE_LIMIT,
),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:-1], result)
# BETWEEN forwards and backwards with a limit, should get
# different results this time
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:4], result)
# BETWEEN forwards and backwards with a limit, should get
# different results this time
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[1:4], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-4:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-4:-1], result)
# same stuff again but with timestamps
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT),
)
result = 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 = 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 = 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 = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-4:-1], result)
if self._supports_timestamp():
# same stuff again but with timestamps
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (
chname,
echo_messages[0].time,
echo_messages[-1].time,
INCLUSIVE_LIMIT,
),
)
result = 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 = 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 = 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 = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-4:-1], result)
def _validate_chathistory_AROUND(self, echo_messages, user, chname):
self.sendLine(
user,
"CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual([echo_messages[7]], result)
if self._supports_msgid():
self.sendLine(
user,
"CHATHISTORY AROUND %s msgid=%s %d"
% (chname, echo_messages[7].msgid, 1),
)
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 = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[6:9], result)
self.sendLine(
user,
"CHATHISTORY AROUND %s msgid=%s %d"
% (chname, echo_messages[7].msgid, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[6:9], result)
self.sendLine(
user,
"CHATHISTORY AROUND %s timestamp=%s %d"
% (chname, echo_messages[7].time, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertIn(echo_messages[7], result)
if self._supports_timestamp():
self.sendLine(
user,
"CHATHISTORY AROUND %s timestamp=%s %d"
% (chname, echo_messages[7].time, 3),
)
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertIn(echo_messages[7], result)
@pytest.mark.arbitrary_client_tags
@skip_ngircd
def testChathistoryTagmsg(self):
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
c1 = random_name("foo")
c2 = random_name("bar")
chname = "#chan" + secrets.token_hex(12)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
@ -683,8 +763,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
@skip_ngircd
def testChathistoryDMClientOnlyTags(self):
# regression test for Ergo #1411
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
c1 = random_name("foo")
c2 = random_name("bar")
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(

View File

@ -7,14 +7,18 @@ and ban exception (`Modern <https://modern.ircdocs.horse/#exception-channel-mode
"""
from irctest import cases, runner
from irctest.numerics import ERR_BANNEDFROMCHAN, RPL_BANLIST, RPL_ENDOFBANLIST
from irctest.numerics import (
ERR_BANNEDFROMCHAN,
ERR_CANNOTSENDTOCHAN,
RPL_BANLIST,
RPL_ENDOFBANLIST,
)
from irctest.patma import ANYSTR, StrRe
class BanModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testBan(self):
"""Basic ban operation"""
def testBanJoin(self):
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
@ -32,6 +36,55 @@ class BanModeTestCase(cases.BaseServerTestCase):
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
@cases.mark_specifications("Modern")
def testBanPrivmsg(self):
"""
TODO: this checks the following quote is false:
"If `<target>` is a channel name and the client is [banned](#ban-channel-mode)
and not covered by a [ban exception](#ban-exception-channel-mode), the
message will not be delivered and the command will silently fail."
-- https://modern.ircdocs.horse/#privmsg-message
to check https://github.com/ircdocs/modern-irc/pull/201
"""
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.connectClient("Bar", name="bar")
self.getMessages("bar")
self.sendLine("bar", "JOIN #chan")
self.getMessages("bar")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.getMessages("chanop")
self.getMessages("bar")
self.sendLine("bar", "PRIVMSG #chan :hello world")
self.assertMessageMatch(
self.getMessage("bar"),
command=ERR_CANNOTSENDTOCHAN,
params=["Bar", "#chan", ANYSTR],
)
self.assertEqual(self.getMessages("bar"), [])
self.assertEqual(self.getMessages("chanop"), [])
self.sendLine("chanop", "MODE #chan -b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.getMessages("chanop")
self.getMessages("bar")
self.sendLine("bar", "PRIVMSG #chan :hello again")
self.assertEqual(self.getMessages("bar"), [])
self.assertMessageMatch(
self.getMessage("chanop"),
command="PRIVMSG",
params=["#chan", "hello again"],
)
@cases.mark_specifications("Modern")
def testBanList(self):
"""`RPL_BANLIST <https://modern.ircdocs.horse/#rplbanlist-367>`_"""

View File

@ -44,8 +44,8 @@ class KeyTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize(
"key",
["passphrase with spaces", "long" * 100, ""],
ids=["spaces", "long", "empty"],
["passphrase with spaces", "long" * 100, "", " "],
ids=["spaces", "long", "empty", "only-space"],
)
@cases.mark_specifications("RFC2812", "Modern")
def testKeyValidation(self, key):
@ -84,6 +84,8 @@ class KeyTestCase(cases.BaseServerTestCase):
"ngIRCd does not validate channel keys: "
"https://github.com/ngircd/ngircd/issues/290"
)
if key == " " and self.controller.software_name == "irc2":
pytest.xfail("irc2 rewrites non-empty keys that contain only spaces")
self.connectClient("bar")
self.joinChannel(1, "#chan")

View File

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

View File

@ -0,0 +1,31 @@
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN
class NoCTCPChannelModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testNoCTCPChannelMode(self):
"""Test Ergo's +C channel mode that blocks CTCPs."""
self.connectClient("bar")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +C")
self.getMessages(1)
self.connectClient("qux")
self.joinChannel(2, "#chan")
self.getMessages(2)
self.sendLine(1, "PRIVMSG #chan :\x01ACTION hi\x01")
self.getMessages(1)
ms = self.getMessages(2)
self.assertEqual(len(ms), 1)
self.assertMessageMatch(
ms[0], command="PRIVMSG", params=["#chan", "\x01ACTION hi\x01"]
)
self.sendLine(1, "PRIVMSG #chan :\x01PING 1473523796 918320\x01")
ms = self.getMessages(1)
self.assertEqual(len(ms), 1)
self.assertMessageMatch(ms[0], command=ERR_CANNOTSENDTOCHAN)
ms = self.getMessages(2)
self.assertEqual(ms, [])

View File

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

View File

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

View File

@ -5,10 +5,12 @@ Tests section 4.1 of RFC 1459.
TODO: cross-reference Modern and RFC 2812 too
"""
import time
from irctest import cases
from irctest.client_mock import ConnectionClosed
from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH
from irctest.patma import ANYSTR, StrRe
from irctest.patma import ANYLIST, ANYSTR, OptStrRe, StrRe
class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
@ -83,6 +85,92 @@ class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
def testConnectionRegistration(self):
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER foo * * :foo")
for numeric in ("001", "002", "003"):
self.assertMessageMatch(
self.getRegistrationMessage(1),
command=numeric,
params=["foo", ANYSTR],
)
self.assertMessageMatch(
self.getRegistrationMessage(1),
command="004", # RPL_MYINFO
params=[
"foo",
"My.Little.Server",
ANYSTR, # version
StrRe("[a-zA-Z]+"), # user modes
StrRe("[a-zA-Z]+"), # channel modes
OptStrRe("[a-zA-Z]+"), # channel modes with parameter
],
)
# ISUPPORT
m = self.getRegistrationMessage(1)
while True:
self.assertMessageMatch(
m,
command="005",
params=["foo", *ANYLIST],
)
m = self.getRegistrationMessage(1)
if m.command != "005":
break
if m.command in ("042", "396"): # RPL_YOURID / RPL_VISIBLEHOST, non-standard
m = self.getRegistrationMessage(1)
# LUSERS
while m.command in ("250", "251", "252", "253", "254", "255", "265", "266"):
m = self.getRegistrationMessage(1)
if m.command == "375": # RPL_MOTDSTART
self.assertMessageMatch(
m,
command="375",
params=["foo", ANYSTR],
)
while (m := self.getRegistrationMessage(1)).command == "372":
self.assertMessageMatch(
m,
command="372", # RPL_MOTD
params=["foo", ANYSTR],
)
self.assertMessageMatch(
m,
command="376", # RPL_ENDOFMOTD
params=["foo", ANYSTR],
)
else:
self.assertMessageMatch(
m,
command="422", # ERR_NOMOTD
params=["foo", ANYSTR],
)
# User mode
if m.command == "MODE":
self.assertMessageMatch(
m,
command="MODE",
params=["foo", ANYSTR, *ANYLIST],
)
m = self.getRegistrationMessage(1)
elif m.command == "221": # RPL_UMODEIS
self.assertMessageMatch(
m,
command="221",
params=["foo", ANYSTR, *ANYLIST],
)
m = self.getRegistrationMessage(1)
else:
print("Warning: missing MODE")
@cases.mark_specifications("RFC1459")
def testQuitDisconnects(self):
"""“The server must close the connection to a client which sends a
@ -133,7 +221,7 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
self.assertNotEqual(
m.command,
"001",
"Received 001 after registering with the nick of a " "registered user.",
"Received 001 after registering with the nick of a registered user.",
)
def testEarlyNickCollision(self):
@ -206,3 +294,58 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
command=ERR_NEEDMOREPARAMS,
params=[StrRe(r"(\*|foo)"), "USER", ANYSTR],
)
def testNonutf8Realname(self):
self.addClient()
self.sendLine(1, "NICK foo")
line = b"USER username * * :i\xe8rc\xe9\r\n"
print("1 -> S (repr): " + repr(line))
self.clients[1].conn.sendall(line)
for _ in range(10):
time.sleep(1)
d = self.clients[1].conn.recv(10000)
self.assertTrue(d, "Server closed connection")
print("S -> 1 (repr): " + repr(d))
if b" 001 " in d:
break
if b"ERROR " in d or b" FAIL " in d:
# Rejected; nothing more to test.
return
for line in d.split(b"\r\n"):
if line.startswith(b"PING "):
line = line.replace(b"PING", b"PONG") + b"\r\n"
print("1 -> S (repr): " + repr(line))
self.clients[1].conn.sendall(line)
else:
self.assertTrue(False, "stuck waiting")
self.sendLine(1, "WHOIS foo")
time.sleep(3) # for ngIRCd
d = self.clients[1].conn.recv(10000)
print("S -> 1 (repr): " + repr(d))
self.assertIn(b"username", d)
def testNonutf8Username(self):
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER 😊😊😊😊😊😊😊😊😊😊 * * :realname")
for _ in range(10):
time.sleep(1)
d = self.clients[1].conn.recv(10000)
self.assertTrue(d, "Server closed connection")
print("S -> 1 (repr): " + repr(d))
if b" 001 " in d:
break
if b" 468" in d or b"ERROR " in d:
# Rejected; nothing more to test.
return
for line in d.split(b"\r\n"):
if line.startswith(b"PING "):
line = line.replace(b"PING", b"PONG") + b"\r\n"
print("1 -> S (repr): " + repr(line))
self.clients[1].conn.sendall(line)
else:
self.assertTrue(False, "stuck waiting")
self.sendLine(1, "WHOIS foo")
d = self.clients[1].conn.recv(10000)
print("S -> 1 (repr): " + repr(d))
self.assertIn(b"realname", d)

View File

@ -32,6 +32,9 @@ class EchoMessageTestCase(cases.BaseServerTestCase):
self.sendLine(1, "JOIN #chan")
# Synchronize
self.getMessages(1)
if not solo:
self.connectClient("qux", capabilities=capabilities)
self.sendLine(2, "JOIN #chan")

View File

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

View File

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

View File

@ -13,6 +13,7 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
self.getMessages(1) # synchronize
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
self.getMessages(2) # synchronize
@ -53,6 +54,28 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
# ERR_NOSUCHNICK: 401 <sender> <recipient> :No such nick
self.assertMessageMatch(msg, command="401", params=["foo", "bar", ANYSTR])
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["irc2"],
"replies with ERR_NEEDMOREPARAMS instead of ERR_NOTEXTTOSEND",
)
def testEmptyPrivmsg(self):
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
self.getMessages(1) # synchronize
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
self.getMessages(2) # synchronize
self.getMessages(1) # synchronize
self.sendLine(1, "PRIVMSG #chan :")
self.assertMessageMatch(
self.getMessage(1),
command="412", # ERR_NOTEXTTOSEND
params=["foo", ANYSTR],
)
self.assertEqual(self.getMessages(2), [])
class NoticeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ TODO: cross-reference RFC 1459 and Modern
import time
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN
from irctest.patma import StrRe
@ -40,31 +39,3 @@ class ChannelQuitTestCase(cases.BaseServerTestCase):
m = self.getMessage(1)
self.assertMessageMatch(m, command="QUIT", params=[StrRe(".*qux out.*")])
self.assertTrue(m.prefix.startswith("qux")) # nickmask of quitter
class NoCTCPTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testQuit(self):
self.connectClient("bar")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +C")
self.getMessages(1)
self.connectClient("qux")
self.joinChannel(2, "#chan")
self.getMessages(2)
self.sendLine(1, "PRIVMSG #chan :\x01ACTION hi\x01")
self.getMessages(1)
ms = self.getMessages(2)
self.assertEqual(len(ms), 1)
self.assertMessageMatch(
ms[0], command="PRIVMSG", params=["#chan", "\x01ACTION hi\x01"]
)
self.sendLine(1, "PRIVMSG #chan :\x01PING 1473523796 918320\x01")
ms = self.getMessages(1)
self.assertEqual(len(ms), 1)
self.assertMessageMatch(ms[0], command=ERR_CANNOTSENDTOCHAN)
ms = self.getMessages(2)
self.assertEqual(ms, [])

View File

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

View File

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

View File

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

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

View File

@ -60,7 +60,7 @@ class BaseWhoTestCase:
"*", # no chan
StrRe("~?" + self.username),
StrRe(host_re),
"My.Little.Server",
StrRe(r"(My.Little.Server|\*)"),
"coolNick",
flags,
StrRe(realname_regexp(self.realname)),
@ -76,7 +76,7 @@ class BaseWhoTestCase:
"#chan",
StrRe("~?" + self.username),
StrRe(host_re),
"My.Little.Server",
StrRe(r"(My.Little.Server|\*)"),
"coolNick",
flags + "@",
StrRe(realname_regexp(self.realname)),
@ -87,7 +87,7 @@ class BaseWhoTestCase:
class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
def testWhoStar(self):
if self.controller.software_name == "Bahamut":
if self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self._init()
@ -118,7 +118,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
)
@cases.mark_specifications("Modern")
def testWhoNick(self, mask):
if "*" in mask and self.controller.software_name == "Bahamut":
if "*" in mask and self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self._init()
@ -148,7 +148,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
ids=["username", "realname-mask", "hostname"],
)
def testWhoUsernameRealName(self, mask):
if "*" in mask and self.controller.software_name == "Bahamut":
if "*" in mask and self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self._init()
@ -201,7 +201,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
)
@cases.mark_specifications("Modern")
def testWhoNickAway(self, mask):
if "*" in mask and self.controller.software_name == "Bahamut":
if "*" in mask and self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self._init()
@ -228,9 +228,14 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
@pytest.mark.parametrize(
"mask", ["coolNick", "coolnick", "coolni*"], ids=["exact", "casefolded", "mask"]
)
@cases.xfailIfSoftware(
["Sable"],
"Sable does not advertise oper status in WHO: "
"https://github.com/Libera-Chat/sable/pull/77",
)
@cases.mark_specifications("Modern")
def testWhoNickOper(self, mask):
if "*" in mask and self.controller.software_name == "Bahamut":
if "*" in mask and self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self._init()
@ -262,9 +267,14 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
@pytest.mark.parametrize(
"mask", ["coolNick", "coolnick", "coolni*"], ids=["exact", "casefolded", "mask"]
)
@cases.xfailIfSoftware(
["Sable"],
"Sable does not advertise oper status in WHO: "
"https://github.com/Libera-Chat/sable/pull/77",
)
@cases.mark_specifications("Modern")
def testWhoNickAwayAndOper(self, mask):
if "*" in mask and self.controller.software_name == "Bahamut":
if "*" in mask and self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self._init()
@ -298,18 +308,11 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
@pytest.mark.parametrize("mask", ["#chan", "#CHAN"], ids=["exact", "casefolded"])
@cases.mark_specifications("Modern")
def testWhoChan(self, mask):
if "*" in mask and self.controller.software_name == "Bahamut":
if "*" in mask and self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self._init()
self.sendLine(1, "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages(1)],
fail_msg="OPER failed",
)
self.sendLine(1, "AWAY :be right back")
self.getMessages(1)
self.getMessages(2)
@ -333,9 +336,9 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
"#chan",
StrRe("~?" + self.username),
StrRe(host_re),
"My.Little.Server",
StrRe(r"(My.Little.Server|\*)"),
"coolNick",
"G*@",
"G@",
StrRe(realname_regexp(self.realname)),
],
)
@ -348,7 +351,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
"#chan",
ANYSTR,
ANYSTR,
"My.Little.Server",
StrRe(r"(My.Little.Server|\*)"),
"otherNick",
"H",
StrRe("[0-9]+ .*"),
@ -395,7 +398,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
chan,
ANYSTR,
ANYSTR,
"My.Little.Server",
StrRe(r"(My.Little.Server|\*)"),
"coolNick",
ANYSTR,
ANYSTR,
@ -410,7 +413,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
chan,
ANYSTR,
ANYSTR,
"My.Little.Server",
StrRe(r"(My.Little.Server|\*)"),
"otherNick",
ANYSTR,
ANYSTR,
@ -476,7 +479,7 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
StrRe("~?myusernam"),
ANYSTR,
ANYSTR,
"My.Little.Server",
StrRe(r"(My.Little.Server|\*)"),
"coolNick",
StrRe("H@?"),
ANYSTR, # hopcount
@ -493,6 +496,46 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
params=["otherNick", InsensitiveStr("coolNick"), ANYSTR],
)
@pytest.mark.parametrize("char", "cuihsnfdlaor")
@cases.xfailIf(
lambda self, char: bool(
char == "l" and self.controller.software_name == "ircu2"
),
"https://github.com/UndernetIRC/ircu2/commit/17c539103abbd0055b2297e17854cd0756c85d62",
)
@cases.xfailIf(
lambda self, char: bool(
char == "l" and self.controller.software_name == "Nefarious"
),
"https://github.com/evilnet/nefarious2/pull/73",
)
def testWhoxOneChar(self, char):
self._init()
if "WHOX" not in self.server_support:
raise runner.IsupportTokenNotSupported("WHOX")
self.sendLine(2, f"WHO coolNick %{char}")
messages = self.getMessages(2)
self.assertEqual(len(messages), 2, "Unexpected number of messages")
(reply, end) = messages
self.assertMessageMatch(
reply,
command=RPL_WHOSPCRPL,
params=[
"otherNick",
StrRe(".+"),
],
)
self.assertMessageMatch(
end,
command=RPL_ENDOFWHO,
params=["otherNick", InsensitiveStr("coolNick"), ANYSTR],
)
def testWhoxToken(self):
"""https://github.com/ircv3/ircv3-specifications/pull/482"""
self._init()
@ -589,7 +632,7 @@ class WhoServicesTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
class WhoInvisibleTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
def testWhoInvisible(self):
if self.controller.software_name == "Bahamut":
if self.controller.software_name in ("Bahamut",):
raise runner.OptionalExtensionNotSupported("WHO mask")
self.connectClient("evan", name="evan")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,5 @@
pytest
# The following dependencies are actually optional:
ecdsa
pytest
filelock

View File

@ -134,9 +134,9 @@ software:
path: ergo
prefix: ~/go
pre_deps:
- uses: actions/setup-go@v2
- uses: actions/setup-go@v3
with:
go-version: '^1.21.0'
go-version: '^1.22.0'
- run: go version
separate_build_job: false
build_script: |
@ -148,7 +148,7 @@ software:
name: InspIRCd
repository: inspircd/inspircd
refs: &inspircd_refs
stable: v3.15.0
stable: v3.17.1
release: null
devel: master
devel_release: insp3
@ -158,12 +158,7 @@ software:
separate_build_job: true
build_script: &inspircd_build_script |
cd $GITHUB_WORKSPACE/inspircd/
# Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true
./configure --prefix=$HOME/.local/inspircd --development
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install
irc2:
@ -235,7 +230,7 @@ software:
name: ngircd
repository: ngircd/ngircd
refs:
stable: rel-26.1
stable: 3e3f6cbeceefd9357b53b27c2386bb39306ab353 # three years ahead of rel-26.1
release: null
devel: master
devel_release: null
@ -250,6 +245,34 @@ software:
make -j 4
make install
sable:
name: Sable
repository: Libera-Chat/sable
refs:
stable: e9701e5e8d0c4f278ddd61ce7285f4918ecf99e9
release: null
devel: master
devel_release: null
path: sable
prefix: "$GITHUB_WORKSPACE/sable/target/debug"
pre_deps:
- name: Install rust toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: nightly
profile: minimal
override: true
- name: Enable Cargo cache
uses: Swatinem/rust-cache@v2
with:
workspaces: "sable -> target"
cache-on-failure: true
- run: rustc --version
separate_build_job: false
build_script: |
cd $GITHUB_WORKSPACE/sable/
cargo build
snircd:
name: snircd
repository: quakenet/snircd
@ -320,16 +343,16 @@ software:
separate_build_job: true
path: anope
refs:
stable: "2.0.9"
release: "2.0.9"
devel: "2.0.9"
devel_release: "2.0.9"
stable: "2.0.14"
release: "2.1.1"
devel: "2.1"
devel_release: "2.0"
build_script: |
cd $GITHUB_WORKSPACE/anope/
cp $GITHUB_WORKSPACE/data/anope/* .
CFLAGS=-O0 ./Config -quick
make -C build -j 4
make -C build install
sudo apt-get install ninja-build --no-install-recommends
mkdir build && cd build
cmake -DCMAKE_INSTALL_PREFIX=$HOME/.local/ -DPROGRAM_NAME=anope -DUSE_PCH=ON -GNinja ..
ninja install
dlk:
name: Dlk
@ -337,8 +360,8 @@ software:
separate_build_job: false
path: Dlk-Services
refs:
stable: &dlk_stable "6db51ea03f039c48fd20427c04cec8ff98df7878"
release: *dlk_stable
stable: null # disabled because flaky, and hard to debug with all the PHP 8 warnings
release: &dlk_stable "6db51ea03f039c48fd20427c04cec8ff98df7878"
devel: "main"
devel_release: *dlk_stable
build_script: |
@ -366,7 +389,7 @@ software:
run: pip install limnoria cryptography pyxmpp2-scram
devel:
- name: Install dependencies
run: pip install git+https://github.com/ProgVal/Limnoria.git@testing cryptography pyxmpp2-scram
run: pip install git+https://github.com/progval/Limnoria.git@master cryptography pyxmpp2-scram
devel_release: null
sopel:
@ -454,6 +477,9 @@ tests:
nefarious:
software: [nefarious]
sable:
software: [sable]
# doesn't build because it can't find liblex for some reason
#snircd:
# software: [snircd]