56 Commits

Author SHA1 Message Date
cab27be1f5 fix incorrect type for empty tags 2021-02-15 21:36:55 +01:00
fc6bd4968d add a test for case changes 2021-02-15 21:36:55 +01:00
c2adc33109 remove starttls test 2021-02-15 21:36:55 +01:00
4519927265 add a test for PART messages 2021-02-15 21:36:55 +01:00
42f3a1f2fe add an away test 2021-02-15 21:36:55 +01:00
f3ff2a424b deflake another test 2021-02-15 21:13:12 +01:00
0c03a8e1c7 expand pyflakes list 2021-02-15 21:12:06 +01:00
7a05081960 add makefile 2021-02-15 21:11:56 +01:00
ee2e77b1e6 deflake registration tests by waiting for quit 2021-02-15 21:11:15 +01:00
9ff2239943 test RPL_ENDOFMONLIST responses 2021-02-15 21:06:18 +01:00
5f75f231a4 add list of numerics, start using them 2021-02-15 21:06:05 +01:00
b3a4a18885 kick privileges test 2021-02-15 21:05:19 +01:00
24df4e4496 fix lots of pyflakes3 failures 2021-02-15 21:00:51 +01:00
2b8aeacd4a tests for RPL_NOTOPIC 2021-02-15 20:59:03 +01:00
fc15ff09c0 fix testWhoisUser for charybdis. 2021-02-15 20:58:52 +01:00
7bf7df8ad1 basic whois test 2021-02-15 20:58:30 +01:00
6abc5b4f98 channel quit test case 2021-02-15 20:53:08 +01:00
cefbabf5c3 test incorrect PASS passwords 2021-02-15 20:52:49 +01:00
9b6f65b622 add regression tests 2021-02-15 20:52:00 +01:00
034fe5c51c framework enhancements 2021-02-15 20:47:08 +01:00
911c0ded04 add a test for INVITE 2021-02-15 20:45:08 +01:00
9b99c6ce20 Fix compatibility with return value of SSLSocket.sendall in python >= 3.6.
https://bugs.python.org/issue25951
2021-02-14 23:18:51 +01:00
e6b1ca5521 remove psutil 2021-02-14 22:59:19 +01:00
ac2671acb0 limnoria: add support for STS. 2021-02-14 22:22:01 +01:00
8a81224ba8 Fix ecdsa tests to use the same protocol as Atheme.
Which requires not hashing the challenge.
2019-12-26 12:10:45 +01:00
442c57e6c6 Temporarily disabling sts on Limnoria until it's released. 2019-12-09 22:13:55 +01:00
f8faec77f1 Ignore return value of sendall; it's not None on py < 3.6.
https://bugs.python.org/issue25951
2019-12-08 22:36:56 +01:00
71d9315813 Add STS tests. 2019-12-08 21:26:21 +01:00
857e8d195e Fix exception handling in testUntrustedCertificate on closed connection. 2019-10-22 18:28:39 +02:00
19a0623e91 echo-message: Check server-time more accurately, and handle slight timing differences due to late application 2018-07-12 15:19:07 +02:00
de4c51e744 oragono: Fix ACC command 2018-07-12 15:19:07 +02:00
1198504f74 Add tests for labeled-responses 2018-07-12 15:17:00 +02:00
5b319a46ee Make message tests less fragile 2018-07-12 15:17:00 +02:00
5950d97926 Bump version number to 0.1.2. 2017-12-10 20:21:29 +01:00
b35a7f7a60 Fix merge. 2017-12-10 20:21:02 +01:00
f47584589b Add tests for nonexistent channels
Inspired by oragono issue #165.
2017-12-09 09:44:45 +01:00
0804b78572 Add multi-prefix testcase 2017-12-09 09:43:58 +01:00
aecfc26a63 tls -> starttls, to match feature name better 2017-12-09 09:43:46 +01:00
f9f13961eb controllers: Add hybrid controller 2017-12-09 09:43:34 +01:00
2bc3cafd25 Make openssl binary configurable, for OSX 2017-12-09 09:43:19 +01:00
86d26d6121 charybdis: New releases name the binary 'charybdis' rather than 'ircd' 2017-12-09 09:42:58 +01:00
3d0b493a11 Don't send empty CAP REQ 2017-12-09 09:41:53 +01:00
711de43b22 Fix channel deterministic joining s'more 2017-12-09 09:41:45 +01:00
8b52ceeee3 Make tests around joining channels more deterministic 2017-12-09 09:41:22 +01:00
6d9c06096b channels: Check server casemapping before doing mapping checks 2017-12-09 09:38:26 +01:00
9575987555 For SCRAM, check clients send an empty response at the end.
https://github.com/ircv3/ircv3-specifications/pull/326
2017-11-15 17:11:44 +01:00
15a92ccf0b oragono: Allow TLS tests 2017-10-01 13:15:46 +02:00
0c12e0ed20 oragono: Use new registration command 2017-07-28 20:19:46 +02:00
f71badbbc1 oragono: Fix config so it loads 2017-07-28 20:19:38 +02:00
41f0418df7 Add gIRC controller 2017-01-11 00:35:22 +01:00
2a55c85c5a gitignore: Use gitignore.io 2017-01-11 00:35:07 +01:00
0c7358c0a5 test_sasl: Unify successful auth checking a bit more 2017-01-11 00:29:14 +01:00
6326af34cc Add Oragono IRCd 2017-01-11 00:29:03 +01:00
ba1fe57248 Bump version number. 2017-01-11 00:07:55 +01:00
6baee70852 Add tests for SCRAM. 2017-01-11 00:07:25 +01:00
2bdcba3da5 Update README. 2016-11-20 09:18:25 +01:00
146 changed files with 3750 additions and 18827 deletions

View File

@ -1,41 +0,0 @@
name: Lint
on:
push:
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Cache dependencies
uses: actions/cache@v2
with:
path: |
~/.cache
key: ${{ runner.os }}-lint
- name: Install dependencies
run: |
python -m pip install --upgrade pip
python -m pip install pre-commit pytest
pip install -r requirements.txt
- name: Lint
run: |
pre-commit run -a
- name: Check generated workflows are in sync
run: |
python make_workflows.py
git diff --exit-code

File diff suppressed because it is too large Load Diff

View File

@ -1,215 +0,0 @@
# This file was auto-generated by make_workflows.py.
# Do not edit it manually, modifications will be lost.
jobs:
build-anope:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Create directories
run: cd ~/; mkdir -p .local/ go/
- name: Cache Anope
uses: actions/cache@v2
with:
key: 3-${{ runner.os }}-anope-2.0.9
path: '~/.cache
${{ github.workspace }}/anope
'
- name: Checkout Anope
uses: actions/checkout@v2
with:
path: anope
ref: 2.0.9
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
- name: Make artefact tarball
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
- name: Upload build artefacts
uses: actions/upload-artifact@v2
with:
name: installed-anope
path: ~/artefacts-*.tar.gz
retention-days: 1
build-inspircd:
runs-on: ubuntu-latest
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
with:
python-version: 3.7
- name: Checkout InspIRCd
uses: actions/checkout@v2
with:
path: inspircd
ref: insp3
repository: inspircd/inspircd
- 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
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
with:
name: installed-inspircd
path: ~/artefacts-*.tar.gz
retention-days: 1
publish-test-results:
if: success() || failure()
name: Publish Unit Tests Results
needs:
- test-inspircd
- test-inspircd-anope
- test-inspircd-atheme
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Download Artifacts
uses: actions/download-artifact@v2
with:
path: artifacts
- if: github.event_name == 'pull_request'
name: Publish Unit Test Results
uses: actions/github-script@v4
with:
result-encoding: string
script: |
let body = '';
const options = {};
options.listeners = {
stdout: (data) => {
body += data.toString();
}
};
await exec.exec('bash', ['-c', 'shopt -s globstar; python3 report.py artifacts/**/*.xml'], options);
github.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: body,
});
return body;
test-inspircd:
needs:
- build-inspircd
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-inspircd
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install Atheme
run: sudo apt-get install atheme-services
- 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 inspircd
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
with:
name: pytest results inspircd (devel_release)
path: pytest.xml
test-inspircd-anope:
needs:
- build-inspircd
- build-anope
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-inspircd
path: '~'
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-anope
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install Atheme
run: sudo apt-get install atheme-services
- 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
inspircd-anope
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
with:
name: pytest results inspircd-anope (devel_release)
path: pytest.xml
test-inspircd-atheme:
needs:
- build-inspircd
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.7
uses: actions/setup-python@v2
with:
python-version: 3.7
- name: Download build artefacts
uses: actions/download-artifact@v2
with:
name: installed-inspircd
path: '~'
- name: Unpack artefacts
run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \;
- name: Install Atheme
run: sudo apt-get install atheme-services
- 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 inspircd-atheme
- if: always()
name: Publish results
uses: actions/upload-artifact@v2
with:
name: pytest results inspircd-atheme (devel_release)
path: pytest.xml
name: irctest with devel_release versions
'on':
schedule:
- cron: 51 8 * * 6
- cron: 51 8 * * 0
- cron: 51 17 * * *
workflow_dispatch: null

File diff suppressed because it is too large Load Diff

View File

@ -1,23 +0,0 @@
exclude: ^irctest/scram
repos:
- repo: https://github.com/psf/black
rev: 20.8b1
hooks:
- id: black
language_version: python3
- repo: https://github.com/PyCQA/isort
rev: 5.5.2
hooks:
- id: isort
- repo: https://gitlab.com/pycqa/flake8
rev: 3.8.3
hooks:
- id: flake8
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.812
hooks:
- id: mypy

View File

@ -1,92 +0,0 @@
# Contributing
## Code style
### Syntax
Any color you like as long as it's [Black](https://github.com/psf/black).
In short:
* 88 columns
* double quotes
* avoid backslashes at line breaks (use parentheses)
* closing brackets/parentheses/... go on the same indent level as the line
that opened them
We also use `isort` to order imports (in short: just
[follow PEP 8](https://www.python.org/dev/peps/pep-0008/#imports))
You can use [pre-commit](https://pre-commit.com/) to automatically run them
when you create a git commit.
Alternatively, run `pre-commit run -a`
### Naming
[Follow PEP 8](https://www.python.org/dev/peps/pep-0008/#naming-conventions),
with these exceptions:
* assertion methods (eg. `assertMessageMatch` are mixedCase to be consistent
with the unittest module)
* other methods defined in `cases.py` are also mixedCase for consistency with
the former, for now
* test names are also mixedCase for the same reason
Additionally:
* test module names should be snake\_case and match the name of the
specification they are testing (if IRCv3), or the feature they are
testing (if RFC or just common usage)
## What to test
**All tests should have a docstring** pointing to a reference document
(eg. RFC, IRCv3 specification, or modern.ircdocs.horse).
If there is no reference, documentation can do.
If the behavior being tested is not documented, then **please document it
outside** this repository (eg. at modern.ircdocs.horse),
and/or get it specified through IRCv3.
"That's just how everyone does it" is not good justification.
Linking to an external document saying "Here is how everyone does it" is.
If reference documents / documentations are long or not trivial,
**try to quote the specific part being tested**.
See `irctest/server_tests/kick.py` for example.
Tests for **pending/draft specifications are welcome**.
Note that irctest also welcomes implementation-specific tests for
functional testing; for now only Ergo.
This does not relax the requirement on documentating tests.
## Writing tests
**Use unittest-style assertions** (`self.assertEqual(x, y)` instead of
pytest-style (`assert x == y`). This allows consistency with the assertion
methods we define, such as `assertMessageMatch`.
Always **add an error message in assertions**.
`irctest` should show readable errors to people unfamiliar with the
codebase.
Ideally, explain what happened and what should have happened instead.
All tests should be tagged with
`@cases.mark_specifications`.
## Continuous integration
We run automated tests on all commits and pull requests, to check that tests
accept existing implementations.
Scripts to run the tests are defined in `workflows.yml`, and the
`make_workflows.py` script reads this configuration to generate files
in `.github/workflows/` that are used by the CI.
If an implementation cannot pass a test, that test should be excluded via
a definition in the Makefile.
If it is a bug, please open a bug report to the affected software if possible,
and link to the bug report in a comment.

358
Makefile
View File

@ -1,355 +1,5 @@
PYTEST ?= python3 -m pytest
.PHONY: oragono
# Extra arguments to pass to pytest (eg. `-n 4` to run in parallel if
# pytest-xdist is installed)
PYTEST_ARGS ?=
# Will be appended at the end of the -k argument to pytest
EXTRA_SELECTORS ?=
# testPlainLarge fails because it doesn't handle split AUTHENTICATE (reported on IRC)
ANOPE_SELECTORS := \
and not testPlainLarge
# buffering tests cannot pass because of issues with UTF-8 handling: https://github.com/DALnet/bahamut/issues/196
# mask tests in test_who.py fail because they are not implemented.
# some HelpTestCase::*[HELP] tests fail because Bahamut forwards /HELP to HelpServ (but not /HELPOP)
# testWhowasMultiTarget fails because Bahamut returns the results in query order instead of chronological order
BAHAMUT_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not IRCv3 \
and not buffering \
and not (testWho and not whois and mask) \
and not testWhoStar \
and (not HelpTestCase or HELPOP) \
and not testWhowasMultiTarget \
$(EXTRA_SELECTORS)
# testQuitErrors is very flaky
# AccountTagTestCase.testInvite fails because https://github.com/solanum-ircd/solanum/issues/166
# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
# testWhoisNumerics[oper] fails because charybdis uses RPL_WHOISSPECIAL instead of RPL_WHOISOPERATOR
# testWhowasNoSuchNick fails because of a typo (solved in https://github.com/solanum-ircd/solanum/commit/08b7b6bd7e60a760ad47b58cbe8075b45d66166f)
CHARYBDIS_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not testQuitErrors \
and not testKickDefaultComment \
and not (AccountTagTestCase and testInvite) \
and not (testWhoisNumerics and oper) \
and not testWhowasNoSuchNick \
$(EXTRA_SELECTORS)
# testInfoNosuchserver does not apply to Ergo: Ergo ignores the optional <target> argument
ERGO_SELECTORS := \
not deprecated \
and not testInfoNosuchserver \
$(EXTRA_SELECTORS)
# testInviteUnopped is the only strict test that Hybrid fails
HYBRID_SELECTORS := \
not Ergo \
and not testInviteUnopped \
and not deprecated \
$(EXTRA_SELECTORS)
# testBotPrivateMessage and testBotChannelMessage fail because https://github.com/inspircd/inspircd/pull/1910 is not released yet
# WHOWAS tests fail because https://github.com/inspircd/inspircd/pull/1967 and https://github.com/inspircd/inspircd/pull/1968 are not released yet
INSPIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not testNoticeNonexistentChannel \
and not testBotPrivateMessage and not testBotChannelMessage \
and not whowas \
$(EXTRA_SELECTORS)
# buffering tests fail because ircu2 discards the whole buffer on long lines (TODO: refine how we exclude these tests)
# testQuit and testQuitErrors fail because ircu2 does not send ERROR or QUIT
# lusers "full" tests fail because they depend on Modern behavior, not just RFC2812
# statusmsg tests fail because STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG target
# testKeyValidation[empty] fails because ircu2 returns ERR_NEEDMOREPARAMS on empty keys: https://github.com/UndernetIRC/ircu2/issues/13
# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
# testEmptyRealname fails because it uses a default value instead of ERR_NEEDMOREPARAMS.
# HelpTestCase fails because it returns NOTICEs instead of numerics
# testWhowasCountZero fails: https://github.com/UndernetIRC/ircu2/pull/19
IRCU2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not buffering \
and not testQuit \
and not (lusers and full) \
and not statusmsg \
and not (testKeyValidation and empty) \
and not testKickDefaultComment \
and not testEmptyRealname \
and not HelpTestCase \
and not testWhowasCountZero \
$(EXTRA_SELECTORS)
# same justification as ircu2
SNIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not buffering \
and not testQuit \
and not (lusers and full) \
and not statusmsg \
$(EXTRA_SELECTORS)
# testListEmpty and testListOne fails because irc2 deprecated LIST
# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker.
# testWallopsPrivileges fails because it ignores the command instead of replying ERR_UNKNOWNCOMMAND
# HelpTestCase fails because it returns NOTICEs instead of numerics
IRC2_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not testListEmpty and not testListOne \
and not testKickDefaultComment \
and not testWallopsPrivileges \
and not HelpTestCase \
$(EXTRA_SELECTORS)
MAMMON_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS)
# testKeyValidation[spaces] and testKeyValidation[empty] fail because ngIRCd does not validate them https://github.com/ngircd/ngircd/issues/290
# testStarNick: wat
# testEmptyRealname fails because it uses a default value instead of ERR_NEEDMOREPARAMS.
# chathistory tests fail because they need nicks longer than 9 chars
# HelpTestCase::*[HELP] fails because it returns NOTICEs instead of numerics
NGIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not (testKeyValidation and (spaces or empty)) \
and not testStarNick \
and not testEmptyRealname \
and not chathistory \
and (not HelpTestCase or HELPOP) \
$(EXTRA_SELECTORS)
# testInviteUnopped is the only strict test that Plexus4 fails
# testInviteInviteOnly fails because Plexus4 allows non-op to invite if (and only if) the channel is not invite-only
PLEXUS4_SELECTORS := \
not Ergo \
and not testInviteUnopped \
and not testInviteInviteOnly \
and not deprecated \
$(EXTRA_SELECTORS)
# Limnoria can actually pass all the test so there is none to exclude.
# `(foo or not foo)` serves as a `true` value so it doesn't break when
# $(EXTRA_SELECTORS) is non-empty
LIMNORIA_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS)
# testQuitErrors is too flaky for CI
# testKickDefaultComment fails because solanum uses the nick of the kickee rather than the kicker.
SOLANUM_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not testQuitErrors \
and not testKickDefaultComment \
$(EXTRA_SELECTORS)
SOPEL_SELECTORS := \
not testPlainNotAvailable \
$(EXTRA_SELECTORS)
# testNoticeNonexistentChannel fails: https://bugs.unrealircd.org/view.php?id=5949
# regressions::testTagCap fails: https://bugs.unrealircd.org/view.php?id=5948
# messages::testLineTooLong fails: https://bugs.unrealircd.org/view.php?id=5947
# testCapRemovalByClient and testNakWhole fail pending https://github.com/unrealircd/unrealircd/pull/148
# 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
# testChathistory[BETWEEN] fails: https://bugs.unrealircd.org/view.php?id=5952
# testChathistory[AROUND] fails: https://bugs.unrealircd.org/view.php?id=5953
# testWhoAllOpers fails because Unreal skips results when the mask is too broad
# HELP and HELPOP tests fail because Unreal uses custom numerics https://github.com/unrealircd/unrealircd/pull/184
UNREALIRCD_SELECTORS := \
not Ergo \
and not deprecated \
and not strict \
and not testNoticeNonexistentChannel \
and not (regressions.py and testTagCap) \
and not (messages.py and testLineTooLong) \
and not (cap.py and (testCapRemovalByClient or testNakWhole)) \
and not (account_tag.py and testInvite) \
and not arbitrary_client_tags \
and not react_tag \
and not private_chathistory \
and not (testChathistory and (between or around)) \
and not testWhoAllOpers \
and not HelpTestCase \
$(EXTRA_SELECTORS)
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon limnoria sopel solanum unrealircd
all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon limnoria sopel solanum unrealircd
flakes:
find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3
bahamut:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
-m 'not services' \
-n 10 \
-k '$(BAHAMUT_SELECTORS)'
bahamut-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-n 10 \
-k '$(BAHAMUT_SELECTORS)'
bahamut-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-n 10 \
-k '$(BAHAMUT_SELECTORS) $(ANOPE_SELECTORS)'
charybdis:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.charybdis \
--services-controller=irctest.controllers.atheme_services \
-k '$(CHARYBDIS_SELECTORS)'
ergo:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ergo \
-k "$(ERGO_SELECTORS)"
hybrid:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.hybrid \
--services-controller=irctest.controllers.anope_services \
-k "$(HYBRID_SELECTORS)"
inspircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
-m 'not services' \
-k '$(INSPIRCD_SELECTORS)'
inspircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-k '$(INSPIRCD_SELECTORS)'
inspircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k '$(INSPIRCD_SELECTORS) $(ANOPE_SELECTORS)'
ircu2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.ircu2 \
-m 'not services and not IRCv3' \
-n 10 \
-k '$(IRCU2_SELECTORS)'
snircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.snircd \
-m 'not services and not IRCv3' \
-n 10 \
-k '$(SNIRCD_SELECTORS)'
irc2:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.irc2 \
-m 'not services and not IRCv3' \
-n 10 \
-k '$(IRC2_SELECTORS)'
limnoria:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.limnoria \
-k '$(LIMNORIA_SELECTORS)'
mammon:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.mammon \
-k '$(MAMMON_SELECTORS)'
plexus4:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.plexus4 \
--services-controller=irctest.controllers.anope_services \
-k "$(PLEXUS4_SELECTORS)"
ngircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
-m 'not services' \
-n 10 \
-k "$(NGIRCD_SELECTORS)"
ngircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k "$(NGIRCD_SELECTORS)"
ngircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-k "$(NGIRCD_SELECTORS)"
solanum:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.solanum \
--services-controller=irctest.controllers.atheme_services \
-k '$(SOLANUM_SELECTORS)'
sopel:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sopel \
-k '$(SOPEL_SELECTORS)'
unrealircd:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
-m 'not services' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-5: unrealircd
unrealircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.atheme_services \
-m 'services' \
-k '$(UNREALIRCD_SELECTORS)'
unrealircd-anope:
$(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.anope_services \
-m 'services' \
-k '$(UNREALIRCD_SELECTORS) $(ANOPE_SELECTORS)'
oragono:
pyflakes3 ./irctest/cases.py ./irctest/client_mock.py ./irctest/controllers/oragono.py irctest/server_tests/*.py
./test.py irctest.controllers.oragono

180
README.md
View File

@ -1,7 +1,10 @@
# irctest
This project aims at testing interoperability of software using the
IRC protocol, by running them against common test suites.
IRC protocol, by running them against test suites and making different
software communicate with each other.
It is very young and does not contain a lot of test cases yet.
## The big picture
@ -11,193 +14,95 @@ This project contains:
* small wrappers around existing software to run tests on them
Wrappers run software in temporary directories, so running `irctest` should
have no side effect.
have no side effect, with [the exception of Sopel](https://github.com/sopel-irc/sopel/issues/946).
## Prerequisites
Install irctest and dependencies:
```
cd ~
git clone https://github.com/ProgVal/irctest.git
cd irctest
pip3 install --user -r requirements.txt
python3 setup.py install --user
```
Add `~/.local/bin/` (and/or `~/go/bin/` for Ergo)
to your `PATH` if it is not.
Add `~/.local/bin/` to your `PATH` if it is not.
```
export PATH=$HOME/.local/bin/:$HOME/go/bin/:$PATH
export PATH=$HOME/.local/bin/:$PATH
```
## Using pytest
## Run tests
irctest is invoked using the pytest test runner / CLI.
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).
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`.
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`.
This excludes:
* `Ergo`-specific tests (included as Ergo uses irctest as its official
integration test suite)
* tests for deprecated specifications, such as the IRCv3 METADATA
specification
* tests that check for a strict interpretation of a specification, when
the specification is ambiguous.
## Running tests
### Servers
#### Ergo:
To run (client) tests on Limnoria:
```
cd /tmp/
git clone https://github.com/ergochat/ergo.git
cd ergo/
make install
cd ~/irctest
pytest --controller irctest.controllers.ergo -k 'not deprecated'
pip3 install --user limnoria
python3 -m irctest irctest.controllers.limnoria
```
#### Solanum:
To run (client) tests on Sopel:
```
cd /tmp/
git clone https://github.com/solanum-ircd/solanum.git
cd solanum
./autogen.sh
./configure --prefix=$HOME/.local/
make -j 4
make install
pytest --controller irctest.controllers.solanum -k 'not Ergo and not deprecated and not strict'
pip3 install --user sopel
mkdir ~/.sopel/
python3 -m irctest irctest.controllers.sopel
```
#### 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:
To run (server) tests on InspIRCd:
```
cd /tmp/
git clone https://github.com/inspircd/inspircd.git
cd inspircd
# optional, makes tests run considerably faster
patch src/inspircd.cpp < ~/irctest/patches/inspircd_mainloop.patch
./configure --prefix=$HOME/.local/ --development
make -j 4
make install
cd ~/irctest
pytest --controller irctest.controllers.inspircd -k 'not Ergo and not deprecated and not strict'
python3 -m irctest irctest.controllers.inspircd
```
#### Mammon:
To run (server) tests on 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'
python3 -m irctest irctest.controllers.mammon
```
#### UnrealIRCd:
To run (server) tests on Charybdis::
```
cd /tmp/
git clone https://github.com/unrealircd/unrealircd.git
cd unrealircd
./Config # This will ask a few questions, answer them.
git clone https://github.com/atheme/charybdis.git
cd charybdis
./configure --prefix=$HOME/.local/
make -j 4
make install
cd ~/irctest
pytest --controller irctest.controllers.unreal -k 'not Ergo and not deprecated and not strict'
python3 -m irctest irctest.controllers.charybdis
```
### Servers with services
Besides Ergo (that has built-in services), most server controllers can optionally run
service packages.
#### Atheme:
You can install it with
## Full help
```
sudo apt install atheme-services
```
usage: python3 -m irctest [-h] [--show-io] [-v] [-s SPECIFICATION] [-l] module
and add this to the `pytest` call:
positional arguments:
module The module used to run the tested program.
```
--services-controller irctest.controllers.atheme_services
```
#### Anope:
Build with:
```
cd /tmp/
git clone https://github.com/anope/anope.git
cd anope
./Config # This will ask a few questions, answer them.
make -C build -j 4
make -C build install
```
and add this to the `pytest` call:
```
--services-controller irctest.controllers.anope_services
```
### Clients
#### Limnoria:
```
pip3 install --user limnoria pyxmpp2-scram
cd ~/irctest
pytest --controller irctest.controllers.limnoria
```
#### Sopel:
```
pip3 install --user sopel
mkdir ~/.sopel/
cd ~/irctest
pytest --controller irctest.controllers.sopel
optional arguments:
-h, --help show this help message and exit
--show-io Show input/outputs with the tested program.
-v, --verbose Verbosity. Give this option multiple times to make it
even more verbose.
-s SPECIFICATION, --specification SPECIFICATION
The set of specifications to test the program with.
Valid values: RFC1459, RFC2812, IRCv3.1, IRCv3.2. Use
this option multiple times to test with multiple
specifications. If it is not given, defaults to all.
-l, --loose Disables strict checks of conformity to the
specification. Strict means the specification is
unclear, and the most restrictive interpretation is
choosen.
```
## What `irctest` is not
@ -209,4 +114,3 @@ At best, `irctest` can help you find issues in your software, but it may
still have false positives (because it does not implement itself a
full-featured client/server, so it supports only “usual” behavior).
Bug reports for false positives are welcome.

View File

@ -1,132 +0,0 @@
import importlib
import _pytest.unittest
import pytest
# Must be called before importing irctest.cases.
pytest.register_assert_rewrite("irctest.cases")
from irctest.basecontrollers import ( # noqa: E402
BaseClientController,
BaseServerController,
)
from irctest.cases import ( # noqa: E402
BaseClientTestCase,
BaseServerTestCase,
_IrcTestCase,
)
def pytest_addoption(parser):
"""Called by pytest, registers CLI options passed to the pytest command."""
parser.addoption(
"--controller", help="Which module to use to run the tested software."
)
parser.addoption(
"--services-controller", help="Which module to use to run a services package."
)
parser.addoption(
"--openssl-bin", type=str, default="openssl", help="The openssl binary to use"
)
def pytest_configure(config):
"""Called by pytest, after it parsed the command-line."""
module_name = config.getoption("controller")
services_module_name = config.getoption("services_controller")
if module_name is None:
print("Missing --controller option, errors may occur.")
_IrcTestCase.controllerClass = None
_IrcTestCase.show_io = True # TODO
return
try:
module = importlib.import_module(module_name)
except ImportError:
pytest.exit("Cannot import module {}".format(module_name), 1)
controller_class = module.get_irctest_controller_class()
if issubclass(controller_class, BaseClientController):
from irctest import client_tests as module
if services_module_name is not None:
pytest.exit("You may not use --services-controller for client tests.")
elif issubclass(controller_class, BaseServerController):
from irctest import server_tests as module
else:
pytest.exit(
r"{}.Controller should be a subclass of "
r"irctest.basecontroller.Base{{Client,Server}}Controller".format(
module_name
),
1,
)
if services_module_name is not None:
try:
services_module = importlib.import_module(services_module_name)
except ImportError:
pytest.exit("Cannot import module {}".format(services_module_name), 1)
controller_class.services_controller_class = (
services_module.get_irctest_controller_class()
)
_IrcTestCase.controllerClass = controller_class
_IrcTestCase.controllerClass.openssl_bin = config.getoption("openssl_bin")
_IrcTestCase.show_io = True # TODO
def pytest_collection_modifyitems(session, config, items):
"""Called by pytest after finishing the test collection,
and before actually running the tests.
This function filters out client tests if running with a server controller,
and vice versa.
"""
# First, check if we should run server tests or client tests
server_tests = client_tests = False
if _IrcTestCase.controllerClass is None:
pass
elif issubclass(_IrcTestCase.controllerClass, BaseServerController):
server_tests = True
elif issubclass(_IrcTestCase.controllerClass, BaseClientController):
client_tests = True
else:
assert False, (
f"{_IrcTestCase.controllerClass} inherits neither "
f"BaseClientController or BaseServerController"
)
filtered_items = []
# Iterate over each of the test functions (they are pytest "Nodes")
for item in 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
),
)
# and that node references the UnitTest class
assert issubclass(item.parent.cls, _IrcTestCase)
# and in this project, TestCase classes all inherit either from
# BaseClientController or BaseServerController.
if issubclass(item.parent.cls, BaseServerTestCase):
if server_tests:
filtered_items.append(item)
elif issubclass(item.parent.cls, BaseClientTestCase):
if client_tests:
filtered_items.append(item)
else:
filtered_items.append(item)
# Finally, rewrite in-place the list of tests pytest will run
items[:] = filtered_items

View File

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

View File

@ -1,3 +0,0 @@
Boilerplate files so Unreal can be built non-interactively.
Obviously, you shouldn't use the .pem in a production environment!

View File

@ -1,24 +0,0 @@
BASEPATH="$HOME/.local/unrealircd"
BINDIR="$HOME/.local/unrealircd/bin"
DATADIR="$HOME/.local/unrealircd/data"
CONFDIR="$HOME/.local/unrealircd/conf"
MODULESDIR="$HOME/.local/unrealircd/modules"
LOGDIR="$HOME/.local/unrealircd/logs"
CACHEDIR="$HOME/.local/unrealircd/cache"
DOCDIR="$HOME/.local/unrealircd/doc"
TMPDIR="$HOME/.local/unrealircd/tmp"
PRIVATELIBDIR="$HOME/.local/unrealircd/lib"
PREFIXAQ="1"
MAXCONNECTIONS_REQUEST="auto"
NICKNAMEHISTORYLENGTH="2000"
DEFPERM="0600"
SSLDIR=""
REMOTEINC=""
CURLDIR=""
SHOWLISTMODES="1"
NOOPEROVERRIDE=""
OPEROVERRIDEVERIFY=""
GENCERTIFICATE="1"
EXTRAPARA=""
ADVANCED=""

View File

@ -1,14 +0,0 @@
-----BEGIN CERTIFICATE-----
MIICGDCCAZ6gAwIBAgIUeHAOQnvT7N9kCmUuIklelkzz8SUwCgYIKoZIzj0EAwIw
QzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRIwEAYDVQQKDAlJUkMg
Z2Vla3MxDTALBgNVBAsMBElSQ2QwHhcNMjEwNzAyMTk1MTM5WhcNMzEwNjMwMTk1
MTM5WjBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQBgNVBAoM
CUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuBBAAiA2IA
BHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBCUbISsk/C
Txru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTpEAqMSnrc
zKNTMFEwHQYDVR0OBBYEFFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMB8GA1UdIwQYMBaA
FFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0E
AwIDaAAwZQIwAo29xUEAzqOMgPAWtMifHFLuPQPuWcNGbaI5S4W81NO8uIcNv/kM
mFocuITr76p0AjEApzGjc5wM+KydwoVTP+fg1aGQA13Ba2nCzN3R5XwR/USCigjv
na1QtWAKjpvR/rsp
-----END CERTIFICATE-----

View File

@ -1,9 +0,0 @@
-----BEGIN EC PARAMETERS-----
BgUrgQQAIg==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MIGkAgEBBDCWkDHktJiTqC7im+Ni37fbXxtMBqIKPwkAItpKMeuh28QrXWwNE1a5
wSa38C1nd8igBwYFK4EEACKhZANiAARwOoqi0IJEuNsR4P3RD6vXSo5S4tB1rwjO
5zglwMhctQ465mxaEgkAQlGyErJPwk8a7t2CH13uo0gkvFE29eZu9Jq5polK/7ps
YqowDorKNOdYQq88VZo06RAKjEp63Mw=
-----END EC PRIVATE KEY-----

View File

@ -1,9 +0,0 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBOjCBwgIBADBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQ
BgNVBAoMCUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuB
BAAiA2IABHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBC
UbISsk/CTxru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTp
EAqMSnrczKAAMAoGCCqGSM49BAMCA2cAMGQCMEL5ezlauGUaxh+pXt897ffmzqci
fqYm3FgVW5x6EdtCxtcwwAwnR84LKcd/YRKOygIwNmZiRVKeSeC7Ess1PxuzT1Mu
Cw3bBqkE5LmO1hu/+0lK+QoFPEeLDrygIh+SDdGH
-----END CERTIFICATE REQUEST-----

87
irctest/__main__.py Normal file
View File

@ -0,0 +1,87 @@
import sys
import unittest
import argparse
import unittest
import functools
import importlib
from .cases import _IrcTestCase
from .runner import TextTestRunner
from .specifications import Specifications
from .basecontrollers import BaseClientController, BaseServerController
def main(args):
try:
module = importlib.import_module(args.module)
except ImportError:
print('Cannot import module {}'.format(args.module), file=sys.stderr)
exit(1)
controller_class = module.get_irctest_controller_class()
if issubclass(controller_class, BaseClientController):
import irctest.client_tests as module
elif issubclass(controller_class, BaseServerController):
import irctest.server_tests as module
else:
print(r'{}.Controller should be a subclass of '
r'irctest.basecontroller.Base{{Client,Server}}Controller'
.format(args.module),
file=sys.stderr)
exit(1)
_IrcTestCase.controllerClass = controller_class
_IrcTestCase.controllerClass.openssl_bin = args.openssl_bin
_IrcTestCase.show_io = args.show_io
_IrcTestCase.strictTests = not args.loose
if args.specification:
try:
_IrcTestCase.testedSpecifications = frozenset(
Specifications.of_name(x) for x in args.specification
)
except ValueError:
print('Invalid set of specifications: {}'
.format(', '.join(args.specification)))
exit(1)
else:
_IrcTestCase.testedSpecifications = frozenset(
Specifications)
print('Testing {} on specification(s): {}'.format(
controller_class.software_name,
', '.join(sorted(map(lambda x:x.value,
_IrcTestCase.testedSpecifications)))))
ts = module.discover()
testRunner = TextTestRunner(
verbosity=args.verbose,
descriptions=True,
)
testLoader = unittest.loader.defaultTestLoader
result = testRunner.run(ts)
if result.failures or result.errors:
exit(1)
else:
exit(0)
parser = argparse.ArgumentParser(
description='A script to test interoperability of IRC software.')
parser.add_argument('module', type=str,
help='The module used to run the tested program.')
parser.add_argument('--openssl-bin', type=str, default='openssl',
help='The openssl binary to use')
parser.add_argument('--show-io', action='store_true',
help='Show input/outputs with the tested program.')
parser.add_argument('-v', '--verbose', action='count', default=1,
help='Verbosity. Give this option multiple times to make '
'it even more verbose.')
parser.add_argument('-s', '--specification', type=str, action='append',
help=('The set of specifications to test the program with. '
'Valid values: {}. '
'Use this option multiple times to test with multiple '
'specifications. If it is not given, defaults to all.')
.format(', '.join(x.value for x in Specifications)))
parser.add_argument('-l', '--loose', action='store_true',
help='Disables strict checks of conformity to the specification. '
'Strict means the specification is unclear, and the most restrictive '
'interpretation is choosen.')
args = parser.parse_args()
main(args)

View File

@ -1,23 +1,19 @@
import dataclasses
import enum
from typing import Optional, Tuple
import collections
@enum.unique
class Mechanisms(enum.Enum):
"""Enumeration for representing possible mechanisms."""
def to_string(self) -> str:
return self.name.upper().replace("_", "-")
@classmethod
def as_string(cls, mech):
return {cls.plain: 'PLAIN',
cls.ecdsa_nist256p_challenge: 'ECDSA-NIST256P-CHALLENGE',
cls.scram_sha_256: 'SCRAM-SHA-256',
}[mech]
plain = 1
ecdsa_nist256p_challenge = 2
scram_sha_256 = 3
@dataclasses.dataclass
class Authentication:
mechanisms: Tuple[Mechanisms] = (Mechanisms.plain,)
username: Optional[str] = None
password: Optional[str] = None
ecdsa_key: Optional[str] = None
Authentication = collections.namedtuple('Authentication',
'mechanisms username password ecdsa_key')
Authentication.__new__.__defaults__ = ([Mechanisms.plain], None, None, None)

View File

@ -1,112 +1,49 @@
from __future__ import annotations
import dataclasses
import os
import shutil
import socket
import subprocess
import tempfile
import time
from typing import IO, Any, Callable, Dict, List, Optional, Set, Tuple, Type
import subprocess
import irctest
from . import authentication, tls
from .client_mock import ClientMock
from .irc_utils.junkdrawer import find_hostname_and_port
from .irc_utils.message_parser import Message
from .runner import NotImplementedByController
class ProcessStopped(Exception):
"""Raised when the controlled process stopped unexpectedly"""
pass
@dataclasses.dataclass
class TestCaseControllerConfig:
"""Test-case-specific configuration passed to the controller.
This is usually used to ask controllers to enable a feature;
but should not be an issue if controllers enable it all the time."""
chathistory: bool = False
"""Whether to enable chathistory features."""
ergo_roleplay: bool = False
"""Whether to enable the Ergo role-play commands."""
ergo_config: Optional[Callable[[Dict], Any]] = None
"""Oragono-specific configuration function that alters the dict in-place
This should be used as little as possible, using the other attributes instead;
as they are work with any controller."""
class _BaseController:
"""Base class for software controllers.
A software controller is an object that handles configuring and running
a process (eg. a server or a client), as well as sending it instructions
that are not part of the IRC specification."""
pass
# set by conftest.py
openssl_bin: str
supports_sts: bool
supported_sasl_mechanisms: Set[str]
proc: Optional[subprocess.Popen]
def __init__(self, test_config: TestCaseControllerConfig):
self.test_config = test_config
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
arbitrary directory."""
def __init__(self):
super().__init__()
self.directory = None
self.proc = None
def check_is_alive(self) -> None:
assert self.proc
self.proc.poll()
if self.proc.returncode is not None:
raise ProcessStopped()
def kill_proc(self) -> None:
def kill_proc(self):
"""Terminates the controlled process, waits for it to exit, and
eventually kills it."""
assert self.proc
self.proc.terminate()
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc = None
def kill(self) -> None:
def kill(self):
"""Calls `kill_proc` and cleans the configuration."""
if self.proc:
self.kill_proc()
class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an
arbitrary directory."""
directory: Optional[str]
def __init__(self, test_config: TestCaseControllerConfig):
super().__init__(test_config)
self.directory = None
def kill(self) -> None:
"""Calls `kill_proc` and cleans the configuration."""
super().kill()
if self.directory:
shutil.rmtree(self.directory)
def terminate(self) -> None:
def terminate(self):
"""Stops the process gracefully, and does not clean its config."""
assert self.proc
self.proc.terminate()
self.proc.wait()
self.proc = None
def open_file(self, name: str, mode: str = "a") -> IO:
def open_file(self, name, mode='a'):
"""Open a file in the configuration directory."""
assert self.directory
if os.sep in name:
@ -115,248 +52,51 @@ class DirectoryBasedController(_BaseController):
os.makedirs(dir_)
assert os.path.isdir(dir_)
return open(os.path.join(self.directory, name), mode)
def create_config(self) -> None:
if not self.directory:
def create_config(self):
"""If there is no config dir, creates it and returns True.
Else returns False."""
if self.directory:
return False
else:
self.directory = tempfile.mkdtemp()
return True
def gen_ssl(self) -> None:
assert self.directory
self.csr_path = os.path.join(self.directory, "ssl.csr")
self.key_path = os.path.join(self.directory, "ssl.key")
self.pem_path = os.path.join(self.directory, "ssl.pem")
self.dh_path = os.path.join(self.directory, "dh.pem")
subprocess.check_output(
[
self.openssl_bin,
"req",
"-new",
"-newkey",
"rsa",
"-nodes",
"-out",
self.csr_path,
"-keyout",
self.key_path,
"-batch",
],
stderr=subprocess.DEVNULL,
)
subprocess.check_output(
[
self.openssl_bin,
"x509",
"-req",
"-in",
self.csr_path,
"-signkey",
self.key_path,
"-out",
self.pem_path,
],
stderr=subprocess.DEVNULL,
)
subprocess.check_output(
[self.openssl_bin, "dhparam", "-out", self.dh_path, "128"],
stderr=subprocess.DEVNULL,
)
def gen_ssl(self):
self.csr_path = os.path.join(self.directory, 'ssl.csr')
self.key_path = os.path.join(self.directory, 'ssl.key')
self.pem_path = os.path.join(self.directory, 'ssl.pem')
self.dh_path = os.path.join(self.directory, 'dh.pem')
subprocess.check_output([self.openssl_bin, 'req', '-new', '-newkey', 'rsa',
'-nodes', '-out', self.csr_path, '-keyout', self.key_path,
'-batch'],
stderr=subprocess.DEVNULL)
subprocess.check_output([self.openssl_bin, 'x509', '-req',
'-in', self.csr_path, '-signkey', self.key_path,
'-out', self.pem_path],
stderr=subprocess.DEVNULL)
subprocess.check_output([self.openssl_bin, 'dhparam',
'-out', self.dh_path, '128'],
stderr=subprocess.DEVNULL)
class BaseClientController(_BaseController):
"""Base controller for IRC clients."""
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
def run(self, hostname, port, auth):
raise NotImplementedError()
class BaseServerController(_BaseController):
"""Base controller for IRC server."""
software_name: str # Class property
_port_wait_interval = 0.1
port_open = False
port: int
hostname: str
services_controller: Optional[BaseServicesController] = None
services_controller_class: Type[BaseServicesController]
extban_mute_char: Optional[str] = None
"""Character used for the 'mute' extban"""
nickserv = "NickServ"
def get_hostname_and_port(self) -> Tuple[str, int]:
return find_hostname_and_port()
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]],
invalid_metadata_keys: Optional[Set[str]],
) -> None:
def run(self, hostname, port, password,
valid_metadata_keys, invalid_metadata_keys):
raise NotImplementedError()
def registerUser(
self,
case: irctest.cases.BaseServerTestCase, # type: ignore
username: str,
password: Optional[str] = None,
**kwargs: Any,
) -> None:
if self.services_controller is not None:
self.services_controller.registerUser(case, username, password, **kwargs)
else:
raise NotImplementedByController("account registration")
def wait_for_port(self) -> None:
def registerUser(self, case, username, password=None):
raise NotImplementedByController('account registration')
def wait_for_port(self):
while not self.port_open:
self.check_is_alive()
time.sleep(self._port_wait_interval)
time.sleep(0.1)
try:
c = socket.create_connection(("localhost", self.port), timeout=1.0)
c.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
# Make sure the server properly processes the disconnect.
# Otherwise, it may still count it in LUSER and fail tests in
# test_lusers.py (eg. this happens with Charybdis 3.5.0)
c.sendall(b"QUIT :chkport\r\n")
data = b""
try:
while b"chkport" not in data and b"ERROR" not in data:
data += c.recv(4096)
time.sleep(0.01)
c.send(b" ") # Triggers BrokenPipeError
except BrokenPipeError:
# ircu2 cuts the connection without a message if registration
# is not complete.
pass
c = socket.create_connection(('localhost', self.port), timeout=1.0)
c.close()
self.port_open = True
except Exception:
except Exception as e:
continue
def wait_for_services(self) -> None:
assert self.services_controller
self.services_controller.wait_for_services()
def terminate(self) -> None:
if self.services_controller is not None:
self.services_controller.terminate() # type: ignore
super().terminate() # type: ignore
def kill(self) -> None:
if self.services_controller is not None:
self.services_controller.kill() # type: ignore
super().kill()
class BaseServicesController(_BaseController):
def __init__(
self,
test_config: TestCaseControllerConfig,
server_controller: BaseServerController,
):
super().__init__(test_config)
self.test_config = test_config
self.server_controller = server_controller
self.services_up = False
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
raise NotImplementedError("BaseServerController.run()")
def wait_for_services(self) -> None:
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkNS", show_io=True)
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()
timeout = time.time() + 5
while True:
c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :HELP")
msgs = self.getServiceResponse(c)
for msg in msgs:
if msg.command == "401":
# NickServ not available yet
pass
elif msg.command == "NOTICE":
# NickServ is available
assert "nickserv" in (msg.prefix or "").lower(), msg
print("breaking")
break
else:
assert False, f"unexpected reply from NickServ: {msg}"
else:
if time.time() > timeout:
raise Exception("Timeout while waiting for NickServ")
continue
# If we're here, it means we broke from the for loop, so NickServ
# is available and we can break again
break
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
self.services_up = True
def getServiceResponse(self, client: Any) -> List[Message]:
"""Wrapper aroung getMessages() that waits longer, because NickServ
is queried asynchronously."""
msgs: List[Message] = []
while not msgs:
time.sleep(0.05)
msgs = client.getMessages()
return msgs
def registerUser(
self,
case: irctest.cases.BaseServerTestCase, # type: ignore
username: str,
password: Optional[str] = None,
**kwargs: Any,
) -> None:
if not case.run_services:
raise ValueError(
"Attempted to register a nick, but `run_services` it not True."
)
if kwargs:
raise NotImplementedByController(", ".join(kwargs))
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"PRIVMSG {self.server_controller.nickserv} "
f":REGISTER {password} foo@example.org",
)
msgs = self.getServiceResponse(case.clients[client])
if self.server_controller.software_name == "inspircd":
assert "900" in {msg.command for msg in msgs}, msgs
assert "NOTICE" in {msg.command for msg in msgs}, msgs
case.sendLine(client, "QUIT")
case.assertDisconnected(client)

File diff suppressed because it is too large Load Diff

View File

@ -1,55 +1,36 @@
import socket
import ssl
import sys
import time
from typing import Any, Callable, List, Optional, Union
from .exceptions import ConnectionClosed, NoMessageException
import socket
from .irc_utils import message_parser
from .exceptions import NoMessageException, ConnectionClosed
class ClientMock:
def __init__(self, name: Any, show_io: bool):
def __init__(self, name, show_io):
self.name = name
self.show_io = show_io
self.inbuffer: List[message_parser.Message] = []
self.inbuffer = []
self.ssl = False
def connect(self, hostname: str, port: int) -> None:
def connect(self, hostname, port):
self.conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# probably useful for test_buffering, as it relies on chunking
# the packets to be useful
self.conn.setsockopt(socket.SOL_TCP, socket.TCP_NODELAY, 1)
self.conn.settimeout(1) # TODO: configurable
self.conn.settimeout(1) # TODO: configurable
self.conn.connect((hostname, port))
if self.show_io:
print("{:.3f} {}: connects to server.".format(time.time(), self.name))
def disconnect(self) -> None:
print('{:.3f} {}: connects to server.'.format(time.time(), self.name))
def disconnect(self):
if self.show_io:
print("{:.3f} {}: disconnects from server.".format(time.time(), self.name))
print('{:.3f} {}: disconnects from server.'.format(time.time(), self.name))
self.conn.close()
def starttls(self) -> None:
assert not self.ssl, "SSL already active."
def starttls(self):
assert not self.ssl, 'SSL already active.'
self.conn = ssl.wrap_socket(self.conn)
self.ssl = True
def getMessages(
self, synchronize: bool = True, assert_get_one: bool = False, raw: bool = False
) -> List[message_parser.Message]:
"""actually returns List[str] in the rare case where raw=True."""
__tracebackhide__ = True # Hide from pytest tracebacks on test failure.
token: Optional[str]
def getMessages(self, synchronize=True, assert_get_one=False):
if synchronize:
token = "synchronize{}".format(time.monotonic())
self.sendLine("PING {}".format(token))
else:
token = None
token = 'synchronize{}'.format(time.monotonic())
self.sendLine('PING {}'.format(token))
got_pong = False
data = b""
data = b''
(self.inbuffer, messages) = ([], self.inbuffer)
conn = self.conn
try:
@ -57,11 +38,12 @@ class ClientMock:
try:
new_data = conn.recv(4096)
except socket.timeout:
if not assert_get_one and not synchronize and data == b"":
if not assert_get_one and not synchronize and data == b'':
# Received nothing
return []
if self.show_io:
print("{:.3f} {}: waiting…".format(time.time(), self.name))
print('{:.3f} waiting…'.format(time.time()))
time.sleep(0.1)
continue
except ConnectionResetError:
raise ConnectionClosed()
@ -70,38 +52,26 @@ class ClientMock:
# Connection closed
raise ConnectionClosed()
data += new_data
if not new_data.endswith(b"\r\n"):
if not new_data.endswith(b'\r\n'):
time.sleep(0.1)
continue
if not synchronize:
got_pong = True
for line in data.decode().split("\r\n"):
for line in data.decode().split('\r\n'):
if line:
if self.show_io:
print(
"{time:.3f}{ssl} S -> {client}: {line}".format(
time=time.time(),
ssl=" (ssl)" if self.ssl else "",
client=self.name,
line=line,
)
)
message = message_parser.parse_message(line)
if message.command == "PONG" and token in message.params:
print('{time:.3f}{ssl} S -> {client}: {line}'.format(
time=time.time(),
ssl=' (ssl)' if self.ssl else '',
client=self.name,
line=line))
message = message_parser.parse_message(line + '\r\n')
if message.command == 'PONG' and \
token in message.params:
got_pong = True
elif (
synchronize
and message.command == "451"
and message.params[1] == "PING"
):
raise ValueError(
"Got '451 * PONG'. Did you forget synchronize=False?"
)
else:
if raw:
messages.append(line) # type: ignore
else:
messages.append(message)
data = b""
messages.append(message)
data = b''
except ConnectionClosed:
if messages:
return messages
@ -109,58 +79,31 @@ class ClientMock:
raise
else:
return messages
def getMessage(
self,
filter_pred: Optional[Callable[[message_parser.Message], bool]] = None,
synchronize: bool = True,
raw: bool = False,
) -> message_parser.Message:
"""Returns str in the rare case where raw=True"""
__tracebackhide__ = True # Hide from pytest tracebacks on test failure.
def getMessage(self, filter_pred=None, synchronize=True):
while True:
if not self.inbuffer:
self.inbuffer = self.getMessages(
synchronize=synchronize, assert_get_one=True, raw=raw
)
synchronize=synchronize, assert_get_one=True)
if not self.inbuffer:
raise NoMessageException()
message = self.inbuffer.pop(0) # TODO: use dequeue
message = self.inbuffer.pop(0) # TODO: use dequeue
if not filter_pred or filter_pred(message):
return message
def sendLine(self, line: Union[str, bytes]) -> None:
if isinstance(line, str):
encoded_line = line.encode()
elif isinstance(line, bytes):
encoded_line = line
else:
raise ValueError(line)
if not encoded_line.endswith(b"\r\n"):
encoded_line += b"\r\n"
def sendLine(self, line):
if not line.endswith('\r\n'):
line += '\r\n'
encoded_line = line.encode()
try:
ret = self.conn.sendall(encoded_line) # type: ignore
ret = self.conn.sendall(encoded_line)
except BrokenPipeError:
raise ConnectionClosed()
if (
sys.version_info <= (3, 6) and self.ssl
): # https://bugs.python.org/issue25951
if sys.version_info <= (3, 6) and self.ssl: # https://bugs.python.org/issue25951
assert ret == len(encoded_line), (ret, repr(encoded_line))
else:
assert ret is None, ret
if self.show_io:
if isinstance(line, str):
escaped_line = line
escaped = ""
else:
escaped_line = repr(line)
escaped = " (escaped)"
print(
"{time:.3f}{escaped}{ssl} {client} -> S: {line}".format(
time=time.time(),
escaped=escaped,
ssl=" (ssl)" if self.ssl else "",
client=self.name,
line=escaped_line.strip("\r\n"),
)
)
print('{time:.3f}{ssl} {client} -> S: {line}'.format(
time=time.time(),
ssl=' (ssl)' if self.ssl else '',
client=self.name,
line=line.strip('\r\n')))

View File

@ -1,278 +0,0 @@
import base64
import pytest
try:
import ecdsa
from ecdsa.util import sigdecode_der
except ImportError:
ecdsa = None
from irctest import authentication, cases, scram
from irctest.irc_utils.message_parser import Message
ECDSA_KEY = """
-----BEGIN EC PARAMETERS-----
BggqhkjOPQMBBw==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
-----END EC PRIVATE KEY-----
"""
CHALLENGE = bytes(range(32))
assert len(CHALLENGE) == 32
class IdentityHash:
def __init__(self, data):
self._data = data
def digest(self):
return self._data
class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testPlain(self):
"""Test PLAIN authentication with correct username/password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="jilles",
password="sesame",
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(
m, Message({}, None, "AUTHENTICATE", ["amlsbGVzAGppbGxlcwBzZXNhbWU="])
)
self.sendLine("900 * * jilles :You are now logged in.")
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testPlainNotAvailable(self):
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
to use PLAIN.
A client implementing sasl-3.2 can give up authentication immediately.
A client not implementing it will try authenticating, and will get
a 904.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="jilles",
password="sesame",
)
m = self.negotiateCapabilities(["sasl=EXTERNAL"], auth=auth)
self.assertEqual(self.acked_capabilities, {"sasl"})
if m == Message({}, None, "CAP", ["END"]):
# IRCv3.2-style, for clients that skip authentication
# when unavailable (eg. Limnoria)
return
elif m.command == "QUIT":
# IRCv3.2-style, for clients that quit when unavailable
# (eg. Sopel)
return
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("904 {} :SASL auth failed".format(self.nick))
m = self.getMessage()
self.assertMessageMatch(m, command="CAP")
@pytest.mark.parametrize("pattern", ["barbaz", "éèà"])
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testPlainLarge(self, pattern):
"""Test the client splits large AUTHENTICATE messages whose payload
is not a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="foo",
password=pattern * 100,
)
authstring = base64.b64encode(
b"\x00".join([b"foo", b"foo", pattern.encode() * 100])
).decode()
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[800:]]))
self.sendLine("900 * * {} :You are now logged in.".format("foo"))
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
@pytest.mark.parametrize("pattern", ["quux", "éè"])
def testPlainLargeMultiple(self, pattern):
"""Test the client splits large AUTHENTICATE messages whose payload
is a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="foo",
password=pattern * 148,
)
authstring = base64.b64encode(
b"\x00".join([b"foo", b"foo", pattern.encode() * 148])
).decode()
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", [authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["+"]))
self.sendLine("900 * * {} :You are now logged in.".format("foo"))
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@pytest.mark.skipif(ecdsa is None, reason="python3-ecdsa is not available")
@cases.OptionalityHelper.skipUnlessHasMechanism("ECDSA-NIST256P-CHALLENGE")
def testEcdsa(self):
"""Test ECDSA authentication."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.ecdsa_nist256p_challenge],
username="jilles",
ecdsa_key=ECDSA_KEY,
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(
m, Message({}, None, "AUTHENTICATE", ["ECDSA-NIST256P-CHALLENGE"])
)
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["amlsbGVz"])) # jilles
self.sendLine(
"AUTHENTICATE {}".format(base64.b64encode(CHALLENGE).decode("ascii"))
)
m = self.getMessage()
self.assertMessageMatch(m, command="AUTHENTICATE")
sk = ecdsa.SigningKey.from_pem(ECDSA_KEY)
vk = sk.get_verifying_key()
signature = base64.b64decode(m.params[0])
try:
vk.verify(
signature, CHALLENGE, hashfunc=IdentityHash, sigdecode=sigdecode_der
)
except ecdsa.BadSignatureError:
raise AssertionError("Bad signature")
self.sendLine("900 * * foo :You are now logged in.")
self.sendLine("903 * :SASL authentication successful")
m = self.negotiateCapabilities(["sasl"], False)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScram(self):
"""Test SCRAM-SHA-256 authentication."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username="jilles",
password="sesame",
)
class PasswdDb:
def get_password(self, *args):
return ("sesame", "plain")
authenticator = scram.SCRAMServerAuthenticator(
"SHA-256", channel_binding=False, password_database=PasswdDb()
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["SCRAM-SHA-256"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
msg = base64.b64decode(m.params[0])
r = authenticator.response(msg)
assert isinstance(r, tuple), r
assert len(r) == 2, r
(properties, response) = r
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
self.assertEqual(properties, {"authzid": None, "username": "jilles"})
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
self.assertEqual(m.params, ["+"], m)
@cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256")
def testScramBadPassword(self):
"""Test SCRAM-SHA-256 authentication with a bad password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username="jilles",
password="sesame",
)
class PasswdDb:
def get_password(self, *args):
return ("notsesame", "plain")
authenticator = scram.SCRAMServerAuthenticator(
"SHA-256", channel_binding=False, password_database=PasswdDb()
)
m = self.negotiateCapabilities(["sasl"], auth=auth)
self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["SCRAM-SHA-256"]))
self.sendLine("AUTHENTICATE +")
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine("AUTHENTICATE :" + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, "AUTHENTICATE", m)
msg = base64.b64decode(m.params[0])
with self.assertRaises(scram.NotAuthorizedException):
authenticator.response(msg)
class Irc302SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testPlainNotAvailable(self):
"""Test the client does not try to authenticate using a mechanism the
server does not advertise.
Actually, this is optional."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username="jilles",
password="sesame",
)
m = self.negotiateCapabilities(["sasl=EXTERNAL"], auth=auth)
self.assertEqual(self.acked_capabilities, {"sasl"})
if m.command == "QUIT":
# Some clients quit when it can't authenticate (eg. Sopel)
pass
else:
# Others will just skip authentication (eg. Limnoria)
self.assertEqual(m, Message({}, None, "CAP", ["END"]))

View File

@ -1,15 +1,14 @@
from irctest import cases
from irctest.irc_utils.message_parser import Message
class CapTestCase(cases.BaseClientTestCase):
@cases.mark_specifications("IRCv3")
class CapTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper):
@cases.SpecificationSelector.requiredBySpecification('IRCv3.1', 'IRCv3.2')
def testSendCap(self):
"""Send CAP LS 302 and read the result."""
self.readCapLs()
@cases.mark_specifications("IRCv3")
@cases.SpecificationSelector.requiredBySpecification('IRCv3.1', 'IRCv3.2')
def testEmptyCapLs(self):
"""Empty result to CAP LS. Client should send CAP END."""
m = self.negotiateCapabilities([])
self.assertEqual(m, Message({}, None, "CAP", ["END"]))
self.assertEqual(m, Message([], None, 'CAP', ['END']))

View File

@ -0,0 +1,257 @@
import hashlib
import ecdsa
from ecdsa.util import sigencode_der, sigdecode_der
import base64
import pyxmpp2_scram as scram
from irctest import cases
from irctest import authentication
from irctest.irc_utils.message_parser import Message
ECDSA_KEY = """
-----BEGIN EC PARAMETERS-----
BggqhkjOPQMBBw==
-----END EC PARAMETERS-----
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIIJueQ3W2IrGbe9wKdOI75yGS7PYZSj6W4tg854hlsvmoAoGCCqGSM49
AwEHoUQDQgAEAZmaVhNSMmV5r8FXPvKuMnqDKyIA9pDHN5TNMfiF3mMeikGgK10W
IRX9cyi2wdYg9mUUYyh9GKdBCYHGUJAiCA==
-----END EC PRIVATE KEY-----
"""
CHALLENGE = bytes(range(32))
assert len(CHALLENGE) == 32
class IdentityHash:
def __init__(self, data):
self._data = data
def digest(self):
return self._data
class SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlain(self):
"""Test PLAIN authentication with correct username/password."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='jilles',
password='sesame',
)
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
['amlsbGVzAGppbGxlcwBzZXNhbWU=']))
self.sendLine('900 * * jilles :You are now logged in.')
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainNotAvailable(self):
"""`sasl=EXTERNAL` is advertized, whereas the client is configured
to use PLAIN.
A client implementing sasl-3.2 can give up authentication immediately.
A client not implementing it will try authenticating, and will get
a 904.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='jilles',
password='sesame',
)
m = self.negotiateCapabilities(['sasl=EXTERNAL'], auth=auth)
self.assertEqual(self.acked_capabilities, {'sasl'})
if m == Message([], None, 'CAP', ['END']):
# IRCv3.2-style
return
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('904 {} :SASL auth failed'.format(self.nick))
m = self.getMessage()
self.assertMessageEqual(m, command='CAP')
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainLarge(self):
"""Test the client splits large AUTHENTICATE messages whose payload
is not a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='foo',
password='bar'*200,
)
authstring = base64.b64encode(b'\x00'.join(
[b'foo', b'foo', b'bar'*200])).decode()
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[800:]]))
self.sendLine('900 * * {} :You are now logged in.'.format('foo'))
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainLargeMultiple(self):
"""Test the client splits large AUTHENTICATE messages whose payload
is a multiple of 400.
<http://ircv3.net/specs/extensions/sasl-3.1.html#the-authenticate-command>
"""
# TODO: authzid is optional
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='foo',
password='quux'*148,
)
authstring = base64.b64encode(b'\x00'.join(
[b'foo', b'foo', b'quux'*148])).decode()
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['PLAIN']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[0:400]]), m)
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
[authstring[400:800]]))
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
['+']))
self.sendLine('900 * * {} :You are now logged in.'.format('foo'))
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('ECDSA-NIST256P-CHALLENGE')
def testEcdsa(self):
"""Test ECDSA authentication.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.ecdsa_nist256p_challenge],
username='jilles',
ecdsa_key=ECDSA_KEY,
)
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['ECDSA-NIST256P-CHALLENGE']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m, Message([], None, 'AUTHENTICATE',
['amlsbGVz'])) # jilles
self.sendLine('AUTHENTICATE {}'.format(base64.b64encode(CHALLENGE).decode('ascii')))
m = self.getMessage()
self.assertMessageEqual(m, command='AUTHENTICATE')
sk = ecdsa.SigningKey.from_pem(ECDSA_KEY)
vk = sk.get_verifying_key()
signature = base64.b64decode(m.params[0])
try:
vk.verify(signature, CHALLENGE, hashfunc=IdentityHash, sigdecode=sigdecode_der)
except ecdsa.BadSignatureError:
raise AssertionError('Bad signature')
self.sendLine('900 * * foo :You are now logged in.')
self.sendLine('903 * :SASL authentication successful')
m = self.negotiateCapabilities(['sasl'], False)
self.assertEqual(m, Message([], None, 'CAP', ['END']))
@cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256')
def testScram(self):
"""Test SCRAM-SHA-256 authentication.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username='jilles',
password='sesame',
)
class PasswdDb:
def get_password(self, *args):
return ('sesame', 'plain')
authenticator = scram.SCRAMServerAuthenticator('SHA-256',
channel_binding=False, password_database=PasswdDb())
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['SCRAM-SHA-256']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
msg = base64.b64decode(m.params[0])
r = authenticator.response(msg)
assert isinstance(r, tuple), r
assert len(r) == 2, r
(properties, response) = r
self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode())
self.assertEqual(properties, {'authzid': None, 'username': 'jilles'})
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
self.assertEqual(m.params, ['+'], m)
@cases.OptionalityHelper.skipUnlessHasMechanism('SCRAM-SHA-256')
def testScramBadPassword(self):
"""Test SCRAM-SHA-256 authentication with a bad password.
"""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.scram_sha_256],
username='jilles',
password='sesame',
)
class PasswdDb:
def get_password(self, *args):
return ('notsesame', 'plain')
authenticator = scram.SCRAMServerAuthenticator('SHA-256',
channel_binding=False, password_database=PasswdDb())
m = self.negotiateCapabilities(['sasl'], auth=auth)
self.assertEqual(m, Message([], None, 'AUTHENTICATE', ['SCRAM-SHA-256']))
self.sendLine('AUTHENTICATE +')
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
client_first = base64.b64decode(m.params[0])
response = authenticator.start(properties={}, initial_response=client_first)
assert isinstance(response, bytes), response
self.sendLine('AUTHENTICATE :' + base64.b64encode(response).decode())
m = self.getMessage()
self.assertEqual(m.command, 'AUTHENTICATE', m)
msg = base64.b64decode(m.params[0])
with self.assertRaises(scram.NotAuthorizedException):
authenticator.response(msg)
class Irc302SaslTestCase(cases.BaseClientTestCase, cases.ClientNegociationHelper,
cases.OptionalityHelper):
@cases.OptionalityHelper.skipUnlessHasMechanism('PLAIN')
def testPlainNotAvailable(self):
"""Test the client does not try to authenticate using a mechanism the
server does not advertise.
Actually, this is optional."""
auth = authentication.Authentication(
mechanisms=[authentication.Mechanisms.plain],
username='jilles',
password='sesame',
)
m = self.negotiateCapabilities(['sasl=EXTERNAL'], auth=auth)
self.assertEqual(self.acked_capabilities, {'sasl'})
self.assertEqual(m, Message([], None, 'CAP', ['END']))

View File

@ -1,11 +1,10 @@
import socket
import ssl
import pytest
from irctest import cases, runner, tls
from irctest import tls
from irctest import cases
from irctest.exceptions import ConnectionClosed
from irctest.patma import ANYSTR
from irctest.irc_utils.message_parser import Message
BAD_CERT = """
-----BEGIN CERTIFICATE-----
@ -61,7 +60,7 @@ h4WuPDAI4yh24GjaCZYGR5xcqPCy5CNjMLxdA7HsP+Gcr3eY5XS7noBrbC6IaA0j
-----END PRIVATE KEY-----
"""
GOOD_FINGERPRINT = "E1EE6DE2DBC0D43E3B60407B5EE389AEC9D2C53178E0FB14CD51C3DFD544AA2B"
GOOD_FINGERPRINT = 'E1EE6DE2DBC0D43E3B60407B5EE389AEC9D2C53178E0FB14CD51C3DFD544AA2B'
GOOD_CERT = """
-----BEGIN CERTIFICATE-----
MIIDXTCCAkWgAwIBAgIJAKtD9XMC1R0vMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV
@ -116,78 +115,83 @@ El9iqRlAhgqaXc4Iz/Zxxhs=
-----END PRIVATE KEY-----
"""
class TlsTestCase(cases.BaseClientTestCase):
def testTrustedCertificate(self):
tls_config = tls.TlsConfig(enable=True, trusted_fingerprints=[GOOD_FINGERPRINT])
def testTrustedCertificate(self):
tls_config = tls.TlsConfig(
enable=True,
trusted_fingerprints=[GOOD_FINGERPRINT])
(hostname, port) = self.server.getsockname()
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
self.getMessage()
m = self.getMessage()
def testUntrustedCertificate(self):
tls_config = tls.TlsConfig(enable=True, trusted_fingerprints=[GOOD_FINGERPRINT])
def testUntrustedCertificate(self):
tls_config = tls.TlsConfig(
enable=True,
trusted_fingerprints=[GOOD_FINGERPRINT])
(hostname, port) = self.server.getsockname()
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
self.acceptClient(tls_cert=BAD_CERT, tls_key=BAD_KEY)
with self.assertRaises((ConnectionClosed, ConnectionResetError)):
self.getMessage()
m = self.getMessage()
class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
def setUp(self):
super().setUp()
self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.insecure_server.bind(("", 0)) # Bind any free port
self.insecure_server.bind(('', 0)) # Bind any free port
self.insecure_server.listen(1)
def tearDown(self):
self.insecure_server.close()
super().tearDown()
@cases.mark_capabilities("sts")
@pytest.mark.parametrize("portOnSecure", [False, True])
def testSts(self, portOnSecure):
if not self.controller.supports_sts:
raise runner.CapabilityNotSupported("sts")
@cases.OptionalityHelper.skipUnlessSupportsCapability('sts')
def testSts(self):
tls_config = tls.TlsConfig(
enable=False, trusted_fingerprints=[GOOD_FINGERPRINT]
)
enable=False,
trusted_fingerprints=[GOOD_FINGERPRINT])
# Connect client to insecure server
(hostname, port) = self.insecure_server.getsockname()
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
self.acceptClient(server=self.insecure_server)
# Send STS policy to client
self.assertMessageMatch(
self.getMessage(),
command="CAP",
params=["LS", ANYSTR],
fail_msg="First message is not CAP LS: {got}",
)
self.sendLine("CAP * LS :sts=port={}".format(self.server.getsockname()[1]))
m = self.getMessage()
self.assertEqual(m.command, 'CAP',
'First message is not CAP LS.')
self.assertEqual(m.params[0], 'LS',
'First message is not CAP LS.')
self.sendLine('CAP * LS :sts=port={}'.format(self.server.getsockname()[1]))
# "If the client is not already connected securely to the server
# at the requested hostname, it MUST close the insecure connection
# and reconnect securely on the stated port."
self.acceptClient(tls_cert=GOOD_CERT, tls_key=GOOD_KEY)
# Send the STS policy, over secure connection this time.
if portOnSecure:
# Should be ignored
self.sendLine("CAP * LS :sts=duration=10,port=12345")
else:
self.sendLine("CAP * LS :sts=duration=10")
# Send the STS policy, over secure connection this time
self.sendLine('CAP * LS :sts=duration=10,port={}'.format(
self.server.getsockname()[1]))
# Make the client reconnect. It should reconnect to the secure server.
self.sendLine("ERROR :closing link")
self.sendLine('ERROR :closing link')
self.acceptClient()
# Kill the client
@ -195,31 +199,34 @@ class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper):
# Run the client, still configured to connect to the insecure server
self.controller.run(
hostname=hostname, port=port, auth=None, tls_config=tls_config
)
hostname=hostname,
port=port,
auth=None,
tls_config=tls_config,
)
# The client should remember the STS policy and connect to the secure
# server
self.acceptClient()
@cases.mark_capabilities("sts")
@cases.OptionalityHelper.skipUnlessSupportsCapability('sts')
def testStsInvalidCertificate(self):
if not self.controller.supports_sts:
raise runner.CapabilityNotSupported("sts")
# Connect client to insecure server
(hostname, port) = self.insecure_server.getsockname()
self.controller.run(hostname=hostname, port=port, auth=None)
self.controller.run(
hostname=hostname,
port=port,
auth=None,
)
self.acceptClient(server=self.insecure_server)
# Send STS policy to client
self.assertMessageMatch(
self.getMessage(),
command="CAP",
params=["LS", ANYSTR],
fail_msg="First message is not CAP LS: {got}",
)
self.sendLine("CAP * LS :sts=port={}".format(self.server.getsockname()[1]))
m = self.getMessage()
self.assertEqual(m.command, 'CAP',
'First message is not CAP LS.')
self.assertEqual(m.params[0], 'LS',
'First message is not CAP LS.')
self.sendLine('CAP * LS :sts=port={}'.format(self.server.getsockname()[1]))
# The client will reconnect to the TLS port. Unfortunately, it does
# not trust its fingerprint.

View File

@ -1,189 +0,0 @@
import os
import shutil
import subprocess
from typing import Any, Optional, Type
from irctest import cases, runner
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
from irctest.client_mock import ClientMock
from irctest.irc_utils.sasl import sasl_plain_blob
TEMPLATE_CONFIG = """
serverinfo {{
name = "services.example.org"
description = "Anope IRC Services"
numeric = "00A"
pid = "services.pid"
motd = "conf/empty_file"
}}
uplink {{
host = "{server_hostname}"
port = {server_port}
password = "password"
}}
module {{
name = "{protocol}"
}}
networkinfo {{
networkname = "testnet"
nicklen = 31
userlen = 10
hostlen = 64
chanlen = 32
vhost_chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-"
}}
mail {{
usemail = no
}}
/************************
* NickServ:
*/
service {{
nick = "NickServ"
user = "services"
host = "services.host"
gecos = "Nickname Registration Service"
}}
module {{
name = "nickserv"
client = "NickServ"
forceemail = no
passlen = 1000 # Some tests need long passwords
}}
command {{ service = "NickServ"; name = "HELP"; command = "generic/help"; }}
module {{
name = "ns_register"
registration = "none"
}}
command {{ service = "NickServ"; name = "REGISTER"; command = "nickserv/register"; }}
/************************
* HostServ:
*/
service {{
nick = "HostServ"
user = "services"
host = "services.host"
gecos = "vHost Service"
}}
module {{
name = "hostserv"
client = "HostServ"
}}
module {{ name = "hs_set" }}
command {{ service = "HostServ"; name = "SET"; command = "hostserv/set"; }}
/************************
* Misc:
*/
options {{
casemap = "ascii"
readtimeout = 5s
warningtimeout = 4h
}}
module {{ name = "m_sasl" }}
module {{ name = "enc_sha256" }}
module {{ name = "ns_cert" }}
"""
class AnopeController(BaseServicesController, DirectoryBasedController):
"""Collaborator for server controllers that rely on Anope"""
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
self.create_config()
assert protocol in (
"bahamut",
"inspircd3",
"charybdis",
"hybrid",
"plexus",
"unreal4",
"ngircd",
)
with self.open_file("conf/services.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
protocol=protocol,
server_hostname=server_hostname,
server_port=server_port,
)
)
with self.open_file("conf/empty_file") as fd:
pass
assert self.directory
# Config and code need to be in the same directory, *obviously*
os.symlink(
os.path.join(
os.path.dirname(shutil.which("services")), "..", "lib" # type: ignore
),
os.path.join(self.directory, "lib"),
)
self.proc = subprocess.Popen(
[
"services",
"-n", # don't fork
"--config=services.conf", # can't be an absolute path
# "--logdir",
# f"/tmp/services-{server_port}.log",
],
cwd=self.directory,
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
)
def registerUser(
self,
case: cases.BaseServerTestCase, # type: ignore
username: str,
password: Optional[str] = None,
vhost: Optional[str] = None,
**kwargs: Any,
) -> None:
super().registerUser(case, username, password)
if vhost:
if not password:
raise runner.NotImplementedByController(
"vHost for users with no password"
)
c = ClientMock(name="setVhost", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("CAP REQ :sasl")
c.sendLine("NICK " + username)
c.sendLine("USER r e g :user")
while c.getMessage(synchronize=False).command != "CAP":
pass
c.sendLine("AUTHENTICATE PLAIN")
while c.getMessage(synchronize=False).command != "AUTHENTICATE":
pass
c.sendLine(sasl_plain_blob(username, password))
c.sendLine("CAP END")
while c.getMessage(synchronize=False).command != "001":
pass
c.getMessages()
c.sendLine(f"PRIVMSG HostServ :SET {username} {vhost}")
self.getServiceResponse(c)
def get_irctest_controller_class() -> Type[AnopeController]:
return AnopeController

View File

@ -1,110 +0,0 @@
import os
import subprocess
from typing import Optional, Type
import irctest
from irctest.basecontrollers import BaseServicesController, DirectoryBasedController
import irctest.cases
import irctest.runner
TEMPLATE_CONFIG = """
loadmodule "modules/protocol/{protocol}";
loadmodule "modules/backend/opensex";
loadmodule "modules/crypto/pbkdf2";
loadmodule "modules/nickserv/main";
loadmodule "modules/nickserv/cert";
loadmodule "modules/nickserv/register";
loadmodule "modules/nickserv/verify";
loadmodule "modules/saslserv/main";
loadmodule "modules/saslserv/authcookie";
#loadmodule "modules/saslserv/ecdh-x25519-challenge";
loadmodule "modules/saslserv/ecdsa-nist256p-challenge";
loadmodule "modules/saslserv/external";
loadmodule "modules/saslserv/plain";
#loadmodule "modules/saslserv/scram";
serverinfo {{
name = "services.example.org";
desc = "Atheme IRC Services";
numeric = "00A";
netname = "testnet";
adminname = "no admin";
adminemail = "no-admin@example.org";
registeremail = "registration@example.org";
auth = none; // Disable email check
}};
general {{
commit_interval = 5;
}};
uplink "My.Little.Server" {{
host = "{server_hostname}";
port = {server_port};
send_password = "password";
receive_password = "password";
}};
saslserv {{
nick = "SaslServ";
}};
"""
class AthemeController(BaseServicesController, DirectoryBasedController):
"""Mixin for server controllers that rely on Atheme"""
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
self.create_config()
if protocol == "inspircd3":
# That's the name used by Anope
protocol = "inspircd"
assert protocol in ("bahamut", "inspircd", "charybdis", "unreal4", "ngircd")
with self.open_file("services.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
protocol=protocol,
server_hostname=server_hostname,
server_port=server_port,
)
)
assert self.directory
self.proc = subprocess.Popen(
[
"atheme-services",
"-n", # don't fork
"-c",
os.path.join(self.directory, "services.conf"),
"-l",
f"/tmp/services-{server_port}.log",
"-p",
os.path.join(self.directory, "services.pid"),
"-D",
self.directory,
],
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
)
def registerUser(
self,
case: irctest.cases.BaseServerTestCase,
username: str,
password: Optional[str] = None,
) -> None:
assert password
if len(password.encode()) > 288:
# It's hardcoded at compile-time :(
# https://github.com/atheme/atheme/blob/4fa0e03bd3ce2cb6041a339f308616580c5aac29/include/atheme/constants.h#L51
raise irctest.runner.NotImplementedByController("Passwords over 288 bytes")
super().registerUser(case, username, password)
def get_irctest_controller_class() -> Type[AthemeController]:
return AthemeController

View File

@ -1,163 +0,0 @@
import os
import shutil
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
TEMPLATE_CONFIG = """
global {{
name My.Little.Server; # IRC name of the server
info "located on earth"; # A short info line
}};
options {{
network_name unconfigured;
allow_split_ops; # Give ops in empty channels
services_name services.example.org;
// if you need to link more than 1 server, uncomment the following line
servtype hub;
}};
/* where to listen for connections */
port {{
port {port};
bind {hostname};
}};
/* allow clients to connect */
allow {{
host *@*; # Allow anyone
class users; # Place them in the users class
flags T; # No throttling
{password_field}
}};
/* connection class for users */
class {{
name users; # Class name
maxusers 100; # Maximum connections
pingfreq 1000; # Check idle connections every N seconds
maxsendq 100000; # 100KB send buffer limit
}};
/* for services */
super {{
"services.example.org";
}};
/* class for services */
class {{
name services;
pingfreq 60; # Idle check every minute
maxsendq 5000000; # 5MB backlog buffer
}};
/* our services */
connect {{
name services.example.org;
host *@127.0.0.1; # unfortunately, masks aren't allowed here
apasswd password;
cpasswd password;
class services;
}};
oper {{
name operuser;
host *@*;
passwd operpassword;
access *Aa;
class users;
}};
"""
class BahamutController(BaseServerController, DirectoryBasedController):
software_name = "Bahamut"
supported_sasl_mechanisms: Set[str] = set()
supports_sts = False
nickserv = "NickServ@services.example.org"
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(unused_hostname, unused_port) = find_hostname_and_port()
(services_hostname, services_port) = find_hostname_and_port()
password_field = "passwd {};".format(password) if password else ""
self.gen_ssl()
assert self.directory
# they are hardcoded... thankfully Bahamut reads them from the CWD.
shutil.copy(self.pem_path, os.path.join(self.directory, "ircd.crt"))
shutil.copy(self.key_path, os.path.join(self.directory, "ircd.key"))
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
password_field=password_field,
# key_path=self.key_path,
# pem_path=self.pem_path,
)
)
self.proc = subprocess.Popen(
[
# "strace", "-f", "-e", "file",
"ircd",
"-t", # don't fork
"-f",
os.path.join(self.directory, "server.conf"),
],
# stdout=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="bahamut",
server_hostname=hostname,
server_port=port,
)
def get_irctest_controller_class() -> Type[BahamutController]:
return BahamutController

View File

@ -1,97 +0,0 @@
import os
import subprocess
from typing import Optional, Set
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
TEMPLATE_SSL_CONFIG = """
ssl_private_key = "{key_path}";
ssl_cert = "{pem_path}";
ssl_dh_params = "{dh_path}";
"""
class BaseHybridController(BaseServerController, DirectoryBasedController):
"""A base class for all controllers derived from ircd-hybrid (Hybrid itself,
Charybdis, Solanum, ...)"""
binary_name: str
services_protocol: str
supports_sts = False
extban_mute_char = None
template_config: str
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(services_hostname, services_port) = find_hostname_and_port()
password_field = 'password = "{}";'.format(password) if password else ""
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path, pem_path=self.pem_path, dh_path=self.dh_path
)
else:
ssl_config = ""
with self.open_file("server.conf") as fd:
fd.write(
(self.template_config).format(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
)
)
assert self.directory
self.proc = subprocess.Popen(
[
self.binary_name,
"-foreground",
"-configfile",
os.path.join(self.directory, "server.conf"),
"-pidfile",
os.path.join(self.directory, "server.pid"),
],
# stderr=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol=self.services_protocol,
server_hostname=hostname,
server_port=services_port,
)

View File

@ -1,6 +1,13 @@
from typing import Type
import os
import time
import shutil
import tempfile
import subprocess
from .base_hybrid import BaseHybridController
from irctest import client_mock
from irctest import authentication
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
serverinfo {{
@ -9,79 +16,73 @@ serverinfo {{
description = "test server";
{ssl_config}
}};
general {{
throttle_count = 100; # We need to connect lots of clients quickly
# disable throttling for LIST and similar:
pace_wait_simple = 0 second;
pace_wait = 0 second;
sasl_service = "SaslServ";
}};
class "server" {{
ping_time = 5 minutes;
connectfreq = 5 minutes;
}};
listen {{
defer_accept = yes;
host = "{hostname}";
port = {port};
port = {services_port};
}};
auth {{
user = "*";
flags = exceed_limit;
{password_field}
}};
channel {{
disable_local_channels = no;
no_create_on_split = no;
no_join_on_split = no;
displayed_usercount = 0;
}};
"""
connect "services.example.org" {{
host = "localhost"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
accept_password = "password";
class = "server";
flags = topicburst;
}};
service {{
name = "services.example.org";
}};
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,
usermode:servnotice, auspex:oper, auspex:hostname, auspex:umodes, auspex:cmodes,
oper:admin, oper:die, oper:rehash, oper:spy, oper:grant;
}};
operator "operuser" {{
user = "*@*";
password = "operpassword";
privset = "omnioper";
flags = ~encrypted;
}};
TEMPLATE_SSL_CONFIG = """
ssl_private_key = "{key_path}";
ssl_cert = "{pem_path}";
ssl_dh_params = "{dh_path}";
"""
class CharybdisController(BaseHybridController):
software_name = "Charybdis"
binary_name = "charybdis"
services_protocol = "charybdis"
class CharybdisController(BaseServerController, DirectoryBasedController):
software_name = 'Charybdis'
supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
def create_config(self):
super().create_config()
with self.open_file('server.conf'):
pass
supported_sasl_mechanisms = {"PLAIN"}
template_config = TEMPLATE_CONFIG
def run(self, hostname, port, password=None, ssl=False,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
'Defining valid and invalid METADATA keys.')
assert self.proc is None
self.create_config()
self.port = port
password_field = 'password = "{}";'.format(password) if password else ''
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path,
pem_path=self.pem_path,
dh_path=self.dh_path,
)
else:
ssl_config = ''
with self.open_file('server.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
ssl_config=ssl_config,
))
self.proc = subprocess.Popen(['charybdis', '-foreground',
'-configfile', os.path.join(self.directory, 'server.conf'),
'-pidfile', os.path.join(self.directory, 'server.pid'),
],
stderr=subprocess.DEVNULL
)
def get_irctest_controller_class() -> Type[CharybdisController]:
def get_irctest_controller_class():
return CharybdisController

View File

@ -1,305 +0,0 @@
import copy
import json
import os
import subprocess
from typing import Any, Dict, Optional, Set, Type, Union
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.cases import BaseServerTestCase
BASE_CONFIG = {
"network": {"name": "ErgoTest"},
"server": {
"name": "My.Little.Server",
"listeners": {},
"max-sendq": "16k",
"connection-limits": {
"enabled": True,
"cidr-len-ipv4": 32,
"cidr-len-ipv6": 64,
"ips-per-subnet": 1,
"exempted": ["localhost"],
},
"connection-throttling": {
"enabled": True,
"cidr-len-ipv4": 32,
"cidr-len-ipv6": 64,
"ips-per-subnet": 16,
"duration": "10m",
"max-connections": 1,
"ban-duration": "10m",
"ban-message": "Try again later",
"exempted": ["localhost"],
},
"lookup-hostnames": False,
"enforce-utf8": True,
"relaymsg": {"enabled": True, "separators": "/", "available-to-chanops": True},
"compatibility": {
"allow-truncation": False,
},
},
"accounts": {
"authentication-enabled": True,
"advertise-scram": True,
"multiclient": {
"allowed-by-default": True,
"enabled": True,
"always-on": "disabled",
},
"registration": {
"bcrypt-cost": 4,
"enabled": True,
"enabled-callbacks": ["none"],
"verify-timeout": "120h",
},
"nick-reservation": {
"enabled": True,
"method": "strict",
},
},
"channels": {"registration": {"enabled": True}},
"datastore": {"path": None},
"limits": {
"awaylen": 200,
"chan-list-modes": 60,
"channellen": 64,
"kicklen": 390,
"linelen": {"rest": 2048},
"monitor-entries": 100,
"nicklen": 32,
"topiclen": 390,
"whowas-entries": 100,
"multiline": {"max-bytes": 4096, "max-lines": 32},
},
"history": {
"enabled": True,
"channel-length": 128,
"client-length": 128,
"chathistory-maxmessages": 100,
"tagmsg-storage": {
"default": False,
"whitelist": ["+draft/persist", "+persist"],
},
},
"oper-classes": {
"server-admin": {
"title": "Server Admin",
"capabilities": [
"oper:local_kill",
"oper:local_ban",
"oper:local_unban",
"nofakelag",
"oper:remote_kill",
"oper:remote_ban",
"oper:remote_unban",
"oper:rehash",
"oper:die",
"accreg",
"sajoin",
"samode",
"vhosts",
"chanreg",
"relaymsg",
],
}
},
"opers": {
"operuser": {
"class": "server-admin",
"whois-line": "is a server admin",
# "operpassword"
"password": "$2a$04$bKb6k5A6yuFA2wx.iJtxcuT2dojHQAjHd5ZPK/I2sjJml7p4spxjG",
}
},
}
LOGGING_CONFIG = {"logging": [{"method": "stderr", "level": "debug", "type": "*"}]}
def hash_password(password: Union[str, bytes]) -> str:
if isinstance(password, str):
password = password.encode("utf-8")
# simulate entry of password and confirmation:
input_ = password + b"\n" + password + b"\n"
p = subprocess.Popen(
["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE
)
out, _ = p.communicate(input_)
return out.decode("utf-8")
class ErgoController(BaseServerController, DirectoryBasedController):
software_name = "Ergo"
_port_wait_interval = 0.01
supported_sasl_mechanisms = {"PLAIN", "SCRAM-SHA-256"}
supports_sts = True
extban_mute_char = "m"
def create_config(self) -> None:
super().create_config()
with self.open_file("ircd.yaml"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
config: Optional[Any] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
self.create_config()
if config is None:
config = copy.deepcopy(BASE_CONFIG)
assert self.directory
enable_chathistory = self.test_config.chathistory
enable_roleplay = self.test_config.ergo_roleplay
if enable_chathistory or enable_roleplay:
config = self.addMysqlToConfig(config)
if enable_roleplay:
config["roleplay"] = {"enabled": True}
if self.test_config.ergo_config:
self.test_config.ergo_config(config)
self.port = port
bind_address = "127.0.0.1:%s" % (port,)
listener_conf = None # plaintext
if ssl:
self.key_path = os.path.join(self.directory, "ssl.key")
self.pem_path = os.path.join(self.directory, "ssl.pem")
listener_conf = {"tls": {"cert": self.pem_path, "key": self.key_path}}
config["server"]["listeners"][bind_address] = listener_conf # type: ignore
config["datastore"]["path"] = os.path.join( # type: ignore
self.directory, "ircd.db"
)
if password is not None:
config["server"]["password"] = hash_password(password) # type: ignore
assert self.proc is None
self._config_path = os.path.join(self.directory, "server.yml")
self._config = config
self._write_config()
subprocess.call(["ergo", "initdb", "--conf", self._config_path, "--quiet"])
subprocess.call(["ergo", "mkcerts", "--conf", self._config_path, "--quiet"])
self.proc = subprocess.Popen(
["ergo", "run", "--conf", self._config_path, "--quiet"]
)
def wait_for_services(self) -> None:
# Nothing to wait for, they start at the same time as Ergo.
pass
def registerUser(
self,
case: BaseServerTestCase,
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
# controllers.
raise ValueError(
"Attempted to register a nick, but `run_services` it not True."
)
client = case.addClient(show_io=False)
case.sendLine(client, "CAP LS 302")
case.sendLine(client, "NICK " + username)
case.sendLine(client, "USER r e g :user")
case.sendLine(client, "CAP END")
while case.getRegistrationMessage(client).command != "001":
pass
case.getMessages(client)
assert password
case.sendLine(client, "NS REGISTER " + password)
msg = case.getMessage(client)
assert msg.params == [username, "Account created"]
case.sendLine(client, "QUIT")
case.assertDisconnected(client)
def _write_config(self) -> None:
with open(self._config_path, "w") as fd:
json.dump(self._config, fd)
def baseConfig(self) -> Dict:
return copy.deepcopy(BASE_CONFIG)
def getConfig(self) -> Dict:
return copy.deepcopy(self._config)
def addLoggingToConfig(self, config: Optional[Dict] = None) -> Dict:
if config is None:
config = self.baseConfig()
config.update(LOGGING_CONFIG)
return config
def addMysqlToConfig(self, config: Optional[Dict] = None) -> Dict:
mysql_password = os.getenv("MYSQL_PASSWORD")
if config is None:
config = self.baseConfig()
if not mysql_password:
return config
config["datastore"]["mysql"] = {
"enabled": True,
"host": "localhost",
"user": "ergo",
"password": mysql_password,
"history-database": "ergo_history",
"timeout": "3s",
}
config["accounts"]["multiclient"] = {
"enabled": True,
"allowed-by-default": True,
"always-on": "disabled",
}
config["history"]["persistent"] = {
"enabled": True,
"unregistered-channels": True,
"registered-channels": "opt-out",
"direct-messages": "opt-out",
}
return config
def rehash(self, case: BaseServerTestCase, config: Dict) -> None:
self._config = config
self._write_config()
client = "operator_for_rehash"
case.connectClient(nick=client, name=client)
case.sendLine(client, "OPER operuser operpassword")
case.sendLine(client, "REHASH")
case.getMessages(client)
case.sendLine(client, "QUIT")
case.assertDisconnected(client)
def enable_debug_logging(self, case: BaseServerTestCase) -> None:
config = self.getConfig()
config.update(LOGGING_CONFIG)
self.rehash(case, config)
def get_irctest_controller_class() -> Type[ErgoController]:
return ErgoController

View File

@ -1,50 +0,0 @@
import os
from typing import Optional, Set, Tuple, Type
from irctest.basecontrollers import BaseServerController
class ExternalServerController(BaseServerController):
"""Dummy controller that doesn't run a server.
Instead, it allows connecting to servers ran outside irctest."""
software_name = "unknown external server"
supported_sasl_mechanisms = set(
os.environ.get("IRCTEST_SERVER_SASL_MECHS", "").split()
)
def check_is_alive(self) -> None:
pass
def kill_proc(self) -> None:
pass
def wait_for_port(self) -> None:
pass
def get_hostname_and_port(self) -> Tuple[str, int]:
hostname = os.environ.get("IRCTEST_SERVER_HOSTNAME")
port = os.environ.get("IRCTEST_SERVER_PORT")
if not hostname or not port:
raise RuntimeError(
"Please set IRCTEST_SERVER_HOSTNAME and IRCTEST_SERVER_PORT."
)
return (hostname, int(port))
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
) -> None:
pass
def get_irctest_controller_class() -> Type[ExternalServerController]:
return ExternalServerController

View File

@ -1,38 +1,45 @@
import subprocess
from typing import Optional, Type
from irctest import authentication, tls
from irctest.basecontrollers import (
BaseClientController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.basecontrollers import BaseClientController, NotImplementedByController
class GircController(BaseClientController):
software_name = 'gIRC'
supported_sasl_mechanisms = ['PLAIN']
supported_capabilities = set() # Not exhaustive
class GircController(BaseClientController, DirectoryBasedController):
software_name = "gIRC"
supported_sasl_mechanisms = {"PLAIN"}
def __init__(self):
super().__init__()
self.directory = None
self.proc = None
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
def kill(self):
if self.proc:
self.proc.terminate()
try:
self.proc.wait(5)
except subprocess.TimeoutExpired:
self.proc.kill()
self.proc = None
def __del__(self):
if self.proc:
self.proc.kill()
if self.directory:
self.directory.cleanup()
def run(self, hostname, port, auth, tls_config):
if tls_config:
print(tls_config)
raise NotImplementedByController("TLS options")
args = ["--host", hostname, "--port", str(port), "--quiet"]
raise NotImplementedByController('TLS options')
args = ['--host', hostname, '--port', str(port), '--quiet']
if auth and auth.username and auth.password:
args += ["--sasl-name", auth.username]
args += ["--sasl-pass", auth.password]
args += ["--sasl-fail-is-ok"]
args += ['--sasl-name', auth.username]
args += ['--sasl-pass', auth.password]
args += ['--sasl-fail-is-ok']
# Runs a client with the config given as arguments
self.proc = subprocess.Popen(["girc_test", "connect"] + args)
self.proc = subprocess.Popen(['girc_test', 'connect'] + args)
def get_irctest_controller_class() -> Type[GircController]:
def get_irctest_controller_class():
return GircController

View File

@ -1,82 +1,88 @@
from typing import Set, Type
import os
import time
import shutil
import tempfile
import subprocess
from .base_hybrid import BaseHybridController
from irctest import client_mock
from irctest import authentication
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
serverinfo {{
name = "My.Little.Server";
sid = "42X";
description = "test server";
# Hybrid defaults to 9
max_nick_length = 20;
{ssl_config}
}};
general {{
throttle_count = 100; # We need to connect lots of clients quickly
sasl_service = "SaslServ";
# Allow PART/QUIT reasons quickly
anti_spam_exit_message_time = 0;
# Allow all commands quickly
pace_wait_simple = 0;
pace_wait = 0;
}};
listen {{
defer_accept = yes;
host = "{hostname}";
port = {port};
port = {services_port};
}};
class {{
name = "server";
ping_time = 5 minutes;
connectfreq = 5 minutes;
general {{
disable_auth = yes;
anti_nick_flood = no;
max_nick_changes = 256;
throttle_count = 512;
}};
connect {{
name = "services.example.org";
host = "127.0.0.1"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
accept_password = "password";
class = "server";
}};
service {{
name = "services.example.org";
}};
auth {{
user = "*";
flags = exceed_limit;
{password_field}
}};
"""
operator {{
name = "operuser";
user = "*@*";
password = "operpassword";
encrypted = no;
umodes = locops, servnotice, wallop;
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
kline, module, rehash, restart, set, unkline, unxline, wallops, xline;
}};
TEMPLATE_SSL_CONFIG = """
rsa_private_key_file = "{key_path}";
ssl_certificate_file = "{pem_path}";
ssl_dh_param_file = "{dh_path}";
"""
class HybridController(BaseHybridController):
software_name = "Hybrid"
binary_name = "ircd"
services_protocol = "hybrid"
class HybridController(BaseServerController, DirectoryBasedController):
software_name = 'Hybrid'
supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
supported_sasl_mechanisms: Set[str] = set()
def create_config(self):
super().create_config()
with self.open_file('server.conf'):
pass
template_config = TEMPLATE_CONFIG
def run(self, hostname, port, password=None, ssl=False,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
'Defining valid and invalid METADATA keys.')
assert self.proc is None
self.create_config()
self.port = port
password_field = 'password = "{}";'.format(password) if password else ''
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path,
pem_path=self.pem_path,
dh_path=self.dh_path,
)
else:
ssl_config = ''
with self.open_file('server.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
ssl_config=ssl_config,
))
self.proc = subprocess.Popen(['ircd', '-foreground',
'-configfile', os.path.join(self.directory, 'server.conf'),
'-pidfile', os.path.join(self.directory, 'server.pid'),
],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL
)
def get_irctest_controller_class() -> Type[HybridController]:
def get_irctest_controller_class():
return HybridController

View File

@ -1,91 +1,25 @@
import os
import time
import shutil
import tempfile
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
from irctest import authentication
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
# Clients:
<bind address="{hostname}" port="{port}" type="clients">
{ssl_config}
<module name="cap">
<module name="ircv3">
<module name="ircv3_capnotify">
<module name="ircv3_echomessage">
<module name="namesx"> # For multi-prefix
<connect allow="*"
resolvehostnames="no" # Faster
recvq="40960" # Needs to be larger than a valid message with tags
timeout="10" # So tests don't hang too long
localmax="1000"
globalmax="1000"
{password_field}>
<class
name="ServerOperators"
commands="WALLOPS GLOBOPS"
privs="channels/auspex users/auspex channels/auspex servers/auspex"
>
<type
name="NetAdmin"
classes="ServerOperators"
>
<oper name="operuser"
password="operpassword"
host="*@*"
type="NetAdmin"
class="ServerOperators"
>
<options casemapping="ascii">
# 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"
ipaddr="{services_hostname}"
port="{services_port}"
allowmask="*"
recvpass="password"
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">
# Protocol:
<module name="botmode">
<module name="cap">
<module name="chghost">
<module name="ircv3">
<module name="ircv3_accounttag">
<module name="ircv3_batch">
<module name="ircv3_capnotify">
<module name="ircv3_chghost">
<module name="ircv3_ctctags">
<module name="ircv3_echomessage">
<module name="ircv3_invitenotify">
<module name="ircv3_labeledresponse">
<module name="ircv3_msgid">
<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="alias"> # for the HELP alias
<module name="helpop">
<include file="examples/helpop.conf.example">
# Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
<server name="My.Little.Server" description="testnet" id="000" network="testnet">
"""
TEMPLATE_SSL_CONFIG = """
@ -93,83 +27,47 @@ TEMPLATE_SSL_CONFIG = """
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
"""
class InspircdController(BaseServerController, DirectoryBasedController):
software_name = "InspIRCd"
supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False
extban_mute_char = "m"
software_name = 'InspIRCd'
supported_sasl_mechanisms = set()
supported_capabilities = set() # Not exhaustive
def create_config(self) -> None:
def create_config(self):
super().create_config()
with self.open_file("server.conf"):
with self.open_file('server.conf'):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
) -> None:
def run(self, hostname, port, password=None, ssl=False,
restricted_metadata_keys=None,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
'Defining valid and invalid METADATA keys.')
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(services_hostname, services_port) = find_hostname_and_port()
password_field = 'password="{}"'.format(password) if password else ""
password_field = 'password="{}"'.format(password) if password else ''
if ssl:
self.gen_ssl()
ssl_config = TEMPLATE_SSL_CONFIG.format(
key_path=self.key_path, pem_path=self.pem_path, dh_path=self.dh_path
)
key_path=self.key_path,
pem_path=self.pem_path,
dh_path=self.dh_path,
)
else:
ssl_config = ""
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
password_field=password_field,
ssl_config=ssl_config,
)
)
assert self.directory
self.proc = subprocess.Popen(
[
"inspircd",
"--nofork",
"--config",
os.path.join(self.directory, "server.conf"),
],
stdout=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="inspircd3",
server_hostname=services_hostname,
server_port=services_port,
ssl_config = ''
with self.open_file('server.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
ssl_config=ssl_config
))
self.proc = subprocess.Popen(['inspircd', '--nofork', '--config',
os.path.join(self.directory, 'server.conf')],
stdout=subprocess.DEVNULL
)
def get_irctest_controller_class() -> Type[InspircdController]:
def get_irctest_controller_class():
return InspircdController

View File

@ -1,93 +0,0 @@
import os
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
TEMPLATE_CONFIG = """
# M:<Server NAME>:<YOUR Internet IP#>:<Geographic Location>:<Port>:<SID>:
M:My.Little.Server:{hostname}:Somewhere:{port}:0042:
# A:<Your Name/Location>:<Your E-Mail Addr>:<other info>::<network name>:
A:Organization, IRC dept.:Daemon <ircd@example.irc.org>:Client Server::IRCnet:
# P:<YOUR Internet IP#>:<*>::<Port>:<Flags>
P::::{port}::
# Y:<Class>:<Ping Frequency>::<Max Links>:<SendQ>:<Local Limit>:<Global Limit>:
Y:10:90::100:512000:100.100:100.100:
# I:<TARGET Host Addr>:<Password>:<TARGET Hosts NAME>:<Port>:<Class>:<Flags>:
I::{password_field}:::10::
# O:<TARGET Host NAME>:<Password>:<Nickname>:<Port>:<Class>:<Flags>:
O:*:operpassword:operuser::::
"""
class Ircu2Controller(BaseServerController, DirectoryBasedController):
binary_name: str
services_protocol: str
supports_sts = False
extban_mute_char = None
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:
raise NotImplementedByController("Services")
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
password_field = password if password else ""
assert self.directory
pidfile = os.path.join(self.directory, "ircd.pid")
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
pidfile=pidfile,
)
)
self.proc = subprocess.Popen(
[
"ircd",
"-s", # no iauth
"-p",
"on",
"-f",
os.path.join(self.directory, "server.conf"),
],
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[Ircu2Controller]:
return Ircu2Controller

View File

@ -1,111 +0,0 @@
import os
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
TEMPLATE_CONFIG = """
General {{
name = "My.Little.Server";
numeric = 42;
description = "test server";
}};
Port {{
vhost = "{hostname}";
port = {port};
}};
Class {{
name = "Client";
pingfreq = 5 minutes;
sendq = 160000;
maxlinks = 1024;
}};
Client {{
username = "*";
class = "Client";
{password_field}
}};
Operator {{
local = no;
host = "*@*";
password = "$PLAIN$operpassword";
name = "operuser";
class = "Client";
}};
features {{
"PPATH" = "{pidfile}";
# workaround for whois tests, checking the server name
"HIS_SERVERNAME" = "My.Little.Server";
}};
"""
class Ircu2Controller(BaseServerController, DirectoryBasedController):
supports_sts = False
extban_mute_char = None
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:
raise NotImplementedByController("Services")
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
password_field = 'password = "{}";'.format(password) if password else ""
assert self.directory
pidfile = os.path.join(self.directory, "ircd.pid")
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
pidfile=pidfile,
)
)
self.proc = subprocess.Popen(
[
"ircd",
"-n", # don't detach
"-f",
os.path.join(self.directory, "server.conf"),
"-x",
"DEBUG",
],
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[Ircu2Controller]:
return Ircu2Controller

View File

@ -1,8 +1,9 @@
import os
import subprocess
from typing import Optional, Type
from irctest import authentication, tls
from irctest import authentication
from irctest import tls
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseClientController, DirectoryBasedController
TEMPLATE_CONFIG = """
@ -25,70 +26,50 @@ supybot.networks.testnet.sasl.ecdsa_key: {directory}/ecdsa_key.pem
supybot.networks.testnet.sasl.mechanisms: {mechanisms}
"""
class LimnoriaController(BaseClientController, DirectoryBasedController):
software_name = "Limnoria"
software_name = 'Limnoria'
supported_sasl_mechanisms = {
"PLAIN",
"ECDSA-NIST256P-CHALLENGE",
"SCRAM-SHA-256",
"EXTERNAL",
}
supports_sts = True
'PLAIN', 'ECDSA-NIST256P-CHALLENGE', 'SCRAM-SHA-256', 'EXTERNAL',
}
supported_capabilities = set(['sts']) # Not exhaustive
def create_config(self) -> None:
super().create_config()
with self.open_file("bot.conf"):
pass
with self.open_file("conf/users.conf"):
pass
def create_config(self):
create_config = super().create_config()
if 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:
def run(self, hostname, port, auth, tls_config=None):
if tls_config is None:
tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[])
# Runs a client with the config given as arguments
assert self.proc is None
self.create_config()
username = password = ""
mechanisms = ""
if auth:
mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms)
mechanisms = ' '.join(map(authentication.Mechanisms.as_string,
auth.mechanisms))
if auth.ecdsa_key:
with self.open_file("ecdsa_key.pem") as fd:
with self.open_file('ecdsa_key.pem') as fd:
fd.write(auth.ecdsa_key)
else:
mechanisms = ''
with self.open_file('bot.conf') as fd:
fd.write(TEMPLATE_CONFIG.format(
directory=self.directory,
loglevel='CRITICAL',
hostname=hostname,
port=port,
username=auth.username if auth else '',
password=auth.password if auth else '',
mechanisms=mechanisms.lower(),
enable_tls=tls_config.enable if tls_config else 'False',
trusted_fingerprints=' '.join(tls_config.trusted_fingerprints) if tls_config else '',
))
self.proc = subprocess.Popen(['supybot',
os.path.join(self.directory, 'bot.conf')],
stderr=subprocess.STDOUT)
if auth.username:
username = auth.username.encode("unicode_escape").decode()
if auth.password:
password = auth.password.encode("unicode_escape").decode()
with self.open_file("bot.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
directory=self.directory,
loglevel="CRITICAL",
hostname=hostname,
port=port,
username=username,
password=password,
mechanisms=mechanisms.lower(),
enable_tls=tls_config.enable if tls_config else "False",
trusted_fingerprints=" ".join(tls_config.trusted_fingerprints)
if tls_config
else "",
)
)
assert self.directory
self.proc = subprocess.Popen(
["supybot", os.path.join(self.directory, "bot.conf")]
)
def get_irctest_controller_class() -> Type[LimnoriaController]:
def get_irctest_controller_class():
return LimnoriaController

View File

@ -1,13 +1,9 @@
import os
import time
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.cases import BaseServerTestCase
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
clients:
@ -62,89 +58,66 @@ server:
recvq_len: 20
"""
def make_list(list_: Set[str]) -> str:
return "\n".join(map(" - {}".format, list_))
def make_list(l):
return '\n'.join(map(' - {}'.format, l))
class MammonController(BaseServerController, DirectoryBasedController):
software_name = "Mammon"
supported_sasl_mechanisms = {"PLAIN", "ECDSA-NIST256P-CHALLENGE"}
software_name = 'Mammon'
supported_sasl_mechanisms = {
'PLAIN', 'ECDSA-NIST256P-CHALLENGE',
}
supported_capabilities = set() # Not exhaustive
def create_config(self) -> None:
def create_config(self):
super().create_config()
with self.open_file("server.conf"):
with self.open_file('server.conf'):
pass
def kill_proc(self) -> None:
def kill_proc(self):
# Mammon does not seem to handle SIGTERM very well
assert self.proc
self.proc.kill()
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
) -> None:
def run(self, hostname, port, password=None, ssl=False,
restricted_metadata_keys=(),
valid_metadata_keys=(), invalid_metadata_keys=()):
if password is not None:
raise NotImplementedByController("PASS command")
raise NotImplementedByController('PASS command')
if ssl:
raise NotImplementedByController("SSL")
raise NotImplementedByController('SSL')
assert self.proc is None
self.port = port
self.create_config()
with self.open_file("server.yml") as fd:
fd.write(
TEMPLATE_CONFIG.format(
directory=self.directory,
hostname=hostname,
port=port,
authorized_keys=make_list(valid_metadata_keys or set()),
restricted_keys=make_list(restricted_metadata_keys or set()),
)
)
# with self.open_file('server.yml', 'r') as fd:
with self.open_file('server.yml') as fd:
fd.write(TEMPLATE_CONFIG.format(
directory=self.directory,
hostname=hostname,
port=port,
authorized_keys=make_list(valid_metadata_keys),
restricted_keys=make_list(restricted_metadata_keys),
))
#with self.open_file('server.yml', 'r') as fd:
# print(fd.read())
assert self.directory
self.proc = subprocess.Popen(
[
"mammond",
"--nofork", # '--debug',
"--config",
os.path.join(self.directory, "server.yml"),
]
)
self.proc = subprocess.Popen(['mammond', '--nofork', #'--debug',
'--config', os.path.join(self.directory, 'server.yml')])
def registerUser(
self,
case: BaseServerTestCase,
username: str,
password: Optional[str] = None,
) -> None:
def registerUser(self, case, username, password=None):
# XXX: Move this somewhere else when
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
# part of the specification
client = case.addClient(show_io=False)
case.sendLine(client, "CAP LS 302")
case.sendLine(client, "NICK registration_user")
case.sendLine(client, "USER r e g :user")
case.sendLine(client, "CAP END")
while case.getRegistrationMessage(client).command != "001":
case.sendLine(client, 'CAP LS 302')
case.sendLine(client, 'NICK registration_user')
case.sendLine(client, 'USER r e g :user')
case.sendLine(client, 'CAP END')
while case.getRegistrationMessage(client).command != '001':
pass
list(case.getMessages(client))
case.sendLine(client, "REG CREATE {} passphrase {}".format(username, password))
case.sendLine(client, 'REG CREATE {} passphrase {}'.format(
username, password))
msg = case.getMessage(client)
assert msg.command == "920", msg
assert msg.command == '920', msg
list(case.getMessages(client))
case.removeClient(client)
def get_irctest_controller_class() -> Type[MammonController]:
def get_irctest_controller_class():
return MammonController

View File

@ -1,120 +0,0 @@
import os
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
TEMPLATE_CONFIG = """
[Global]
Name = My.Little.Server
Info = ExampleNET Server
Bind = {hostname}
Ports = {port}
AdminInfo1 = Bob Smith
AdminEMail = email@example.org
{password_field}
[Server]
Name = services.example.org
MyPassword = password
PeerPassword = password
Passive = yes # don't connect to it
ServiceMask = *Serv
[Options]
MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK
[Operator]
Name = operuser
Password = operpassword
"""
class NgircdController(BaseServerController, DirectoryBasedController):
software_name = "ngIRCd"
supported_sasl_mechanisms: Set[str] = set()
supports_sts = False
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(unused_hostname, unused_port) = find_hostname_and_port()
password_field = "Password = {}".format(password) if password else ""
self.gen_ssl()
if ssl:
(tls_hostname, tls_port) = (hostname, port)
(hostname, port) = (unused_hostname, unused_port)
else:
# Unreal refuses to start without TLS enabled
(tls_hostname, tls_port) = (unused_hostname, unused_port)
with self.open_file("empty.txt") as fd:
fd.write("\n")
assert self.directory
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
tls_hostname=tls_hostname,
tls_port=tls_port,
password_field=password_field,
key_path=self.key_path,
pem_path=self.pem_path,
empty_file=os.path.join(self.directory, "empty.txt"),
)
)
self.proc = subprocess.Popen(
[
"ngircd",
"--nodaemon",
"--config",
os.path.join(self.directory, "server.conf"),
],
# stdout=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="ngircd",
server_hostname=hostname,
server_port=port,
)
def get_irctest_controller_class() -> Type[NgircdController]:
return NgircdController

View File

@ -0,0 +1,144 @@
import os
import subprocess
from irctest.basecontrollers import NotImplementedByController
from irctest.basecontrollers import BaseServerController, DirectoryBasedController
TEMPLATE_CONFIG = """
network:
name: OragonoTest
server:
name: oragono.test
listen:
- "{hostname}:{port}"
{tls}
check-ident: false
max-sendq: 16k
connection-limits:
cidr-len-ipv4: 24
cidr-len-ipv6: 120
ips-per-subnet: 16
exempted:
- "127.0.0.1/8"
- "::1/128"
connection-throttling:
enabled: true
cidr-len-ipv4: 32
cidr-len-ipv6: 128
duration: 10m
max-connections: 12
ban-duration: 10m
ban-message: You have attempted to connect too many times within a short duration. Wait a while, and you will be able to connect.
exempted:
- "127.0.0.1/8"
- "::1/128"
accounts:
registration:
enabled: true
verify-timeout: "120h"
enabled-callbacks:
- none # no verification needed, will instantly register successfully
allow-multiple-per-connection: true
authentication-enabled: true
channels:
registration:
enabled: true
datastore:
path: {directory}/ircd.db
limits:
nicklen: 32
channellen: 64
awaylen: 200
kicklen: 390
topiclen: 390
monitor-entries: 100
whowas-entries: 100
chan-list-modes: 60
linelen:
tags: 2048
rest: 2048
"""
class OragonoController(BaseServerController, DirectoryBasedController):
software_name = 'Oragono'
supported_sasl_mechanisms = {
'PLAIN',
}
supported_capabilities = set() # Not exhaustive
def create_config(self):
super().create_config()
with self.open_file('ircd.yaml'):
pass
def kill_proc(self):
self.proc.kill()
def run(self, hostname, port, password=None, ssl=False,
restricted_metadata_keys=None,
valid_metadata_keys=None, invalid_metadata_keys=None):
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
'Defining valid and invalid METADATA keys.')
if password is not None:
#TODO(dan): fix dis
raise NotImplementedByController('PASS command')
self.create_config()
tls_config = ""
if ssl:
self.key_path = os.path.join(self.directory, 'ssl.key')
self.pem_path = os.path.join(self.directory, 'ssl.pem')
tls_config = 'tls-listeners:\n ":{port}":\n key: {key}\n cert: {pem}'.format(
port=port,
key=self.key_path,
pem=self.pem_path,
)
assert self.proc is None
self.port = port
with self.open_file('server.yml') as fd:
fd.write(TEMPLATE_CONFIG.format(
directory=self.directory,
hostname=hostname,
port=port,
tls=tls_config,
))
subprocess.call(['oragono', 'initdb',
'--conf', os.path.join(self.directory, 'server.yml'), '--quiet'])
subprocess.call(['oragono', 'mkcerts',
'--conf', os.path.join(self.directory, 'server.yml'), '--quiet'])
self.proc = subprocess.Popen(['oragono', 'run',
'--conf', os.path.join(self.directory, 'server.yml'), '--quiet'])
def registerUser(self, case, username, password=None):
# XXX: Move this somewhere else when
# https://github.com/ircv3/ircv3-specifications/pull/152 becomes
# part of the specification
client = case.addClient(show_io=False)
case.sendLine(client, 'CAP LS 302')
case.sendLine(client, 'NICK registration_user')
case.sendLine(client, 'USER r e g :user')
case.sendLine(client, 'CAP END')
while case.getRegistrationMessage(client).command != '001':
pass
case.getMessages(client)
case.sendLine(client, 'ACC REGISTER {} * {}'.format(
username, password))
msg = case.getMessage(client)
assert msg.command == '920', msg
case.sendLine(client, 'QUIT')
case.assertDisconnected(client)
def get_irctest_controller_class():
return OragonoController

View File

@ -1,87 +0,0 @@
from typing import Set, Type
from .base_hybrid import BaseHybridController
TEMPLATE_CONFIG = """
serverinfo {{
name = "My.Little.Server";
sid = "42X";
description = "test server";
# Hybrid defaults to 9
max_nick_length = 20;
{ssl_config}
}};
general {{
throttle_count = 100; # We need to connect lots of clients quickly
sasl_service = "SaslServ";
# Allow connections quickly
throttle_num = 100;
# Allow PART/QUIT reasons quickly
anti_spam_exit_message_time = 0;
# Allow all commands quickly
pace_wait_simple = 0;
pace_wait = 0;
}};
listen {{
defer_accept = yes;
host = "{hostname}";
port = {port};
flags = server;
port = {services_port};
}};
class {{
name = "server";
ping_time = 5 minutes;
connectfreq = 5 minutes;
}};
connect {{
name = "services.example.org";
host = "127.0.0.1"; # Used to validate incoming connection
port = 0; # We don't need servers to connect to services
send_password = "password";
accept_password = "password";
class = "server";
}};
service {{
name = "services.example.org";
}};
auth {{
user = "*";
flags = exceed_limit;
{password_field}
}};
operator {{
name = "operuser";
user = "*@*";
password = "operpassword";
encrypted = no;
umodes = locops, servnotice, wallop;
flags = admin, connect, connect:remote, die, globops, kill, kill:remote,
kline, module, rehash, restart, set, unkline, unxline, wallops, xline;
}};
"""
class Plexus4Controller(BaseHybridController):
software_name = "Hybrid"
binary_name = "ircd"
services_protocol = "plexus"
supported_sasl_mechanisms: Set[str] = set()
template_config = TEMPLATE_CONFIG
def get_irctest_controller_class() -> Type[Plexus4Controller]:
return Plexus4Controller

View File

@ -1,111 +0,0 @@
import os
import subprocess
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
TEMPLATE_CONFIG = """
General {{
name = "My.Little.Server";
numeric = 42;
description = "test server";
}};
Port {{
vhost = "{hostname}";
port = {port};
}};
Class {{
name = "Client";
pingfreq = 5 minutes;
sendq = 160000;
maxlinks = 1024;
}};
Client {{
username = "*";
class = "Client";
{password_field}
}};
Operator {{
local = no;
host = "*@*";
password = "$PLAIN$operpassword";
name = "operuser";
class = "Client";
}};
features {{
"PPATH" = "{pidfile}";
# don't block notices by default, wtf
"AUTOCHANMODES_LIST" = "+tnC";
}};
"""
class SnircdController(BaseServerController, DirectoryBasedController):
supports_sts = False
extban_mute_char = None
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
if ssl:
raise NotImplementedByController("TLS")
if run_services:
raise NotImplementedByController("Services")
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
password_field = 'password = "{}";'.format(password) if password else ""
assert self.directory
pidfile = os.path.join(self.directory, "ircd.pid")
with self.open_file("server.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
password_field=password_field,
pidfile=pidfile,
)
)
self.proc = subprocess.Popen(
[
"ircd",
"-n", # don't detach
"-f",
os.path.join(self.directory, "server.conf"),
"-x",
"DEBUG",
],
# stderr=subprocess.DEVNULL,
)
def get_irctest_controller_class() -> Type[SnircdController]:
return SnircdController

View File

@ -1,12 +0,0 @@
from typing import Type
from .charybdis import CharybdisController
class SolanumController(CharybdisController):
software_name = "Solanum"
binary_name = "solanum"
def get_irctest_controller_class() -> Type[SolanumController]:
return SolanumController

View File

@ -1,14 +1,9 @@
import os
import subprocess
import tempfile
from typing import Optional, TextIO, Type, cast
import subprocess
from irctest import authentication, tls
from irctest.basecontrollers import (
BaseClientController,
NotImplementedByController,
TestCaseControllerConfig,
)
from irctest.basecontrollers import BaseClientController
from irctest.basecontrollers import NotImplementedByController
TEMPLATE_CONFIG = """
[core]
@ -17,64 +12,59 @@ host = {hostname}
use_ssl = false
port = {port}
owner = me
channels =
channels =
timeout = 5
auth_username = {username}
auth_password = {password}
{auth_method}
"""
class SopelController(BaseClientController):
software_name = "Sopel"
supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False
software_name = 'Sopel'
supported_sasl_mechanisms = {
'PLAIN',
}
supported_capabilities = set() # Not exhaustive
def __init__(self, test_config: TestCaseControllerConfig):
super().__init__(test_config)
self.filename = next(tempfile._get_candidate_names()) + ".cfg" # type: ignore
def kill(self) -> None:
super().kill()
def __init__(self):
super().__init__()
self.filename = next(tempfile._get_candidate_names()) + '.cfg'
self.proc = None
def kill(self):
if self.proc:
self.proc.kill()
if self.filename:
try:
os.unlink(os.path.join(os.path.expanduser("~/.sopel/"), self.filename))
except OSError: #  File does not exist
os.unlink(os.path.join(os.path.expanduser('~/.sopel/'),
self.filename))
except OSError: # File does not exist
pass
def open_file(self, filename: str, mode: str = "a") -> TextIO:
dir_path = os.path.expanduser("~/.sopel/")
os.makedirs(dir_path, exist_ok=True)
return cast(TextIO, open(os.path.join(dir_path, filename), mode))
def open_file(self, filename, mode='a'):
return open(os.path.join(os.path.expanduser('~/.sopel/'), filename),
mode)
def create_config(self) -> None:
with self.open_file(self.filename):
def create_config(self):
with self.open_file(self.filename) as fd:
pass
def run(
self,
hostname: str,
port: int,
auth: Optional[authentication.Authentication],
tls_config: Optional[tls.TlsConfig] = None,
) -> None:
def run(self, hostname, port, auth, tls_config):
# Runs a client with the config given as arguments
if tls_config is not None:
raise NotImplementedByController("TLS configuration")
raise NotImplementedByController(
'TLS configuration')
assert self.proc is None
self.create_config()
with self.open_file(self.filename) as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
username=auth.username if auth else "",
password=auth.password if auth else "",
auth_method="auth_method = sasl" if auth else "",
)
)
self.proc = subprocess.Popen(["sopel", "--quiet", "-c", self.filename])
fd.write(TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
username=auth.username if auth else '',
password=auth.password if auth else '',
auth_method='auth_method = sasl' if auth else '',
))
self.proc = subprocess.Popen(['sopel', '--quiet', '-c', self.filename])
def get_irctest_controller_class() -> Type[SopelController]:
def get_irctest_controller_class():
return SopelController

View File

@ -1,265 +0,0 @@
import functools
import os
import pathlib
import shutil
import signal
import subprocess
import textwrap
from typing import Optional, Set, Type
from irctest.basecontrollers import (
BaseServerController,
DirectoryBasedController,
NotImplementedByController,
)
from irctest.irc_utils.junkdrawer import find_hostname_and_port
TEMPLATE_CONFIG = """
include "modules.default.conf";
include "operclass.default.conf";
{extras}
include "help/help.conf";
me {{
name "My.Little.Server";
info "ExampleNET Server";
sid "001";
}}
admin {{
"Bob Smith";
"bob";
"email@example.org";
}}
class clients {{
pingfreq 90;
maxclients 1000;
sendq 200k;
recvq 8000;
}}
class servers {{
pingfreq 60;
connfreq 15; /* try to connect every 15 seconds */
maxclients 10; /* max servers */
sendq 20M;
}}
allow {{
mask *;
class clients;
maxperip 50;
{password_field}
}}
listen {{
ip {hostname};
port {port};
}}
listen {{
ip {tls_hostname};
port {tls_port};
options {{ tls; }}
tls-options {{
certificate "{pem_path}";
key "{key_path}";
}};
}}
/* Special SSL/TLS servers-only port for linking */
listen {{
ip {services_hostname};
port {services_port};
options {{ serversonly; }}
}}
link services.example.org {{
incoming {{
mask *;
}}
password "password";
class servers;
}}
ulines {{
services.example.org;
}}
set {{
sasl-server services.example.org;
kline-address "example@example.org";
network-name "ExampleNET";
default-server "irc.example.org";
help-channel "#Help";
cloak-keys {{ "aaaA1"; "bbbB2"; "cccC3"; }}
options {{
identd-check; // Disable it, so it doesn't prefix idents with a tilde
}}
anti-flood {{
// Prevent throttling, especially test_buffering.py which
// triggers anti-flood with its very long lines
unknown-users {{
nick-flood 255:10;
lag-penalty 1;
lag-penalty-bytes 10000;
}}
}}
modes-on-join "+H 100:1d"; // Enables CHATHISTORY
}}
tld {{
mask *;
motd "{empty_file}";
botmotd "{empty_file}";
rules "{empty_file}";
}}
files {{
tunefile "{empty_file}";
}}
oper "operuser" {{
password = "operpassword";
mask *;
class clients;
operclass netadmin;
}}
"""
@functools.lru_cache()
def installed_version() -> int:
output = subprocess.check_output(["unrealircd", "-v"], universal_newlines=True)
if output.startswith("UnrealIRCd-5."):
return 5
elif output.startswith("UnrealIRCd-6."):
return 6
else:
assert False, f"unexpected version: {output}"
class UnrealircdController(BaseServerController, DirectoryBasedController):
software_name = "UnrealIRCd"
supported_sasl_mechanisms = {"PLAIN"}
supports_sts = False
extban_mute_char = "quiet" if installed_version() >= 6 else "q"
def create_config(self) -> None:
super().create_config()
with self.open_file("server.conf"):
pass
def run(
self,
hostname: str,
port: int,
*,
password: Optional[str],
ssl: bool,
run_services: bool,
valid_metadata_keys: Optional[Set[str]] = None,
invalid_metadata_keys: Optional[Set[str]] = None,
restricted_metadata_keys: Optional[Set[str]] = None,
) -> None:
if valid_metadata_keys or invalid_metadata_keys:
raise NotImplementedByController(
"Defining valid and invalid METADATA keys."
)
assert self.proc is None
self.port = port
self.hostname = hostname
self.create_config()
(unused_hostname, unused_port) = find_hostname_and_port()
(services_hostname, services_port) = find_hostname_and_port()
password_field = 'password "{}";'.format(password) if password else ""
self.gen_ssl()
if ssl:
(tls_hostname, tls_port) = (hostname, port)
(hostname, port) = (unused_hostname, unused_port)
else:
# Unreal refuses to start without TLS enabled
(tls_hostname, tls_port) = (unused_hostname, unused_port)
if installed_version() >= 6:
extras = textwrap.dedent(
"""
include "snomasks.default.conf";
loadmodule "cloak_md5";
"""
)
else:
extras = ""
with self.open_file("empty.txt") as fd:
fd.write("\n")
assert self.directory
with self.open_file("unrealircd.conf") as fd:
fd.write(
TEMPLATE_CONFIG.format(
hostname=hostname,
port=port,
services_hostname=services_hostname,
services_port=services_port,
tls_hostname=tls_hostname,
tls_port=tls_port,
password_field=password_field,
key_path=self.key_path,
pem_path=self.pem_path,
empty_file=os.path.join(self.directory, "empty.txt"),
extras=extras,
)
)
proot_cmd = []
self.using_proot = False
if shutil.which("proot"):
unrealircd_path = shutil.which("unrealircd")
if unrealircd_path:
unrealircd_prefix = pathlib.Path(unrealircd_path).parents[1]
tmpdir = os.path.join(self.directory, "tmp")
os.mkdir(tmpdir)
# Unreal cleans its tmp/ directory after each run, which prevents
# multiple processes from running at the same time.
# Using PRoot, we can isolate them, with a tmp/ directory for each
# process, so they don't interfere with each other, allowing use of
# the -n option (of pytest-xdist) to speed-up tests
proot_cmd = ["proot", "-b", f"{tmpdir}:{unrealircd_prefix}/tmp"]
self.using_proot = True
self.proc = subprocess.Popen(
[
*proot_cmd,
"unrealircd",
"-t",
"-F", # BOOT_NOFORK
"-f",
os.path.join(self.directory, "unrealircd.conf"),
],
# stdout=subprocess.DEVNULL,
)
if run_services:
self.wait_for_port()
self.services_controller = self.services_controller_class(
self.test_config, self
)
self.services_controller.run(
protocol="unreal4",
server_hostname=services_hostname,
server_port=services_port,
)
def kill(self) -> None:
if self.using_proot:
# Kill grandchild process, instead of killing proot, which takes more
# time (and does not seem to always work)
assert self.proc is not None
output = subprocess.check_output(
["ps", "-opid", "--no-headers", "--ppid", str(self.proc.pid)]
)
(grandchild_pid,) = [int(line) for line in output.decode().split()]
os.kill(grandchild_pid, signal.SIGKILL)
super().kill()
def get_irctest_controller_class() -> Type[UnrealircdController]:
return UnrealircdController

View File

@ -1,6 +1,6 @@
class NoMessageException(AssertionError):
pass
class ConnectionClosed(Exception):
pass

View File

@ -2,10 +2,7 @@
Handles ambiguities of RFCs.
"""
from typing import List
def normalize_namreply_params(params: List[str]) -> List[str]:
def normalize_namreply_params(params):
# So… RFC 2812 says:
# "( "=" / "*" / "@" ) <channel>
# :[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )
@ -14,10 +11,9 @@ def normalize_namreply_params(params: List[str]) -> List[str]:
# 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])
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

@ -1,12 +1,10 @@
from typing import Dict, List, Optional
def cap_list_to_dict(caps: List[str]) -> Dict[str, Optional[str]]:
d: Dict[str, Optional[str]] = {}
for cap in caps:
if "=" in cap:
(key, value) = cap.split("=", 1)
d[key] = value
def cap_list_to_dict(l):
d = {}
for cap in l:
if '=' in cap:
(key, value) = cap.split('=', 1)
else:
d[cap] = None
key = cap
value = None
d[key] = value
return d

View File

@ -1,46 +0,0 @@
import datetime
import re
import secrets
import socket
from typing import Dict, Tuple
# thanks jess!
IRCV3_FORMAT_STRFTIME = "%Y-%m-%dT%H:%M:%S.%f%z"
def ircv3_timestamp_to_unixtime(timestamp: str) -> float:
return datetime.datetime.strptime(timestamp, IRCV3_FORMAT_STRFTIME).timestamp()
def random_name(base: str) -> str:
return base + "-" + secrets.token_hex(8)
def find_hostname_and_port() -> Tuple[str, int]:
"""Find available hostname/port to listen on."""
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(("", 0))
(hostname, port) = s.getsockname()
s.close()
return (hostname, port)
"""
Stolen from supybot:
"""
class MultipleReplacer:
"""Return a callable that replaces all dict keys by the associated
value. More efficient than multiple .replace()."""
# We use an object instead of a lambda function because it avoids the
# need for using the staticmethod() on the lambda function if assigning
# it to a class in Python 3.
def __init__(self, dict_: Dict[str, str]):
self._dict = dict_
dict_ = dict([(re.escape(key), val) for key, val in dict_.items()])
self._matcher = re.compile("|".join(dict_.keys()))
def __call__(self, s: str) -> str:
return self._matcher.sub(lambda m: self._dict[m.group(0)], s)

View File

@ -1,76 +1,62 @@
import dataclasses
import re
from typing import Any, Dict, List, Optional
from .junkdrawer import MultipleReplacer
import collections
import supybot.utils
# http://ircv3.net/specs/core/message-tags-3.2.html#escaping-values
TAG_ESCAPE = [
("\\", "\\\\"), # \ -> \\
(" ", r"\s"),
(";", r"\:"),
("\r", r"\r"),
("\n", r"\n"),
]
unescape_tag_value = MultipleReplacer(dict(map(lambda x: (x[1], x[0]), TAG_ESCAPE)))
('\\', '\\\\'), # \ -> \\
(' ', r'\s'),
(';', r'\:'),
('\r', r'\r'),
('\n', r'\n'),
]
unescape_tag_value = supybot.utils.str.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('(\S+/)?[a-zA-Z0-9-]+')
def parse_tags(s: str) -> Dict[str, Optional[str]]:
tags: Dict[str, Optional[str]] = {}
for tag in s.split(";"):
if "=" not in tag:
def parse_tags(s):
tags = {}
for tag in s.split(';'):
if '=' not in tag:
tags[tag] = None
else:
(key, value) = tag.split("=", 1)
assert tag_key_validator.match(key), "Invalid tag key: {}".format(key)
(key, value) = tag.split('=', 1)
assert tag_key_validator.match(key), \
'Invalid tag key: {}'.format(key)
tags[key] = unescape_tag_value(value)
return tags
Message = collections.namedtuple('Message',
'tags prefix command params')
@dataclasses.dataclass(frozen=True)
class HistoryMessage:
time: Any
msgid: Optional[str]
target: str
text: str
@dataclasses.dataclass(frozen=True)
class Message:
tags: Dict[str, Optional[str]]
prefix: Optional[str]
command: str
params: List[str]
def to_history_message(self) -> HistoryMessage:
return HistoryMessage(
time=self.tags.get("time"),
msgid=self.tags.get("msgid"),
target=self.params[0],
text=self.params[1],
)
def parse_message(s: str) -> Message:
def parse_message(s):
"""Parse a message according to
http://tools.ietf.org/html/rfc1459#section-2.3.1
and
http://ircv3.net/specs/core/message-tags-3.2.html"""
s = s.rstrip("\r\n")
if s.startswith("@"):
(tags_str, s) = s.split(" ", 1)
tags = parse_tags(tags_str[1:])
assert s.endswith('\r\n'), 'Message does not end with CR LF: {!r}'.format(s)
s = s[0:-2]
if s.startswith('@'):
(tags, s) = s.split(' ', 1)
tags = parse_tags(tags[1:])
else:
tags = {}
if " :" in s:
(other_tokens, trailing_param) = s.split(" :", 1)
tokens = list(filter(bool, other_tokens.split(" "))) + [trailing_param]
if ' :' in s:
(other_tokens, trailing_param) = s.split(' :', 1)
tokens = list(filter(bool, other_tokens.split(' '))) + [trailing_param]
else:
tokens = list(filter(bool, s.split(" ")))
prefix = prefix = tokens.pop(0)[1:] if tokens[0].startswith(":") else None
tokens = list(filter(bool, s.split(' ')))
if tokens[0].startswith(':'):
prefix = tokens.pop(0)[1:]
else:
prefix = None
command = tokens.pop(0)
params = tokens
return Message(tags=tags, prefix=prefix, command=command, params=params)
return Message(
tags=tags,
prefix=prefix,
command=command,
params=params,
)

View File

@ -1,15 +0,0 @@
import base64
def sasl_plain_blob(username: str, passphrase: str) -> str:
blob = base64.b64encode(
b"\x00".join(
(
username.encode("utf-8"),
username.encode("utf-8"),
passphrase.encode("utf-8"),
)
)
)
blobstr = blob.decode("ascii")
return f"AUTHENTICATE {blobstr}"

View File

@ -9,198 +9,185 @@
# They're intended to represent a relatively-standard cross-section of the IRC
# server ecosystem out there. Custom numerics will be marked as such.
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
RPL_CREATED = "003"
RPL_MYINFO = "004"
RPL_ISUPPORT = "005"
RPL_SNOMASKIS = "008"
RPL_BOUNCE = "010"
RPL_HELLO = "020"
RPL_TRACELINK = "200"
RPL_TRACECONNECTING = "201"
RPL_TRACEHANDSHAKE = "202"
RPL_TRACEUNKNOWN = "203"
RPL_TRACEOPERATOR = "204"
RPL_TRACEUSER = "205"
RPL_TRACESERVER = "206"
RPL_TRACESERVICE = "207"
RPL_TRACENEWTYPE = "208"
RPL_TRACECLASS = "209"
RPL_TRACERECONNECT = "210"
RPL_STATSLINKINFO = "211"
RPL_STATSCOMMANDS = "212"
RPL_ENDOFSTATS = "219"
RPL_UMODEIS = "221"
RPL_SERVLIST = "234"
RPL_SERVLISTEND = "235"
RPL_STATSUPTIME = "242"
RPL_STATSOLINE = "243"
RPL_LUSERCLIENT = "251"
RPL_LUSEROP = "252"
RPL_LUSERUNKNOWN = "253"
RPL_LUSERCHANNELS = "254"
RPL_LUSERME = "255"
RPL_ADMINME = "256"
RPL_ADMINLOC1 = "257"
RPL_ADMINLOC2 = "258"
RPL_ADMINEMAIL = "259"
RPL_TRACELOG = "261"
RPL_TRACEEND = "262"
RPL_TRYAGAIN = "263"
RPL_LOCALUSERS = "265"
RPL_GLOBALUSERS = "266"
RPL_WHOISCERTFP = "276"
RPL_AWAY = "301"
RPL_USERHOST = "302"
RPL_ISON = "303"
RPL_UNAWAY = "305"
RPL_NOWAWAY = "306"
RPL_WHOISREGNICK = "307"
RPL_WHOISUSER = "311"
RPL_WHOISSERVER = "312"
RPL_WHOISOPERATOR = "313"
RPL_WHOWASUSER = "314"
RPL_ENDOFWHO = "315"
RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_WHOISSPECIAL = "320"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
RPL_UNIQOPIS = "325"
RPL_CHANNELCREATED = "329"
RPL_WHOISACCOUNT = "330"
RPL_NOTOPIC = "331"
RPL_TOPIC = "332"
RPL_TOPICTIME = "333"
RPL_WHOISBOT = "335"
RPL_WHOISACTUALLY = "338"
RPL_INVITING = "341"
RPL_SUMMONING = "342"
RPL_INVITELIST = "346"
RPL_ENDOFINVITELIST = "347"
RPL_EXCEPTLIST = "348"
RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351"
RPL_WHOREPLY = "352"
RPL_NAMREPLY = "353"
RPL_WHOSPCRPL = "354"
RPL_LINKS = "364"
RPL_ENDOFLINKS = "365"
RPL_ENDOFNAMES = "366"
RPL_BANLIST = "367"
RPL_ENDOFBANLIST = "368"
RPL_ENDOFWHOWAS = "369"
RPL_INFO = "371"
RPL_MOTD = "372"
RPL_ENDOFINFO = "374"
RPL_MOTDSTART = "375"
RPL_ENDOFMOTD = "376"
RPL_WHOISHOST = "378"
RPL_WHOISMODES = "379"
RPL_YOUREOPER = "381"
RPL_REHASHING = "382"
RPL_YOURESERVICE = "383"
RPL_TIME = "391"
RPL_USERSSTART = "392"
RPL_USERS = "393"
RPL_ENDOFUSERS = "394"
RPL_NOUSERS = "395"
ERR_UNKNOWNERROR = "400"
ERR_NOSUCHNICK = "401"
ERR_NOSUCHSERVER = "402"
ERR_NOSUCHCHANNEL = "403"
ERR_CANNOTSENDTOCHAN = "404"
ERR_TOOMANYCHANNELS = "405"
ERR_WASNOSUCHNICK = "406"
ERR_TOOMANYTARGETS = "407"
ERR_NOSUCHSERVICE = "408"
ERR_NOORIGIN = "409"
ERR_INVALIDCAPCMD = "410"
ERR_NORECIPIENT = "411"
ERR_NOTEXTTOSEND = "412"
ERR_NOTOPLEVEL = "413"
ERR_WILDTOPLEVEL = "414"
ERR_BADMASK = "415"
ERR_INPUTTOOLONG = "417"
ERR_UNKNOWNCOMMAND = "421"
ERR_NOMOTD = "422"
ERR_NOADMININFO = "423"
ERR_FILEERROR = "424"
ERR_NONICKNAMEGIVEN = "431"
ERR_ERRONEUSNICKNAME = "432"
ERR_NICKNAMEINUSE = "433"
ERR_NICKCOLLISION = "436"
ERR_UNAVAILRESOURCE = "437"
ERR_REG_UNAVAILABLE = "440"
ERR_USERNOTINCHANNEL = "441"
ERR_NOTONCHANNEL = "442"
ERR_USERONCHANNEL = "443"
ERR_NOLOGIN = "444"
ERR_SUMMONDISABLED = "445"
ERR_USERSDISABLED = "446"
ERR_NOTREGISTERED = "451"
ERR_NEEDMOREPARAMS = "461"
ERR_ALREADYREGISTRED = "462"
ERR_NOPERMFORHOST = "463"
ERR_PASSWDMISMATCH = "464"
ERR_YOUREBANNEDCREEP = "465"
ERR_YOUWILLBEBANNED = "466"
ERR_KEYSET = "467"
ERR_INVALIDUSERNAME = "468"
ERR_LINKCHANNEL = "470"
ERR_CHANNELISFULL = "471"
ERR_UNKNOWNMODE = "472"
ERR_INVITEONLYCHAN = "473"
ERR_BANNEDFROMCHAN = "474"
ERR_BADCHANNELKEY = "475"
ERR_BADCHANMASK = "476"
ERR_NOCHANMODES = "477"
ERR_NEEDREGGEDNICK = "477"
ERR_BANLISTFULL = "478"
ERR_NOPRIVILEGES = "481"
ERR_CHANOPRIVSNEEDED = "482"
ERR_CANTKILLSERVER = "483"
ERR_RESTRICTED = "484"
ERR_UNIQOPPRIVSNEEDED = "485"
ERR_NOOPERHOST = "491"
ERR_UMODEUNKNOWNFLAG = "501"
ERR_USERSDONTMATCH = "502"
ERR_HELPNOTFOUND = "524"
ERR_INVALIDKEY = "525"
ERR_CANNOTSENDRP = "573"
RPL_WHOISSECURE = "671"
RPL_YOURLANGUAGESARE = "687"
RPL_WHOISLANGUAGE = "690"
ERR_INVALIDMODEPARAM = "696"
RPL_HELPSTART = "704"
RPL_HELPTXT = "705"
RPL_ENDOFHELP = "706"
ERR_NOPRIVS = "723"
RPL_MONONLINE = "730"
RPL_MONOFFLINE = "731"
RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734"
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902"
RPL_SASLSUCCESS = "903"
ERR_SASLFAIL = "904"
ERR_SASLTOOLONG = "905"
ERR_SASLABORTED = "906"
ERR_SASLALREADY = "907"
RPL_SASLMECHS = "908"
RPL_REGISTRATION_SUCCESS = "920"
ERR_ACCOUNT_ALREADY_EXISTS = "921"
ERR_REG_UNSPECIFIED_ERROR = "922"
RPL_VERIFYSUCCESS = "923"
ERR_ACCOUNT_ALREADY_VERIFIED = "924"
RPL_WELCOME = "001"
RPL_YOURHOST = "002"
RPL_CREATED = "003"
RPL_MYINFO = "004"
RPL_ISUPPORT = "005"
RPL_SNOMASKIS = "008"
RPL_BOUNCE = "010"
RPL_TRACELINK = "200"
RPL_TRACECONNECTING = "201"
RPL_TRACEHANDSHAKE = "202"
RPL_TRACEUNKNOWN = "203"
RPL_TRACEOPERATOR = "204"
RPL_TRACEUSER = "205"
RPL_TRACESERVER = "206"
RPL_TRACESERVICE = "207"
RPL_TRACENEWTYPE = "208"
RPL_TRACECLASS = "209"
RPL_TRACERECONNECT = "210"
RPL_STATSLINKINFO = "211"
RPL_STATSCOMMANDS = "212"
RPL_ENDOFSTATS = "219"
RPL_UMODEIS = "221"
RPL_SERVLIST = "234"
RPL_SERVLISTEND = "235"
RPL_STATSUPTIME = "242"
RPL_STATSOLINE = "243"
RPL_LUSERCLIENT = "251"
RPL_LUSEROP = "252"
RPL_LUSERUNKNOWN = "253"
RPL_LUSERCHANNELS = "254"
RPL_LUSERME = "255"
RPL_ADMINME = "256"
RPL_ADMINLOC1 = "257"
RPL_ADMINLOC2 = "258"
RPL_ADMINEMAIL = "259"
RPL_TRACELOG = "261"
RPL_TRACEEND = "262"
RPL_TRYAGAIN = "263"
RPL_WHOISCERTFP = "276"
RPL_AWAY = "301"
RPL_USERHOST = "302"
RPL_ISON = "303"
RPL_UNAWAY = "305"
RPL_NOWAWAY = "306"
RPL_WHOISUSER = "311"
RPL_WHOISSERVER = "312"
RPL_WHOISOPERATOR = "313"
RPL_WHOWASUSER = "314"
RPL_ENDOFWHO = "315"
RPL_WHOISIDLE = "317"
RPL_ENDOFWHOIS = "318"
RPL_WHOISCHANNELS = "319"
RPL_LIST = "322"
RPL_LISTEND = "323"
RPL_CHANNELMODEIS = "324"
RPL_UNIQOPIS = "325"
RPL_CHANNELCREATED = "329"
RPL_WHOISACCOUNT = "330"
RPL_NOTOPIC = "331"
RPL_TOPIC = "332"
RPL_TOPICTIME = "333"
RPL_WHOISBOT = "335"
RPL_WHOISACTUALLY = "338"
RPL_INVITING = "341"
RPL_SUMMONING = "342"
RPL_INVITELIST = "346"
RPL_ENDOFINVITELIST = "347"
RPL_EXCEPTLIST = "348"
RPL_ENDOFEXCEPTLIST = "349"
RPL_VERSION = "351"
RPL_WHOREPLY = "352"
RPL_NAMREPLY = "353"
RPL_LINKS = "364"
RPL_ENDOFLINKS = "365"
RPL_ENDOFNAMES = "366"
RPL_BANLIST = "367"
RPL_ENDOFBANLIST = "368"
RPL_ENDOFWHOWAS = "369"
RPL_INFO = "371"
RPL_MOTD = "372"
RPL_ENDOFINFO = "374"
RPL_MOTDSTART = "375"
RPL_ENDOFMOTD = "376"
RPL_YOUREOPER = "381"
RPL_REHASHING = "382"
RPL_YOURESERVICE = "383"
RPL_TIME = "391"
RPL_USERSSTART = "392"
RPL_USERS = "393"
RPL_ENDOFUSERS = "394"
RPL_NOUSERS = "395"
ERR_UNKNOWNERROR = "400"
ERR_NOSUCHNICK = "401"
ERR_NOSUCHSERVER = "402"
ERR_NOSUCHCHANNEL = "403"
ERR_CANNOTSENDTOCHAN = "404"
ERR_TOOMANYCHANNELS = "405"
ERR_WASNOSUCHNICK = "406"
ERR_TOOMANYTARGETS = "407"
ERR_NOSUCHSERVICE = "408"
ERR_NOORIGIN = "409"
ERR_INVALIDCAPCMD = "410"
ERR_NORECIPIENT = "411"
ERR_NOTEXTTOSEND = "412"
ERR_NOTOPLEVEL = "413"
ERR_WILDTOPLEVEL = "414"
ERR_BADMASK = "415"
ERR_UNKNOWNCOMMAND = "421"
ERR_NOMOTD = "422"
ERR_NOADMININFO = "423"
ERR_FILEERROR = "424"
ERR_NONICKNAMEGIVEN = "431"
ERR_ERRONEUSNICKNAME = "432"
ERR_NICKNAMEINUSE = "433"
ERR_NICKCOLLISION = "436"
ERR_UNAVAILRESOURCE = "437"
ERR_REG_UNAVAILABLE = "440"
ERR_USERNOTINCHANNEL = "441"
ERR_NOTONCHANNEL = "442"
ERR_USERONCHANNEL = "443"
ERR_NOLOGIN = "444"
ERR_SUMMONDISABLED = "445"
ERR_USERSDISABLED = "446"
ERR_NOTREGISTERED = "451"
ERR_NEEDMOREPARAMS = "461"
ERR_ALREADYREGISTRED = "462"
ERR_NOPERMFORHOST = "463"
ERR_PASSWDMISMATCH = "464"
ERR_YOUREBANNEDCREEP = "465"
ERR_YOUWILLBEBANNED = "466"
ERR_KEYSET = "467"
ERR_INVALIDUSERNAME = "468"
ERR_CHANNELISFULL = "471"
ERR_UNKNOWNMODE = "472"
ERR_INVITEONLYCHAN = "473"
ERR_BANNEDFROMCHAN = "474"
ERR_BADCHANNELKEY = "475"
ERR_BADCHANMASK = "476"
ERR_NOCHANMODES = "477"
ERR_BANLISTFULL = "478"
ERR_NOPRIVILEGES = "481"
ERR_CHANOPRIVSNEEDED = "482"
ERR_CANTKILLSERVER = "483"
ERR_RESTRICTED = "484"
ERR_UNIQOPPRIVSNEEDED = "485"
ERR_NOOPERHOST = "491"
ERR_UMODEUNKNOWNFLAG = "501"
ERR_USERSDONTMATCH = "502"
ERR_HELPNOTFOUND = "524"
ERR_CANNOTSENDRP = "573"
RPL_WHOISSECURE = "671"
RPL_YOURLANGUAGESARE = "687"
RPL_WHOISLANGUAGE = "690"
RPL_HELPSTART = "704"
RPL_HELPTXT = "705"
RPL_ENDOFHELP = "706"
ERR_NOPRIVS = "723"
RPL_MONONLINE = "730"
RPL_MONOFFLINE = "731"
RPL_MONLIST = "732"
RPL_ENDOFMONLIST = "733"
ERR_MONLISTFULL = "734"
RPL_LOGGEDIN = "900"
RPL_LOGGEDOUT = "901"
ERR_NICKLOCKED = "902"
RPL_SASLSUCCESS = "903"
ERR_SASLFAIL = "904"
ERR_SASLTOOLONG = "905"
ERR_SASLABORTED = "906"
ERR_SASLALREADY = "907"
RPL_SASLMECHS = "908"
RPL_REGISTRATION_SUCCESS = "920"
ERR_ACCOUNT_ALREADY_EXISTS = "921"
ERR_REG_UNSPECIFIED_ERROR = "922"
RPL_VERIFYSUCCESS = "923"
ERR_ACCOUNT_ALREADY_VERIFIED = "924"
ERR_ACCOUNT_INVALID_VERIFY_CODE = "925"
RPL_REG_VERIFICATION_REQUIRED = "927"
ERR_REG_INVALID_CRED_TYPE = "928"
ERR_REG_INVALID_CALLBACK = "929"
ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982"
RPL_REG_VERIFICATION_REQUIRED = "927"
ERR_REG_INVALID_CRED_TYPE = "928"
ERR_REG_INVALID_CALLBACK = "929"
ERR_TOOMANYLANGUAGES = "981"
ERR_NOLANGUAGE = "982"

View File

@ -1,178 +0,0 @@
"""Pattern-matching utilities"""
import dataclasses
import re
from typing import Dict, List, Optional, Union
class Operator:
"""Used as a wildcards and operators when matching message arguments
(see assertMessageMatch and match_list)"""
def __init__(self) -> None:
pass
class _AnyStr(Operator):
"""Wildcard matching any string"""
def __repr__(self) -> str:
return "ANYSTR"
class _AnyOptStr(Operator):
"""Wildcard matching any string as well as None"""
def __repr__(self) -> str:
return "ANYOPTSTR"
@dataclasses.dataclass(frozen=True)
class StrRe(Operator):
regexp: str
def __repr__(self) -> str:
return f"StrRe(r'{self.regexp}')"
@dataclasses.dataclass(frozen=True)
class NotStrRe(Operator):
regexp: str
def __repr__(self) -> str:
return f"NotStrRe(r'{self.regexp}')"
@dataclasses.dataclass(frozen=True)
class InsensitiveStr(Operator):
string: str
def __repr__(self) -> str:
return f"InsensitiveStr({self.string!r})"
@dataclasses.dataclass(frozen=True)
class RemainingKeys(Operator):
"""Used in a dict pattern to match all remaining keys.
May only be present once."""
key: Operator
def __repr__(self) -> str:
return f"RemainingKeys({self.key!r})"
ANYSTR = _AnyStr()
"""Singleton, spares two characters"""
ANYOPTSTR = _AnyOptStr()
"""Singleton, spares two characters"""
ANYDICT = {RemainingKeys(ANYSTR): ANYOPTSTR}
"""Matches any dictionary; useful to compare tags dict, eg.
`match_dict(got_tags, {"label": "foo", **ANYDICT})`"""
@dataclasses.dataclass(frozen=True)
class ListRemainder:
item: Operator
min_length: int = 0
def __repr__(self) -> str:
if self.min_length:
return f"ListRemainder({self.item!r}, min_length={self.min_length})"
elif self.item is ANYSTR:
return "*ANYLIST"
else:
return f"ListRemainder({self.item!r})"
ANYLIST = [ListRemainder(ANYSTR)]
"""Matches any list remainder"""
def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool:
if isinstance(expected, _AnyOptStr):
return True
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):
return False
elif isinstance(expected, NotStrRe):
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():
return False
elif isinstance(expected, Operator):
raise NotImplementedError(f"Unsupported operator: {expected}")
elif got != expected:
return False
return True
def match_list(
got: List[Optional[str]], expected: List[Union[str, None, Operator]]
) -> bool:
"""Returns True iff the list are equal.
The ANYSTR operator can be used on the 'expected' side as a wildcard,
matching any *single* value; and StrRe("<regexp>") can be used to match regular
expressions"""
if expected and isinstance(expected[-1], ListRemainder):
# Expand the 'expected' list to have as many items as the 'got' list
expected = list(expected) # copy
remainder = expected.pop()
nb_remaining_items = len(got) - len(expected)
expected += [remainder.item] * max(nb_remaining_items, remainder.min_length)
if len(got) != len(expected):
return False
return all(
match_string(got_value, expected_value)
for (got_value, expected_value) in zip(got, expected)
)
def match_dict(
got: Dict[str, Optional[str]],
expected: Dict[Union[str, Operator], Union[str, Operator, None]],
) -> bool:
"""Returns True iff the list are equal.
The ANYSTR operator can be used on the 'expected' side as a wildcard,
matching any *single* value; and StrRe("<regexp>") can be used to match regular
expressions
Additionally, the Keys() operator can be used to match remaining keys, and
ANYDICT to match any remaining dict"""
got = dict(got) # shallow copy, as we will remove keys
# Set to not-None if we find a Keys() operator in the dict keys
remaining_keys_wildcard = None
for (expected_key, expected_value) in expected.items():
if isinstance(expected_key, RemainingKeys):
remaining_keys_wildcard = (expected_key.key, expected_value)
elif isinstance(expected_key, Operator):
raise NotImplementedError(f"Unsupported operator: {expected_key}")
else:
if expected_key not in got:
return False
got_value = got.pop(expected_key)
if not match_string(got_value, expected_value):
return False
if remaining_keys_wildcard:
(expected_key, expected_value) = remaining_keys_wildcard
for (key, value) in got.items():
if not match_string(key, expected_key):
return False
if not match_string(value, expected_value):
return False
return True
else:
# There should be nothing left unmatched in the dict
return got == {}

View File

@ -1,59 +1,60 @@
import unittest
import operator
import collections
class NotImplementedByController(unittest.SkipTest, NotImplementedError):
def __str__(self) -> str:
return "Not implemented by controller: {}".format(self.args[0])
def __str__(self):
return 'Not implemented by controller: {}'.format(self.args[0])
class ImplementationChoice(unittest.SkipTest):
def __str__(self) -> str:
return (
"Choice in the implementation makes it impossible to "
"perform a test: {}".format(self.args[0])
)
class OptionalCommandNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported command: {}".format(self.args[0])
def __str__(self):
return 'Choice in the implementation makes it impossible to ' \
'perform a test: {}'.format(self.args[0])
class OptionalExtensionNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported extension: {}".format(self.args[0])
def __str__(self):
return 'Unsupported extension: {}'.format(self.args[0])
class OptionalSaslMechanismNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported SASL mechanism: {}".format(self.args[0])
def __str__(self):
return 'Unsupported SASL mechanism: {}'.format(self.args[0])
class CapabilityNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported capability: {}".format(self.args[0])
class IsupportTokenNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported ISUPPORT token: {}".format(self.args[0])
class ChannelModeNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported channel mode: {} ({})".format(self.args[0], self.args[1])
class ExtbanNotSupported(unittest.SkipTest):
def __str__(self) -> str:
return "Unsupported extban: {} ({})".format(self.args[0], self.args[1])
def __str__(self):
return 'Unsupported capability: {}'.format(self.args[0])
class NotRequiredBySpecifications(unittest.SkipTest):
def __str__(self) -> str:
return "Tests not required by the set of tested specification(s)."
def __str__(self):
return 'Tests not required by the set of tested specification(s).'
class SkipStrictTest(unittest.SkipTest):
def __str__(self) -> str:
return "Tests not required because strict tests are disabled."
def __str__(self):
return 'Tests not required because strict tests are disabled.'
class TextTestResult(unittest.TextTestResult):
def getDescription(self, test):
if hasattr(test, 'description'):
doc_first_lines = test.description()
else:
doc_first_lines = test.shortDescription()
return '\n'.join((str(test), doc_first_lines or ''))
class TextTestRunner(unittest.TextTestRunner):
"""Small wrapper around unittest.TextTestRunner that reports the
number of tests that were skipped because the software does not support
an optional feature."""
resultclass = TextTestResult
def run(self, test):
result = super().run(test)
assert self.resultclass is TextTestResult
if result.skipped:
print()
print('Some tests were skipped because the following optional '
'specifications/mechanisms are not supported:')
msg_to_count = collections.defaultdict(lambda: 0)
for (test, msg) in result.skipped:
msg_to_count[msg] += 1
for (msg, count) in sorted(msg_to_count.items()):
print('\t{} ({} test(s))'.format(msg, count))
return result

View File

@ -1,2 +0,0 @@
from .scram import *
from .exceptions import *

View File

@ -1,9 +0,0 @@
import uuid
def default_nonce_factory():
"""Generate a random string for digest authentication challenges.
The string should be cryptographicaly secure random pattern.
:return: the string generated.
:returntype: `bytes`
"""
return uuid.uuid4().hex.encode("us-ascii")

View File

@ -1,17 +0,0 @@
class ScramException(Exception):
pass
class BadChallengeException(ScramException):
pass
class ExtraChallengeException(ScramException):
pass
class ServerScramError(ScramException):
pass
class BadSuccessException(ScramException):
pass
class NotAuthorizedException(ScramException):
pass

View File

@ -1,561 +0,0 @@
#
# (C) Copyright 2011 Jacek Konieczny <jajcus@jajcus.net>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License Version
# 2.1 as published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this program; if not, write to the Free Software
# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
#
"""SCRAM authentication mechanisms for PyXMPP SASL implementation.
Normative reference:
- :RFC:`5802`
"""
from __future__ import absolute_import, division, unicode_literals
__docformat__ = "restructuredtext en"
import sys
import re
import logging
import hashlib
import hmac
from binascii import a2b_base64
from base64 import standard_b64encode
from .core import default_nonce_factory
from .exceptions import BadChallengeException, \
ExtraChallengeException, ServerScramError, BadSuccessException, \
NotAuthorizedException
logger = logging.getLogger("pyxmpp2_scram")
HASH_FACTORIES = {
"SHA-1": hashlib.sha1, # pylint: disable=E1101
"SHA-224": hashlib.sha224, # pylint: disable=E1101
"SHA-256": hashlib.sha256, # pylint: disable=E1101
"SHA-384": hashlib.sha384, # pylint: disable=E1101
"SHA-512": hashlib.sha512, # pylint: disable=E1101
"MD-5": hashlib.md5, # pylint: disable=E1101
}
VALUE_CHARS_RE = re.compile(br"^[\x21-\x2B\x2D-\x7E]+$")
_QUOTED_VALUE_RE = br"(?:[\x21-\x2B\x2D-\x7E]|=2C|=3D)+"
CLIENT_FIRST_MESSAGE_RE = re.compile(
br"^(?P<gs2_header>(?:y|n|p=(?P<cb_name>[a-zA-z0-9.-]+)),"
br"(?:a=(?P<authzid>" + _QUOTED_VALUE_RE + br"))?,)"
br"(?P<client_first_bare>(?P<mext>m=[^\000=]+,)?"
br"n=(?P<username>" + _QUOTED_VALUE_RE + br"),"
br"r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+)"
br"(?:,.*)?)$"
)
SERVER_FIRST_MESSAGE_RE = re.compile(
br"^(?P<mext>m=[^\000=]+,)?"
br"r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+),"
br"s=(?P<salt>[a-zA-Z0-9/+=]+),"
br"i=(?P<iteration_count>\d+)"
br"(?:,.*)?$"
)
CLIENT_FINAL_MESSAGE_RE = re.compile(
br"(?P<without_proof>c=(?P<cb>[a-zA-Z0-9/+=]+),"
br"(?:r=(?P<nonce>[\x21-\x2B\x2D-\x7E]+))"
br"(?:,.*)?)"
br",p=(?P<proof>[a-zA-Z0-9/+=]+)$"
)
SERVER_FINAL_MESSAGE_RE = re.compile(
br"^(?:e=(?P<error>[^,]+)|v=(?P<verifier>[a-zA-Z0-9/+=]+)(?:,.*)?)$")
class SCRAMOperations(object):
"""Functions used during SCRAM authentication and defined in the RFC.
"""
def __init__(self, hash_function_name):
self.hash_function_name = hash_function_name
self.hash_factory = HASH_FACTORIES[hash_function_name]
self.digest_size = self.hash_factory().digest_size
@staticmethod
def Normalize(str_):
"""The Normalize(str) function.
This one also accepts Unicode string input (in the RFC only UTF-8
strings are used).
"""
# pylint: disable=C0103
if isinstance(str_, bytes):
str_ = str_.decode("utf-8")
return str_.encode("utf-8")
def HMAC(self, key, str_):
"""The HMAC(key, str) function."""
# pylint: disable=C0103
return hmac.new(key, str_, self.hash_factory).digest()
def H(self, str_):
"""The H(str) function."""
# pylint: disable=C0103
return self.hash_factory(str_).digest()
if sys.version_info.major >= 3:
@staticmethod
# pylint: disable=C0103
def XOR(str1, str2):
"""The XOR operator for two byte strings."""
return bytes(a ^ b for a, b in zip(str1, str2))
else:
@staticmethod
# pylint: disable=C0103
def XOR(str1, str2):
"""The XOR operator for two byte strings."""
return "".join(chr(ord(a) ^ ord(b)) for a, b in zip(str1, str2))
def Hi(self, str_, salt, i):
"""The Hi(str, salt, i) function."""
# pylint: disable=C0103
Uj = self.HMAC(str_, salt + b"\000\000\000\001") # U1
result = Uj
for _ in range(2, i + 1):
Uj = self.HMAC(str_, Uj) # Uj = HMAC(str, Uj-1)
result = self.XOR(result, Uj) # ... XOR Uj-1 XOR Uj
return result
@staticmethod
def escape(data):
"""Escape the ',' and '=' characters for 'a=' and 'n=' attributes.
Replaces '=' with '=3D' and ',' with '=2C'.
:Parameters:
- `data`: string to escape
:Types:
- `data`: `bytes`
"""
return data.replace(b'=', b'=3D').replace(b',', b'=2C')
@staticmethod
def unescape(data):
"""Unescape the ',' and '=' characters for 'a=' and 'n=' attributes.
Reverse of `escape`.
:Parameters:
- `data`: string to unescape
:Types:
- `data`: `bytes`
"""
return data.replace(b'=2C', b',').replace(b'=3D', b'=')
class SCRAMClientAuthenticator(SCRAMOperations):
"""Provides SCRAM SASL authentication for a client.
:Ivariables:
- `password`: current authentication password
- `pformat`: current authentication password format
- `realm`: current authentication realm
"""
# pylint: disable-msg=R0902
def __init__(self, hash_name, channel_binding):
"""Initialize a `SCRAMClientAuthenticator` object.
:Parameters:
- `hash_function_name`: hash function name, e.g. ``"SHA-1"``
- `channel_binding`: `True` to enable channel binding
:Types:
- `hash_function_name`: `unicode`
- `channel_binding`: `bool`
"""
SCRAMOperations.__init__(self, hash_name)
self.name = "SCRAM-{0}".format(hash_name)
if channel_binding:
self.name += "-PLUS"
self.channel_binding = channel_binding
self.username = None
self.password = None
self.authzid = None
self._c_nonce = None
self._server_first_message = False
self._client_first_message_bare = False
self._gs2_header = None
self._finished = False
self._auth_message = None
self._salted_password = None
self._cb_data = None
@classmethod
def are_properties_sufficient(cls, properties):
return "username" in properties and "password" in properties
def start(self, properties):
self.username = properties["username"]
self.password = properties["password"]
self.authzid = properties.get("authzid", "")
c_nonce = properties.get("nonce_factory", default_nonce_factory)()
if not VALUE_CHARS_RE.match(c_nonce):
c_nonce = standard_b64encode(c_nonce)
self._c_nonce = c_nonce
if self.channel_binding:
cb_data = properties.get("channel-binding")
if not cb_data:
raise ValueError("No channel binding data provided")
if "tls-unique" in cb_data:
cb_type = "tls-unique"
elif "tls-server-end-point" in cb_data:
cb_type = "tls-server-end-point"
elif cb_data:
cb_type = cb_data.keys()[0]
self._cb_data = cb_data[cb_type]
cb_flag = b"p=" + cb_type.encode("utf-8")
else:
plus_name = self.name + "-PLUS"
if plus_name in properties.get("enabled_mechanisms", []):
# -PLUS is enabled (supported) on our side,
# but was not selected - that means it was not included
# in the server features
cb_flag = b"y"
else:
cb_flag = b"n"
if self.authzid:
authzid = b"a=" + self.escape(self.authzid.encode("utf-8"))
else:
authzid = b""
gs2_header = cb_flag + b"," + authzid + b","
self._gs2_header = gs2_header
nonce = b"r=" + c_nonce
client_first_message_bare = (b"n=" +
self.escape(self.username.encode("utf-8")) + b"," + nonce)
self._client_first_message_bare = client_first_message_bare
client_first_message = gs2_header + client_first_message_bare
return client_first_message
def challenge(self, challenge):
"""Process a challenge and return the response.
:Parameters:
- `challenge`: the challenge from server.
:Types:
- `challenge`: `bytes`
:return: the response
:returntype: bytes
:raises: `BadChallengeException`
"""
# pylint: disable=R0911
if not challenge:
raise BadChallengeException('Empty challenge')
if self._server_first_message:
return self._final_challenge(challenge)
match = SERVER_FIRST_MESSAGE_RE.match(challenge)
if not match:
raise BadChallengeException("Bad challenge syntax: {0!r}".format(challenge))
self._server_first_message = challenge
mext = match.group("mext")
if mext:
raise BadChallengeException("Unsupported extension received: {0!r}".format(mext))
nonce = match.group("nonce")
if not nonce.startswith(self._c_nonce):
raise BadChallengeException("Nonce does not start with our nonce")
salt = match.group("salt")
try:
salt = a2b_base64(salt)
except ValueError:
raise BadChallengeException("Bad base64 encoding for salt: {0!r}".format(salt))
iteration_count = match.group("iteration_count")
try:
iteration_count = int(iteration_count)
except ValueError:
raise BadChallengeException("Bad iteration_count: {0!r}".format(iteration_count))
return self._make_response(nonce, salt, iteration_count)
def _make_response(self, nonce, salt, iteration_count):
"""Make a response for the first challenge from the server.
:return: the response
:returntype: bytes
"""
self._salted_password = self.Hi(self.Normalize(self.password), salt,
iteration_count)
self.password = None # not needed any more
if self.channel_binding:
channel_binding = b"c=" + standard_b64encode(self._gs2_header +
self._cb_data)
else:
channel_binding = b"c=" + standard_b64encode(self._gs2_header)
# pylint: disable=C0103
client_final_message_without_proof = (channel_binding + b",r=" + nonce)
client_key = self.HMAC(self._salted_password, b"Client Key")
stored_key = self.H(client_key)
auth_message = ( self._client_first_message_bare + b"," +
self._server_first_message + b"," +
client_final_message_without_proof )
self._auth_message = auth_message
client_signature = self.HMAC(stored_key, auth_message)
client_proof = self.XOR(client_key, client_signature)
proof = b"p=" + standard_b64encode(client_proof)
client_final_message = (client_final_message_without_proof + b"," +
proof)
return client_final_message
def _final_challenge(self, challenge):
"""Process the second challenge from the server and return the
response.
:Parameters:
- `challenge`: the challenge from server.
:Types:
- `challenge`: `bytes`
:raises: `ExtraChallengeException`, `BadChallengeException`, `ServerScramError`, or `BadSuccessException`
"""
if self._finished:
return ExtraChallengeException()
match = SERVER_FINAL_MESSAGE_RE.match(challenge)
if not match:
raise BadChallengeException("Bad final message syntax: {0!r}".format(challenge))
error = match.group("error")
if error:
raise ServerScramError("{0!r}".format(error))
verifier = match.group("verifier")
if not verifier:
raise BadSuccessException("No verifier value in the final message")
server_key = self.HMAC(self._salted_password, b"Server Key")
server_signature = self.HMAC(server_key, self._auth_message)
if server_signature != a2b_base64(verifier):
raise BadSuccessException("Server verifier does not match")
self._finished = True
def finish(self, data):
"""Process success indicator from the server.
Process any addiitional data passed with the success.
Fail if the server was not authenticated.
:Parameters:
- `data`: an optional additional data with success.
:Types:
- `data`: `bytes`
:return: username and authzid
:returntype: `dict`
:raises: `BadSuccessException`"""
if not self._server_first_message:
raise BadSuccessException("Got success too early")
if self._finished:
return {"username": self.username, "authzid": self.authzid}
else:
self._final_challenge(data)
if self._finished:
return {"username": self.username,
"authzid": self.authzid}
else:
raise BadSuccessException("Something went wrong when processing additional"
" data with success?")
class SCRAMServerAuthenticator(SCRAMOperations):
"""Provides SCRAM SASL authentication for a server.
"""
def __init__(self, hash_name, channel_binding, password_database):
"""Initialize a `SCRAMClientAuthenticator` object.
:Parameters:
- `hash_function_name`: hash function name, e.g. ``"SHA-1"``
- `channel_binding`: `True` to enable channel binding
:Types:
- `hash_function_name`: `unicode`
- `channel_binding`: `bool`
"""
SCRAMOperations.__init__(self, hash_name)
self.name = "SCRAM-{0}".format(hash_name)
if channel_binding:
self.name += "-PLUS"
self.channel_binding = channel_binding
self.properties = None
self.out_properties = None
self.password_database = password_database
self._client_first_message_bare = None
self._stored_key = None
self._server_key = None
def start(self, properties, initial_response):
self.properties = properties
self._client_first_message_bare = None
self.out_properties = {}
if not initial_response:
return b""
return self.response(initial_response)
def response(self, response):
if self._client_first_message_bare:
logger.debug("Client final message: {0!r}".format(response))
return self._handle_final_response(response)
else:
logger.debug("Client first message: {0!r}".format(response))
return self._handle_first_response(response)
def _handle_first_response(self, response):
match = CLIENT_FIRST_MESSAGE_RE.match(response)
if not match:
raise NotAuthorizedException("Bad response syntax: {0!r}".format(response))
mext = match.group("mext")
if mext:
raise NotAuthorizedException("Unsupported extension received: {0!r}".format(mext))
gs2_header = match.group("gs2_header")
cb_name = match.group("cb_name")
if self.channel_binding:
if not cb_name:
raise NotAuthorizedException("{0!r} used with no channel-binding"
.format(self.name))
cb_name = cb_name.decode("utf-8")
if cb_name not in self.properties["channel-binding"]:
raise NotAuthorizedException("Channel binding data type {0!r} not available"
.format(cb_name))
else:
if gs2_header.startswith(b'y'):
plus_name = self.name + "-PLUS"
if plus_name in self.properties.get("enabled_mechanisms", []):
raise NotAuthorizedException("Channel binding downgrade attack detected")
elif gs2_header.startswith(b'p'):
# is this really an error?
raise NotAuthorizedException("Channel binding requested for {0!r}"
.format(self.name))
authzid = match.group("authzid")
if authzid:
self.out_properties['authzid'] = self.unescape(authzid
).decode("utf-8")
else:
self.out_properties['authzid'] = None
username = self.unescape(match.group("username")).decode("utf-8")
self.out_properties['username'] = username
nonce_factory = self.properties.get("nonce_factory",
default_nonce_factory)
properties = dict(self.properties)
properties.update(self.out_properties)
s_pformat = "SCRAM-{0}-SaltedPassword".format(self.hash_function_name)
k_pformat = "SCRAM-{0}-Keys".format(self.hash_function_name)
password, pformat = self.password_database.get_password(username,
(s_pformat, "plain"), properties)
if pformat == s_pformat:
if password is not None:
salt, iteration_count, salted_password = password
else:
logger.debug("No password for user {0!r}".format(username))
elif pformat != k_pformat:
salt = self.properties.get("SCRAM-salt")
if not salt:
salt = nonce_factory()
iteration_count = self.properties.get("SCRAM-iteration-count", 4096)
if pformat == "plain" and password is not None:
salted_password = self.Hi(self.Normalize(password), salt,
iteration_count)
else:
logger.debug("No password for user {0!r}".format(username))
password = None
# to prevent timing attack, compute the key anyway
salted_password = self.Hi(self.Normalize(""), salt,
iteration_count)
if pformat == k_pformat:
salt, iteration_count, stored_key, server_key = password
else:
client_key = self.HMAC(salted_password, b"Client Key")
stored_key = self.H(client_key)
server_key = self.HMAC(salted_password, b"Server Key")
if password is not None:
self._stored_key = stored_key
self._server_key = server_key
else:
self._stored_key = None
self._server_key = None
c_nonce = match.group("nonce")
s_nonce = nonce_factory()
if not VALUE_CHARS_RE.match(s_nonce):
s_nonce = standard_b64encode(s_nonce)
nonce = c_nonce + s_nonce
server_first_message = (
b"r=" + nonce
+ b",s=" + standard_b64encode(salt)
+ b",i=" + str(iteration_count).encode("utf-8")
)
self._nonce = nonce
self._cb_name = cb_name
self._gs2_header = gs2_header
self._client_first_message_bare = match.group("client_first_bare")
self._server_first_message = server_first_message
return server_first_message
def _handle_final_response(self, response):
match = CLIENT_FINAL_MESSAGE_RE.match(response)
if not match:
raise NotAuthorizedException("Bad response syntax: {0!r}".format(response))
if match.group("nonce") != self._nonce:
raise NotAuthorizedException("Bad nonce in the final client response")
cb_input = a2b_base64(match.group("cb"))
if not cb_input.startswith(self._gs2_header):
raise NotAuthorizedException("GS2 header in the final response ({0!r}) doesn't"
" match the one sent in the first message ({1!r})"
.format(cb_input, self._gs2_header))
if self._cb_name:
cb_data = cb_input[len(self._gs2_header):]
if cb_data != self.properties["channel-binding"][self._cb_name]:
raise NotAuthorizedException("Channel binding data doesn't match")
proof = a2b_base64(match.group("proof"))
auth_message = (self._client_first_message_bare + b"," +
self._server_first_message + b"," +
match.group("without_proof"))
if self._stored_key is None:
# compute something to prevent timing attack
client_signature = self.HMAC(b"", auth_message)
client_key = self.XOR(client_signature, proof)
self.H(client_key)
raise NotAuthorizedException("Authentication failed (bad username)")
client_signature = self.HMAC(self._stored_key, auth_message)
client_key = self.XOR(client_signature, proof)
if self.H(client_key) != self._stored_key:
raise NotAuthorizedException("Authentication failed")
server_signature = self.HMAC(self._server_key, auth_message)
server_final_message = b"v=" + standard_b64encode(server_signature)
return (self.out_properties, server_final_message)

View File

@ -1,334 +0,0 @@
from typing import Dict, List, Tuple
import pytest
from irctest import cases
from irctest.irc_utils.message_parser import parse_message
from irctest.patma import (
ANYDICT,
ANYLIST,
ANYOPTSTR,
ANYSTR,
ListRemainder,
NotStrRe,
RemainingKeys,
StrRe,
)
# fmt: off
MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [
(
# the specification:
dict(
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"PRIVMSG #chan hello",
"PRIVMSG #chan :hello",
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
":foo!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMSG #chan hello2",
"PRIVMSG #chan2 hello",
],
# and they each error with:
[
"expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']",
"expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']",
]
),
(
# the specification:
dict(
command="PRIVMSG",
params=["#chan", StrRe("hello.*")],
),
# matches:
[
"PRIVMSG #chan hello",
"PRIVMSG #chan :hello",
"PRIVMSG #chan hello2",
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
":foo!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMSG #chan :hi",
"PRIVMSG #chan2 hello",
],
# and they each error with:
[
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan', 'hi']",
"expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan2', 'hello']",
]
),
(
# the specification:
dict(
nick="foo",
command="PRIVMSG",
),
# matches:
[
":foo!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMSG #chan :hi",
":foo2!baz@qux PRIVMSG #chan hello",
"@tag1=bar :foo2!baz@qux PRIVMSG #chan :hello",
],
# and they each error with:
[
"expected nick to be foo, got None instead",
"expected nick to be foo, got foo2 instead",
"expected nick to be foo, got foo2 instead",
]
),
(
# the specification:
dict(
tags={"tag1": "bar"},
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"PRIVMSG #chan hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': 'bar'}, got {'tag1': 'value1'}",
"expected tags to match {'tag1': 'bar'}, got {}",
"expected tags to match {'tag1': 'bar'}, got {}",
]
),
(
# the specification:
dict(
tags={"tag1": ANYSTR},
command="PRIVMSG",
params=["#chan", ANYSTR],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"PRIVMSG #chan hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected tags to match {'tag1': ANYSTR}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': ANYSTR}, got {}",
"expected tags to match {'tag1': ANYSTR}, got {}",
]
),
(
# the specification:
dict(
tags={"tag1": "bar", **ANYDICT},
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"PRIVMSG #chan hello2",
"PRIVMSG #chan2 hello",
":foo!baz@qux PRIVMSG #chan hello",
],
# and they each error with:
[
"expected command to be 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']",
"expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}",
]
),
(
# the specification:
dict(
tags={"tag1": "bar", RemainingKeys(NotStrRe("tag2")): ANYOPTSTR},
command="PRIVMSG",
params=["#chan", "hello"],
),
# matches:
[
"@tag1=bar PRIVMSG #chan :hello",
"@tag1=bar :foo!baz@qux PRIVMSG #chan :hello",
"@tag1=bar;tag3= PRIVMSG #chan :hello",
],
# and does not match:
[
"PRIVMG #chan :hello",
"@tag1=value1 PRIVMSG #chan :hello",
"@tag1=bar;tag2= PRIVMSG #chan :hello",
"@tag1=bar;tag2=baz PRIVMSG #chan :hello",
],
# and they each error with:
[
"expected command to be PRIVMSG, got PRIVMG",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'value1'}",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': ''}",
"expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': 'baz'}",
]
),
(
# the specification:
dict(
command="005",
params=["nick", "FOO=1", *ANYLIST],
),
# matches:
[
"005 nick FOO=1",
"005 nick FOO=1 BAR=2",
],
# and does not match:
[
"005 nick",
"005 nick BAR=2",
],
# and they each error with:
[
"expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick']",
"expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick', 'BAR=2']",
]
),
(
# the specification:
dict(
command="005",
params=["nick", ListRemainder(ANYSTR, min_length=1)],
),
# matches:
[
"005 nick FOO=1",
"005 nick FOO=1 BAR=2",
"005 nick BAR=2",
],
# and does not match:
[
"005 nick",
],
# and they each error with:
[
"expected params to match ['nick', ListRemainder(ANYSTR, min_length=1)], got ['nick']",
]
),
(
# the specification:
dict(
command="005",
params=["nick", ListRemainder(StrRe("[A-Z]+=.*"), min_length=1)],
),
# matches:
[
"005 nick FOO=1",
"005 nick FOO=1 BAR=2",
"005 nick BAR=2",
],
# and does not match:
[
"005 nick",
"005 nick foo=1",
],
# and they each error with:
[
"expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick']",
"expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick', 'foo=1']",
]
),
(
# the specification:
dict(
command="PING",
params=["abc"]
),
# matches:
[
"PING abc",
],
# and does not match:
[
"PONG def"
],
# and they each error with:
[
"expected command to be PING, got PONG"
]
),
]
# fmt: on
class IrcTestCaseTestCase(cases._IrcTestCase):
@pytest.mark.parametrize(
"spec,msg",
[
pytest.param(spec, msg, id=f"{spec}-{msg}")
for (spec, positive_matches, _, _) in MESSAGE_SPECS
for msg in positive_matches
],
)
def test_message_matching_positive(self, spec, msg):
assert not self.messageDiffers(parse_message(msg), **spec), msg
assert self.messageEqual(parse_message(msg), **spec), msg
self.assertMessageMatch(parse_message(msg), **spec), msg
@pytest.mark.parametrize(
"spec,msg",
[
pytest.param(spec, msg, id=f"{spec}-{msg}")
for (spec, _, negative_matches, _) in MESSAGE_SPECS
for msg in negative_matches
],
)
def test_message_matching_negative(self, spec, msg):
assert self.messageDiffers(parse_message(msg), **spec), msg
assert not self.messageEqual(parse_message(msg), **spec), msg
with pytest.raises(AssertionError):
self.assertMessageMatch(parse_message(msg), **spec), msg
@pytest.mark.parametrize(
"spec,msg,error_string",
[
pytest.param(spec, msg, error_string, id=error_string)
for (spec, _, negative_matches, error_stringgexps) in MESSAGE_SPECS
for (msg, error_string) in zip(negative_matches, error_stringgexps)
],
)
def test_message_matching_negative_message(self, spec, msg, error_string):
self.assertIn(error_string, self.messageDiffers(parse_message(msg), **spec))

View File

@ -1,70 +0,0 @@
"""
<http://ircv3.net/specs/extensions/account-tag-3.2.html>
"""
from irctest import cases
@cases.mark_services
class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
def connectRegisteredClient(self, nick):
self.addClient()
self.sendLine(2, "CAP LS 302")
capabilities = self.getCapLs(2)
assert "sasl" in capabilities
self.sendLine(2, "USER f * * :Realname")
self.sendLine(2, "NICK {}".format(nick))
self.sendLine(2, "CAP REQ :sasl")
self.getRegistrationMessage(2)
self.sendLine(2, "AUTHENTICATE PLAIN")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="AUTHENTICATE",
params=["+"],
fail_msg="Sent “AUTHENTICATE PLAIN”, server should have "
"replied with “AUTHENTICATE +”, but instead sent: {msg}",
)
self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="900",
fail_msg="Did not send 900 after correct SASL authentication.",
)
self.sendLine(2, "USER f * * :Realname")
self.sendLine(2, "NICK {}".format(nick))
self.sendLine(2, "CAP END")
self.skipToWelcome(2)
@cases.mark_capabilities("account-tag")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testPrivmsg(self):
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
self.getMessages(1)
self.controller.registerUser(self, "jilles", "sesame")
self.connectRegisteredClient("bar")
self.sendLine(2, "PRIVMSG foo :hi")
self.getMessages(2)
m = self.getMessage(1)
self.assertMessageMatch(
m, command="PRIVMSG", params=["foo", "hi"], tags={"account": "jilles"}
)
@cases.mark_capabilities("account-tag")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testInvite(self):
self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True)
self.getMessages(1)
self.controller.registerUser(self, "jilles", "sesame")
self.connectRegisteredClient("bar")
self.sendLine(2, "JOIN #chan")
self.getMessages(2)
self.sendLine(2, "INVITE foo #chan")
self.getMessages(2)
m = self.getMessage(1)
self.assertMessageMatch(
m, command="INVITE", params=["foo", "#chan"], tags={"account": "jilles"}
)

View File

@ -1,136 +0,0 @@
from irctest import cases
from irctest.numerics import RPL_AWAY, RPL_NOWAWAY, RPL_UNAWAY, RPL_USERHOST
from irctest.patma import StrRe
class AwayTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC2812", "Modern")
def testAway(self):
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.connectClient("qux")
self.sendLine(2, "PRIVMSG bar :what's up")
self.assertMessageMatch(
self.getMessage(2),
command=RPL_AWAY,
params=["qux", "bar", "I'm not here right now"],
)
self.sendLine(1, "AWAY")
replies = self.getMessages(1)
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
self.sendLine(2, "PRIVMSG bar :what's up")
replies = self.getMessages(2)
self.assertEqual(len(replies), 0)
@cases.mark_specifications("Modern")
def testAwayAck(self):
"""
"The server acknowledges the change in away status by returning the
`RPL_NOWAWAY` and `RPL_UNAWAY` numerics."
-- https://github.com/ircdocs/modern-irc/pull/100
"""
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.sendLine(1, "AWAY")
replies = self.getMessages(1)
self.assertIn(RPL_UNAWAY, [msg.command for msg in replies])
@cases.mark_specifications("Modern")
def testAwayPrivmsg(self):
"""
"Servers SHOULD notify clients when a user they're interacting with
is away when relevant"
-- https://github.com/ircdocs/modern-irc/pull/100
"<client> <nick> :<message>"
-- https://modern.ircdocs.horse/#rplaway-301
"""
self.connectClient("bar")
self.connectClient("qux")
self.sendLine(2, "PRIVMSG bar :what's up")
self.assertEqual(self.getMessages(2), [])
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.sendLine(2, "PRIVMSG bar :what's up")
self.assertMessageMatch(
self.getMessage(2),
command=RPL_AWAY,
params=["qux", "bar", "I'm not here right now"],
)
@cases.mark_specifications("Modern")
def testAwayWhois(self):
"""
"Servers SHOULD notify clients when a user they're interacting with
is away when relevant"
-- https://github.com/ircdocs/modern-irc/pull/100
"<client> <nick> :<message>"
-- https://modern.ircdocs.horse/#rplaway-301
"""
self.connectClient("bar")
self.connectClient("qux")
self.sendLine(2, "WHOIS bar")
msgs = [msg for msg in self.getMessages(2) if msg.command == RPL_AWAY]
self.assertEqual(
len(msgs),
0,
fail_msg="Expected no RPL_AWAY (301), got: {}",
extra_format=(msgs,),
)
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.sendLine(2, "WHOIS bar")
msgs = [msg for msg in self.getMessages(2) if msg.command == RPL_AWAY]
self.assertEqual(
len(msgs),
1,
fail_msg="Expected one RPL_AWAY (301), got: {}",
extra_format=(msgs,),
)
self.assertMessageMatch(
msgs[0], command=RPL_AWAY, params=["qux", "bar", "I'm not here right now"]
)
@cases.mark_specifications("Modern")
def testAwayUserhost(self):
"""
"Servers SHOULD notify clients when a user they're interacting with
is away when relevant"
-- https://github.com/ircdocs/modern-irc/pull/100
"<client> <nick> :<message>"
-- https://modern.ircdocs.horse/#rplaway-301
"""
self.connectClient("bar")
self.connectClient("qux")
self.sendLine(2, "USERHOST bar")
self.assertMessageMatch(
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"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.sendLine(2, "USERHOST bar")
self.assertMessageMatch(
self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=-.*")]
)

View File

@ -1,62 +0,0 @@
"""
<https://ircv3.net/specs/extensions/away-notify-3.1>
"""
from irctest import cases
class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
@cases.mark_capabilities("away-notify")
def testAwayNotify(self):
"""Basic away-notify test."""
self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True)
self.getMessages(1)
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.getMessages(2)
self.joinChannel(2, "#chan")
self.getMessages(2)
self.getMessages(1)
self.sendLine(2, "AWAY :i'm going away")
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,),
)
@cases.mark_capabilities("away-notify")
def testAwayNotifyOnJoin(self):
"""The away-notify specification states:
"Clients will be sent an AWAY message [...] when a user joins
and has an away message set."
"""
self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True)
self.getMessages(1)
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.getMessages(2)
self.sendLine(2, "AWAY :i'm already away")
self.getMessages(2)
self.joinChannel(2, "#chan")
self.getMessages(2)
messages = [msg for msg in self.getMessages(1) if msg.command == "AWAY"]
self.assertEqual(
len(messages),
1,
"Someone away joined a channel, "
"but users in the channel did not get AWAY messages.",
)
awayNotify = messages[0]
self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm already away"])
self.assertTrue(
awayNotify.prefix.startswith("bar!"),
"Unexpected away-notify source: %s" % (awayNotify.prefix,),
)

View File

@ -1,153 +0,0 @@
"""
Draft bot mode specification, as defined in
<https://ircv3.net/specs/extensions/bot-mode>
"""
from irctest import cases, runner
from irctest.numerics import RPL_WHOISBOT
from irctest.patma import ANYDICT, ANYSTR, StrRe
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("BOT")
class BotModeTestCase(cases.BaseServerTestCase):
def setUp(self):
super().setUp()
self.connectClient("modegettr")
if "BOT" not in self.server_support:
raise runner.IsupportTokenNotSupported("BOT")
self._mode_char = self.server_support["BOT"]
def _initBot(self):
self.assertEqual(
len(self._mode_char),
1,
fail_msg=(
f"BOT ISUPPORT token should be exactly one character, "
f"but is: {self._mode_char!r}"
),
)
self.connectClient("botnick", "bot")
self.sendLine("bot", f"MODE botnick +{self._mode_char}")
# Check echoed mode
while True:
msg = self.getMessage("bot")
if msg.command != "NOTICE":
# Unreal sends the BOTMOTD here
self.assertMessageMatch(
msg,
command="MODE",
params=["botnick", StrRe(r"\+?" + self._mode_char)],
)
break
def testBotMode(self):
self._initBot()
def testBotWhois(self):
self._initBot()
self.connectClient("usernick", "user")
self.sendLine("user", "WHOIS botnick")
messages = self.getMessages("user")
messages = [msg for msg in messages if msg.command == RPL_WHOISBOT]
self.assertEqual(
len(messages),
1,
msg=(
f"Expected exactly one RPL_WHOISBOT ({RPL_WHOISBOT}), "
f"got: {messages}"
),
)
(message,) = messages
self.assertMessageMatch(
message, command=RPL_WHOISBOT, params=["usernick", "botnick", ANYSTR]
)
def testBotPrivateMessage(self):
self._initBot()
self.connectClient(
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
)
self.sendLine("bot", "PRIVMSG usernick :beep boop")
self.getMessages("bot") # Synchronizes
self.assertMessageMatch(
self.getMessage("user"),
command="PRIVMSG",
params=["usernick", "beep boop"],
tags={"draft/bot": None, **ANYDICT},
)
def testBotChannelMessage(self):
self._initBot()
self.connectClient(
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
)
self.sendLine("bot", "JOIN #chan")
self.sendLine("user", "JOIN #chan")
self.getMessages("bot")
self.getMessages("user")
self.sendLine("bot", "PRIVMSG #chan :beep boop")
self.getMessages("bot") # Synchronizes
self.assertMessageMatch(
self.getMessage("user"),
command="PRIVMSG",
params=["#chan", "beep boop"],
tags={"draft/bot": None, **ANYDICT},
)
def testBotWhox(self):
self._initBot()
self.connectClient(
"usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True
)
self.sendLine("bot", "JOIN #chan")
self.sendLine("user", "JOIN #chan")
self.getMessages("bot")
self.getMessages("user")
self.sendLine("user", "WHO #chan")
msg1 = self.getMessage("user")
self.assertMessageMatch(
msg1, command="352", fail_msg="Expected WHO response (352), got: {msg}"
)
msg2 = self.getMessage("user")
self.assertMessageMatch(
msg2, command="352", fail_msg="Expected WHO response (352), got: {msg}"
)
if msg1.params[5] == "botnick":
msg = msg1
elif msg2.params[5] == "botnick":
msg = msg2
else:
assert False, "No WHO response contained botnick"
self.assertMessageMatch(
msg,
command="352",
params=[
"usernick",
"#chan",
ANYSTR, # ident
ANYSTR, # hostname
ANYSTR, # server
"botnick",
StrRe(f".*{self._mode_char}.*"),
ANYSTR, # realname
],
fail_msg="Expected WHO response with bot flag, got: {msg}",
)

View File

@ -1,160 +0,0 @@
from irctest import cases
from irctest.irc_utils.sasl import sasl_plain_blob
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
from irctest.patma import ANYSTR, StrRe
@cases.mark_services
class BouncerTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testBouncer(self):
"""Test basic bouncer functionality."""
self.controller.registerUser(self, "observer", "observerpassword")
self.controller.registerUser(self, "testuser", "mypassword")
self.connectClient(
"observer", password="observerpassword", capabilities=["sasl"]
)
self.joinChannel(1, "#chan")
self.sendLine(1, "CAP REQ :message-tags server-time")
self.getMessages(1)
self.addClient()
self.sendLine(2, "CAP LS 302")
self.sendLine(2, "AUTHENTICATE PLAIN")
self.sendLine(2, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(2, "NICK testnick")
self.sendLine(2, "USER a 0 * a")
self.sendLine(2, "CAP REQ :server-time message-tags")
self.sendLine(2, "CAP END")
messages = self.getMessages(2)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
# should see a regburst for testnick
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
self.joinChannel(2, "#chan")
self.addClient()
self.sendLine(3, "CAP LS 302")
self.sendLine(3, "AUTHENTICATE PLAIN")
self.sendLine(3, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(3, "NICK testnick")
self.sendLine(3, "USER a 0 * a")
self.sendLine(3, "CAP REQ :server-time message-tags account-tag")
self.sendLine(3, "CAP END")
messages = self.getMessages(3)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
# should see the *same* regburst for testnick
self.assertMessageMatch(welcomes[0], params=["testnick", ANYSTR])
joins = [message for message in messages if message.command == "JOIN"]
# we should be automatically joined to #chan
self.assertMessageMatch(joins[0], params=["#chan"])
# disable multiclient in nickserv
self.sendLine(3, "NS SET MULTICLIENT OFF")
self.getMessages(3)
self.addClient()
self.sendLine(4, "CAP LS 302")
self.sendLine(4, "AUTHENTICATE PLAIN")
self.sendLine(4, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(4, "NICK testnick")
self.sendLine(4, "USER a 0 * a")
self.sendLine(4, "CAP REQ :server-time message-tags")
self.sendLine(4, "CAP END")
# with multiclient disabled, we should not be able to attach to the nick
messages = self.getMessages(4)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 0)
errors = [
message for message in messages if message.command == ERR_NICKNAMEINUSE
]
self.assertEqual(len(errors), 1)
self.sendLine(3, "NS SET MULTICLIENT ON")
self.getMessages(3)
self.addClient()
self.sendLine(5, "CAP LS 302")
self.sendLine(5, "AUTHENTICATE PLAIN")
self.sendLine(5, sasl_plain_blob("testuser", "mypassword"))
self.sendLine(5, "NICK testnick")
self.sendLine(5, "USER a 0 * a")
self.sendLine(5, "CAP REQ server-time")
self.sendLine(5, "CAP END")
messages = self.getMessages(5)
welcomes = [message for message in messages if message.command == RPL_WELCOME]
self.assertEqual(len(welcomes), 1)
self.sendLine(1, "@+clientOnlyTag=Value PRIVMSG #chan :hey")
self.getMessages(1)
messagesfortwo = [
msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"
]
messagesforthree = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
]
self.assertEqual(len(messagesfortwo), 1)
self.assertEqual(len(messagesforthree), 1)
messagefortwo = messagesfortwo[0]
messageforthree = messagesforthree[0]
messageforfive = self.getMessage(5)
self.assertMessageMatch(messagefortwo, params=["#chan", "hey"])
self.assertMessageMatch(messageforthree, params=["#chan", "hey"])
self.assertMessageMatch(messageforfive, params=["#chan", "hey"])
self.assertIn("time", messagefortwo.tags)
self.assertIn("time", messageforthree.tags)
self.assertIn("time", messageforfive.tags)
# 3 has account-tag
self.assertIn("account", messageforthree.tags)
# should get same msgid
self.assertEqual(messagefortwo.tags["msgid"], messageforthree.tags["msgid"])
# 5 only has server-time, shouldn't get account or msgid tags
self.assertNotIn("account", messageforfive.tags)
self.assertNotIn("msgid", messageforfive.tags)
# test that copies of sent messages go out to other sessions
self.sendLine(2, "PRIVMSG observer :this is a direct message")
self.getMessages(2)
messageForRecipient = [
msg for msg in self.getMessages(1) if msg.command == "PRIVMSG"
][0]
copyForOtherSession = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
][0]
self.assertEqual(messageForRecipient.params, copyForOtherSession.params)
self.assertEqual(
messageForRecipient.tags["msgid"], copyForOtherSession.tags["msgid"]
)
self.sendLine(2, "QUIT :two out")
quitLines = [msg for msg in self.getMessages(2) if msg.command == "QUIT"]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*two out.*")])
# neither the observer nor the other attached session should see a quit here
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
self.assertEqual(quitLines, [])
quitLines = [msg for msg in self.getMessages(3) if msg.command == "QUIT"]
self.assertEqual(quitLines, [])
# session 3 should be untouched at this point
self.sendLine(1, "@+clientOnlyTag=Value PRIVMSG #chan :hey again")
self.getMessages(1)
messagesforthree = [
msg for msg in self.getMessages(3) if msg.command == "PRIVMSG"
]
self.assertEqual(len(messagesforthree), 1)
self.assertMessageMatch(
messagesforthree[0], command="PRIVMSG", params=["#chan", "hey again"]
)
self.sendLine(5, "QUIT :five out")
self.getMessages(5)
self.sendLine(3, "QUIT :three out")
quitLines = [msg for msg in self.getMessages(3) if msg.command == "QUIT"]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])
# observer should see *this* quit
quitLines = [msg for msg in self.getMessages(1) if msg.command == "QUIT"]
self.assertEqual(len(quitLines), 1)
self.assertMessageMatch(quitLines[0], params=[StrRe(".*three out.*")])

View File

@ -1,134 +0,0 @@
"""Sends packets with various length to check the server reassembles them
correctly. Also checks truncation"""
import socket
import time
import pytest
from irctest import cases
from irctest.irc_utils import message_parser
from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYSTR
def _sendWhole(self, line):
print("(repr) 1 -> S", repr(line.encode()))
self.clients[1].conn.sendall(line.encode())
def _sendCharPerChar(self, line):
print("(repr) 1 -> S", repr(line.encode()))
for char in line:
self.clients[1].conn.sendall(char.encode())
def _sendBytePerByte(self, line):
print("(repr) 1 -> S", repr(line.encode()))
for byte in line.encode():
self.clients[1].conn.sendall(bytes([byte]))
class BufferingTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize(
"sender_function,colon",
[
pytest.param(_sendWhole, "", id="whole-no colon"),
pytest.param(_sendCharPerChar, "", id="charperchar-no colon"),
pytest.param(_sendBytePerByte, "", id="byteperbyte-no colon"),
pytest.param(_sendWhole, ":", id="whole-colon"),
pytest.param(_sendCharPerChar, ":", id="charperchar-colon"),
pytest.param(_sendBytePerByte, ":", id="byteperbyte-colon"),
],
)
def testNoTags(self, sender_function, colon):
self.connectClient("nick1")
self.connectClient("nick2")
overhead = self.get_overhead(1, 2, colon=colon)
print(f"overhead is {overhead}")
line = f"PRIVMSG nick2 {colon}"
remaining_size = 512 - len(line) - len("\r\n")
emoji_size = len("😃".encode())
payloads = [
# one byte:
"a",
# one multi-byte char:
"😃",
# full payload, will be truncated
"a" * remaining_size,
"a" * (remaining_size - emoji_size) + "😃",
# full payload to recipient:
"a" * (remaining_size - overhead),
"a" * (remaining_size - emoji_size - overhead) + "😃",
# full payload to recipient plus one byte:
"a" * (remaining_size - overhead + 1),
"a" * (remaining_size - emoji_size - overhead + 1) + "😃",
# full payload to recipient plus two bytes:
"a" * (remaining_size - emoji_size - overhead + 1) + "😃",
]
for payload in payloads:
sender_function(self, line + payload + "\r\n")
messages = self.getMessages(1)
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"),
512 - overhead,
"Got ERR_INPUTTOOLONG for a messag that should fit "
"withing 512 characters.",
)
continue
received_line = self._getLine(2)
print("(repr) S -> 2", repr(received_line))
try:
decoded_line = received_line.decode()
except UnicodeDecodeError:
# server truncated a byte off the emoji at the end
if "UTF8ONLY" in self.server_support:
# https://github.com/ircv3/ircv3-specifications/pull/432
raise self.failureException(
f"Server advertizes UTF8ONLY, but sent an invalid UTF8 "
f"message: {received_line!r}"
)
payload_intact = False
else:
msg = message_parser.parse_message(decoded_line)
self.assertMessageMatch(
msg, command="PRIVMSG", params=["nick2", ANYSTR]
)
payload_intact = msg.params[1] == payload
if not payload_intact:
# truncated
self.assertLessEqual(len(received_line), 512, received_line)
if received_line.endswith(b"[CUT]\r\n"):
# ngircd
received_line = received_line[0:-7] + b"\r\n"
self.assertTrue(
payload.encode().startswith(
received_line.split(b" ")[-1].strip().lstrip(b":")
),
f"expected payload to be a prefix of {payload!r}, "
f"but got {payload!r}",
)
def get_overhead(self, client1, client2, colon):
self.sendLine(client1, f"PRIVMSG nick2 {colon}a\r\n")
line = self._getLine(client2)
return len(line) - len(f"PRIVMSG nick2 {colon}a\r\n")
def _getLine(self, client) -> bytes:
line = b""
for _ in range(30):
try:
data = self.clients[client].conn.recv(4096)
except socket.timeout:
data = b""
line += data
if data.endswith(b"\r\n"):
return line
time.sleep(0.1)
print(f"{client}: Waiting...")
return line

View File

@ -1,174 +0,0 @@
from irctest import cases
from irctest.patma import ANYSTR
from irctest.runner import CapabilityNotSupported, ImplementationChoice
class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
@cases.mark_specifications("IRCv3")
def testNoReq(self):
"""Test the server handles gracefully clients which do not send
REQs.
“Clients that support capabilities but do not wish to enter
negotiation SHOULD send CAP END upon connection to the server.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-end-subcommand>
""" # noqa
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
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 testReqUnavailable(self):
"""Test the server handles gracefully clients which request
capabilities that are not available.
<http://ircv3.net/specs/core/capability-negotiation-3.1.html>
"""
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP REQ :foo")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo"],
fail_msg="Expected CAP NAK after requesting non-existing "
"capability, 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")
def testNakExactString(self):
"""“The argument of the NAK subcommand MUST consist of at least the
first 100 characters of the capability list in the REQ subcommand which
triggered the NAK.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-nak-subcommand>
""" # noqa
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
# Five should be enough to check there is no reordering, even
# alphabetical
self.sendLine(1, "CAP REQ :foo qux bar baz qux quux")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo qux bar baz qux quux"],
fail_msg="Expected “CAP NAK :foo qux bar baz qux quux” after "
"sending “CAP REQ :foo qux bar baz qux quux”, but got {msg}.",
)
@cases.mark_specifications("IRCv3")
def testNakWhole(self):
"""“The capability identifier set must be accepted as a whole, or
rejected entirely.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-req-subcommand>
""" # noqa
self.addClient(1)
self.sendLine(1, "CAP LS 302")
self.assertIn("multi-prefix", self.getCapLs(1))
self.sendLine(1, "CAP REQ :foo multi-prefix bar")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo multi-prefix bar"],
fail_msg="Expected “CAP NAK :foo multi-prefix bar” after "
"sending “CAP REQ :foo multi-prefix bar”, but got {msg}.",
)
self.sendLine(1, "CAP REQ :multi-prefix bar")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "multi-prefix bar"],
fail_msg="Expected “CAP NAK :multi-prefix bar” after "
"sending “CAP REQ :multi-prefix bar”, but got {msg}.",
)
self.sendLine(1, "CAP REQ :foo multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "NAK", "foo multi-prefix"],
fail_msg="Expected “CAP NAK :foo multi-prefix” after "
"sending “CAP REQ :foo multi-prefix”, but got {msg}.",
)
# TODO: make sure multi-prefix is not enabled at this point
self.sendLine(1, "CAP REQ :multi-prefix")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=[ANYSTR, "ACK", "multi-prefix"],
fail_msg="Expected “CAP ACK :multi-prefix” after "
"sending “CAP REQ :multi-prefix”, but got {msg}.",
)
@cases.mark_specifications("IRCv3")
def testCapRemovalByClient(self):
"""Test CAP LIST and removal of caps via CAP REQ :-tagname."""
cap1 = "echo-message"
cap2 = "server-time"
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())):
raise CapabilityNotSupported(f"{cap1} or {cap2}")
self.sendLine(1, f"CAP REQ :{cap1} {cap2}")
self.sendLine(1, "nick bar")
self.sendLine(1, "user user 0 * realname")
self.sendLine(1, "CAP END")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(m, command="CAP", params=[ANYSTR, "ACK", ANYSTR])
self.assertEqual(
set(m.params[2].split()), {cap1, cap2}, "Didn't ACK both REQed caps"
)
self.skipToWelcome(1)
self.sendLine(1, "CAP LIST")
messages = self.getMessages(1)
cap_list = [m for m in messages if m.command == "CAP"][0]
enabled_caps = set(cap_list.params[2].split())
enabled_caps.discard("cap-notify") # implicitly added by some impls
self.assertEqual(enabled_caps, {cap1, cap2})
self.sendLine(2, "PRIVMSG bar :hi")
self.getMessages(2) # Synchronize
m = self.getMessage(1)
self.assertIn("time", m.tags, m)
# remove the server-time 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}"]):
self.assertMessageMatch(
m, command="CAP", params=[ANYSTR, "NAK", f"-{cap2}"]
)
raise ImplementationChoice(f"Does not support CAP REQ -{cap2}")
# server-time should be disabled
self.sendLine(1, "CAP LIST")
messages = self.getMessages(1)
cap_list = [m for m in messages if m.command == "CAP"][0]
enabled_caps = set(cap_list.params[2].split())
enabled_caps.discard("cap-notify") # implicitly added by some impls
self.assertEqual(enabled_caps, {cap1})
self.assertNotIn("time", cap_list.tags)

View File

@ -1,61 +0,0 @@
import pytest
from irctest import cases, client_mock, runner
class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize(
"casemapping,name1,name2",
[
("ascii", "#Foo", "#foo"),
("rfc1459", "#Foo", "#foo"),
("rfc1459", "#F]|oo{", "#f}\\oo["),
("rfc1459", "#F}o\\o[", "#f]o|o{"),
],
)
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testChannelsEquivalent(self, casemapping, name1, name2):
self.connectClient("foo")
self.connectClient("bar")
if self.server_support["CASEMAPPING"] != casemapping:
raise runner.NotImplementedByController(
"Casemapping {} not implemented".format(casemapping)
)
self.joinClient(1, name1)
self.joinClient(2, name2)
try:
m = self.getMessage(1)
self.assertMessageMatch(m, command="JOIN", nick="bar")
except client_mock.NoMessageException:
raise AssertionError(
"Channel names {} and {} are not equivalent.".format(name1, name2)
)
@pytest.mark.parametrize(
"casemapping,name1,name2",
[
("ascii", "#Foo", "#fooa"),
("rfc1459", "#Foo", "#fooa"),
],
)
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testChannelsNotEquivalent(self, casemapping, name1, name2):
self.connectClient("foo")
self.connectClient("bar")
if self.server_support["CASEMAPPING"] != casemapping:
raise runner.NotImplementedByController(
"Casemapping {} not implemented".format(casemapping)
)
self.joinClient(1, name1)
self.joinClient(2, name2)
try:
m = self.getMessage(1)
except client_mock.NoMessageException:
pass
else:
self.assertMessageMatch(
m, command="JOIN", nick="bar"
) # This should always be true
raise AssertionError(
"Channel names {} and {} are equivalent.".format(name1, name2)
)

View File

@ -1,52 +0,0 @@
from irctest import cases
from irctest.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
class ChannelForwardingTestCase(cases.BaseServerTestCase):
"""Test the +f channel forwarding mode."""
@cases.mark_specifications("Ergo")
def testChannelForwarding(self):
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS)
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
self.joinChannel("bar", "#bar")
self.joinChannel("bar", "#bar_two")
self.joinChannel("baz", "#baz")
self.sendLine("bar", "MODE #bar +f #nonexistent")
msg = self.getMessage("bar")
self.assertMessageMatch(msg, command=ERR_INVALIDMODEPARAM)
# need chanops in the target channel as well
self.sendLine("bar", "MODE #bar +f #baz")
responses = set(msg.command for msg in self.getMessages("bar"))
self.assertIn(ERR_CHANOPRIVSNEEDED, responses)
self.sendLine("bar", "MODE #bar +f #bar_two")
msg = self.getMessage("bar")
self.assertMessageMatch(msg, command="MODE", params=["#bar", "+f", "#bar_two"])
# can still join the channel fine
self.joinChannel("baz", "#bar")
self.sendLine("baz", "PART #bar")
self.getMessages("baz")
# now make it invite-only, which should cause forwarding
self.sendLine("bar", "MODE #bar +i")
self.getMessages("bar")
self.sendLine("baz", "JOIN #bar")
msgs = self.getMessages("baz")
forward = [msg for msg in msgs if msg.command == ERR_LINKCHANNEL]
self.assertEqual(forward[0].params[:3], ["baz", "#bar", "#bar_two"])
join = [msg for msg in msgs if msg.command == "JOIN"]
self.assertMessageMatch(join[0], params=["#bar_two"])

View File

@ -1,59 +0,0 @@
from irctest import cases
from irctest.numerics import ERR_CHANOPRIVSNEEDED
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
RENAME_CAP = "draft/channel-rename"
class ChannelRenameTestCase(cases.BaseServerTestCase):
"""Basic tests for channel-rename."""
@cases.mark_specifications("Ergo")
def testChannelRename(self):
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS + [RENAME_CAP])
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
self.joinChannel("bar", "#bar")
self.joinChannel("baz", "#bar")
self.getMessages("bar")
self.getMessages("baz")
self.sendLine("bar", "RENAME #bar #qux :no reason")
self.assertMessageMatch(
self.getMessage("bar"),
command="RENAME",
params=["#bar", "#qux", "no reason"],
)
legacy_responses = self.getMessages("baz")
self.assertEqual(
1,
len(
[
msg
for msg in legacy_responses
if msg.command == "PART" and msg.params[0] == "#bar"
]
),
)
self.assertEqual(
1,
len(
[
msg
for msg in legacy_responses
if msg.command == "JOIN" and msg.params == ["#qux"]
]
),
)
self.joinChannel("baz", "#bar")
self.sendLine("baz", "MODE #bar +k beer")
self.assertNotIn(
ERR_CHANOPRIVSNEEDED, [msg.command for msg in self.getMessages("baz")]
)

View File

@ -1,707 +0,0 @@
import secrets
import time
import pytest
from irctest import cases
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYSTR
CHATHISTORY_CAP = "draft/chathistory"
EVENT_PLAYBACK_CAP = "draft/event-playback"
# Keep this in sync with validate_chathistory()
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
@cases.mark_specifications("IRCv3")
@cases.mark_services
class ChathistoryTestCase(cases.BaseServerTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(chathistory=True)
def testInvalidTargets(self):
bar, pw = random_name("bar"), random_name("pw")
self.controller.registerUser(self, bar, pw)
self.connectClient(
bar,
name=bar,
capabilities=[
"batch",
"labeled-response",
"message-tags",
"server-time",
"sasl",
CHATHISTORY_CAP,
],
password=pw,
skip_if_cap_nak=True,
)
self.getMessages(bar)
qux = random_name("qux")
real_chname = random_name("#real_channel")
self.connectClient(qux, name=qux)
self.joinChannel(qux, real_chname)
self.getMessages(qux)
# test a nonexistent channel
self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10")
msgs = self.getMessages(bar)
msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r
self.assertMessageMatch(
msgs[0],
command="FAIL",
params=["CHATHISTORY", "INVALID_TARGET", "LATEST", ANYSTR, ANYSTR],
)
# as should a real channel to which one is not joined:
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,))
msgs = self.getMessages(bar)
self.assertMessageMatch(
msgs[0],
command="FAIL",
params=["CHATHISTORY", "INVALID_TARGET", "LATEST", ANYSTR, ANYSTR],
)
@pytest.mark.private_chathistory
def testMessagesToSelf(self):
bar, pw = random_name("bar"), random_name("pw")
self.controller.registerUser(self, bar, pw)
self.connectClient(
bar,
name=bar,
capabilities=[
"batch",
"labeled-response",
"message-tags",
"sasl",
"server-time",
CHATHISTORY_CAP,
],
password=pw,
skip_if_cap_nak=True,
)
self.getMessages(bar)
messages = []
self.sendLine(bar, "PRIVMSG %s :this is a privmsg sent to myself" % (bar,))
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual(len(replies), 1)
msg = replies[0]
self.assertMessageMatch(msg, params=[bar, "this is a privmsg sent to myself"])
messages.append(msg.to_history_message())
self.sendLine(bar, "CAP REQ echo-message")
self.getMessages(bar)
self.sendLine(
bar, "PRIVMSG %s :this is a second privmsg sent to myself" % (bar,)
)
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
# two messages, the echo and the delivery
self.assertEqual(len(replies), 2)
self.assertMessageMatch(
replies[0], params=[bar, "this is a second privmsg sent to myself"]
)
messages.append(replies[0].to_history_message())
# messages should be otherwise identical
self.assertEqual(
replies[0].to_history_message(), replies[1].to_history_message()
)
self.sendLine(
bar,
"@label=xyz PRIVMSG %s :this is a third privmsg sent to myself" % (bar,),
)
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual(len(replies), 2)
# exactly one of the replies MUST be labeled
echo = [msg for msg in replies if msg.tags.get("label") == "xyz"][0]
delivery = [msg for msg in replies if msg.tags.get("label") is None][0]
self.assertMessageMatch(
echo, params=[bar, "this is a third privmsg sent to myself"]
)
messages.append(echo.to_history_message())
self.assertEqual(echo.to_history_message(), delivery.to_history_message())
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,))
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual([msg.to_history_message() for msg in replies], messages)
def validate_echo_messages(self, num_messages, echo_messages):
# sanity checks: should have received the correct number of echo messages,
# all with distinct time tags (because we slept) and msgids
self.assertEqual(len(echo_messages), num_messages)
self.assertEqual(len(set(msg.msgid for msg in echo_messages)), num_messages)
self.assertEqual(len(set(msg.time for msg in echo_messages)), num_messages)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
def testChathistory(self, subcommand):
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, "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_chathistory(subcommand, echo_messages, 1, chname)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
def testChathistoryEventPlayback(self, subcommand):
self.connectClient(
"bar",
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
EVENT_PLAYBACK_CAP,
],
skip_if_cap_nak=True,
)
chname = "#chan" + secrets.token_hex(12)
self.joinChannel(1, chname)
self.getMessages(1)
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
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_chathistory(subcommand, echo_messages, 1, chname)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@pytest.mark.private_chathistory
def testChathistoryDMs(self, subcommand):
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
c1,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame1",
skip_if_cap_nak=True,
)
self.connectClient(
c2,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame2",
)
self.getMessages(1)
self.getMessages(2)
NUM_MESSAGES = 10
echo_messages = []
for i in range(NUM_MESSAGES):
user = (i % 2) + 1
if user == 1:
target = c2
else:
target = c1
self.getMessages(user)
self.sendLine(user, "PRIVMSG %s :this is message %d" % (target, i))
echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(user)
)
time.sleep(0.002)
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)
self.connectClient(
c3,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
CHATHISTORY_CAP,
],
skip_if_cap_nak=True,
)
self.sendLine(
1, "PRIVMSG %s :this is a message in a separate conversation" % (c3,)
)
self.getMessages(1)
self.sendLine(
3, "PRIVMSG %s :i agree that this is a separate conversation" % (c1,)
)
# 3 received the first message as a delivery and the second as an echo
new_convo = [
msg.to_history_message()
for msg in self.getMessages(3)
if msg.command == "PRIVMSG"
]
self.assertEqual(
[msg.text for msg in new_convo],
[
"this is a message in a separate conversation",
"i agree that this is a separate conversation",
],
)
# messages should be stored and retrievable by c1,
# even though c3 is not registered
self.getMessages(1)
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (c3,))
results = [
msg.to_history_message()
for msg in self.getMessages(1)
if msg.command == "PRIVMSG"
]
self.assertEqual(results, new_convo)
# additional messages with c3 should not show up in the c1-c2 history:
self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1)
self.validate_chathistory(subcommand, echo_messages, 2, c1.upper())
# regression test for #833
self.sendLine(3, "QUIT")
self.assertDisconnected(3)
# register c3 as an account, then attempt to retrieve
# the conversation history with c1
self.controller.registerUser(self, c3, "sesame3")
self.connectClient(
c3,
name=c3,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame3",
skip_if_cap_nak=True,
)
self.getMessages(c3)
self.sendLine(c3, "CHATHISTORY LATEST %s * 10" % (c1,))
results = [
msg.to_history_message()
for msg in self.getMessages(c3)
if msg.command == "PRIVMSG"
]
# should get nothing
self.assertEqual(results, [])
def validate_chathistory(self, subcommand, echo_messages, user, chname):
# Keep this list of subcommands in sync with the SUBCOMMANDS global
method = getattr(self, f"_validate_chathistory_{subcommand}")
method(echo_messages, user, chname)
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))
self.assertEqual(echo_messages, result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
result = validate_chathistory_batch(self.getMessages(user))
self.assertEqual(echo_messages[-5:], result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
result = validate_chathistory_batch(self.getMessages(user))
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)
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)
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)
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)
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)
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)
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)
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)
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)
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)
# 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)
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)
# 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)
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)
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 timestamp=%s %d"
% (chname, echo_messages[7].time, 3),
)
result = validate_chathistory_batch(self.getMessages(user))
self.assertIn(echo_messages[7], result)
@pytest.mark.arbitrary_client_tags
def testChathistoryTagmsg(self):
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
chname = "#chan" + secrets.token_hex(12)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
c1,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
EVENT_PLAYBACK_CAP,
],
password="sesame1",
skip_if_cap_nak=True,
)
self.connectClient(
c2,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame2",
)
self.joinChannel(1, chname)
self.joinChannel(2, chname)
self.getMessages(1)
self.getMessages(2)
self.sendLine(
1, "@+client-only-tag-test=success;+draft/persist TAGMSG %s" % (chname,)
)
echo = self.getMessages(1)[0]
msgid = echo.tags["msgid"]
def validate_tagmsg(msg, target, msgid):
self.assertMessageMatch(msg, command="TAGMSG", params=[target])
self.assertEqual(msg.tags["+client-only-tag-test"], "success")
self.assertEqual(msg.tags["msgid"], msgid)
validate_tagmsg(echo, chname, msgid)
relay = self.getMessages(2)
self.assertEqual(len(relay), 1)
validate_tagmsg(relay[0], chname, msgid)
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,))
history_tagmsgs = [
msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 1)
validate_tagmsg(history_tagmsgs[0], chname, msgid)
# c2 doesn't have event-playback and MUST NOT receive replayed tagmsg
self.sendLine(2, "CHATHISTORY LATEST %s * 10" % (chname,))
history_tagmsgs = [
msg for msg in self.getMessages(2) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 0)
# now try a DM
self.sendLine(
1, "@+client-only-tag-test=success;+draft/persist TAGMSG %s" % (c2,)
)
echo = self.getMessages(1)[0]
msgid = echo.tags["msgid"]
validate_tagmsg(echo, c2, msgid)
relay = self.getMessages(2)
self.assertEqual(len(relay), 1)
validate_tagmsg(relay[0], c2, msgid)
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (c2,))
history_tagmsgs = [
msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 1)
validate_tagmsg(history_tagmsgs[0], c2, msgid)
# c2 doesn't have event-playback and MUST NOT receive replayed tagmsg
self.sendLine(2, "CHATHISTORY LATEST %s * 10" % (c1,))
history_tagmsgs = [
msg for msg in self.getMessages(2) if msg.command == "TAGMSG"
]
self.assertEqual(len(history_tagmsgs), 0)
@pytest.mark.arbitrary_client_tags
@pytest.mark.private_chathistory
def testChathistoryDMClientOnlyTags(self):
# regression test for Ergo #1411
c1 = "foo" + secrets.token_hex(12)
c2 = "bar" + secrets.token_hex(12)
self.controller.registerUser(self, c1, "sesame1")
self.controller.registerUser(self, c2, "sesame2")
self.connectClient(
c1,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame1",
skip_if_cap_nak=True,
)
self.connectClient(
c2,
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
password="sesame2",
)
self.getMessages(1)
self.getMessages(2)
echo_msgid = None
def validate_msg(msg):
self.assertMessageMatch(msg, command="PRIVMSG", params=[c2, "hi"])
self.assertEqual(msg.tags["+client-only-tag-test"], "success")
self.assertEqual(msg.tags["msgid"], echo_msgid)
self.sendLine(
1, "@+client-only-tag-test=success;+draft/persist PRIVMSG %s hi" % (c2,)
)
echo = self.getMessage(1)
echo_msgid = echo.tags["msgid"]
validate_msg(echo)
relay = self.getMessage(2)
validate_msg(relay)
assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == {
meth_name
for meth_name in dir(ChathistoryTestCase)
if meth_name.startswith("_validate_chathistory_")
}, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync"

View File

@ -1,46 +0,0 @@
"""
<http://ircv3.net/specs/extensions/chghost.html>
"""
from irctest import cases
from irctest.irc_utils.sasl import sasl_plain_blob
from irctest.patma import ANYSTR, StrRe
@cases.mark_services
class ChghostServicesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
def testChghostFromServices(self):
self.connectClient("observer", capabilities=["chghost"], skip_if_cap_nak=True)
self.connectClient("oldclient")
self.controller.registerUser(
self, "vhostuser", "sesame", vhost="vhost.example.com"
)
self.connectClient("vhost-user", capabilities=["sasl"], skip_if_cap_nak=True)
for i in (1, 2, 3):
self.sendLine(i, "JOIN #chan")
self.getMessages(i)
for i in (1, 2, 3):
self.getMessages(i)
self.sendLine(3, "AUTHENTICATE PLAIN")
self.assertMessageMatch(
self.getRegistrationMessage(3),
command="AUTHENTICATE",
params=["+"],
)
self.sendLine(3, sasl_plain_blob("vhostuser", "sesame"))
self.assertMessageMatch(
self.getRegistrationMessage(3),
command="900",
)
self.assertMessageMatch(
self.getMessage(1),
prefix=StrRe("vhost-user!.*@(?!vhost-user.example)"),
command="CHGHOST",
params=[ANYSTR, "vhost.example.com"],
)
self.assertEqual(self.getMessages(2), []) # cycle?

View File

@ -1,127 +0,0 @@
import math
import time
from irctest import cases
from irctest.irc_utils.junkdrawer import ircv3_timestamp_to_unixtime
from irctest.numerics import RPL_NAMREPLY
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
class AuditoriumTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testAuditorium(self):
self.connectClient("bar", name="bar", capabilities=MODERN_CAPS)
self.joinChannel("bar", "#auditorium")
self.getMessages("bar")
self.sendLine("bar", "MODE #auditorium +u")
modelines = [msg for msg in self.getMessages("bar") if msg.command == "MODE"]
self.assertEqual(len(modelines), 1)
self.assertMessageMatch(modelines[0], params=["#auditorium", "+u"])
self.connectClient("guest1", name="guest1", capabilities=MODERN_CAPS)
self.joinChannel("guest1", "#auditorium")
self.getMessages("guest1")
# chanop should get a JOIN message
join_msgs = [msg for msg in self.getMessages("bar") if msg.command == "JOIN"]
self.assertEqual(len(join_msgs), 1)
self.assertMessageMatch(join_msgs[0], nick="guest1", params=["#auditorium"])
self.connectClient("guest2", name="guest2", capabilities=MODERN_CAPS)
self.joinChannel("guest2", "#auditorium")
self.getMessages("guest2")
# chanop should get a JOIN message
join_msgs = [msg for msg in self.getMessages("bar") if msg.command == "JOIN"]
self.assertEqual(len(join_msgs), 1)
join_msg = join_msgs[0]
self.assertMessageMatch(join_msg, nick="guest2", params=["#auditorium"])
# oragono/oragono#1642 ; msgid should be populated,
# and the time tag should be sane
self.assertTrue(join_msg.tags.get("msgid"))
self.assertLessEqual(
math.fabs(time.time() - ircv3_timestamp_to_unixtime(join_msg.tags["time"])),
60.0,
)
# fellow unvoiced participant should not
unvoiced_join_msgs = [
msg for msg in self.getMessages("guest1") if msg.command == "JOIN"
]
self.assertEqual(len(unvoiced_join_msgs), 0)
self.connectClient("guest3", name="guest3", capabilities=MODERN_CAPS)
self.joinChannel("guest3", "#auditorium")
self.getMessages("guest3")
self.sendLine("bar", "PRIVMSG #auditorium hi")
echo_message = [
msg for msg in self.getMessages("bar") if msg.command == "PRIVMSG"
][0]
self.assertEqual(echo_message, self.getMessages("guest1")[0])
self.assertEqual(echo_message, self.getMessages("guest2")[0])
self.assertEqual(echo_message, self.getMessages("guest3")[0])
# unvoiced users can speak
self.sendLine("guest1", "PRIVMSG #auditorium :hi you")
echo_message = [
msg for msg in self.getMessages("guest1") if msg.command == "PRIVMSG"
][0]
self.assertEqual(self.getMessages("bar"), [echo_message])
self.assertEqual(self.getMessages("guest2"), [echo_message])
self.assertEqual(self.getMessages("guest3"), [echo_message])
def names(client):
self.sendLine(client, "NAMES #auditorium")
result = set()
for msg in self.getMessages(client):
if msg.command == RPL_NAMREPLY:
result.update(msg.params[-1].split())
return result
self.assertEqual(names("bar"), {"@bar", "guest1", "guest2", "guest3"})
self.assertEqual(names("guest1"), {"@bar"})
self.assertEqual(names("guest2"), {"@bar"})
self.assertEqual(names("guest3"), {"@bar"})
self.sendLine("bar", "MODE #auditorium +v guest1")
modeLine = [msg for msg in self.getMessages("bar") if msg.command == "MODE"][0]
self.assertEqual(self.getMessages("guest1"), [modeLine])
self.assertEqual(self.getMessages("guest2"), [modeLine])
self.assertEqual(self.getMessages("guest3"), [modeLine])
self.assertEqual(names("bar"), {"@bar", "+guest1", "guest2", "guest3"})
self.assertEqual(names("guest2"), {"@bar", "+guest1"})
self.assertEqual(names("guest3"), {"@bar", "+guest1"})
self.sendLine("guest1", "PART #auditorium")
part = [msg for msg in self.getMessages("guest1") if msg.command == "PART"][0]
# everyone should see voiced PART
self.assertEqual(self.getMessages("bar")[0], part)
self.assertEqual(self.getMessages("guest2")[0], part)
self.assertEqual(self.getMessages("guest3")[0], part)
self.joinChannel("guest1", "#auditorium")
self.getMessages("guest1")
self.getMessages("bar")
self.sendLine("guest2", "PART #auditorium")
part = [msg for msg in self.getMessages("guest2") if msg.command == "PART"][0]
self.assertEqual(self.getMessages("bar"), [part])
# part should be hidden from unvoiced participants
self.assertEqual(self.getMessages("guest1"), [])
self.assertEqual(self.getMessages("guest3"), [])
self.sendLine("guest3", "QUIT")
self.assertDisconnected("guest3")
# quit should be hidden from unvoiced participants
self.assertEqual(
len([msg for msg in self.getMessages("bar") if msg.command == "QUIT"]), 1
)
self.assertEqual(
len([msg for msg in self.getMessages("guest1") if msg.command == "QUIT"]), 0
)

View File

@ -1,92 +0,0 @@
from irctest import cases
from irctest.numerics import ERR_BANNEDFROMCHAN, RPL_BANLIST, RPL_ENDOFBANLIST
from irctest.patma import ANYSTR, StrRe
class BanModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testBan(self):
"""Basic ban operation"""
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.connectClient("Bar", name="bar")
self.getMessages("bar")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
self.sendLine("chanop", "MODE #chan -b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")
@cases.mark_specifications("Modern")
def testBanList(self):
"""https://github.com/ircdocs/modern-irc/pull/125"""
self.connectClient("chanop")
self.joinChannel(1, "#chan")
self.getMessages(1)
self.sendLine(1, "MODE #chan +b bar!*@*")
self.assertMessageMatch(self.getMessage(1), command="MODE")
self.sendLine(1, "MODE #chan +b")
m = self.getMessage(1)
if len(m.params) == 3:
# Old format
self.assertMessageMatch(
m,
command=RPL_BANLIST,
params=[
"chanop",
"#chan",
"bar!*@*",
],
)
else:
self.assertMessageMatch(
m,
command=RPL_BANLIST,
params=[
"chanop",
"#chan",
"bar!*@*",
StrRe("chanop(!.*@.*)?"),
StrRe("[0-9]+"),
],
)
self.assertMessageMatch(
self.getMessage(1),
command=RPL_ENDOFBANLIST,
params=[
"chanop",
"#chan",
ANYSTR,
],
)
@cases.mark_specifications("Ergo")
def testCaseInsensitive(self):
"""Some clients allow unsetting modes if their argument matches
up to normalization"""
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +b BAR!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.connectClient("Bar", name="bar")
self.getMessages("bar")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN)
self.sendLine("chanop", "MODE #chan -b bar!*@*")
self.assertMessageMatch(self.getMessage("chanop"), command="MODE")
self.sendLine("bar", "JOIN #chan")
self.assertMessageMatch(self.getMessage("bar"), command="JOIN")

View File

@ -1,124 +0,0 @@
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
MODERN_CAPS = [
"server-time",
"message-tags",
"batch",
"labeled-response",
"echo-message",
"account-tag",
]
@cases.mark_services
class RegisteredOnlySpeakModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testRegisteredOnlySpeakMode(self):
self.controller.registerUser(self, "evan", "sesame")
# test the +M (only registered users and ops can speak) channel mode
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +M")
replies = self.getMessages("chanop")
modeLines = [line for line in replies if line.command == "MODE"]
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+M"])
self.connectClient("baz", name="baz")
self.joinChannel("baz", "#chan")
self.getMessages("chanop")
# this message should be suppressed completely by +M
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"), [])
# +v exempts users from the registration requirement:
self.sendLine("chanop", "MODE #chan +v baz")
self.getMessages("chanop")
self.getMessages("baz")
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
replies = self.getMessages("baz")
# baz should not receive an error (or an echo)
self.assertEqual(replies, [])
replies = self.getMessages("chanop")
self.assertMessageMatch(
replies[0], command="PRIVMSG", params=["#chan", "hi again from baz"]
)
self.connectClient(
"evan",
name="evan",
account="evan",
password="sesame",
capabilities=["sasl"],
)
self.joinChannel("evan", "#chan")
self.getMessages("baz")
self.sendLine("evan", "PRIVMSG #chan :hi from evan")
replies = self.getMessages("evan")
# evan should not receive an error (or an echo)
self.assertEqual(replies, [])
replies = self.getMessages("baz")
self.assertMessageMatch(
replies[0], command="PRIVMSG", params=["#chan", "hi from evan"]
)
class OpModeratedTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testOpModerated(self):
# test the +U channel mode
self.connectClient("chanop", name="chanop", capabilities=MODERN_CAPS)
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +U")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient("baz", name="baz", capabilities=MODERN_CAPS)
self.joinChannel("baz", "#chan")
self.sendLine("baz", "PRIVMSG #chan :hi from baz")
echo = self.getMessages("baz")[0]
self.assertMessageMatch(
echo, command="PRIVMSG", params=["#chan", "hi from baz"]
)
self.assertEqual(
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
[echo],
)
self.connectClient("qux", name="qux", capabilities=MODERN_CAPS)
self.joinChannel("qux", "#chan")
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
echo = self.getMessages("qux")[0]
self.assertMessageMatch(
echo, command="PRIVMSG", params=["#chan", "hi from qux"]
)
# message is relayed to chanop but not to unprivileged
self.assertEqual(
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
[echo],
)
self.assertEqual(
[msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], []
)
self.sendLine("chanop", "MODE #chan +v qux")
self.getMessages("chanop")
self.sendLine("qux", "PRIVMSG #chan :hi again from qux")
echo = [msg for msg in self.getMessages("qux") if msg.command == "PRIVMSG"][0]
self.assertMessageMatch(
echo, command="PRIVMSG", params=["#chan", "hi again from qux"]
)
self.assertEqual(
[msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"],
[echo],
)
self.assertEqual(
[msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], [echo]
)

View File

@ -1,134 +0,0 @@
import pytest
from irctest import cases
from irctest.numerics import (
ERR_BADCHANNELKEY,
ERR_INVALIDKEY,
ERR_INVALIDMODEPARAM,
ERR_UNKNOWNERROR,
)
from irctest.patma import ANYSTR
class KeyTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testKeyNormal(self):
self.connectClient("bar")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +k beer")
self.getMessages(1)
self.connectClient("qux")
self.getMessages(2)
self.sendLine(2, "JOIN #chan")
reply = self.getMessages(2)
self.assertNotIn("JOIN", {msg.command for msg in reply})
self.assertIn(ERR_BADCHANNELKEY, {msg.command for msg in reply})
self.sendLine(2, "JOIN #chan beer")
reply = self.getMessages(2)
self.assertMessageMatch(reply[0], command="JOIN", params=["#chan"])
@pytest.mark.parametrize(
"key",
["passphrase with spaces", "long" * 100, ""],
ids=["spaces", "long", "empty"],
)
@cases.mark_specifications("RFC2812", "Modern")
def testKeyValidation(self, key):
"""
key = 1*23( %x01-05 / %x07-08 / %x0C / %x0E-1F / %x21-7F )
; any 7-bit US_ASCII character,
; except NUL, CR, LF, FF, h/v TABs, and " "
-- https://tools.ietf.org/html/rfc2812#page-8
"Servers may validate the value (eg. to forbid spaces, as they make it harder
to use the key in `JOIN` messages). If the value is invalid, they SHOULD
return [`ERR_INVALIDMODEPARAM`](#errinvalidmodeparam-696).
However, clients MUST be able to handle any of the following:
* [`ERR_INVALIDMODEPARAM`](#errinvalidmodeparam-696)
* [`ERR_INVALIDKEY`](#errinvalidkey-525)
* `MODE` echoed with a different key (eg. truncated or stripped of invalid
characters)
* the key changed ignored, and no `MODE` echoed if no other mode change
was valid.
"
-- https://modern.ircdocs.horse/#key-channel-mode
-- https://github.com/ircdocs/modern-irc/pull/111
"""
self.connectClient("bar")
self.joinChannel(1, "#chan")
self.sendLine(1, f"MODE #chan +k :{key}")
# The spec requires no space; but doesn't say what to do
# if there is one.
# Let's check the various alternatives
replies = self.getMessages(1)
self.assertNotIn(
ERR_UNKNOWNERROR,
{msg.command for msg in replies},
fail_msg="Sending an invalid key caused an "
"ERR_UNKNOWNERROR instead of being handled explicitly "
"(eg. ERR_INVALIDMODEPARAM or truncation): {msg}",
)
commands = {msg.command for msg in replies}
if {ERR_INVALIDMODEPARAM, ERR_INVALIDKEY} & commands:
# First option: ERR_INVALIDMODEPARAM (eg. Ergo) or ERR_INVALIDKEY
# (eg. ircu2)
if ERR_INVALIDMODEPARAM in commands:
command = [
msg for msg in replies if msg.command == ERR_INVALIDMODEPARAM
]
self.assertEqual(len(command), 1, command)
self.assertMessageMatch(
command[0],
command=ERR_INVALIDMODEPARAM,
params=["bar", "#chan", "k", "*", ANYSTR],
)
return
if not replies:
# MODE was ignored entirely
self.connectClient("foo")
self.sendLine(2, "JOIN #chan")
self.assertMessageMatch(
self.getMessage(2), command="JOIN", params=["#chan"]
)
return
# Second and third options: truncating the key (eg. UnrealIRCd)
# or replacing spaces (eg. Charybdis)
mode_commands = [msg for msg in replies if msg.command == "MODE"]
self.assertGreaterEqual(
len(mode_commands),
1,
fail_msg="Sending an invalid key (with a space) triggered "
"neither ERR_UNKNOWNERROR, ERR_INVALIDMODEPARAM, ERR_INVALIDKEY, "
" or a MODE. Only these: {}",
extra_format=(replies,),
)
self.assertLessEqual(
len(mode_commands),
1,
fail_msg="Sending an invalid key (with a space) triggered "
"multiple MODE responses: {}",
extra_format=(replies,),
)
mode_command = mode_commands[0]
if mode_command.params == ["#chan", "+k", "passphrase"]:
key = "passphrase"
elif mode_command.params == ["#chan", "+k", "passphrasewithspaces"]:
key = "passphrasewithspaces"
elif mode_command.params[2].startswith("longlonglong"):
key = mode_command.params[2]
assert mode_command.params == ["#chan", "+k", key]
elif mode_command.params == ["#chan", "+k", "passphrase with spaces"]:
raise self.failureException("Invalid key (with a space) was not rejected.")
self.connectClient("foo")
self.sendLine(2, f"JOIN #chan {key}")
self.assertMessageMatch(self.getMessage(2), command="JOIN", params=["#chan"])

View File

@ -1,37 +0,0 @@
from irctest import cases
from irctest.numerics import ERR_CANNOTSENDTOCHAN
class ModeratedModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC2812")
def testModeratedMode(self):
# test the +m channel mode
self.connectClient("chanop", name="chanop")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", "MODE #chan +m")
replies = self.getMessages("chanop")
modeLines = [line for line in replies if line.command == "MODE"]
self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+m"])
self.connectClient("baz", name="baz")
self.joinChannel("baz", "#chan")
self.getMessages("chanop")
# this message should be suppressed completely by +m
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"), [])
# grant +v, user should be able to send messages
self.sendLine("chanop", "MODE #chan +v baz")
self.getMessages("chanop")
self.getMessages("baz")
self.sendLine("baz", "PRIVMSG #chan :hi again from baz")
self.getMessages("baz")
relays = self.getMessages("chanop")
relay = relays[0]
self.assertMessageMatch(
relay, command="PRIVMSG", params=["#chan", "hi again from baz"]
)

View File

@ -1,267 +0,0 @@
from irctest import cases, runner
from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED
from irctest.patma import ANYLIST, StrRe
class MuteExtbanTestCase(cases.BaseServerTestCase):
"""https://defs.ircdocs.horse/defs/isupport.html#extban
It magically guesses what char the IRCd uses for mutes."""
def char(self):
if self.controller.extban_mute_char is None:
raise runner.ExtbanNotSupported("", "mute")
else:
return self.controller.extban_mute_char
@cases.mark_specifications("Ergo")
def testISupport(self):
self.connectClient("chk") # Fetches ISUPPORT
isupport = self.server_support
token = isupport["EXTBAN"]
prefix, comma, types = token.partition(",")
self.assertIn(self.char(), types, f"Missing '{self.char()}' in ISUPPORT EXTBAN")
self.assertEqual(prefix, "")
self.assertEqual(comma, ",")
@cases.mark_specifications("ircdocs")
def testMuteExtban(self):
"""Basic usage of mute"""
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
if self.char() not in types:
raise runner.ExtbanNotSupported(self.char(), "mute")
clients = ("chanop", "bar")
# Mute "bar"
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:bar!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient("bar", name="bar", capabilities=["echo-message"])
self.joinChannel("bar", "#chan")
for client in clients:
self.getMessages(client)
# "bar" sees the MODE too
self.sendLine("bar", "MODE #chan +b")
self.assertMessageMatch(
self.getMessage("bar"),
command="367",
params=[
"bar",
"#chan",
f"{prefix}{self.char()}:bar!*@*",
StrRe("chanop(!.*)?"),
*ANYLIST,
],
)
self.getMessages("bar")
# "bar" talks: rejected
self.sendLine("bar", "PRIVMSG #chan :hi from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
# remove mute on "bar" with -b
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan -b {prefix}{self.char()}:bar!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
# "bar" can now talk
self.sendLine("bar", "PRIVMSG #chan :hi again from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)
@cases.mark_specifications("ircdocs")
def testMuteExtbanVoiced(self):
"""Checks +v overrides the mute"""
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
if self.char() not in types:
raise runner.ExtbanNotSupported(self.char(), "mute")
clients = ("chanop", "qux")
# Mute "qux"
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:qux!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient(
"qux", name="qux", ident="evan", capabilities=["echo-message"]
)
self.joinChannel("qux", "#chan")
for client in clients:
self.getMessages(client)
# "qux" talks: rejected
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
for client in clients:
self.getMessages(client)
# +v grants an exemption to +b
self.sendLine("chanop", "MODE #chan +v qux")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
# so "qux" can now talk
self.sendLine("qux", "PRIVMSG #chan :hi again from qux")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)
@cases.mark_specifications("ircdocs")
def testMuteExtbanExempt(self):
"""Checks +e overrides the mute
<https://defs.ircdocs.horse/defs/chanmodes.html#e-ban-exception>"""
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
if self.char() not in types:
raise runner.ExtbanNotSupported(self.char(), "mute")
if "e" not in self.server_support["CHANMODES"]:
raise runner.ChannelModeNotSupported(self.char(), "mute")
clients = ("chanop", "qux")
# Mute "qux"
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:qux!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient(
"qux", name="qux", ident="evan", capabilities=["echo-message"]
)
self.joinChannel("qux", "#chan")
for client in clients:
self.getMessages(client)
# "qux" talks: rejected
self.sendLine("qux", "PRIVMSG #chan :hi from qux")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
for client in clients:
self.getMessages(client)
# +e grants an exemption to +b
self.sendLine("chanop", f"MODE #chan +e {prefix}{self.char()}:*!~evan@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.getMessages("qux")
# so "qux" can now talk
self.sendLine("qux", "PRIVMSG #chan :thanks for mute-excepting me")
replies = self.getMessages("qux")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)
@cases.mark_specifications("Ergo")
def testCapitalization(self):
"""
Regression test for oragono #1370: mutes not correctly enforced against
users with capital letters in their NUH
For consistency with regular -b, which allows unsetting up to
normalization
"""
clients = ("chanop", "bar")
self.connectClient("chanop", name="chanop")
isupport = self.server_support
token = isupport.get("EXTBAN", "")
prefix, comma, types = token.partition(",")
self.joinChannel("chanop", "#chan")
self.getMessages("chanop")
self.sendLine("chanop", f"MODE #chan +b {prefix}{self.char()}:BAR!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
self.connectClient("Bar", name="bar", capabilities=["echo-message"])
self.joinChannel("bar", "#chan")
for client in clients:
self.getMessages(client)
self.sendLine("bar", "PRIVMSG #chan :hi from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertNotIn("PRIVMSG", replies_cmds)
self.assertIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(self.getMessages("chanop"), [])
# remove mute with -b
self.sendLine("chanop", f"MODE #chan -b {prefix}{self.char()}:bar!*@*")
replies = {msg.command for msg in self.getMessages("chanop")}
self.assertIn("MODE", replies)
self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies)
# "bar" can talk again
self.sendLine("bar", "PRIVMSG #chan :hi again from bar")
replies = self.getMessages("bar")
replies_cmds = {msg.command for msg in replies}
self.assertIn("PRIVMSG", replies_cmds)
self.assertNotIn(ERR_CANNOTSENDTOCHAN, replies_cmds)
self.assertEqual(
self.getMessages("chanop"),
[msg for msg in replies if msg.command == "PRIVMSG"],
)

View File

@ -1,55 +0,0 @@
from irctest import cases
from irctest.numerics import RPL_LIST
class SecretChannelTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "Modern")
def testSecretChannelListCommand(self):
"""
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.6>
"Likewise, secret channels are not listed
at all unless the client is a member of the channel in question."
<https://modern.ircdocs.horse/#secret-channel-mode>
"A channel that is set to secret will not show up in responses to
the LIST or NAMES command unless the client sending the command is
joined to the channel."
"""
def get_listed_channels(replies):
channels = set()
for reply in replies:
# skip pseudo-channels (&SERVER, &NOTICES) listed by ngircd
# and ircu:
if reply.command == RPL_LIST and reply.params[1].startswith("#"):
channels.add(reply.params[1])
return channels
# test that a silent channel is shown in list if the user is in the channel.
self.connectClient("first", name="first")
self.joinChannel("first", "#gen")
self.getMessages("first")
self.sendLine("first", "MODE #gen +s")
# run command LIST
self.sendLine("first", "LIST")
replies = self.getMessages("first")
self.assertEqual(get_listed_channels(replies), {"#gen"})
# test that another client would not see the secret
# channel.
self.connectClient("second", name="second")
self.getMessages("second")
self.sendLine("second", "LIST")
replies = self.getMessages("second")
# RPL_LIST 322 should NOT be present this time.
self.assertEqual(get_listed_channels(replies), set())
# Second client will join the secret channel
# and call command LIST. The channel SHOULD
# appear this time.
self.joinChannel("second", "#gen")
self.sendLine("second", "LIST")
replies = self.getMessages("second")
# Should be only one line with command RPL_LIST
self.assertEqual(get_listed_channels(replies), {"#gen"})

View File

@ -1,35 +0,0 @@
from irctest import cases
from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME
@cases.mark_services
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"}}
)
)
@cases.mark_specifications("Ergo")
def testConfusableNicks(self):
self.controller.registerUser(self, "evan", "sesame")
self.addClient(1)
# U+0435 in place of e:
self.sendLine(1, "NICK еvan")
self.sendLine(1, "USER a 0 * a")
messages = self.getMessages(1)
commands = set(msg.command for msg in messages)
self.assertNotIn(RPL_WELCOME, commands)
self.assertIn(ERR_NICKNAMEINUSE, commands)
self.connectClient(
"evan", name="evan", password="sesame", capabilities=["sasl"]
)
# should be able to switch to the confusable nick
self.sendLine("evan", "NICK еvan")
messages = self.getMessages("evan")
commands = set(msg.command for msg in messages)
self.assertIn("NICK", commands)

View File

@ -1,242 +0,0 @@
"""
Tests section 4.1 of RFC 1459.
<https://tools.ietf.org/html/rfc1459#section-4.1>
"""
from irctest import cases
from irctest.client_mock import ConnectionClosed
from irctest.numerics import ERR_NEEDMOREPARAMS
from irctest.patma import ANYSTR, StrRe
class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase):
password = "testpassword"
@cases.mark_specifications("RFC1459", "RFC2812")
def testPassBeforeNickuser(self):
self.addClient()
self.sendLine(1, "PASS {}".format(self.password))
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="001",
fail_msg="Did not get 001 after correct PASS+NICK+USER: {msg}",
)
@cases.mark_specifications("RFC1459", "RFC2812")
def testNoPassword(self):
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
m = self.getRegistrationMessage(1)
self.assertNotEqual(
m.command, "001", msg="Got 001 after NICK+USER but missing PASS"
)
@cases.mark_specifications("RFC1459", "RFC2812")
def testWrongPassword(self):
self.addClient()
self.sendLine(1, "PASS {}".format(self.password + "garbage"))
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
m = self.getRegistrationMessage(1)
self.assertNotEqual(
m.command, "001", msg="Got 001 after NICK+USER but incorrect PASS"
)
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testPassAfterNickuser(self):
"""“The password can and must be set before any attempt to register
the connection is made.”
-- <https://tools.ietf.org/html/rfc1459#section-4.1.1>
“The optional password can and MUST be set before any attempt to
register the connection is made.
Currently this requires that user send a PASS command before
sending the NICK/USER combination.”
-- <https://tools.ietf.org/html/rfc2812#section-3.1.1>
"""
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
self.sendLine(1, "PASS {}".format(self.password))
m = self.getRegistrationMessage(1)
self.assertNotEqual(m.command, "001", "Got 001 after PASS sent after NICK+USER")
class ConnectionRegistrationTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459")
def testQuitDisconnects(self):
"""“The server must close the connection to a client which sends a
QUIT message.”
-- <https://tools.ietf.org/html/rfc1459#section-4.1.3>
"""
self.connectClient("foo")
self.getMessages(1)
self.sendLine(1, "QUIT")
with self.assertRaises(ConnectionClosed):
self.getMessages(1) # Fetch remaining messages
self.getMessages(1)
@cases.mark_specifications("RFC2812")
def testQuitErrors(self):
"""“A client session is terminated with a quit message. The server
acknowledges this by sending an ERROR message to the client.”
-- <https://tools.ietf.org/html/rfc2812#section-3.1.7>
"""
self.connectClient("foo")
self.getMessages(1)
self.sendLine(1, "QUIT")
while True:
try:
new_messages = self.getMessages(1)
if not new_messages:
break
commands = {m.command for m in new_messages}
except ConnectionClosed:
break
self.assertIn(
"ERROR", commands, fail_msg="Did not receive ERROR as a reply to QUIT."
)
def testNickCollision(self):
"""A user connects and requests the same nickname as an already
registered user.
"""
self.connectClient("foo")
self.addClient()
self.sendLine(2, "NICK foo")
self.sendLine(2, "USER username * * :Realname")
m = self.getRegistrationMessage(2)
self.assertNotEqual(
m.command,
"001",
"Received 001 after registering with the nick of a " "registered user.",
)
def testEarlyNickCollision(self):
"""Two users register simultaneously with the same nick."""
self.addClient()
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(2, "NICK foo")
self.sendLine(1, "USER username * * :Realname")
try:
self.sendLine(2, "USER username * * :Realname")
except (ConnectionClosed, ConnectionResetError):
# Bahamut closes the connection here
pass
try:
m1 = self.getRegistrationMessage(1)
except (ConnectionClosed, ConnectionResetError):
# Unreal closes the connection, see
# https://bugs.unrealircd.org/view.php?id=5950
command1 = None
else:
command1 = m1.command
try:
m2 = self.getRegistrationMessage(2)
except (ConnectionClosed, ConnectionResetError):
# ditto
command2 = None
else:
command2 = m2.command
self.assertNotEqual(
(command1, command2),
("001", "001"),
"Two concurrently registering requesting the same nickname "
"both got 001.",
)
self.assertIn(
"001",
(command1, command2),
"Two concurrently registering requesting the same nickname "
"neither got 001.",
)
def testEmptyRealname(self):
"""
Syntax:
"<client> <command> :Not enough parameters"
-- https://defs.ircdocs.horse/defs/numerics.html#err-needmoreparams-461
-- https://modern.ircdocs.horse/#errneedmoreparams-461
Use of this numeric:
"The minimum length of `<username>` is 1, ie. it MUST not be empty.
If it is empty, the server SHOULD reject the command with ERR_NEEDMOREPARAMS
(even an empty parameter is provided)"
https://github.com/ircdocs/modern-irc/issues/85
"""
self.addClient()
self.sendLine(1, "NICK foo")
self.sendLine(1, "USER username * * :")
self.assertMessageMatch(
self.getRegistrationMessage(1),
command=ERR_NEEDMOREPARAMS,
params=[StrRe(r"(\*|foo)"), "USER", ANYSTR],
)
@cases.mark_specifications("IRCv3")
def testIrc301CapLs(self):
"""
Current version:
"The LS subcommand is used to list the capabilities supported by the server.
The client should send an LS subcommand with no other arguments to solicit
a list of all capabilities."
"If a client has not indicated support for CAP LS 302 features,
the server MUST NOT send these new features to the client."
-- <https://ircv3.net/specs/core/capability-negotiation.html>
Before the v3.1 / v3.2 merge:
IRCv3.1: “The LS subcommand is used to list the capabilities
supported by the server. The client should send an LS subcommand with
no other arguments to solicit a list of all capabilities.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.1.html#the-cap-ls-subcommand>
IRCv3.2: “Servers MUST NOT send messages described by this document if
the client only supports version 3.1.”
-- <http://ircv3.net/specs/core/capability-negotiation-3.2.html#version-in-cap-ls>
""" # noqa
self.addClient()
self.sendLine(1, "CAP LS")
m = self.getRegistrationMessage(1)
self.assertNotEqual(
m.params[2],
"*",
m,
fail_msg="Server replied with multi-line CAP LS to a "
"“CAP LS” (ie. IRCv3.1) request: {msg}",
)
self.assertFalse(
any("=" in cap for cap in m.params[2].split()),
"Server replied with a name-value capability in "
"CAP LS reply as a response to “CAP LS” (ie. IRCv3.1) "
"request: {}".format(m),
)
@cases.mark_specifications("IRCv3")
def testEmptyCapList(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 LIST")
m = self.getRegistrationMessage(1)
self.assertMessageMatch(
m,
command="CAP",
params=["*", "LIST", ""],
fail_msg="Sending “CAP LIST” as first message got a reply "
"that is not “CAP * LIST :”: {msg}",
)

View File

@ -1,149 +0,0 @@
"""
<http://ircv3.net/specs/extensions/echo-message-3.2.html>
"""
import pytest
from irctest import cases
from irctest.basecontrollers import NotImplementedByController
from irctest.irc_utils.junkdrawer import random_name
from irctest.patma import ANYDICT
class EchoMessageTestCase(cases.BaseServerTestCase):
@pytest.mark.parametrize(
"command,solo,server_time",
[
("PRIVMSG", False, False),
("PRIVMSG", True, True),
("PRIVMSG", False, True),
("NOTICE", False, True),
],
)
@cases.mark_capabilities("echo-message")
def testEchoMessage(self, command, solo, server_time):
"""<http://ircv3.net/specs/extensions/echo-message-3.2.html>"""
self.addClient()
self.sendLine(1, "CAP LS 302")
capabilities = self.getCapLs(1)
if "echo-message" not in capabilities:
raise NotImplementedByController("echo-message")
if server_time and "server-time" not in capabilities:
raise NotImplementedByController("server-time")
# TODO: check also without this
self.sendLine(
1,
"CAP REQ :echo-message{}".format(" server-time" if server_time else ""),
)
self.getRegistrationMessage(1)
# TODO: Remove this one the trailing space issue is fixed in Charybdis
# and Mammon:
# self.assertMessageMatch(m, command='CAP',
# params=['*', 'ACK', 'echo-message'] +
# (['server-time'] if server_time else []),
# fail_msg='Did not ACK advertised capabilities: {msg}')
self.sendLine(1, "USER f * * :foo")
self.sendLine(1, "NICK baz")
self.sendLine(1, "CAP END")
self.skipToWelcome(1)
self.getMessages(1)
self.sendLine(1, "JOIN #chan")
if not solo:
capabilities = ["server-time"] if server_time else None
self.connectClient("qux", capabilities=capabilities)
self.sendLine(2, "JOIN #chan")
# Synchronize and clean
self.getMessages(1)
if not solo:
self.getMessages(2)
self.getMessages(1)
self.sendLine(1, "{} #chan :hello everyone".format(command))
m1 = self.getMessage(1)
self.assertMessageMatch(
m1,
command=command,
params=["#chan", "hello everyone"],
fail_msg="Did not echo “{} #chan :hello everyone”: {msg}",
extra_format=(command,),
)
if not solo:
m2 = self.getMessage(2)
self.assertMessageMatch(
m2,
command=command,
params=["#chan", "hello everyone"],
fail_msg="Did not propagate “{} #chan :hello everyone”: "
"after echoing it to the author: {msg}",
extra_format=(command,),
)
self.assertEqual(
m1.params,
m2.params,
fail_msg="Parameters of forwarded and echoed " "messages differ: {} {}",
extra_format=(m1, m2),
)
if server_time:
self.assertIn(
"time",
m1.tags,
fail_msg="Echoed message is missing server time: {}",
extra_format=(m1,),
)
self.assertIn(
"time",
m2.tags,
fail_msg="Forwarded message is missing server time: {}",
extra_format=(m2,),
)
@pytest.mark.arbitrary_client_tags
@cases.mark_capabilities(
"batch", "labeled-response", "echo-message", "message-tags"
)
def testDirectMessageEcho(self):
bar = random_name("bar")
self.connectClient(
bar,
name=bar,
capabilities=["batch", "labeled-response", "echo-message", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(bar)
qux = random_name("qux")
self.connectClient(
qux,
name=qux,
capabilities=["batch", "labeled-response", "echo-message", "message-tags"],
)
self.getMessages(qux)
self.sendLine(
bar,
"@label=xyz;+example-client-tag=example-value PRIVMSG %s :hi there"
% (qux,),
)
echo = self.getMessages(bar)[0]
delivery = self.getMessages(qux)[0]
self.assertMessageMatch(
echo,
command="PRIVMSG",
params=[qux, "hi there"],
tags={"label": "xyz", "+example-client-tag": "example-value", **ANYDICT},
)
self.assertMessageMatch(
delivery,
command="PRIVMSG",
params=[qux, "hi there"],
tags={"+example-client-tag": "example-value", **ANYDICT},
)
# Either both messages have a msgid, or neither does
self.assertEqual(delivery.tags.get("msgid"), echo.tags.get("msgid"))

View File

@ -1,25 +0,0 @@
from irctest import cases
from irctest.numerics import RPL_YOUREOPER
class NickservTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def test_saregister(self):
self.connectClient("root", name="root")
self.sendLine("root", "OPER operuser operpassword")
self.assertIn(RPL_YOUREOPER, {msg.command for msg in self.getMessages("root")})
self.sendLine(
"root",
"PRIVMSG NickServ :SAREGISTER saregister_test saregistertestpassphrase",
)
self.getMessages("root")
# test that the account was registered
self.connectClient(
name="saregister_test",
nick="saregister_test",
capabilities=["sasl"],
account="saregister_test",
password="saregistertestpassphrase",
)

View File

@ -1,69 +0,0 @@
"""
<http://ircv3.net/specs/extensions/extended-join-3.1.html>
"""
from irctest import cases
@cases.mark_services
class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
def connectRegisteredClient(self, nick):
self.addClient()
self.sendLine(2, "CAP LS 302")
capabilities = self.getCapLs(2)
assert "sasl" in capabilities
self.requestCapabilities(2, ["sasl"], skip_if_cap_nak=False)
self.sendLine(2, "AUTHENTICATE PLAIN")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="AUTHENTICATE",
params=["+"],
fail_msg="Sent “AUTHENTICATE PLAIN”, server should have "
"replied with “AUTHENTICATE +”, but instead sent: {msg}",
)
self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=")
m = self.getRegistrationMessage(2)
self.assertMessageMatch(
m,
command="900",
fail_msg="Did not send 900 after correct SASL authentication.",
)
self.sendLine(2, "USER f * * :Realname")
self.sendLine(2, "NICK {}".format(nick))
self.sendLine(2, "CAP END")
self.skipToWelcome(2)
@cases.mark_capabilities("extended-join")
def testNotLoggedIn(self):
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="JOIN",
params=["#chan", "*", "Realname"],
fail_msg="Expected “JOIN #chan * :Realname” after "
"unregistered user joined, got: {msg}",
)
@cases.mark_capabilities("extended-join")
@cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN")
def testLoggedIn(self):
self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True)
self.joinChannel(1, "#chan")
self.controller.registerUser(self, "jilles", "sesame")
self.connectRegisteredClient("bar")
self.joinChannel(2, "#chan")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="JOIN",
params=["#chan", "jilles", "Realname"],
fail_msg="Expected “JOIN #chan * :Realname” after "
"nick “bar” logged in as “jilles” joined, got: {msg}",
)

View File

@ -1,98 +0,0 @@
"""
The HELP and HELPOP command.
"""
import re
import pytest
from irctest import cases, runner
from irctest.numerics import (
ERR_HELPNOTFOUND,
ERR_UNKNOWNCOMMAND,
RPL_ENDOFHELP,
RPL_HELPSTART,
RPL_HELPTXT,
)
from irctest.patma import ANYSTR, StrRe
class HelpTestCase(cases.BaseServerTestCase):
def _assertValidHelp(self, messages, subject):
if subject != ANYSTR:
subject = StrRe("(?i)" + re.escape(subject))
self.assertMessageMatch(
messages[0],
command=RPL_HELPSTART,
params=["nick", subject, ANYSTR],
fail_msg=f"Expected {RPL_HELPSTART} (RPL_HELPSTART), got: {{msg}}",
)
self.assertMessageMatch(
messages[-1],
command=RPL_ENDOFHELP,
params=["nick", subject, ANYSTR],
fail_msg=f"Expected {RPL_ENDOFHELP} (RPL_ENDOFHELP), got: {{msg}}",
)
for i in range(1, len(messages) - 1):
self.assertMessageMatch(
messages[i],
command=RPL_HELPTXT,
params=["nick", subject, ANYSTR],
fail_msg=f"Expected {RPL_HELPTXT} (RPL_HELPTXT), got: {{msg}}",
)
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
@cases.mark_specifications("Modern")
def testHelpNoArg(self, command):
self.connectClient("nick")
self.sendLine(1, f"{command}")
messages = self.getMessages(1)
if messages[0].command == ERR_UNKNOWNCOMMAND:
raise runner.OptionalCommandNotSupported(command)
self._assertValidHelp(messages, ANYSTR)
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
@cases.mark_specifications("Modern")
def testHelpPrivmsg(self, command):
self.connectClient("nick")
self.sendLine(1, f"{command} PRIVMSG")
messages = self.getMessages(1)
if messages[0].command == ERR_UNKNOWNCOMMAND:
raise runner.OptionalCommandNotSupported(command)
self._assertValidHelp(messages, "PRIVMSG")
@pytest.mark.parametrize("command", ["HELP", "HELPOP"])
@cases.mark_specifications("Modern")
def testHelpUnknownSubject(self, command):
self.connectClient("nick")
self.sendLine(1, f"{command} THISISNOTACOMMAND")
messages = self.getMessages(1)
if messages[0].command == ERR_UNKNOWNCOMMAND:
raise runner.OptionalCommandNotSupported(command)
if messages[0].command == ERR_HELPNOTFOUND:
# Inspircd, Hybrid et al
self.assertEqual(len(messages), 1)
self.assertMessageMatch(
messages[0],
command=ERR_HELPNOTFOUND,
params=[
"nick",
StrRe(
"(?i)THISISNOTACOMMAND"
), # case-insensitive, for Hybrid and Plexus4 (but not Chary et al)
ANYSTR,
],
)
else:
# Unrealircd
self._assertValidHelp(messages, ANYSTR)

View File

@ -1,112 +0,0 @@
"""
The INFO command.
"""
import pytest
from irctest import cases
from irctest.numerics import ERR_NOSUCHSERVER, RPL_ENDOFINFO, RPL_INFO, RPL_YOUREOPER
from irctest.patma import ANYSTR
class InfoTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testInfo(self):
"""
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
"Upon receiving an INFO command, the given server will respond with zero or
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
-- <https://modern.ircdocs.horse/#info-message>
"""
self.connectClient("nick")
# Remote /INFO is oper-only on Unreal and ircu2
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, "INFO")
messages = self.getMessages(1)
last_message = messages.pop()
self.assertMessageMatch(
last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR]
)
for message in messages:
self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR])
@pytest.mark.parametrize(
"target",
["My.Little.Server", "*Little*", "nick"],
ids=["target-server", "target-wildcard", "target-nick"],
)
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInfoTarget(self, target):
"""
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
"Upon receiving an INFO command, the given server will respond with zero or
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
-- <https://modern.ircdocs.horse/#info-message>
"""
self.connectClient("nick")
# Remote /INFO is oper-only on Unreal and ircu2
self.sendLine(1, "OPER operuser operpassword")
self.assertIn(
RPL_YOUREOPER,
[m.command for m in self.getMessages(1)],
fail_msg="OPER failed",
)
if target:
self.sendLine(1, "INFO My.Little.Server")
else:
self.sendLine(1, "INFO")
messages = self.getMessages(1)
last_message = messages.pop()
self.assertMessageMatch(
last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR]
)
for message in messages:
self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR])
@pytest.mark.parametrize("target", ["invalid.server.example", "invalidserver"])
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInfoNosuchserver(self, target):
"""
<https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.8>
<https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.10>
"Upon receiving an INFO command, the given server will respond with zero or
more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric"
-- <https://modern.ircdocs.horse/#info-message>
"""
self.connectClient("nick")
# Remote /INFO is oper-only on Unreal and ircu2
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, f"INFO {target}")
self.assertMessageMatch(
self.getMessage(1),
command=ERR_NOSUCHSERVER,
params=["nick", target, ANYSTR],
)

View File

@ -1,398 +0,0 @@
import pytest
from irctest import cases
from irctest.numerics import (
ERR_BANNEDFROMCHAN,
ERR_CHANOPRIVSNEEDED,
ERR_INVITEONLYCHAN,
ERR_NOSUCHNICK,
ERR_NOTONCHANNEL,
ERR_USERONCHANNEL,
RPL_INVITING,
)
from irctest.patma import ANYSTR, StrRe
class InviteTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
def testInvites(self):
"""Test some basic functionality related to INVITE and the +i mode.
https://modern.ircdocs.horse/#invite-only-channel-mode
https://modern.ircdocs.horse/#rplinviting-341
"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +i")
self.getMessages(1)
self.sendLine(1, "INVITE bar #chan")
m = self.getMessage(1)
self.assertEqual(m.command, ERR_NOSUCHNICK)
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
m = self.getMessage(2)
self.assertEqual(m.command, ERR_INVITEONLYCHAN)
self.sendLine(1, "INVITE bar #chan")
m = self.getMessage(1)
# modern/ircv3 param order: inviter, invitee, channel
self.assertMessageMatch(m, command=RPL_INVITING, params=["foo", "bar", "#chan"])
m = self.getMessage(2)
self.assertMessageMatch(m, command="INVITE", params=["bar", "#chan"])
self.assertTrue(m.prefix.startswith("foo")) # nickmask of inviter
# we were invited, so join should succeed now
self.joinChannel(2, "#chan")
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInviteNonExistingChannelTransmitted(self):
"""“There is no requirement that the channel the target user is being
invited to must exist or be a valid channel.”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.7>
and <https://tools.ietf.org/html/rfc2812#section-3.2.7>
“Only the user inviting and the user being invited will receive
notification of the invitation.”
-- <https://tools.ietf.org/html/rfc2812#section-3.2.7>
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "INVITE #chan bar")
self.getMessages(1)
messages = self.getMessages(2)
self.assertNotEqual(
messages,
[],
fail_msg="After using “INVITE #chan bar” while #chan does "
"not exist, “bar” received nothing.",
)
self.assertMessageMatch(
messages[0],
command="INVITE",
params=["#chan", "bar"],
fail_msg="After “foo” invited “bar” do non-existing channel "
"#chan, “bar” should have received “INVITE #chan bar” but "
"got this instead: {msg}",
)
@cases.mark_specifications("RFC1459", "RFC2812", deprecated=True)
def testInviteNonExistingChannelEchoed(self):
"""“There is no requirement that the channel the target user is being
invited to must exist or be a valid channel.”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.7>
and <https://tools.ietf.org/html/rfc2812#section-3.2.7>
“Only the user inviting and the user being invited will receive
notification of the invitation.”
-- <https://tools.ietf.org/html/rfc2812#section-3.2.7>
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "INVITE #chan bar")
messages = self.getMessages(1)
self.assertNotEqual(
messages,
[],
fail_msg="After using “INVITE #chan bar” while #chan does "
"not exist, the author received nothing.",
)
self.assertMessageMatch(
messages[0],
command="INVITE",
params=["#chan", "bar"],
fail_msg="After “foo” invited “bar” do non-existing channel "
"#chan, “foo” should have received “INVITE #chan bar” but "
"got this instead: {msg}",
)
def _testInvite(self, opped, invite_only):
"""
"Only the user inviting and the user being invited will receive
notification of the invitation."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
" 341 RPL_INVITING
"<channel> <nick>"
- Returned by the server to indicate that the
attempted INVITE message was successful and is
being passed onto the end client."
-- https://datatracker.ietf.org/doc/html/rfc1459
-- https://datatracker.ietf.org/doc/html/rfc2812
"When the invite is successful, the server MUST send a `RPL_INVITING`
numeric to the command issuer, and an `INVITE` message,
with the issuer as prefix, to the target user."
-- https://modern.ircdocs.horse/#invite-message
"### `RPL_INVITING (341)`
<client> <nick> <channel>
Sent as a reply to the [`INVITE`](#invite-message) command to indicate
that the attempt was successful and the client with the nickname `<nick>`
has been invited to `<channel>`.
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
if invite_only:
self.sendLine(1, "MODE #chan +i")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "+i"],
)
if not opped:
self.sendLine(1, "MODE #chan -o foo")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "-o", "foo"],
)
self.sendLine(1, "INVITE bar #chan")
self.assertMessageMatch(
self.getMessage(1),
command=RPL_INVITING,
params=["foo", "bar", "#chan"],
fail_msg=f"After “foo” invited “bar” to a channel, “foo” should have "
f"received “{RPL_INVITING} foo #chan bar” but got this instead: "
f"{{msg}}",
)
messages = self.getMessages(2)
self.assertNotEqual(
messages,
[],
fail_msg="After using “INVITE #chan bar”, “bar” received nothing.",
)
self.assertMessageMatch(
messages[0],
prefix=StrRe("foo!.*"),
command="INVITE",
params=["bar", "#chan"],
fail_msg="After “foo” invited “bar”, “bar” should have received "
"“INVITE bar #chan” but got this instead: {msg}",
)
@pytest.mark.parametrize("invite_only", [True, False])
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testInvite(self, invite_only):
self._testInvite(opped=True, invite_only=invite_only)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern", strict=True)
def testInviteUnopped(self):
"""Tests invites from unopped users on not-invite-only chans."""
self._testInvite(opped=False, invite_only=False)
@cases.mark_specifications("RFC2812", "Modern")
def testInviteNoNotificationForOtherMembers(self):
"""
"Other channel members are not notified."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
"Other channel members SHOULD NOT be notified."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.connectClient("baz")
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.sendLine(3, "JOIN #chan")
self.getMessages(3)
self.sendLine(1, "INVITE bar #chan")
self.getMessages(1)
self.assertEqual(
self.getMessages(3),
[],
fail_msg="After foo used “INVITE #chan bar”, other channel members "
"were notified: {got}",
)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testInviteInviteOnly(self):
"""
"To invite a user to a channel which is invite only (MODE
+i), the client sending the invite must be recognised as being a
channel operator on the given channel."
-- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.7
"When the channel has invite-only
flag set, only channel operators may issue INVITE command."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
"When the channel has [invite-only](#invite-only-channel-mode) mode set,
only channel operators may issue INVITE command.
Otherwise, the server MUST reject the command with the `ERR_CHANOPRIVSNEEDED`
numeric."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.sendLine(1, "MODE #chan +i")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "+i"],
)
self.sendLine(1, "MODE #chan -o foo")
self.assertMessageMatch(
self.getMessage(1),
command="MODE",
params=["#chan", "-o", "foo"],
)
self.sendLine(1, "INVITE bar #chan")
self.assertMessageMatch(
self.getMessage(1),
command=ERR_CHANOPRIVSNEEDED,
params=["foo", "#chan", ANYSTR],
fail_msg=f"After “foo” invited “bar” to a channel to an invite-only "
f"channel without being opped, “foo” should have received "
f"{ERR_CHANOPRIVSNEEDED} foo #chan :*” but got this instead: {{msg}}",
)
@cases.mark_specifications("RFC2812", "Modern")
def testInviteOnlyFromUsersInChannel(self):
"""
"if the channel exists, only members of the channel are allowed
to invite other users"
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.7
" 442 ERR_NOTONCHANNEL
"<channel> :You're not on that channel"
- Returned by the server whenever a client tries to
perform a channel affecting command for which the
client isn't a member.
"
-- https://datatracker.ietf.org/doc/html/rfc2812
" Only members of the channel are allowed to invite other users.
Otherwise, the server MUST reject the command with the `ERR_NOTONCHANNEL`
numeric."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.connectClient("baz")
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
# Create the channel
self.sendLine(3, "JOIN #chan")
self.getMessages(3)
self.sendLine(1, "INVITE bar #chan")
self.assertMessageMatch(
self.getMessage(1),
command=ERR_NOTONCHANNEL,
params=["foo", "#chan", ANYSTR],
fail_msg=f"After “foo” invited “bar” to a channel it is not on "
f"#chan, “foo” should have received "
f"“ERR_NOTONCHANNEL ({ERR_NOTONCHANNEL}) foo #chan :*” but "
f"got this instead: {{msg}}",
)
messages = self.getMessages(2)
self.assertEqual(
messages,
[],
fail_msg="After using “INVITE #chan bar” while the emitter is "
"not in #chan, “bar” received something.",
)
@cases.mark_specifications("Modern")
def testInviteAlreadyInChannel(self):
"""
"If the user is already on the target channel,
the server MUST reject the command with the `ERR_USERONCHANNEL` numeric."
-- https://modern.ircdocs.horse/#invite-message
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.getMessages(2)
self.sendLine(1, "JOIN #chan")
self.sendLine(2, "JOIN #chan")
self.getMessages(1)
self.getMessages(2)
self.getMessages(1)
self.sendLine(1, "INVITE bar #chan")
self.assertMessageMatch(
self.getMessage(1),
command=ERR_USERONCHANNEL,
params=["foo", "bar", "#chan", ANYSTR],
)
@cases.mark_specifications("Ergo")
def testInviteExemptsFromBan(self):
# regression test for ergochat/ergo#1876;
# INVITE should override a +b ban
self.connectClient("alice", name="alice")
self.joinChannel("alice", "#alice")
self.sendLine("alice", "MODE #alice +b bob!*@*")
result = {msg.command for msg in self.getMessages("alice")}
self.assertIn("MODE", result)
self.connectClient("bob", name="bob")
self.sendLine("bob", "JOIN #alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn(ERR_BANNEDFROMCHAN, result)
self.assertNotIn("JOIN", result)
self.sendLine("alice", "INVITE bob #alice")
result = {msg.command for msg in self.getMessages("alice")}
self.assertIn(RPL_INVITING, result)
self.assertNotIn(ERR_USERONCHANNEL, result)
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn("INVITE", result)
self.sendLine("bob", "JOIN #alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertNotIn(ERR_BANNEDFROMCHAN, result)
self.assertIn("JOIN", result)
self.sendLine("alice", "KICK #alice bob")
self.getMessages("alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn("KICK", result)
# INVITE gets "used up" after one JOIN
self.sendLine("bob", "JOIN #alice")
result = {msg.command for msg in self.getMessages("bob")}
self.assertIn(ERR_BANNEDFROMCHAN, result)
self.assertNotIn("JOIN", result)

View File

@ -1,84 +0,0 @@
import re
from irctest import cases, runner
class IsupportTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Modern")
@cases.mark_isupport("PREFIX")
def testPrefix(self):
"""https://modern.ircdocs.horse/#prefix-parameter"""
self.connectClient("foo")
if "PREFIX" not in self.server_support:
raise runner.NotImplementedByController("PREFIX")
if self.server_support["PREFIX"] == "":
# "The value is OPTIONAL and when it is not specified indicates that no
# prefixes are supported."
return
m = re.match(
r"\((?P<modes>[a-zA-Z]+)\)(?P<prefixes>\S+)", self.server_support["PREFIX"]
)
self.assertTrue(
m,
f"PREFIX={self.server_support['PREFIX']} does not have the expected "
f"format.",
)
modes = m.group("modes")
prefixes = m.group("prefixes")
# "There is a one-to-one mapping between prefixes and channel modes."
self.assertEqual(
len(modes), len(prefixes), "Mismatched length of prefix and channel modes."
)
# "The prefixes in this parameter are in descending order, from the prefix
# that gives the most privileges to the prefix that gives the least."
self.assertLess(modes.index("o"), modes.index("v"), "'o' is not before 'v'")
if "h" in modes:
self.assertLess(modes.index("o"), modes.index("h"), "'o' is not before 'h'")
self.assertLess(modes.index("h"), modes.index("v"), "'h' is not before 'v'")
if "q" in modes:
self.assertLess(modes.index("q"), modes.index("o"), "'q' is not before 'o'")
# Not technically in the spec, but it would be very confusing not to follow
# these conventions.
mode_to_prefix = dict(zip(modes, prefixes))
self.assertEqual(mode_to_prefix["o"], "@", "Prefix char for mode +o is not @")
self.assertEqual(mode_to_prefix["v"], "+", "Prefix char for mode +v is not +")
if "h" in modes:
self.assertEqual(
mode_to_prefix["h"], "%", "Prefix char for mode +h is not %"
)
if "q" in modes:
self.assertEqual(
mode_to_prefix["q"], "~", "Prefix char for mode +q is not ~"
)
if "a" in modes:
self.assertEqual(
mode_to_prefix["a"], "&", "Prefix char for mode +a is not &"
)
@cases.mark_specifications("Modern", "ircdocs")
@cases.mark_isupport("TARGMAX")
def testTargmax(self):
"""
"Format: TARGMAX=[<command>:[limit]{,<command>:[limit]}]"
-- https://modern.ircdocs.horse/#targmax-parameter
"TARGMAX=[cmd:[number][,cmd:[number][,...]]]"
-- https://defs.ircdocs.horse/defs/isupport.html#targmax
"""
self.connectClient("foo")
if "TARGMAX" not in self.server_support:
raise runner.NotImplementedByController("TARGMAX")
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
)

View File

@ -1,112 +0,0 @@
from irctest import cases
from irctest.irc_utils import ambiguities
class JoinTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", strict=True)
def testJoinAllMessages(self):
"""“If a JOIN is successful, the user receives a JOIN message as
confirmation and is then sent the channel's topic (using RPL_TOPIC) and
the list of users who are on the channel (using RPL_NAMREPLY), which
MUST include the user joining.”
-- <https://tools.ietf.org/html/rfc2812#section-3.2.1>
“If a JOIN is successful, the user is then sent the channel's topic
(using RPL_TOPIC) and the list of users who are on the channel (using
RPL_NAMREPLY), which must include the user joining.”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.1>
"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
received_commands = {m.command for m in self.getMessages(1)}
expected_commands = {"353", "366"} # RPL_NAMREPLY # RPL_ENDOFNAMES
self.assertTrue(
expected_commands.issubset(received_commands),
"Server sent {} commands, but at least {} were expected.".format(
received_commands, expected_commands
),
)
@cases.mark_specifications("RFC2812")
def testJoinNamreply(self):
"""“353 RPL_NAMREPLY
"( "=" / "*" / "@" ) <channel>
:[ "@" / "+" ] <nick> *( " " [ "@" / "+" ] <nick> )”
-- <https://tools.ietf.org/html/rfc2812#section-5.2>
This test makes a user join and check what is sent to them.
"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
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: should contain only user "
'"foo" with an optional "+" or "@" prefix, but got: '
"{msg}",
)
def testJoinTwice(self):
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
m = self.getMessage(1)
self.assertMessageMatch(m, command="JOIN", params=["#chan"])
self.getMessages(1)
self.sendLine(1, "JOIN #chan")
# Note that there may be no message. Both RFCs require replies only
# 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}",
)

View File

@ -1,268 +0,0 @@
import pytest
from irctest import cases, client_mock, runner
from irctest.numerics import (
ERR_CHANOPRIVSNEEDED,
ERR_NOSUCHCHANNEL,
ERR_NOTONCHANNEL,
RPL_NAMREPLY,
)
from irctest.patma import ANYSTR
class KickTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testKickSendsMessages(self):
"""“Once a user has joined a channel, he receives information about
all commands his server receives affecting the channel. This
includes […] KICK”
-- <https://tools.ietf.org/html/rfc1459#section-4.2.1>
and <https://tools.ietf.org/html/rfc2812#section-3.2.1>
"If a comment is given, this will be sent instead of the default message,
the nickname of the user targeted by the KICK."
-- https://modern.ircdocs.horse/#kick-message
"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
self.connectClient("baz")
self.joinChannel(3, "#chan")
# TODO: check foo is an operator
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "KICK #chan bar :bye")
try:
m = self.getMessage(1)
if m.command == "482":
raise runner.ImplementationChoice(
"Channel creators are not opped by default."
)
self.assertMessageMatch(m, command="KICK")
except client_mock.NoMessageException:
# The RFCs do not say KICK must be echoed
pass
m = self.getMessage(2)
self.assertMessageMatch(m, command="KICK", params=["#chan", "bar", "bye"])
m = self.getMessage(3)
self.assertMessageMatch(m, command="KICK", params=["#chan", "bar", "bye"])
def _testKickNoComment(self, check_default):
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
self.connectClient("baz")
self.joinChannel(3, "#chan")
# TODO: check foo is an operator
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "KICK #chan bar")
try:
m = self.getMessage(1)
if m.command == "482":
raise runner.ImplementationChoice(
"Channel creators are not opped by default."
)
self.assertMessageMatch(m, command="KICK")
except client_mock.NoMessageException:
# The RFCs do not say KICK must be echoed
pass
m2 = self.getMessage(2)
m3 = self.getMessage(3)
if check_default:
self.assertMessageMatch(m2, command="KICK", params=["#chan", "bar", "foo"])
self.assertMessageMatch(m3, command="KICK", params=["#chan", "bar", "foo"])
else:
self.assertMessageMatch(m2, command="KICK", params=["#chan", "bar", ANYSTR])
self.assertMessageMatch(m3, command="KICK", params=["#chan", "bar", ANYSTR])
@cases.mark_specifications("RFC2812")
def testKickDefaultComment(self):
"""
"If a "comment" is
given, this will be sent instead of the default message, the nickname
of the user issuing the KICK."
-- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.8
"""
self._testKickNoComment(check_default=True)
@cases.mark_specifications("Modern")
def testKickNoComment(self):
"""
"If no comment is given, the server SHOULD use a default message instead."
-- https://modern.ircdocs.horse/#kick-message
"""
self._testKickNoComment(check_default=False)
@cases.mark_specifications("RFC2812")
def testKickPrivileges(self):
"""Test who has the ability to kick / what error codes are sent
for invalid kicks."""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
messages = self.getMessages(2)
names = set()
for message in messages:
if message.command == RPL_NAMREPLY:
names.update(set(message.params[-1].split()))
# assert foo is opped
self.assertIn("@foo", names, f"unexpected names: {names}")
self.connectClient("baz")
self.sendLine(3, "KICK #chan bar")
replies = set(m.command for m in self.getMessages(3))
self.assertTrue(
ERR_NOTONCHANNEL in replies
or ERR_CHANOPRIVSNEEDED in replies
or ERR_NOSUCHCHANNEL in replies,
f"did not receive acceptable error code for kick from outside channel: "
f"{replies}",
)
self.joinChannel(3, "#chan")
self.getMessages(3)
self.sendLine(3, "KICK #chan bar")
replies = set(m.command for m in self.getMessages(3))
# now we're a channel member so we should receive ERR_CHANOPRIVSNEEDED
self.assertIn(ERR_CHANOPRIVSNEEDED, replies)
self.sendLine(1, "MODE #chan +o baz")
self.getMessages(1)
# should be able to kick an unprivileged user:
self.sendLine(3, "KICK #chan bar")
# should be able to kick an operator:
self.sendLine(3, "KICK #chan foo")
baz_replies = set(m.command for m in self.getMessages(3))
self.assertNotIn(ERR_CHANOPRIVSNEEDED, baz_replies)
kick_targets = [m.params[1] for m in self.getMessages(1) if m.command == "KICK"]
# foo should see bar and foo being kicked
self.assertTrue(
any(target.startswith("foo") for target in kick_targets),
f"unexpected kick targets: {kick_targets}",
)
self.assertTrue(
any(target.startswith("bar") for target in kick_targets),
f"unexpected kick targets: {kick_targets}",
)
@cases.mark_specifications("RFC2812")
def testKickNonexistentChannel(self):
"""“Kick command [...] Numeric replies: [...] ERR_NOSUCHCHANNEL."""
self.connectClient("nick")
self.connectClient("foo")
self.sendLine(1, "KICK #chan nick")
m = self.getMessage(1)
# should return ERR_NOSUCHCHANNEL
self.assertMessageMatch(m, command="403")
@pytest.mark.parametrize("multiple_targets", [True, False])
@cases.mark_specifications("RFC2812", "Modern", "ircdocs")
def testDoubleKickMessages(self, multiple_targets):
"""“The server MUST NOT send KICK messages with multiple channels or
users to clients. This is necessarily to maintain backward
compatibility with old client software.”
-- https://tools.ietf.org/html/rfc2812#section-3.2.8
"The server MUST NOT send KICK messages with multiple channels or
users to clients.
This is necessary to maintain backward compatibility with existing
client software."
-- https://modern.ircdocs.horse/#kick-message
"Servers MAY limit the number of target users per `KICK` command
via the [`TARGMAX` parameter of `RPL_ISUPPORT`](#targmax-parameter),
and silently drop targets if the number of targets exceeds the limit."
-- https://modern.ircdocs.horse/#kick-message
"If the "TARGMAX" parameter is not advertised or a value is not sent
then a client SHOULD assume that no commands except the "JOIN" and "PART"
commands accept multiple parameters."
-- https://defs.ircdocs.horse/defs/isupport.html#targmax
"If this parameter is not advertised or a value is not sent then a client
SHOULD assume that no commands except the `JOIN` and `PART` commands
accept multiple parameters."
-- https://github.com/ircdocs/modern-irc/pull/113
"If <limit> is not specified, then there is no maximum number of targets
for that command."
-- https://modern.ircdocs.horse/#targmax-parameter
"""
self.connectClient("foo")
self.joinChannel(1, "#chan")
self.connectClient("bar")
self.joinChannel(2, "#chan")
self.connectClient("baz")
self.joinChannel(3, "#chan")
self.connectClient("qux")
self.joinChannel(4, "#chan")
targmax = dict(
item.split(":", 1)
for item in self.server_support.get("TARGMAX", "").split(",")
if item
)
if targmax.get("KICK", "1") == "1":
raise runner.NotImplementedByController("Multi-target KICK")
# TODO: check foo is an operator
# Synchronize
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.getMessages(4)
if multiple_targets:
self.sendLine(1, "KICK #chan,#chan bar,baz :bye")
else:
self.sendLine(1, "KICK #chan bar,baz :bye")
try:
m = self.getMessage(1)
if m.command == "482":
raise runner.OptionalExtensionNotSupported(
"Channel creators are not opped by default."
)
except client_mock.NoMessageException:
# The RFCs do not say KICK must be echoed
pass
mgroup = self.getMessages(4)
self.assertGreaterEqual(len(mgroup), 2, mgroup)
m1, m2 = mgroup[:2]
self.assertMessageMatch(m1, command="KICK", params=["#chan", ANYSTR, "bye"])
self.assertMessageMatch(m2, command="KICK", params=["#chan", ANYSTR, "bye"])
if (m1.params[1] == "bar" and m2.params[1] == "baz") or (
m1.params[1] == "baz" and m2.params[1] == "bar"
):
... # success
else:
raise AssertionError(
"Middle params [{}, {}] are not correct.".format(
m1.params[1], m2.params[1]
)
)

View File

@ -1,521 +0,0 @@
"""
This specification is a little hard to test because all labels are optional;
so there may be many false positives.
<https://ircv3.net/specs/extensions/labeled-response.html>
"""
import re
import pytest
from irctest import cases
from irctest.numerics import ERR_UNKNOWNCOMMAND
from irctest.patma import ANYDICT, ANYOPTSTR, NotStrRe, RemainingKeys, StrRe
class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToMultipleClients(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
self.connectClient(
"carl",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(3)
self.connectClient(
"alice",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(4)
self.sendLine(1, "@label=12345 PRIVMSG bar,carl,alice :hi")
m = self.getMessage(1)
m2 = self.getMessage(2)
m3 = self.getMessage(3)
m4 = self.getMessage(4)
# ensure the label isn't sent to recipients
self.assertMessageMatch(m2, command="PRIVMSG", tags={})
self.assertMessageMatch(
m3,
command="PRIVMSG",
tags={},
)
self.assertMessageMatch(m4, command="PRIVMSG", tags={})
self.assertMessageMatch(
m, command="BATCH", fail_msg="No BATCH echo received after sending one out"
)
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToClient(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
self.sendLine(1, "@label=12345 PRIVMSG bar :hi")
m = self.getMessage(1)
m2 = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(m2, command="PRIVMSG", tags={})
self.assertMessageMatch(m, command="PRIVMSG", tags={"label": "12345"})
@pytest.mark.react_tag
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToChannel(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# join channels
self.sendLine(1, "JOIN #test")
self.getMessages(1)
self.sendLine(2, "JOIN #test")
self.getMessages(2)
self.getMessages(1)
self.sendLine(
1, "@label=12345;+draft/reply=123;+draft/react=l😃l PRIVMSG #test :hi"
)
ms = self.getMessage(1)
mt = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(mt, command="PRIVMSG", tags={})
# ensure sender correctly receives msg
self.assertMessageMatch(ms, command="PRIVMSG", tags={"label": "12345"})
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledPrivmsgResponsesToSelf(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345 PRIVMSG foo :hi")
m1 = self.getMessage(1)
m2 = self.getMessage(1)
number_of_labels = 0
for m in [m1, m2]:
self.assertMessageMatch(
m,
command="PRIVMSG",
fail_msg="Got a message back that wasn't a PRIVMSG",
)
if "label" in m.tags:
number_of_labels += 1
self.assertEqual(
m.tags["label"],
"12345",
m,
fail_msg=(
"Echo'd label doesn't match the label we sent "
"(should be '12345'): {msg}"
),
)
self.assertEqual(
number_of_labels,
1,
m1,
fail_msg=(
"When sending a PRIVMSG to self with echo-message, "
"we only expect one message to contain the label. "
"Instead, {} messages had the label"
).format(number_of_labels),
)
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledNoticeResponsesToClient(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
self.sendLine(1, "@label=12345 NOTICE bar :hi")
m = self.getMessage(1)
m2 = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(m2, command="NOTICE", tags={})
self.assertMessageMatch(m, command="NOTICE", tags={"label": "12345"})
@pytest.mark.react_tag
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledNoticeResponsesToChannel(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# join channels
self.sendLine(1, "JOIN #test")
self.getMessages(1)
self.sendLine(2, "JOIN #test")
self.getMessages(2)
self.getMessages(1)
self.sendLine(
1, "@label=12345;+draft/reply=123;+draft/react=l😃l NOTICE #test :hi"
)
ms = self.getMessage(1)
mt = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(mt, command="NOTICE", tags={})
# ensure sender correctly receives msg
self.assertMessageMatch(ms, command="NOTICE", tags={"label": "12345"})
@cases.mark_capabilities("echo-message", "batch", "labeled-response")
def testLabeledNoticeResponsesToSelf(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345 NOTICE foo :hi")
m1 = self.getMessage(1)
m2 = self.getMessage(1)
number_of_labels = 0
for m in [m1, m2]:
self.assertMessageMatch(
m, command="NOTICE", fail_msg="Got a message back that wasn't a NOTICE"
)
if "label" in m.tags:
number_of_labels += 1
self.assertEqual(
m.tags["label"],
"12345",
m,
fail_msg=(
"Echo'd label doesn't match the label we sent "
"(should be '12345'): {msg}"
),
)
self.assertEqual(
number_of_labels,
1,
m1,
fail_msg=(
"When sending a NOTICE to self with echo-message, "
"we only expect one message to contain the label. "
"Instead, {} messages had the label"
).format(number_of_labels),
)
@pytest.mark.react_tag
@cases.mark_capabilities(
"echo-message", "batch", "labeled-response", "message-tags"
)
def testLabeledTagMsgResponsesToClient(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# Need to get a valid msgid because Unreal validates them
self.sendLine(1, "PRIVMSG bar :hi")
msgid = self.getMessage(1).tags["msgid"]
assert msgid == self.getMessage(2).tags["msgid"]
self.sendLine(
1, f"@label=12345;+draft/reply={msgid};+draft/react=l😃l TAGMSG bar"
)
m = self.getMessage(1)
m2 = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(
m2,
command="TAGMSG",
tags={
"+draft/reply": msgid,
"+draft/react": "l😃l",
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
},
)
self.assertNotIn(
"label",
m2.tags,
m2,
fail_msg=(
"When sending a TAGMSG with a label, "
"the target user shouldn't receive the label "
"(only the sending user should): {msg}"
),
)
self.assertMessageMatch(
m,
command="TAGMSG",
tags={
"label": "12345",
"+draft/reply": msgid,
"+draft/react": "l😃l",
**ANYDICT,
},
)
@pytest.mark.react_tag
@cases.mark_capabilities(
"echo-message", "batch", "labeled-response", "message-tags"
)
def testLabeledTagMsgResponsesToChannel(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.connectClient(
"bar",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(2)
# join channels
self.sendLine(1, "JOIN #test")
self.getMessages(1)
self.sendLine(2, "JOIN #test")
self.getMessages(2)
self.getMessages(1)
# Need to get a valid msgid because Unreal validates them
self.sendLine(1, "PRIVMSG #test :hi")
msgid = self.getMessage(1).tags["msgid"]
assert msgid == self.getMessage(2).tags["msgid"]
self.sendLine(
1, f"@label=12345;+draft/reply={msgid};+draft/react=l😃l TAGMSG #test"
)
ms = self.getMessage(1)
mt = self.getMessage(2)
# ensure the label isn't sent to recipient
self.assertMessageMatch(
mt,
command="TAGMSG",
tags={
"+draft/reply": msgid,
"+draft/react": "l😃l",
RemainingKeys(NotStrRe("label")): ANYOPTSTR,
},
fail_msg="No TAGMSG received by the target after sending one out",
)
self.assertNotIn(
"label",
mt.tags,
mt,
fail_msg=(
"When sending a TAGMSG with a label, "
"the target user shouldn't receive the label "
"(only the sending user should): {msg}"
),
)
# ensure sender correctly receives msg
self.assertMessageMatch(
ms,
command="TAGMSG",
tags={"label": "12345", "+draft/reply": msgid, **ANYDICT},
)
@pytest.mark.react_tag
@cases.mark_capabilities(
"echo-message", "batch", "labeled-response", "message-tags"
)
def testLabeledTagMsgResponsesToSelf(self):
self.connectClient(
"foo",
capabilities=["echo-message", "batch", "labeled-response", "message-tags"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345;+draft/reply=123;+draft/react=l😃l TAGMSG foo")
m1 = self.getMessage(1)
m2 = self.getMessage(1)
number_of_labels = 0
for m in [m1, m2]:
self.assertMessageMatch(
m, command="TAGMSG", fail_msg="Got a message back that wasn't a TAGMSG"
)
if "label" in m.tags:
number_of_labels += 1
self.assertEqual(
m.tags["label"],
"12345",
m,
fail_msg=(
"Echo'd label doesn't match the label we sent "
"(should be '12345'): {msg}"
),
)
self.assertEqual(
number_of_labels,
1,
m1,
fail_msg=(
"When sending a TAGMSG to self with echo-message, "
"we only expect one message to contain the label. "
"Instead, {} messages had the label"
).format(number_of_labels),
)
@cases.mark_capabilities("batch", "labeled-response", "message-tags", "server-time")
def testBatchedJoinMessages(self):
self.connectClient(
"bar",
capabilities=["batch", "labeled-response", "message-tags", "server-time"],
skip_if_cap_nak=True,
)
self.getMessages(1)
self.sendLine(1, "@label=12345 JOIN #xyz")
m = self.getMessages(1)
# we expect at least join and names lines, which must be batched
self.assertGreaterEqual(len(m), 3)
# valid BATCH start line:
batch_start = m[0]
self.assertMessageMatch(
batch_start,
command="BATCH",
params=[StrRe(r"\+.*"), "labeled-response"],
)
batch_id = batch_start.params[0][1:]
# batch id MUST be alphanumerics and hyphens
self.assertTrue(
re.match(r"^[A-Za-z0-9\-]+$", batch_id) is not None,
"batch id must be alphanumerics and hyphens, got %r" % (batch_id,),
)
self.assertEqual(batch_start.tags.get("label"), "12345")
# valid BATCH end line
batch_end = m[-1]
self.assertMessageMatch(batch_end, command="BATCH", params=["-" + batch_id])
# messages must have the BATCH tag
for message in m[1:-1]:
self.assertEqual(message.tags.get("batch"), batch_id)
@cases.mark_capabilities("labeled-response")
def testNoBatchForSingleMessage(self):
self.connectClient(
"bar", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
)
self.getMessages(1)
self.sendLine(1, "@label=98765 PING adhoctestline")
# no BATCH should be initiated for a one-line response,
# it should just be labeled
m = self.getMessage(1)
self.assertMessageMatch(m, command="PONG", tags={"label": "98765"})
self.assertEqual(m.params[-1], "adhoctestline")
@cases.mark_capabilities("labeled-response")
def testEmptyBatchForNoResponse(self):
self.connectClient(
"bar", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
)
self.getMessages(1)
# PONG never receives a response
self.sendLine(1, "@label=98765 PONG adhoctestline")
# labeled-response: "Servers MUST respond with a labeled
# `ACK` message when a client sends a labeled command that normally
# produces no response."
ms = self.getMessages(1)
self.assertEqual(len(ms), 1)
ack = ms[0]
self.assertMessageMatch(ack, command="ACK", tags={"label": "98765"})
@cases.mark_capabilities("labeled-response")
def testUnknownCommand(self):
self.connectClient(
"bar", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True
)
# this command doesn't exist, but the error response should still
# be labeled:
self.sendLine(1, "@label=deadbeef NONEXISTENT_COMMAND")
ms = self.getMessages(1)
self.assertEqual(len(ms), 1)
unknowncommand = ms[0]
self.assertMessageMatch(
unknowncommand, command=ERR_UNKNOWNCOMMAND, tags={"label": "deadbeef"}
)

View File

@ -1,75 +0,0 @@
from irctest import cases
class ListTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testListEmpty(self):
"""<https://tools.ietf.org/html/rfc1459#section-4.2.6>
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
"""
self.connectClient("foo")
self.connectClient("bar")
self.getMessages(1)
self.sendLine(2, "LIST")
m = self.getMessage(2)
if m.command == "321":
# skip RPL_LISTSTART
m = self.getMessage(2)
# skip local pseudo-channels listed by ngircd and ircu
while m.command == "322" and m.params[1].startswith("&"):
m = self.getMessage(2)
self.assertNotEqual(
m.command,
"322", # RPL_LIST
"LIST response gives (at least) one channel, whereas there " "is none.",
)
self.assertMessageMatch(
m,
command="323", # RPL_LISTEND
fail_msg="Second reply to LIST is not 322 (RPL_LIST) "
"or 323 (RPL_LISTEND), or but: {msg}",
)
@cases.mark_specifications("RFC1459", "RFC2812")
def testListOne(self):
"""When a channel exists, LIST should get it in a reply.
<https://tools.ietf.org/html/rfc1459#section-4.2.6>
<https://tools.ietf.org/html/rfc2812#section-3.2.6>
"""
self.connectClient("foo")
self.connectClient("bar")
self.sendLine(1, "JOIN #chan")
self.getMessages(1)
self.sendLine(2, "LIST")
m = self.getMessage(2)
if m.command == "321":
# skip RPL_LISTSTART
m = self.getMessage(2)
self.assertNotEqual(
m.command,
"323", # RPL_LISTEND
fail_msg="LIST response ended (ie. 323, aka RPL_LISTEND) "
"without listing any channel, whereas there is one.",
)
self.assertMessageMatch(
m,
command="322", # RPL_LIST
fail_msg="Second reply to LIST is not 322 (RPL_LIST), "
"nor 323 (RPL_LISTEND) but: {msg}",
)
m = self.getMessage(2)
# skip local pseudo-channels listed by ngircd and ircu
while m.command == "322" and m.params[1].startswith("&"):
m = self.getMessage(2)
self.assertNotEqual(
m.command,
"322", # RPL_LIST
fail_msg="LIST response gives (at least) two channels, "
"whereas there is only one.",
)
self.assertMessageMatch(
m,
command="323", # RPL_LISTEND
fail_msg="Third reply to LIST is not 322 (RPL_LIST) "
"or 323 (RPL_LISTEND), or but: {msg}",
)

View File

@ -1,318 +0,0 @@
from dataclasses import dataclass
import re
from typing import Optional
from irctest import cases
from irctest.numerics import (
ERR_NOTREGISTERED,
RPL_GLOBALUSERS,
RPL_LOCALUSERS,
RPL_LUSERCHANNELS,
RPL_LUSERCLIENT,
RPL_LUSERME,
RPL_LUSEROP,
RPL_LUSERUNKNOWN,
RPL_YOUREOPER,
)
from irctest.patma import ANYSTR, StrRe
# 3 numbers, delimited by spaces, possibly negative (eek)
LUSERCLIENT_REGEX = re.compile(r"^.*( [-0-9]* ).*( [-0-9]* ).*( [-0-9]* ).*$")
# 2 numbers
LUSERME_REGEX = re.compile(r"^.*?( [-0-9]* ).*( [-0-9]* ).*$")
@dataclass
class LusersResult:
GlobalVisible: Optional[int] = None
GlobalInvisible: Optional[int] = None
Servers: Optional[int] = None
Opers: Optional[int] = None
Unregistered: Optional[int] = None
Channels: Optional[int] = None
LocalTotal: Optional[int] = None
LocalMax: Optional[int] = None
GlobalTotal: Optional[int] = None
GlobalMax: Optional[int] = None
class LusersTestCase(cases.BaseServerTestCase):
def assertLusersResult(self, lusers, unregistered, total, max_):
self.assertIn(lusers.Unregistered, (unregistered, None))
self.assertIn(lusers.GlobalTotal, (total, None))
self.assertIn(lusers.GlobalMax, (max_, None))
self.assertGreaterEqual(lusers.GlobalInvisible, 0)
self.assertGreaterEqual(lusers.GlobalVisible, 0)
self.assertLessEqual(lusers.GlobalInvisible, total)
self.assertLessEqual(lusers.GlobalVisible, total)
self.assertEqual(lusers.GlobalInvisible + lusers.GlobalVisible, total)
self.assertIn(lusers.LocalTotal, (total, None))
self.assertIn(lusers.LocalMax, (max_, None))
def getLusers(self, client, allow_missing_265_266):
self.sendLine(client, "LUSERS")
messages = self.getMessages(client)
by_numeric = dict((msg.command, msg) for msg in messages)
self.assertEqual(len(by_numeric), len(messages), "Duplicated numerics")
result = LusersResult()
# all of these take the nick as first param
for message in messages:
self.assertEqual(client, message.params[0])
luserclient = by_numeric[RPL_LUSERCLIENT] # 251
self.assertEqual(len(luserclient.params), 2)
luserclient_param = luserclient.params[1]
try:
match = LUSERCLIENT_REGEX.match(luserclient_param)
result.GlobalVisible = int(match.group(1))
result.GlobalInvisible = int(match.group(2))
result.Servers = int(match.group(3))
except Exception:
raise ValueError("corrupt reply for 251 RPL_LUSERCLIENT", luserclient_param)
if RPL_LUSEROP in by_numeric:
self.assertMessageMatch(
by_numeric[RPL_LUSEROP], params=[client, StrRe("[0-9]+"), ANYSTR]
)
result.Opers = int(by_numeric[RPL_LUSEROP].params[1])
if RPL_LUSERUNKNOWN in by_numeric:
self.assertMessageMatch(
by_numeric[RPL_LUSERUNKNOWN], params=[client, StrRe("[0-9]+"), ANYSTR]
)
result.Unregistered = int(by_numeric[RPL_LUSERUNKNOWN].params[1])
if RPL_LUSERCHANNELS in by_numeric:
self.assertMessageMatch(
by_numeric[RPL_LUSERCHANNELS], params=[client, StrRe("[0-9]+"), ANYSTR]
)
result.Channels = int(by_numeric[RPL_LUSERCHANNELS].params[1])
self.assertMessageMatch(by_numeric[RPL_LUSERCLIENT], params=[client, ANYSTR])
self.assertMessageMatch(by_numeric[RPL_LUSERME], params=[client, ANYSTR])
if (
allow_missing_265_266
and RPL_LOCALUSERS not in by_numeric
and RPL_GLOBALUSERS not in by_numeric
):
return
# FIXME: RPL_LOCALUSERS and RPL_GLOBALUSERS are only in Modern, not in RFC2812
localusers = by_numeric[RPL_LOCALUSERS]
globalusers = by_numeric[RPL_GLOBALUSERS]
if len(localusers.params) == 4:
result.LocalTotal = int(localusers.params[1])
result.LocalMax = int(localusers.params[2])
result.GlobalTotal = int(globalusers.params[1])
result.GlobalMax = int(globalusers.params[2])
else:
# Arguments 1 and 2 are optional
self.assertEqual(len(localusers.params), 2)
result.LocalTotal = result.LocalMax = None
result.GlobalTotal = result.GlobalMax = None
return result
luserme = by_numeric[RPL_LUSERME]
self.assertEqual(len(luserme.params), 2)
luserme_param = luserme.params[1]
try:
match = LUSERME_REGEX.match(luserme_param)
localTotalFromUserme = int(match.group(1))
serversFromUserme = int(match.group(2))
except Exception:
raise ValueError("corrupt reply for 255 RPL_LUSERME", luserme_param)
self.assertEqual(result.LocalTotal, localTotalFromUserme)
# serversFromUserme is "servers i'm currently connected to", generally undefined
self.assertGreaterEqual(serversFromUserme, 0)
return result
class BasicLusersTestCase(LusersTestCase):
@cases.mark_specifications("RFC2812")
def testLusers(self):
self.connectClient("bar", name="bar")
self.getLusers("bar", True)
self.connectClient("qux", name="qux")
self.getLusers("qux", True)
self.sendLine("qux", "QUIT")
self.assertDisconnected("qux")
self.getLusers("bar", True)
@cases.mark_specifications("Modern")
def testLusersFull(self):
self.connectClient("bar", name="bar")
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
self.connectClient("qux", name="qux")
lusers = self.getLusers("qux", False)
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
self.sendLine("qux", "QUIT")
self.assertDisconnected("qux")
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
class LusersUnregisteredTestCase(LusersTestCase):
@cases.mark_specifications("RFC2812")
def testLusersRfc2812(self):
self.doLusersTest(True)
@cases.mark_specifications("Modern")
def testLusersFull(self):
self.doLusersTest(False)
def _synchronize(self, client_name):
"""Synchronizes using a PING, but accept ERR_NOTREGISTERED as a response."""
self.sendLine(client_name, "PING PARAM")
for _ in range(1000):
msg = self.getRegistrationMessage(client_name)
if msg.command in (ERR_NOTREGISTERED, "PONG"):
break
else:
assert False, (
"Sent a PING before registration, "
"got neither PONG or ERR_NOTREGISTERED"
)
def doLusersTest(self, allow_missing_265_266):
self.connectClient("bar", name="bar")
lusers = self.getLusers("bar", allow_missing_265_266)
if lusers:
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
self.addClient("qux")
self.sendLine("qux", "NICK qux")
self._synchronize("qux")
lusers = self.getLusers("bar", allow_missing_265_266)
if lusers:
self.assertLusersResult(lusers, unregistered=1, total=1, max_=1)
self.addClient("bat")
self.sendLine("bat", "NICK bat")
self._synchronize("bat")
lusers = self.getLusers("bar", allow_missing_265_266)
if lusers:
self.assertLusersResult(lusers, unregistered=2, total=1, max_=1)
# complete registration on one client
self.sendLine("qux", "USER u s e r")
self.getRegistrationMessage("qux")
lusers = self.getLusers("bar", allow_missing_265_266)
if lusers:
self.assertLusersResult(lusers, unregistered=1, total=2, max_=2)
# QUIT the other without registering
self.sendLine("bat", "QUIT")
self.assertDisconnected("bat")
lusers = self.getLusers("bar", allow_missing_265_266)
if lusers:
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
class LusersUnregisteredDefaultInvisibleTestCase(LusersUnregisteredTestCase):
"""Same as above but with +i as the default."""
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["accounts"].update(
{"default-user-modes": "+i"}
)
)
@cases.mark_specifications("Ergo")
def testLusers(self):
self.doLusersTest(False)
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
self.assertEqual(lusers.GlobalInvisible, 2)
self.assertEqual(lusers.GlobalVisible, 0)
class LuserOpersTestCase(LusersTestCase):
@cases.mark_specifications("Ergo")
def testLuserOpers(self):
self.connectClient("bar", name="bar")
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
self.assertIn(lusers.Opers, (0, None))
# add 1 oper
self.sendLine("bar", "OPER operuser operpassword")
msgs = self.getMessages("bar")
self.assertIn(RPL_YOUREOPER, {msg.command for msg in msgs})
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
self.assertEqual(lusers.Opers, 1)
# now 2 opers
self.connectClient("qux", name="qux")
self.sendLine("qux", "OPER operuser operpassword")
self.getMessages("qux")
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
self.assertEqual(lusers.Opers, 2)
# remove oper with MODE
self.sendLine("bar", "MODE bar -o")
msgs = self.getMessages("bar")
self.assertIn("MODE", {msg.command for msg in msgs})
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
self.assertEqual(lusers.Opers, 1)
# remove oper by quit
self.sendLine("qux", "QUIT")
self.assertDisconnected("qux")
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
self.assertEqual(lusers.Opers, 0)
class ErgoInvisibleDefaultTestCase(LusersTestCase):
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return cases.TestCaseControllerConfig(
ergo_config=lambda config: config["accounts"].update(
{"default-user-modes": "+i"}
)
)
@cases.mark_specifications("Ergo")
def testLusers(self):
self.connectClient("bar", name="bar")
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=1, max_=1)
self.assertEqual(lusers.GlobalInvisible, 1)
self.assertEqual(lusers.GlobalVisible, 0)
self.connectClient("qux", name="qux")
lusers = self.getLusers("qux", False)
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
self.assertEqual(lusers.GlobalInvisible, 2)
self.assertEqual(lusers.GlobalVisible, 0)
# remove +i with MODE
self.sendLine("bar", "MODE bar -i")
msgs = self.getMessages("bar")
lusers = self.getLusers("bar", False)
self.assertIn("MODE", {msg.command for msg in msgs})
self.assertLusersResult(lusers, unregistered=0, total=2, max_=2)
self.assertEqual(lusers.GlobalInvisible, 1)
self.assertEqual(lusers.GlobalVisible, 1)
# disconnect invisible user
self.sendLine("qux", "QUIT")
self.assertDisconnected("qux")
lusers = self.getLusers("bar", False)
self.assertLusersResult(lusers, unregistered=0, total=1, max_=2)
self.assertEqual(lusers.GlobalInvisible, 0)
self.assertEqual(lusers.GlobalVisible, 1)

View File

@ -1,196 +0,0 @@
"""
https://ircv3.net/specs/extensions/message-tags.html
"""
import pytest
from irctest import cases
from irctest.irc_utils.message_parser import parse_message
from irctest.numerics import ERR_INPUTTOOLONG
from irctest.patma import ANYDICT, ANYSTR, StrRe
class MessageTagsTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
@pytest.mark.arbitrary_client_tags
@cases.mark_capabilities("message-tags")
def testBasic(self):
def getAllMessages():
for name in ["alice", "bob", "carol", "dave"]:
self.getMessages(name)
def assertNoTags(line):
# tags start with '@', without tags we start with the prefix,
# which begins with ':'
self.assertEqual(line[0], ":")
msg = parse_message(line)
self.assertEqual(msg.tags, {})
return msg
self.connectClient(
"alice", name="alice", capabilities=["message-tags"], skip_if_cap_nak=True
)
self.joinChannel("alice", "#test")
self.connectClient(
"bob", name="bob", capabilities=["message-tags", "echo-message"]
)
self.joinChannel("bob", "#test")
self.connectClient("carol", name="carol")
self.joinChannel("carol", "#test")
self.connectClient("dave", name="dave", capabilities=["server-time"])
self.joinChannel("dave", "#test")
getAllMessages()
self.sendLine("alice", "@+baz=bat;fizz=buzz PRIVMSG #test hi")
self.getMessages("alice")
bob_msg = self.getMessage("bob")
carol_line = self.getMessage("carol", raw=True)
self.assertMessageMatch(
bob_msg,
command="PRIVMSG",
params=["#test", "hi"],
tags={"+baz": "bat", "msgid": ANYSTR, **ANYDICT},
)
# should not relay a non-client-only tag
self.assertNotIn("fizz", bob_msg.tags)
# carol MUST NOT receive tags
carol_msg = assertNoTags(carol_line)
self.assertMessageMatch(carol_msg, command="PRIVMSG", params=["#test", "hi"])
# dave SHOULD receive server-time tag
dave_msg = self.getMessage("dave")
self.assertMessageMatch(
dave_msg,
command="PRIVMSG",
params=["#test", "hi"],
tags={"time": ANYSTR, **ANYDICT},
)
# dave MUST NOT receive client-only tags
self.assertNotIn("+baz", dave_msg.tags)
getAllMessages()
self.sendLine("bob", "@+bat=baz;+fizz=buzz PRIVMSG #test :hi yourself")
bob_msg = self.getMessage("bob") # bob has echo-message
alice_msg = self.getMessage("alice")
carol_line = self.getMessage("carol", raw=True)
carol_msg = assertNoTags(carol_line)
for msg in [alice_msg, bob_msg]:
self.assertMessageMatch(
msg,
command="PRIVMSG",
params=["#test", "hi yourself"],
tags={"+bat": "baz", "+fizz": "buzz", "msgid": ANYSTR, **ANYDICT},
)
self.assertMessageMatch(
carol_msg,
command="PRIVMSG",
params=["#test", "hi yourself"],
)
self.assertEqual(alice_msg.tags["msgid"], bob_msg.tags["msgid"])
getAllMessages()
# test TAGMSG and basic escaping
self.sendLine("bob", r"@+buzz=fizz\:buzz;cat=dog;+steel=wootz TAGMSG #test")
bob_msg = self.getMessage("bob") # bob has echo-message
alice_msg = self.getMessage("alice")
# carol MUST NOT receive TAGMSG at all
self.assertEqual(self.getMessages("carol"), [])
# dave MUST NOT receive TAGMSG either, despite having server-time
self.assertEqual(self.getMessages("dave"), [])
for msg in [alice_msg, bob_msg]:
self.assertMessageMatch(
alice_msg,
command="TAGMSG",
params=["#test"],
tags={
"+buzz": "fizz;buzz",
"+steel": "wootz",
"msgid": ANYSTR,
**ANYDICT,
},
)
self.assertNotIn("cat", msg.tags)
self.assertEqual(alice_msg.tags["msgid"], bob_msg.tags["msgid"])
@pytest.mark.arbitrary_client_tags
@cases.mark_capabilities("message-tags")
@cases.mark_specifications("ircdocs")
def testLengthLimits(self):
self.connectClient(
"alice",
name="alice",
capabilities=["message-tags", "echo-message"],
skip_if_cap_nak=True,
)
self.joinChannel("alice", "#test")
self.connectClient("bob", name="bob", capabilities=["message-tags"])
self.joinChannel("bob", "#test")
self.getMessages("alice")
self.getMessages("bob")
# this is right at the limit of 4094 bytes of server tag data,
# 4096 bytes of client tag data (including the starting '@' and the final ' ')
max_tagmsg = "@foo=bar;+baz=%s TAGMSG #test" % ("a" * 4081,)
self.assertEqual(max_tagmsg.index("TAGMSG"), 4096)
self.sendLine("alice", max_tagmsg)
echo = self.getMessage("alice")
relay = self.getMessage("bob")
self.assertMessageMatch(
echo,
command="TAGMSG",
params=["#test"],
tags={"+baz": "a" * 4081, "msgid": StrRe(".+"), **ANYDICT},
)
self.assertMessageMatch(
relay,
command="TAGMSG",
params=["#test"],
tags={"+baz": "a" * 4081, "msgid": StrRe(".+"), **ANYDICT},
)
self.assertEqual(echo.tags["msgid"], relay.tags["msgid"])
excess_tagmsg = "@foo=bar;+baz=%s TAGMSG #test" % ("a" * 4082,)
self.assertEqual(excess_tagmsg.index("TAGMSG"), 4097)
self.sendLine("alice", excess_tagmsg)
reply = self.getMessage("alice")
self.assertEqual(reply.command, ERR_INPUTTOOLONG)
self.assertEqual(self.getMessages("bob"), [])
max_privmsg = "@foo=bar;+baz=%s PRIVMSG #test %s" % ("a" * 4081, "b" * 496)
# irctest adds the '\r\n' for us, this is right at the limit
self.assertEqual(len(max_privmsg), 4096 + (512 - 2))
self.sendLine("alice", max_privmsg)
echo = self.getMessage("alice")
# the server may still reject this message on the grounds that the final
# parameter is too long to be relayed without truncation, once alice's
# NUH is included. however, if the message was accepted, the tags MUST
# be relayed intact, because they are unquestionably valid. See the
# original context of ERR_INPUTTOOLONG:
# https://defs.ircdocs.horse/defs/numerics.html#err-inputtoolong-417
if echo.command != ERR_INPUTTOOLONG:
relay = self.getMessage("bob")
self.assertMessageMatch(
echo,
command="PRIVMSG",
params=["#test", StrRe("b{400,496}")],
tags={"+baz": "a" * 4081, "msgid": StrRe(".+"), **ANYDICT},
)
self.assertMessageMatch(
relay,
command="PRIVMSG",
params=["#test", StrRe("b{400,496}")],
tags={"+baz": "a" * 4081, "msgid": StrRe(".+"), **ANYDICT},
)
self.assertEqual(echo.tags["msgid"], relay.tags["msgid"])
# message may have been truncated
self.assertIn("b" * 400, echo.params[1])
self.assertEqual(echo.params[1].rstrip("b"), "")
self.assertIn("b" * 400, relay.params[1])
self.assertEqual(relay.params[1].rstrip("b"), "")
excess_privmsg = "@foo=bar;+baz=%s PRIVMSG #test %s" % ("a" * 4082, "b" * 495)
# TAGMSG data is over the limit, but we're within the overall limit for a line
self.assertEqual(excess_privmsg.index("PRIVMSG"), 4097)
self.assertEqual(len(excess_privmsg), 4096 + (512 - 2))
self.sendLine("alice", excess_privmsg)
reply = self.getMessage("alice")
self.assertEqual(reply.command, ERR_INPUTTOOLONG)
self.assertEqual(self.getMessages("bob"), [])

View File

@ -1,152 +0,0 @@
"""
Section 3.2 of RFC 2812
<https://tools.ietf.org/html/rfc2812#section-3.3>
"""
from irctest import cases
from irctest.numerics import ERR_INPUTTOOLONG
class PrivmsgTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testPrivmsg(self):
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
self.getMessages(2) # synchronize
self.sendLine(1, "PRIVMSG #chan :hello there")
self.getMessages(1) # synchronize
pms = [msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"]
self.assertEqual(len(pms), 1)
self.assertMessageMatch(
pms[0], command="PRIVMSG", params=["#chan", "hello there"]
)
@cases.mark_specifications("RFC1459", "RFC2812")
def testPrivmsgNonexistentChannel(self):
"""<https://tools.ietf.org/html/rfc2812#section-3.3.1>"""
self.connectClient("foo")
self.sendLine(1, "PRIVMSG #nonexistent :hello there")
msg = self.getMessage(1)
# ERR_NOSUCHNICK, ERR_NOSUCHCHANNEL, or ERR_CANNOTSENDTOCHAN
self.assertIn(msg.command, ("401", "403", "404"))
class NoticeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("RFC1459", "RFC2812")
def testNotice(self):
"""<https://tools.ietf.org/html/rfc2812#section-3.3.2>"""
self.connectClient("foo")
self.sendLine(1, "JOIN #chan")
self.connectClient("bar")
self.sendLine(2, "JOIN #chan")
self.getMessages(2) # synchronize
self.sendLine(1, "NOTICE #chan :hello there")
self.getMessages(1) # synchronize
notices = [msg for msg in self.getMessages(2) if msg.command == "NOTICE"]
self.assertEqual(len(notices), 1)
self.assertMessageMatch(
notices[0], command="NOTICE", params=["#chan", "hello there"]
)
@cases.mark_specifications("RFC1459", "RFC2812")
def testNoticeNonexistentChannel(self):
"""
"automatic replies must never be
sent in response to a NOTICE message. This rule applies to servers
too - they must not send any error reply back to the client on
receipt of a notice"
<https://tools.ietf.org/html/rfc1459#section-4.4.2>
'automatic replies MUST NEVER be sent in response to a NOTICE message.
This rule applies to servers too - they MUST NOT send any error repl
back to the client on receipt of a notice."
<https://tools.ietf.org/html/rfc2812#section-3.3.2>
"""
self.connectClient("foo")
self.sendLine(1, "NOTICE #nonexistent :hello there")
self.assertEqual(self.getMessages(1), [])
class TagsTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("message-tags")
def testLineTooLong(self):
self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True)
self.connectClient(
"recver", capabilities=["message-tags"], skip_if_cap_nak=True
)
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))
class LengthLimitTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testLineAtLimit(self):
self.connectClient("bar", name="bar")
self.getMessages("bar")
line = "PING " + ("x" * (512 - 7))
# this line is exactly as the limit, after including \r\n:
self.assertEqual(len(line), 510)
# oragono should accept and process this message. the outgoing PONG
# will be truncated due to the addition of the server name as source
# and initial parameter; this is fine:
self.sendLine("bar", line)
result = self.getMessage("bar", synchronize=False)
self.assertMessageMatch(result, command="PONG")
self.assertIn("x" * 450, result.params[-1])
@cases.mark_specifications("Ergo")
def testLineBeyondLimit(self):
self.connectClient("bar", name="bar")
self.getMessages("bar")
line = "PING " + ("x" * (512 - 6))
# this line is one over the limit after including \r\n:
self.assertEqual(len(line), 511)
# oragono should reject this message for exceeding the length limit:
self.sendLine("bar", line)
result = self.getMessage("bar", synchronize=False)
self.assertMessageMatch(result, command=ERR_INPUTTOOLONG)
# we should not be disconnected and should be able to join a channel
self.joinChannel("bar", "#test_channel")
class NoCTCPModeTestCase(cases.BaseServerTestCase):
@cases.mark_specifications("Ergo")
def testNoCTCPMode(self):
self.connectClient("bar", "bar")
self.connectClient("qux", "qux")
# CTCP is not blocked by default:
self.sendLine("qux", "PRIVMSG bar :\x01VERSION\x01")
self.getMessages("qux")
relay = [msg for msg in self.getMessages("bar") if msg.command == "PRIVMSG"][0]
self.assertMessageMatch(
relay, command="PRIVMSG", params=["bar", "\x01VERSION\x01"]
)
# set the no-CTCP user mode on bar:
self.sendLine("bar", "MODE bar +T")
replies = self.getMessages("bar")
umode_line = [msg for msg in replies if msg.command == "MODE"][0]
self.assertMessageMatch(umode_line, command="MODE", params=["bar", "+T"])
# CTCP is now blocked:
self.sendLine("qux", "PRIVMSG bar :\x01VERSION\x01")
self.getMessages("qux")
self.assertEqual(self.getMessages("bar"), [])
# normal PRIVMSG go through:
self.sendLine("qux", "PRIVMSG bar :please just tell me your client version")
self.getMessages("qux")
relay = self.getMessages("bar")[0]
self.assertMessageMatch(
relay,
command="PRIVMSG",
nick="qux",
params=["bar", "please just tell me your client version"],
)

View File

@ -1,245 +0,0 @@
"""
Tests METADATA features.
<http://ircv3.net/specs/core/metadata-3.2.html>
"""
from irctest import cases
class MetadataTestCase(cases.BaseServerTestCase):
valid_metadata_keys = {"valid_key1", "valid_key2"}
invalid_metadata_keys = {"invalid_key1", "invalid_key2"}
@cases.mark_specifications("IRCv3", deprecated=True)
def testInIsupport(self):
"""“If METADATA is supported, it MUST be specified in RPL_ISUPPORT
using the METADATA key.”
-- <http://ircv3.net/specs/core/metadata-3.2.html>
"""
self.addClient()
self.sendLine(1, "CAP LS 302")
self.getCapLs(1)
self.sendLine(1, "USER foo foo foo :foo")
self.sendLine(1, "NICK foo")
self.sendLine(1, "CAP END")
self.skipToWelcome(1)
m = self.getMessage(1)
while m.command != "005": # RPL_ISUPPORT
m = self.getMessage(1)
self.assertIn(
"METADATA",
{x.split("=")[0] for x in m.params[1:-1]},
fail_msg="{item} missing from RPL_ISUPPORT",
)
self.getMessages(1)
@cases.mark_specifications("IRCv3", deprecated=True)
def testGetOneUnsetValid(self):
"""<http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>"""
self.connectClient("foo")
self.sendLine(1, "METADATA * GET valid_key1")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="766", # ERR_NOMATCHINGKEY
fail_msg="Did not reply with 766 (ERR_NOMATCHINGKEY) to a "
"request to an unset valid METADATA key.",
)
@cases.mark_specifications("IRCv3", deprecated=True)
def testGetTwoUnsetValid(self):
"""“Multiple keys may be given. The response will be either RPL_KEYVALUE,
ERR_KEYINVALID or ERR_NOMATCHINGKEY for every key in order.”
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-get>
"""
self.connectClient("foo")
self.sendLine(1, "METADATA * GET valid_key1 valid_key2")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="766", # ERR_NOMATCHINGKEY
fail_msg="Did not reply with 766 (ERR_NOMATCHINGKEY) to a "
"request to two unset valid METADATA key: {msg}",
)
self.assertEqual(
m.params[1],
"valid_key1",
m,
fail_msg="Response to “METADATA * GET valid_key1 valid_key2” "
"did not respond to valid_key1 first: {msg}",
)
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="766", # ERR_NOMATCHINGKEY
fail_msg="Did not reply with two 766 (ERR_NOMATCHINGKEY) to a "
"request to two unset valid METADATA key: {msg}",
)
self.assertEqual(
m.params[1],
"valid_key2",
m,
fail_msg="Response to “METADATA * GET valid_key1 valid_key2” "
"did not respond to valid_key2 as second response: {msg}",
)
@cases.mark_specifications("IRCv3", deprecated=True)
def testListNoSet(self):
"""“This subcommand MUST list all currently-set metadata keys along
with their values. The response will be zero or more RPL_KEYVALUE
events, following by RPL_METADATAEND event.”
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-list>
"""
self.connectClient("foo")
self.sendLine(1, "METADATA * LIST")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="762", # RPL_METADATAEND
fail_msg="Response to “METADATA * LIST” was not "
"762 (RPL_METADATAEND) but: {msg}",
)
@cases.mark_specifications("IRCv3", deprecated=True)
def testListInvalidTarget(self):
"""“In case of invalid target RPL_METADATAEND MUST NOT be sent.”
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-list>
"""
self.connectClient("foo")
self.sendLine(1, "METADATA foobar LIST")
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="765", # ERR_TARGETINVALID
fail_msg="Response to “METADATA <invalid target> LIST” was "
"not 765 (ERR_TARGETINVALID) but: {msg}",
)
commands = {m.command for m in self.getMessages(1)}
self.assertNotIn(
"762",
commands,
fail_msg="Sent “METADATA <invalid target> LIST”, got 765 "
"(ERR_TARGETINVALID), and then 762 (RPL_METADATAEND)",
)
def assertSetValue(self, target, key, value, displayable_value=None):
if displayable_value is None:
displayable_value = value
self.sendLine(1, "METADATA {} SET {} :{}".format(target, key, value))
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="761", # RPL_KEYVALUE
fail_msg="Did not reply with 761 (RPL_KEYVALUE) to a valid "
"“METADATA * SET {} :{}”: {msg}",
extra_format=(key, displayable_value),
)
self.assertEqual(
m.params[1],
"valid_key1",
m,
fail_msg="Second param of 761 after setting “{expects}” to "
"{}” is not “{expects}”: {msg}.",
extra_format=(displayable_value,),
)
self.assertEqual(
m.params[3],
value,
m,
fail_msg="Fourth param of 761 after setting “{0}” to "
"{1}” is not “{1}”: {msg}.",
extra_format=(key, displayable_value),
)
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="762", # RPL_METADATAEND
fail_msg="Did not send RPL_METADATAEND after setting "
"a valid METADATA key.",
)
def assertGetValue(self, target, key, value, displayable_value=None):
self.sendLine(1, "METADATA * GET {}".format(key))
m = self.getMessage(1)
self.assertMessageMatch(
m,
command="761", # RPL_KEYVALUE
fail_msg="Did not reply with 761 (RPL_KEYVALUE) to a valid "
"“METADATA * GET” when the key is set is set: {msg}",
)
self.assertEqual(
m.params[1],
key,
m,
fail_msg="Second param of 761 after getting “{expects}"
"(which is set) is not “{expects}”: {msg}.",
)
self.assertEqual(
m.params[3],
value,
m,
fail_msg="Fourth param of 761 after getting “{0}"
"(which is set to “{1}”) is not ”{1}”: {msg}.",
extra_format=(key, displayable_value),
)
def assertSetGetValue(self, target, key, value, displayable_value=None):
self.assertSetValue(target, key, value, displayable_value)
self.assertGetValue(target, key, value, displayable_value)
@cases.mark_specifications("IRCv3", deprecated=True)
def testSetGetValid(self):
"""<http://ircv3.net/specs/core/metadata-3.2.html>"""
self.connectClient("foo")
self.assertSetGetValue("*", "valid_key1", "myvalue")
@cases.mark_specifications("IRCv3", deprecated=True)
def testSetGetZeroCharInValue(self):
"""“Values are unrestricted, except that they MUST be UTF-8.”
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-restrictions>
"""
self.connectClient("foo")
self.assertSetGetValue("*", "valid_key1", "zero->\0<-zero", "zero->\\0<-zero")
@cases.mark_specifications("IRCv3", deprecated=True)
def testSetGetHeartInValue(self):
"""“Values are unrestricted, except that they MUST be UTF-8.”
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-restrictions>
"""
heart = b"\xf0\x9f\x92\x9c".decode()
self.connectClient("foo")
self.assertSetGetValue(
"*",
"valid_key1",
"->{}<-".format(heart),
"zero->{}<-zero".format(heart.encode()),
)
@cases.mark_specifications("IRCv3", deprecated=True)
def testSetInvalidUtf8(self):
"""“Values are unrestricted, except that they MUST be UTF-8.”
-- <http://ircv3.net/specs/core/metadata-3.2.html#metadata-restrictions>
"""
self.connectClient("foo")
# Sending directly because it is not valid UTF-8 so Python would
# not like it
self.clients[1].conn.sendall(
b"METADATA * SET valid_key1 " b":invalid UTF-8 ->\xc3<-\r\n"
)
commands = {m.command for m in self.getMessages(1)}
self.assertNotIn(
"761",
commands, # RPL_KEYVALUE
fail_msg="Setting METADATA key to a value containing invalid "
"UTF-8 was answered with 761 (RPL_KEYVALUE)",
)
self.clients[1].conn.sendall(
b"METADATA * SET valid_key1 " b":invalid UTF-8: \xc3\r\n"
)
commands = {m.command for m in self.getMessages(1)}
self.assertNotIn(
"761",
commands, # RPL_KEYVALUE
fail_msg="Setting METADATA key to a value containing invalid "
"UTF-8 was answered with 761 (RPL_KEYVALUE)",
)

View File

@ -1,316 +0,0 @@
"""
<http://ircv3.net/specs/core/monitor-3.2.html>
"""
from irctest import cases
from irctest.basecontrollers import NotImplementedByController
from irctest.client_mock import NoMessageException
from irctest.numerics import (
RPL_ENDOFMONLIST,
RPL_MONLIST,
RPL_MONOFFLINE,
RPL_MONONLINE,
)
from irctest.patma import ANYSTR, StrRe
class MonitorTestCase(cases.BaseServerTestCase):
def check_server_support(self):
if "MONITOR" not in self.server_support:
raise NotImplementedByController("MONITOR")
def assertMononline(self, client, nick, m=None):
if not m:
m = self.getMessage(client)
self.assertMessageMatch(
m,
command="730", # RPL_MONONLINE
params=[ANYSTR, StrRe(nick + "(!.*)?")],
fail_msg="Unexpected notification that monitored nick “{}"
"is online: {msg}",
extra_format=(nick,),
)
def assertMonoffline(self, client, nick, m=None):
if not m:
m = self.getMessage(client)
self.assertMessageMatch(
m,
command="731", # RPL_MONOFFLINE
params=[ANYSTR, nick],
fail_msg="Unexpected notification that monitored nick “{}"
"is offline: {msg}",
extra_format=(nick,),
)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorOneDisconnected(self):
"""“If any of the targets being added are online, the server will
generate RPL_MONONLINE numerics listing those targets that are
online.”
-- <http://ircv3.net/specs/core/monitor-3.2.html#monitor--targettarget2>
"""
self.connectClient("foo")
self.check_server_support()
self.sendLine(1, "MONITOR + bar")
self.assertMonoffline(1, "bar")
self.connectClient("bar")
self.assertMononline(1, "bar")
self.sendLine(2, "QUIT :bye")
try:
self.getMessages(2)
except ConnectionResetError:
pass
self.assertMonoffline(1, "bar")
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorOneConnection(self):
self.connectClient("foo")
self.check_server_support()
self.sendLine(1, "MONITOR + bar")
self.getMessages(1)
self.connectClient("bar")
self.assertMononline(1, "bar")
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorOneConnected(self):
"""“If any of the targets being added are offline, the server will
generate RPL_MONOFFLINE numerics listing those targets that are
online.”
-- <http://ircv3.net/specs/core/monitor-3.2.html#monitor--targettarget2>
"""
self.connectClient("foo")
self.check_server_support()
self.connectClient("bar")
self.sendLine(1, "MONITOR + bar")
self.assertMononline(1, "bar")
self.sendLine(2, "QUIT :bye")
try:
self.getMessages(2)
except ConnectionResetError:
pass
self.assertMonoffline(1, "bar")
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorOneConnectionWithQuit(self):
self.connectClient("foo")
self.check_server_support()
self.connectClient("bar")
self.sendLine(1, "MONITOR + bar")
self.assertMononline(1, "bar")
self.sendLine(2, "QUIT :bye")
try:
self.getMessages(2)
except ConnectionResetError:
pass
self.assertMonoffline(1, "bar")
self.connectClient("bar")
self.assertMononline(1, "bar")
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorConnectedAndDisconnected(self):
"""“If any of the targets being added are online, the server will
generate RPL_MONONLINE numerics listing those targets that are
online.
If any of the targets being added are offline, the server will
generate RPL_MONOFFLINE numerics listing those targets that are
online.”
-- <http://ircv3.net/specs/core/monitor-3.2.html#monitor--targettarget2>
"""
self.connectClient("foo")
self.check_server_support()
self.connectClient("bar")
self.sendLine(1, "MONITOR + bar,baz")
m1 = self.getMessage(1)
m2 = self.getMessage(1)
commands = {m1.command, m2.command}
self.assertEqual(
commands,
{"730", "731"},
fail_msg="Did not send one 730 (RPL_MONONLINE) and one "
"731 (RPL_MONOFFLINE) after “MONITOR + bar,baz” when “bar” "
"is online and “baz” is offline. Sent this instead: {}",
extra_format=((m1, m2)),
)
if m1.command == "731":
(m1, m2) = (m2, m1)
self.assertMononline(None, "bar", m=m1)
self.assertMonoffline(None, "baz", m=m2)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testUnmonitor(self):
self.connectClient("foo")
self.check_server_support()
self.sendLine(1, "MONITOR + bar")
self.getMessages(1)
self.connectClient("bar")
self.assertMononline(1, "bar")
self.sendLine(1, "MONITOR - bar")
self.assertEqual(
self.getMessages(1),
[],
fail_msg="Got messages after “MONITOR - bar”: {got}",
)
self.sendLine(2, "QUIT :bye")
try:
self.getMessages(2)
except ConnectionResetError:
pass
self.assertEqual(
self.getMessages(1),
[],
fail_msg="Got messages after disconnection of unmonitored " "nick: {got}",
)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorForbidsMasks(self):
"""“The MONITOR implementation also enhances user privacy by
disallowing subscription to hostmasks, allowing users to avoid
nick-change stalking.”
-- <http://ircv3.net/specs/core/monitor-3.2.html#watch-vs-monitor>
“For this specification, target MUST be a valid nick as determined
by the IRC daemon.”
-- <http://ircv3.net/specs/core/monitor-3.2.html#monitor-command>
"""
self.connectClient("foo")
self.check_server_support()
self.sendLine(1, "MONITOR + *!username@localhost")
self.sendLine(1, "MONITOR + *!username@127.0.0.1")
try:
m = self.getMessage(1)
self.assertMessageMatch(m, command="731")
except NoMessageException:
pass
else:
m = self.getMessage(1)
self.assertMessageMatch(m, command="731")
self.connectClient("bar")
try:
m = self.getMessage(1)
except NoMessageException:
pass
else:
raise AssertionError(
"Got message after client whose MONITORing "
"was requested via hostmask connected: {}".format(m)
)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testTwoMonitoringOneRemove(self):
"""Tests the following scenario:
* foo MONITORs qux
* bar MONITORs qux
* bar unMONITORs qux
* qux connects.
"""
self.connectClient("foo")
self.check_server_support()
self.connectClient("bar")
self.sendLine(1, "MONITOR + qux")
self.sendLine(2, "MONITOR + qux")
self.getMessages(1)
self.getMessages(2)
self.sendLine(2, "MONITOR - qux")
messages = self.getMessages(2)
self.assertEqual(
messages,
[],
fail_msg="Got response to “MONITOR -”: {}",
extra_format=(messages,),
)
self.connectClient("qux")
self.getMessages(3)
messages = self.getMessages(1)
self.assertNotEqual(
messages,
[],
fail_msg="Received no message after MONITORed client " "connects.",
)
messages = self.getMessages(2)
self.assertEqual(
messages,
[],
fail_msg="Got response to unmonitored client: {}",
extra_format=(messages,),
)
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testMonitorList(self):
def checkMonitorSubjects(messages, client_nick, expected_targets):
# collect all the RPL_MONLIST nicks into a set:
result = set()
for message in messages:
if message.command == RPL_MONLIST:
self.assertEqual(message.params[0], client_nick)
result.update(message.params[1].split(","))
# finally, RPL_ENDOFMONLIST should be sent
self.assertEqual(messages[-1].command, RPL_ENDOFMONLIST)
self.assertEqual(messages[-1].params[0], client_nick)
self.assertEqual(result, expected_targets)
self.connectClient("bar")
self.check_server_support()
self.sendLine(1, "MONITOR L")
checkMonitorSubjects(self.getMessages(1), "bar", set())
self.sendLine(1, "MONITOR + qux")
self.getMessages(1)
self.sendLine(1, "MONITOR L")
checkMonitorSubjects(self.getMessages(1), "bar", {"qux"})
self.sendLine(1, "MONITOR + bazbat")
self.getMessages(1)
self.sendLine(1, "MONITOR L")
checkMonitorSubjects(self.getMessages(1), "bar", {"qux", "bazbat"})
self.sendLine(1, "MONITOR - qux")
self.getMessages(1)
self.sendLine(1, "MONITOR L")
checkMonitorSubjects(self.getMessages(1), "bar", {"bazbat"})
@cases.mark_specifications("IRCv3")
@cases.mark_isupport("MONITOR")
def testNickChange(self):
# see oragono issue #1076: nickname changes must trigger RPL_MONOFFLINE
self.connectClient("bar")
self.check_server_support()
self.sendLine(1, "MONITOR + qux")
self.getMessages(1)
self.connectClient("baz")
self.getMessages(2)
self.assertEqual(self.getMessages(1), [])
self.sendLine(2, "NICK qux")
self.getMessages(2)
mononline = self.getMessages(1)[0]
self.assertEqual(mononline.command, RPL_MONONLINE)
self.assertEqual(len(mononline.params), 2, mononline.params)
self.assertIn(mononline.params[0], ("bar", "*"))
self.assertEqual(mononline.params[1].split("!")[0], "qux")
# no numerics for a case change
self.sendLine(2, "NICK QUX")
self.getMessages(2)
self.assertEqual(self.getMessages(1), [])
self.sendLine(2, "NICK bazbat")
self.getMessages(2)
monoffline = self.getMessages(1)[0]
# should get RPL_MONOFFLINE with the current unfolded nick
self.assertEqual(monoffline.command, RPL_MONOFFLINE)
self.assertEqual(len(monoffline.params), 2, monoffline.params)
self.assertIn(monoffline.params[0], ("bar", "*"))
self.assertEqual(monoffline.params[1].split("!")[0], "QUX")

View File

@ -1,56 +0,0 @@
"""
Tests multi-prefix.
<http://ircv3.net/specs/extensions/multi-prefix-3.1.html>
"""
from irctest import cases
from irctest.patma import ANYSTR
class MultiPrefixTestCase(cases.BaseServerTestCase):
@cases.mark_capabilities("multi-prefix")
def testMultiPrefix(self):
"""“When requested, the multi-prefix client capability will cause the
IRC server to send all possible prefixes which apply to a user in NAMES
and WHO output.
These prefixes MUST be in order of rank, from highest to lowest.
"""
self.connectClient("foo", capabilities=["multi-prefix"])
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",
fail_msg="Expected NAMES response (353) with @+foo, got: {msg}",
)
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.assertTrue(
"@+" in msg.params[6],
'Expected WHO response (352) with "@+" in param 7, got: {msg}'.format(
msg=msg
),
)

View File

@ -1,137 +0,0 @@
"""
draft/multiline
"""
from irctest import cases
from irctest.patma import ANYDICT, StrRe
CAP_NAME = "draft/multiline"
BATCH_TYPE = "draft/multiline"
CONCAT_TAG = "draft/multiline-concat"
base_caps = ["message-tags", "batch", "echo-message", "server-time", "labeled-response"]
class MultilineTestCase(cases.BaseServerTestCase, cases.OptionalityHelper):
@cases.mark_capabilities("draft/multiline")
def testBasic(self):
self.connectClient(
"alice", capabilities=(base_caps + [CAP_NAME]), skip_if_cap_nak=True
)
self.joinChannel(1, "#test")
self.connectClient("bob", capabilities=(base_caps + [CAP_NAME]))
self.joinChannel(2, "#test")
self.connectClient("charlie", capabilities=base_caps)
self.joinChannel(3, "#test")
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(1, "@label=xyz BATCH +123 %s #test" % (BATCH_TYPE,))
self.sendLine(1, "@batch=123 PRIVMSG #test hello")
self.sendLine(1, "@batch=123 PRIVMSG #test :#how is ")
self.sendLine(1, "@batch=123;%s PRIVMSG #test :everyone?" % (CONCAT_TAG,))
self.sendLine(1, "BATCH -123")
echo = self.getMessages(1)
batchStart, batchEnd = echo[0], echo[-1]
self.assertMessageMatch(
batchStart,
command="BATCH",
params=[StrRe(r"\+.*"), BATCH_TYPE, "#test"],
tags={"label": "xyz", **ANYDICT},
)
self.assertEqual(batchStart.tags.get("label"), "xyz")
self.assertMessageMatch(batchEnd, command="BATCH", params=[StrRe("-.*")])
self.assertEqual(
batchStart.params[0][1:],
batchEnd.params[0][1:],
fail_msg="batch start and end do not match",
)
msgid = batchStart.tags.get("msgid")
time = batchStart.tags.get("time")
assert msgid
assert time
privmsgs = echo[1:-1]
for msg in privmsgs:
self.assertMessageMatch(msg, command="PRIVMSG")
self.assertNotIn("msgid", msg.tags)
self.assertNotIn("time", msg.tags)
self.assertIn(CONCAT_TAG, echo[3].tags)
relay = self.getMessages(2)
batchStart, batchEnd = relay[0], relay[-1]
self.assertMessageMatch(
batchStart, command="BATCH", params=[StrRe(r"\+.*"), BATCH_TYPE, "#test"]
)
batchTag = batchStart.params[0][1:]
self.assertMessageMatch(batchEnd, command="BATCH", params=["-" + batchTag])
self.assertEqual(batchStart.tags.get("msgid"), msgid)
self.assertEqual(batchStart.tags.get("time"), time)
privmsgs = relay[1:-1]
for msg in privmsgs:
self.assertMessageMatch(msg, command="PRIVMSG")
self.assertNotIn("msgid", msg.tags)
self.assertNotIn("time", msg.tags)
self.assertEqual(msg.tags.get("batch"), batchTag)
self.assertIn(CONCAT_TAG, relay[3].tags)
fallback_relay = self.getMessages(3)
relayed_fmsgids = []
for msg in fallback_relay:
self.assertMessageMatch(msg, command="PRIVMSG")
relayed_fmsgids.append(msg.tags.get("msgid"))
self.assertEqual(msg.tags.get("time"), time)
self.assertNotIn(CONCAT_TAG, msg.tags)
self.assertEqual(relayed_fmsgids, [msgid] + [None] * (len(fallback_relay) - 1))
@cases.mark_capabilities("draft/multiline")
def testBlankLines(self):
self.connectClient(
"alice", capabilities=(base_caps + [CAP_NAME]), skip_if_cap_nak=True
)
self.joinChannel(1, "#test")
self.connectClient("bob", capabilities=(base_caps + [CAP_NAME]))
self.joinChannel(2, "#test")
self.connectClient("charlie", capabilities=base_caps)
self.joinChannel(3, "#test")
self.getMessages(1)
self.getMessages(2)
self.getMessages(3)
self.sendLine(
1, "@label=xyz;+client-only-tag BATCH +123 %s #test" % (BATCH_TYPE,)
)
self.sendLine(1, "@batch=123 PRIVMSG #test :")
self.sendLine(1, "@batch=123 PRIVMSG #test :#how is ")
self.sendLine(1, "@batch=123;%s PRIVMSG #test :everyone?" % (CONCAT_TAG,))
self.sendLine(1, "BATCH -123")
self.getMessages(1)
relay = self.getMessages(2)
batch_start = relay[0]
privmsgs = relay[1:-1]
self.assertEqual(len(privmsgs), 3)
self.assertMessageMatch(privmsgs[0], command="PRIVMSG", params=["#test", ""])
self.assertMessageMatch(
privmsgs[1], command="PRIVMSG", params=["#test", "#how is "]
)
self.assertMessageMatch(
privmsgs[2], command="PRIVMSG", params=["#test", "everyone?"]
)
self.assertIn("+client-only-tag", batch_start.tags)
msgid = batch_start.tags["msgid"]
fallback_relay = self.getMessages(3)
self.assertEqual(len(fallback_relay), 2)
self.assertMessageMatch(
fallback_relay[0], command="PRIVMSG", params=["#test", "#how is "]
)
self.assertMessageMatch(
fallback_relay[1], command="PRIVMSG", params=["#test", "everyone?"]
)
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)

Some files were not shown because too many files have changed in this diff Show More