├── .github ├── deploy_to_netlify.py └── workflows │ ├── lint.yml │ ├── test-devel.yml │ ├── test-devel_release.yml │ └── test-stable.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── conftest.py ├── data ├── nefarious │ └── ircd.pem └── unreal │ ├── README │ ├── config.settings │ ├── server.cert.pem │ ├── server.key.pem │ └── server.req.pem ├── irctest ├── __init__.py ├── authentication.py ├── basecontrollers.py ├── cases.py ├── client_mock.py ├── client_tests │ ├── __init__.py │ ├── cap.py │ ├── sasl.py │ └── tls.py ├── controllers │ ├── __init__.py │ ├── anope_services.py │ ├── atheme_services.py │ ├── bahamut.py │ ├── base_hybrid.py │ ├── charybdis.py │ ├── dlk_services.py │ ├── ergo.py │ ├── external_server.py │ ├── girc.py │ ├── hybrid.py │ ├── inspircd.py │ ├── irc2.py │ ├── ircu2.py │ ├── limnoria.py │ ├── mammon.py │ ├── nefarious.py │ ├── ngircd.py │ ├── plexus4.py │ ├── sable.py │ ├── snircd.py │ ├── solanum.py │ ├── sopel.py │ ├── thelounge.py │ └── unrealircd.py ├── dashboard │ ├── format.py │ ├── github_download.py │ ├── shortxml.py │ └── style.css ├── exceptions.py ├── irc_utils │ ├── __init__.py │ ├── capabilities.py │ ├── filelock.py │ ├── junkdrawer.py │ ├── message_parser.py │ └── sasl.py ├── numerics.py ├── patma.py ├── runner.py ├── scram │ ├── __init__.py │ ├── core.py │ ├── exceptions.py │ └── scram.py ├── self_tests │ └── cases.py ├── server_tests │ ├── __init__.py │ ├── account_registration.py │ ├── account_tag.py │ ├── away.py │ ├── away_notify.py │ ├── bot_mode.py │ ├── bouncer.py │ ├── buffering.py │ ├── cap.py │ ├── channel.py │ ├── channel_forward.py │ ├── channel_rename.py │ ├── chathistory.py │ ├── chmodes │ │ ├── __init__.py │ │ ├── auditorium.py │ │ ├── ban.py │ │ ├── ergo.py │ │ ├── key.py │ │ ├── modeis.py │ │ ├── moderated.py │ │ ├── mute_extban.py │ │ ├── no_ctcp.py │ │ ├── no_external.py │ │ ├── operator.py │ │ └── secret.py │ ├── confusables.py │ ├── connection_registration.py │ ├── echo_message.py │ ├── ergo │ │ ├── __init__.py │ │ └── services.py │ ├── extended_join.py │ ├── help.py │ ├── info.py │ ├── invite.py │ ├── isupport.py │ ├── join.py │ ├── kick.py │ ├── kill.py │ ├── labeled_responses.py │ ├── links.py │ ├── list.py │ ├── lusers.py │ ├── message_tags.py │ ├── messages.py │ ├── metadata.py │ ├── monitor.py │ ├── multi_prefix.py │ ├── multiline.py │ ├── names.py │ ├── part.py │ ├── pingpong.py │ ├── quit.py │ ├── readq.py │ ├── regressions.py │ ├── relaymsg.py │ ├── roleplay.py │ ├── sasl.py │ ├── setname.py │ ├── statusmsg.py │ ├── time.py │ ├── topic.py │ ├── umodes │ │ └── registeredonly.py │ ├── utf8.py │ ├── wallops.py │ ├── who.py │ ├── whois.py │ ├── whowas.py │ └── znc_playback.py ├── specifications.py └── tls.py ├── make_workflows.py ├── mypy.ini ├── patches ├── bahamut_localhost.patch ├── bahamut_mainloop.patch ├── bahamut_ubuntu22.patch ├── charybdis_ubuntu22.patch └── ngircd_whowas_delay.patch ├── pyproject.toml ├── pytest.ini ├── report.py ├── requirements.txt ├── setup.cfg └── workflows.yml /.github/deploy_to_netlify.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import json 4 | import os 5 | import pprint 6 | import re 7 | import subprocess 8 | import sys 9 | import urllib.request 10 | 11 | event_name = os.environ["GITHUB_EVENT_NAME"] 12 | 13 | is_pull_request = is_push = False 14 | if event_name.startswith("pull_request"): 15 | is_pull_request = True 16 | elif event_name.startswith("push"): 17 | is_push = True 18 | elif event_name.startswith("schedule"): 19 | # Don't publish; scheduled workflows run against the latest commit of every 20 | # implementation, so they are likely to have failed tests for the wrong reasons 21 | sys.exit(0) 22 | else: 23 | print("Unexpected event name:", event_name) 24 | 25 | with open(os.environ["GITHUB_EVENT_PATH"]) as fd: 26 | github_event = json.load(fd) 27 | 28 | pprint.pprint(github_event) 29 | 30 | context_suffix = "" 31 | 32 | command = ["netlify", "deploy", "--dir=dashboard/"] 33 | if is_pull_request: 34 | pr_number = github_event["number"] 35 | sha = github_event.get("after") or github_event["pull_request"]["head"]["sha"] 36 | # Aliases can't exceed 37 chars 37 | command.extend(["--alias", f"pr-{pr_number}-{sha[0:10]}"]) 38 | context_suffix = " (pull_request)" 39 | elif is_push: 40 | ref = github_event["ref"] 41 | m = re.match("refs/heads/(.*)", ref) 42 | if m: 43 | branch = m.group(1) 44 | sha = github_event["head_commit"]["id"] 45 | 46 | if branch in ("main", "master"): 47 | command.extend(["--prod"]) 48 | else: 49 | command.extend(["--alias", f"br-{branch[0:23]}-{sha[0:10]}"]) 50 | context_suffix = " (push)" 51 | else: 52 | # TODO 53 | pass 54 | 55 | 56 | print("Running", command) 57 | proc = subprocess.run(command, capture_output=True) 58 | 59 | output = proc.stdout.decode() 60 | assert proc.returncode == 0, (output, proc.stderr.decode()) 61 | print(output) 62 | 63 | m = re.search("https://[^ ]*--[^ ]*netlify.app", output) 64 | assert m 65 | netlify_site_url = m.group(0) 66 | target_url = f"{netlify_site_url}/index.xhtml" 67 | 68 | print("Published to", netlify_site_url) 69 | 70 | 71 | def send_status() -> None: 72 | statuses_url = github_event["repository"]["statuses_url"].format(sha=sha) 73 | 74 | payload = { 75 | "state": "success", 76 | "context": f"Dashboard{context_suffix}", 77 | "description": "Table of all test results", 78 | "target_url": target_url, 79 | } 80 | request = urllib.request.Request( 81 | statuses_url, 82 | data=json.dumps(payload).encode(), 83 | headers={ 84 | "Authorization": f'token {os.environ["GITHUB_TOKEN"]}', 85 | "Content-Type": "text/json", 86 | "Accept": "application/vnd.github+json", 87 | }, 88 | ) 89 | 90 | response = urllib.request.urlopen(request) 91 | 92 | assert response.status == 201, response.read() 93 | 94 | 95 | send_status() 96 | 97 | 98 | def send_pr_comment() -> None: 99 | comments_url = github_event["pull_request"]["_links"]["comments"]["href"] 100 | 101 | payload = { 102 | "body": f"[Test results]({target_url})", 103 | } 104 | request = urllib.request.Request( 105 | comments_url, 106 | data=json.dumps(payload).encode(), 107 | headers={ 108 | "Authorization": f'token {os.environ["GITHUB_TOKEN"]}', 109 | "Content-Type": "text/json", 110 | "Accept": "application/vnd.github+json", 111 | }, 112 | ) 113 | 114 | response = urllib.request.urlopen(request) 115 | 116 | assert response.status == 201, response.read() 117 | 118 | 119 | if is_pull_request: 120 | send_pr_comment() 121 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | push: 5 | pull_request: 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Python 3.11 17 | uses: actions/setup-python@v2 18 | with: 19 | python-version: 3.11 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v4 23 | with: 24 | path: | 25 | ~/.cache 26 | key: ${{ runner.os }}-lint 27 | 28 | - name: Install dependencies 29 | run: | 30 | python -m pip install --upgrade pip 31 | python -m pip install pre-commit pytest 32 | pip install -r requirements.txt 33 | 34 | - name: Lint 35 | run: | 36 | pre-commit run -a 37 | 38 | - name: Check generated workflows are in sync 39 | run: | 40 | python make_workflows.py 41 | git diff --exit-code 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/windows,osx,linux,python 3 | 4 | ### Windows ### 5 | # Windows image file caches 6 | Thumbs.db 7 | ehthumbs.db 8 | 9 | # Folder config file 10 | Desktop.ini 11 | 12 | # Recycle Bin used on file shares 13 | $RECYCLE.BIN/ 14 | 15 | # Windows Installer files 16 | *.cab 17 | *.msi 18 | *.msm 19 | *.msp 20 | 21 | # Windows shortcuts 22 | *.lnk 23 | 24 | 25 | ### OSX ### 26 | *.DS_Store 27 | .AppleDouble 28 | .LSOverride 29 | 30 | # Icon must end with two \r 31 | Icon 32 | # Thumbnails 33 | ._* 34 | # Files that might appear in the root of a volume 35 | .DocumentRevisions-V100 36 | .fseventsd 37 | .Spotlight-V100 38 | .TemporaryItems 39 | .Trashes 40 | .VolumeIcon.icns 41 | .com.apple.timemachine.donotpresent 42 | # Directories potentially created on remote AFP share 43 | .AppleDB 44 | .AppleDesktop 45 | Network Trash Folder 46 | Temporary Items 47 | .apdisk 48 | 49 | 50 | ### Linux ### 51 | *~ 52 | 53 | # temporary files which can be created if a process still has a handle open of a deleted file 54 | .fuse_hidden* 55 | 56 | # KDE directory preferences 57 | .directory 58 | 59 | # Linux trash folder which might appear on any partition or disk 60 | .Trash-* 61 | 62 | # .nfs files are created when an open file is removed but is still being accessed 63 | .nfs* 64 | 65 | 66 | ### Python ### 67 | # Byte-compiled / optimized / DLL files 68 | __pycache__/ 69 | *.py[cod] 70 | *$py.class 71 | 72 | # C extensions 73 | *.so 74 | 75 | # Distribution / packaging 76 | .Python 77 | env/ 78 | build/ 79 | develop-eggs/ 80 | dist/ 81 | downloads/ 82 | eggs/ 83 | .eggs/ 84 | lib/ 85 | lib64/ 86 | parts/ 87 | sdist/ 88 | var/ 89 | *.egg-info/ 90 | .installed.cfg 91 | *.egg 92 | 93 | # PyInstaller 94 | # Usually these files are written by a python script from a template 95 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 96 | *.manifest 97 | *.spec 98 | 99 | # Installer logs 100 | pip-log.txt 101 | pip-delete-this-directory.txt 102 | 103 | # Unit test / coverage reports 104 | htmlcov/ 105 | .tox/ 106 | .coverage 107 | .coverage.* 108 | .cache 109 | nosetests.xml 110 | coverage.xml 111 | *,cover 112 | .hypothesis/ 113 | 114 | # Translations 115 | *.mo 116 | *.pot 117 | 118 | # Django stuff: 119 | *.log 120 | local_settings.py 121 | 122 | # Flask stuff: 123 | instance/ 124 | .webassets-cache 125 | 126 | # Scrapy stuff: 127 | .scrapy 128 | 129 | # Sphinx documentation 130 | docs/_build/ 131 | 132 | # PyBuilder 133 | target/ 134 | 135 | # Jupyter Notebook 136 | .ipynb_checkpoints 137 | 138 | # pyenv 139 | .python-version 140 | 141 | # celery beat schedule file 142 | celerybeat-schedule 143 | 144 | # dotenv 145 | .env 146 | 147 | # virtualenv 148 | .venv/ 149 | venv/ 150 | ENV/ 151 | 152 | # Spyder project settings 153 | .spyderproject 154 | 155 | # Rope project settings 156 | .ropeproject 157 | 158 | # vim swapfiles 159 | *.swp 160 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | exclude: ^irctest/scram 2 | 3 | repos: 4 | - repo: https://github.com/psf/black 5 | rev: 23.1.0 6 | hooks: 7 | - id: black 8 | language_version: python3 9 | 10 | - repo: https://github.com/PyCQA/isort 11 | rev: 5.11.5 12 | hooks: 13 | - id: isort 14 | 15 | - repo: https://github.com/PyCQA/flake8 16 | rev: 5.0.4 17 | hooks: 18 | - id: flake8 19 | 20 | - repo: https://github.com/pre-commit/mirrors-mypy 21 | rev: v1.0.1 22 | hooks: 23 | - id: mypy 24 | additional_dependencies: [types-PyYAML, types-docutils] 25 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Code style 4 | 5 | ### Syntax 6 | 7 | Any color you like as long as it's [Black](https://github.com/psf/black). 8 | In short: 9 | 10 | * 88 columns 11 | * double quotes 12 | * avoid backslashes at line breaks (use parentheses) 13 | * closing brackets/parentheses/... go on the same indent level as the line 14 | that opened them 15 | 16 | We also use `isort` to order imports (in short: just 17 | [follow PEP 8](https://www.python.org/dev/peps/pep-0008/#imports)) 18 | 19 | You can use [pre-commit](https://pre-commit.com/) to automatically run them 20 | when you create a git commit. 21 | Alternatively, run `pre-commit run -a` 22 | 23 | 24 | ### Naming 25 | 26 | [Follow PEP 8](https://www.python.org/dev/peps/pep-0008/#naming-conventions), 27 | with these exceptions: 28 | 29 | * assertion methods (eg. `assertMessageMatch` are mixedCase to be consistent 30 | with the unittest module) 31 | * other methods defined in `cases.py` are also mixedCase for consistency with 32 | the former, for now 33 | * test names are also mixedCase for the same reason 34 | 35 | Additionally: 36 | 37 | * test module names should be snake\_case and match the name of the 38 | specification they are testing (if IRCv3), or the feature they are 39 | testing (if RFC or just common usage) 40 | 41 | 42 | ## What to test 43 | 44 | **All tests should have a docstring** pointing to a reference document 45 | (eg. RFC, IRCv3 specification, or modern.ircdocs.horse). 46 | If there is no reference, documentation can do. 47 | 48 | If the behavior being tested is not documented, then **please document it 49 | outside** this repository (eg. at modern.ircdocs.horse), 50 | and/or get it specified through IRCv3. 51 | 52 | "That's just how everyone does it" is not good justification. 53 | Linking to an external document saying "Here is how everyone does it" is. 54 | 55 | If reference documents / documentations are long or not trivial, 56 | **try to quote the specific part being tested**. 57 | See `irctest/server_tests/kick.py` for example. 58 | 59 | Tests for **pending/draft specifications are welcome**. 60 | 61 | Note that irctest also welcomes implementation-specific tests for 62 | functional testing; for now only Ergo. 63 | This does not relax the requirement on documentating tests. 64 | 65 | 66 | ## Writing tests 67 | 68 | **Use unittest-style assertions** (`self.assertEqual(x, y)` instead of 69 | pytest-style (`assert x == y`). This allows consistency with the assertion 70 | methods we define, such as `assertMessageMatch`. 71 | 72 | Always **add an error message in assertions**. 73 | `irctest` should show readable errors to people unfamiliar with the 74 | codebase. 75 | Ideally, explain what happened and what should have happened instead. 76 | 77 | All tests should be tagged with 78 | `@cases.mark_specifications`. 79 | 80 | 81 | ## Continuous integration 82 | 83 | We run automated tests on all commits and pull requests, to check that tests 84 | accept existing implementations. 85 | Scripts to run the tests are defined in `workflows.yml`, and the 86 | `make_workflows.py` script reads this configuration to generate files 87 | in `.github/workflows/` that are used by the CI. 88 | 89 | If an implementation cannot pass a test, that test should be excluded via 90 | a definition in the Makefile. 91 | If it is a bug, please open a bug report to the affected software if possible, 92 | and link to the bug report in a comment. 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Valentin Lorentz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /conftest.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | import _pytest.unittest 4 | import pytest 5 | 6 | # Must be called before importing irctest.cases. 7 | pytest.register_assert_rewrite("irctest.cases") 8 | 9 | from irctest.basecontrollers import ( # noqa: E402 10 | BaseClientController, 11 | BaseServerController, 12 | ) 13 | from irctest.cases import ( # noqa: E402 14 | BaseClientTestCase, 15 | BaseServerTestCase, 16 | _IrcTestCase, 17 | ) 18 | 19 | 20 | def pytest_addoption(parser): 21 | """Called by pytest, registers CLI options passed to the pytest command.""" 22 | parser.addoption( 23 | "--controller", help="Which module to use to run the tested software." 24 | ) 25 | parser.addoption( 26 | "--services-controller", help="Which module to use to run a services package." 27 | ) 28 | parser.addoption( 29 | "--openssl-bin", type=str, default="openssl", help="The openssl binary to use" 30 | ) 31 | 32 | 33 | def pytest_configure(config): 34 | """Called by pytest, after it parsed the command-line.""" 35 | module_name = config.getoption("controller") 36 | services_module_name = config.getoption("services_controller") 37 | 38 | if module_name is None: 39 | print("Missing --controller option, errors may occur.") 40 | _IrcTestCase.controllerClass = None 41 | _IrcTestCase.show_io = True # TODO 42 | return 43 | 44 | try: 45 | module = importlib.import_module(module_name) 46 | except ImportError: 47 | pytest.exit("Cannot import module {}".format(module_name), 1) 48 | 49 | controller_class = module.get_irctest_controller_class() 50 | if issubclass(controller_class, BaseClientController): 51 | from irctest import client_tests as module 52 | 53 | if services_module_name is not None: 54 | pytest.exit("You may not use --services-controller for client tests.") 55 | elif issubclass(controller_class, BaseServerController): 56 | from irctest import server_tests as module 57 | else: 58 | pytest.exit( 59 | r"{}.Controller should be a subclass of " 60 | r"irctest.basecontroller.Base{{Client,Server}}Controller".format( 61 | module_name 62 | ), 63 | 1, 64 | ) 65 | 66 | if services_module_name is not None: 67 | try: 68 | services_module = importlib.import_module(services_module_name) 69 | except ImportError: 70 | pytest.exit("Cannot import module {}".format(services_module_name), 1) 71 | controller_class.services_controller_class = ( 72 | services_module.get_irctest_controller_class() 73 | ) 74 | 75 | _IrcTestCase.controllerClass = controller_class 76 | _IrcTestCase.controllerClass.openssl_bin = config.getoption("openssl_bin") 77 | _IrcTestCase.show_io = True # TODO 78 | 79 | 80 | def pytest_collection_modifyitems(session, config, items): 81 | """Called by pytest after finishing the test collection, 82 | and before actually running the tests. 83 | 84 | This function filters out client tests if running with a server controller, 85 | and vice versa. 86 | """ 87 | 88 | # First, check if we should run server tests or client tests 89 | server_tests = client_tests = False 90 | if _IrcTestCase.controllerClass is None: 91 | pass 92 | elif issubclass(_IrcTestCase.controllerClass, BaseServerController): 93 | server_tests = True 94 | elif issubclass(_IrcTestCase.controllerClass, BaseClientController): 95 | client_tests = True 96 | else: 97 | assert False, ( 98 | f"{_IrcTestCase.controllerClass} inherits neither " 99 | f"BaseClientController or BaseServerController" 100 | ) 101 | 102 | filtered_items = [] 103 | 104 | # Iterate over each of the test functions (they are pytest "Nodes") 105 | for item in items: 106 | assert isinstance(item, _pytest.python.Function) 107 | 108 | # unittest-style test functions have the node of UnitTest class as parent 109 | if tuple(map(int, _pytest.__version__.split("."))) >= (7,): 110 | assert isinstance(item.parent, _pytest.python.Class) 111 | else: 112 | assert isinstance(item.parent, _pytest.python.Instance) 113 | 114 | # and that node references the UnitTest class 115 | assert issubclass(item.parent.cls, _IrcTestCase) 116 | 117 | # and in this project, TestCase classes all inherit either from 118 | # BaseClientController or BaseServerController. 119 | if issubclass(item.parent.cls, BaseServerTestCase): 120 | if server_tests: 121 | filtered_items.append(item) 122 | elif issubclass(item.parent.cls, BaseClientTestCase): 123 | if client_tests: 124 | filtered_items.append(item) 125 | else: 126 | filtered_items.append(item) 127 | 128 | # Finally, rewrite in-place the list of tests pytest will run 129 | items[:] = filtered_items 130 | -------------------------------------------------------------------------------- /data/nefarious/ircd.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQDT0URxi7/l7ZGe 3 | tkPv9Yh8h2s9BpbAR4Wq8sakgqETWg/nE/JQM5dPxroVbtZWWQXuJEFsgBKbASLa 4 | /eg5cyJv4Uu5WIZpG1LxdPEEIOSMWjzoAGwoLxbTRGrS7qNXsknB9RwDuq8lPQiK 5 | kiAahg1Cn1vRrQ4cRrG+AkQWpRHJEDoLjCSo8IcAsKAZlw/eGtAcmeNvkr5AujEw 6 | XjIwx2FoDyKaNGRH5Z7gLWvCKBNxQuJuMTzh8guLqdGbE4hH3rqyICbW5DGPaOZL 7 | LErWuJ7kEhLZG2HDW5JaXOr0QfFYAA8pl9/qCuFMdoxRUKRcYBoxoMmz6dlsmipN 8 | 7vIj+TT6TemwcAT25pwMJIVS4WC4+BZilNH2eWKD9hZA8Kq7FDPu+1rxOJaLbE/b 9 | vpK8jZeRdqFzE1eBCgPkw8D8V0r7J18d+DsmgOe2kRycaia/t9M4rhqe0FXjX1X1 10 | lzQ52grxgc28Ejd1fGQXIJmdTh4BqKqTzxup0izS7dgFP1Ezm6Z4O+wklpL5uQF2 11 | Ex4X6QEj76iCxH+J/01/cvbxMe3iuGXECbO/y1FIrg7FKzZSrQo4aP63lS7Y7aq0 12 | t2t6kOS83ebhnpgHClgFs8/C3ayzYBBtbK63PYthwO8Rt6WamCIZFF5tA3XoI4Ak 13 | fZcWD18loZai+QznVzbLNINf++rTwwIDAQABAoICAQCs1tT3piZHU2yAyo9zLbJa 14 | kxGxcT/v1CzBSmtG8ATJyrKxRzhxszdj9HABbzjcqrXJFbKA+5yy+OFdOtSUlFtk 15 | Wb21lwPOnmo29sp4KPL1h+itEzMuMwZ4DBry1aFZvPSsnPpoHJwwUbY3hHdHzVzi 16 | oTCGTqT188Wzmxu+MqHppCEJLSj45ZPzvyxU1UwwW0a4H+ZTM7WlEYlzw1lHLlpQ 17 | VBFTLS8q77aNjOKiQptiz0X+zpS0dhJvu3l7BhwtMRS8prmqnffG4r0QWCsVPP8C 18 | cbEJkWtbwswQikN6Xpi1yw6UTQZ8brZa810aOSh07EJTfrU35rjxAndEspbJPd+4 19 | Zz6fKNnRA7A4fFap2hF4TSP/UF12b4ulQ8FfcMMTFszWko5M6fWFuCeWfNbyXML5 20 | fmn+NmSOboj7LkDqbpxtyaIVXXb2Y3F6A2fNllm/mxaGrRoEGNaH3o0qBgWRzJJB 21 | TDSvIQtJddzL+iMaqxz4ufXAREJernZmPa3vlkVGLINNQUC9JLrB5eFjLzPiZN2U 22 | 8RgQ9YX5tjoJ+DtPWuMFDiCS1ZE20/UBOEYTeqIVuKdK3AjJDMFSjg8fRvsWRqZe 23 | zsHv6tCtIFZFxYRxtrRGTUPQF+1QD6zBjYxZZk1B4n3uYBGVQFM/LnNHUxRnJBx1 24 | PunD4ICOY97xd2hcPmGiCQKCAQEA8NCXYaHzhv6fg95H/iMuJVcOCKrJ5rVr4poG 25 | SD0KZtS7SLzUYat8WcuoSubh5Eb2hHtrsnLrSOTnwQUO61f4gCRm2sEqHYsOAd7+ 26 | mNe1jfil0UBVqqL9GBcGYJkc5+DHgUlJQaxMV+4YLt8fD0KfZEnHaDAYX3kUdz+p 27 | be//YAKv+JmxWcUdBF60AUWPjbCJT/1pfJeY8nEBFiYtlYKKN24+4OiRdJ2yRGzt 28 | ZtNHaWy5EFF70yVgPX5MGQ7Z2JpejzK+lt+9nG4h1uJ4M2X4YrGVrRCn1W8jwqm/ 29 | bXest3E6wkkLoWDm9EaeYj00DUgMOviPyP4ckyxilG+Fny4JbwKCAQEA4SyUV03X 30 | KEoL5sOD69sLu3TpnIQz73u9an9W/f2f7rsGwmCcR9RdYGV29ltwfBvOm0FnPQio 31 | GliN+3PTWAL6bb8VYo2IR53VKpVHKSQUlzDOD9PGObXw1CT/+0zoMP7FBA4dTJDf 32 | xQ63AQNpSCGdwbxZygPWzLV5O1WxMeXhnQRL1EBvMyJ52od0+HbajDXg5mNiBKNQ 33 | AtVhB9pEu47Blu/KBqWjfh/GeBLPZB7MHmGNBYbBGGskKRLG4sIbwShs9cx8UM0/ 34 | 9dxXkX2d8hjnSD/0ZBh54HHUaEvKAKfpz1L8AC0ll7biCAy0CZK23AoZu/KT8zJ+ 35 | qvz3AuJcW3lo7QKCAQEAzfGltNJawPUamBzNttKBUV+s2c6tkkdO92C/xKGnNp/x 36 | dtg+TTTpyKV5zGy9fIsPoecnCFptS06vwAvCYZQ/Kd93stcFXHSiSwlY9H9tfffK 37 | XzTEzoRLLIHsa0omRUufcrqpEqf2NjChr9wS5OsWAx9xkHGpNmUHEqB4FlPsM0C5 38 | G0LdQCdplGYlTP0fMo5qL+VJhErle1kXE8kcrMMRzyvSTGe4lWGTph79vDUt2kQn 39 | 1IPLAJzzPEO5cqiXtzz1Z0N/aOn5b0FkYTAWmeY30LeMiJA46Df+/ihLVKPHKq6E 40 | EMmFT8LeYMPQCbXLwRv/kaMm3D4tU9PejpD9Vk95swKCAQAtULBlxXeIVyaAAVba 41 | L1H0Hroo0n41MtzSwt+568G05JSep5yr4/QKw0CmoY5Im7v/iLEDGmviKXIhaZTd 42 | wHOvhGYEWGFVsFDG6hXRFL7EEoFVtBPPZ2sY9n1BkJ+lxI/XmhORZhJycNypaotU 43 | hddets4HFrCyr86+/ybS2OWHmOa9x13Zl5WYQexrWFfxIaKqGtQOBOPEPjbxwp5U 44 | dI1HF+i7X7hAWJqzbW2pQ31mm9EqjIztoho73diCp/e37q/G46kdBcFadEZ3NCWG 45 | JDbfVmeTgU19usq5Vo9HhINMQvIOAwfuuVJRtmTBDHKaY7n8FfxqU/4j4RbA0Ncv 46 | XYadAoIBAQC7yh4/UZCGhklUhhk/667OfchWvWGriCSaYGmQPdMzxjnIjAvvIUe9 47 | riOTBSZXYYLmZHsmY/jK7KMGB3AsLTypSa9+ddAWqWn2dvOYyxNiAaSJK/RZfA9A 48 | ocVfvvkhOfNAYIF+A+fyJ2pznsDkBf9tPkhN7kovl+mr/e25qZb1d09377770Pi7 49 | thzEi+JLrRgYVLrCrPi2j4l7/Va/UaAPz+Dtu2GCT9vXgnhZtpb8R1kTViZFryTv 50 | k+qbNYJzVm61Vit9mVAGe+WuzhlclJnN6LIZGG3zYHIulRAJrH1XDauHZfHzCKgi 51 | FnMesy4thDMH/MhUfRtbylZTq45gtvCA 52 | -----END PRIVATE KEY----- 53 | -----BEGIN CERTIFICATE----- 54 | MIIFazCCA1OgAwIBAgIUYHD08+9S32VTD9IEsr2Oe1dH3VEwDQYJKoZIhvcNAQEL 55 | BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM 56 | GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjA0MDQxODE2NTZaFw0yMzA0 57 | MDQxODE2NTZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw 58 | HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB 59 | AQUAA4ICDwAwggIKAoICAQDT0URxi7/l7ZGetkPv9Yh8h2s9BpbAR4Wq8sakgqET 60 | Wg/nE/JQM5dPxroVbtZWWQXuJEFsgBKbASLa/eg5cyJv4Uu5WIZpG1LxdPEEIOSM 61 | WjzoAGwoLxbTRGrS7qNXsknB9RwDuq8lPQiKkiAahg1Cn1vRrQ4cRrG+AkQWpRHJ 62 | EDoLjCSo8IcAsKAZlw/eGtAcmeNvkr5AujEwXjIwx2FoDyKaNGRH5Z7gLWvCKBNx 63 | QuJuMTzh8guLqdGbE4hH3rqyICbW5DGPaOZLLErWuJ7kEhLZG2HDW5JaXOr0QfFY 64 | AA8pl9/qCuFMdoxRUKRcYBoxoMmz6dlsmipN7vIj+TT6TemwcAT25pwMJIVS4WC4 65 | +BZilNH2eWKD9hZA8Kq7FDPu+1rxOJaLbE/bvpK8jZeRdqFzE1eBCgPkw8D8V0r7 66 | J18d+DsmgOe2kRycaia/t9M4rhqe0FXjX1X1lzQ52grxgc28Ejd1fGQXIJmdTh4B 67 | qKqTzxup0izS7dgFP1Ezm6Z4O+wklpL5uQF2Ex4X6QEj76iCxH+J/01/cvbxMe3i 68 | uGXECbO/y1FIrg7FKzZSrQo4aP63lS7Y7aq0t2t6kOS83ebhnpgHClgFs8/C3ayz 69 | YBBtbK63PYthwO8Rt6WamCIZFF5tA3XoI4AkfZcWD18loZai+QznVzbLNINf++rT 70 | wwIDAQABo1MwUTAdBgNVHQ4EFgQU+9eHi2eqy0f3fDS0GjqkijGDDocwHwYDVR0j 71 | BBgwFoAU+9eHi2eqy0f3fDS0GjqkijGDDocwDwYDVR0TAQH/BAUwAwEB/zANBgkq 72 | hkiG9w0BAQsFAAOCAgEAAJXO3qUc/PW75pI2dt1cKv20VqozkfEf7P0eeVisCDxn 73 | 1p3QhVgI2lEe9kzdHp7t42g5xLkUhQEVmBcKm9xbl+k2D1X0+T8px1x6ZiWfbhXL 74 | ptc/qCIXjPCgVN3s+Kasii3hHkZxKGZz/ySmBmfDJZjQZtbZzQWpvvX6SD4s7sjo 75 | gWbZW3qvQ0bFTGdD1IjKYGaxK6aSrNkAIutiAX4RczJ1QSwb9Z2EIen+ABAvOZS9 76 | xv3LiiidWcuOT7WzXEa4QvOslCEkAF+jj6mGYB7NWtly0kj4AEPvI4IoYTi9dohS 77 | CA0zd1DTfjRwpAnT5P4sj4mpjLyRBumeeVGpCZhUxfKpFjIB2AnlgxrU+LPq5c9R 78 | ZZ9Q5oeLxjRPjpqBeWwgnbjXstQCL9g0U7SsEemsv+zmvG5COhAmG5Wce/65ILlg 79 | 450H4bcn1ul0xvxz9hat6tqEZry3HcNE/CGDT+tXuhHpqOXkY1/c78C0QbWjWodR 80 | tCvlXW00a+7TlEhNr4XBNdqtIQfYS9K9yiVVNfZLPEsN/SA3BGXmrr+du1/E4Ria 81 | CkVpmBdJsVu5eMaUj1arsCqI4fwHzljtojJe/pCzZBVkOaSWQEQ+LL4iVnMas68m 82 | qyshtNf4KNiM55OQmyTiFHMTIxCtdEcHaR3mUxR7GrIhc/bxyxUUBtMAuUX0Kjs= 83 | -----END CERTIFICATE----- 84 | -------------------------------------------------------------------------------- /data/unreal/README: -------------------------------------------------------------------------------- 1 | Boilerplate files so Unreal can be built non-interactively. 2 | 3 | Obviously, you shouldn't use the .pem in a production environment! 4 | -------------------------------------------------------------------------------- /data/unreal/config.settings: -------------------------------------------------------------------------------- 1 | BASEPATH="$HOME/.local/unrealircd" 2 | BINDIR="$HOME/.local/unrealircd/bin" 3 | DATADIR="$HOME/.local/unrealircd/data" 4 | CONFDIR="$HOME/.local/unrealircd/conf" 5 | MODULESDIR="$HOME/.local/unrealircd/modules" 6 | LOGDIR="$HOME/.local/unrealircd/logs" 7 | CACHEDIR="$HOME/.local/unrealircd/cache" 8 | DOCDIR="$HOME/.local/unrealircd/doc" 9 | TMPDIR="$HOME/.local/unrealircd/tmp" 10 | PRIVATELIBDIR="$HOME/.local/unrealircd/lib" 11 | PREFIXAQ="1" 12 | MAXCONNECTIONS_REQUEST="auto" 13 | NICKNAMEHISTORYLENGTH="2000" 14 | DEFPERM="0600" 15 | SSLDIR="" 16 | REMOTEINC="" 17 | CURLDIR="" 18 | SHOWLISTMODES="1" 19 | NOOPEROVERRIDE="" 20 | OPEROVERRIDEVERIFY="" 21 | GENCERTIFICATE="1" 22 | 23 | # Use system argon to avoid getting SIGILLed if the build machine has a more recent 24 | # CPU than the one running the tests. 25 | EXTRAPARA="--with-system-argon2" 26 | 27 | ADVANCED="" 28 | 29 | -------------------------------------------------------------------------------- /data/unreal/server.cert.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIICGDCCAZ6gAwIBAgIUeHAOQnvT7N9kCmUuIklelkzz8SUwCgYIKoZIzj0EAwIw 3 | QzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE5ldyBZb3JrMRIwEAYDVQQKDAlJUkMg 4 | Z2Vla3MxDTALBgNVBAsMBElSQ2QwHhcNMjEwNzAyMTk1MTM5WhcNMzEwNjMwMTk1 5 | MTM5WjBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQBgNVBAoM 6 | CUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuBBAAiA2IA 7 | BHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBCUbISsk/C 8 | Txru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTpEAqMSnrc 9 | zKNTMFEwHQYDVR0OBBYEFFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMB8GA1UdIwQYMBaA 10 | FFNHqsBNxDNhVxfAgdv6/y4Xd6/ZMA8GA1UdEwEB/wQFMAMBAf8wCgYIKoZIzj0E 11 | AwIDaAAwZQIwAo29xUEAzqOMgPAWtMifHFLuPQPuWcNGbaI5S4W81NO8uIcNv/kM 12 | mFocuITr76p0AjEApzGjc5wM+KydwoVTP+fg1aGQA13Ba2nCzN3R5XwR/USCigjv 13 | na1QtWAKjpvR/rsp 14 | -----END CERTIFICATE----- 15 | -------------------------------------------------------------------------------- /data/unreal/server.key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN EC PARAMETERS----- 2 | BgUrgQQAIg== 3 | -----END EC PARAMETERS----- 4 | -----BEGIN EC PRIVATE KEY----- 5 | MIGkAgEBBDCWkDHktJiTqC7im+Ni37fbXxtMBqIKPwkAItpKMeuh28QrXWwNE1a5 6 | wSa38C1nd8igBwYFK4EEACKhZANiAARwOoqi0IJEuNsR4P3RD6vXSo5S4tB1rwjO 7 | 5zglwMhctQ465mxaEgkAQlGyErJPwk8a7t2CH13uo0gkvFE29eZu9Jq5polK/7ps 8 | YqowDorKNOdYQq88VZo06RAKjEp63Mw= 9 | -----END EC PRIVATE KEY----- 10 | -------------------------------------------------------------------------------- /data/unreal/server.req.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIIBOjCBwgIBADBDMQswCQYDVQQGEwJVUzERMA8GA1UECAwITmV3IFlvcmsxEjAQ 3 | BgNVBAoMCUlSQyBnZWVrczENMAsGA1UECwwESVJDZDB2MBAGByqGSM49AgEGBSuB 4 | BAAiA2IABHA6iqLQgkS42xHg/dEPq9dKjlLi0HWvCM7nOCXAyFy1DjrmbFoSCQBC 5 | UbISsk/CTxru3YIfXe6jSCS8UTb15m70mrmmiUr/umxiqjAOiso051hCrzxVmjTp 6 | EAqMSnrczKAAMAoGCCqGSM49BAMCA2cAMGQCMEL5ezlauGUaxh+pXt897ffmzqci 7 | fqYm3FgVW5x6EdtCxtcwwAwnR84LKcd/YRKOygIwNmZiRVKeSeC7Ess1PxuzT1Mu 8 | Cw3bBqkE5LmO1hu/+0lK+QoFPEeLDrygIh+SDdGH 9 | -----END CERTIFICATE REQUEST----- 10 | -------------------------------------------------------------------------------- /irctest/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progval/irctest/4e4fd4f1b3e0a16d412a92d4a5f513bbeed64803/irctest/__init__.py -------------------------------------------------------------------------------- /irctest/authentication.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import enum 3 | from typing import Optional, Tuple 4 | 5 | 6 | @enum.unique 7 | class Mechanisms(enum.Enum): 8 | """Enumeration for representing possible mechanisms.""" 9 | 10 | def to_string(self) -> str: 11 | return self.name.upper().replace("_", "-") 12 | 13 | plain = 1 14 | ecdsa_nist256p_challenge = 2 15 | scram_sha_256 = 3 16 | 17 | 18 | @dataclasses.dataclass 19 | class Authentication: 20 | mechanisms: Tuple[Mechanisms] = (Mechanisms.plain,) 21 | username: Optional[str] = None 22 | password: Optional[str] = None 23 | ecdsa_key: Optional[str] = None 24 | -------------------------------------------------------------------------------- /irctest/client_tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | 5 | def discover(): 6 | ts = unittest.TestSuite() 7 | ts.addTests(unittest.defaultTestLoader.discover(os.path.dirname(__file__))) 8 | return ts 9 | -------------------------------------------------------------------------------- /irctest/client_tests/cap.py: -------------------------------------------------------------------------------- 1 | """Format of ``CAP LS`` sent by IRCv3 clients.""" 2 | 3 | from irctest import cases 4 | from irctest.irc_utils.message_parser import Message 5 | 6 | 7 | class CapTestCase(cases.BaseClientTestCase): 8 | @cases.mark_specifications("IRCv3") 9 | def testSendCap(self): 10 | """Send CAP LS 302 and read the result.""" 11 | self.readCapLs() 12 | 13 | @cases.mark_specifications("IRCv3") 14 | def testEmptyCapLs(self): 15 | """Empty result to CAP LS. Client should send CAP END.""" 16 | m = self.negotiateCapabilities([]) 17 | self.assertEqual(m, Message({}, None, "CAP", ["END"])) 18 | -------------------------------------------------------------------------------- /irctest/controllers/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progval/irctest/4e4fd4f1b3e0a16d412a92d4a5f513bbeed64803/irctest/controllers/__init__.py -------------------------------------------------------------------------------- /irctest/controllers/anope_services.py: -------------------------------------------------------------------------------- 1 | import functools 2 | from pathlib import Path 3 | import shutil 4 | import subprocess 5 | from typing import Tuple, Type 6 | 7 | from irctest.basecontrollers import BaseServicesController, DirectoryBasedController 8 | 9 | TEMPLATE_CONFIG = """ 10 | serverinfo {{ 11 | name = "My.Little.Services" 12 | description = "Anope IRC Services" 13 | numeric = "00A" 14 | pid = "services.pid" 15 | motd = "conf/empty_file" 16 | }} 17 | 18 | uplink {{ 19 | host = "{server_hostname}" 20 | port = {server_port} 21 | password = "password" 22 | }} 23 | 24 | module {{ 25 | name = "{protocol}" 26 | }} 27 | 28 | networkinfo {{ 29 | networkname = "testnet" 30 | nicklen = 31 31 | userlen = 10 32 | hostlen = 64 33 | chanlen = 32 34 | }} 35 | 36 | mail {{ 37 | usemail = no 38 | }} 39 | 40 | service {{ 41 | nick = "NickServ" 42 | user = "services" 43 | host = "services.host" 44 | gecos = "Nickname Registration Service" 45 | }} 46 | 47 | module {{ 48 | name = "nickserv" 49 | client = "NickServ" 50 | forceemail = no 51 | passlen = 1000 # Some tests need long passwords 52 | maxpasslen = 1000 53 | minpasslen = 1 54 | }} 55 | command {{ service = "NickServ"; name = "HELP"; command = "generic/help"; }} 56 | 57 | module {{ 58 | name = "ns_register" 59 | registration = "none" 60 | }} 61 | command {{ service = "NickServ"; name = "REGISTER"; command = "nickserv/register"; }} 62 | 63 | options {{ 64 | casemap = "ascii" 65 | readtimeout = 5s 66 | warningtimeout = 4h 67 | }} 68 | 69 | module {{ name = "ns_sasl" }} # since 2.1.13 70 | module {{ name = "sasl" }} # 2.1.2 to 2.1.12 71 | module {{ name = "m_sasl" }} # 2.0 to 2.1.1 72 | 73 | module {{ name = "enc_sha2" }} # 2.1 74 | module {{ name = "enc_sha256" }} # 2.0 75 | 76 | module {{ name = "ns_cert" }} 77 | 78 | """ 79 | 80 | 81 | @functools.lru_cache() 82 | def installed_version() -> Tuple[int, ...]: 83 | output = subprocess.run( 84 | ["anope", "--version"], stdout=subprocess.PIPE, universal_newlines=True 85 | ).stdout 86 | (anope, version, *trailing) = output.split()[0].split("-") 87 | assert anope == "Anope" 88 | return tuple(map(int, version.split("."))) 89 | 90 | 91 | class AnopeController(BaseServicesController, DirectoryBasedController): 92 | """Collaborator for server controllers that rely on Anope""" 93 | 94 | software_name = "Anope" 95 | software_version = None 96 | 97 | def run(self, protocol: str, server_hostname: str, server_port: int) -> None: 98 | self.create_config() 99 | 100 | assert protocol in ( 101 | "bahamut", 102 | "inspircd3", 103 | "charybdis", 104 | "hybrid", 105 | "plexus", 106 | "unreal4", 107 | "ngircd", 108 | ) 109 | 110 | assert self.directory 111 | services_path = shutil.which("anope") 112 | assert services_path 113 | 114 | # Rewrite Anope 2.0 module names for 2.1 115 | if not self.software_version: 116 | self.software_version = installed_version() 117 | if self.software_version >= (2, 1, 0): 118 | if protocol == "charybdis": 119 | protocol = "solanum" 120 | elif protocol == "inspircd3": 121 | protocol = "inspircd" 122 | elif protocol == "unreal4": 123 | protocol = "unrealircd" 124 | 125 | with self.open_file("conf/services.conf") as fd: 126 | fd.write( 127 | TEMPLATE_CONFIG.format( 128 | protocol=protocol, 129 | server_hostname=server_hostname, 130 | server_port=server_port, 131 | ) 132 | ) 133 | 134 | with self.open_file("conf/empty_file") as fd: 135 | pass 136 | 137 | # Config and code need to be in the same directory, *obviously* 138 | (self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib") 139 | (self.directory / "modules").symlink_to( 140 | Path(services_path).parent.parent / "modules" 141 | ) 142 | 143 | extra_args = [] 144 | if self.debug_mode: 145 | extra_args.append("--debug") 146 | 147 | self.proc = self.execute( 148 | [ 149 | "anope", 150 | "--config=services.conf", # can't be an absolute path in 2.0 151 | "--nofork", # don't fork 152 | "--nopid", # don't write a pid 153 | *extra_args, 154 | ], 155 | cwd=self.directory, 156 | ) 157 | 158 | 159 | def get_irctest_controller_class() -> Type[AnopeController]: 160 | return AnopeController 161 | -------------------------------------------------------------------------------- /irctest/controllers/atheme_services.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | import irctest 4 | from irctest.basecontrollers import BaseServicesController, DirectoryBasedController 5 | import irctest.cases 6 | import irctest.runner 7 | 8 | TEMPLATE_CONFIG = """ 9 | loadmodule "modules/protocol/{protocol}"; 10 | loadmodule "modules/backend/opensex"; 11 | loadmodule "modules/crypto/pbkdf2"; 12 | 13 | loadmodule "modules/nickserv/main"; 14 | loadmodule "modules/nickserv/cert"; 15 | loadmodule "modules/nickserv/register"; 16 | loadmodule "modules/nickserv/verify"; 17 | 18 | loadmodule "modules/saslserv/main"; 19 | loadmodule "modules/saslserv/authcookie"; 20 | #loadmodule "modules/saslserv/ecdh-x25519-challenge"; 21 | loadmodule "modules/saslserv/ecdsa-nist256p-challenge"; 22 | loadmodule "modules/saslserv/external"; 23 | loadmodule "modules/saslserv/plain"; 24 | #loadmodule "modules/saslserv/scram"; 25 | 26 | serverinfo {{ 27 | name = "My.Little.Services"; 28 | desc = "Atheme IRC Services"; 29 | numeric = "00A"; 30 | netname = "testnet"; 31 | adminname = "no admin"; 32 | adminemail = "no-admin@example.org"; 33 | registeremail = "registration@example.org"; 34 | auth = none; // Disable email check 35 | }}; 36 | 37 | general {{ 38 | commit_interval = 5; 39 | }}; 40 | 41 | uplink "My.Little.Server" {{ 42 | host = "{server_hostname}"; 43 | port = {server_port}; 44 | send_password = "password"; 45 | receive_password = "password"; 46 | }}; 47 | 48 | saslserv {{ 49 | nick = "SaslServ"; 50 | }}; 51 | """ 52 | 53 | 54 | class AthemeController(BaseServicesController, DirectoryBasedController): 55 | """Mixin for server controllers that rely on Atheme""" 56 | 57 | software_name = "Atheme" 58 | 59 | def run(self, protocol: str, server_hostname: str, server_port: int) -> None: 60 | self.create_config() 61 | 62 | if protocol == "inspircd3": 63 | # That's the name used by Anope 64 | protocol = "inspircd" 65 | assert protocol in ("bahamut", "inspircd", "charybdis", "unreal4", "ngircd") 66 | 67 | with self.open_file("services.conf") as fd: 68 | fd.write( 69 | TEMPLATE_CONFIG.format( 70 | protocol=protocol, 71 | server_hostname=server_hostname, 72 | server_port=server_port, 73 | ) 74 | ) 75 | 76 | assert self.directory 77 | self.proc = self.execute( 78 | [ 79 | "atheme-services", 80 | "-n", # don't fork 81 | "-c", 82 | self.directory / "services.conf", 83 | "-l", 84 | f"/tmp/services-{server_port}.log", 85 | "-p", 86 | self.directory / "services.pid", 87 | "-D", 88 | self.directory, 89 | ], 90 | ) 91 | 92 | def registerUser( 93 | self, 94 | case: irctest.cases.BaseServerTestCase, 95 | username: str, 96 | password: Optional[str] = None, 97 | ) -> None: 98 | assert password 99 | if len(password.encode()) > 288: 100 | # It's hardcoded at compile-time :( 101 | # https://github.com/atheme/atheme/blob/4fa0e03bd3ce2cb6041a339f308616580c5aac29/include/atheme/constants.h#L51 102 | raise irctest.runner.NotImplementedByController("Passwords over 288 bytes") 103 | 104 | super().registerUser(case, username, password) 105 | 106 | 107 | def get_irctest_controller_class() -> Type[AthemeController]: 108 | return AthemeController 109 | -------------------------------------------------------------------------------- /irctest/controllers/bahamut.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import shutil 3 | from typing import Optional, Set, Type 4 | 5 | from irctest.basecontrollers import BaseServerController, DirectoryBasedController 6 | 7 | TEMPLATE_CONFIG = """ 8 | global {{ 9 | name My.Little.Server; # IRC name of the server 10 | info "located on earth"; # A short info line 11 | }}; 12 | 13 | options {{ 14 | network_name unconfigured; 15 | allow_split_ops; # Give ops in empty channels 16 | 17 | services_name My.Little.Services; 18 | 19 | // if you need to link more than 1 server, uncomment the following line 20 | servtype hub; 21 | }}; 22 | 23 | /* where to listen for connections */ 24 | port {{ 25 | port {port}; 26 | bind {hostname}; 27 | }}; 28 | 29 | /* allow clients to connect */ 30 | allow {{ 31 | host *@*; # Allow anyone 32 | class users; # Place them in the users class 33 | flags T; # No throttling 34 | {password_field} 35 | }}; 36 | 37 | /* connection class for users */ 38 | class {{ 39 | name users; # Class name 40 | maxusers 100; # Maximum connections 41 | pingfreq 1000; # Check idle connections every N seconds 42 | maxsendq 100000; # 100KB send buffer limit 43 | }}; 44 | 45 | /* for services */ 46 | super {{ 47 | "My.Little.Services"; 48 | }}; 49 | 50 | 51 | /* class for services */ 52 | class {{ 53 | name services; 54 | pingfreq 60; # Idle check every minute 55 | maxsendq 5000000; # 5MB backlog buffer 56 | }}; 57 | 58 | /* our services */ 59 | connect {{ 60 | name My.Little.Services; 61 | host *@127.0.0.1; # unfortunately, masks aren't allowed here 62 | apasswd password; 63 | cpasswd password; 64 | class services; 65 | }}; 66 | 67 | oper {{ 68 | name operuser; 69 | host *@*; 70 | passwd operpassword; 71 | access *Aa; 72 | class users; 73 | }}; 74 | """ 75 | 76 | 77 | def initialize_entropy(directory: Path) -> None: 78 | # https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/include/dh.h#L35-L38 79 | nb_rand_bytes = 512 // 8 80 | # https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/src/dh.c#L186 81 | entropy_file_size = nb_rand_bytes * 4 82 | 83 | # Not actually random; but we don't care. 84 | entropy = b"\x00" * entropy_file_size 85 | 86 | with (directory / ".ircd.entropy").open("wb") as fd: 87 | fd.write(entropy) 88 | 89 | 90 | class BahamutController(BaseServerController, DirectoryBasedController): 91 | software_name = "Bahamut" 92 | supported_sasl_mechanisms: Set[str] = set() 93 | supports_sts = False 94 | nickserv = "NickServ@My.Little.Services" 95 | 96 | def create_config(self) -> None: 97 | super().create_config() 98 | with self.open_file("server.conf"): 99 | pass 100 | 101 | def run( 102 | self, 103 | hostname: str, 104 | port: int, 105 | *, 106 | password: Optional[str], 107 | ssl: bool, 108 | run_services: bool, 109 | faketime: Optional[str], 110 | ) -> None: 111 | assert self.proc is None 112 | self.port = port 113 | self.hostname = hostname 114 | self.create_config() 115 | (unused_hostname, unused_port) = self.get_hostname_and_port() 116 | (services_hostname, services_port) = self.get_hostname_and_port() 117 | 118 | password_field = "passwd {};".format(password) if password else "" 119 | 120 | self.gen_ssl() 121 | 122 | assert self.directory 123 | 124 | # Bahamut reads some bytes from /dev/urandom on startup, which causes 125 | # GitHub Actions to sometimes freeze and timeout. 126 | # This initializes the entropy file so Bahamut does not need to do it itself. 127 | initialize_entropy(self.directory) 128 | 129 | # they are hardcoded... thankfully Bahamut reads them from the CWD. 130 | shutil.copy(self.pem_path, self.directory / "ircd.crt") 131 | shutil.copy(self.key_path, self.directory / "ircd.key") 132 | 133 | with self.open_file("server.conf") as fd: 134 | fd.write( 135 | TEMPLATE_CONFIG.format( 136 | hostname=hostname, 137 | port=port, 138 | services_hostname=services_hostname, 139 | services_port=services_port, 140 | password_field=password_field, 141 | # key_path=self.key_path, 142 | # pem_path=self.pem_path, 143 | ) 144 | ) 145 | 146 | if faketime and shutil.which("faketime"): 147 | faketime_cmd = ["faketime", "-f", faketime] 148 | self.faketime_enabled = True 149 | else: 150 | faketime_cmd = [] 151 | 152 | self.proc = self.execute( 153 | [ 154 | *faketime_cmd, 155 | "ircd", 156 | "-t", # don't fork 157 | "-f", 158 | self.directory / "server.conf", 159 | ], 160 | ) 161 | 162 | if run_services: 163 | self.wait_for_port() 164 | self.services_controller = self.services_controller_class( 165 | self.test_config, self 166 | ) 167 | self.services_controller.run( 168 | protocol="bahamut", 169 | server_hostname=hostname, 170 | server_port=port, 171 | ) 172 | 173 | 174 | def get_irctest_controller_class() -> Type[BahamutController]: 175 | return BahamutController 176 | -------------------------------------------------------------------------------- /irctest/controllers/base_hybrid.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import shutil 3 | from typing import Optional 4 | 5 | from irctest.basecontrollers import BaseServerController, DirectoryBasedController 6 | 7 | TEMPLATE_SSL_CONFIG = """ 8 | ssl_private_key = "{key_path}"; 9 | ssl_cert = "{pem_path}"; 10 | ssl_dh_params = "{dh_path}"; 11 | """ 12 | 13 | 14 | class BaseHybridController(BaseServerController, DirectoryBasedController): 15 | """A base class for all controllers derived from ircd-hybrid (Hybrid itself, 16 | Charybdis, Solanum, ...)""" 17 | 18 | binary_name: str 19 | services_protocol: str 20 | 21 | supports_sts = False 22 | extban_mute_char = None 23 | 24 | template_config: str 25 | 26 | def create_config(self) -> None: 27 | super().create_config() 28 | with self.open_file("server.conf"): 29 | pass 30 | 31 | def run( 32 | self, 33 | hostname: str, 34 | port: int, 35 | *, 36 | password: Optional[str], 37 | ssl: bool, 38 | run_services: bool, 39 | faketime: Optional[str], 40 | ) -> None: 41 | assert self.proc is None 42 | self.port = port 43 | self.hostname = hostname 44 | self.create_config() 45 | (services_hostname, services_port) = self.get_hostname_and_port() 46 | password_field = 'password = "{}";'.format(password) if password else "" 47 | if ssl: 48 | self.gen_ssl() 49 | ssl_config = TEMPLATE_SSL_CONFIG.format( 50 | key_path=self.key_path, pem_path=self.pem_path, dh_path=self.dh_path 51 | ) 52 | else: 53 | ssl_config = "" 54 | binary_path = shutil.which(self.binary_name) 55 | assert binary_path, f"Could not find '{binary_path}' executable" 56 | with self.open_file("server.conf") as fd: 57 | fd.write( 58 | (self.template_config).format( 59 | hostname=hostname, 60 | port=port, 61 | services_hostname=services_hostname, 62 | services_port=services_port, 63 | password_field=password_field, 64 | ssl_config=ssl_config, 65 | install_prefix=Path(binary_path).parent.parent, 66 | ) 67 | ) 68 | assert self.directory 69 | 70 | if faketime and shutil.which("faketime"): 71 | faketime_cmd = ["faketime", "-f", faketime] 72 | self.faketime_enabled = True 73 | else: 74 | faketime_cmd = [] 75 | 76 | self.proc = self.execute( 77 | [ 78 | *faketime_cmd, 79 | self.binary_name, 80 | "-foreground", 81 | "-configfile", 82 | self.directory / "server.conf", 83 | "-pidfile", 84 | self.directory / "server.pid", 85 | ], 86 | ) 87 | 88 | if run_services: 89 | self.wait_for_port() 90 | self.services_controller = self.services_controller_class( 91 | self.test_config, self 92 | ) 93 | self.services_controller.run( 94 | protocol=self.services_protocol, 95 | server_hostname=hostname, 96 | server_port=services_port, 97 | ) 98 | -------------------------------------------------------------------------------- /irctest/controllers/charybdis.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from .base_hybrid import BaseHybridController 4 | 5 | TEMPLATE_CONFIG = """ 6 | serverinfo {{ 7 | name = "My.Little.Server"; 8 | sid = "42X"; 9 | description = "test server"; 10 | {ssl_config} 11 | }}; 12 | 13 | general {{ 14 | throttle_count = 100; # We need to connect lots of clients quickly 15 | # disable throttling for LIST and similar: 16 | pace_wait_simple = 0 second; 17 | pace_wait = 0 second; 18 | sasl_service = "SaslServ"; 19 | }}; 20 | 21 | class "server" {{ 22 | ping_time = 5 minutes; 23 | connectfreq = 5 minutes; 24 | }}; 25 | 26 | listen {{ 27 | defer_accept = yes; 28 | 29 | host = "{hostname}"; 30 | port = {port}; 31 | port = {services_port}; 32 | }}; 33 | 34 | auth {{ 35 | user = "*"; 36 | flags = exceed_limit; 37 | {password_field} 38 | }}; 39 | 40 | channel {{ 41 | disable_local_channels = no; 42 | no_create_on_split = no; 43 | no_join_on_split = no; 44 | displayed_usercount = 0; 45 | }}; 46 | 47 | connect "My.Little.Services" {{ 48 | host = "localhost"; # Used to validate incoming connection 49 | port = 0; # We don't need servers to connect to services 50 | send_password = "password"; 51 | accept_password = "password"; 52 | class = "server"; 53 | flags = topicburst; 54 | }}; 55 | service {{ 56 | name = "My.Little.Services"; 57 | }}; 58 | 59 | privset "omnioper" {{ 60 | privs = oper:general, oper:privs, oper:testline, oper:kill, oper:operwall, oper:message, 61 | oper:routing, oper:kline, oper:unkline, oper:xline, 62 | oper:resv, oper:cmodes, oper:mass_notice, oper:wallops, 63 | oper:remoteban, oper:local_kill, 64 | usermode:servnotice, auspex:oper, auspex:hostname, auspex:umodes, auspex:cmodes, 65 | oper:admin, oper:die, oper:rehash, oper:spy, oper:grant; 66 | }}; 67 | operator "operuser" {{ 68 | user = "*@*"; 69 | password = "operpassword"; 70 | privset = "omnioper"; 71 | flags = ~encrypted; 72 | }}; 73 | """ 74 | 75 | 76 | class CharybdisController(BaseHybridController): 77 | software_name = "Charybdis" 78 | binary_name = "charybdis" 79 | services_protocol = "charybdis" 80 | 81 | supported_sasl_mechanisms = {"PLAIN"} 82 | 83 | template_config = TEMPLATE_CONFIG 84 | 85 | 86 | def get_irctest_controller_class() -> Type[CharybdisController]: 87 | return CharybdisController 88 | -------------------------------------------------------------------------------- /irctest/controllers/external_server.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import Optional, Tuple, Type 3 | 4 | from irctest.basecontrollers import BaseServerController 5 | 6 | 7 | class ExternalServerController(BaseServerController): 8 | """Dummy controller that doesn't run a server. 9 | Instead, it allows connecting to servers ran outside irctest.""" 10 | 11 | software_name = "unknown external server" 12 | supported_sasl_mechanisms = set( 13 | os.environ.get("IRCTEST_SERVER_SASL_MECHS", "").split() 14 | ) 15 | 16 | def check_is_alive(self) -> None: 17 | pass 18 | 19 | def kill_proc(self) -> None: 20 | pass 21 | 22 | def wait_for_port(self) -> None: 23 | pass 24 | 25 | def get_hostname_and_port(self) -> Tuple[str, int]: 26 | hostname = os.environ.get("IRCTEST_SERVER_HOSTNAME") 27 | port = os.environ.get("IRCTEST_SERVER_PORT") 28 | if not hostname or not port: 29 | raise RuntimeError( 30 | "Please set IRCTEST_SERVER_HOSTNAME and IRCTEST_SERVER_PORT." 31 | ) 32 | return (hostname, int(port)) 33 | 34 | def run( 35 | self, 36 | hostname: str, 37 | port: int, 38 | *, 39 | password: Optional[str], 40 | ssl: bool, 41 | run_services: bool, 42 | faketime: Optional[str], 43 | ) -> None: 44 | pass 45 | 46 | 47 | def get_irctest_controller_class() -> Type[ExternalServerController]: 48 | return ExternalServerController 49 | -------------------------------------------------------------------------------- /irctest/controllers/girc.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from irctest import authentication, tls 4 | from irctest.basecontrollers import ( 5 | BaseClientController, 6 | DirectoryBasedController, 7 | NotImplementedByController, 8 | ) 9 | 10 | 11 | class GircController(BaseClientController, DirectoryBasedController): 12 | software_name = "gIRC" 13 | supported_sasl_mechanisms = {"PLAIN"} 14 | 15 | def run( 16 | self, 17 | hostname: str, 18 | port: int, 19 | auth: Optional[authentication.Authentication], 20 | tls_config: Optional[tls.TlsConfig] = None, 21 | ) -> None: 22 | if tls_config: 23 | print(tls_config) 24 | raise NotImplementedByController("TLS options") 25 | args = ["--host", hostname, "--port", str(port), "--quiet"] 26 | 27 | if auth and auth.username and auth.password: 28 | args += ["--sasl-name", auth.username] 29 | args += ["--sasl-pass", auth.password] 30 | args += ["--sasl-fail-is-ok"] 31 | 32 | # Runs a client with the config given as arguments 33 | self.proc = self.execute(["girc_test", "connect"] + args) 34 | 35 | 36 | def get_irctest_controller_class() -> Type[GircController]: 37 | return GircController 38 | -------------------------------------------------------------------------------- /irctest/controllers/hybrid.py: -------------------------------------------------------------------------------- 1 | from typing import Set, Type 2 | 3 | from .base_hybrid import BaseHybridController 4 | 5 | TEMPLATE_CONFIG = """ 6 | module_base_path = "{install_prefix}/lib/ircd-hybrid/modules"; 7 | .include "./reference.modules.conf" 8 | 9 | serverinfo {{ 10 | name = "My.Little.Server"; 11 | sid = "42X"; 12 | description = "test server"; 13 | 14 | # Hybrid defaults to 9 15 | max_nick_length = 20; 16 | {ssl_config} 17 | }}; 18 | 19 | general {{ 20 | throttle_count = 100; # We need to connect lots of clients quickly 21 | sasl_service = "SaslServ"; 22 | 23 | # Allow PART/QUIT reasons quickly 24 | anti_spam_exit_message_time = 0; 25 | 26 | # Allow all commands quickly 27 | pace_wait_simple = 0; 28 | pace_wait = 0; 29 | }}; 30 | 31 | listen {{ 32 | defer_accept = yes; 33 | 34 | host = "{hostname}"; 35 | port = {port}; 36 | port = {services_port}; 37 | }}; 38 | 39 | class {{ 40 | name = "server"; 41 | ping_time = 5 minutes; 42 | connectfreq = 5 minutes; 43 | }}; 44 | connect {{ 45 | name = "My.Little.Services"; 46 | host = "127.0.0.1"; # Used to validate incoming connection 47 | port = 0; # We don't need servers to connect to services 48 | send_password = "password"; 49 | accept_password = "password"; 50 | class = "server"; 51 | }}; 52 | service {{ 53 | name = "My.Little.Services"; 54 | }}; 55 | 56 | auth {{ 57 | user = "*"; 58 | flags = exceed_limit; 59 | {password_field} 60 | }}; 61 | 62 | operator {{ 63 | name = "operuser"; 64 | user = "*@*"; 65 | password = "operpassword"; 66 | encrypted = no; 67 | umodes = locops, servnotice, wallop; 68 | flags = admin, connect, connect:remote, die, globops, kill, kill:remote, 69 | kline, module, rehash, restart, set, unkline, unxline, wallops, xline; 70 | }}; 71 | """ 72 | 73 | 74 | class HybridController(BaseHybridController): 75 | software_name = "Hybrid" 76 | binary_name = "ircd" 77 | services_protocol = "hybrid" 78 | 79 | supported_sasl_mechanisms: Set[str] = set() 80 | 81 | template_config = TEMPLATE_CONFIG 82 | 83 | 84 | def get_irctest_controller_class() -> Type[HybridController]: 85 | return HybridController 86 | -------------------------------------------------------------------------------- /irctest/controllers/irc2.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional, Type 3 | 4 | from irctest.basecontrollers import ( 5 | BaseServerController, 6 | DirectoryBasedController, 7 | NotImplementedByController, 8 | ) 9 | 10 | TEMPLATE_CONFIG = """ 11 | # M:::::: 12 | M:My.Little.Server:{hostname}:test server:{port}:0042: 13 | 14 | # A:::::: 15 | A:Organization, IRC dept.:Daemon :Client Server::IRCnet: 16 | 17 | # P::<*>::: 18 | P::::{port}:: 19 | 20 | # Y:::::::: 21 | Y:10:90::100:512000:100.100:100.100: 22 | 23 | # I::::::: 24 | I::{password_field}:::10:: 25 | 26 | # O::::::: 27 | O:*:operpassword:operuser:::K: 28 | """ 29 | 30 | 31 | class Irc2Controller(BaseServerController, DirectoryBasedController): 32 | software_name = "irc2" 33 | services_protocol: str 34 | 35 | supports_sts = False 36 | extban_mute_char = None 37 | 38 | def create_config(self) -> None: 39 | super().create_config() 40 | with self.open_file("server.conf"): 41 | pass 42 | 43 | def run( 44 | self, 45 | hostname: str, 46 | port: int, 47 | *, 48 | password: Optional[str], 49 | ssl: bool, 50 | run_services: bool, 51 | faketime: Optional[str], 52 | ) -> None: 53 | if ssl: 54 | raise NotImplementedByController("TLS") 55 | if run_services: 56 | raise NotImplementedByController("Services") 57 | assert self.proc is None 58 | self.port = port 59 | self.hostname = hostname 60 | self.create_config() 61 | password_field = password if password else "" 62 | assert self.directory 63 | pidfile = self.directory / "ircd.pid" 64 | with self.open_file("server.conf") as fd: 65 | fd.write( 66 | TEMPLATE_CONFIG.format( 67 | hostname=hostname, 68 | port=port, 69 | password_field=password_field, 70 | pidfile=pidfile, 71 | ) 72 | ) 73 | 74 | if faketime and shutil.which("faketime"): 75 | faketime_cmd = ["faketime", "-f", faketime] 76 | self.faketime_enabled = True 77 | else: 78 | faketime_cmd = [] 79 | 80 | self.proc = self.execute( 81 | [ 82 | *faketime_cmd, 83 | "ircd", 84 | "-s", # no iauth 85 | "-p", 86 | "on", 87 | "-f", 88 | self.directory / "server.conf", 89 | ], 90 | ) 91 | 92 | 93 | def get_irctest_controller_class() -> Type[Irc2Controller]: 94 | return Irc2Controller 95 | -------------------------------------------------------------------------------- /irctest/controllers/ircu2.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional, Type 3 | 4 | from irctest.basecontrollers import ( 5 | BaseServerController, 6 | DirectoryBasedController, 7 | NotImplementedByController, 8 | ) 9 | 10 | TEMPLATE_CONFIG = """ 11 | General {{ 12 | name = "My.Little.Server"; 13 | numeric = 42; 14 | description = "test server"; 15 | }}; 16 | 17 | Port {{ 18 | vhost = "{hostname}"; 19 | port = {port}; 20 | }}; 21 | 22 | Class {{ 23 | name = "Client"; 24 | pingfreq = 5 minutes; 25 | sendq = 160000; 26 | maxlinks = 1024; 27 | }}; 28 | 29 | Client {{ 30 | username = "*"; 31 | class = "Client"; 32 | {password_field} 33 | }}; 34 | 35 | Operator {{ 36 | local = no; 37 | host = "*@*"; 38 | password = "$PLAIN$operpassword"; 39 | name = "operuser"; 40 | class = "Client"; 41 | }}; 42 | 43 | features {{ 44 | "PPATH" = "{pidfile}"; 45 | 46 | # workaround for whois tests, checking the server name 47 | "HIS_SERVERNAME" = "My.Little.Server"; 48 | }}; 49 | """ 50 | 51 | 52 | class Ircu2Controller(BaseServerController, DirectoryBasedController): 53 | software_name = "ircu2" 54 | supports_sts = False 55 | extban_mute_char = None 56 | 57 | def create_config(self) -> None: 58 | super().create_config() 59 | with self.open_file("server.conf"): 60 | pass 61 | 62 | def run( 63 | self, 64 | hostname: str, 65 | port: int, 66 | *, 67 | password: Optional[str], 68 | ssl: bool, 69 | run_services: bool, 70 | faketime: Optional[str], 71 | ) -> None: 72 | if ssl: 73 | raise NotImplementedByController("TLS") 74 | if run_services: 75 | raise NotImplementedByController("Services") 76 | assert self.proc is None 77 | self.port = port 78 | self.hostname = hostname 79 | self.create_config() 80 | password_field = 'password = "{}";'.format(password) if password else "" 81 | assert self.directory 82 | pidfile = self.directory / "ircd.pid" 83 | with self.open_file("server.conf") as fd: 84 | fd.write( 85 | TEMPLATE_CONFIG.format( 86 | hostname=hostname, 87 | port=port, 88 | password_field=password_field, 89 | pidfile=pidfile, 90 | ) 91 | ) 92 | 93 | if faketime and shutil.which("faketime"): 94 | faketime_cmd = ["faketime", "-f", faketime] 95 | self.faketime_enabled = True 96 | else: 97 | faketime_cmd = [] 98 | 99 | self.proc = self.execute( 100 | [ 101 | *faketime_cmd, 102 | "ircd", 103 | "-n", # don't detach 104 | "-f", 105 | self.directory / "server.conf", 106 | "-x", 107 | "DEBUG", 108 | ], 109 | ) 110 | 111 | 112 | def get_irctest_controller_class() -> Type[Ircu2Controller]: 113 | return Ircu2Controller 114 | -------------------------------------------------------------------------------- /irctest/controllers/limnoria.py: -------------------------------------------------------------------------------- 1 | from typing import Optional, Type 2 | 3 | from irctest import authentication, tls 4 | from irctest.basecontrollers import BaseClientController, DirectoryBasedController 5 | 6 | TEMPLATE_CONFIG = """ 7 | supybot.directories.conf: {directory}/conf 8 | supybot.directories.data: {directory}/data 9 | supybot.directories.migrations: {directory}/migrations 10 | supybot.log.level: DEBUG 11 | supybot.log.stdout.level: {loglevel} 12 | 13 | supybot.networks: testnet 14 | supybot.networks.testnet.servers: {hostname}:{port} 15 | 16 | supybot.protocols.ssl.verifyCertificates: True 17 | supybot.networks.testnet.ssl: {enable_tls} 18 | supybot.networks.testnet.ssl.serverFingerprints: {trusted_fingerprints} 19 | 20 | supybot.networks.testnet.sasl.username: {username} 21 | supybot.networks.testnet.sasl.password: {password} 22 | supybot.networks.testnet.sasl.ecdsa_key: {directory}/ecdsa_key.pem 23 | supybot.networks.testnet.sasl.mechanisms: {mechanisms} 24 | """ 25 | 26 | 27 | class LimnoriaController(BaseClientController, DirectoryBasedController): 28 | software_name = "Limnoria" 29 | supported_sasl_mechanisms = { 30 | "PLAIN", 31 | "ECDSA-NIST256P-CHALLENGE", 32 | "SCRAM-SHA-256", 33 | "EXTERNAL", 34 | } 35 | supports_sts = True 36 | 37 | def create_config(self) -> None: 38 | super().create_config() 39 | with self.open_file("bot.conf"): 40 | pass 41 | with self.open_file("conf/users.conf"): 42 | pass 43 | 44 | def run( 45 | self, 46 | hostname: str, 47 | port: int, 48 | auth: Optional[authentication.Authentication], 49 | tls_config: Optional[tls.TlsConfig] = None, 50 | ) -> None: 51 | if tls_config is None: 52 | tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[]) 53 | # Runs a client with the config given as arguments 54 | assert self.proc is None 55 | self.create_config() 56 | 57 | username = password = "" 58 | mechanisms = "" 59 | if auth: 60 | mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms) 61 | if auth.ecdsa_key: 62 | with self.open_file("ecdsa_key.pem") as fd: 63 | fd.write(auth.ecdsa_key) 64 | 65 | if auth.username: 66 | username = auth.username.encode("unicode_escape").decode() 67 | if auth.password: 68 | password = auth.password.encode("unicode_escape").decode() 69 | with self.open_file("bot.conf") as fd: 70 | fd.write( 71 | TEMPLATE_CONFIG.format( 72 | directory=self.directory, 73 | loglevel="CRITICAL", 74 | hostname=hostname, 75 | port=port, 76 | username=username, 77 | password=password, 78 | mechanisms=mechanisms.lower(), 79 | enable_tls=tls_config.enable if tls_config else "False", 80 | trusted_fingerprints=" ".join(tls_config.trusted_fingerprints) 81 | if tls_config 82 | else "", 83 | ) 84 | ) 85 | assert self.directory 86 | self.proc = self.execute(["supybot", self.directory / "bot.conf"]) 87 | 88 | 89 | def get_irctest_controller_class() -> Type[LimnoriaController]: 90 | return LimnoriaController 91 | -------------------------------------------------------------------------------- /irctest/controllers/mammon.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional, Set, Type 3 | 4 | from irctest.basecontrollers import ( 5 | BaseServerController, 6 | DirectoryBasedController, 7 | NotImplementedByController, 8 | ) 9 | from irctest.cases import BaseServerTestCase 10 | 11 | TEMPLATE_CONFIG = """ 12 | clients: 13 | # ping_frequency - client ping frequency 14 | ping_frequency: 15 | minutes: 1000 16 | 17 | # ping_timeout - ping timeout length 18 | ping_timeout: 19 | seconds: 10 20 | data: 21 | format: json 22 | filename: {directory}/data.json 23 | save_frequency: 24 | minutes: 5 25 | extensions: 26 | - mammon.ext.rfc1459.42 27 | - mammon.ext.rfc1459.ident 28 | - mammon.ext.ircv3.account_notify 29 | - mammon.ext.ircv3.server_time 30 | - mammon.ext.ircv3.echo_message 31 | - mammon.ext.ircv3.register 32 | - mammon.ext.ircv3.sasl 33 | - mammon.ext.misc.nopost 34 | metadata: 35 | restricted_keys: [] 36 | whitelist: 37 | - display-name 38 | - avatar 39 | monitor: 40 | limit: 20 41 | motd: 42 | - "Hi" 43 | limits: 44 | foo: bar 45 | listeners: 46 | - {{"host": "{hostname}", "port": {port}, "ssl": false}} 47 | logs: 48 | {{ 49 | }} 50 | register: 51 | enabled_callbacks: 52 | - none 53 | verify_timeout: 54 | days: 1 55 | roles: 56 | "placeholder": 57 | title: "Just a placeholder" 58 | server: 59 | name: MyLittleServer 60 | network: MyLittleNetwork 61 | recvq_len: 20 62 | """ 63 | 64 | 65 | def make_list(list_: Set[str]) -> str: 66 | return "\n".join(map(" - {}".format, list_)) 67 | 68 | 69 | class MammonController(BaseServerController, DirectoryBasedController): 70 | software_name = "Mammon" 71 | supported_sasl_mechanisms = {"PLAIN", "ECDSA-NIST256P-CHALLENGE"} 72 | 73 | def create_config(self) -> None: 74 | super().create_config() 75 | with self.open_file("server.conf"): 76 | pass 77 | 78 | def kill_proc(self) -> None: 79 | # Mammon does not seem to handle SIGTERM very well 80 | assert self.proc 81 | self.proc.kill() 82 | 83 | def run( 84 | self, 85 | hostname: str, 86 | port: int, 87 | *, 88 | password: Optional[str], 89 | ssl: bool, 90 | run_services: bool, 91 | faketime: Optional[str], 92 | ) -> None: 93 | if password is not None: 94 | raise NotImplementedByController("PASS command") 95 | if ssl: 96 | raise NotImplementedByController("SSL") 97 | assert self.proc is None 98 | self.port = port 99 | self.create_config() 100 | with self.open_file("server.yml") as fd: 101 | fd.write( 102 | TEMPLATE_CONFIG.format( 103 | directory=self.directory, 104 | hostname=hostname, 105 | port=port, 106 | ) 107 | ) 108 | # with self.open_file('server.yml', 'r') as fd: 109 | # print(fd.read()) 110 | assert self.directory 111 | 112 | if faketime and shutil.which("faketime"): 113 | faketime_cmd = ["faketime", "-f", faketime] 114 | self.faketime_enabled = True 115 | else: 116 | faketime_cmd = [] 117 | 118 | self.proc = self.execute( 119 | [ 120 | *faketime_cmd, 121 | "mammond", 122 | "--nofork", # '--debug', 123 | "--config", 124 | self.directory / "server.yml", 125 | ] 126 | ) 127 | 128 | def registerUser( 129 | self, 130 | case: BaseServerTestCase, 131 | username: str, 132 | password: Optional[str] = None, 133 | ) -> None: 134 | # XXX: Move this somewhere else when 135 | # https://github.com/ircv3/ircv3-specifications/pull/152 becomes 136 | # part of the specification 137 | client = case.addClient(show_io=False) 138 | case.sendLine(client, "CAP LS 302") 139 | case.sendLine(client, "NICK registration_user") 140 | case.sendLine(client, "USER r e g :user") 141 | case.sendLine(client, "CAP END") 142 | while case.getRegistrationMessage(client).command != "001": 143 | pass 144 | list(case.getMessages(client)) 145 | case.sendLine(client, "REG CREATE {} passphrase {}".format(username, password)) 146 | msg = case.getMessage(client) 147 | assert msg.command == "920", msg 148 | list(case.getMessages(client)) 149 | case.removeClient(client) 150 | 151 | 152 | def get_irctest_controller_class() -> Type[MammonController]: 153 | return MammonController 154 | -------------------------------------------------------------------------------- /irctest/controllers/nefarious.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from .ircu2 import Ircu2Controller 4 | 5 | 6 | class NefariousController(Ircu2Controller): 7 | software_name = "Nefarious" 8 | 9 | 10 | def get_irctest_controller_class() -> Type[NefariousController]: 11 | return NefariousController 12 | -------------------------------------------------------------------------------- /irctest/controllers/ngircd.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional, Set, Type 3 | 4 | from irctest.basecontrollers import BaseServerController, DirectoryBasedController 5 | 6 | TEMPLATE_CONFIG = """ 7 | [Global] 8 | Name = My.Little.Server 9 | Info = test server 10 | Bind = {hostname} 11 | Ports = {port} 12 | AdminInfo1 = Bob Smith 13 | AdminEMail = email@example.org 14 | {password_field} 15 | 16 | [Server] 17 | Name = My.Little.Services 18 | MyPassword = password 19 | PeerPassword = password 20 | Passive = yes # don't connect to it 21 | ServiceMask = *Serv 22 | 23 | [Options] 24 | MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK 25 | PAM = no 26 | 27 | [Operator] 28 | Name = operuser 29 | Password = operpassword 30 | 31 | [Limits] 32 | MaxNickLength = 32 # defaults to 9 33 | """ 34 | 35 | 36 | class NgircdController(BaseServerController, DirectoryBasedController): 37 | software_name = "ngIRCd" 38 | supported_sasl_mechanisms: Set[str] = set() 39 | supports_sts = False 40 | 41 | def create_config(self) -> None: 42 | super().create_config() 43 | with self.open_file("server.conf"): 44 | pass 45 | 46 | def run( 47 | self, 48 | hostname: str, 49 | port: int, 50 | *, 51 | password: Optional[str], 52 | ssl: bool, 53 | run_services: bool, 54 | faketime: Optional[str], 55 | ) -> None: 56 | assert self.proc is None 57 | self.port = port 58 | self.hostname = hostname 59 | self.create_config() 60 | (unused_hostname, unused_port) = self.get_hostname_and_port() 61 | 62 | password_field = "Password = {}".format(password) if password else "" 63 | 64 | self.gen_ssl() 65 | if ssl: 66 | (tls_hostname, tls_port) = (hostname, port) 67 | (hostname, port) = (unused_hostname, unused_port) 68 | else: 69 | # Unreal refuses to start without TLS enabled 70 | (tls_hostname, tls_port) = (unused_hostname, unused_port) 71 | 72 | with self.open_file("empty.txt") as fd: 73 | fd.write("\n") 74 | 75 | assert self.directory 76 | 77 | with self.open_file("server.conf") as fd: 78 | fd.write( 79 | TEMPLATE_CONFIG.format( 80 | hostname=hostname, 81 | port=port, 82 | tls_hostname=tls_hostname, 83 | tls_port=tls_port, 84 | password_field=password_field, 85 | key_path=self.key_path, 86 | pem_path=self.pem_path, 87 | empty_file=self.directory / "empty.txt", 88 | ) 89 | ) 90 | 91 | if faketime and shutil.which("faketime"): 92 | faketime_cmd = ["faketime", "-f", faketime] 93 | self.faketime_enabled = True 94 | else: 95 | faketime_cmd = [] 96 | 97 | self.proc = self.execute( 98 | [ 99 | *faketime_cmd, 100 | "ngircd", 101 | "--nodaemon", 102 | "--config", 103 | self.directory / "server.conf", 104 | ], 105 | ) 106 | 107 | if run_services: 108 | self.wait_for_port() 109 | self.services_controller = self.services_controller_class( 110 | self.test_config, self 111 | ) 112 | self.services_controller.run( 113 | protocol="ngircd", 114 | server_hostname=hostname, 115 | server_port=port, 116 | ) 117 | 118 | 119 | def get_irctest_controller_class() -> Type[NgircdController]: 120 | return NgircdController 121 | -------------------------------------------------------------------------------- /irctest/controllers/plexus4.py: -------------------------------------------------------------------------------- 1 | from typing import Set, Type 2 | 3 | from .base_hybrid import BaseHybridController 4 | 5 | TEMPLATE_CONFIG = """ 6 | serverinfo {{ 7 | name = "My.Little.Server"; 8 | sid = "42X"; 9 | description = "test server"; 10 | 11 | # Hybrid defaults to 9 12 | max_nick_length = 20; 13 | {ssl_config} 14 | }}; 15 | 16 | general {{ 17 | throttle_count = 100; # We need to connect lots of clients quickly 18 | sasl_service = "SaslServ"; 19 | 20 | # Allow connections quickly 21 | throttle_num = 100; 22 | 23 | # Allow PART/QUIT reasons quickly 24 | anti_spam_exit_message_time = 0; 25 | 26 | # Allow all commands quickly 27 | pace_wait_simple = 0; 28 | pace_wait = 0; 29 | }}; 30 | 31 | listen {{ 32 | defer_accept = yes; 33 | 34 | host = "{hostname}"; 35 | port = {port}; 36 | 37 | flags = server; 38 | port = {services_port}; 39 | }}; 40 | 41 | class {{ 42 | name = "server"; 43 | ping_time = 5 minutes; 44 | connectfreq = 5 minutes; 45 | }}; 46 | connect {{ 47 | name = "My.Little.Services"; 48 | host = "127.0.0.1"; # Used to validate incoming connection 49 | port = 0; # We don't need servers to connect to services 50 | send_password = "password"; 51 | accept_password = "password"; 52 | class = "server"; 53 | }}; 54 | service {{ 55 | name = "My.Little.Services"; 56 | }}; 57 | 58 | auth {{ 59 | user = "*"; 60 | flags = exceed_limit; 61 | {password_field} 62 | }}; 63 | 64 | operator {{ 65 | name = "operuser"; 66 | user = "*@*"; 67 | password = "operpassword"; 68 | encrypted = no; 69 | umodes = locops, servnotice, wallop; 70 | flags = admin, connect, connect:remote, die, globops, kill, kill:remote, 71 | kline, module, rehash, restart, set, unkline, unxline, wallops, xline; 72 | }}; 73 | """ 74 | 75 | 76 | class Plexus4Controller(BaseHybridController): 77 | software_name = "Plexus4" 78 | binary_name = "ircd" 79 | services_protocol = "plexus" 80 | 81 | supported_sasl_mechanisms: Set[str] = set() 82 | 83 | template_config = TEMPLATE_CONFIG 84 | 85 | 86 | def get_irctest_controller_class() -> Type[Plexus4Controller]: 87 | return Plexus4Controller 88 | -------------------------------------------------------------------------------- /irctest/controllers/snircd.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from typing import Optional, Type 3 | 4 | from irctest.basecontrollers import ( 5 | BaseServerController, 6 | DirectoryBasedController, 7 | NotImplementedByController, 8 | ) 9 | 10 | TEMPLATE_CONFIG = """ 11 | General {{ 12 | name = "My.Little.Server"; 13 | numeric = 42; 14 | description = "test server"; 15 | }}; 16 | 17 | Port {{ 18 | vhost = "{hostname}"; 19 | port = {port}; 20 | }}; 21 | 22 | Class {{ 23 | name = "Client"; 24 | pingfreq = 5 minutes; 25 | sendq = 160000; 26 | maxlinks = 1024; 27 | }}; 28 | 29 | Client {{ 30 | username = "*"; 31 | class = "Client"; 32 | {password_field} 33 | }}; 34 | 35 | Operator {{ 36 | local = no; 37 | host = "*@*"; 38 | password = "$PLAIN$operpassword"; 39 | name = "operuser"; 40 | class = "Client"; 41 | }}; 42 | 43 | features {{ 44 | "PPATH" = "{pidfile}"; 45 | 46 | # don't block notices by default, wtf 47 | "AUTOCHANMODES_LIST" = "+tnC"; 48 | }}; 49 | """ 50 | 51 | 52 | class SnircdController(BaseServerController, DirectoryBasedController): 53 | supports_sts = False 54 | extban_mute_char = None 55 | 56 | def create_config(self) -> None: 57 | super().create_config() 58 | with self.open_file("server.conf"): 59 | pass 60 | 61 | def run( 62 | self, 63 | hostname: str, 64 | port: int, 65 | *, 66 | password: Optional[str], 67 | ssl: bool, 68 | run_services: bool, 69 | faketime: Optional[str], 70 | ) -> None: 71 | if ssl: 72 | raise NotImplementedByController("TLS") 73 | if run_services: 74 | raise NotImplementedByController("Services") 75 | assert self.proc is None 76 | self.port = port 77 | self.hostname = hostname 78 | self.create_config() 79 | password_field = 'password = "{}";'.format(password) if password else "" 80 | assert self.directory 81 | pidfile = self.directory / "ircd.pid" 82 | with self.open_file("server.conf") as fd: 83 | fd.write( 84 | TEMPLATE_CONFIG.format( 85 | hostname=hostname, 86 | port=port, 87 | password_field=password_field, 88 | pidfile=pidfile, 89 | ) 90 | ) 91 | 92 | if faketime and shutil.which("faketime"): 93 | faketime_cmd = ["faketime", "-f", faketime] 94 | self.faketime_enabled = True 95 | else: 96 | faketime_cmd = [] 97 | 98 | self.proc = self.execute( 99 | [ 100 | *faketime_cmd, 101 | "ircd", 102 | "-n", # don't detach 103 | "-f", 104 | self.directory / "server.conf", 105 | "-x", 106 | "DEBUG", 107 | ], 108 | ) 109 | 110 | 111 | def get_irctest_controller_class() -> Type[SnircdController]: 112 | return SnircdController 113 | -------------------------------------------------------------------------------- /irctest/controllers/solanum.py: -------------------------------------------------------------------------------- 1 | from typing import Type 2 | 3 | from .charybdis import CharybdisController 4 | 5 | 6 | class SolanumController(CharybdisController): 7 | software_name = "Solanum" 8 | binary_name = "solanum" 9 | 10 | 11 | def get_irctest_controller_class() -> Type[SolanumController]: 12 | return SolanumController 13 | -------------------------------------------------------------------------------- /irctest/controllers/sopel.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | import tempfile 3 | from typing import Optional, TextIO, Type, cast 4 | 5 | from irctest import authentication, tls 6 | from irctest.basecontrollers import ( 7 | BaseClientController, 8 | NotImplementedByController, 9 | TestCaseControllerConfig, 10 | ) 11 | 12 | TEMPLATE_CONFIG = """ 13 | [core] 14 | nick = Sopel 15 | host = {hostname} 16 | use_ssl = false 17 | port = {port} 18 | owner = me 19 | channels = 20 | timeout = 5 21 | auth_username = {username} 22 | auth_password = {password} 23 | {auth_method} 24 | """ 25 | 26 | 27 | class SopelController(BaseClientController): 28 | software_name = "Sopel" 29 | supported_sasl_mechanisms = {"PLAIN"} 30 | supports_sts = False 31 | 32 | def __init__(self, test_config: TestCaseControllerConfig): 33 | super().__init__(test_config) 34 | self.filename = next(tempfile._get_candidate_names()) + ".cfg" # type: ignore 35 | 36 | def kill(self) -> None: 37 | super().kill() 38 | if self.filename: 39 | try: 40 | (Path("~/.sopel/").expanduser() / self.filename).unlink() 41 | except OSError: # File does not exist 42 | pass 43 | 44 | def open_file(self, filename: str, mode: str = "a") -> TextIO: 45 | dir_path = Path("~/.sopel/").expanduser() 46 | dir_path.mkdir(parents=True, exist_ok=True) 47 | return cast(TextIO, (dir_path / filename).open(mode)) 48 | 49 | def create_config(self) -> None: 50 | with self.open_file(self.filename): 51 | pass 52 | 53 | def run( 54 | self, 55 | hostname: str, 56 | port: int, 57 | auth: Optional[authentication.Authentication], 58 | tls_config: Optional[tls.TlsConfig] = None, 59 | ) -> None: 60 | # Runs a client with the config given as arguments 61 | if tls_config is not None: 62 | raise NotImplementedByController("TLS configuration") 63 | assert self.proc is None 64 | self.create_config() 65 | with self.open_file(self.filename) as fd: 66 | fd.write( 67 | TEMPLATE_CONFIG.format( 68 | hostname=hostname, 69 | port=port, 70 | username=auth.username if auth else "", 71 | password=auth.password if auth else "", 72 | auth_method="auth_method = sasl" if auth else "", 73 | ) 74 | ) 75 | self.proc = self.execute(["sopel", "-c", self.filename]) 76 | 77 | 78 | def get_irctest_controller_class() -> Type[SopelController]: 79 | return SopelController 80 | -------------------------------------------------------------------------------- /irctest/controllers/thelounge.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | from typing import Optional, Type 4 | 5 | from irctest import authentication, tls 6 | from irctest.basecontrollers import ( 7 | BaseClientController, 8 | DirectoryBasedController, 9 | NotImplementedByController, 10 | ) 11 | 12 | TEMPLATE_CONFIG = """ 13 | "use strict"; 14 | 15 | module.exports = {config}; 16 | """ 17 | 18 | 19 | class TheLoungeController(BaseClientController, DirectoryBasedController): 20 | software_name = "TheLounge" 21 | supported_sasl_mechanisms = { 22 | "PLAIN", 23 | "ECDSA-NIST256P-CHALLENGE", 24 | "SCRAM-SHA-256", 25 | "EXTERNAL", 26 | } 27 | supports_sts = True 28 | 29 | def create_config(self) -> None: 30 | super().create_config() 31 | with self.open_file("bot.conf"): 32 | pass 33 | with self.open_file("conf/users.conf"): 34 | pass 35 | 36 | def run( 37 | self, 38 | hostname: str, 39 | port: int, 40 | auth: Optional[authentication.Authentication], 41 | tls_config: Optional[tls.TlsConfig] = None, 42 | ) -> None: 43 | if tls_config is None: 44 | tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[]) 45 | if tls_config and tls_config.trusted_fingerprints: 46 | raise NotImplementedByController("Trusted fingerprints.") 47 | if auth and any( 48 | mech.to_string().startswith(("SCRAM-", "ECDSA-")) 49 | for mech in auth.mechanisms 50 | ): 51 | raise NotImplementedByController("ecdsa") 52 | if auth and auth.password and len(auth.password) > 300: 53 | # https://github.com/thelounge/thelounge/pull/4480 54 | # Note that The Lounge truncates on 300 characters, not bytes. 55 | raise NotImplementedByController("Passwords longer than 300 chars") 56 | # Runs a client with the config given as arguments 57 | assert self.proc is None 58 | self.create_config() 59 | if auth: 60 | mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms) 61 | if auth.ecdsa_key: 62 | with self.open_file("ecdsa_key.pem") as fd: 63 | fd.write(auth.ecdsa_key) 64 | else: 65 | mechanisms = "" 66 | 67 | assert self.directory 68 | with self.open_file("config.js") as fd: 69 | fd.write( 70 | TEMPLATE_CONFIG.format( 71 | config=json.dumps( 72 | dict( 73 | public=False, 74 | host=f"unix:{self.directory}/sock", # prevents binding 75 | ) 76 | ) 77 | ) 78 | ) 79 | with self.open_file("users/testuser.json") as fd: 80 | json.dump( 81 | dict( 82 | networks=[ 83 | dict( 84 | name="testnet", 85 | host=hostname, 86 | port=port, 87 | tls=tls_config.enable if tls_config else "False", 88 | sasl=mechanisms.lower(), 89 | saslAccount=auth.username if auth else "", 90 | saslPassword=auth.password if auth else "", 91 | ) 92 | ] 93 | ), 94 | fd, 95 | ) 96 | with self.open_file("users/testuser.json", "r") as fd: 97 | print("config", json.load(fd)["networks"][0]["saslPassword"]) 98 | self.proc = self.execute( 99 | [os.environ.get("THELOUNGE_BIN", "thelounge"), "start"], 100 | env={**os.environ, "THELOUNGE_HOME": str(self.directory)}, 101 | ) 102 | 103 | 104 | def get_irctest_controller_class() -> Type[TheLoungeController]: 105 | return TheLoungeController 106 | -------------------------------------------------------------------------------- /irctest/dashboard/github_download.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import gzip 3 | import io 4 | import json 5 | from pathlib import Path 6 | import sys 7 | from typing import Iterator 8 | import urllib.parse 9 | import urllib.request 10 | import zipfile 11 | 12 | 13 | @dataclasses.dataclass 14 | class Artifact: 15 | repo: str 16 | run_id: int 17 | name: str 18 | download_url: str 19 | 20 | @property 21 | def public_download_url(self) -> str: 22 | # GitHub API is not available publicly for artifacts, we need to use 23 | # a third-party proxy to access it... 24 | name = urllib.parse.quote(self.name) 25 | return f"https://nightly.link/{repo}/actions/runs/{self.run_id}/{name}.zip" 26 | 27 | 28 | def iter_run_artifacts(repo: str, run_id: int) -> Iterator[Artifact]: 29 | request = urllib.request.Request( 30 | f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/artifacts" 31 | "?per_page=100", 32 | headers={"Accept": "application/vnd.github.v3+json"}, 33 | ) 34 | 35 | response = urllib.request.urlopen(request) 36 | 37 | for artifact in json.load(response)["artifacts"]: 38 | if not artifact["name"].startswith(("pytest-results_", "pytest results ")): 39 | continue 40 | if artifact["expired"]: 41 | continue 42 | yield Artifact( 43 | repo=repo, 44 | run_id=run_id, 45 | name=artifact["name"], 46 | download_url=artifact["archive_download_url"], 47 | ) 48 | 49 | 50 | def download_artifact(output_name: Path, url: str) -> None: 51 | if output_name.exists(): 52 | return 53 | response = urllib.request.urlopen(url) 54 | archive_bytes = response.read() # Can't stream it, it's a ZIP 55 | with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: 56 | with archive.open("pytest.xml") as input_fd: 57 | pytest_xml = input_fd.read() 58 | 59 | tmp_output_path = output_name.with_suffix(".tmp") 60 | with gzip.open(tmp_output_path, "wb") as output_fd: 61 | output_fd.write(pytest_xml) 62 | 63 | # Atomically write to the output path, so that we don't write partial files in case 64 | # the download process is interrupted 65 | tmp_output_path.rename(output_name) 66 | 67 | 68 | def main(output_dir: Path, repo: str, run_id: int) -> int: 69 | output_dir.mkdir(parents=True, exist_ok=True) 70 | run_path = output_dir / str(run_id) 71 | run_path.mkdir(exist_ok=True) 72 | 73 | for artifact in iter_run_artifacts(repo, run_id): 74 | artifact_path = run_path / artifact.name / "pytest.xml.gz" 75 | artifact_path.parent.mkdir(exist_ok=True) 76 | try: 77 | download_artifact(artifact_path, artifact.download_url) 78 | except Exception: 79 | download_artifact(artifact_path, artifact.public_download_url) 80 | print("downloaded", artifact.name) 81 | 82 | return 0 83 | 84 | 85 | if __name__ == "__main__": 86 | (_, output_path, repo, run_id) = sys.argv 87 | exit(main(Path(output_path), repo, int(run_id))) 88 | -------------------------------------------------------------------------------- /irctest/dashboard/shortxml.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2023 Valentin Lorentz 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining a copy 4 | # of this software and associated documentation files (the "Software"), to deal 5 | # in the Software without restriction, including without limitation the rights 6 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | # copies of the Software, and to permit persons to whom the Software is 8 | # furnished to do so, subject to the following conditions: 9 | # 10 | # The above copyright notice and this permission notice shall be included in all 11 | # copies or substantial portions of the Software. 12 | # 13 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | # SOFTWARE. 20 | 21 | """This module allows writing XML ASTs in a way that is more concise than the default 22 | :mod:`xml.etree.ElementTree` interface. 23 | 24 | For example: 25 | 26 | .. code-block:: python 27 | 28 | from .shortxml import Namespace 29 | 30 | HTML = Namespace("http://www.w3.org/1999/xhtml") 31 | 32 | page = HTML.html( 33 | HTML.head( 34 | HTML.title("irctest dashboard"), 35 | HTML.link(rel="stylesheet", type="text/css", href="./style.css"), 36 | ), 37 | HTML.body( 38 | HTML.h1("irctest dashboard"), 39 | HTML.h2("Tests by command/specification"), 40 | HTML.dl( 41 | [ 42 | ( # elements can be arbitrarily nested in lists 43 | HTML.dt(HTML.a(title, href=f"./{title}.xhtml")), 44 | HTML.dd(defintion), 45 | ) 46 | for title, definition in sorted(definitions) 47 | ], 48 | class_="module-index", 49 | ), 50 | HTML.h2("Tests by implementation"), 51 | HTML.ul( 52 | [ 53 | HTML.li(HTML.a(job, href=f"./{file_name}")) 54 | for job, file_name in sorted(job_pages) 55 | ], 56 | class_="job-index", 57 | ), 58 | ), 59 | ) 60 | 61 | print(ET.tostring(page, default_namespace=HTML.uri)) 62 | 63 | 64 | Attributes can be passed either as dictionaries or as kwargs, and can be mixed 65 | with child elements. 66 | Trailing underscores are stripped from attributes, which allows passing reserved 67 | Python keywords (eg. ``class_`` instead of ``class``) 68 | 69 | Attributes are always qualified, and share the namespace of the element they are 70 | attached to. 71 | 72 | Mixed content (elements containing both text and child elements) is not supported. 73 | """ 74 | 75 | from typing import Dict, Sequence, Union 76 | import xml.etree.ElementTree as ET 77 | 78 | 79 | def _namespacify(ns: str, s: str) -> str: 80 | return "{%s}%s" % (ns, s) 81 | 82 | 83 | _Children = Union[None, Dict[str, str], ET.Element, Sequence["_Children"]] 84 | 85 | 86 | class ElementFactory: 87 | def __init__(self, namespace: str, tag: str): 88 | self._tag = _namespacify(namespace, tag) 89 | self._namespace = namespace 90 | 91 | def __call__(self, *args: Union[str, _Children], **kwargs: str) -> ET.Element: 92 | e = ET.Element(self._tag) 93 | 94 | attributes = {k.rstrip("_"): v for (k, v) in kwargs.items()} 95 | children = [*args, attributes] 96 | 97 | if args and isinstance(children[0], str): 98 | e.text = children[0] 99 | children.pop(0) 100 | 101 | for child in children: 102 | self._append_child(e, child) 103 | 104 | return e 105 | 106 | def _append_child(self, e: ET.Element, child: _Children) -> None: 107 | if isinstance(child, ET.Element): 108 | e.append(child) 109 | elif child is None: 110 | pass 111 | elif isinstance(child, dict): 112 | for k, v in child.items(): 113 | e.set(_namespacify(self._namespace, k), str(v)) 114 | elif isinstance(child, str): 115 | raise ValueError("Mixed content is not supported") 116 | else: 117 | for grandchild in child: 118 | self._append_child(e, grandchild) 119 | 120 | 121 | class Namespace: 122 | def __init__(self, uri: str): 123 | self.uri = uri 124 | 125 | def __getattr__(self, tag: str) -> ElementFactory: 126 | return ElementFactory(self.uri, tag) 127 | -------------------------------------------------------------------------------- /irctest/dashboard/style.css: -------------------------------------------------------------------------------- 1 | @media (prefers-color-scheme: dark) { 2 | body { 3 | background-color: #121212; 4 | color: rgba(255, 255, 255, 0.87); 5 | } 6 | a { 7 | filter: invert(0.85) hue-rotate(180deg); 8 | } 9 | } 10 | 11 | dl.module-index { 12 | column-width: 40em; /* Magic constant for 2 columns on average laptop/desktop */ 13 | } 14 | 15 | /* Only 1px solid border between cells */ 16 | table.test-matrix { 17 | border-spacing: 0; 18 | border-collapse: collapse; 19 | } 20 | table.test-matrix td { 21 | text-align: center; 22 | border: 1px solid grey; 23 | } 24 | 25 | /* Make link take the whole cell */ 26 | table.test-matrix td a { 27 | display: block; 28 | margin: 0; 29 | padding: 0; 30 | width: 100%; 31 | height: 100%; 32 | color: black; 33 | text-decoration: none; 34 | } 35 | 36 | /* Test matrix colors */ 37 | table.test-matrix .deselected { 38 | background-color: grey; 39 | } 40 | table.test-matrix .success { 41 | background-color: green; 42 | } 43 | table.test-matrix .skipped { 44 | background-color: yellow; 45 | } 46 | table.test-matrix .failure { 47 | background-color: red; 48 | } 49 | table.test-matrix .expected-failure { 50 | background-color: orange; 51 | } 52 | 53 | /* Rotate headers, thanks to https://css-tricks.com/rotated-table-column-headers/ */ 54 | table.module-results th.job-name { 55 | height: 140px; 56 | white-space: nowrap; 57 | } 58 | table.module-results th.job-name > div { 59 | transform: 60 | translate(28px, 50px) 61 | rotate(315deg); 62 | width: 40px; 63 | } 64 | table.module-results th.job-name > div > span { 65 | border-bottom: 1px solid grey; 66 | padding-left: 0px; 67 | } 68 | -------------------------------------------------------------------------------- /irctest/exceptions.py: -------------------------------------------------------------------------------- 1 | class NoMessageException(AssertionError): 2 | pass 3 | 4 | 5 | class ConnectionClosed(Exception): 6 | pass 7 | -------------------------------------------------------------------------------- /irctest/irc_utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progval/irctest/4e4fd4f1b3e0a16d412a92d4a5f513bbeed64803/irctest/irc_utils/__init__.py -------------------------------------------------------------------------------- /irctest/irc_utils/capabilities.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, List, Optional 2 | 3 | 4 | def cap_list_to_dict(caps: List[str]) -> Dict[str, Optional[str]]: 5 | d: Dict[str, Optional[str]] = {} 6 | for cap in caps: 7 | if "=" in cap: 8 | (key, value) = cap.split("=", 1) 9 | d[key] = value 10 | else: 11 | d[cap] = None 12 | return d 13 | -------------------------------------------------------------------------------- /irctest/irc_utils/filelock.py: -------------------------------------------------------------------------------- 1 | """ 2 | Compatibility layer for filelock ( https://pypi.org/project/filelock/ ); 3 | commonly packaged by Linux distributions but might not be available 4 | in some environments. 5 | """ 6 | 7 | import contextlib 8 | import os 9 | from typing import Any, ContextManager 10 | 11 | if os.getenv("PYTEST_XDIST_WORKER"): 12 | # running under pytest-xdist; filelock is required for reliability 13 | from filelock import FileLock 14 | else: 15 | # normal test execution, no port races 16 | 17 | def FileLock(*args: Any, **kwargs: Any) -> ContextManager[None]: 18 | return contextlib.nullcontext() 19 | -------------------------------------------------------------------------------- /irctest/irc_utils/junkdrawer.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import re 3 | import secrets 4 | import socket 5 | from typing import Dict, Tuple 6 | 7 | # thanks jess! 8 | IRCV3_FORMAT_STRFTIME = "%Y-%m-%dT%H:%M:%S.%f%z" 9 | 10 | 11 | def ircv3_timestamp_to_unixtime(timestamp: str) -> float: 12 | return datetime.datetime.strptime(timestamp, IRCV3_FORMAT_STRFTIME).timestamp() 13 | 14 | 15 | def random_name(base: str) -> str: 16 | return base + "-" + secrets.token_hex(5) 17 | 18 | 19 | def find_hostname_and_port() -> Tuple[str, int]: 20 | """Find available hostname/port to listen on.""" 21 | s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 22 | s.bind(("", 0)) 23 | (hostname, port) = s.getsockname() 24 | s.close() 25 | return (hostname, port) 26 | 27 | 28 | """ 29 | Stolen from supybot: 30 | """ 31 | 32 | 33 | class MultipleReplacer: 34 | """Return a callable that replaces all dict keys by the associated 35 | value. More efficient than multiple .replace().""" 36 | 37 | # We use an object instead of a lambda function because it avoids the 38 | # need for using the staticmethod() on the lambda function if assigning 39 | # it to a class in Python 3. 40 | def __init__(self, dict_: Dict[str, str]): 41 | self._dict = dict_ 42 | dict_ = dict([(re.escape(key), val) for key, val in dict_.items()]) 43 | self._matcher = re.compile("|".join(dict_.keys())) 44 | 45 | def __call__(self, s: str) -> str: 46 | return self._matcher.sub(lambda m: self._dict[m.group(0)], s) 47 | -------------------------------------------------------------------------------- /irctest/irc_utils/message_parser.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | import re 3 | from typing import Any, Dict, List, Optional 4 | 5 | from .junkdrawer import MultipleReplacer 6 | 7 | # http://ircv3.net/specs/core/message-tags-3.2.html#escaping-values 8 | TAG_ESCAPE = [ 9 | ("\\", "\\\\"), # \ -> \\ 10 | (" ", r"\s"), 11 | (";", r"\:"), 12 | ("\r", r"\r"), 13 | ("\n", r"\n"), 14 | ] 15 | unescape_tag_value = MultipleReplacer(dict(map(lambda x: (x[1], x[0]), TAG_ESCAPE))) 16 | 17 | # TODO: validate host 18 | tag_key_validator = re.compile(r"^\+?(\S+/)?[a-zA-Z0-9-]+$") 19 | 20 | 21 | def parse_tags(s: str) -> Dict[str, Optional[str]]: 22 | tags: Dict[str, Optional[str]] = {} 23 | for tag in s.split(";"): 24 | if "=" not in tag: 25 | tags[tag] = None 26 | else: 27 | (key, value) = tag.split("=", 1) 28 | assert tag_key_validator.match(key), "Invalid tag key: {}".format(key) 29 | tags[key] = unescape_tag_value(value) 30 | return tags 31 | 32 | 33 | @dataclasses.dataclass(frozen=True) 34 | class HistoryMessage: 35 | time: Any 36 | msgid: Optional[str] 37 | target: str 38 | text: str 39 | 40 | 41 | @dataclasses.dataclass(frozen=True) 42 | class Message: 43 | tags: Dict[str, Optional[str]] 44 | prefix: Optional[str] 45 | command: str 46 | params: List[str] 47 | 48 | def to_history_message(self) -> HistoryMessage: 49 | return HistoryMessage( 50 | time=self.tags.get("time"), 51 | msgid=self.tags.get("msgid"), 52 | target=self.params[0], 53 | text=self.params[1], 54 | ) 55 | 56 | 57 | def parse_message(s: str) -> Message: 58 | """Parse a message according to 59 | http://tools.ietf.org/html/rfc1459#section-2.3.1 60 | and 61 | http://ircv3.net/specs/core/message-tags-3.2.html""" 62 | s = s.rstrip("\r\n") 63 | if s.startswith("@"): 64 | (tags_str, s) = s.split(" ", 1) 65 | tags = parse_tags(tags_str[1:]) 66 | else: 67 | tags = {} 68 | if " :" in s: 69 | (other_tokens, trailing_param) = s.split(" :", 1) 70 | tokens = list(filter(bool, other_tokens.split(" "))) + [trailing_param] 71 | else: 72 | tokens = list(filter(bool, s.split(" "))) 73 | prefix = prefix = tokens.pop(0)[1:] if tokens[0].startswith(":") else None 74 | command = tokens.pop(0) 75 | params = tokens 76 | return Message(tags=tags, prefix=prefix, command=command, params=params) 77 | -------------------------------------------------------------------------------- /irctest/irc_utils/sasl.py: -------------------------------------------------------------------------------- 1 | import base64 2 | 3 | 4 | def sasl_plain_blob(username: str, passphrase: str) -> str: 5 | blob = base64.b64encode( 6 | b"\x00".join( 7 | ( 8 | username.encode("utf-8"), 9 | username.encode("utf-8"), 10 | passphrase.encode("utf-8"), 11 | ) 12 | ) 13 | ) 14 | blobstr = blob.decode("ascii") 15 | return f"AUTHENTICATE {blobstr}" 16 | -------------------------------------------------------------------------------- /irctest/numerics.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2012-2014 Jeremy Latt 2 | # Copyright (c) 2014-2015 Edmund Huber 3 | # Copyright (c) 2016-2017 Daniel Oaks 4 | # released under the MIT license 5 | 6 | # These numerics have been retrieved from: 7 | # http://defs.ircdocs.horse/ and http://modern.ircdocs.horse/ 8 | 9 | # They're intended to represent a relatively-standard cross-section of the IRC 10 | # server ecosystem out there. Custom numerics will be marked as such. 11 | 12 | RPL_WELCOME = "001" 13 | RPL_YOURHOST = "002" 14 | RPL_CREATED = "003" 15 | RPL_MYINFO = "004" 16 | RPL_ISUPPORT = "005" 17 | RPL_SNOMASKIS = "008" 18 | RPL_BOUNCE = "010" 19 | RPL_HELLO = "020" 20 | RPL_TRACELINK = "200" 21 | RPL_TRACECONNECTING = "201" 22 | RPL_TRACEHANDSHAKE = "202" 23 | RPL_TRACEUNKNOWN = "203" 24 | RPL_TRACEOPERATOR = "204" 25 | RPL_TRACEUSER = "205" 26 | RPL_TRACESERVER = "206" 27 | RPL_TRACESERVICE = "207" 28 | RPL_TRACENEWTYPE = "208" 29 | RPL_TRACECLASS = "209" 30 | RPL_TRACERECONNECT = "210" 31 | RPL_STATSLINKINFO = "211" 32 | RPL_STATSCOMMANDS = "212" 33 | RPL_ENDOFSTATS = "219" 34 | RPL_UMODEIS = "221" 35 | RPL_SERVLIST = "234" 36 | RPL_SERVLISTEND = "235" 37 | RPL_STATSUPTIME = "242" 38 | RPL_STATSOLINE = "243" 39 | RPL_LUSERCLIENT = "251" 40 | RPL_LUSEROP = "252" 41 | RPL_LUSERUNKNOWN = "253" 42 | RPL_LUSERCHANNELS = "254" 43 | RPL_LUSERME = "255" 44 | RPL_ADMINME = "256" 45 | RPL_ADMINLOC1 = "257" 46 | RPL_ADMINLOC2 = "258" 47 | RPL_ADMINEMAIL = "259" 48 | RPL_TRACELOG = "261" 49 | RPL_TRACEEND = "262" 50 | RPL_TRYAGAIN = "263" 51 | RPL_LOCALUSERS = "265" 52 | RPL_GLOBALUSERS = "266" 53 | RPL_WHOISCERTFP = "276" 54 | RPL_AWAY = "301" 55 | RPL_USERHOST = "302" 56 | RPL_ISON = "303" 57 | RPL_UNAWAY = "305" 58 | RPL_NOWAWAY = "306" 59 | RPL_WHOISREGNICK = "307" 60 | RPL_WHOISUSER = "311" 61 | RPL_WHOISSERVER = "312" 62 | RPL_WHOISOPERATOR = "313" 63 | RPL_WHOWASUSER = "314" 64 | RPL_ENDOFWHO = "315" 65 | RPL_WHOISIDLE = "317" 66 | RPL_ENDOFWHOIS = "318" 67 | RPL_WHOISCHANNELS = "319" 68 | RPL_WHOISSPECIAL = "320" 69 | RPL_LISTSTART = "321" 70 | RPL_LIST = "322" 71 | RPL_LISTEND = "323" 72 | RPL_CHANNELMODEIS = "324" 73 | RPL_UNIQOPIS = "325" 74 | RPL_CHANNELCREATED = "329" 75 | RPL_WHOISACCOUNT = "330" 76 | RPL_NOTOPIC = "331" 77 | RPL_TOPIC = "332" 78 | RPL_TOPICTIME = "333" 79 | RPL_WHOISBOT = "335" 80 | RPL_WHOISACTUALLY = "338" 81 | RPL_INVITING = "341" 82 | RPL_SUMMONING = "342" 83 | RPL_INVITELIST = "346" 84 | RPL_ENDOFINVITELIST = "347" 85 | RPL_EXCEPTLIST = "348" 86 | RPL_ENDOFEXCEPTLIST = "349" 87 | RPL_VERSION = "351" 88 | RPL_WHOREPLY = "352" 89 | RPL_NAMREPLY = "353" 90 | RPL_WHOSPCRPL = "354" 91 | RPL_LINKS = "364" 92 | RPL_ENDOFLINKS = "365" 93 | RPL_ENDOFNAMES = "366" 94 | RPL_BANLIST = "367" 95 | RPL_ENDOFBANLIST = "368" 96 | RPL_ENDOFWHOWAS = "369" 97 | RPL_INFO = "371" 98 | RPL_MOTD = "372" 99 | RPL_ENDOFINFO = "374" 100 | RPL_MOTDSTART = "375" 101 | RPL_ENDOFMOTD = "376" 102 | RPL_WHOISHOST = "378" 103 | RPL_WHOISMODES = "379" 104 | RPL_YOUREOPER = "381" 105 | RPL_REHASHING = "382" 106 | RPL_YOURESERVICE = "383" 107 | RPL_TIME = "391" 108 | RPL_USERSSTART = "392" 109 | RPL_USERS = "393" 110 | RPL_ENDOFUSERS = "394" 111 | RPL_NOUSERS = "395" 112 | ERR_UNKNOWNERROR = "400" 113 | ERR_NOSUCHNICK = "401" 114 | ERR_NOSUCHSERVER = "402" 115 | ERR_NOSUCHCHANNEL = "403" 116 | ERR_CANNOTSENDTOCHAN = "404" 117 | ERR_TOOMANYCHANNELS = "405" 118 | ERR_WASNOSUCHNICK = "406" 119 | ERR_TOOMANYTARGETS = "407" 120 | ERR_NOSUCHSERVICE = "408" 121 | ERR_NOORIGIN = "409" 122 | ERR_INVALIDCAPCMD = "410" 123 | ERR_NORECIPIENT = "411" 124 | ERR_NOTEXTTOSEND = "412" 125 | ERR_NOTOPLEVEL = "413" 126 | ERR_WILDTOPLEVEL = "414" 127 | ERR_BADMASK = "415" 128 | ERR_INPUTTOOLONG = "417" 129 | ERR_UNKNOWNCOMMAND = "421" 130 | ERR_NOMOTD = "422" 131 | ERR_NOADMININFO = "423" 132 | ERR_FILEERROR = "424" 133 | ERR_NONICKNAMEGIVEN = "431" 134 | ERR_ERRONEUSNICKNAME = "432" 135 | ERR_NICKNAMEINUSE = "433" 136 | ERR_NICKCOLLISION = "436" 137 | ERR_UNAVAILRESOURCE = "437" 138 | ERR_REG_UNAVAILABLE = "440" 139 | ERR_USERNOTINCHANNEL = "441" 140 | ERR_NOTONCHANNEL = "442" 141 | ERR_USERONCHANNEL = "443" 142 | ERR_NOLOGIN = "444" 143 | ERR_SUMMONDISABLED = "445" 144 | ERR_USERSDISABLED = "446" 145 | ERR_FORBIDDENCHANNEL = "448" 146 | ERR_NOTREGISTERED = "451" 147 | ERR_NEEDMOREPARAMS = "461" 148 | ERR_ALREADYREGISTRED = "462" 149 | ERR_NOPERMFORHOST = "463" 150 | ERR_PASSWDMISMATCH = "464" 151 | ERR_YOUREBANNEDCREEP = "465" 152 | ERR_YOUWILLBEBANNED = "466" 153 | ERR_KEYSET = "467" 154 | ERR_INVALIDUSERNAME = "468" 155 | ERR_LINKCHANNEL = "470" 156 | ERR_CHANNELISFULL = "471" 157 | ERR_UNKNOWNMODE = "472" 158 | ERR_INVITEONLYCHAN = "473" 159 | ERR_BANNEDFROMCHAN = "474" 160 | ERR_BADCHANNELKEY = "475" 161 | ERR_BADCHANMASK = "476" 162 | ERR_NOCHANMODES = "477" 163 | ERR_NEEDREGGEDNICK = "477" 164 | ERR_BANLISTFULL = "478" 165 | ERR_NOPRIVILEGES = "481" 166 | ERR_CHANOPRIVSNEEDED = "482" 167 | ERR_CANTKILLSERVER = "483" 168 | ERR_RESTRICTED = "484" 169 | ERR_UNIQOPPRIVSNEEDED = "485" 170 | ERR_NOOPERHOST = "491" 171 | ERR_UMODEUNKNOWNFLAG = "501" 172 | ERR_USERSDONTMATCH = "502" 173 | ERR_HELPNOTFOUND = "524" 174 | ERR_INVALIDKEY = "525" 175 | ERR_CANNOTSENDRP = "573" 176 | RPL_WHOISSECURE = "671" 177 | RPL_YOURLANGUAGESARE = "687" 178 | RPL_WHOISLANGUAGE = "690" 179 | ERR_INVALIDMODEPARAM = "696" 180 | RPL_HELPSTART = "704" 181 | RPL_HELPTXT = "705" 182 | RPL_ENDOFHELP = "706" 183 | ERR_NOPRIVS = "723" 184 | RPL_MONONLINE = "730" 185 | RPL_MONOFFLINE = "731" 186 | RPL_MONLIST = "732" 187 | RPL_ENDOFMONLIST = "733" 188 | ERR_MONLISTFULL = "734" 189 | RPL_LOGGEDIN = "900" 190 | RPL_LOGGEDOUT = "901" 191 | ERR_NICKLOCKED = "902" 192 | RPL_SASLSUCCESS = "903" 193 | ERR_SASLFAIL = "904" 194 | ERR_SASLTOOLONG = "905" 195 | ERR_SASLABORTED = "906" 196 | ERR_SASLALREADY = "907" 197 | RPL_SASLMECHS = "908" 198 | RPL_REGISTRATION_SUCCESS = "920" 199 | ERR_ACCOUNT_ALREADY_EXISTS = "921" 200 | ERR_REG_UNSPECIFIED_ERROR = "922" 201 | RPL_VERIFYSUCCESS = "923" 202 | ERR_ACCOUNT_ALREADY_VERIFIED = "924" 203 | ERR_ACCOUNT_INVALID_VERIFY_CODE = "925" 204 | RPL_REG_VERIFICATION_REQUIRED = "927" 205 | ERR_REG_INVALID_CRED_TYPE = "928" 206 | ERR_REG_INVALID_CALLBACK = "929" 207 | ERR_TOOMANYLANGUAGES = "981" 208 | ERR_NOLANGUAGE = "982" 209 | -------------------------------------------------------------------------------- /irctest/runner.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | 4 | class NotImplementedByController(unittest.SkipTest, NotImplementedError): 5 | def __str__(self) -> str: 6 | return "Not implemented by controller: {}".format(self.args[0]) 7 | 8 | 9 | class ImplementationChoice(unittest.SkipTest): 10 | def __str__(self) -> str: 11 | return ( 12 | "Choice in the implementation makes it impossible to " 13 | "perform a test: {}".format(self.args[0]) 14 | ) 15 | 16 | 17 | class OptionalCommandNotSupported(unittest.SkipTest): 18 | def __str__(self) -> str: 19 | return "Unsupported command: {}".format(self.args[0]) 20 | 21 | 22 | class OptionalExtensionNotSupported(unittest.SkipTest): 23 | def __str__(self) -> str: 24 | return "Unsupported extension: {}".format(self.args[0]) 25 | 26 | 27 | class OptionalSaslMechanismNotSupported(unittest.SkipTest): 28 | def __str__(self) -> str: 29 | return "Unsupported SASL mechanism: {}".format(self.args[0]) 30 | 31 | 32 | class CapabilityNotSupported(unittest.SkipTest): 33 | def __str__(self) -> str: 34 | return "Unsupported capability: {}".format(self.args[0]) 35 | 36 | 37 | class IsupportTokenNotSupported(unittest.SkipTest): 38 | def __str__(self) -> str: 39 | return "Unsupported ISUPPORT token: {}".format(self.args[0]) 40 | 41 | 42 | class ChannelModeNotSupported(unittest.SkipTest): 43 | def __str__(self) -> str: 44 | return "Unsupported channel mode: {} ({})".format(self.args[0], self.args[1]) 45 | 46 | 47 | class ExtbanNotSupported(unittest.SkipTest): 48 | def __str__(self) -> str: 49 | return "Unsupported extban: {} ({})".format(self.args[0], self.args[1]) 50 | 51 | 52 | class NotRequiredBySpecifications(unittest.SkipTest): 53 | def __str__(self) -> str: 54 | return "Tests not required by the set of tested specification(s)." 55 | 56 | 57 | class SkipStrictTest(unittest.SkipTest): 58 | def __str__(self) -> str: 59 | return "Tests not required because strict tests are disabled." 60 | -------------------------------------------------------------------------------- /irctest/scram/__init__.py: -------------------------------------------------------------------------------- 1 | from .scram import * 2 | from .exceptions import * 3 | -------------------------------------------------------------------------------- /irctest/scram/core.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | def default_nonce_factory(): 4 | """Generate a random string for digest authentication challenges. 5 | The string should be cryptographicaly secure random pattern. 6 | :return: the string generated. 7 | :returntype: `bytes` 8 | """ 9 | return uuid.uuid4().hex.encode("us-ascii") 10 | -------------------------------------------------------------------------------- /irctest/scram/exceptions.py: -------------------------------------------------------------------------------- 1 | class ScramException(Exception): 2 | pass 3 | 4 | class BadChallengeException(ScramException): 5 | pass 6 | 7 | class ExtraChallengeException(ScramException): 8 | pass 9 | 10 | class ServerScramError(ScramException): 11 | pass 12 | 13 | class BadSuccessException(ScramException): 14 | pass 15 | 16 | class NotAuthorizedException(ScramException): 17 | pass 18 | -------------------------------------------------------------------------------- /irctest/server_tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import unittest 3 | 4 | 5 | def discover(): 6 | ts = unittest.TestSuite() 7 | ts.addTests(unittest.defaultTestLoader.discover(os.path.dirname(__file__))) 8 | return ts 9 | -------------------------------------------------------------------------------- /irctest/server_tests/account_tag.py: -------------------------------------------------------------------------------- 1 | """ 2 | `IRCv3 account-tag `_ 3 | """ 4 | 5 | from irctest import cases 6 | 7 | 8 | @cases.mark_services 9 | class AccountTagTestCase(cases.BaseServerTestCase): 10 | def connectRegisteredClient(self, nick): 11 | self.addClient() 12 | self.sendLine(2, "CAP LS 302") 13 | capabilities = self.getCapLs(2) 14 | assert "sasl" in capabilities 15 | 16 | self.sendLine(2, "USER f * * :Realname") 17 | self.sendLine(2, "NICK {}".format(nick)) 18 | self.sendLine(2, "CAP REQ :sasl") 19 | self.getRegistrationMessage(2) 20 | 21 | self.sendLine(2, "AUTHENTICATE PLAIN") 22 | m = self.getRegistrationMessage(2) 23 | self.assertMessageMatch( 24 | m, 25 | command="AUTHENTICATE", 26 | params=["+"], 27 | fail_msg="Sent “AUTHENTICATE PLAIN”, server should have " 28 | "replied with “AUTHENTICATE +”, but instead sent: {msg}", 29 | ) 30 | self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=") 31 | m = self.getRegistrationMessage(2) 32 | self.assertMessageMatch( 33 | m, 34 | command="900", 35 | fail_msg="Did not send 900 after correct SASL authentication.", 36 | ) 37 | self.sendLine(2, "USER f * * :Realname") 38 | self.sendLine(2, "NICK {}".format(nick)) 39 | self.sendLine(2, "CAP END") 40 | self.skipToWelcome(2) 41 | 42 | @cases.mark_capabilities("account-tag") 43 | @cases.skipUnlessHasMechanism("PLAIN") 44 | def testPrivmsg(self): 45 | self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True) 46 | self.getMessages(1) 47 | self.controller.registerUser(self, "jilles", "sesame") 48 | self.connectRegisteredClient("bar") 49 | self.sendLine(2, "PRIVMSG foo :hi") 50 | self.getMessages(2) 51 | m = self.getMessage(1) 52 | self.assertMessageMatch( 53 | m, command="PRIVMSG", params=["foo", "hi"], tags={"account": "jilles"} 54 | ) 55 | 56 | @cases.mark_capabilities("account-tag") 57 | @cases.skipUnlessHasMechanism("PLAIN") 58 | @cases.xfailIfSoftware( 59 | ["Charybdis"], "https://github.com/solanum-ircd/solanum/issues/166" 60 | ) 61 | def testInvite(self): 62 | self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True) 63 | self.getMessages(1) 64 | self.controller.registerUser(self, "jilles", "sesame") 65 | self.connectRegisteredClient("bar") 66 | self.sendLine(2, "JOIN #chan") 67 | self.getMessages(2) 68 | self.sendLine(2, "INVITE foo #chan") 69 | self.getMessages(2) 70 | m = self.getMessage(1) 71 | self.assertMessageMatch( 72 | m, command="INVITE", params=["foo", "#chan"], tags={"account": "jilles"} 73 | ) 74 | -------------------------------------------------------------------------------- /irctest/server_tests/away_notify.py: -------------------------------------------------------------------------------- 1 | """ 2 | `IRCv3 away-notify `_ 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.numerics import RPL_NOWAWAY, RPL_UNAWAY 7 | from irctest.patma import ANYSTR, StrRe 8 | 9 | 10 | class AwayNotifyTestCase(cases.BaseServerTestCase): 11 | @cases.mark_capabilities("away-notify") 12 | def testAwayNotify(self): 13 | """Basic away-notify test.""" 14 | self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True) 15 | self.getMessages(1) 16 | self.joinChannel(1, "#chan") 17 | 18 | self.connectClient("bar") 19 | self.getMessages(2) 20 | self.joinChannel(2, "#chan") 21 | self.getMessages(2) 22 | self.getMessages(1) 23 | 24 | self.sendLine(2, "AWAY :i'm going away") 25 | self.assertMessageMatch( 26 | self.getMessage(2), command=RPL_NOWAWAY, params=["bar", ANYSTR] 27 | ) 28 | self.assertEqual(self.getMessages(2), []) 29 | 30 | awayNotify = self.getMessage(1) 31 | self.assertMessageMatch( 32 | awayNotify, 33 | prefix=StrRe("bar!.*"), 34 | command="AWAY", 35 | params=["i'm going away"], 36 | ) 37 | 38 | self.sendLine(2, "AWAY") 39 | self.assertMessageMatch( 40 | self.getMessage(2), command=RPL_UNAWAY, params=["bar", ANYSTR] 41 | ) 42 | self.assertEqual(self.getMessages(2), []) 43 | 44 | awayNotify = self.getMessage(1) 45 | self.assertMessageMatch( 46 | awayNotify, prefix=StrRe("bar!.*"), command="AWAY", params=[] 47 | ) 48 | 49 | @cases.mark_capabilities("away-notify") 50 | def testAwayNotifyOnJoin(self): 51 | """The away-notify specification states: 52 | "Clients will be sent an AWAY message [...] when a user joins 53 | and has an away message set." 54 | """ 55 | self.connectClient("foo", capabilities=["away-notify"], skip_if_cap_nak=True) 56 | self.getMessages(1) 57 | self.joinChannel(1, "#chan") 58 | 59 | self.connectClient("bar") 60 | self.getMessages(2) 61 | self.sendLine(2, "AWAY :i'm already away") 62 | self.getMessages(2) 63 | 64 | self.joinChannel(2, "#chan") 65 | self.assertNotIn( 66 | "AWAY", 67 | [m.command for m in self.getMessages(2)], 68 | "joining user got their own away status when they joined", 69 | ) 70 | 71 | messages = [msg for msg in self.getMessages(1) if msg.command == "AWAY"] 72 | self.assertEqual( 73 | len(messages), 74 | 1, 75 | "Someone away joined a channel, " 76 | "but users in the channel did not get AWAY messages.", 77 | ) 78 | awayNotify = messages[0] 79 | self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm already away"]) 80 | self.assertTrue( 81 | awayNotify.prefix.startswith("bar!"), 82 | "Unexpected away-notify source: %s" % (awayNotify.prefix,), 83 | ) 84 | -------------------------------------------------------------------------------- /irctest/server_tests/bot_mode.py: -------------------------------------------------------------------------------- 1 | """ 2 | `IRCv3 bot mode `_ 3 | """ 4 | 5 | from irctest import cases, runner 6 | from irctest.numerics import RPL_WHOISBOT 7 | from irctest.patma import ANYDICT, ANYSTR, StrRe 8 | 9 | 10 | @cases.mark_specifications("IRCv3") 11 | @cases.mark_isupport("BOT") 12 | class BotModeTestCase(cases.BaseServerTestCase): 13 | def setUp(self): 14 | super().setUp() 15 | self.connectClient("modegettr") 16 | if "BOT" not in self.server_support: 17 | raise runner.IsupportTokenNotSupported("BOT") 18 | self._mode_char = self.server_support["BOT"] 19 | 20 | def _initBot(self): 21 | self.assertEqual( 22 | len(self._mode_char), 23 | 1, 24 | fail_msg=( 25 | f"BOT ISUPPORT token should be exactly one character, " 26 | f"but is: {self._mode_char!r}" 27 | ), 28 | ) 29 | 30 | self.connectClient("botnick", "bot") 31 | 32 | self.sendLine("bot", f"MODE botnick +{self._mode_char}") 33 | 34 | # Check echoed mode 35 | while True: 36 | msg = self.getMessage("bot") 37 | if msg.command != "NOTICE": 38 | # Unreal sends the BOTMOTD here 39 | self.assertMessageMatch( 40 | msg, 41 | command="MODE", 42 | params=["botnick", StrRe(r"\+?" + self._mode_char)], 43 | ) 44 | break 45 | 46 | def testBotMode(self): 47 | self._initBot() 48 | 49 | def testBotWhois(self): 50 | self._initBot() 51 | 52 | self.connectClient("usernick", "user") 53 | self.sendLine("user", "WHOIS botnick") 54 | messages = self.getMessages("user") 55 | messages = [msg for msg in messages if msg.command == RPL_WHOISBOT] 56 | self.assertEqual( 57 | len(messages), 58 | 1, 59 | msg=( 60 | f"Expected exactly one RPL_WHOISBOT ({RPL_WHOISBOT}), " 61 | f"got: {messages}" 62 | ), 63 | ) 64 | 65 | (message,) = messages 66 | self.assertMessageMatch( 67 | message, command=RPL_WHOISBOT, params=["usernick", "botnick", ANYSTR] 68 | ) 69 | 70 | @cases.xfailIfSoftware( 71 | ["InspIRCd"], 72 | "Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910", 73 | ) 74 | def testBotPrivateMessage(self): 75 | self._initBot() 76 | 77 | self.connectClient( 78 | "usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True 79 | ) 80 | 81 | self.sendLine("bot", "PRIVMSG usernick :beep boop") 82 | self.getMessages("bot") # Synchronizes 83 | 84 | self.assertMessageMatch( 85 | self.getMessage("user"), 86 | command="PRIVMSG", 87 | params=["usernick", "beep boop"], 88 | tags={StrRe("(draft/)?bot"): None, **ANYDICT}, 89 | ) 90 | 91 | @cases.xfailIfSoftware( 92 | ["InspIRCd"], 93 | "Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910", 94 | ) 95 | def testBotChannelMessage(self): 96 | self._initBot() 97 | 98 | self.connectClient( 99 | "usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True 100 | ) 101 | 102 | self.sendLine("bot", "JOIN #chan") 103 | self.sendLine("user", "JOIN #chan") 104 | self.getMessages("bot") 105 | self.getMessages("user") 106 | 107 | self.sendLine("bot", "PRIVMSG #chan :beep boop") 108 | self.getMessages("bot") # Synchronizes 109 | 110 | self.assertMessageMatch( 111 | self.getMessage("user"), 112 | command="PRIVMSG", 113 | params=["#chan", "beep boop"], 114 | tags={StrRe("(draft/)?bot"): None, **ANYDICT}, 115 | ) 116 | 117 | def testBotWhox(self): 118 | self._initBot() 119 | 120 | self.connectClient( 121 | "usernick", "user", capabilities=["message-tags"], skip_if_cap_nak=True 122 | ) 123 | 124 | self.sendLine("bot", "JOIN #chan") 125 | self.sendLine("user", "JOIN #chan") 126 | self.getMessages("bot") 127 | self.getMessages("user") 128 | 129 | self.sendLine("user", "WHO #chan") 130 | msg1 = self.getMessage("user") 131 | self.assertMessageMatch( 132 | msg1, command="352", fail_msg="Expected WHO response (352), got: {msg}" 133 | ) 134 | msg2 = self.getMessage("user") 135 | self.assertMessageMatch( 136 | msg2, command="352", fail_msg="Expected WHO response (352), got: {msg}" 137 | ) 138 | 139 | if msg1.params[5] == "botnick": 140 | msg = msg1 141 | elif msg2.params[5] == "botnick": 142 | msg = msg2 143 | else: 144 | assert False, "No WHO response contained botnick" 145 | 146 | self.assertMessageMatch( 147 | msg, 148 | command="352", 149 | params=[ 150 | "usernick", 151 | "#chan", 152 | ANYSTR, # ident 153 | ANYSTR, # hostname 154 | ANYSTR, # server 155 | "botnick", 156 | StrRe(f".*{self._mode_char}.*"), 157 | ANYSTR, # realname 158 | ], 159 | fail_msg="Expected WHO response with bot flag, got: {msg}", 160 | ) 161 | -------------------------------------------------------------------------------- /irctest/server_tests/channel.py: -------------------------------------------------------------------------------- 1 | """ 2 | Channel casemapping 3 | """ 4 | 5 | import pytest 6 | 7 | from irctest import cases, client_mock, runner 8 | 9 | 10 | class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase): 11 | @pytest.mark.parametrize( 12 | "casemapping,name1,name2", 13 | [ 14 | ("ascii", "#Foo", "#foo"), 15 | ("rfc1459", "#Foo", "#foo"), 16 | ("rfc1459", "#F]|oo{", "#f}\\oo["), 17 | ("rfc1459", "#F}o\\o[", "#f]o|o{"), 18 | ], 19 | ) 20 | @cases.mark_specifications("RFC1459", "RFC2812", strict=True) 21 | def testChannelsEquivalent(self, casemapping, name1, name2): 22 | self.connectClient("foo") 23 | self.connectClient("bar") 24 | if self.server_support["CASEMAPPING"] != casemapping: 25 | raise runner.ImplementationChoice( 26 | "Casemapping {} not implemented".format(casemapping) 27 | ) 28 | self.joinClient(1, name1) 29 | self.joinClient(2, name2) 30 | try: 31 | m = self.getMessage(1) 32 | self.assertMessageMatch(m, command="JOIN", nick="bar") 33 | except client_mock.NoMessageException: 34 | raise AssertionError( 35 | "Channel names {} and {} are not equivalent.".format(name1, name2) 36 | ) 37 | 38 | @pytest.mark.parametrize( 39 | "casemapping,name1,name2", 40 | [ 41 | ("ascii", "#Foo", "#fooa"), 42 | ("rfc1459", "#Foo", "#fooa"), 43 | ], 44 | ) 45 | @cases.mark_specifications("RFC1459", "RFC2812", strict=True) 46 | def testChannelsNotEquivalent(self, casemapping, name1, name2): 47 | self.connectClient("foo") 48 | self.connectClient("bar") 49 | if self.server_support["CASEMAPPING"] != casemapping: 50 | raise runner.ImplementationChoice( 51 | "Casemapping {} not implemented".format(casemapping) 52 | ) 53 | self.joinClient(1, name1) 54 | self.joinClient(2, name2) 55 | try: 56 | m = self.getMessage(1) 57 | except client_mock.NoMessageException: 58 | pass 59 | else: 60 | self.assertMessageMatch( 61 | m, command="JOIN", nick="bar" 62 | ) # This should always be true 63 | raise AssertionError( 64 | "Channel names {} and {} are equivalent.".format(name1, name2) 65 | ) 66 | -------------------------------------------------------------------------------- /irctest/server_tests/channel_forward.py: -------------------------------------------------------------------------------- 1 | """ 2 | `Ergo `_-specific tests of channel forwarding 3 | 4 | TODO: Should be extended to other servers, once a specification is written. 5 | """ 6 | 7 | from irctest import cases 8 | from irctest.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL 9 | 10 | MODERN_CAPS = [ 11 | "server-time", 12 | "message-tags", 13 | "batch", 14 | "labeled-response", 15 | "echo-message", 16 | "account-tag", 17 | ] 18 | 19 | 20 | class ChannelForwardingTestCase(cases.BaseServerTestCase): 21 | """Test the +f channel forwarding mode.""" 22 | 23 | @cases.mark_specifications("Ergo") 24 | def testChannelForwarding(self): 25 | self.connectClient("bar", name="bar", capabilities=MODERN_CAPS) 26 | self.connectClient("baz", name="baz", capabilities=MODERN_CAPS) 27 | self.joinChannel("bar", "#bar") 28 | self.joinChannel("bar", "#bar_two") 29 | self.joinChannel("baz", "#baz") 30 | 31 | self.sendLine("bar", "MODE #bar +f #nonexistent") 32 | msg = self.getMessage("bar") 33 | self.assertMessageMatch(msg, command=ERR_INVALIDMODEPARAM) 34 | 35 | # need chanops in the target channel as well 36 | self.sendLine("bar", "MODE #bar +f #baz") 37 | responses = set(msg.command for msg in self.getMessages("bar")) 38 | self.assertIn(ERR_CHANOPRIVSNEEDED, responses) 39 | 40 | self.sendLine("bar", "MODE #bar +f #bar_two") 41 | msg = self.getMessage("bar") 42 | self.assertMessageMatch(msg, command="MODE", params=["#bar", "+f", "#bar_two"]) 43 | 44 | # can still join the channel fine 45 | self.joinChannel("baz", "#bar") 46 | self.sendLine("baz", "PART #bar") 47 | self.getMessages("baz") 48 | 49 | # now make it invite-only, which should cause forwarding 50 | self.sendLine("bar", "MODE #bar +i") 51 | self.getMessages("bar") 52 | 53 | self.sendLine("baz", "JOIN #bar") 54 | msgs = self.getMessages("baz") 55 | forward = [msg for msg in msgs if msg.command == ERR_LINKCHANNEL] 56 | self.assertEqual(forward[0].params[:3], ["baz", "#bar", "#bar_two"]) 57 | join = [msg for msg in msgs if msg.command == "JOIN"] 58 | self.assertMessageMatch(join[0], params=["#bar_two"]) 59 | -------------------------------------------------------------------------------- /irctest/server_tests/channel_rename.py: -------------------------------------------------------------------------------- 1 | """ 2 | `Draft IRCv3 channel-rename `_ 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.numerics import ERR_CHANOPRIVSNEEDED 7 | 8 | RENAME_CAP = "draft/channel-rename" 9 | 10 | 11 | @cases.mark_specifications("IRCv3") 12 | class ChannelRenameTestCase(cases.BaseServerTestCase): 13 | """Basic tests for channel-rename.""" 14 | 15 | def testChannelRename(self): 16 | self.connectClient( 17 | "bar", name="bar", capabilities=[RENAME_CAP], skip_if_cap_nak=True 18 | ) 19 | self.connectClient("baz", name="baz") 20 | self.joinChannel("bar", "#bar") 21 | self.joinChannel("baz", "#bar") 22 | self.getMessages("bar") 23 | self.getMessages("baz") 24 | 25 | self.sendLine("bar", "RENAME #bar #qux :no reason") 26 | self.assertMessageMatch( 27 | self.getMessage("bar"), 28 | command="RENAME", 29 | params=["#bar", "#qux", "no reason"], 30 | ) 31 | legacy_responses = self.getMessages("baz") 32 | self.assertEqual( 33 | 1, 34 | len( 35 | [ 36 | msg 37 | for msg in legacy_responses 38 | if msg.command == "PART" and msg.params[0] == "#bar" 39 | ] 40 | ), 41 | ) 42 | self.assertEqual( 43 | 1, 44 | len( 45 | [ 46 | msg 47 | for msg in legacy_responses 48 | if msg.command == "JOIN" and msg.params == ["#qux"] 49 | ] 50 | ), 51 | ) 52 | 53 | self.joinChannel("baz", "#bar") 54 | self.sendLine("baz", "MODE #bar +k beer") 55 | self.assertNotIn( 56 | ERR_CHANOPRIVSNEEDED, [msg.command for msg in self.getMessages("baz")] 57 | ) 58 | -------------------------------------------------------------------------------- /irctest/server_tests/chmodes/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progval/irctest/4e4fd4f1b3e0a16d412a92d4a5f513bbeed64803/irctest/server_tests/chmodes/__init__.py -------------------------------------------------------------------------------- /irctest/server_tests/chmodes/ergo.py: -------------------------------------------------------------------------------- 1 | """ 2 | Various Ergo-specific channel modes 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED 7 | 8 | MODERN_CAPS = [ 9 | "server-time", 10 | "message-tags", 11 | "batch", 12 | "labeled-response", 13 | "echo-message", 14 | "account-tag", 15 | ] 16 | 17 | 18 | @cases.mark_services 19 | class RegisteredOnlySpeakModeTestCase(cases.BaseServerTestCase): 20 | @cases.mark_specifications("Ergo") 21 | def testRegisteredOnlySpeakMode(self): 22 | self.controller.registerUser(self, "evan", "sesame") 23 | 24 | # test the +M (only registered users and ops can speak) channel mode 25 | self.connectClient("chanop", name="chanop") 26 | self.joinChannel("chanop", "#chan") 27 | self.getMessages("chanop") 28 | self.sendLine("chanop", "MODE #chan +M") 29 | replies = self.getMessages("chanop") 30 | modeLines = [line for line in replies if line.command == "MODE"] 31 | self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+M"]) 32 | 33 | self.connectClient("baz", name="baz") 34 | self.joinChannel("baz", "#chan") 35 | self.getMessages("chanop") 36 | # this message should be suppressed completely by +M 37 | self.sendLine("baz", "PRIVMSG #chan :hi from baz") 38 | replies = self.getMessages("baz") 39 | reply_cmds = {reply.command for reply in replies} 40 | self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds) 41 | self.assertEqual(self.getMessages("chanop"), []) 42 | 43 | # +v exempts users from the registration requirement: 44 | self.sendLine("chanop", "MODE #chan +v baz") 45 | self.getMessages("chanop") 46 | self.getMessages("baz") 47 | self.sendLine("baz", "PRIVMSG #chan :hi again from baz") 48 | replies = self.getMessages("baz") 49 | # baz should not receive an error (or an echo) 50 | self.assertEqual(replies, []) 51 | replies = self.getMessages("chanop") 52 | self.assertMessageMatch( 53 | replies[0], command="PRIVMSG", params=["#chan", "hi again from baz"] 54 | ) 55 | 56 | self.connectClient( 57 | "evan", 58 | name="evan", 59 | account="evan", 60 | password="sesame", 61 | capabilities=["sasl"], 62 | ) 63 | self.joinChannel("evan", "#chan") 64 | self.getMessages("baz") 65 | self.sendLine("evan", "PRIVMSG #chan :hi from evan") 66 | replies = self.getMessages("evan") 67 | # evan should not receive an error (or an echo) 68 | self.assertEqual(replies, []) 69 | replies = self.getMessages("baz") 70 | self.assertMessageMatch( 71 | replies[0], command="PRIVMSG", params=["#chan", "hi from evan"] 72 | ) 73 | 74 | 75 | class OpModeratedTestCase(cases.BaseServerTestCase): 76 | @cases.mark_specifications("Ergo") 77 | def testOpModerated(self): 78 | # test the +U channel mode 79 | self.connectClient("chanop", name="chanop", capabilities=MODERN_CAPS) 80 | self.joinChannel("chanop", "#chan") 81 | self.getMessages("chanop") 82 | self.sendLine("chanop", "MODE #chan +U") 83 | replies = {msg.command for msg in self.getMessages("chanop")} 84 | self.assertIn("MODE", replies) 85 | self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies) 86 | 87 | self.connectClient("baz", name="baz", capabilities=MODERN_CAPS) 88 | self.joinChannel("baz", "#chan") 89 | self.sendLine("baz", "PRIVMSG #chan :hi from baz") 90 | echo = self.getMessages("baz")[0] 91 | self.assertMessageMatch( 92 | echo, command="PRIVMSG", params=["#chan", "hi from baz"] 93 | ) 94 | self.assertEqual( 95 | [msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"], 96 | [echo], 97 | ) 98 | 99 | self.connectClient("qux", name="qux", capabilities=MODERN_CAPS) 100 | self.joinChannel("qux", "#chan") 101 | self.sendLine("qux", "PRIVMSG #chan :hi from qux") 102 | echo = self.getMessages("qux")[0] 103 | self.assertMessageMatch( 104 | echo, command="PRIVMSG", params=["#chan", "hi from qux"] 105 | ) 106 | # message is relayed to chanop but not to unprivileged 107 | self.assertEqual( 108 | [msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"], 109 | [echo], 110 | ) 111 | self.assertEqual( 112 | [msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], [] 113 | ) 114 | 115 | self.sendLine("chanop", "MODE #chan +v qux") 116 | self.getMessages("chanop") 117 | self.sendLine("qux", "PRIVMSG #chan :hi again from qux") 118 | echo = [msg for msg in self.getMessages("qux") if msg.command == "PRIVMSG"][0] 119 | self.assertMessageMatch( 120 | echo, command="PRIVMSG", params=["#chan", "hi again from qux"] 121 | ) 122 | self.assertEqual( 123 | [msg for msg in self.getMessages("chanop") if msg.command == "PRIVMSG"], 124 | [echo], 125 | ) 126 | self.assertEqual( 127 | [msg for msg in self.getMessages("baz") if msg.command == "PRIVMSG"], [echo] 128 | ) 129 | -------------------------------------------------------------------------------- /irctest/server_tests/chmodes/modeis.py: -------------------------------------------------------------------------------- 1 | from irctest import cases 2 | from irctest.numerics import RPL_CHANNELCREATED, RPL_CHANNELMODEIS 3 | from irctest.patma import ANYSTR, ListRemainder, StrRe 4 | 5 | 6 | class RplChannelModeIsTestCase(cases.BaseServerTestCase): 7 | @cases.mark_specifications("Modern") 8 | def testChannelModeIs(self): 9 | """Test RPL_CHANNELMODEIS and RPL_CHANNELCREATED as responses to 10 | `MODE #channel`: 11 | 12 | 13 | """ 14 | expected_numerics = {RPL_CHANNELMODEIS, RPL_CHANNELCREATED} 15 | if self.controller.software_name in ("irc2", "Sable"): 16 | # irc2 and Sable don't use timestamps for conflict resolution, 17 | # consequently they don't store the channel creation timestamp 18 | # and don't send RPL_CHANNELCREATED 19 | expected_numerics = {RPL_CHANNELMODEIS} 20 | 21 | self.connectClient("chanop", name="chanop") 22 | self.joinChannel("chanop", "#chan") 23 | # i, n, and t are specified by RFC1459; some of them may be on by default, 24 | # but after this, at least those three should be enabled: 25 | self.sendLine("chanop", "MODE #chan +int") 26 | self.getMessages("chanop") 27 | 28 | self.sendLine("chanop", "MODE #chan") 29 | messages = self.getMessages("chanop") 30 | self.assertEqual(expected_numerics, {msg.command for msg in messages}) 31 | for message in messages: 32 | if message.command == RPL_CHANNELMODEIS: 33 | # the final parameters are the mode string (e.g. `+int`), 34 | # and then optionally any mode parameters (in case the ircd 35 | # lists a mode that takes a parameter) 36 | self.assertMessageMatch( 37 | message, 38 | command=RPL_CHANNELMODEIS, 39 | params=["chanop", "#chan", ListRemainder(ANYSTR, min_length=1)], 40 | ) 41 | final_param = message.params[2] 42 | self.assertEqual(final_param[0], "+") 43 | enabled_modes = list(final_param[1:]) 44 | break 45 | 46 | self.assertLessEqual({"i", "n", "t"}, set(enabled_modes)) 47 | 48 | # remove all the modes listed by RPL_CHANNELMODEIS 49 | self.sendLine("chanop", f"MODE #chan -{''.join(enabled_modes)}") 50 | response = self.getMessage("chanop") 51 | # we should get something like: MODE #chan -int 52 | self.assertMessageMatch( 53 | response, command="MODE", params=["#chan", StrRe("^-.*")] 54 | ) 55 | self.assertEqual(set(response.params[1][1:]), set(enabled_modes)) 56 | 57 | self.sendLine("chanop", "MODE #chan") 58 | messages = self.getMessages("chanop") 59 | self.assertEqual(expected_numerics, {msg.command for msg in messages}) 60 | # all modes have been disabled; the correct representation of this is `+` 61 | for message in messages: 62 | if message.command == RPL_CHANNELMODEIS: 63 | self.assertMessageMatch( 64 | message, 65 | command=RPL_CHANNELMODEIS, 66 | params=["chanop", "#chan", "+"], 67 | ) 68 | -------------------------------------------------------------------------------- /irctest/server_tests/chmodes/moderated.py: -------------------------------------------------------------------------------- 1 | """ 2 | Channel moderation mode (`RFC 2812 3 | `__, 4 | `Modern `__) 5 | """ 6 | 7 | from irctest import cases 8 | from irctest.numerics import ERR_CANNOTSENDTOCHAN 9 | 10 | 11 | class ModeratedModeTestCase(cases.BaseServerTestCase): 12 | @cases.mark_specifications("RFC2812") 13 | def testModeratedMode(self): 14 | # test the +m channel mode 15 | self.connectClient("chanop", name="chanop") 16 | self.joinChannel("chanop", "#chan") 17 | self.getMessages("chanop") 18 | self.sendLine("chanop", "MODE #chan +m") 19 | replies = self.getMessages("chanop") 20 | modeLines = [line for line in replies if line.command == "MODE"] 21 | self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "+m"]) 22 | 23 | self.connectClient("baz", name="baz") 24 | self.joinChannel("baz", "#chan") 25 | self.getMessages("chanop") 26 | # this message should be suppressed completely by +m 27 | self.sendLine("baz", "PRIVMSG #chan :hi from baz") 28 | replies = self.getMessages("baz") 29 | reply_cmds = {reply.command for reply in replies} 30 | self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds) 31 | self.assertEqual(self.getMessages("chanop"), []) 32 | 33 | # grant +v, user should be able to send messages 34 | self.sendLine("chanop", "MODE #chan +v baz") 35 | self.getMessages("chanop") 36 | self.getMessages("baz") 37 | self.sendLine("baz", "PRIVMSG #chan :hi again from baz") 38 | self.getMessages("baz") 39 | relays = self.getMessages("chanop") 40 | relay = relays[0] 41 | self.assertMessageMatch( 42 | relay, command="PRIVMSG", params=["#chan", "hi again from baz"] 43 | ) 44 | -------------------------------------------------------------------------------- /irctest/server_tests/chmodes/no_ctcp.py: -------------------------------------------------------------------------------- 1 | from irctest import cases 2 | from irctest.numerics import ERR_CANNOTSENDTOCHAN 3 | 4 | 5 | class NoCTCPChannelModeTestCase(cases.BaseServerTestCase): 6 | @cases.mark_specifications("Ergo") 7 | def testNoCTCPChannelMode(self): 8 | """Test Ergo's +C channel mode that blocks CTCPs.""" 9 | self.connectClient("bar") 10 | self.joinChannel(1, "#chan") 11 | self.sendLine(1, "MODE #chan +C") 12 | self.getMessages(1) 13 | 14 | self.connectClient("qux") 15 | self.joinChannel(2, "#chan") 16 | self.getMessages(2) 17 | 18 | self.sendLine(1, "PRIVMSG #chan :\x01ACTION hi\x01") 19 | self.getMessages(1) 20 | ms = self.getMessages(2) 21 | self.assertEqual(len(ms), 1) 22 | self.assertMessageMatch( 23 | ms[0], command="PRIVMSG", params=["#chan", "\x01ACTION hi\x01"] 24 | ) 25 | 26 | self.sendLine(1, "PRIVMSG #chan :\x01PING 1473523796 918320\x01") 27 | ms = self.getMessages(1) 28 | self.assertEqual(len(ms), 1) 29 | self.assertMessageMatch(ms[0], command=ERR_CANNOTSENDTOCHAN) 30 | ms = self.getMessages(2) 31 | self.assertEqual(ms, []) 32 | -------------------------------------------------------------------------------- /irctest/server_tests/chmodes/no_external.py: -------------------------------------------------------------------------------- 1 | """ 2 | Channel "no external messages" mode (`RFC 1459 3 | `__, 4 | `Modern `__) 5 | """ 6 | 7 | from irctest import cases 8 | from irctest.numerics import ERR_CANNOTSENDTOCHAN 9 | 10 | 11 | class NoExternalMessagesTestCase(cases.BaseServerTestCase): 12 | @cases.mark_specifications("RFC1459", "Modern") 13 | def testNoExternalMessagesMode(self): 14 | # test the +n channel mode 15 | self.connectClient("chanop", name="chanop") 16 | self.joinChannel("chanop", "#chan") 17 | self.sendLine("chanop", "MODE #chan +n") 18 | self.getMessages("chanop") 19 | 20 | self.connectClient("baz", name="baz") 21 | # this message should be suppressed completely by +n 22 | self.sendLine("baz", "PRIVMSG #chan :hi from baz") 23 | replies = self.getMessages("baz") 24 | reply_cmds = {reply.command for reply in replies} 25 | self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds) 26 | self.assertEqual(self.getMessages("chanop"), []) 27 | 28 | # set the channel to -n: baz should be able to send now 29 | self.sendLine("chanop", "MODE #chan -n") 30 | replies = self.getMessages("chanop") 31 | modeLines = [line for line in replies if line.command == "MODE"] 32 | self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "-n"]) 33 | self.sendLine("baz", "PRIVMSG #chan :hi again from baz") 34 | self.getMessages("baz") 35 | relays = self.getMessages("chanop") 36 | self.assertMessageMatch( 37 | relays[0], command="PRIVMSG", params=["#chan", "hi again from baz"] 38 | ) 39 | -------------------------------------------------------------------------------- /irctest/server_tests/chmodes/secret.py: -------------------------------------------------------------------------------- 1 | """ 2 | Channel secrecy mode (`RFC 1459 3 | `__, 4 | `RFC 2812 `__, 5 | `Modern `__) 6 | """ 7 | 8 | from irctest import cases 9 | from irctest.numerics import RPL_LIST 10 | 11 | 12 | class SecretChannelTestCase(cases.BaseServerTestCase): 13 | @cases.mark_specifications("RFC1459", "Modern") 14 | def testSecretChannelListCommand(self): 15 | """ 16 | 17 | 18 | "Likewise, secret channels are not listed 19 | at all unless the client is a member of the channel in question." 20 | 21 | 22 | "A channel that is set to secret will not show up in responses to 23 | the LIST or NAMES command unless the client sending the command is 24 | joined to the channel." 25 | """ 26 | 27 | def get_listed_channels(replies): 28 | channels = set() 29 | for reply in replies: 30 | # skip pseudo-channels (&SERVER, &NOTICES) listed by ngircd 31 | # and ircu: 32 | if reply.command == RPL_LIST and reply.params[1].startswith("#"): 33 | channels.add(reply.params[1]) 34 | return channels 35 | 36 | # test that a silent channel is shown in list if the user is in the channel. 37 | self.connectClient("first", name="first") 38 | self.joinChannel("first", "#gen") 39 | self.getMessages("first") 40 | self.sendLine("first", "MODE #gen +s") 41 | # run command LIST 42 | self.sendLine("first", "LIST") 43 | replies = self.getMessages("first") 44 | self.assertEqual(get_listed_channels(replies), {"#gen"}) 45 | 46 | # test that another client would not see the secret 47 | # channel. 48 | self.connectClient("second", name="second") 49 | self.getMessages("second") 50 | self.sendLine("second", "LIST") 51 | replies = self.getMessages("second") 52 | # RPL_LIST 322 should NOT be present this time. 53 | self.assertEqual(get_listed_channels(replies), set()) 54 | 55 | # Second client will join the secret channel 56 | # and call command LIST. The channel SHOULD 57 | # appear this time. 58 | self.joinChannel("second", "#gen") 59 | self.sendLine("second", "LIST") 60 | replies = self.getMessages("second") 61 | # Should be only one line with command RPL_LIST 62 | self.assertEqual(get_listed_channels(replies), {"#gen"}) 63 | -------------------------------------------------------------------------------- /irctest/server_tests/confusables.py: -------------------------------------------------------------------------------- 1 | """ 2 | `Ergo `_-specific tests for nick collisions based on Unicode 3 | confusable characters 4 | """ 5 | 6 | from irctest import cases 7 | from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME 8 | 9 | 10 | @cases.mark_services 11 | class ConfusablesTestCase(cases.BaseServerTestCase): 12 | @staticmethod 13 | def config() -> cases.TestCaseControllerConfig: 14 | return cases.TestCaseControllerConfig( 15 | ergo_config=lambda config: config["server"].update( 16 | {"casemapping": "precis"}, 17 | ) 18 | ) 19 | 20 | @cases.mark_specifications("Ergo") 21 | def testConfusableNicks(self): 22 | self.controller.registerUser(self, "evan", "sesame") 23 | 24 | self.addClient(1) 25 | # U+0435 in place of e: 26 | self.sendLine(1, "NICK еvan") 27 | self.sendLine(1, "USER a 0 * a") 28 | messages = self.getMessages(1) 29 | commands = set(msg.command for msg in messages) 30 | self.assertNotIn(RPL_WELCOME, commands) 31 | self.assertIn(ERR_NICKNAMEINUSE, commands) 32 | 33 | self.connectClient( 34 | "evan", name="evan", password="sesame", capabilities=["sasl"] 35 | ) 36 | # should be able to switch to the confusable nick 37 | self.sendLine("evan", "NICK еvan") 38 | messages = self.getMessages("evan") 39 | commands = set(msg.command for msg in messages) 40 | self.assertIn("NICK", commands) 41 | -------------------------------------------------------------------------------- /irctest/server_tests/echo_message.py: -------------------------------------------------------------------------------- 1 | """ 2 | `IRCv3 echo-message `_ 3 | """ 4 | 5 | import pytest 6 | 7 | from irctest import cases 8 | from irctest.irc_utils.junkdrawer import random_name 9 | from irctest.patma import ANYDICT 10 | 11 | 12 | class EchoMessageTestCase(cases.BaseServerTestCase): 13 | @pytest.mark.parametrize( 14 | "command,solo,server_time", 15 | [ 16 | ("PRIVMSG", False, False), 17 | ("PRIVMSG", True, True), 18 | ("PRIVMSG", False, True), 19 | ("NOTICE", False, True), 20 | ], 21 | ) 22 | @cases.mark_capabilities("echo-message") 23 | def testEchoMessage(self, command, solo, server_time): 24 | """""" 25 | capabilities = ["server-time"] if server_time else [] 26 | 27 | self.connectClient( 28 | "baz", 29 | capabilities=["echo-message", *capabilities], 30 | skip_if_cap_nak=True, 31 | ) 32 | 33 | self.sendLine(1, "JOIN #chan") 34 | 35 | # Synchronize 36 | self.getMessages(1) 37 | 38 | if not solo: 39 | self.connectClient("qux", capabilities=capabilities) 40 | self.sendLine(2, "JOIN #chan") 41 | 42 | # Synchronize and clean 43 | self.getMessages(1) 44 | if not solo: 45 | self.getMessages(2) 46 | self.getMessages(1) 47 | 48 | self.sendLine(1, "{} #chan :hello everyone".format(command)) 49 | m1 = self.getMessage(1) 50 | self.assertMessageMatch( 51 | m1, 52 | command=command, 53 | params=["#chan", "hello everyone"], 54 | fail_msg="Did not echo “{} #chan :hello everyone”: {msg}", 55 | extra_format=(command,), 56 | ) 57 | 58 | if not solo: 59 | m2 = self.getMessage(2) 60 | self.assertMessageMatch( 61 | m2, 62 | command=command, 63 | params=["#chan", "hello everyone"], 64 | fail_msg="Did not propagate “{} #chan :hello everyone”: " 65 | "after echoing it to the author: {msg}", 66 | extra_format=(command,), 67 | ) 68 | self.assertEqual( 69 | m1.params, 70 | m2.params, 71 | fail_msg="Parameters of forwarded and echoed " "messages differ: {} {}", 72 | extra_format=(m1, m2), 73 | ) 74 | if server_time: 75 | self.assertIn( 76 | "time", 77 | m1.tags, 78 | fail_msg="Echoed message is missing server time: {}", 79 | extra_format=(m1,), 80 | ) 81 | self.assertIn( 82 | "time", 83 | m2.tags, 84 | fail_msg="Forwarded message is missing server time: {}", 85 | extra_format=(m2,), 86 | ) 87 | 88 | @pytest.mark.arbitrary_client_tags 89 | @cases.mark_capabilities( 90 | "batch", "labeled-response", "echo-message", "message-tags" 91 | ) 92 | def testDirectMessageEcho(self): 93 | bar = random_name("bar") 94 | self.connectClient( 95 | bar, 96 | name=bar, 97 | capabilities=["batch", "labeled-response", "echo-message", "message-tags"], 98 | skip_if_cap_nak=True, 99 | ) 100 | self.getMessages(bar) 101 | 102 | qux = random_name("qux") 103 | self.connectClient( 104 | qux, 105 | name=qux, 106 | capabilities=["batch", "labeled-response", "echo-message", "message-tags"], 107 | ) 108 | self.getMessages(qux) 109 | 110 | self.sendLine( 111 | bar, 112 | "@label=xyz;+example-client-tag=example-value PRIVMSG %s :hi there" 113 | % (qux,), 114 | ) 115 | echo = self.getMessages(bar)[0] 116 | delivery = self.getMessages(qux)[0] 117 | 118 | self.assertMessageMatch( 119 | echo, 120 | command="PRIVMSG", 121 | params=[qux, "hi there"], 122 | tags={"label": "xyz", "+example-client-tag": "example-value", **ANYDICT}, 123 | ) 124 | self.assertMessageMatch( 125 | delivery, 126 | command="PRIVMSG", 127 | params=[qux, "hi there"], 128 | tags={"+example-client-tag": "example-value", **ANYDICT}, 129 | ) 130 | 131 | # Either both messages have a msgid, or neither does 132 | self.assertEqual(delivery.tags.get("msgid"), echo.tags.get("msgid")) 133 | -------------------------------------------------------------------------------- /irctest/server_tests/ergo/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/progval/irctest/4e4fd4f1b3e0a16d412a92d4a5f513bbeed64803/irctest/server_tests/ergo/__init__.py -------------------------------------------------------------------------------- /irctest/server_tests/ergo/services.py: -------------------------------------------------------------------------------- 1 | """ 2 | `Ergo `-specific tests of NickServ. 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.numerics import RPL_YOUREOPER 7 | 8 | 9 | class NickservTestCase(cases.BaseServerTestCase): 10 | @cases.mark_specifications("Ergo") 11 | def test_saregister(self): 12 | self.connectClient("root", name="root") 13 | self.sendLine("root", "OPER operuser operpassword") 14 | self.assertIn(RPL_YOUREOPER, {msg.command for msg in self.getMessages("root")}) 15 | 16 | self.sendLine( 17 | "root", 18 | "PRIVMSG NickServ :SAREGISTER saregister_test saregistertestpassphrase", 19 | ) 20 | self.getMessages("root") 21 | 22 | # test that the account was registered 23 | self.connectClient( 24 | name="saregister_test", 25 | nick="saregister_test", 26 | capabilities=["sasl"], 27 | account="saregister_test", 28 | password="saregistertestpassphrase", 29 | ) 30 | -------------------------------------------------------------------------------- /irctest/server_tests/extended_join.py: -------------------------------------------------------------------------------- 1 | """ 2 | `IRCv3 extended-join `_ 3 | """ 4 | 5 | from irctest import cases 6 | 7 | 8 | @cases.mark_services 9 | class MetadataTestCase(cases.BaseServerTestCase): 10 | def connectRegisteredClient(self, nick): 11 | self.addClient() 12 | self.sendLine(2, "CAP LS 302") 13 | capabilities = self.getCapLs(2) 14 | assert "sasl" in capabilities 15 | self.requestCapabilities(2, ["sasl"], skip_if_cap_nak=False) 16 | self.sendLine(2, "AUTHENTICATE PLAIN") 17 | m = self.getRegistrationMessage(2) 18 | self.assertMessageMatch( 19 | m, 20 | command="AUTHENTICATE", 21 | params=["+"], 22 | fail_msg="Sent “AUTHENTICATE PLAIN”, server should have " 23 | "replied with “AUTHENTICATE +”, but instead sent: {msg}", 24 | ) 25 | self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=") 26 | m = self.getRegistrationMessage(2) 27 | self.assertMessageMatch( 28 | m, 29 | command="900", 30 | fail_msg="Did not send 900 after correct SASL authentication.", 31 | ) 32 | self.sendLine(2, "USER f * * :Realname") 33 | self.sendLine(2, "NICK {}".format(nick)) 34 | self.sendLine(2, "CAP END") 35 | self.skipToWelcome(2) 36 | 37 | @cases.mark_capabilities("extended-join") 38 | def testNotLoggedIn(self): 39 | self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True) 40 | self.joinChannel(1, "#chan") 41 | self.connectClient("bar") 42 | self.joinChannel(2, "#chan") 43 | m = self.getMessage(1) 44 | self.assertMessageMatch( 45 | m, 46 | command="JOIN", 47 | params=["#chan", "*", "Realname"], 48 | fail_msg="Expected “JOIN #chan * :Realname” after " 49 | "unregistered user joined, got: {msg}", 50 | ) 51 | 52 | @cases.mark_capabilities("extended-join") 53 | @cases.skipUnlessHasMechanism("PLAIN") 54 | def testLoggedIn(self): 55 | self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True) 56 | self.joinChannel(1, "#chan") 57 | 58 | self.controller.registerUser(self, "jilles", "sesame") 59 | self.connectRegisteredClient("bar") 60 | self.joinChannel(2, "#chan") 61 | 62 | m = self.getMessage(1) 63 | self.assertMessageMatch( 64 | m, 65 | command="JOIN", 66 | params=["#chan", "jilles", "Realname"], 67 | fail_msg="Expected “JOIN #chan * :Realname” after " 68 | "nick “bar” logged in as “jilles” joined, got: {msg}", 69 | ) 70 | -------------------------------------------------------------------------------- /irctest/server_tests/help.py: -------------------------------------------------------------------------------- 1 | """ 2 | The HELP and HELPOP command (`Modern `__) 3 | """ 4 | 5 | import functools 6 | import re 7 | 8 | import pytest 9 | 10 | from irctest import cases, runner 11 | from irctest.numerics import ( 12 | ERR_HELPNOTFOUND, 13 | ERR_UNKNOWNCOMMAND, 14 | RPL_ENDOFHELP, 15 | RPL_HELPSTART, 16 | RPL_HELPTXT, 17 | ) 18 | from irctest.patma import ANYSTR, StrRe 19 | 20 | 21 | def with_xfails(f): 22 | @functools.wraps(f) 23 | def newf(self, command, *args, **kwargs): 24 | if command == "HELP" and self.controller.software_name == "Bahamut": 25 | raise runner.ImplementationChoice( 26 | "fail because Bahamut forwards /HELP to HelpServ (but not /HELPOP)" 27 | ) 28 | 29 | if self.controller.software_name in ("irc2", "ircu2", "ngIRCd"): 30 | raise runner.ImplementationChoice( 31 | "numerics in reply to /HELP and /HELPOP (uses NOTICE instead)" 32 | ) 33 | 34 | if self.controller.software_name == "UnrealIRCd": 35 | raise runner.ImplementationChoice( 36 | "fails because Unreal uses custom numerics " 37 | "https://github.com/unrealircd/unrealircd/pull/184" 38 | ) 39 | 40 | return f(self, command, *args, **kwargs) 41 | 42 | return newf 43 | 44 | 45 | class HelpTestCase(cases.BaseServerTestCase): 46 | def _assertValidHelp(self, messages, subject): 47 | if subject != ANYSTR: 48 | subject = StrRe("(?i)" + re.escape(subject)) 49 | 50 | self.assertMessageMatch( 51 | messages[0], 52 | command=RPL_HELPSTART, 53 | params=["nick", subject, ANYSTR], 54 | fail_msg=f"Expected {RPL_HELPSTART} (RPL_HELPSTART), got: {{msg}}", 55 | ) 56 | 57 | self.assertMessageMatch( 58 | messages[-1], 59 | command=RPL_ENDOFHELP, 60 | params=["nick", subject, ANYSTR], 61 | fail_msg=f"Expected {RPL_ENDOFHELP} (RPL_ENDOFHELP), got: {{msg}}", 62 | ) 63 | 64 | for i in range(1, len(messages) - 1): 65 | self.assertMessageMatch( 66 | messages[i], 67 | command=RPL_HELPTXT, 68 | params=["nick", subject, ANYSTR], 69 | fail_msg=f"Expected {RPL_HELPTXT} (RPL_HELPTXT), got: {{msg}}", 70 | ) 71 | 72 | @pytest.mark.parametrize("command", ["HELP", "HELPOP"]) 73 | @cases.mark_specifications("Modern") 74 | @with_xfails 75 | def testHelpNoArg(self, command): 76 | self.connectClient("nick") 77 | self.sendLine(1, f"{command}") 78 | 79 | messages = self.getMessages(1) 80 | 81 | if messages[0].command == ERR_UNKNOWNCOMMAND: 82 | raise runner.OptionalCommandNotSupported(command) 83 | 84 | self._assertValidHelp(messages, ANYSTR) 85 | 86 | @pytest.mark.parametrize("command", ["HELP", "HELPOP"]) 87 | @cases.mark_specifications("Modern") 88 | @with_xfails 89 | def testHelpPrivmsg(self, command): 90 | self.connectClient("nick") 91 | self.sendLine(1, f"{command} PRIVMSG") 92 | messages = self.getMessages(1) 93 | 94 | if messages[0].command == ERR_UNKNOWNCOMMAND: 95 | raise runner.OptionalCommandNotSupported(command) 96 | 97 | self._assertValidHelp(messages, "PRIVMSG") 98 | 99 | @pytest.mark.parametrize("command", ["HELP", "HELPOP"]) 100 | @cases.mark_specifications("Modern") 101 | @with_xfails 102 | def testHelpUnknownSubject(self, command): 103 | self.connectClient("nick") 104 | self.sendLine(1, f"{command} THISISNOTACOMMAND") 105 | messages = self.getMessages(1) 106 | 107 | if messages[0].command == ERR_UNKNOWNCOMMAND: 108 | raise runner.OptionalCommandNotSupported(command) 109 | 110 | if messages[0].command == ERR_HELPNOTFOUND: 111 | # Inspircd, Hybrid et al 112 | self.assertEqual(len(messages), 1) 113 | self.assertMessageMatch( 114 | messages[0], 115 | command=ERR_HELPNOTFOUND, 116 | params=[ 117 | "nick", 118 | StrRe( 119 | "(?i)THISISNOTACOMMAND" 120 | ), # case-insensitive, for Hybrid and Plexus4 (but not Chary et al) 121 | ANYSTR, 122 | ], 123 | ) 124 | else: 125 | # Unrealircd 126 | self._assertValidHelp(messages, ANYSTR) 127 | -------------------------------------------------------------------------------- /irctest/server_tests/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | The INFO command (`RFC 1459 3 | `__, 4 | `RFC 2812 `__, 5 | `Modern `__) 6 | """ 7 | 8 | import pytest 9 | 10 | from irctest import cases 11 | from irctest.numerics import ERR_NOSUCHSERVER, RPL_ENDOFINFO, RPL_INFO, RPL_YOUREOPER 12 | from irctest.patma import ANYSTR 13 | 14 | 15 | class InfoTestCase(cases.BaseServerTestCase): 16 | @cases.mark_specifications("RFC1459", "RFC2812", "Modern") 17 | def testInfo(self): 18 | """ 19 | 20 | 21 | 22 | "Upon receiving an INFO command, the given server will respond with zero or 23 | more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric" 24 | -- 25 | """ 26 | self.connectClient("nick") 27 | 28 | # Remote /INFO is oper-only on Unreal and ircu2 29 | self.sendLine(1, "OPER operuser operpassword") 30 | self.assertIn( 31 | RPL_YOUREOPER, 32 | [m.command for m in self.getMessages(1)], 33 | fail_msg="OPER failed", 34 | ) 35 | 36 | self.sendLine(1, "INFO") 37 | 38 | messages = self.getMessages(1) 39 | last_message = messages.pop() 40 | 41 | self.assertMessageMatch( 42 | last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR] 43 | ) 44 | 45 | for message in messages: 46 | self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR]) 47 | 48 | @pytest.mark.parametrize( 49 | "target", 50 | ["My.Little.Server", "*Little*", "nick"], 51 | ids=["target-server", "target-wildcard", "target-nick"], 52 | ) 53 | @cases.mark_specifications("RFC1459", "RFC2812", deprecated=True) 54 | def testInfoTarget(self, target): 55 | """ 56 | 57 | 58 | 59 | "Upon receiving an INFO command, the given server will respond with zero or 60 | more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric" 61 | -- 62 | """ 63 | self.connectClient("nick") 64 | 65 | # Remote /INFO is oper-only on Unreal and ircu2 66 | self.sendLine(1, "OPER operuser operpassword") 67 | self.assertIn( 68 | RPL_YOUREOPER, 69 | [m.command for m in self.getMessages(1)], 70 | fail_msg="OPER failed", 71 | ) 72 | 73 | if target: 74 | self.sendLine(1, "INFO My.Little.Server") 75 | else: 76 | self.sendLine(1, "INFO") 77 | 78 | messages = self.getMessages(1) 79 | last_message = messages.pop() 80 | 81 | self.assertMessageMatch( 82 | last_message, command=RPL_ENDOFINFO, params=["nick", ANYSTR] 83 | ) 84 | 85 | for message in messages: 86 | self.assertMessageMatch(message, command=RPL_INFO, params=["nick", ANYSTR]) 87 | 88 | @pytest.mark.parametrize("target", ["invalid.server.example", "invalidserver"]) 89 | @cases.mark_specifications("RFC1459", "RFC2812", deprecated=True) 90 | @cases.xfailIfSoftware( 91 | ["Ergo"], "does not apply to Ergo, which ignores the optional argument" 92 | ) 93 | def testInfoNosuchserver(self, target): 94 | """ 95 | 96 | 97 | 98 | "Upon receiving an INFO command, the given server will respond with zero or 99 | more RPL_INFO replies, followed by one RPL_ENDOFINFO numeric" 100 | -- 101 | """ 102 | self.connectClient("nick") 103 | 104 | # Remote /INFO is oper-only on Unreal and ircu2 105 | self.sendLine(1, "OPER operuser operpassword") 106 | self.assertIn( 107 | RPL_YOUREOPER, 108 | [m.command for m in self.getMessages(1)], 109 | fail_msg="OPER failed", 110 | ) 111 | 112 | self.sendLine(1, f"INFO {target}") 113 | 114 | self.assertMessageMatch( 115 | self.getMessage(1), 116 | command=ERR_NOSUCHSERVER, 117 | params=["nick", target, ANYSTR], 118 | ) 119 | -------------------------------------------------------------------------------- /irctest/server_tests/isupport.py: -------------------------------------------------------------------------------- 1 | """ 2 | RPL_ISUPPORT: `format `__ 3 | and various `tokens `__ 4 | """ 5 | 6 | import re 7 | 8 | from irctest import cases, runner 9 | 10 | 11 | class IsupportTestCase(cases.BaseServerTestCase): 12 | @cases.mark_specifications("Modern") 13 | @cases.mark_isupport("PREFIX") 14 | def testParameters(self): 15 | """https://modern.ircdocs.horse/#rplisupport-005""" 16 | 17 | # 18 | # "Upon successful completion of the registration process, 19 | # the server MUST send, in this order: 20 | # [...] 21 | # 5. at least one RPL_ISUPPORT (005) numeric to the client." 22 | welcome_005s = [ 23 | msg for msg in self.connectClient("foo") if msg.command == "005" 24 | ] 25 | self.assertGreaterEqual(len(welcome_005s), 1) 26 | for msg in welcome_005s: 27 | # first parameter is the client's nickname; 28 | # last parameter is a human-readable trailing, typically 29 | # "are supported by this server" 30 | self.assertGreaterEqual(len(msg.params), 3) 31 | self.assertEqual(msg.params[0], "foo") 32 | # "As the maximum number of message parameters to any reply is 15, 33 | # the maximum number of RPL_ISUPPORT tokens that can be advertised 34 | # is 13." 35 | self.assertLessEqual(len(msg.params), 15) 36 | for param in msg.params[1:-1]: 37 | self.validateIsupportParam(param) 38 | 39 | def validateIsupportParam(self, param): 40 | if not param.isascii(): 41 | raise ValueError("Invalid non-ASCII 005 parameter", param) 42 | # TODO add more validation 43 | 44 | @cases.mark_specifications("Modern") 45 | @cases.mark_isupport("PREFIX") 46 | def testPrefix(self): 47 | """https://modern.ircdocs.horse/#prefix-parameter""" 48 | self.connectClient("foo") 49 | 50 | if "PREFIX" not in self.server_support: 51 | raise runner.IsupportTokenNotSupported("PREFIX") 52 | 53 | if self.server_support["PREFIX"] == "": 54 | # "The value is OPTIONAL and when it is not specified indicates that no 55 | # prefixes are supported." 56 | return 57 | 58 | m = re.match( 59 | r"^\((?P[a-zA-Z]+)\)(?P\S+)$", 60 | self.server_support["PREFIX"], 61 | ) 62 | self.assertTrue( 63 | m, 64 | f"PREFIX={self.server_support['PREFIX']} does not have the expected " 65 | f"format.", 66 | ) 67 | 68 | modes = m.group("modes") 69 | prefixes = m.group("prefixes") 70 | 71 | # "There is a one-to-one mapping between prefixes and channel modes." 72 | self.assertEqual( 73 | len(modes), len(prefixes), "Mismatched length of prefix and channel modes." 74 | ) 75 | 76 | # "The prefixes in this parameter are in descending order, from the prefix 77 | # that gives the most privileges to the prefix that gives the least." 78 | self.assertLess(modes.index("o"), modes.index("v"), "'o' is not before 'v'") 79 | if "h" in modes: 80 | self.assertLess(modes.index("o"), modes.index("h"), "'o' is not before 'h'") 81 | self.assertLess(modes.index("h"), modes.index("v"), "'h' is not before 'v'") 82 | if "q" in modes: 83 | self.assertLess(modes.index("q"), modes.index("o"), "'q' is not before 'o'") 84 | 85 | # Not technically in the spec, but it would be very confusing not to follow 86 | # these conventions. 87 | mode_to_prefix = dict(zip(modes, prefixes)) 88 | self.assertEqual(mode_to_prefix["o"], "@", "Prefix char for mode +o is not @") 89 | self.assertEqual(mode_to_prefix["v"], "+", "Prefix char for mode +v is not +") 90 | if "h" in modes: 91 | self.assertEqual( 92 | mode_to_prefix["h"], "%", "Prefix char for mode +h is not %" 93 | ) 94 | if "q" in modes: 95 | self.assertEqual( 96 | mode_to_prefix["q"], "~", "Prefix char for mode +q is not ~" 97 | ) 98 | if "a" in modes: 99 | self.assertEqual( 100 | mode_to_prefix["a"], "&", "Prefix char for mode +a is not &" 101 | ) 102 | 103 | @cases.mark_specifications("Modern", "ircdocs") 104 | @cases.mark_isupport("TARGMAX") 105 | def testTargmax(self): 106 | """ 107 | "Format: TARGMAX=[:[limit]{,:[limit]}]" 108 | -- https://modern.ircdocs.horse/#targmax-parameter 109 | 110 | "TARGMAX=[cmd:[number][,cmd:[number][,...]]]" 111 | -- https://defs.ircdocs.horse/defs/isupport.html#targmax 112 | """ 113 | self.connectClient("foo") 114 | 115 | if "TARGMAX" not in self.server_support: 116 | raise runner.IsupportTokenNotSupported("TARGMAX") 117 | 118 | parts = self.server_support["TARGMAX"].split(",") 119 | for part in parts: 120 | self.assertTrue( 121 | re.match("^[A-Z]+:[0-9]*$", part), "Invalid TARGMAX key:value: %r", part 122 | ) 123 | -------------------------------------------------------------------------------- /irctest/server_tests/kill.py: -------------------------------------------------------------------------------- 1 | """ 2 | The KILL command (`Modern `__) 3 | """ 4 | 5 | 6 | from irctest import cases 7 | from irctest.numerics import ERR_NOPRIVILEGES, RPL_YOUREOPER 8 | 9 | 10 | class KillTestCase(cases.BaseServerTestCase): 11 | @cases.mark_specifications("Modern") 12 | @cases.xfailIfSoftware(["Sable"], "https://github.com/Libera-Chat/sable/issues/154") 13 | def testKill(self): 14 | self.connectClient("ircop", name="ircop") 15 | self.connectClient("alice", name="alice") 16 | self.connectClient("bob", name="bob") 17 | 18 | self.sendLine("ircop", "OPER operuser operpassword") 19 | self.assertIn( 20 | RPL_YOUREOPER, 21 | [m.command for m in self.getMessages("ircop")], 22 | fail_msg="OPER failed", 23 | ) 24 | 25 | self.sendLine("alice", "KILL bob :some arbitrary reason") 26 | self.assertIn( 27 | ERR_NOPRIVILEGES, 28 | [m.command for m in self.getMessages("alice")], 29 | fail_msg="unprivileged KILL not rejected", 30 | ) 31 | # bob is not killed 32 | self.getMessages("bob") 33 | 34 | self.sendLine("alice", "KILL alice :some arbitrary reason") 35 | self.assertIn( 36 | ERR_NOPRIVILEGES, 37 | [m.command for m in self.getMessages("alice")], 38 | fail_msg="unprivileged KILL not rejected", 39 | ) 40 | # alice is not killed 41 | self.getMessages("alice") 42 | 43 | # privileged KILL should succeed 44 | self.sendLine("ircop", "KILL alice :some arbitrary reason") 45 | self.getMessages("ircop") 46 | self.assertDisconnected("alice") 47 | 48 | self.sendLine("ircop", "KILL bob :some arbitrary reason") 49 | self.getMessages("ircop") 50 | self.assertDisconnected("bob") 51 | 52 | @cases.mark_specifications("Ergo") 53 | def testKillOneArgument(self): 54 | self.connectClient("ircop", name="ircop") 55 | self.connectClient("alice", name="alice") 56 | 57 | self.sendLine("ircop", "OPER operuser operpassword") 58 | self.assertIn( 59 | RPL_YOUREOPER, 60 | [m.command for m in self.getMessages("ircop")], 61 | fail_msg="OPER failed", 62 | ) 63 | 64 | # 1-argument kill command, accepted by Ergo and some implementations 65 | self.sendLine("ircop", "KILL alice") 66 | self.getMessages("ircop") 67 | self.assertDisconnected("alice") 68 | -------------------------------------------------------------------------------- /irctest/server_tests/links.py: -------------------------------------------------------------------------------- 1 | from irctest import cases, runner 2 | from irctest.numerics import ERR_UNKNOWNCOMMAND, RPL_ENDOFLINKS, RPL_LINKS 3 | from irctest.patma import ANYSTR, StrRe 4 | 5 | 6 | def _server_info_regexp(case: cases.BaseServerTestCase) -> str: 7 | if case.controller.software_name == "Sable": 8 | return ".+" 9 | else: 10 | return "test server" 11 | 12 | 13 | class LinksTestCase(cases.BaseServerTestCase): 14 | @cases.mark_specifications("RFC1459", "RFC2812", "Modern") 15 | def testLinksSingleServer(self): 16 | """ 17 | Only testing the parameter-less case. 18 | 19 | https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.3 20 | https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.5 21 | https://github.com/ircdocs/modern-irc/pull/175 22 | 23 | " 24 | 364 RPL_LINKS 25 | " : " 26 | 365 RPL_ENDOFLINKS 27 | " :End of /LINKS list" 28 | 29 | - In replying to the LINKS message, a server must send 30 | replies back using the RPL_LINKS numeric and mark the 31 | end of the list using an RPL_ENDOFLINKS reply. 32 | " 33 | -- https://datatracker.ietf.org/doc/html/rfc1459#page-51 34 | -- https://datatracker.ietf.org/doc/html/rfc2812#page-48 35 | 36 | RPL_LINKS: " * : " 37 | RPL_ENDOFLINKS: " * :End of /LINKS list" 38 | -- https://github.com/ircdocs/modern-irc/pull/175/files 39 | """ 40 | self.connectClient("nick") 41 | self.sendLine(1, "LINKS") 42 | messages = self.getMessages(1) 43 | if messages[0].command == ERR_UNKNOWNCOMMAND: 44 | raise runner.OptionalCommandNotSupported("LINKS") 45 | 46 | # Ignore '/LINKS has been disabled' from ircu2 47 | messages = [m for m in messages if m.command != "NOTICE"] 48 | 49 | self.assertMessageMatch( 50 | messages.pop(-1), 51 | command=RPL_ENDOFLINKS, 52 | params=["nick", "*", ANYSTR], 53 | ) 54 | 55 | if not messages: 56 | # This server probably redacts links 57 | return 58 | 59 | self.assertMessageMatch( 60 | messages[0], 61 | command=RPL_LINKS, 62 | params=[ 63 | "nick", 64 | "My.Little.Server", 65 | "My.Little.Server", 66 | StrRe(f"0 (0042 )?{_server_info_regexp(self)}"), 67 | ], 68 | ) 69 | 70 | 71 | @cases.mark_services 72 | class ServicesLinksTestCase(cases.BaseServerTestCase): 73 | # On every IRCd but Ergo, services are linked. 74 | # Ergo does not implement LINKS at all, so this test is skipped. 75 | @cases.mark_specifications("RFC1459", "RFC2812", "Modern") 76 | def testLinksWithServices(self): 77 | """ 78 | Only testing the parameter-less case. 79 | 80 | https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.3 81 | https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.5 82 | 83 | " 84 | 364 RPL_LINKS 85 | " : " 86 | 365 RPL_ENDOFLINKS 87 | " :End of /LINKS list" 88 | 89 | - In replying to the LINKS message, a server must send 90 | replies back using the RPL_LINKS numeric and mark the 91 | end of the list using an RPL_ENDOFLINKS reply. 92 | " 93 | -- https://datatracker.ietf.org/doc/html/rfc1459#page-51 94 | -- https://datatracker.ietf.org/doc/html/rfc2812#page-48 95 | 96 | RPL_LINKS: " * : " 97 | RPL_ENDOFLINKS: " * :End of /LINKS list" 98 | -- https://github.com/ircdocs/modern-irc/pull/175/files 99 | """ 100 | self.connectClient("nick") 101 | self.sendLine(1, "LINKS") 102 | messages = self.getMessages(1) 103 | 104 | if messages[0].command == ERR_UNKNOWNCOMMAND: 105 | raise runner.OptionalCommandNotSupported("LINKS") 106 | 107 | # Ignore '/LINKS has been disabled' from ircu2 108 | messages = [m for m in messages if m.command != "NOTICE"] 109 | 110 | self.assertMessageMatch( 111 | messages.pop(-1), 112 | command=RPL_ENDOFLINKS, 113 | params=["nick", "*", ANYSTR], 114 | ) 115 | 116 | if not messages: 117 | # This server redacts links 118 | return 119 | 120 | messages.sort(key=lambda m: tuple(m.params)) 121 | 122 | self.assertMessageMatch( 123 | messages.pop(0), 124 | command=RPL_LINKS, 125 | params=[ 126 | "nick", 127 | "My.Little.Server", 128 | "My.Little.Server", 129 | StrRe(f"0 (0042 )?{_server_info_regexp(self)}"), 130 | ], 131 | ) 132 | self.assertMessageMatch( 133 | messages.pop(0), 134 | command=RPL_LINKS, 135 | params=[ 136 | "nick", 137 | "My.Little.Services", 138 | "My.Little.Server", 139 | StrRe("[01] .+"), # SID instead of description for Anope... 140 | ], 141 | ) 142 | 143 | self.assertEqual(messages, []) 144 | -------------------------------------------------------------------------------- /irctest/server_tests/multi_prefix.py: -------------------------------------------------------------------------------- 1 | """ 2 | `IRCv3 multi-prefix `_ 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.patma import ANYSTR 7 | 8 | 9 | class MultiPrefixTestCase(cases.BaseServerTestCase): 10 | @cases.mark_capabilities("multi-prefix") 11 | def testMultiPrefix(self): 12 | """“When requested, the multi-prefix client capability will cause the 13 | IRC server to send all possible prefixes which apply to a user in NAMES 14 | and WHO output. 15 | 16 | These prefixes MUST be in order of ‘rank’, from highest to lowest. 17 | """ 18 | self.connectClient("foo", capabilities=["multi-prefix"], skip_if_cap_nak=True) 19 | self.joinChannel(1, "#chan") 20 | self.sendLine(1, "MODE #chan +v foo") 21 | self.getMessages(1) 22 | 23 | # TODO(dan): Make sure +v is voice 24 | 25 | self.sendLine(1, "NAMES #chan") 26 | reply = self.getMessage(1) 27 | self.assertMessageMatch( 28 | reply, 29 | command="353", 30 | params=["foo", ANYSTR, "#chan", "@+foo"], 31 | fail_msg="Expected NAMES response (353) with @+foo, got: {msg}", 32 | ) 33 | self.getMessages(1) 34 | 35 | self.sendLine(1, "WHO #chan") 36 | msg = self.getMessage(1) 37 | self.assertEqual( 38 | msg.command, "352", msg, fail_msg="Expected WHO response (352), got: {msg}" 39 | ) 40 | self.assertGreaterEqual( 41 | len(msg.params), 42 | 8, 43 | "Expected WHO response (352) with 8 params, got: {msg}".format(msg=msg), 44 | ) 45 | self.assertIn( 46 | "@+", 47 | msg.params[6], 48 | 'Expected WHO response (352) with "@+" in param 7, got: {msg}'.format( 49 | msg=msg 50 | ), 51 | ) 52 | 53 | @cases.xfailIfSoftware( 54 | ["irc2", "Bahamut"], "irc2 and Bahamut send a trailing space" 55 | ) 56 | def testNoMultiPrefix(self): 57 | """When not requested, only the highest prefix should be sent""" 58 | self.connectClient("foo") 59 | self.joinChannel(1, "#chan") 60 | self.sendLine(1, "MODE #chan +v foo") 61 | self.getMessages(1) 62 | 63 | # TODO(dan): Make sure +v is voice 64 | 65 | self.sendLine(1, "NAMES #chan") 66 | reply = self.getMessage(1) 67 | self.assertMessageMatch( 68 | reply, 69 | command="353", 70 | params=["foo", ANYSTR, "#chan", "@foo"], 71 | fail_msg="Expected NAMES response (353) with @foo, got: {msg}", 72 | ) 73 | self.getMessages(1) 74 | 75 | self.sendLine(1, "WHO #chan") 76 | msg = self.getMessage(1) 77 | self.assertEqual( 78 | msg.command, "352", msg, fail_msg="Expected WHO response (352), got: {msg}" 79 | ) 80 | self.assertGreaterEqual( 81 | len(msg.params), 82 | 8, 83 | "Expected WHO response (352) with 8 params, got: {msg}".format(msg=msg), 84 | ) 85 | self.assertIn( 86 | "@", 87 | msg.params[6], 88 | 'Expected WHO response (352) with "@" in param 7, got: {msg}'.format( 89 | msg=msg 90 | ), 91 | ) 92 | self.assertNotIn( 93 | "+", 94 | msg.params[6], 95 | 'Expected WHO response (352) with no "+" in param 7, got: {msg}'.format( 96 | msg=msg 97 | ), 98 | ) 99 | -------------------------------------------------------------------------------- /irctest/server_tests/pingpong.py: -------------------------------------------------------------------------------- 1 | """ 2 | The PING and PONG commands 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_NOORIGIN 7 | from irctest.patma import ANYSTR 8 | 9 | 10 | class PingPongTestCase(cases.BaseServerTestCase): 11 | @cases.mark_specifications("Modern") 12 | def testPing(self): 13 | """https://github.com/ircdocs/modern-irc/pull/99""" 14 | self.connectClient("foo") 15 | self.sendLine(1, "PING abcdef") 16 | self.assertMessageMatch( 17 | self.getMessage(1), command="PONG", params=["My.Little.Server", "abcdef"] 18 | ) 19 | 20 | @cases.mark_specifications("Modern") 21 | def testPingNoToken(self): 22 | """https://github.com/ircdocs/modern-irc/pull/99""" 23 | self.connectClient("foo") 24 | self.sendLine(1, "PING") 25 | m = self.getMessage(1) 26 | if m.command == ERR_NOORIGIN: 27 | self.assertMessageMatch(m, command=ERR_NOORIGIN, params=["foo", ANYSTR]) 28 | else: 29 | self.assertMessageMatch( 30 | m, command=ERR_NEEDMOREPARAMS, params=["foo", "PING", ANYSTR] 31 | ) 32 | 33 | @cases.mark_specifications("Modern") 34 | def testPingEmptyToken(self): 35 | """https://github.com/ircdocs/modern-irc/pull/99""" 36 | self.connectClient("foo") 37 | self.sendLine(1, "PING :") 38 | m = self.getMessage(1) 39 | if m.command == "PONG": 40 | self.assertMessageMatch(m, command="PONG", params=["My.Little.Server", ""]) 41 | elif m.command == ERR_NOORIGIN: 42 | self.assertMessageMatch(m, command=ERR_NOORIGIN, params=["foo", ANYSTR]) 43 | else: 44 | self.assertMessageMatch( 45 | m, command=ERR_NEEDMOREPARAMS, params=["foo", "PING", ANYSTR] 46 | ) 47 | -------------------------------------------------------------------------------- /irctest/server_tests/quit.py: -------------------------------------------------------------------------------- 1 | """ 2 | The QUITcommand (`RFC 1459 3 | `__, 4 | `RFC 2812 `__, 5 | `Modern `__) 6 | 7 | TODO: cross-reference RFC 1459 and Modern 8 | """ 9 | 10 | import time 11 | 12 | from irctest import cases 13 | from irctest.patma import StrRe 14 | 15 | 16 | class ChannelQuitTestCase(cases.BaseServerTestCase): 17 | @cases.mark_specifications("RFC2812") 18 | @cases.xfailIfSoftware(["ircu2", "Nefarious", "snircd"], "ircu2 does not echo QUIT") 19 | def testQuit(self): 20 | """“Once a user has joined a channel, he receives information about 21 | all commands his server receives affecting the channel. This 22 | includes [...] QUIT” 23 | 24 | """ 25 | self.connectClient("bar") 26 | self.joinChannel(1, "#chan") 27 | self.connectClient("qux") 28 | self.sendLine(2, "JOIN #chan") 29 | self.getMessages(2) 30 | 31 | self.getMessages(1) 32 | 33 | # Despite `anti_spam_exit_message_time = 0`, hybrid does not immediately 34 | # allow custom PART reasons. 35 | time.sleep(1) 36 | 37 | self.sendLine(2, "QUIT :qux out") 38 | self.getMessages(2) 39 | m = self.getMessage(1) 40 | self.assertMessageMatch(m, command="QUIT", params=[StrRe(".*qux out.*")]) 41 | self.assertTrue(m.prefix.startswith("qux")) # nickmask of quitter 42 | -------------------------------------------------------------------------------- /irctest/server_tests/readq.py: -------------------------------------------------------------------------------- 1 | """ 2 | `Ergo `_-specific tests of responses to DoS attacks 3 | using long lines. 4 | """ 5 | 6 | from irctest import cases 7 | 8 | 9 | class ReadqTestCase(cases.BaseServerTestCase): 10 | @cases.mark_specifications("Ergo") 11 | @cases.mark_capabilities("message-tags") 12 | def testReadqTags(self): 13 | self.connectClient("mallory", name="mallory", capabilities=["message-tags"]) 14 | self.joinChannel("mallory", "#test") 15 | self.sendLine("mallory", "PRIVMSG #test " + "a" * 16384) 16 | self.assertDisconnected("mallory") 17 | 18 | @cases.mark_specifications("Ergo") 19 | def testReadqNoTags(self): 20 | self.connectClient("mallory", name="mallory") 21 | self.joinChannel("mallory", "#test") 22 | self.sendLine("mallory", "PRIVMSG #test " + "a" * 16384) 23 | self.assertDisconnected("mallory") 24 | -------------------------------------------------------------------------------- /irctest/server_tests/relaymsg.py: -------------------------------------------------------------------------------- 1 | """ 2 | RELAYMSG command of `Ergo `_ 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.irc_utils.junkdrawer import random_name 7 | from irctest.patma import ANYSTR 8 | from irctest.server_tests.chathistory import CHATHISTORY_CAP, EVENT_PLAYBACK_CAP 9 | 10 | RELAYMSG_CAP = "draft/relaymsg" 11 | RELAYMSG_TAG_NAME = "draft/relaymsg" 12 | 13 | 14 | class RelaymsgTestCase(cases.BaseServerTestCase): 15 | @staticmethod 16 | def config() -> cases.TestCaseControllerConfig: 17 | return cases.TestCaseControllerConfig(chathistory=True) 18 | 19 | @cases.mark_specifications("Ergo") 20 | def testRelaymsg(self): 21 | self.connectClient( 22 | "baz", 23 | name="baz", 24 | capabilities=[ 25 | "batch", 26 | "labeled-response", 27 | "echo-message", 28 | CHATHISTORY_CAP, 29 | EVENT_PLAYBACK_CAP, 30 | ], 31 | ) 32 | self.connectClient( 33 | "qux", 34 | name="qux", 35 | capabilities=[ 36 | "batch", 37 | "labeled-response", 38 | "echo-message", 39 | CHATHISTORY_CAP, 40 | EVENT_PLAYBACK_CAP, 41 | ], 42 | ) 43 | chname = random_name("#relaymsg") 44 | self.joinChannel("baz", chname) 45 | self.joinChannel("qux", chname) 46 | self.getMessages("baz") 47 | self.getMessages("qux") 48 | 49 | self.sendLine("baz", "RELAYMSG %s invalid!nick/discord hi" % (chname,)) 50 | self.assertMessageMatch( 51 | self.getMessages("baz")[0], 52 | command="FAIL", 53 | params=["RELAYMSG", "INVALID_NICK", ANYSTR], 54 | ) 55 | 56 | self.sendLine("baz", "RELAYMSG %s regular_nick hi" % (chname,)) 57 | self.assertMessageMatch( 58 | self.getMessages("baz")[0], 59 | command="FAIL", 60 | params=["RELAYMSG", "INVALID_NICK", ANYSTR], 61 | ) 62 | 63 | self.sendLine("baz", "RELAYMSG %s smt/discord hi" % (chname,)) 64 | response = self.getMessages("baz")[0] 65 | self.assertMessageMatch( 66 | response, nick="smt/discord", command="PRIVMSG", params=[chname, "hi"] 67 | ) 68 | relayed_msg = self.getMessages("qux")[0] 69 | self.assertMessageMatch( 70 | relayed_msg, nick="smt/discord", command="PRIVMSG", params=[chname, "hi"] 71 | ) 72 | 73 | # labeled-response 74 | self.sendLine("baz", "@label=x RELAYMSG %s smt/discord :hi again" % (chname,)) 75 | response = self.getMessages("baz")[0] 76 | self.assertMessageMatch( 77 | response, 78 | nick="smt/discord", 79 | command="PRIVMSG", 80 | params=[chname, "hi again"], 81 | tags={"label": "x"}, 82 | ) 83 | relayed_msg = self.getMessages("qux")[0] 84 | self.assertMessageMatch( 85 | relayed_msg, 86 | nick="smt/discord", 87 | command="PRIVMSG", 88 | params=[chname, "hi again"], 89 | ) 90 | 91 | self.sendLine("qux", "RELAYMSG %s smt/discord :hi a third time" % (chname,)) 92 | self.assertMessageMatch( 93 | self.getMessages("qux")[0], 94 | command="FAIL", 95 | params=["RELAYMSG", "PRIVS_NEEDED", ANYSTR], 96 | ) 97 | 98 | # grant qux chanop, allowing relaymsg 99 | self.sendLine("baz", "MODE %s +o qux" % (chname,)) 100 | self.getMessages("baz") 101 | self.getMessages("qux") 102 | # give baz the relaymsg cap 103 | self.sendLine("baz", "CAP REQ %s" % (RELAYMSG_CAP)) 104 | self.assertMessageMatch( 105 | self.getMessages("baz")[0], 106 | command="CAP", 107 | params=["baz", "ACK", RELAYMSG_CAP], 108 | ) 109 | 110 | self.sendLine("qux", "RELAYMSG %s smt/discord :hi a third time" % (chname,)) 111 | response = self.getMessages("qux")[0] 112 | self.assertMessageMatch( 113 | response, 114 | nick="smt/discord", 115 | command="PRIVMSG", 116 | params=[chname, "hi a third time"], 117 | ) 118 | relayed_msg = self.getMessages("baz")[0] 119 | self.assertMessageMatch( 120 | relayed_msg, 121 | nick="smt/discord", 122 | command="PRIVMSG", 123 | params=[chname, "hi a third time"], 124 | tags={RELAYMSG_TAG_NAME: "qux"}, 125 | ) 126 | 127 | self.sendLine("baz", "CHATHISTORY LATEST %s * 10" % (chname,)) 128 | messages = self.getMessages("baz") 129 | self.assertEqual( 130 | [msg.params[-1] for msg in messages if msg.command == "PRIVMSG"], 131 | ["hi", "hi again", "hi a third time"], 132 | ) 133 | -------------------------------------------------------------------------------- /irctest/server_tests/roleplay.py: -------------------------------------------------------------------------------- 1 | """ 2 | Roleplay features of `Ergo `_ 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.irc_utils.junkdrawer import random_name 7 | from irctest.numerics import ERR_CANNOTSENDRP 8 | from irctest.patma import StrRe 9 | 10 | 11 | class RoleplayTestCase(cases.BaseServerTestCase): 12 | @staticmethod 13 | def config() -> cases.TestCaseControllerConfig: 14 | return cases.TestCaseControllerConfig(ergo_roleplay=True) 15 | 16 | @cases.mark_specifications("Ergo") 17 | def testRoleplay(self): 18 | bar = random_name("bar") 19 | qux = random_name("qux") 20 | chan = random_name("#chan") 21 | self.connectClient( 22 | bar, 23 | name=bar, 24 | capabilities=["batch", "labeled-response", "message-tags", "server-time"], 25 | ) 26 | self.connectClient( 27 | qux, 28 | name=qux, 29 | capabilities=["batch", "labeled-response", "message-tags", "server-time"], 30 | ) 31 | self.joinChannel(bar, chan) 32 | self.joinChannel(qux, chan) 33 | self.getMessages(bar) 34 | 35 | # roleplay should be forbidden because we aren't +E yet 36 | self.sendLine(bar, "NPC %s bilbo too much bread" % (chan,)) 37 | reply = self.getMessages(bar)[0] 38 | self.assertEqual(reply.command, ERR_CANNOTSENDRP) 39 | 40 | self.sendLine(bar, "MODE %s +E" % (chan,)) 41 | reply = self.getMessages(bar)[0] 42 | self.assertEqual(reply.command, "MODE") 43 | self.assertMessageMatch(reply, command="MODE", params=[chan, "+E"]) 44 | self.getMessages(qux) 45 | 46 | self.sendLine(bar, "NPC %s bilbo too much bread" % (chan,)) 47 | reply = self.getMessages(bar)[0] 48 | self.assertMessageMatch( 49 | reply, command="PRIVMSG", params=[chan, StrRe(".*too much bread.*")] 50 | ) 51 | self.assertTrue(reply.prefix.startswith("*bilbo*!")) 52 | 53 | reply = self.getMessages(qux)[0] 54 | self.assertMessageMatch( 55 | reply, command="PRIVMSG", params=[chan, StrRe(".*too much bread.*")] 56 | ) 57 | self.assertTrue(reply.prefix.startswith("*bilbo*!")) 58 | 59 | self.sendLine(bar, "SCENE %s dark and stormy night" % (chan,)) 60 | reply = self.getMessages(bar)[0] 61 | self.assertMessageMatch( 62 | reply, command="PRIVMSG", params=[chan, StrRe(".*dark and stormy night.*")] 63 | ) 64 | self.assertTrue(reply.prefix.startswith("=Scene=!")) 65 | 66 | reply = self.getMessages(qux)[0] 67 | self.assertMessageMatch( 68 | reply, command="PRIVMSG", params=[chan, StrRe(".*dark and stormy night.*")] 69 | ) 70 | self.assertTrue(reply.prefix.startswith("=Scene=!")) 71 | 72 | # test history storage 73 | self.sendLine(qux, "CHATHISTORY LATEST %s * 10" % (chan,)) 74 | reply = [ 75 | msg 76 | for msg in self.getMessages(qux) 77 | if msg.command == "PRIVMSG" and "bilbo" in msg.prefix 78 | ][0] 79 | self.assertMessageMatch( 80 | reply, command="PRIVMSG", params=[chan, StrRe(".*too much bread.*")] 81 | ) 82 | self.assertTrue(reply.prefix.startswith("*bilbo*!")) 83 | -------------------------------------------------------------------------------- /irctest/server_tests/setname.py: -------------------------------------------------------------------------------- 1 | """ 2 | `IRCv3 SETNAME`_ 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.numerics import RPL_WHOISUSER 7 | 8 | 9 | class SetnameMessageTestCase(cases.BaseServerTestCase): 10 | @cases.mark_specifications("IRCv3") 11 | @cases.mark_capabilities("setname") 12 | def testSetnameMessage(self): 13 | self.connectClient("foo", capabilities=["setname"], skip_if_cap_nak=True) 14 | 15 | self.sendLine(1, "SETNAME bar") 16 | self.assertMessageMatch( 17 | self.getMessage(1), 18 | command="SETNAME", 19 | params=["bar"], 20 | ) 21 | 22 | self.sendLine(1, "WHOIS foo") 23 | whoisuser = [m for m in self.getMessages(1) if m.command == RPL_WHOISUSER][0] 24 | self.assertEqual(whoisuser.params[-1], "bar") 25 | 26 | @cases.mark_specifications("IRCv3") 27 | @cases.mark_capabilities("setname") 28 | def testSetnameChannel(self): 29 | """“[Servers] MUST send the server-to-client version of the 30 | SETNAME message to all clients in common channels, as well as 31 | to the client from which it originated, to confirm the change 32 | has occurred. 33 | 34 | The SETNAME message MUST NOT be sent to clients which do not 35 | have the setname capability negotiated.“ 36 | """ 37 | self.connectClient("foo", capabilities=["setname"], skip_if_cap_nak=True) 38 | self.connectClient("bar", capabilities=["setname"], skip_if_cap_nak=True) 39 | self.connectClient("baz") 40 | 41 | self.joinChannel(1, "#chan") 42 | self.joinChannel(2, "#chan") 43 | self.joinChannel(3, "#chan") 44 | self.getMessages(1) 45 | self.getMessages(2) 46 | self.getMessages(3) 47 | 48 | self.sendLine(1, "SETNAME qux") 49 | self.assertMessageMatch( 50 | self.getMessage(1), 51 | command="SETNAME", 52 | params=["qux"], 53 | ) 54 | 55 | self.assertMessageMatch( 56 | self.getMessage(2), 57 | command="SETNAME", 58 | params=["qux"], 59 | ) 60 | 61 | self.assertEqual( 62 | self.getMessages(3), 63 | [], 64 | "Got SETNAME response when it was not negotiated", 65 | ) 66 | -------------------------------------------------------------------------------- /irctest/server_tests/statusmsg.py: -------------------------------------------------------------------------------- 1 | """ 2 | STATUSMSG ISUPPORT token and related PRIVMSG (`Modern 3 | `__) 4 | 5 | TODO: cross-reference Modern 6 | """ 7 | 8 | from irctest import cases, runner 9 | from irctest.numerics import RPL_NAMREPLY 10 | 11 | 12 | class StatusmsgTestCase(cases.BaseServerTestCase): 13 | @cases.mark_specifications("Ergo") 14 | def testInIsupport(self): 15 | """Check that the expected STATUSMSG parameter appears in our isupport list.""" 16 | self.connectClient("foo") # detects ISUPPORT 17 | self.assertEqual(self.server_support["STATUSMSG"], "~&@%+") 18 | 19 | @cases.mark_isupport("STATUSMSG") 20 | @cases.xfailIfSoftware( 21 | ["ircu2", "Nefarious", "snircd"], 22 | "STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG " 23 | "target (only for WALLCOPS/WALLCHOPS/...)", 24 | ) 25 | def testStatusmsgFromOp(self): 26 | """Test that STATUSMSG are sent to the intended recipients, 27 | with the intended prefixes.""" 28 | self.connectClient("chanop") 29 | self.joinChannel(1, "#chan") 30 | self.getMessages(1) 31 | 32 | if "@" not in self.server_support.get("STATUSMSG", ""): 33 | raise runner.IsupportTokenNotSupported("STATUSMSG") 34 | 35 | self.connectClient("joe") 36 | self.joinChannel(2, "#chan") 37 | self.getMessages(2) 38 | 39 | self.connectClient("schmoe") 40 | self.sendLine(3, "join #chan") 41 | 42 | messages = self.getMessages(3) 43 | names = set() 44 | for message in messages: 45 | if message.command == RPL_NAMREPLY: 46 | names.update(set(message.params[-1].split())) 47 | # chanop should be opped 48 | self.assertEqual( 49 | names, {"@chanop", "joe", "schmoe"}, f"unexpected names: {names}" 50 | ) 51 | 52 | self.sendLine(1, "MODE #chan +o schmoe") 53 | self.getMessages(1) 54 | 55 | self.getMessages(3) 56 | self.sendLine(3, "privmsg @#chan :this message is for operators") 57 | self.assertEqual( 58 | self.getMessages(3), 59 | [], 60 | fail_msg="PRIVMSG @#chan from channel op was refused", 61 | ) 62 | 63 | # check the operator's messages 64 | statusMsg = self.getMessage(1, filter_pred=lambda m: m.command == "PRIVMSG") 65 | self.assertMessageMatch( 66 | statusMsg, params=["@#chan", "this message is for operators"] 67 | ) 68 | 69 | # check the non-operator's messages 70 | unprivilegedMessages = [ 71 | msg for msg in self.getMessages(2) if msg.command == "PRIVMSG" 72 | ] 73 | self.assertEqual(len(unprivilegedMessages), 0) 74 | 75 | @cases.mark_isupport("STATUSMSG") 76 | @cases.xfailIfSoftware( 77 | ["ircu2", "Nefarious", "snircd"], 78 | "STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG " 79 | "target (only for WALLCOPS/WALLCHOPS/...)", 80 | ) 81 | def testStatusmsgFromRegular(self): 82 | """Test that STATUSMSG are sent to the intended recipients, 83 | with the intended prefixes.""" 84 | self.connectClient("chanop") 85 | self.joinChannel(1, "#chan") 86 | self.getMessages(1) 87 | 88 | if "@" not in self.server_support.get("STATUSMSG", ""): 89 | raise runner.IsupportTokenNotSupported("STATUSMSG") 90 | 91 | self.connectClient("joe") 92 | self.joinChannel(2, "#chan") 93 | self.getMessages(2) 94 | 95 | self.connectClient("schmoe") 96 | self.sendLine(3, "join #chan") 97 | messages = self.getMessages(3) 98 | names = set() 99 | for message in messages: 100 | if message.command == RPL_NAMREPLY: 101 | names.update(set(message.params[-1].split())) 102 | # chanop should be opped 103 | self.assertEqual( 104 | names, {"@chanop", "joe", "schmoe"}, f"unexpected names: {names}" 105 | ) 106 | 107 | self.getMessages(3) 108 | self.sendLine(3, "privmsg @#chan :this message is for operators") 109 | if self.getMessages(3) != []: 110 | raise runner.ImplementationChoice( 111 | "Regular users can not send PRIVMSG @#chan" 112 | ) 113 | 114 | # check the operator's messages 115 | statusMsg = self.getMessage(1, filter_pred=lambda m: m.command == "PRIVMSG") 116 | self.assertMessageMatch( 117 | statusMsg, params=["@#chan", "this message is for operators"] 118 | ) 119 | 120 | # check the non-operator's messages 121 | unprivilegedMessages = [ 122 | msg for msg in self.getMessages(2) if msg.command == "PRIVMSG" 123 | ] 124 | self.assertEqual(len(unprivilegedMessages), 0) 125 | -------------------------------------------------------------------------------- /irctest/server_tests/time.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | 4 | from irctest import cases 5 | from irctest.numerics import RPL_TIME 6 | from irctest.patma import ANYSTR, StrRe 7 | 8 | 9 | class TimeTestCase(cases.BaseServerTestCase): 10 | def testTime(self): 11 | self.connectClient("user") 12 | 13 | time_before = math.floor(time.time()) 14 | self.sendLine(1, "TIME") 15 | 16 | msg = self.getMessage(1) 17 | 18 | time_after = math.ceil(time.time()) 19 | 20 | if len(msg.params) == 5: 21 | # ircu2, snircd 22 | self.assertMessageMatch( 23 | msg, 24 | command=RPL_TIME, 25 | params=["user", "My.Little.Server", StrRe("[0-9]+"), "0", ANYSTR], 26 | ) 27 | self.assertIn( 28 | int(msg.params[2]), 29 | range(time_before, time_after + 1), 30 | "Timestamp not in expected range", 31 | ) 32 | elif len(msg.params) == 4: 33 | # bahamut 34 | self.assertMessageMatch( 35 | msg, 36 | command=RPL_TIME, 37 | params=["user", "My.Little.Server", StrRe("[0-9]+"), ANYSTR], 38 | ) 39 | self.assertIn( 40 | int(msg.params[2]), 41 | range(time_before, time_after + 1), 42 | "Timestamp not in expected range", 43 | ) 44 | else: 45 | # Common case 46 | self.assertMessageMatch( 47 | msg, command=RPL_TIME, params=["user", "My.Little.Server", ANYSTR] 48 | ) 49 | -------------------------------------------------------------------------------- /irctest/server_tests/umodes/registeredonly.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the registered-only DM user mode (commonly +R). 3 | """ 4 | 5 | from irctest import cases 6 | from irctest.numerics import ERR_NEEDREGGEDNICK 7 | 8 | 9 | @cases.mark_services 10 | class RegisteredOnlyUmodeTestCase(cases.BaseServerTestCase): 11 | @cases.mark_specifications("Ergo") 12 | def testRegisteredOnlyUserMode(self): 13 | """Test the +R registered-only mode.""" 14 | self.controller.registerUser(self, "evan", "sesame") 15 | self.controller.registerUser(self, "carmen", "pink") 16 | 17 | self.connectClient( 18 | "evan", 19 | name="evan", 20 | account="evan", 21 | password="sesame", 22 | capabilities=["sasl"], 23 | ) 24 | self.connectClient("shivaram", name="shivaram") 25 | self.sendLine("evan", "MODE evan +R") 26 | self.assertMessageMatch( 27 | self.getMessage("evan"), command="MODE", params=["evan", "+R"] 28 | ) 29 | 30 | # this DM should be blocked by +R registered-only 31 | self.getMessages("shivaram") 32 | self.sendLine("shivaram", "PRIVMSG evan :hey there") 33 | self.assertMessageMatch( 34 | self.getMessage("shivaram"), 35 | command=ERR_NEEDREGGEDNICK, 36 | ) 37 | self.assertEqual(self.getMessages("evan"), []) 38 | 39 | self.connectClient( 40 | "carmen", 41 | name="carmen", 42 | account="carmen", 43 | password="pink", 44 | capabilities=["sasl"], 45 | ) 46 | self.getMessages("evan") 47 | self.sendLine("carmen", "PRIVMSG evan :hey there") 48 | self.assertEqual(self.getMessages("carmen"), []) 49 | # this message should go through fine: 50 | self.assertMessageMatch( 51 | self.getMessage("evan"), 52 | command="PRIVMSG", 53 | params=["evan", "hey there"], 54 | ) 55 | 56 | @cases.mark_specifications("Ergo") 57 | def testRegisteredOnlyUserModeAcceptCommand(self): 58 | """Test that the ACCEPT command can authorize another user 59 | to send the accept-er direct messages, overriding the 60 | +R registered-only mode.""" 61 | self.controller.registerUser(self, "evan", "sesame") 62 | self.connectClient( 63 | "evan", 64 | name="evan", 65 | account="evan", 66 | password="sesame", 67 | capabilities=["sasl"], 68 | ) 69 | self.connectClient("shivaram", name="shivaram") 70 | self.sendLine("evan", "MODE evan +R") 71 | self.assertMessageMatch( 72 | self.getMessage("evan"), command="MODE", params=["evan", "+R"] 73 | ) 74 | self.sendLine("evan", "ACCEPT shivaram") 75 | self.getMessages("evan") 76 | 77 | self.sendLine("shivaram", "PRIVMSG evan :hey there") 78 | self.assertEqual(self.getMessages("shivaram"), []) 79 | self.assertMessageMatch( 80 | self.getMessage("evan"), 81 | command="PRIVMSG", 82 | params=["evan", "hey there"], 83 | ) 84 | 85 | self.sendLine("evan", "ACCEPT -shivaram") 86 | self.getMessages("evan") 87 | self.sendLine("shivaram", "PRIVMSG evan :how's it going") 88 | self.assertMessageMatch( 89 | self.getMessage("shivaram"), 90 | command=ERR_NEEDREGGEDNICK, 91 | ) 92 | self.assertEqual(self.getMessages("evan"), []) 93 | 94 | @cases.mark_specifications("Ergo") 95 | def testRegisteredOnlyUserModeAutoAcceptOnDM(self): 96 | """Test that sending someone a DM automatically authorizes them to 97 | reply, overriding the +R registered-only mode.""" 98 | self.controller.registerUser(self, "evan", "sesame") 99 | self.connectClient( 100 | "evan", 101 | name="evan", 102 | account="evan", 103 | password="sesame", 104 | capabilities=["sasl"], 105 | ) 106 | self.connectClient("shivaram", name="shivaram") 107 | self.sendLine("evan", "MODE evan +R") 108 | self.assertMessageMatch( 109 | self.getMessage("evan"), command="MODE", params=["evan", "+R"] 110 | ) 111 | self.sendLine("evan", "PRIVMSG shivaram :hey there") 112 | self.getMessages("evan") 113 | self.assertMessageMatch( 114 | self.getMessage("shivaram"), 115 | command="PRIVMSG", 116 | params=["shivaram", "hey there"], 117 | ) 118 | self.sendLine("shivaram", "PRIVMSG evan :how's it going") 119 | self.assertEqual(self.getMessages("shivaram"), []) 120 | self.assertMessageMatch( 121 | self.getMessage("evan"), 122 | command="PRIVMSG", 123 | params=["evan", "how's it going"], 124 | ) 125 | -------------------------------------------------------------------------------- /irctest/server_tests/utf8.py: -------------------------------------------------------------------------------- 1 | """ 2 | `Ergo `_-specific tests of non-Unicode filtering 3 | 4 | `_ 5 | """ 6 | 7 | from irctest import cases, runner 8 | from irctest.numerics import ERR_ERRONEUSNICKNAME 9 | from irctest.patma import ANYSTR 10 | 11 | 12 | class Utf8TestCase(cases.BaseServerTestCase): 13 | @cases.mark_specifications("Ergo") 14 | def testNonUtf8Filtering(self): 15 | self.connectClient( 16 | "bar", 17 | capabilities=["batch", "echo-message", "labeled-response"], 18 | ) 19 | self.joinChannel(1, "#qux") 20 | self.sendLine(1, b"@label=xyz PRIVMSG #qux hi\xaa") 21 | self.assertMessageMatch( 22 | self.getMessage(1), 23 | command="FAIL", 24 | params=["PRIVMSG", "INVALID_UTF8", ANYSTR], 25 | tags={"label": "xyz"}, 26 | ) 27 | 28 | @cases.mark_isupport("UTF8ONLY") 29 | def testUtf8Validation(self): 30 | self.connectClient("foo") 31 | self.connectClient("bar") 32 | 33 | if "UTF8ONLY" not in self.server_support: 34 | raise runner.IsupportTokenNotSupported("UTF8ONLY") 35 | 36 | self.sendLine(1, "PRIVMSG bar hi") 37 | self.getMessages(1) # synchronize 38 | ms = self.getMessages(2) 39 | self.assertMessageMatch( 40 | [m for m in ms if m.command == "PRIVMSG"][0], params=["bar", "hi"] 41 | ) 42 | 43 | self.sendLine(1, b"PRIVMSG bar hi\xaa") 44 | 45 | m = self.getMessage(1) 46 | assert m.command in ("FAIL", "WARN", "ERROR") 47 | 48 | if m.command in ("FAIL", "WARN"): 49 | self.assertMessageMatch(m, params=["PRIVMSG", "INVALID_UTF8", ANYSTR]) 50 | 51 | def testNonutf8Realname(self): 52 | self.connectClient("foo") 53 | if "UTF8ONLY" not in self.server_support: 54 | raise runner.IsupportTokenNotSupported("UTF8ONLY") 55 | 56 | self.addClient() 57 | self.sendLine(2, "NICK bar") 58 | self.clients[2].conn.sendall(b"USER username * * :i\xe8rc\xe9\r\n") 59 | 60 | d = b"" 61 | while True: 62 | try: 63 | buf = self.clients[2].conn.recv(1024) 64 | except TimeoutError: 65 | break 66 | if d and not buf: 67 | break 68 | d += buf 69 | if b"FAIL " in d or b"ERROR " in d or b"468 " in d: # ERR_INVALIDUSERNAME 70 | return # nothing more to test 71 | self.assertIn(b"001 ", d) 72 | 73 | self.sendLine(2, "WHOIS bar") 74 | self.getMessages(2) 75 | 76 | def testNonutf8Username(self): 77 | self.connectClient("foo") 78 | if "UTF8ONLY" not in self.server_support: 79 | raise runner.IsupportTokenNotSupported("UTF8ONLY") 80 | 81 | self.addClient() 82 | self.sendLine(2, "NICK bar") 83 | self.clients[2].conn.sendall(b"USER \xe8rc\xe9 * * :readlname\r\n") 84 | 85 | d = b"" 86 | while True: 87 | try: 88 | buf = self.clients[2].conn.recv(1024) 89 | except TimeoutError: 90 | break 91 | if d and not buf: 92 | break 93 | d += buf 94 | if b"FAIL " in d or b"ERROR " in d or b"468 " in d: # ERR_INVALIDUSERNAME 95 | return # nothing more to test 96 | self.assertIn(b"001 ", d) 97 | 98 | self.sendLine(2, "WHOIS bar") 99 | self.getMessages(2) 100 | 101 | 102 | class ErgoUtf8NickEnabledTestCase(cases.BaseServerTestCase): 103 | @staticmethod 104 | def config() -> cases.TestCaseControllerConfig: 105 | return cases.TestCaseControllerConfig( 106 | ergo_config=lambda config: config["server"].update( 107 | {"casemapping": "precis"}, 108 | ) 109 | ) 110 | 111 | @cases.mark_specifications("Ergo") 112 | def testUtf8NonAsciiNick(self): 113 | """Ergo accepts certain non-ASCII UTF8 nicknames if PRECIS is enabled.""" 114 | self.connectClient("Işıl") 115 | self.joinChannel(1, "#test") 116 | 117 | self.connectClient("Claire") 118 | self.joinChannel(2, "#test") 119 | 120 | self.sendLine(1, "PRIVMSG #test :hi there") 121 | self.getMessages(1) 122 | self.assertMessageMatch( 123 | self.getMessage(2), nick="Işıl", params=["#test", "hi there"] 124 | ) 125 | 126 | 127 | class ErgoUtf8NickDisabledTestCase(cases.BaseServerTestCase): 128 | @cases.mark_specifications("Ergo") 129 | def testUtf8NonAsciiNick(self): 130 | """Ergo rejects non-ASCII nicknames in its default configuration.""" 131 | self.addClient(1) 132 | self.sendLine(1, "USER u s e r") 133 | self.sendLine(1, "NICK Işıl") 134 | self.assertMessageMatch(self.getMessage(1), command=ERR_ERRONEUSNICKNAME) 135 | -------------------------------------------------------------------------------- /irctest/server_tests/wallops.py: -------------------------------------------------------------------------------- 1 | """ 2 | The WALLOPS command (`RFC 2812 3 | `__, 4 | `Modern `__) 5 | """ 6 | 7 | from irctest import cases, runner 8 | from irctest.numerics import ERR_NOPRIVILEGES, ERR_UNKNOWNCOMMAND, RPL_YOUREOPER 9 | from irctest.patma import ANYSTR, StrRe 10 | 11 | 12 | class WallopsTestCase(cases.BaseServerTestCase): 13 | @cases.mark_specifications("RFC2812", "Modern") 14 | def testWallops(self): 15 | """ 16 | "The WALLOPS command is used to send a message to all currently connected 17 | users who have set the 'w' user mode for themselves." 18 | -- https://datatracker.ietf.org/doc/html/rfc2812#section-4.7 19 | -- https://github.com/ircdocs/modern-irc/pull/118 20 | 21 | "Servers MAY echo WALLOPS messages to their sender even if they don't have 22 | the 'w' user mode. 23 | Servers MAY send WALLOPS only to operators." 24 | -- https://github.com/ircdocs/modern-irc/pull/118 25 | 26 | """ 27 | self.connectClient("nick1") 28 | self.connectClient("nick2") 29 | self.connectClient("nick3") 30 | 31 | self.sendLine(2, "MODE nick2 -w") 32 | self.getMessages(2) 33 | self.sendLine(3, "MODE nick3 +w") 34 | self.getMessages(3) 35 | 36 | self.sendLine(1, "OPER operuser operpassword") 37 | self.assertIn( 38 | RPL_YOUREOPER, 39 | [m.command for m in self.getMessages(1)], 40 | fail_msg="OPER failed", 41 | ) 42 | 43 | self.sendLine(1, "WALLOPS :hi everyone") 44 | 45 | messages = self.getMessages(1) 46 | if ERR_UNKNOWNCOMMAND in (message.command for message in messages): 47 | raise runner.OptionalCommandNotSupported("WALLOPS") 48 | for message in messages: 49 | self.assertMessageMatch( 50 | message, 51 | prefix=StrRe("nick1!.*"), 52 | command="WALLOPS", 53 | params=[StrRe(".*hi everyone")], 54 | ) 55 | 56 | messages = self.getMessages(3) 57 | if messages: 58 | self.assertMessageMatch( 59 | messages[0], 60 | prefix=StrRe("nick1!.*"), 61 | command="WALLOPS", 62 | params=[StrRe(".*hi everyone")], 63 | ) 64 | self.assertEqual( 65 | self.getMessages(2), [], fail_msg="Server sent WALLOPS to user without +w" 66 | ) 67 | 68 | @cases.mark_specifications("Modern") 69 | @cases.xfailIfSoftware( 70 | ["irc2"], "irc2 ignores the command instead of replying ERR_UNKNOWNCOMMAND" 71 | ) 72 | def testWallopsPrivileges(self): 73 | """ 74 | https://github.com/ircdocs/modern-irc/pull/118 75 | """ 76 | self.connectClient("nick1") 77 | self.sendLine(1, "WALLOPS :hi everyone") 78 | message = self.getMessage(1) 79 | if message.command == ERR_UNKNOWNCOMMAND: 80 | raise runner.OptionalCommandNotSupported("WALLOPS") 81 | self.assertMessageMatch( 82 | message, command=ERR_NOPRIVILEGES, params=["nick1", ANYSTR] 83 | ) 84 | -------------------------------------------------------------------------------- /irctest/specifications.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import enum 4 | 5 | 6 | @enum.unique 7 | class Specifications(enum.Enum): 8 | RFC1459 = "RFC1459" 9 | RFC2812 = "RFC2812" 10 | IRCv3 = "IRCv3" # Mark with capabilities whenever possible 11 | Ergo = "Ergo" 12 | 13 | Ircdocs = "ircdocs" 14 | """Any document on ircdocs.horse (especially defs.ircdocs.horse), 15 | excluding modern.ircdocs.horse""" 16 | 17 | Modern = "modern" 18 | 19 | @classmethod 20 | def from_name(cls, name: str) -> Specifications: 21 | name = name.upper() 22 | for spec in cls: 23 | if spec.value.upper() == name: 24 | return spec 25 | raise ValueError(name) 26 | 27 | 28 | @enum.unique 29 | class Capabilities(enum.Enum): 30 | ACCOUNT_NOTIFY = "account-notify" 31 | ACCOUNT_TAG = "account-tag" 32 | AWAY_NOTIFY = "away-notify" 33 | BATCH = "batch" 34 | ECHO_MESSAGE = "echo-message" 35 | EXTENDED_JOIN = "extended-join" 36 | EXTENDED_MONITOR = "extended-monitor" 37 | LABELED_RESPONSE = "labeled-response" 38 | MESSAGE_TAGS = "message-tags" 39 | MULTILINE = "draft/multiline" 40 | MULTI_PREFIX = "multi-prefix" 41 | SERVER_TIME = "server-time" 42 | SETNAME = "setname" 43 | STS = "sts" 44 | 45 | @classmethod 46 | def from_name(cls, name: str) -> Capabilities: 47 | try: 48 | return cls(name.lower()) 49 | except ValueError: 50 | raise ValueError(name) from None 51 | 52 | 53 | @enum.unique 54 | class IsupportTokens(enum.Enum): 55 | BOT = "BOT" 56 | ELIST = "ELIST" 57 | INVEX = "INVEX" 58 | PREFIX = "PREFIX" 59 | MONITOR = "MONITOR" 60 | STATUSMSG = "STATUSMSG" 61 | TARGMAX = "TARGMAX" 62 | UTF8ONLY = "UTF8ONLY" 63 | WHOX = "WHOX" 64 | 65 | @classmethod 66 | def from_name(cls, name: str) -> IsupportTokens: 67 | try: 68 | return cls(name.upper()) 69 | except ValueError: 70 | raise ValueError(name) from None 71 | -------------------------------------------------------------------------------- /irctest/tls.py: -------------------------------------------------------------------------------- 1 | import dataclasses 2 | from typing import List 3 | 4 | 5 | @dataclasses.dataclass 6 | class TlsConfig: 7 | enable: bool 8 | trusted_fingerprints: List[str] 9 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | warn_return_any = True 4 | warn_unused_configs = True 5 | 6 | [mypy-irctest.*] 7 | disallow_untyped_defs = True 8 | 9 | [mypy-irctest.server_tests.*] 10 | disallow_untyped_defs = False 11 | 12 | [mypy-irctest.client_tests.*] 13 | disallow_untyped_defs = False 14 | 15 | [mypy-irctest.self_tests.*] 16 | disallow_untyped_defs = False 17 | 18 | [mypy-defusedxml.*] 19 | ignore_missing_imports = True 20 | 21 | [mypy-ecdsa] 22 | ignore_missing_imports = True 23 | 24 | [mypy-ecdsa.util] 25 | ignore_missing_imports = True 26 | 27 | [mypy-irctest.scram.*] 28 | disallow_untyped_defs = False 29 | -------------------------------------------------------------------------------- /patches/bahamut_localhost.patch: -------------------------------------------------------------------------------- 1 | Allows connections from localhost 2 | 3 | diff --git a/src/s_user.c b/src/s_user.c 4 | index 317b00e..adfcfcf 100644 5 | --- a/src/s_user.c 6 | +++ b/src/s_user.c 7 | @@ -594,13 +594,6 @@ register_user(aClient *cptr, aClient *sptr, char *nick, char *username, 8 | dots = 1; 9 | } 10 | 11 | - if (!dots) 12 | - { 13 | - sendto_realops("Invalid hostname for %s, dumping user %s", 14 | - sptr->hostip, sptr->name); 15 | - return exit_client(cptr, sptr, &me, "Invalid hostname"); 16 | - } 17 | - 18 | if (bad_dns) 19 | { 20 | sendto_one(sptr, ":%s NOTICE %s :*** Notice -- You have a bad " 21 | -------------------------------------------------------------------------------- /patches/bahamut_mainloop.patch: -------------------------------------------------------------------------------- 1 | Lower Bahamut's delay between processing incoming commands 2 | 3 | diff --git a/src/s_bsd.c b/src/s_bsd.c 4 | index fcc1d02..951fd8c 100644 5 | --- a/src/s_bsd.c 6 | +++ b/src/s_bsd.c 7 | @@ -1458,7 +1458,7 @@ int do_client_queue(aClient *cptr) 8 | int dolen = 0, done; 9 | 10 | while (SBufLength(&cptr->recvQ) && !NoNewLine(cptr) && 11 | - ((cptr->status < STAT_UNKNOWN) || (cptr->since - timeofday < 10) || 12 | + ((cptr->status < STAT_UNKNOWN) || (cptr->since - timeofday < 20) || 13 | IsNegoServer(cptr))) 14 | { 15 | /* If it's become registered as a server, just parse the whole block */ 16 | -------------------------------------------------------------------------------- /patches/charybdis_ubuntu22.patch: -------------------------------------------------------------------------------- 1 | From fa5d445e5e2af735378a1219d2a200ee8aef6561 Mon Sep 17 00:00:00 2001 2 | From: Sadie Powell 3 | Date: Sun, 25 Jun 2023 21:50:42 +0100 4 | Subject: [PATCH] Fix Charybdis on Ubuntu 22.04. 5 | 6 | --- 7 | librb/include/rb_lib.h | 2 ++ 8 | 1 file changed, 2 insertions(+) 9 | 10 | diff --git a/librb/include/rb_lib.h b/librb/include/rb_lib.h 11 | index c02dff68..0dd9c378 100644 12 | --- a/librb/include/rb_lib.h 13 | +++ b/librb/include/rb_lib.h 14 | @@ -258,4 +258,6 @@ pid_t rb_getpid(void); 15 | #include 16 | #include 17 | 18 | +#include 19 | + 20 | #endif 21 | -- 22 | 2.34.1 23 | 24 | -------------------------------------------------------------------------------- /patches/ngircd_whowas_delay.patch: -------------------------------------------------------------------------------- 1 | ngIRCd skips WHOWAS entries for users that were connected for less 2 | than 30 seconds. 3 | 4 | To avoid waiting 30s in every WHOWAS test, we need to remove this. 5 | 6 | diff --git a/src/ngircd/client.c b/src/ngircd/client.c 7 | index 67c02604..66e8e540 100644 8 | --- a/src/ngircd/client.c 9 | +++ b/src/ngircd/client.c 10 | @@ -1490,9 +1490,6 @@ Client_RegisterWhowas( CLIENT *Client ) 11 | return; 12 | 13 | now = time(NULL); 14 | - /* Don't register clients that were connected less than 30 seconds. */ 15 | - if( now - Client->starttime < 30 ) 16 | - return; 17 | 18 | slot = Last_Whowas + 1; 19 | if( slot >= MAX_WHOWAS || slot < 0 ) slot = 0; 20 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.black] 2 | target-version = ['py38'] 3 | exclude = 'irctest/scram/*' 4 | 5 | [tool.isort] 6 | multi_line_output = 3 7 | include_trailing_comma = true 8 | force_grid_wrap = 0 9 | use_parentheses = true 10 | ensure_newline_before_comments = true 11 | line_length = 88 12 | force_sort_within_sections = true 13 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | python_files = *_tests/*.py 3 | markers = 4 | # specifications 5 | RFC1459 6 | RFC2812 7 | IRCv3 8 | modern 9 | ircdocs 10 | Ergo 11 | 12 | # misc marks 13 | strict 14 | deprecated 15 | services 16 | arbitrary_client_tags 17 | react_tag 18 | private_chathistory 19 | 20 | # capabilities 21 | account-notify 22 | account-tag 23 | away-notify 24 | batch 25 | echo-message 26 | extended-join 27 | extended-monitor 28 | labeled-response 29 | message-tags 30 | draft/multiline 31 | multi-prefix 32 | server-time 33 | setname 34 | sts 35 | 36 | # isupport tokens 37 | BOT 38 | ELIST 39 | INVEX 40 | MONITOR 41 | PREFIX 42 | STATUSMSG 43 | TARGMAX 44 | UTF8ONLY 45 | WHOX 46 | 47 | python_classes = *TestCase Test* 48 | 49 | # Include stdout in pytest.xml files used by the dashboard. 50 | junit_logging = system-out 51 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pytest 2 | 3 | # The following dependencies are actually optional: 4 | ecdsa 5 | filelock 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # E203: whitespaces before ':' 3 | # E231: missing whitespace after ',' 4 | # E501: line too long 5 | # W503: line break before binary operator 6 | ignore = E203,E231,E501,W503 7 | max-line-length = 88 8 | --------------------------------------------------------------------------------