123 Commits

Author SHA1 Message Date
e9e37f5438 basic test case for KILL (#302) 2025-03-27 05:19:02 -04:00
2b6b666426 Fix irctest for the recent Anope SASL changes. 2025-03-04 07:37:50 +01:00
d218d2f98d Document the versions enc_sha2 and enc_sha256 are used on. 2025-03-04 07:37:50 +01:00
52178a99e5 Install libjansson-dev at build time instead of run time 2025-02-19 19:40:36 +01:00
7f2a631a1a Install libjansson-dev in order to build latest ircd-hybrid 2025-02-18 19:49:02 +01:00
cd9f801b92 Update actions/cache 2025-02-18 19:48:43 +01:00
ccd647ea61 sable: Actually check services are up in tests that rely on them
This should fix some flakiness.
2025-02-14 16:53:03 +01:00
9de64159ba upgrade ergo to go 1.24 (#298) 2025-02-13 08:01:15 +01:00
638f959c95 Fix irctest when using long passwords not supported by bcrypt. (#297) 2025-01-28 19:52:44 +01:00
06e08b52be Make LINKS deterministic for Sable (#296) 2024-12-30 21:07:33 +01:00
54a1ab95ce Relax testLinksWithServices for Sable 2024-12-28 22:54:57 +01:00
3e6d97ae42 Update Sable and make LINKS test support it 2024-12-28 20:30:37 +01:00
00c130d66c Use consistent name for services server 2024-12-28 20:30:37 +01:00
2680502dfe Allow any extban format on InspIRCd. (#294)
This should stop the mute extban test failing when extbanformat is set to name (which will soon be the default in git master).
2024-11-03 10:24:05 +01:00
d090f5455e Update Sable and enable more test (#293) 2024-10-28 18:15:10 +01:00
c31aaf4d61 Bump Bahamut (#291)
The patch does not apply to the latest master, so we need to update it
2024-10-10 20:59:59 +02:00
7b0ee7589f sable: Add object_expiry setting to sable config, required since 015bcc4 2024-10-08 22:54:00 +02:00
d202e440bb upgrade ergo's Go version to 1.23 (#289)
https://github.com/ergochat/ergo/pull/2187
2024-08-16 08:30:52 +02:00
03ad671951 testLineTooLong: Ensure server processed incoming message before reading from the other client (#288) 2024-08-15 20:34:16 +02:00
e3485b92b4 Bump UnrealIRCd and ngIRCd (#285) 2024-07-27 18:14:57 +02:00
75d9040d37 Allow enabling debug output via environment variables. (#284) 2024-07-24 20:35:37 +02:00
a132440789 add various channel mode tests (#276) 2024-07-07 08:33:48 +02:00
aaa2e26b6e Fix file name 2024-06-23 20:37:10 +02:00
052198c61b Add support for Hybrid > 8.2.44 (#283)
The module system changed, modules now need to be loaded explicitly
2024-06-21 21:38:16 +02:00
9f33633cc7 Fix the help filename as of the latest commit. (#282) 2024-06-17 19:15:02 +02:00
465f6637ed enable backtraces in sable (#280) 2024-06-15 08:25:14 +02:00
9856317a64 fix buffering test
The test for erroneous ERR_INPUTTOOLONG was counting codepoints instead
of bytes, consequently underestimating the actual relayed size of the message.
2024-06-11 01:37:23 -04:00
af980ed3b6 Remove use of deprecated config settings on InspIRCd 3+. 2024-06-07 18:35:45 +02:00
15c077d511 Update the GitHub Actions dependencies used by make_workflows.
Fixes various CI warnings.
2024-06-07 17:54:40 +02:00
330300eba1 Update for the new InspIRCd development branch. 2024-06-07 15:58:47 +02:00
f265e28702 update multiline tests (#275) 2024-06-04 07:18:16 +02:00
e3ffff6ad4 Add tests for joining channels with keys (#274) 2024-06-01 17:04:01 +02:00
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
c58167b42d Fix deprecation warning 2023-09-02 16:24:57 +02:00
34c78e5d2f testCapRemovalByClient: Support multiple CAP LS responses (#220) 2023-09-02 15:42:18 +02:00
1c6a7188d6 Add more tests for CAP + allow trailing spaces (#216) 2023-09-02 15:08:29 +02:00
50d3a8e6da echo_message: Simplify code (#219) 2023-09-02 13:43:35 +02:00
fe24e4b8b8 multi_prefix: Skip test on IRCds that don't support it (#218) 2023-09-02 13:43:15 +02:00
360a853bca Skip testLabeledPrivmsgResponsesToMultipleClients if PRIVMSG doesn't support multiple targets (#217) 2023-09-02 13:43:01 +02:00
653d818421 testInviteAlreadyInChannel: Fix synchronization 2023-08-29 20:28:11 +02:00
10e07aa800 Test that WHO with non-existing nick returns RPL_ENDOFWHO (#215) 2023-08-17 20:15:18 +02:00
b28820e562 Bump Go again 2023-08-16 20:12:54 +02:00
cb147f46eb Bump Python to 3.11 on release and devel_release workflows
Sopel dropped support for Python 3.7
2023-08-13 20:09:39 +02:00
a950c724bb Bump Python to 3.11 (#214)
Sopel dropped support for Python 3.7
2023-08-11 20:24:26 +02:00
7255d65514 Test that WHO #chan always returns that channel (#213)
* Test that WHO #chan always returns that channel

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

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

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

* Add a patch to fix Bahamut on Ubuntu 22.04.

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

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

* fix race condition

* fix synchronization issue

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

* Update irctest/server_tests/setname.py

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

---------

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

* Update irctest/server_tests/chmodes/nooutside.py

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

* Update irctest/server_tests/chmodes/nooutside.py

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

* consistently rename to "no external messages"

---------

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

* Remove xfail in WHOWAS as linked PRs have been merged
2023-04-17 18:45:50 +02:00
85 changed files with 4831 additions and 1565 deletions

View File

@ -13,13 +13,13 @@ 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
uses: actions/cache@v4
with:
path: |
~/.cache

File diff suppressed because it is too large Load Diff

View File

@ -3,12 +3,12 @@
jobs:
build-anope:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- name: Cache dependencies
uses: actions/cache@v2
uses: actions/cache@v4
with:
key: 3-${{ runner.os }}-anope-devel_release
path: '~/.cache
@ -16,44 +16,44 @@ jobs:
${ github.workspace }/anope
'
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.7
python-version: 3.11
- name: Checkout Anope
uses: actions/checkout@v2
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@v2
uses: actions/upload-artifact@v4
with:
name: installed-anope
path: ~/artefacts-*.tar.gz
retention-days: 1
build-inspircd:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.7
python-version: 3.11
- name: Checkout InspIRCd
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
path: inspircd
ref: insp3
@ -61,14 +61,13 @@ jobs:
- name: Build InspIRCd
run: |
cd $GITHUB_WORKSPACE/inspircd/
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
./configure --prefix=$HOME/.local/inspircd --development
make -j 4
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: installed-inspircd
path: ~/artefacts-*.tar.gz
@ -80,11 +79,11 @@ jobs:
- test-inspircd
- test-inspircd-anope
- test-inspircd-atheme
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Download Artifacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Install dashboard dependencies
@ -107,15 +106,15 @@ jobs:
test-inspircd:
needs:
- build-inspircd
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: installed-inspircd
path: '~'
@ -126,14 +125,16 @@ jobs:
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH
pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
make inspircd
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: pytest-results_inspircd_devel_release
path: pytest.xml
@ -141,20 +142,20 @@ jobs:
needs:
- build-inspircd
- build-anope
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: installed-inspircd
path: '~'
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: installed-anope
path: '~'
@ -165,29 +166,31 @@ jobs:
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH make
pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make
inspircd-anope
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
uses: actions/upload-artifact@v4
with:
name: pytest-results_inspircd-anope_devel_release
path: pytest.xml
test-inspircd-atheme:
needs:
- build-inspircd
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
- uses: actions/checkout@v4
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
python-version: 3.7
python-version: 3.11
- name: Download build artefacts
uses: actions/download-artifact@v2
uses: actions/download-artifact@v4
with:
name: installed-inspircd
path: '~'
@ -198,14 +201,16 @@ jobs:
- name: Install irctest dependencies
run: |-
python -m pip install --upgrade pip
pip install pytest pytest-xdist -r requirements.txt
- name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH
pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
make inspircd-atheme
timeout-minutes: 30
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
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,16 @@ 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
SABLE_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not arbitrary_client_tags \
and not react_tag \
and not list and not lusers and not time and not info \
$(EXTRA_SELECTORS)
SOLANUM_SELECTORS := \
not Ergo \
and not deprecated \
@ -98,6 +104,13 @@ SOPEL_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# TheLounge can actually pass all the test so there is none to exclude.
# `(foo or not foo)` serves as a `true` value so it doesn't break when
# $(EXTRA_SELECTORS) is non-empty
THELOUNGE_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# Tests marked with arbitrary_client_tags can't pass because Unreal whitelists which tags it relays
# Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149
# Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs
@ -111,9 +124,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
@ -242,6 +255,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 \
@ -253,6 +272,11 @@ sopel:
--controller=irctest.controllers.sopel \
-k '$(SOPEL_SELECTORS)'
thelounge:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.thelounge \
-k '$(THELOUNGE_SELECTORS)'
unrealircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \

View File

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

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

View File

@ -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

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

View File

@ -1,7 +1,8 @@
from __future__ import annotations
import contextlib
import dataclasses
import multiprocessing
import json
import os
from pathlib import Path
import shutil
@ -15,18 +16,21 @@ from typing import (
Any,
Callable,
Dict,
Iterator,
List,
MutableMapping,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
)
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 +51,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 +80,40 @@ 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.debug_mode = os.getenv("IRCTEST_DEBUG_LOGS", "0").lower() in ("true", "1")
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)
@ -112,7 +121,7 @@ class _BaseController:
assert self.proc
self.proc.poll()
if self.proc.returncode is not None:
raise ProcessStopped()
raise ProcessStopped(f"process returned {self.proc.returncode}")
def kill_proc(self) -> None:
"""Terminates the controlled process, waits for it to exit, and
@ -130,10 +139,16 @@ 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))
def execute(
self, command: Sequence[Union[str, Path]], **kwargs: Any
) -> subprocess.Popen:
output_to = None if self.debug_mode else subprocess.DEVNULL
return subprocess.Popen(command, stderr=output_to, stdout=output_to, **kwargs)
class DirectoryBasedController(_BaseController):
@ -248,6 +263,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 +317,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 +371,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 +390,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

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

View File

@ -1,13 +1,14 @@
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
TEMPLATE_CONFIG = """
serverinfo {{
name = "services.example.org"
name = "My.Little.Services"
description = "Anope IRC Services"
numeric = "00A"
pid = "services.pid"
@ -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,33 @@ options {{
warningtimeout = 4h
}}
module {{ name = "m_sasl" }}
module {{ name = "enc_sha256" }}
module {{ name = "ns_sasl" }} # since 2.1.13
module {{ name = "sasl" }} # 2.1.2 to 2.1.12
module {{ name = "m_sasl" }} # 2.0 to 2.1.1
module {{ name = "enc_sha2" }} # 2.1
module {{ name = "enc_sha256" }} # 2.0
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,6 +107,21 @@ 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(
@ -100,24 +134,25 @@ class AnopeController(BaseServicesController, DirectoryBasedController):
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(
extra_args = []
if self.debug_mode:
extra_args.append("--debug")
self.proc = self.execute(
[
"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
*extra_args,
],
cwd=self.directory,
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
)

View File

@ -1,4 +1,3 @@
import subprocess
from typing import Optional, Type
import irctest
@ -25,7 +24,7 @@ loadmodule "modules/saslserv/plain";
#loadmodule "modules/saslserv/scram";
serverinfo {{
name = "services.example.org";
name = "My.Little.Services";
desc = "Atheme IRC Services";
numeric = "00A";
netname = "testnet";
@ -75,7 +74,7 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
)
assert self.directory
self.proc = subprocess.Popen(
self.proc = self.execute(
[
"atheme-services",
"-n", # don't fork
@ -88,8 +87,6 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
"-D",
self.directory,
],
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
)
def registerUser(

View File

@ -1,6 +1,5 @@
from pathlib import Path
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -15,7 +14,7 @@ options {{
network_name unconfigured;
allow_split_ops; # Give ops in empty channels
services_name services.example.org;
services_name My.Little.Services;
// if you need to link more than 1 server, uncomment the following line
servtype hub;
@ -45,7 +44,7 @@ class {{
/* for services */
super {{
"services.example.org";
"My.Little.Services";
}};
@ -58,7 +57,7 @@ class {{
/* our services */
connect {{
name services.example.org;
name My.Little.Services;
host *@127.0.0.1; # unfortunately, masks aren't allowed here
apasswd password;
cpasswd password;
@ -92,7 +91,7 @@ class BahamutController(BaseServerController, DirectoryBasedController):
software_name = "Bahamut"
supported_sasl_mechanisms: Set[str] = set()
supports_sts = False
nickserv = "NickServ@services.example.org"
nickserv = "NickServ@My.Little.Services"
def create_config(self) -> None:
super().create_config()
@ -150,7 +149,7 @@ class BahamutController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*faketime_cmd,
"ircd",

View File

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

View File

@ -44,7 +44,7 @@ channel {{
displayed_usercount = 0;
}};
connect "services.example.org" {{
connect "My.Little.Services" {{
host = "localhost"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
@ -53,14 +53,14 @@ connect "services.example.org" {{
flags = topicburst;
}};
service {{
name = "services.example.org";
name = "My.Little.Services";
}};
privset "omnioper" {{
privs = oper:general, oper:privs, oper:testline, oper:kill, oper:operwall, oper:message,
oper:routing, oper:kline, oper:unkline, oper:xline,
oper:resv, oper:cmodes, oper:mass_notice, oper:wallops,
oper:remoteban,
oper:remoteban, oper:local_kill,
usermode:servnotice, auspex:oper, auspex:hostname, auspex:umodes, auspex:cmodes,
oper:admin, oper:die, oper:rehash, oper:spy, oper:grant;
}};

View File

@ -13,7 +13,7 @@ TEMPLATE_DLK_CONFIG = """\
info {{
SID "00A";
network-name "testnetwork";
services-name "services.example.org";
services-name "My.Little.Services";
admin-email "admin@example.org";
}}
@ -200,7 +200,7 @@ class DlkController(BaseServicesController, DirectoryBasedController):
fd.write(TEMPLATE_DLK_WP_CONFIG.format(**template_vars))
(dlk_conf_dir / "modules.conf").symlink_to(self.dlk_path / "conf/modules.conf")
self.proc = subprocess.Popen(
self.proc = self.execute(
[
"php",
"src/dalek",

View File

@ -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},
@ -126,7 +132,7 @@ def hash_password(password: Union[str, bytes]) -> str:
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
out, _ = p.communicate(input_)
return out.decode("utf-8")
return out.decode("utf-8").strip()
class ErgoController(BaseServerController, DirectoryBasedController):
@ -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)
@ -197,7 +213,7 @@ class ErgoController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
)
@ -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

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

View File

@ -3,6 +3,9 @@ from typing import Set, Type
from .base_hybrid import BaseHybridController
TEMPLATE_CONFIG = """
module_base_path = "{install_prefix}/lib/ircd-hybrid/modules";
.include "./reference.modules.conf"
serverinfo {{
name = "My.Little.Server";
sid = "42X";
@ -39,7 +42,7 @@ class {{
connectfreq = 5 minutes;
}};
connect {{
name = "services.example.org";
name = "My.Little.Services";
host = "127.0.0.1"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
@ -47,7 +50,7 @@ connect {{
class = "server";
}};
service {{
name = "services.example.org";
name = "My.Little.Services";
}};
auth {{

View File

@ -19,7 +19,7 @@ TEMPLATE_CONFIG = """
<class
name="ServerOperators"
commands="WALLOPS GLOBOPS"
commands="WALLOPS GLOBOPS KILL"
privs="channels/auspex users/auspex channels/auspex servers/auspex"
>
<type
@ -33,14 +33,15 @@ TEMPLATE_CONFIG = """
class="ServerOperators"
>
<options casemapping="ascii">
<options casemapping="ascii"
extbanformat="any">
# Disable 'NOTICE #chan :*** foo invited bar into the channel-
<security announceinvites="none">
# Services:
<bind address="{services_hostname}" port="{services_port}" type="servers">
<link name="services.example.org"
<link name="My.Little.Services"
ipaddr="{services_hostname}"
port="{services_port}"
allowmask="*"
@ -48,11 +49,9 @@ 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">
target="My.Little.Services">
# Protocol:
<module name="banexception">
@ -71,13 +70,10 @@ TEMPLATE_CONFIG = """
<module name="ircv3_servertime">
<module name="monitor">
<module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl">
# HELP/HELPOP
<module name="uhnames"> # For userhost-in-names
<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">
@ -89,6 +85,26 @@ TEMPLATE_SSL_CONFIG = """
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
"""
TEMPLATE_V3_CONFIG = """
<module name="namesx"> # For multi-prefix
<module name="services_account">
<module name="svshold"> # Atheme raises a warning when missing
# HELP/HELPOP
<module name="helpop">
<include file="examples/helpop.conf.example">
"""
TEMPLATE_V4_CONFIG = """
<module name="account">
<module name="multiprefix"> # For multi-prefix
<module name="services">
# HELP/HELPOP
<module name="help">
<include file="examples/help.example.conf">
"""
@functools.lru_cache()
def installed_version() -> int:
@ -97,12 +113,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"
@ -139,9 +157,9 @@ class InspircdController(BaseServerController, DirectoryBasedController):
ssl_config = ""
if installed_version() == 3:
help_module_name = "helpop"
elif installed_version() == 4:
help_module_name = "help"
version_config = TEMPLATE_V3_CONFIG
elif installed_version() >= 4:
version_config = TEMPLATE_V4_CONFIG
else:
assert False, f"unexpected version: {installed_version()}"
@ -154,7 +172,7 @@ class InspircdController(BaseServerController, DirectoryBasedController):
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
help_module_name=help_module_name,
version_config=version_config,
)
)
assert self.directory
@ -165,15 +183,22 @@ class InspircdController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
extra_args = []
if self.debug_mode:
if installed_version() >= 4:
extra_args.append("--protocoldebug")
else:
extra_args.append("--debug")
self.proc = self.execute(
[
*faketime_cmd,
"inspircd",
"--nofork",
"--config",
self.directory / "server.conf",
*extra_args,
],
stdout=subprocess.DEVNULL,
)
if run_services:

View File

@ -1,5 +1,4 @@
import shutil
import subprocess
from typing import Optional, Type
from irctest.basecontrollers import (
@ -25,7 +24,7 @@ Y:10:90::100:512000:100.100:100.100:
I::{password_field}:::10::
# O:<TARGET Host NAME>:<Password>:<Nickname>:<Port>:<Class>:<Flags>:
O:*:operpassword:operuser::::
O:*:operpassword:operuser:::K:
"""
@ -78,7 +77,7 @@ class Irc2Controller(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*faketime_cmd,
"ircd",
@ -88,7 +87,6 @@ class Irc2Controller(BaseServerController, DirectoryBasedController):
"-f",
self.directory / "server.conf",
],
# stderr=subprocess.DEVNULL,
)

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -15,7 +14,7 @@ TEMPLATE_CONFIG = """
{password_field}
[Server]
Name = services.example.org
Name = My.Little.Services
MyPassword = password
PeerPassword = password
Passive = yes # don't connect to it
@ -23,10 +22,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
"""
@ -91,7 +94,7 @@ class NgircdController(BaseServerController, DirectoryBasedController):
else:
faketime_cmd = []
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*faketime_cmd,
"ngircd",
@ -99,7 +102,6 @@ class NgircdController(BaseServerController, DirectoryBasedController):
"--config",
self.directory / "server.conf",
],
# stdout=subprocess.DEVNULL,
)
if run_services:

View File

@ -44,7 +44,7 @@ class {{
connectfreq = 5 minutes;
}};
connect {{
name = "services.example.org";
name = "My.Little.Services";
host = "127.0.0.1"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
@ -52,7 +52,7 @@ connect {{
class = "server";
}};
service {{
name = "services.example.org";
name = "My.Little.Services";
}};
auth {{

View File

@ -0,0 +1,549 @@
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.client_mock import ClientMock
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 = """
{
"object_expiry": 300,
"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 = self.execute(
[
*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 = self.execute(
[
"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 wait_for_services(self) -> None:
# by default, wait_for_services() connects a user that sends a HELP command
# to NickServ and assumes services are up when it receives a non-ERR_NOSUCHNICK
# reply.
# However, with Sable, NickServ is always up, even when services are not linked,
# so we need to check a different way. We check presence of a non-EXTERNAL
# value to the sasl capability, but LINKS would
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkSvs", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkSvs")
c.sendLine("USER chk chk chk chk")
time.sleep(self.server_controller.sync_sleep_time)
got_end_of_motd = False
while not got_end_of_motd:
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
# 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() + 10
while not self.services_up:
if time.time() > timeout:
raise Exception("Timeout while waiting for services")
c.sendLine("CAP LS 302")
msgs = self.getNickServResponse(c, timeout=1)
for msg in msgs:
if msg.command == "CAP":
pass
for token in msg.params[-1].split():
if token.startswith("sasl="):
if "PLAIN" in token.removeprefix("sasl=").split(","):
# SASL PLAIN is available, so services are linked.
self.services_up = True
break
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
def get_irctest_controller_class() -> Type[SableController]:
return SableController

View File

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

View File

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

View File

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

View File

@ -64,7 +64,7 @@ listen {{
options {{ serversonly; }}
}}
link services.example.org {{
link My.Little.Services {{
incoming {{
mask *;
}}
@ -72,11 +72,11 @@ link services.example.org {{
class servers;
}}
ulines {{
services.example.org;
My.Little.Services;
}}
set {{
sasl-server services.example.org;
sasl-server My.Little.Services;
kline-address "example@example.org";
network-name "ExampleNET";
default-server "irc.example.org";
@ -112,7 +112,7 @@ files {{
}}
oper "operuser" {{
password = "operpassword";
password "operpassword";
mask *;
class clients;
operclass netadmin;
@ -261,7 +261,7 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
faketime_cmd = []
with _STARTSTOP_LOCK():
self.proc = subprocess.Popen(
self.proc = self.execute(
[
*faketime_cmd,
"unrealircd",
@ -270,7 +270,6 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
"-f",
self.directory / "unrealircd.conf",
],
# stdout=subprocess.DEVNULL,
)
self.wait_for_port()

View File

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

View File

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

View File

@ -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

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

View File

@ -10,7 +10,7 @@ import pytest
from irctest import cases, runner
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYSTR
from irctest.patma import ANYSTR, StrRe
CHATHISTORY_CAP = "draft/chathistory"
EVENT_PLAYBACK_CAP = "draft/event-playback"
@ -21,28 +21,6 @@ SUBCOMMANDS = ["LATEST", "BEFORE", "AFTER", "BETWEEN", "AROUND"]
MYSQL_PASSWORD = ""
def validate_chathistory_batch(msgs):
batch_tag = None
closed_batch_tag = None
result = []
for msg in msgs:
if msg.command == "BATCH":
batch_param = msg.params[0]
if batch_tag is None and batch_param[0] == "+":
batch_tag = batch_param[1:]
elif batch_param[0] == "-":
closed_batch_tag = batch_param[1:]
elif (
msg.command == "PRIVMSG"
and batch_tag is not None
and msg.tags.get("batch") == batch_tag
):
if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific
result.append(msg.to_history_message())
assert batch_tag == closed_batch_tag
return result
def skip_ngircd(f):
@functools.wraps(f)
def newf(self, *args, **kwargs):
@ -56,10 +34,40 @@ def skip_ngircd(f):
@cases.mark_specifications("IRCv3")
@cases.mark_services
class ChathistoryTestCase(cases.BaseServerTestCase):
def validate_chathistory_batch(self, msgs, target):
(start, *inner_msgs, end) = msgs
self.assertMessageMatch(
start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", target]
)
batch_tag = start.params[0][1:]
self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag])
result = []
for msg in inner_msgs:
if (
msg.command in ("PRIVMSG", "TOPIC")
and batch_tag is not None
and msg.tags.get("batch") == batch_tag
):
if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific
result.append(msg.to_history_message())
return result
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(chathistory=True)
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")
@ -222,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):
@ -246,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(
@ -308,11 +363,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
)
time.sleep(0.002)
self.getMessages(1)
self.getMessages(2)
self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
c3 = "baz" + secrets.token_hex(12)
c3 = random_name("baz")
self.connectClient(
c3,
capabilities=[
@ -401,189 +459,212 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def _validate_chathistory_LATEST(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages, result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-5:], result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
result = validate_chathistory_batch(self.getMessages(user))
result = self.validate_chathistory_batch(self.getMessages(user), chname)
self.assertEqual(echo_messages[-1:], result)
self.sendLine(
user,
"CHATHISTORY LATEST %s msgid=%s %d"
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[5:], result)
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (
chname,
echo_messages[-1].msgid,
echo_messages[0].msgid,
INCLUSIVE_LIMIT,
),
)
result = 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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[-1].time, echo_messages[0].time, INCLUSIVE_LIMIT),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[1:-1], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[0].time, echo_messages[-1].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[1:4], result)
self.sendLine(
user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[-1].time, echo_messages[0].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[-4:-1], result)
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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 = validate_chathistory_batch(self.getMessages(user))
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")
@ -682,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

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

View File

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

View File

@ -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

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

View File

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

View File

@ -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

@ -0,0 +1,67 @@
"""
The KILL command (`Modern <https://modern.ircdocs.horse/#kill-message>`__)
"""
from irctest import cases
from irctest.numerics import ERR_NOPRIVILEGES, RPL_YOUREOPER
class KillTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
@cases.xfailIfSoftware(["Sable"], "https://github.com/Libera-Chat/sable/issues/154")
def testKill(self):
self.connectClient("ircop", name="ircop")
self.connectClient("alice", name="alice")
self.connectClient("bob", name="bob")
self.sendLine("ircop", "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages("ircop")],
fail_msg="OPER failed",
)
self.sendLine("alice", "KILL bob :some arbitrary reason")
self.assertIn(
ERR_NOPRIVILEGES,
[m.command for m in self.getMessages("alice")],
fail_msg="unprivileged KILL not rejected",
)
# bob is not killed
self.getMessages("bob")
self.sendLine("alice", "KILL alice :some arbitrary reason")
self.assertIn(
ERR_NOPRIVILEGES,
[m.command for m in self.getMessages("alice")],
fail_msg="unprivileged KILL not rejected",
)
# alice is not killed
self.getMessages("alice")
# privileged KILL should succeed
self.sendLine("ircop", "KILL alice :some arbitrary reason")
self.getMessages("ircop")
self.assertDisconnected("alice")
self.sendLine("ircop", "KILL bob :some arbitrary reason")
self.getMessages("ircop")
self.assertDisconnected("bob")
@cases.mark_specifications("Ergo")
def testKillOneArgument(self):
self.connectClient("ircop", name="ircop")
self.connectClient("alice", name="alice")
self.sendLine("ircop", "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages("ircop")],
fail_msg="OPER failed",
)
# 1-argument kill command, accepted by Ergo and some implementations
self.sendLine("ircop", "KILL alice")
self.getMessages("ircop")
self.assertDisconnected("alice")

View File

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

View File

@ -3,6 +3,13 @@ from irctest.numerics import ERR_UNKNOWNCOMMAND, RPL_ENDOFLINKS, RPL_LINKS
from irctest.patma import ANYSTR, StrRe
def _server_info_regexp(case: cases.BaseServerTestCase) -> str:
if case.controller.software_name == "Sable":
return ".+"
else:
return "test server"
class LinksTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testLinksSingleServer(self):
@ -56,7 +63,7 @@ class LinksTestCase(cases.BaseServerTestCase):
"nick",
"My.Little.Server",
"My.Little.Server",
StrRe("0 (0042 )?test server"),
StrRe(f"0 (0042 )?{_server_info_regexp(self)}"),
],
)
@ -110,7 +117,7 @@ class ServicesLinksTestCase(cases.BaseServerTestCase):
# This server redacts links
return
messages.sort(key=lambda m: m.params[-1])
messages.sort(key=lambda m: tuple(m.params))
self.assertMessageMatch(
messages.pop(0),
@ -119,7 +126,7 @@ class ServicesLinksTestCase(cases.BaseServerTestCase):
"nick",
"My.Little.Server",
"My.Little.Server",
StrRe("0 (0042 )?test server"),
StrRe(f"0 (0042 )?{_server_info_regexp(self)}"),
],
)
self.assertMessageMatch(
@ -127,9 +134,9 @@ class ServicesLinksTestCase(cases.BaseServerTestCase):
command=RPL_LINKS,
params=[
"nick",
"services.example.org",
"My.Little.Services",
"My.Little.Server",
StrRe("1 .+"), # SID instead of description for Anope...
StrRe("[01] .+"), # SID instead of description for Anope...
],
)

View File

@ -4,6 +4,7 @@ The PRIVMSG and NOTICE commands.
from irctest import cases
from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYSTR
class PrivmsgTestCase(cases.BaseServerTestCase):
@ -12,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
@ -45,12 +47,34 @@ class PrivmsgTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testPrivmsgNonexistentUser(self):
"""https://tools.ietf.org/html/rfc2812#section-3.3.1"""
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
self.connectClient("foo")
self.sendLine(1, "PRIVMSG bar :hey there!")
msg = self.getMessage(1)
# ERR_NOSUCHNICK
self.assertIn(msg.command, ("401"))
# ERR_NOSUCHNICK: 401 <sender> <recipient> :No such nick
self.assertMessageMatch(msg, command="401", params=["foo", "bar", ANYSTR])
@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):
@ -116,9 +140,9 @@ class TagsTestCase(cases.BaseServerTestCase):
self.joinChannel(1, "#xyz")
monsterMessage = "@+clientOnlyTagExample=" + "a" * 4096 + " PRIVMSG #xyz hi!"
self.sendLine(1, monsterMessage)
self.assertEqual(self.getMessages(2), [], "overflowing message was relayed")
replies = self.getMessages(1)
self.assertIn(ERR_INPUTTOOLONG, set(reply.command for reply in replies))
self.assertEqual(self.getMessages(2), [], "overflowing message was relayed")
class LengthLimitTestCase(cases.BaseServerTestCase):

View File

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

View File

@ -15,7 +15,7 @@ class MultiPrefixTestCase(cases.BaseServerTestCase):
These prefixes MUST be in order of rank, from highest to lowest.
"""
self.connectClient("foo", capabilities=["multi-prefix"])
self.connectClient("foo", capabilities=["multi-prefix"], skip_if_cap_nak=True)
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +v foo")
self.getMessages(1)
@ -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,6 +10,7 @@ TODO: cross-reference Modern
import time
from irctest import cases
from irctest.numerics import RPL_NAMREPLY
class PartTestCase(cases.BaseServerTestCase):
@ -84,6 +85,12 @@ class PartTestCase(cases.BaseServerTestCase):
self.getMessages(1)
self.getMessages(2)
self.sendLine(2, "PRIVMSG #chan :hi everyone")
self.getMessages(2)
self.assertMessageMatch(
self.getMessage(1), command="PRIVMSG", params=["#chan", "hi everyone"]
)
self.sendLine(1, "PART #chan")
# both the PART'ing client and the other channel member should receive
# a PART line:
@ -92,6 +99,21 @@ class PartTestCase(cases.BaseServerTestCase):
m = self.getMessage(2)
self.assertMessageMatch(m, command="PART")
self.sendLine(2, "PRIVMSG #chan :hi again everyone")
self.getMessages(2)
# client 1 has PART'ed and should not receive channel messages:
self.assertEqual(self.getMessages(1), [])
# client 1 should no longer appear in NAMES responses:
names = set()
self.sendLine(2, "NAMES #chan")
for reply in self.getMessages(2):
if reply.command != RPL_NAMREPLY:
continue
names.update(reply.params[-1].replace("@", "").split())
self.assertNotIn("bar", names)
self.assertIn("baz", names)
@cases.mark_specifications("RFC2812")
def testBasicPartRfc2812(self):
"""

View File

@ -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

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

View File

@ -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,6 +57,7 @@ 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)

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]+ .*"),
@ -361,6 +364,87 @@ class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase):
params=["otherNick", InsensitiveStr(mask), ANYSTR],
)
@cases.mark_specifications("Modern")
def testWhoMultiChan(self):
"""
When WHO <#chan> is sent, the second parameter of RPL_WHOREPLY must
be ``#chan``. See discussion on Modern:
<https://github.com/ircdocs/modern-irc/issues/209>
"""
self._init()
self.sendLine(1, "JOIN #otherchan")
self.getMessages(1)
self.sendLine(2, "JOIN #otherchan")
self.getMessages(2)
for chan in ["#chan", "#otherchan"]:
self.sendLine(2, f"WHO {chan}")
messages = self.getMessages(2)
self.assertEqual(len(messages), 3, "Unexpected number of messages")
(*replies, end) = messages
# Get them in deterministic order
replies.sort(key=lambda msg: msg.params[5])
self.assertMessageMatch(
replies[0],
command=RPL_WHOREPLY,
params=[
"otherNick",
chan,
ANYSTR,
ANYSTR,
StrRe(r"(My.Little.Server|\*)"),
"coolNick",
ANYSTR,
ANYSTR,
],
)
self.assertMessageMatch(
replies[1],
command=RPL_WHOREPLY,
params=[
"otherNick",
chan,
ANYSTR,
ANYSTR,
StrRe(r"(My.Little.Server|\*)"),
"otherNick",
ANYSTR,
ANYSTR,
],
)
self.assertMessageMatch(
end,
command=RPL_ENDOFWHO,
params=["otherNick", InsensitiveStr(chan), ANYSTR],
)
@cases.mark_specifications("Modern")
def testWhoNickNotExists(self):
"""
When WHO is sent with a non-existing nickname, the server must reply
with a single RPL_ENDOFWHO. See:
<https://github.com/ircdocs/modern-irc/pull/216>
"""
self._init()
self.sendLine(2, "WHO idontexist")
(end,) = self.getMessages(2)
self.assertMessageMatch(
end,
command=RPL_ENDOFWHO,
params=["otherNick", InsensitiveStr("idontexist"), ANYSTR],
)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("WHOX")
def testWhoxFull(self):
@ -395,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
@ -412,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()
@ -508,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,44 @@ 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")
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)
@ -201,10 +207,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1967",
)
def testWhowasMultiple(self):
"""
"The history is searched backward, returning the most recent entry first."
@ -215,10 +217,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2")
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1968",
)
def testWhowasCount1(self):
"""
"If there are multiple entries, up to <count> replies will be returned"
@ -229,10 +227,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
self._testWhowasMultiple(second_result=False, whowas_command="WHOWAS nick2 1")
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1968",
)
def testWhowasCount2(self):
"""
"If there are multiple entries, up to <count> replies will be returned"
@ -243,10 +237,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 2")
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1968",
)
def testWhowasCountNegative(self):
"""
"If a non-positive number is passed as being <count>, then a full search
@ -264,10 +254,6 @@ class WhowasTestCase(cases.BaseServerTestCase):
@cases.xfailIfSoftware(
["ircu2"], "Fix not released yet: https://github.com/UndernetIRC/ircu2/pull/19"
)
@cases.xfailIfSoftware(
["InspIRCd"],
"Feature not released yet: https://github.com/inspircd/inspircd/pull/1967",
)
def testWhowasCountZero(self):
"""
"If a non-positive number is passed as being <count>, then a full search

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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

@ -18,6 +18,7 @@ software:
separate_build_job: true
build_script: |
cd $GITHUB_WORKSPACE/charybdis/
patch -p1 < $GITHUB_WORKSPACE/patches/charybdis_ubuntu22.patch
./autogen.sh
./configure --prefix=$HOME/.local/
make -j 4
@ -32,6 +33,9 @@ software:
devel: "8.2.x"
devel_release: null
path: ircd-hybrid
pre_deps:
- name: "Install system dependencies"
run: "sudo apt-get install atheme-services faketime libjansson-dev"
separate_build_job: true
build_script: |
cd $GITHUB_WORKSPACE/ircd-hybrid/
@ -96,7 +100,7 @@ software:
name: Bahamut
repository: DALnet/Bahamut
refs:
stable: "v2.2.1"
stable: "v2.2.4"
release: null
devel: "master"
devel_release: null
@ -106,6 +110,10 @@ software:
cd $GITHUB_WORKSPACE/Bahamut/
patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch
patch src/s_bsd.c < $GITHUB_WORKSPACE/patches/bahamut_mainloop.patch
# <= v2.2.2
patch -p1 < $GITHUB_WORKSPACE/patches/bahamut_ubuntu22.patch || true
echo "#undef THROTTLE_ENABLE" >> include/config.h
libtoolize --force
aclocal
@ -129,9 +137,9 @@ software:
path: ergo
prefix: ~/go
pre_deps:
- uses: actions/setup-go@v2
- uses: actions/setup-go@v3
with:
go-version: '^1.19.0'
go-version: '^1.24.0'
- run: go version
separate_build_job: false
build_script: |
@ -143,7 +151,7 @@ software:
name: InspIRCd
repository: inspircd/inspircd
refs: &inspircd_refs
stable: v3.12.0
stable: v3.17.1
release: null
devel: master
devel_release: insp3
@ -153,9 +161,8 @@ software:
separate_build_job: true
build_script: &inspircd_build_script |
cd $GITHUB_WORKSPACE/inspircd/
patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch
./configure --prefix=$HOME/.local/inspircd --development
make -j 4
CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4
make install
irc2:
name: irc2
@ -226,7 +233,7 @@ software:
name: ngircd
repository: ngircd/ngircd
refs:
stable: rel-26.1
stable: acf8409c60ccc96beed0a1f990c4f9374823c0ce # three months ahead of v27
release: null
devel: master
devel_release: null
@ -241,6 +248,34 @@ software:
make -j 4
make install
sable:
name: Sable
repository: Libera-Chat/sable
refs:
stable: baed3ef9ac4550dc36a45b758436769e82e8ec58
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
@ -268,8 +303,8 @@ software:
name: UnrealIRCd 6
repository: unrealircd/unrealircd
refs:
stable: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
release: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7
stable: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1
release: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1
devel: unreal60_dev
devel_release: null
path: unrealircd
@ -311,16 +346,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
@ -328,8 +363,8 @@ software:
separate_build_job: false
path: Dlk-Services
refs:
stable: &dlk_stable "effd18652fc1c847d1959089d9cca9ff9837a8c0"
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: |
@ -351,13 +386,13 @@ software:
install_steps:
stable:
- name: Install dependencies
run: pip install limnoria==2022.03.17 cryptography pyxmpp2-scram
run: pip install limnoria==2023.5.27 cryptography pyxmpp2-scram
release:
- name: Install dependencies
run: pip install limnoria cryptography pyxmpp2-scram
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:
@ -375,6 +410,23 @@ software:
run: pip install git+https://github.com/sopel-irc/sopel.git
devel_release: null
thelounge:
name: TheLounge
repository: thelounge/thelounge
separate_build_job: false
refs:
stable: "v4.4.0"
release: "v4.4.0"
devel: "master"
devel_release: null
path: thelounge
build_script: |
cd $GITHUB_WORKSPACE/thelounge
yarn install
NODE_ENV=production yarn build
mkdir -p ~/.local/bin/
ln -s $(pwd)/index.js ~/.local/bin/thelounge
tests:
bahamut:
software: [bahamut]
@ -428,6 +480,9 @@ tests:
nefarious:
software: [nefarious]
sable:
software: [sable]
# doesn't build because it can't find liblex for some reason
#snircd:
# software: [snircd]
@ -453,3 +508,6 @@ tests:
sopel:
software: [sopel]
thelounge:
software: [thelounge]