37 Commits

Author SHA1 Message Date
6f1f54b9b8 Host is required 2024-12-08 22:09:47 +01:00
622527ea12 Enable debug logs 2024-12-08 21:46:16 +01:00
a1437277d5 Wait for sable_history to be up 2024-12-08 21:44:28 +01:00
b843581e3f sable: Rely on SASL PLAIN instead of NickServ to check for services availability 2024-12-08 17:46:17 +01:00
c97753da4e Don't run DM tests from SablePostgresqlHistoryTestCase 2024-12-08 16:10:50 +01:00
4cfb7665c6 Use -m instead of -k to filter markers
-k uses substring match, so -k Sable matched ErgoUtf8NickDisabledTestCase
2024-12-08 14:54:19 +01:00
e0b4aec24a Bump sable 2024-12-08 09:42:08 +01:00
388506c3d4 Fix typo in Ergo selectors 2024-12-08 09:36:07 +01:00
a0a859d4fa Fix stderr/stdout typo 2024-11-19 18:01:32 +01:00
a1bb20323a Start postgresql 2024-11-17 18:11:38 +01:00
895849ed93 Fix lint 2024-11-17 18:11:38 +01:00
4983db7cad Merge branch 'master' into sable-pg-history 2024-11-17 18:00:47 +01:00
a9c87eae91 Bump Sable 2024-11-17 17:45:05 +01:00
0dc5a0fdda Need fanout > 1 when more than 2 nodes.
And restore faketime config to previous value, as that wasn't the right fix
2024-11-10 18:52:07 +01:00
827c6a6df4 Actually print logs from sable_services and sable_history 2024-11-10 17:08:01 +01:00
2680502dfe Allow any extban format on InspIRCd. (#294)
This should stop the mute extban test failing when extbanformat is set to name (which will soon be the default in git master).
2024-11-03 10:24:05 +01:00
396841ebff Another workaround for Sable's latency with the history server 2024-10-28 20:23:07 +01:00
d090f5455e Update Sable and enable more test (#293) 2024-10-28 18:15:10 +01:00
f350ff0b96 Make tests less flaky on Sable 2024-10-27 18:35:54 +01:00
b274cad65b Add tests for Sable's postgresql chathistory backend 2024-10-27 18:17:57 +01:00
c31aaf4d61 Bump Bahamut (#291)
The patch does not apply to the latest master, so we need to update it
2024-10-10 20:59:59 +02:00
7b0ee7589f sable: Add object_expiry setting to sable config, required since 015bcc4 2024-10-08 22:54:00 +02:00
d202e440bb upgrade ergo's Go version to 1.23 (#289)
https://github.com/ergochat/ergo/pull/2187
2024-08-16 08:30:52 +02:00
03ad671951 testLineTooLong: Ensure server processed incoming message before reading from the other client (#288) 2024-08-15 20:34:16 +02:00
e3485b92b4 Bump UnrealIRCd and ngIRCd (#285) 2024-07-27 18:14:57 +02:00
75d9040d37 Allow enabling debug output via environment variables. (#284) 2024-07-24 20:35:37 +02:00
a132440789 add various channel mode tests (#276) 2024-07-07 08:33:48 +02:00
aaa2e26b6e Fix file name 2024-06-23 20:37:10 +02:00
052198c61b Add support for Hybrid > 8.2.44 (#283)
The module system changed, modules now need to be loaded explicitly
2024-06-21 21:38:16 +02:00
9f33633cc7 Fix the help filename as of the latest commit. (#282) 2024-06-17 19:15:02 +02:00
465f6637ed enable backtraces in sable (#280) 2024-06-15 08:25:14 +02:00
9856317a64 fix buffering test
The test for erroneous ERR_INPUTTOOLONG was counting codepoints instead
of bytes, consequently underestimating the actual relayed size of the message.
2024-06-11 01:37:23 -04:00
af980ed3b6 Remove use of deprecated config settings on InspIRCd 3+. 2024-06-07 18:35:45 +02:00
15c077d511 Update the GitHub Actions dependencies used by make_workflows.
Fixes various CI warnings.
2024-06-07 17:54:40 +02:00
330300eba1 Update for the new InspIRCd development branch. 2024-06-07 15:58:47 +02:00
f265e28702 update multiline tests (#275) 2024-06-04 07:18:16 +02:00
e3ffff6ad4 Add tests for joining channels with keys (#274) 2024-06-01 17:04:01 +02:00
38 changed files with 1594 additions and 566 deletions

File diff suppressed because it is too large Load Diff

View File

@ -8,7 +8,7 @@ jobs:
- name: Create directories - name: Create directories
run: cd ~/; mkdir -p .local/ go/ run: cd ~/; mkdir -p .local/ go/
- name: Cache dependencies - name: Cache dependencies
uses: actions/cache@v3 uses: actions/cache@v4
with: with:
key: 3-${{ runner.os }}-anope-devel_release key: 3-${{ runner.os }}-anope-devel_release
path: '~/.cache path: '~/.cache
@ -16,13 +16,13 @@ jobs:
${ github.workspace }/anope ${ github.workspace }/anope
' '
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
- name: Checkout Anope - name: Checkout Anope
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
path: anope path: anope
ref: '2.0' ref: '2.0'
@ -37,7 +37,7 @@ jobs:
- name: Make artefact tarball - name: Make artefact tarball
run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/ run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/
- name: Upload build artefacts - name: Upload build artefacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: installed-anope name: installed-anope
path: ~/artefacts-*.tar.gz path: ~/artefacts-*.tar.gz
@ -47,13 +47,13 @@ jobs:
steps: steps:
- name: Create directories - name: Create directories
run: cd ~/; mkdir -p .local/ go/ run: cd ~/; mkdir -p .local/ go/
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
- name: Checkout InspIRCd - name: Checkout InspIRCd
uses: actions/checkout@v3 uses: actions/checkout@v4
with: with:
path: inspircd path: inspircd
ref: insp3 ref: insp3
@ -67,7 +67,7 @@ jobs:
- name: Make artefact tarball - name: Make artefact tarball
run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/ run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/
- name: Upload build artefacts - name: Upload build artefacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: installed-inspircd name: installed-inspircd
path: ~/artefacts-*.tar.gz path: ~/artefacts-*.tar.gz
@ -81,9 +81,9 @@ jobs:
- test-inspircd-atheme - test-inspircd-atheme
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Download Artifacts - name: Download Artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
path: artifacts path: artifacts
- name: Install dashboard dependencies - name: Install dashboard dependencies
@ -108,13 +108,13 @@ jobs:
- build-inspircd - build-inspircd
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: installed-inspircd name: installed-inspircd
path: '~' path: '~'
@ -126,13 +126,15 @@ jobs:
run: |- run: |-
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- name: Test with pytest - env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
make inspircd make inspircd
timeout-minutes: 30 timeout-minutes: 30
- if: always() - if: always()
name: Publish results name: Publish results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: pytest-results_inspircd_devel_release name: pytest-results_inspircd_devel_release
path: pytest.xml path: pytest.xml
@ -142,18 +144,18 @@ jobs:
- build-anope - build-anope
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: installed-inspircd name: installed-inspircd
path: '~' path: '~'
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: installed-anope name: installed-anope
path: '~' path: '~'
@ -165,13 +167,15 @@ jobs:
run: |- run: |-
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- name: Test with pytest - env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make
inspircd-anope inspircd-anope
timeout-minutes: 30 timeout-minutes: 30
- if: always() - if: always()
name: Publish results name: Publish results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: pytest-results_inspircd-anope_devel_release name: pytest-results_inspircd-anope_devel_release
path: pytest.xml path: pytest.xml
@ -180,13 +184,13 @@ jobs:
- build-inspircd - build-inspircd
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Set up Python 3.11 - name: Set up Python 3.11
uses: actions/setup-python@v4 uses: actions/setup-python@v5
with: with:
python-version: 3.11 python-version: 3.11
- name: Download build artefacts - name: Download build artefacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
name: installed-inspircd name: installed-inspircd
path: '~' path: '~'
@ -198,13 +202,15 @@ jobs:
run: |- run: |-
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install pytest pytest-xdist pytest-timeout -r requirements.txt pip install pytest pytest-xdist pytest-timeout -r requirements.txt
- name: Test with pytest - env:
IRCTEST_DEBUG_LOGS: ${{ runner.debug }}
name: Test with pytest
run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH run: PYTEST_ARGS='--junit-xml pytest.xml --timeout 300' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH
make inspircd-atheme make inspircd-atheme
timeout-minutes: 30 timeout-minutes: 30
- if: always() - if: always()
name: Publish results name: Publish results
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: pytest-results_inspircd-atheme_devel_release name: pytest-results_inspircd-atheme_devel_release
path: pytest.xml path: pytest.xml

File diff suppressed because it is too large Load Diff

191
Makefile
View File

@ -4,112 +4,161 @@ PYTEST ?= python3 -m pytest
# pytest-xdist is installed) # pytest-xdist is installed)
PYTEST_ARGS ?= PYTEST_ARGS ?=
# Will be appended at the end of the -m argument to pytest
EXTRA_MARKERS ?=
# Will be appended at the end of the -k argument to pytest # Will be appended at the end of the -k argument to pytest
EXTRA_SELECTORS ?= EXTRA_SELECTORS ?=
BAHAMUT_SELECTORS := \ BAHAMUT_MARKERS := \
not Ergo \ not implementation-specific \
and not deprecated \ and not deprecated \
and not strict \ and not strict \
and not IRCv3 \ and not IRCv3 \
$(EXTRA_MARKERS)
BAHAMUT_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
CHARYBDIS_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
CHARYBDIS_SELECTORS := \ CHARYBDIS_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
ERGO_MARKERS := \
(Ergo or not implementation-specific) \
and not deprecated \
$(EXTRA_MARKERS)
ERGO_SELECTORS := \ ERGO_SELECTORS := \
not deprecated \ (foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
HYBRID_MARKERS := \
not implementation-specific \
and not deprecated \
$(EXTRA_MARKERS)
HYBRID_SELECTORS := \ HYBRID_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
INSPIRCD_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
INSPIRCD_SELECTORS := \ INSPIRCD_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
IRCU2_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_MARKERS)
IRCU2_SELECTORS := \ IRCU2_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
NEFARIOUS_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
NEFARIOUS_SELECTORS := \ NEFARIOUS_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
SNIRCD_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_MARKERS)
SNIRCD_SELECTORS := \ SNIRCD_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
IRC2_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
and not IRCv3 \
$(EXTRA_MARKERS)
IRC2_SELECTORS := \ IRC2_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
MAMMON_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
MAMMON_SELECTORS := \ MAMMON_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
NGIRCD_MARKERS := \
not implementation-specific \
and not deprecated \
and not strict \
$(EXTRA_MARKERS)
NGIRCD_SELECTORS := \ NGIRCD_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
and not strict \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
PLEXUS4_MARKERS := \
not implementation-specific \
and not deprecated \
$(EXTRA_MARKERS)
PLEXUS4_SELECTORS := \ PLEXUS4_SELECTORS := \
not Ergo \ (foo or not foo) \
and not deprecated \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
# Limnoria can actually pass all the test so there is none to exclude. LIMNORIA_MARKERS := \
# `(foo or not foo)` serves as a `true` value so it doesn't break when not implementation-specific \
# $(EXTRA_SELECTORS) is non-empty $(EXTRA_MARKERS)
LIMNORIA_SELECTORS := \ LIMNORIA_SELECTORS := \
(foo or not foo) \ (foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
# Tests marked with arbitrary_client_tags or react_tag can't pass because Sable does not support client tags yet # Tests marked with arbitrary_client_tags or react_tag can't pass because Sable does not support client tags yet
# Tests marked with private_chathistory can't pass because Sable does not implement CHATHISTORY for DMs # 'SablePostgresqlHistoryTestCase and private_chathistory' disabled because Sable does not (yet?) persist private messages to postgresql
SABLE_MARKERS := \
SABLE_SELECTORS := \ (Sable or not implementation-specific) \
not Ergo \
and not deprecated \ and not deprecated \
and not strict \ and not strict \
and not arbitrary_client_tags \ and not arbitrary_client_tags \
and not react_tag \ and not react_tag \
and not private_chathistory \ $(EXTRA_MARKERS)
and not list and not lusers and not time and not info \ SABLE_SELECTORS := \
not list and not lusers and not time and not info \
and not (SablePostgresqlHistoryTestCase and private_chathistory) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
SOLANUM_SELECTORS := \ SOLANUM_MARKERS := \
not Ergo \ not implementation-specific \
and not deprecated \ and not deprecated \
and not strict \ and not strict \
$(EXTRA_MARKERS)
SOLANUM_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
# Same as Limnoria SOPEL_MARKERS := \
not implementation-specific \
$(EXTRA_MARKERS)
SOPEL_SELECTORS := \ SOPEL_SELECTORS := \
(foo or not foo) \ (foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
# TheLounge can actually pass all the test so there is none to exclude. THELOUNGE_MARKERS := \
# `(foo or not foo)` serves as a `true` value so it doesn't break when not implementation-specific \
# $(EXTRA_SELECTORS) is non-empty $(EXTRA_MARKERS)
THELOUNGE_SELECTORS := \ THELOUNGE_SELECTORS := \
(foo or not foo) \ (foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
@ -118,13 +167,16 @@ THELOUNGE_SELECTORS := \
# Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149 # Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149
# Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs # Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs
UNREALIRCD_SELECTORS := \ UNREALIRCD_MARKERS := \
not Ergo \ not implementation-specific \
and not deprecated \ and not deprecated \
and not strict \ and not strict \
and not arbitrary_client_tags \ and not arbitrary_client_tags \
and not react_tag \ and not react_tag \
and not private_chathistory \ and not private_chathistory \
$(EXTRA_MARKERS)
UNREALIRCD_SELECTORS := \
(foo or not foo) \
$(EXTRA_SELECTORS) $(EXTRA_SELECTORS)
.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd .PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd
@ -140,107 +192,114 @@ bahamut:
-m 'not services' \ -m 'not services' \
-n 4 \ -n 4 \
-vv -s \ -vv -s \
-m 'not services and $(BAHAMUT_MARKERS)'
-k '$(BAHAMUT_SELECTORS)' -k '$(BAHAMUT_SELECTORS)'
bahamut-atheme: bahamut-atheme:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \ --controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.atheme_services \ --services-controller=irctest.controllers.atheme_services \
-m 'services' \ -m 'services and $(BAHAMUT_MARKERS)' \
-k '$(BAHAMUT_SELECTORS)' -k '$(BAHAMUT_SELECTORS)'
bahamut-anope: bahamut-anope:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.bahamut \ --controller=irctest.controllers.bahamut \
--services-controller=irctest.controllers.anope_services \ --services-controller=irctest.controllers.anope_services \
-m 'services' \ -m 'services and $(BAHAMUT_MARKERS)' \
-k '$(BAHAMUT_SELECTORS)' -k '$(BAHAMUT_SELECTORS)'
charybdis: charybdis:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.charybdis \ --controller=irctest.controllers.charybdis \
--services-controller=irctest.controllers.atheme_services \ --services-controller=irctest.controllers.atheme_services \
-m '$(CHARYBDIS_MARKERS)'
-k '$(CHARYBDIS_SELECTORS)' -k '$(CHARYBDIS_SELECTORS)'
ergo: ergo:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ergo \ --controller irctest.controllers.ergo \
-m '$(ERGO_MARKERS)'
-k "$(ERGO_SELECTORS)" -k "$(ERGO_SELECTORS)"
hybrid: hybrid:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.hybrid \ --controller irctest.controllers.hybrid \
--services-controller=irctest.controllers.anope_services \ --services-controller=irctest.controllers.anope_services \
-m '$(HYBRID_MARKERS)'
-k "$(HYBRID_SELECTORS)" -k "$(HYBRID_SELECTORS)"
inspircd: inspircd:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \ --controller=irctest.controllers.inspircd \
-m 'not services' \ -m 'not services and $(INSPIRCD_MARKERS)' \
-k '$(INSPIRCD_SELECTORS)' -k '$(INSPIRCD_SELECTORS)'
inspircd-atheme: inspircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \ --controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.atheme_services \ --services-controller=irctest.controllers.atheme_services \
-m 'services' \ -m 'services and $(INSPIRCD_MARKERS)' \
-k '$(INSPIRCD_SELECTORS)' -k '$(INSPIRCD_SELECTORS)'
inspircd-anope: inspircd-anope:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.inspircd \ --controller=irctest.controllers.inspircd \
--services-controller=irctest.controllers.anope_services \ --services-controller=irctest.controllers.anope_services \
-m 'services' \ -m 'services and $(INSPIRCD_MARKERS)' \
-k '$(INSPIRCD_SELECTORS)' -k '$(INSPIRCD_SELECTORS)'
ircu2: ircu2:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.ircu2 \ --controller=irctest.controllers.ircu2 \
-m 'not services and not IRCv3' \ -m 'not services and $(IRCU2_MARKERS)' \
-n 4 \ -n 4 \
-k '$(IRCU2_SELECTORS)' -k '$(IRCU2_SELECTORS)'
nefarious: nefarious:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.nefarious \ --controller=irctest.controllers.nefarious \
-m 'not services' \ -m 'not services and $(NEFARIOUS_MARKERS)' \
-n 4 \ -n 4 \
-k '$(NEFARIOUS_SELECTORS)' -k '$(NEFARIOUS_SELECTORS)'
snircd: snircd:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.snircd \ --controller=irctest.controllers.snircd \
-m 'not services and not IRCv3' \ -m 'not services and $(SNIRCD_MARKERS)' \
-n 4 \ -n 4 \
-k '$(SNIRCD_SELECTORS)' -k '$(SNIRCD_SELECTORS)'
irc2: irc2:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.irc2 \ --controller=irctest.controllers.irc2 \
-m 'not services and not IRCv3' \ -m 'not services and $(IRCU2_MARKERS)' \
-n 4 \ -n 4 \
-k '$(IRC2_SELECTORS)' -k '$(IRC2_SELECTORS)'
limnoria: limnoria:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.limnoria \ --controller=irctest.controllers.limnoria \
-m '$(LIMNORIA_MARKERS)' \
-k '$(LIMNORIA_SELECTORS)' -k '$(LIMNORIA_SELECTORS)'
mammon: mammon:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.mammon \ --controller=irctest.controllers.mammon \
-m '$(MAMMON_MARKERS)' \
-k '$(MAMMON_SELECTORS)' -k '$(MAMMON_SELECTORS)'
plexus4: plexus4:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.plexus4 \ --controller irctest.controllers.plexus4 \
--services-controller=irctest.controllers.anope_services \ --services-controller=irctest.controllers.anope_services \
-m '$(PLEXUS4_MARKERS)' \
-k "$(PLEXUS4_SELECTORS)" -k "$(PLEXUS4_SELECTORS)"
ngircd: ngircd:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \ --controller irctest.controllers.ngircd \
-m 'not services' \ -m 'not services and $(NGIRCD_MARKERS)' \
-n 4 \ -n 4 \
-k "$(NGIRCD_SELECTORS)" -k "$(NGIRCD_SELECTORS)"
@ -248,19 +307,20 @@ ngircd-anope:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \ --controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.anope_services \ --services-controller=irctest.controllers.anope_services \
-m 'services' \ -m 'services and $(NGIRCD_MARKERS)' \
-k "$(NGIRCD_SELECTORS)" -k "$(NGIRCD_SELECTORS)"
ngircd-atheme: ngircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller irctest.controllers.ngircd \ --controller irctest.controllers.ngircd \
--services-controller=irctest.controllers.atheme_services \ --services-controller=irctest.controllers.atheme_services \
-m 'services' \ -m 'services and $(NGIRCD_MARKERS)' \
-k "$(NGIRCD_SELECTORS)" -k "$(NGIRCD_SELECTORS)"
sable: sable:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sable \ --controller=irctest.controllers.sable \
-m '$(SABLE_MARKERS)' \
-n 20 \ -n 20 \
-k '$(SABLE_SELECTORS)' -k '$(SABLE_SELECTORS)'
@ -268,22 +328,25 @@ solanum:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.solanum \ --controller=irctest.controllers.solanum \
--services-controller=irctest.controllers.atheme_services \ --services-controller=irctest.controllers.atheme_services \
-m '$(SOLANUM_MARKERS)' \
-k '$(SOLANUM_SELECTORS)' -k '$(SOLANUM_SELECTORS)'
sopel: sopel:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.sopel \ --controller=irctest.controllers.sopel \
-m '$(SOPEL_MARKERS)' \
-k '$(SOPEL_SELECTORS)' -k '$(SOPEL_SELECTORS)'
thelounge: thelounge:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.thelounge \ --controller=irctest.controllers.thelounge \
-m '$(THELOUNGE_MARKERS)' \
-k '$(THELOUNGE_SELECTORS)' -k '$(THELOUNGE_SELECTORS)'
unrealircd: unrealircd:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \ --controller=irctest.controllers.unrealircd \
-m 'not services' \ -m 'not services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)' -k '$(UNREALIRCD_SELECTORS)'
unrealircd-5: unrealircd unrealircd-5: unrealircd
@ -292,19 +355,19 @@ unrealircd-atheme:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \ --controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.atheme_services \ --services-controller=irctest.controllers.atheme_services \
-m 'services' \ -m 'services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)' -k '$(UNREALIRCD_SELECTORS)'
unrealircd-anope: unrealircd-anope:
$(PYTEST) $(PYTEST_ARGS) \ $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \ --controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.anope_services \ --services-controller=irctest.controllers.anope_services \
-m 'services' \ -m 'services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)' -k '$(UNREALIRCD_SELECTORS)'
unrealircd-dlk: unrealircd-dlk:
pifpaf run mysql -- $(PYTEST) $(PYTEST_ARGS) \ pifpaf run mysql -- $(PYTEST) $(PYTEST_ARGS) \
--controller=irctest.controllers.unrealircd \ --controller=irctest.controllers.unrealircd \
--services-controller=irctest.controllers.dlk_services \ --services-controller=irctest.controllers.dlk_services \
-m 'services' \ -m 'services and $(UNREALIRCD_MARKERS)' \
-k '$(UNREALIRCD_SELECTORS)' -k '$(UNREALIRCD_SELECTORS)'

View File

@ -8,10 +8,25 @@ from pathlib import Path
import shutil import shutil
import socket import socket
import subprocess import subprocess
import sys
import tempfile import tempfile
import textwrap import textwrap
import threading
import time import time
from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type from typing import (
IO,
Any,
Callable,
Dict,
Iterator,
List,
Optional,
Sequence,
Set,
Tuple,
Type,
Union,
)
import irctest import irctest
@ -54,6 +69,9 @@ class TestCaseControllerConfig:
This should be used as little as possible, using the other attributes instead; This should be used as little as possible, using the other attributes instead;
as they are work with any controller.""" as they are work with any controller."""
sable_history_server: bool = False
"""Whether to start Sable's long-term history server"""
class _BaseController: class _BaseController:
"""Base class for software controllers. """Base class for software controllers.
@ -74,6 +92,7 @@ class _BaseController:
_port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock") _port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock")
def __init__(self, test_config: TestCaseControllerConfig): def __init__(self, test_config: TestCaseControllerConfig):
self.debug_mode = os.getenv("IRCTEST_DEBUG_LOGS", "0").lower() in ("true", "1")
self.test_config = test_config self.test_config = test_config
self.proc = None self.proc = None
self._own_ports: Set[Tuple[str, int]] = set() self._own_ports: Set[Tuple[str, int]] = set()
@ -130,6 +149,50 @@ class _BaseController:
used_ports.remove((hostname, port)) used_ports.remove((hostname, port))
self._own_ports.remove((hostname, port)) self._own_ports.remove((hostname, port))
def execute(
self,
command: Sequence[Union[str, Path]],
proc_name: Optional[str] = None,
**kwargs: Any,
) -> subprocess.Popen:
output_to = None if self.debug_mode else subprocess.DEVNULL
proc_name = proc_name or str(command[0])
kwargs.setdefault("stdout", output_to)
kwargs.setdefault("stderr", output_to)
stream_stdout = stream_stderr = None
if kwargs["stdout"] in (None, subprocess.STDOUT):
kwargs["stdout"] = subprocess.PIPE
def stream_stdout() -> None:
assert proc.stdout is not None # for mypy
for line in proc.stdout:
prefix = f"{time.time():.3f} {proc_name} ".encode()
try:
sys.stdout.buffer.write(prefix + line)
except ValueError:
# "I/O operation on closed file"
pass
if kwargs["stderr"] in (subprocess.STDOUT, None):
kwargs["stderr"] = subprocess.PIPE
def stream_stderr() -> None:
assert proc.stderr is not None # for mypy
for line in proc.stderr:
prefix = f"{time.time():.3f} {proc_name} ".encode()
try:
sys.stdout.buffer.write(prefix + line)
except ValueError:
# "I/O operation on closed file"
pass
proc = subprocess.Popen(command, **kwargs)
if stream_stdout is not None:
threading.Thread(target=stream_stdout, name="stream_stdout").start()
if stream_stderr is not None:
threading.Thread(target=stream_stderr, name="stream_stderr").start()
return proc
class DirectoryBasedController(_BaseController): class DirectoryBasedController(_BaseController):
"""Helper for controllers whose software configuration is based on an """Helper for controllers whose software configuration is based on an
@ -253,6 +316,7 @@ class BaseServerController(_BaseController):
def __init__(self, *args: Any, **kwargs: Any): def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.faketime_enabled = False self.faketime_enabled = False
self.services_controller = None
def run( def run(
self, self,

View File

@ -842,16 +842,22 @@ def mark_services(cls: TClass) -> TClass:
def mark_specifications( def mark_specifications(
*specifications_str: str, deprecated: bool = False, strict: bool = False *specifications_str: str, deprecated: bool = False, strict: bool = False
) -> Callable[[TCallable], TCallable]: ) -> Callable[[TCallable], TCallable]:
specifications = frozenset( specifications = {
Specifications.from_name(s) if isinstance(s, str) else s Specifications.from_name(s) if isinstance(s, str) else s
for s in specifications_str for s in specifications_str
) }
if None in specifications: if None in specifications:
raise ValueError("Invalid set of specifications: {}".format(specifications)) raise ValueError("Invalid set of specifications: {}".format(specifications))
is_implementation_specific = all(
spec.is_implementation_specific() for spec in specifications
)
def decorator(f: TCallable) -> TCallable: def decorator(f: TCallable) -> TCallable:
for specification in specifications: for specification in specifications:
f = getattr(pytest.mark, specification.value)(f) f = getattr(pytest.mark, specification.value)(f)
if is_implementation_specific:
f = getattr(pytest.mark, "implementation-specific")(f)
if strict: if strict:
f = pytest.mark.strict(f) f = pytest.mark.strict(f)
if deprecated: if deprecated:

View File

@ -136,16 +136,19 @@ class AnopeController(BaseServicesController, DirectoryBasedController):
Path(services_path).parent.parent / "modules" Path(services_path).parent.parent / "modules"
) )
self.proc = subprocess.Popen( extra_args = []
if self.debug_mode:
extra_args.append("--debug")
self.proc = self.execute(
[ [
"anope", "anope",
"--config=services.conf", # can't be an absolute path in 2.0 "--config=services.conf", # can't be an absolute path in 2.0
"--nofork", # don't fork "--nofork", # don't fork
"--nopid", # don't write a pid "--nopid", # don't write a pid
*extra_args,
], ],
cwd=self.directory, cwd=self.directory,
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
) )

View File

@ -1,4 +1,3 @@
import subprocess
from typing import Optional, Type from typing import Optional, Type
import irctest import irctest
@ -75,7 +74,7 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
) )
assert self.directory assert self.directory
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
"atheme-services", "atheme-services",
"-n", # don't fork "-n", # don't fork
@ -88,8 +87,6 @@ class AthemeController(BaseServicesController, DirectoryBasedController):
"-D", "-D",
self.directory, self.directory,
], ],
# stdout=subprocess.DEVNULL,
# stderr=subprocess.DEVNULL,
) )
def registerUser( def registerUser(

View File

@ -1,6 +1,5 @@
from pathlib import Path from pathlib import Path
import shutil import shutil
import subprocess
from typing import Optional, Set, Type from typing import Optional, Set, Type
from irctest.basecontrollers import BaseServerController, DirectoryBasedController from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -150,7 +149,7 @@ class BahamutController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
*faketime_cmd, *faketime_cmd,
"ircd", "ircd",

View File

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

View File

@ -200,7 +200,7 @@ class DlkController(BaseServicesController, DirectoryBasedController):
fd.write(TEMPLATE_DLK_WP_CONFIG.format(**template_vars)) fd.write(TEMPLATE_DLK_WP_CONFIG.format(**template_vars))
(dlk_conf_dir / "modules.conf").symlink_to(self.dlk_path / "conf/modules.conf") (dlk_conf_dir / "modules.conf").symlink_to(self.dlk_path / "conf/modules.conf")
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
"php", "php",
"src/dalek", "src/dalek",

View File

@ -58,6 +58,11 @@ BASE_CONFIG = {
"enabled": True, "enabled": True,
"method": "strict", "method": "strict",
}, },
"login-throttling": {
"enabled": True,
"duration": "1m",
"max-attempts": 3,
},
}, },
"channels": {"registration": {"enabled": True}}, "channels": {"registration": {"enabled": True}},
"datastore": {"path": None}, "datastore": {"path": None},
@ -208,7 +213,7 @@ class ErgoController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = subprocess.Popen( self.proc = self.execute(
[*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"] [*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"]
) )

View File

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

View File

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

View File

@ -33,7 +33,8 @@ TEMPLATE_CONFIG = """
class="ServerOperators" class="ServerOperators"
> >
<options casemapping="ascii"> <options casemapping="ascii"
extbanformat="any">
# Disable 'NOTICE #chan :*** foo invited bar into the channel- # Disable 'NOTICE #chan :*** foo invited bar into the channel-
<security announceinvites="none"> <security announceinvites="none">
@ -48,9 +49,7 @@ TEMPLATE_CONFIG = """
sendpass="password" sendpass="password"
> >
<module name="spanningtree"> <module name="spanningtree">
<module name="services_account">
<module name="hidechans"> # Anope errors when missing <module name="hidechans"> # Anope errors when missing
<module name="svshold"> # Atheme raises a warning when missing
<sasl requiressl="no" <sasl requiressl="no"
target="services.example.org"> target="services.example.org">
@ -71,14 +70,10 @@ TEMPLATE_CONFIG = """
<module name="ircv3_servertime"> <module name="ircv3_servertime">
<module name="monitor"> <module name="monitor">
<module name="m_muteban"> # for testing mute extbans <module name="m_muteban"> # for testing mute extbans
<module name="namesx"> # For multi-prefix
<module name="sasl"> <module name="sasl">
<module name="uhnames"> # For userhost-in-names <module name="uhnames"> # For userhost-in-names
# HELP/HELPOP
<module name="alias"> # for the HELP alias <module name="alias"> # for the HELP alias
<module name="{help_module_name}"> {version_config}
<include file="examples/{help_module_name}.conf.example">
# Misc: # Misc:
<log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log"> <log method="file" type="*" level="debug" target="/tmp/ircd-{port}.log">
@ -90,6 +85,26 @@ TEMPLATE_SSL_CONFIG = """
<openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1"> <openssl certfile="{pem_path}" keyfile="{key_path}" dhfile="{dh_path}" hash="sha1">
""" """
TEMPLATE_V3_CONFIG = """
<module name="namesx"> # For multi-prefix
<module name="services_account">
<module name="svshold"> # Atheme raises a warning when missing
# HELP/HELPOP
<module name="helpop">
<include file="examples/helpop.conf.example">
"""
TEMPLATE_V4_CONFIG = """
<module name="account">
<module name="multiprefix"> # For multi-prefix
<module name="services">
# HELP/HELPOP
<module name="help">
<include file="examples/help.example.conf">
"""
@functools.lru_cache() @functools.lru_cache()
def installed_version() -> int: def installed_version() -> int:
@ -98,8 +113,9 @@ def installed_version() -> int:
return 3 return 3
if output.startswith("InspIRCd-4"): if output.startswith("InspIRCd-4"):
return 4 return 4
else: if output.startswith("InspIRCd-5"):
assert False, f"unexpected version: {output}" return 5
assert False, f"unexpected version: {output}"
class InspircdController(BaseServerController, DirectoryBasedController): class InspircdController(BaseServerController, DirectoryBasedController):
@ -141,9 +157,9 @@ class InspircdController(BaseServerController, DirectoryBasedController):
ssl_config = "" ssl_config = ""
if installed_version() == 3: if installed_version() == 3:
help_module_name = "helpop" version_config = TEMPLATE_V3_CONFIG
elif installed_version() == 4: elif installed_version() >= 4:
help_module_name = "help" version_config = TEMPLATE_V4_CONFIG
else: else:
assert False, f"unexpected version: {installed_version()}" assert False, f"unexpected version: {installed_version()}"
@ -156,7 +172,7 @@ class InspircdController(BaseServerController, DirectoryBasedController):
services_port=services_port, services_port=services_port,
password_field=password_field, password_field=password_field,
ssl_config=ssl_config, ssl_config=ssl_config,
help_module_name=help_module_name, version_config=version_config,
) )
) )
assert self.directory assert self.directory
@ -167,15 +183,22 @@ class InspircdController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = subprocess.Popen( extra_args = []
if self.debug_mode:
if installed_version() >= 4:
extra_args.append("--protocoldebug")
else:
extra_args.append("--debug")
self.proc = self.execute(
[ [
*faketime_cmd, *faketime_cmd,
"inspircd", "inspircd",
"--nofork", "--nofork",
"--config", "--config",
self.directory / "server.conf", self.directory / "server.conf",
*extra_args,
], ],
stdout=subprocess.DEVNULL,
) )
if run_services: if run_services:

View File

@ -1,5 +1,4 @@
import shutil import shutil
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest.basecontrollers import ( from irctest.basecontrollers import (
@ -78,7 +77,7 @@ class Irc2Controller(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
*faketime_cmd, *faketime_cmd,
"ircd", "ircd",
@ -88,7 +87,6 @@ class Irc2Controller(BaseServerController, DirectoryBasedController):
"-f", "-f",
self.directory / "server.conf", self.directory / "server.conf",
], ],
# stderr=subprocess.DEVNULL,
) )

View File

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

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import shutil import shutil
import subprocess
from typing import Optional, Set, Type from typing import Optional, Set, Type
from irctest.basecontrollers import BaseServerController, DirectoryBasedController from irctest.basecontrollers import BaseServerController, DirectoryBasedController
@ -28,6 +27,9 @@ TEMPLATE_CONFIG = """
[Operator] [Operator]
Name = operuser Name = operuser
Password = operpassword Password = operpassword
[Limits]
MaxNickLength = 32 # defaults to 9
""" """
@ -92,7 +94,7 @@ class NgircdController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
*faketime_cmd, *faketime_cmd,
"ngircd", "ngircd",
@ -100,7 +102,6 @@ class NgircdController(BaseServerController, DirectoryBasedController):
"--config", "--config",
self.directory / "server.conf", self.directory / "server.conf",
], ],
# stdout=subprocess.DEVNULL,
) )
if run_services: if run_services:

View File

@ -4,8 +4,9 @@ import shutil
import signal import signal
import subprocess import subprocess
import tempfile import tempfile
import threading
import time import time
from typing import Optional, Type from typing import Any, Optional, Sequence, Type
from irctest.basecontrollers import ( from irctest.basecontrollers import (
BaseServerController, BaseServerController,
@ -14,6 +15,7 @@ from irctest.basecontrollers import (
NotImplementedByController, NotImplementedByController,
) )
from irctest.cases import BaseServerTestCase from irctest.cases import BaseServerTestCase
from irctest.client_mock import ClientMock
from irctest.exceptions import NoMessageException from irctest.exceptions import NoMessageException
from irctest.patma import ANYSTR from irctest.patma import ANYSTR
@ -85,7 +87,13 @@ def certs_dir() -> Path:
certs_dir = tempfile.TemporaryDirectory() certs_dir = tempfile.TemporaryDirectory()
(Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS) (Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS)
subprocess.run( subprocess.run(
["bash", "gen_certs.sh", "My.Little.Server", "My.Little.Services"], [
"bash",
"gen_certs.sh",
"My.Little.Server",
"My.Little.History",
"My.Little.Services",
],
cwd=certs_dir.name, cwd=certs_dir.name,
check=True, check=True,
) )
@ -95,10 +103,11 @@ def certs_dir() -> Path:
NETWORK_CONFIG = """ NETWORK_CONFIG = """
{ {
"fanout": 1, "fanout": 2,
"ca_file": "%(certs_dir)s/ca_cert.pem", "ca_file": "%(certs_dir)s/ca_cert.pem",
"peers": [ "peers": [
{ "name": "My.Little.History", "address": "%(history_hostname)s:%(history_port)s", "fingerprint": "%(history_cert_sha1)s" },
{ "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" }, { "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" },
{ "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" } { "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" }
] ]
@ -107,6 +116,8 @@ NETWORK_CONFIG = """
NETWORK_CONFIG_CONFIG = """ NETWORK_CONFIG_CONFIG = """
{ {
"object_expiry": 60, // 1 minute
"opers": [ "opers": [
{ {
"name": "operuser", "name": "operuser",
@ -217,6 +228,58 @@ SERVER_CONFIG = """
} }
""" """
HISTORY_SERVER_CONFIG = """
{
"server_id": 50,
"server_name": "My.Little.History",
"management": {
"address": "%(history_management_hostname)s:%(history_management_port)s",
"client_ca": "%(certs_dir)s/ca_cert.pem",
"authorised_fingerprints": [
{ "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" }
]
},
"server": {
"database": "%(history_db_url)s",
"auto_run_migrations": true,
},
"event_log": {
"event_expiry": 300, // five minutes, for local testing
},
"tls_config": {
"key_file": "%(certs_dir)s/My.Little.History.key",
"cert_file": "%(certs_dir)s/My.Little.History.pem"
},
"node_config": {
"listen_addr": "%(history_hostname)s:%(history_port)s",
"cert_file": "%(certs_dir)s/My.Little.History.pem",
"key_file": "%(certs_dir)s/My.Little.History.key"
},
"log": {
"dir": "log/services/",
"module-levels": {
"": "debug",
"sable_history": "trace",
},
"targets": [
{
"target": "stdout",
"level": "trace",
"modules": [ "sable" ]
}
]
}
}
"""
SERVICES_CONFIG = """ SERVICES_CONFIG = """
{ {
"server_id": 99, "server_id": 99,
@ -295,7 +358,7 @@ SERVICES_CONFIG = """
{ {
"target": "stdout", "target": "stdout",
"level": "debug", "level": "debug",
"modules": [ "sable_services" ] "modules": [ "sable" ]
} }
] ]
} }
@ -310,6 +373,12 @@ class SableController(BaseServerController, DirectoryBasedController):
"""Sable processes commands very quickly, but responses for commands changing the """Sable processes commands very quickly, but responses for commands changing the
state may be sent after later commands for messages which don't.""" state may be sent after later commands for messages which don't."""
history_controller: Optional[BaseServicesController] = None
def __init__(self, *args: Any, **kwargs: Any):
super().__init__(*args, **kwargs)
self.history_controller = None
def run( def run(
self, self,
hostname: str, hostname: str,
@ -346,10 +415,11 @@ class SableController(BaseServerController, DirectoryBasedController):
(server1_hostname, server1_port) = self.get_hostname_and_port() (server1_hostname, server1_port) = self.get_hostname_and_port()
(services_hostname, services_port) = self.get_hostname_and_port() (services_hostname, services_port) = self.get_hostname_and_port()
(history_hostname, history_port) = self.get_hostname_and_port()
# Sable requires inbound connections to match the configured hostname, # Sable requires inbound connections to match the configured hostname,
# so we can't configure 0.0.0.0 # so we can't configure 0.0.0.0
server1_hostname = services_hostname = "127.0.0.1" server1_hostname = history_hostname = services_hostname = "127.0.0.1"
( (
server1_management_hostname, server1_management_hostname,
@ -359,6 +429,10 @@ class SableController(BaseServerController, DirectoryBasedController):
services_management_hostname, services_management_hostname,
services_management_port, services_management_port,
) = self.get_hostname_and_port() ) = self.get_hostname_and_port()
(
history_management_hostname,
history_management_port,
) = self.get_hostname_and_port()
self.template_vars = dict( self.template_vars = dict(
certs_dir=certs_dir(), certs_dir=certs_dir(),
@ -379,6 +453,13 @@ class SableController(BaseServerController, DirectoryBasedController):
services_management_hostname=services_management_hostname, services_management_hostname=services_management_hostname,
services_management_port=services_management_port, services_management_port=services_management_port,
services_alias_users=SERVICES_ALIAS_USERS if run_services else "", services_alias_users=SERVICES_ALIAS_USERS if run_services else "",
history_hostname=history_hostname,
history_port=history_port,
history_cert_sha1=(certs_dir() / "My.Little.History.pem.sha1")
.read_text()
.strip(),
history_management_hostname=history_management_hostname,
history_management_port=history_management_port,
) )
with self.open_file("configs/network.conf") as fd: with self.open_file("configs/network.conf") as fd:
@ -394,7 +475,7 @@ class SableController(BaseServerController, DirectoryBasedController):
else: else:
faketime_cmd = [] faketime_cmd = []
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
*faketime_cmd, *faketime_cmd,
"sable_ircd", "sable_ircd",
@ -408,17 +489,29 @@ class SableController(BaseServerController, DirectoryBasedController):
], ],
cwd=self.directory, cwd=self.directory,
preexec_fn=os.setsid, preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
proc_name="sable_ircd ",
) )
self.pgroup_id = os.getpgid(self.proc.pid) self.pgroup_id = os.getpgid(self.proc.pid)
if run_services: if run_services:
self.services_controller = SableServicesController(self.test_config, self) self.services_controller = SableServicesController(self.test_config, self)
self.services_controller.faketime_cmd = faketime_cmd
self.services_controller.run( self.services_controller.run(
protocol="sable", protocol="sable",
server_hostname=services_hostname, server_hostname=services_hostname,
server_port=services_port, server_port=services_port,
) )
if self.test_config.sable_history_server:
self.history_controller = SableHistoryController(self.test_config, self)
self.history_controller.faketime_cmd = faketime_cmd
self.history_controller.run(
protocol="sable",
server_hostname=history_hostname,
server_port=history_port,
)
def kill_proc(self) -> None: def kill_proc(self) -> None:
os.killpg(self.pgroup_id, signal.SIGKILL) os.killpg(self.pgroup_id, signal.SIGKILL)
super().kill_proc() super().kill_proc()
@ -462,11 +555,62 @@ class SableController(BaseServerController, DirectoryBasedController):
case.sendLine(client, "QUIT") case.sendLine(client, "QUIT")
case.assertDisconnected(client) case.assertDisconnected(client)
def wait_for_services(self) -> None:
# FIXME: this isn't called when sable_history is enabled but sable_services
# isn't. This doesn't happen with the existing tests so this isn't an issue yet
if self.services_controller is not None:
t1 = threading.Thread(target=self.services_controller.wait_for_services)
t1.start()
if self.history_controller is not None:
t2 = threading.Thread(target=self.history_controller.wait_for_services)
t2.start()
t2.join()
if self.services_controller is not None:
t1.join()
class SableServicesController(BaseServicesController): class SableServicesController(BaseServicesController):
server_controller: SableController server_controller: SableController
software_name = "Sable Services" software_name = "Sable Services"
faketime_cmd: Sequence[str]
def wait_for_services(self) -> None:
"""Overrides the default implementation, as it relies on
``PRIVMSG NickServ: HELP``, which always succeeds on Sable.
Instead, this relies on SASL PLAIN availability."""
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkSASL", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
def wait() -> None:
while True:
c.sendLine("CAP LS 302")
for msg in c.getMessages(synchronize=False):
if msg.command == "CAP":
assert msg.params[-2] == "LS", msg
for cap in msg.params[-1].split():
if cap.startswith("sasl="):
mechanisms = cap.split("=", 1)[1].split(",")
if "PLAIN" in mechanisms:
return
else:
if msg.params[0] == "*":
# End of CAP LS
time.sleep(self.server_controller.sync_sleep_time)
wait()
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
self.services_up = True
def run(self, protocol: str, server_hostname: str, server_port: int) -> None: def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
assert protocol == "sable" assert protocol == "sable"
assert self.server_controller.directory is not None assert self.server_controller.directory is not None
@ -474,8 +618,9 @@ class SableServicesController(BaseServicesController):
with self.server_controller.open_file("configs/services.conf") as fd: with self.server_controller.open_file("configs/services.conf") as fd:
fd.write(SERVICES_CONFIG % self.server_controller.template_vars) fd.write(SERVICES_CONFIG % self.server_controller.template_vars)
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
*self.faketime_cmd,
"sable_services", "sable_services",
"--foreground", "--foreground",
"--server-conf", "--server-conf",
@ -485,6 +630,8 @@ class SableServicesController(BaseServicesController):
], ],
cwd=self.server_controller.directory, cwd=self.server_controller.directory,
preexec_fn=os.setsid, preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
proc_name="sable_services",
) )
self.pgroup_id = os.getpgid(self.proc.pid) self.pgroup_id = os.getpgid(self.proc.pid)
@ -493,5 +640,92 @@ class SableServicesController(BaseServicesController):
super().kill_proc() super().kill_proc()
class SableHistoryController(BaseServicesController):
server_controller: SableController
software_name = "Sable History Server"
faketime_cmd: Sequence[str]
def run(self, protocol: str, server_hostname: str, server_port: int) -> None:
assert protocol == "sable"
assert self.server_controller.directory is not None
history_db_url = os.environ.get("PIFPAF_POSTGRESQL_URL") or os.environ.get(
"IRCTEST_POSTGRESQL_URL"
)
assert history_db_url, (
"Cannot find a postgresql database to use as backend for sable_history. "
"Either set the IRCTEST_POSTGRESQL_URL env var to a libpq URL, or "
"run `pip3 install pifpaf` and wrap irctest in a pifpaf call (ie. "
"pifpaf run postgresql -- pytest --controller=irctest.controllers.sable ...)"
)
with self.server_controller.open_file("configs/history_server.conf") as fd:
vals = dict(self.server_controller.template_vars)
vals["history_db_url"] = history_db_url
fd.write(HISTORY_SERVER_CONFIG % vals)
self.proc = self.execute(
[
*self.faketime_cmd,
"sable_history",
"--foreground",
"--server-conf",
self.server_controller.directory / "configs/history_server.conf",
"--network-conf",
self.server_controller.directory / "configs/network.conf",
],
cwd=self.server_controller.directory,
preexec_fn=os.setsid,
env={"RUST_BACKTRACE": "1", **os.environ},
proc_name="sable_history ",
)
self.pgroup_id = os.getpgid(self.proc.pid)
def wait_for_services(self) -> None:
"""Overrides the default implementation, as it relies on
``PRIVMSG NickServ: HELP``, which always succeeds on Sable.
Instead, this relies on SASL PLAIN availability."""
if self.services_up:
# Don't check again if they are already available
return
self.server_controller.wait_for_port()
c = ClientMock(name="chkHist", show_io=True)
c.connect(self.server_controller.hostname, self.server_controller.port)
c.sendLine("NICK chkHist")
c.sendLine("USER chk chk chk chk")
time.sleep(self.server_controller.sync_sleep_time)
got_end_of_motd = False
while not got_end_of_motd:
for msg in c.getMessages(synchronize=False):
if msg.command == "PING":
c.sendLine("PONG :" + msg.params[0])
if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD
got_end_of_motd = True
def wait() -> None:
timeout = time.time() + 10
while time.time() < timeout:
c.sendLine("LINKS")
time.sleep(self.server_controller.sync_sleep_time)
for msg in c.getMessages(synchronize=False):
if msg.command == "364": # RPL_LINKS
if msg.params[2] == "My.Little.History":
return
raise Exception("History server is not available")
wait()
c.sendLine("QUIT")
c.getMessages()
c.disconnect()
self.services_up = True
def kill_proc(self) -> None:
os.killpg(self.pgroup_id, signal.SIGKILL)
super().kill_proc()
def get_irctest_controller_class() -> Type[SableController]: def get_irctest_controller_class() -> Type[SableController]:
return SableController return SableController

View File

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

View File

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

View File

@ -1,6 +1,5 @@
import json import json
import os import os
import subprocess
from typing import Optional, Type from typing import Optional, Type
from irctest import authentication, tls from irctest import authentication, tls
@ -96,7 +95,7 @@ class TheLoungeController(BaseClientController, DirectoryBasedController):
) )
with self.open_file("users/testuser.json", "r") as fd: with self.open_file("users/testuser.json", "r") as fd:
print("config", json.load(fd)["networks"][0]["saslPassword"]) print("config", json.load(fd)["networks"][0]["saslPassword"])
self.proc = subprocess.Popen( self.proc = self.execute(
[os.environ.get("THELOUNGE_BIN", "thelounge"), "start"], [os.environ.get("THELOUNGE_BIN", "thelounge"), "start"],
env={**os.environ, "THELOUNGE_HOME": str(self.directory)}, env={**os.environ, "THELOUNGE_HOME": str(self.directory)},
) )

View File

@ -261,7 +261,7 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
faketime_cmd = [] faketime_cmd = []
with _STARTSTOP_LOCK(): with _STARTSTOP_LOCK():
self.proc = subprocess.Popen( self.proc = self.execute(
[ [
*faketime_cmd, *faketime_cmd,
"unrealircd", "unrealircd",
@ -270,7 +270,6 @@ class UnrealircdController(BaseServerController, DirectoryBasedController):
"-f", "-f",
self.directory / "unrealircd.conf", self.directory / "unrealircd.conf",
], ],
# stdout=subprocess.DEVNULL,
) )
self.wait_for_port() self.wait_for_port()

View File

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

View File

@ -2,6 +2,7 @@
`IRCv3 draft chathistory <https://ircv3.net/specs/extensions/chathistory>`_ `IRCv3 draft chathistory <https://ircv3.net/specs/extensions/chathistory>`_
""" """
import dataclasses
import functools import functools
import secrets import secrets
import time import time
@ -31,10 +32,22 @@ def skip_ngircd(f):
return newf return newf
@cases.mark_specifications("IRCv3") class _BaseChathistoryTests(cases.BaseServerTestCase):
@cases.mark_services def _wait_before_chathistory(self):
class ChathistoryTestCase(cases.BaseServerTestCase): """Hook for the Sable-specific tests that check the postgresql-based
def validate_chathistory_batch(self, msgs, target): CHATHISTORY implementation is sound. This implementation only kicks in
after the in-memory history is cleared, which happens after a 5 min timeout;
and this gives a chance to :class:``SablePostgresqlHistoryTestCase`` to
wait this timeout.
For other tests, this does nothing.
"""
raise NotImplementedError("_BaseChathistoryTests._wait_before_chathistory")
def validate_chathistory_batch(self, user, target):
# may need to try again for Sable, as it has a pretty high latency here
while not (msgs := self.getMessages(user)):
pass
(start, *inner_msgs, end) = msgs (start, *inner_msgs, end) = msgs
self.assertMessageMatch( self.assertMessageMatch(
@ -94,9 +107,13 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.joinChannel(qux, real_chname) self.joinChannel(qux, real_chname)
self.getMessages(qux) self.getMessages(qux)
self._wait_before_chathistory()
# test a nonexistent channel # test a nonexistent channel
self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10") self.sendLine(bar, "CHATHISTORY LATEST #nonexistent_channel * 10")
msgs = self.getMessages(bar) while not (msgs := self.getMessages(bar)):
# need to retry when Sable has the history server on
pass
msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r msgs = [msg for msg in msgs if msg.command != "MODE"] # :NickServ MODE +r
self.assertMessageMatch( self.assertMessageMatch(
msgs[0], msgs[0],
@ -106,7 +123,9 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
# as should a real channel to which one is not joined: # as should a real channel to which one is not joined:
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,)) self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (real_chname,))
msgs = self.getMessages(bar) while not (msgs := self.getMessages(bar)):
# need to retry when Sable has the history server on
pass
self.assertMessageMatch( self.assertMessageMatch(
msgs[0], msgs[0],
command="FAIL", command="FAIL",
@ -175,6 +194,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
messages.append(echo.to_history_message()) messages.append(echo.to_history_message())
self.assertEqual(echo.to_history_message(), delivery.to_history_message()) self.assertEqual(echo.to_history_message(), delivery.to_history_message())
self._wait_before_chathistory()
self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,)) self.sendLine(bar, "CHATHISTORY LATEST %s * 10" % (bar,))
replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"] replies = [msg for msg in self.getMessages(bar) if msg.command == "PRIVMSG"]
self.assertEqual([msg.to_history_message() for msg in replies], messages) self.assertEqual([msg.to_history_message() for msg in replies], messages)
@ -225,9 +246,12 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
echo_messages.extend( echo_messages.extend(
msg.to_history_message() for msg in self.getMessages(1) msg.to_history_message() for msg in self.getMessages(1)
) )
time.sleep(0.002) time.sleep(0.02)
self.validate_echo_messages(NUM_MESSAGES, echo_messages) self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self._wait_before_chathistory()
self.validate_chathistory(subcommand, echo_messages, 1, chname) self.validate_chathistory(subcommand, echo_messages, 1, chname)
@skip_ngircd @skip_ngircd
@ -264,6 +288,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
) )
time.sleep(0.002) time.sleep(0.002)
self._wait_before_chathistory()
self.validate_echo_messages(NUM_MESSAGES, echo_messages) self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname) self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname)
(batch_open, *messages, batch_close) = self.getMessages(1) (batch_open, *messages, batch_close) = self.getMessages(1)
@ -308,6 +334,9 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
time.sleep(0.002) time.sleep(0.002)
self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages) self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages)
self._wait_before_chathistory()
self.validate_chathistory(subcommand, echo_messages, 1, chname) self.validate_chathistory(subcommand, echo_messages, 1, chname)
@pytest.mark.parametrize("subcommand", SUBCOMMANDS) @pytest.mark.parametrize("subcommand", SUBCOMMANDS)
@ -367,6 +396,9 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.getMessages(2) self.getMessages(2)
self.validate_echo_messages(NUM_MESSAGES, echo_messages) self.validate_echo_messages(NUM_MESSAGES, echo_messages)
self._wait_before_chathistory()
self.validate_chathistory(subcommand, echo_messages, 1, c2) self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1) self.validate_chathistory(subcommand, echo_messages, 2, c1)
@ -415,6 +447,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
] ]
self.assertEqual(results, new_convo) self.assertEqual(results, new_convo)
self._wait_before_chathistory()
# additional messages with c3 should not show up in the c1-c2 history: # additional messages with c3 should not show up in the c1-c2 history:
self.validate_chathistory(subcommand, echo_messages, 1, c2) self.validate_chathistory(subcommand, echo_messages, 1, c2)
self.validate_chathistory(subcommand, echo_messages, 2, c1) self.validate_chathistory(subcommand, echo_messages, 2, c1)
@ -459,15 +493,15 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
def _validate_chathistory_LATEST(self, echo_messages, user, chname): def _validate_chathistory_LATEST(self, echo_messages, user, chname):
INCLUSIVE_LIMIT = len(echo_messages) * 2 INCLUSIVE_LIMIT = len(echo_messages) * 2
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT)) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT))
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages, result) self.assertEqual(echo_messages, result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5)) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5))
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-5:], result) self.assertEqual(echo_messages[-5:], result)
self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1)) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1))
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-1:], result) self.assertEqual(echo_messages[-1:], result)
if self._supports_msgid(): if self._supports_msgid():
@ -476,7 +510,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s msgid=%s %d" "CHATHISTORY LATEST %s msgid=%s %d"
% (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT), % (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[5:], result) self.assertEqual(echo_messages[5:], result)
if self._supports_timestamp(): if self._supports_timestamp():
@ -485,7 +519,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY LATEST %s timestamp=%s %d" "CHATHISTORY LATEST %s timestamp=%s %d"
% (chname, echo_messages[4].time, INCLUSIVE_LIMIT), % (chname, echo_messages[4].time, INCLUSIVE_LIMIT),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[5:], result) self.assertEqual(echo_messages[5:], result)
def _validate_chathistory_BEFORE(self, echo_messages, user, chname): def _validate_chathistory_BEFORE(self, echo_messages, user, chname):
@ -496,7 +530,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s msgid=%s %d" "CHATHISTORY BEFORE %s msgid=%s %d"
% (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT), % (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[:6], result) self.assertEqual(echo_messages[:6], result)
if self._supports_timestamp(): if self._supports_timestamp():
@ -505,7 +539,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d" "CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, INCLUSIVE_LIMIT), % (chname, echo_messages[6].time, INCLUSIVE_LIMIT),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[:6], result) self.assertEqual(echo_messages[:6], result)
self.sendLine( self.sendLine(
@ -513,7 +547,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BEFORE %s timestamp=%s %d" "CHATHISTORY BEFORE %s timestamp=%s %d"
% (chname, echo_messages[6].time, 2), % (chname, echo_messages[6].time, 2),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:6], result) self.assertEqual(echo_messages[4:6], result)
def _validate_chathistory_AFTER(self, echo_messages, user, chname): def _validate_chathistory_AFTER(self, echo_messages, user, chname):
@ -524,7 +558,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s msgid=%s %d" "CHATHISTORY AFTER %s msgid=%s %d"
% (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT), % (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:], result) self.assertEqual(echo_messages[4:], result)
if self._supports_timestamp(): if self._supports_timestamp():
@ -533,7 +567,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s timestamp=%s %d" "CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, INCLUSIVE_LIMIT), % (chname, echo_messages[3].time, INCLUSIVE_LIMIT),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:], result) self.assertEqual(echo_messages[4:], result)
self.sendLine( self.sendLine(
@ -541,7 +575,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AFTER %s timestamp=%s %d" "CHATHISTORY AFTER %s timestamp=%s %d"
% (chname, echo_messages[3].time, 3), % (chname, echo_messages[3].time, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[4:7], result) self.assertEqual(echo_messages[4:7], result)
def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): def _validate_chathistory_BETWEEN(self, echo_messages, user, chname):
@ -558,7 +592,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT, INCLUSIVE_LIMIT,
), ),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result) self.assertEqual(echo_messages[1:-1], result)
self.sendLine( self.sendLine(
@ -571,7 +605,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT, INCLUSIVE_LIMIT,
), ),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result) self.assertEqual(echo_messages[1:-1], result)
# BETWEEN forwards and backwards with a limit, should get # BETWEEN forwards and backwards with a limit, should get
@ -581,7 +615,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3), % (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:4], result) self.assertEqual(echo_messages[1:4], result)
self.sendLine( self.sendLine(
@ -589,7 +623,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d"
% (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3), % (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-4:-1], result) self.assertEqual(echo_messages[-4:-1], result)
if self._supports_timestamp(): if self._supports_timestamp():
@ -604,7 +638,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT, INCLUSIVE_LIMIT,
), ),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result) self.assertEqual(echo_messages[1:-1], result)
self.sendLine( self.sendLine(
user, user,
@ -616,21 +650,21 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
INCLUSIVE_LIMIT, INCLUSIVE_LIMIT,
), ),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:-1], result) self.assertEqual(echo_messages[1:-1], result)
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[0].time, echo_messages[-1].time, 3), % (chname, echo_messages[0].time, echo_messages[-1].time, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[1:4], result) self.assertEqual(echo_messages[1:4], result)
self.sendLine( self.sendLine(
user, user,
"CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d"
% (chname, echo_messages[-1].time, echo_messages[0].time, 3), % (chname, echo_messages[-1].time, echo_messages[0].time, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[-4:-1], result) self.assertEqual(echo_messages[-4:-1], result)
def _validate_chathistory_AROUND(self, echo_messages, user, chname): def _validate_chathistory_AROUND(self, echo_messages, user, chname):
@ -640,7 +674,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s msgid=%s %d" "CHATHISTORY AROUND %s msgid=%s %d"
% (chname, echo_messages[7].msgid, 1), % (chname, echo_messages[7].msgid, 1),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual([echo_messages[7]], result) self.assertEqual([echo_messages[7]], result)
self.sendLine( self.sendLine(
@ -648,7 +682,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s msgid=%s %d" "CHATHISTORY AROUND %s msgid=%s %d"
% (chname, echo_messages[7].msgid, 3), % (chname, echo_messages[7].msgid, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertEqual(echo_messages[6:9], result) self.assertEqual(echo_messages[6:9], result)
if self._supports_timestamp(): if self._supports_timestamp():
@ -657,7 +691,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
"CHATHISTORY AROUND %s timestamp=%s %d" "CHATHISTORY AROUND %s timestamp=%s %d"
% (chname, echo_messages[7].time, 3), % (chname, echo_messages[7].time, 3),
) )
result = self.validate_chathistory_batch(self.getMessages(user), chname) result = self.validate_chathistory_batch(user, chname)
self.assertIn(echo_messages[7], result) self.assertIn(echo_messages[7], result)
@pytest.mark.arbitrary_client_tags @pytest.mark.arbitrary_client_tags
@ -718,6 +752,8 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
self.assertEqual(len(relay), 1) self.assertEqual(len(relay), 1)
validate_tagmsg(relay[0], chname, msgid) validate_tagmsg(relay[0], chname, msgid)
self._wait_before_chathistory()
self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,)) self.sendLine(1, "CHATHISTORY LATEST %s * 10" % (chname,))
history_tagmsgs = [ history_tagmsgs = [
msg for msg in self.getMessages(1) if msg.command == "TAGMSG" msg for msg in self.getMessages(1) if msg.command == "TAGMSG"
@ -814,8 +850,95 @@ class ChathistoryTestCase(cases.BaseServerTestCase):
validate_msg(relay) validate_msg(relay)
@cases.mark_specifications("IRCv3")
@cases.mark_services
class ChathistoryTestCase(_BaseChathistoryTests):
def _wait_before_chathistory(self):
"""does nothing"""
pass
assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == { assert {f"_validate_chathistory_{cmd}" for cmd in SUBCOMMANDS} == {
meth_name meth_name
for meth_name in dir(ChathistoryTestCase) for meth_name in dir(ChathistoryTestCase)
if meth_name.startswith("_validate_chathistory_") if meth_name.startswith("_validate_chathistory_")
}, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync" }, "ChathistoryTestCase.validate_chathistory and SUBCOMMANDS are out of sync"
@cases.mark_specifications("Sable")
@cases.mark_services
class SablePostgresqlHistoryTestCase(_BaseChathistoryTests):
# for every wall clock second, 15 seconds pass for the server.
# at x30, links between nodes timeout.
faketime = "+1y x15"
@staticmethod
def config() -> cases.TestCaseControllerConfig:
return dataclasses.replace( # type: ignore[no-any-return]
_BaseChathistoryTests.config(),
sable_history_server=True,
)
def _wait_before_chathistory(self):
"""waits 6 seconds which appears to be a 1.5 min to Sable; which goes over
the 1 min timeout for in-memory history (+ 1 min because the cleanup job
only runs every min)"""
assert self.controller.faketime_enabled, "faketime is not installed"
time.sleep(8)
@cases.mark_specifications("Sable")
@cases.mark_services
class SableExpiringHistoryTestCase(cases.BaseServerTestCase):
faketime = "+1y x15"
def _wait_before_chathistory(self):
"""waits 6 seconds which appears to be a 1.5 min to Sable; which goes over
the 1 min timeout for in-memory history (+ 1 min because the cleanup job
only runs every min)"""
assert self.controller.faketime_enabled, "faketime is not installed"
time.sleep(8)
def testChathistoryExpired(self):
"""Checks that Sable forgets about messages if the history server is not available"""
self.connectClient(
"bar",
capabilities=[
"message-tags",
"server-time",
"echo-message",
"batch",
"labeled-response",
"sasl",
CHATHISTORY_CAP,
],
skip_if_cap_nak=True,
)
chname = "#chan" + secrets.token_hex(12)
self.joinChannel(1, chname)
self.getMessages(1)
self.getMessages(1)
self.sendLine(1, f"PRIVMSG {chname} :this is a message")
self.getMessages(1)
self._wait_before_chathistory()
self.sendLine(1, f"CHATHISTORY LATEST {chname} * 10")
while not (messages := self.getMessages(1)):
# Sable processes CHATHISTORY asynchronously, which can be pretty slow as it
# sends cross-server requests. This means we can't just rely on a PING-PONG
# or the usual time.sleep(self.controller.sync_sleep_time) to make sure
# the ircd replied to us
time.sleep(self.controller.sync_sleep_time)
(start, *middle, end) = messages
self.assertMessageMatch(
start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", chname]
)
batch_tag = start.params[0][1:]
self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag])
self.assertEqual(
len(middle), 0, f"Got messages that should be expired: {middle}"
)

View File

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

View File

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

View File

@ -200,3 +200,78 @@ class JoinTestCase(cases.BaseServerTestCase):
fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', " fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', "
"got {got}", "got {got}",
) )
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testJoinKey(self):
"""Joins a single channel with a key"""
self.connectClient("chanop")
self.joinChannel(1, "#chan")
self.sendLine(1, "MODE #chan +k key")
self.getMessages(1)
self.connectClient("joiner")
self.sendLine(2, "JOIN #chan key")
self.assertMessageMatch(
self.getMessage(2),
command="JOIN",
params=["#chan"],
)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testJoinKeys(self):
"""Joins two channels, both with keys"""
self.connectClient("chanop")
if self.targmax.get("JOIN", "1000") == "1":
raise runner.OptionalExtensionNotSupported("Multi-target JOIN")
self.joinChannel(1, "#chan1")
self.sendLine(1, "MODE #chan1 +k key1")
self.getMessages(1)
self.joinChannel(1, "#chan2")
self.sendLine(1, "MODE #chan2 +k key2")
self.getMessages(1)
self.connectClient("joiner")
self.sendLine(2, "JOIN #chan1,#chan2 key1,key2")
self.assertMessageMatch(
self.getMessage(2),
command="JOIN",
params=["#chan1"],
)
self.assertMessageMatch(
[
msg
for msg in self.getMessages(2)
if msg.command not in {RPL_NAMREPLY, RPL_ENDOFNAMES}
][0],
command="JOIN",
params=["#chan2"],
)
@cases.mark_specifications("RFC1459", "RFC2812", "Modern")
def testJoinManySingleKey(self):
"""Joins two channels, the first one has a key."""
self.connectClient("chanop")
if self.targmax.get("JOIN", "1000") == "1":
raise runner.OptionalExtensionNotSupported("Multi-target JOIN")
self.joinChannel(1, "#chan1")
self.sendLine(1, "MODE #chan1 +k key1")
self.getMessages(1)
self.joinChannel(1, "#chan2")
self.getMessages(1)
self.connectClient("joiner")
self.sendLine(2, "JOIN #chan1,#chan2 key1")
self.assertMessageMatch(
self.getMessage(2),
command="JOIN",
params=["#chan1"],
)
self.assertMessageMatch(
[
msg
for msg in self.getMessages(2)
if msg.command not in {RPL_NAMREPLY, RPL_ENDOFNAMES}
][0],
command="JOIN",
params=["#chan2"],
)

View File

@ -140,9 +140,9 @@ class TagsTestCase(cases.BaseServerTestCase):
self.joinChannel(1, "#xyz") self.joinChannel(1, "#xyz")
monsterMessage = "@+clientOnlyTagExample=" + "a" * 4096 + " PRIVMSG #xyz hi!" monsterMessage = "@+clientOnlyTagExample=" + "a" * 4096 + " PRIVMSG #xyz hi!"
self.sendLine(1, monsterMessage) self.sendLine(1, monsterMessage)
self.assertEqual(self.getMessages(2), [], "overflowing message was relayed")
replies = self.getMessages(1) replies = self.getMessages(1)
self.assertIn(ERR_INPUTTOOLONG, set(reply.command for reply in replies)) self.assertIn(ERR_INPUTTOOLONG, set(reply.command for reply in replies))
self.assertEqual(self.getMessages(2), [], "overflowing message was relayed")
class LengthLimitTestCase(cases.BaseServerTestCase): class LengthLimitTestCase(cases.BaseServerTestCase):

View File

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

View File

@ -221,7 +221,6 @@ class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase):
) )
@cases.mark_specifications("RFC2812") @cases.mark_specifications("RFC2812")
@cases.xfailIfSoftware(["Sable"], "https://github.com/Libera-Chat/sable/issues/101")
def testWhoisMissingUser(self): def testWhoisMissingUser(self):
"""Test WHOIS on a nonexistent nickname.""" """Test WHOIS on a nonexistent nickname."""
self.connectClient("qux", name="qux") self.connectClient("qux", name="qux")

View File

@ -9,6 +9,7 @@ class Specifications(enum.Enum):
RFC2812 = "RFC2812" RFC2812 = "RFC2812"
IRCv3 = "IRCv3" # Mark with capabilities whenever possible IRCv3 = "IRCv3" # Mark with capabilities whenever possible
Ergo = "Ergo" Ergo = "Ergo"
Sable = "Sable"
Ircdocs = "ircdocs" Ircdocs = "ircdocs"
"""Any document on ircdocs.horse (especially defs.ircdocs.horse), """Any document on ircdocs.horse (especially defs.ircdocs.horse),
@ -24,6 +25,9 @@ class Specifications(enum.Enum):
return spec return spec
raise ValueError(name) raise ValueError(name)
def is_implementation_specific(self) -> bool:
return self in (Specifications.Ergo, Specifications.Sable)
@enum.unique @enum.unique
class Capabilities(enum.Enum): class Capabilities(enum.Enum):

View File

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

View File

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

View File

@ -7,7 +7,12 @@ markers =
IRCv3 IRCv3
modern modern
ircdocs ircdocs
# implementations for which we have specific test get two markers:
# the implementation name and 'implementation-specific'
implementation-specific
Ergo Ergo
Sable
# misc marks # misc marks
strict strict

View File

@ -97,7 +97,7 @@ software:
name: Bahamut name: Bahamut
repository: DALnet/Bahamut repository: DALnet/Bahamut
refs: refs:
stable: "v2.2.1" stable: "v2.2.4"
release: null release: null
devel: "master" devel: "master"
devel_release: null devel_release: null
@ -136,7 +136,7 @@ software:
pre_deps: pre_deps:
- uses: actions/setup-go@v3 - uses: actions/setup-go@v3
with: with:
go-version: '^1.22.0' go-version: '^1.23.0'
- run: go version - run: go version
separate_build_job: false separate_build_job: false
build_script: | build_script: |
@ -148,7 +148,7 @@ software:
name: InspIRCd name: InspIRCd
repository: inspircd/inspircd repository: inspircd/inspircd
refs: &inspircd_refs refs: &inspircd_refs
stable: v3.17.0 stable: v3.17.1
release: null release: null
devel: master devel: master
devel_release: insp3 devel_release: insp3
@ -230,7 +230,7 @@ software:
name: ngircd name: ngircd
repository: ngircd/ngircd repository: ngircd/ngircd
refs: refs:
stable: 3e3f6cbeceefd9357b53b27c2386bb39306ab353 # three years ahead of rel-26.1 stable: acf8409c60ccc96beed0a1f990c4f9374823c0ce # three months ahead of v27
release: null release: null
devel: master devel: master
devel_release: null devel_release: null
@ -249,7 +249,7 @@ software:
name: Sable name: Sable
repository: Libera-Chat/sable repository: Libera-Chat/sable
refs: refs:
stable: e9701e5e8d0c4f278ddd61ce7285f4918ecf99e9 stable: 034c4d5dd937774099773238d8d5b8054b015607
release: null release: null
devel: master devel: master
devel_release: null devel_release: null
@ -268,6 +268,9 @@ software:
workspaces: "sable -> target" workspaces: "sable -> target"
cache-on-failure: true cache-on-failure: true
- run: rustc --version - run: rustc --version
- run: start postgresql
run: "sudo systemctl start postgresql.service"
env: "IRCTEST_POSTGRESQL_URL=postgresql://localhost IRCTEST_DEBUG_LOGS=1"
separate_build_job: false separate_build_job: false
build_script: | build_script: |
cd $GITHUB_WORKSPACE/sable/ cd $GITHUB_WORKSPACE/sable/
@ -300,8 +303,8 @@ software:
name: UnrealIRCd 6 name: UnrealIRCd 6
repository: unrealircd/unrealircd repository: unrealircd/unrealircd
refs: refs:
stable: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7 stable: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1
release: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7 release: a68625454078641ce984eeb197f7e02b1857ab6c # 6.1.7.1
devel: unreal60_dev devel: unreal60_dev
devel_release: null devel_release: null
path: unrealircd path: unrealircd