diff --git a/.github/deploy_to_netlify.py b/.github/deploy_to_netlify.py new file mode 100755 index 0000000..1f77187 --- /dev/null +++ b/.github/deploy_to_netlify.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 + +import json +import os +import pprint +import re +import subprocess +import sys +import urllib.request + +event_name = os.environ["GITHUB_EVENT_NAME"] + +is_pull_request = is_push = False +if event_name.startswith("pull_request"): + is_pull_request = True +elif event_name.startswith("push"): + is_push = True +elif event_name.startswith("schedule"): + # Don't publish; scheduled workflows run against the latest commit of every + # implementation, so they are likely to have failed tests for the wrong reasons + sys.exit(0) +else: + print("Unexpected event name:", event_name) + +with open(os.environ["GITHUB_EVENT_PATH"]) as fd: + github_event = json.load(fd) + +pprint.pprint(github_event) + +context_suffix = "" + +command = ["netlify", "deploy", "--dir=dashboard/"] +if is_pull_request: + pr_number = github_event["number"] + sha = github_event.get("after") or github_event["pull_request"]["head"]["sha"] + # Aliases can't exceed 37 chars + command.extend(["--alias", f"pr-{pr_number}-{sha[0:10]}"]) + context_suffix = " (pull_request)" +elif is_push: + ref = github_event["ref"] + m = re.match("refs/heads/(.*)", ref) + if m: + branch = m.group(1) + sha = github_event["head_commit"]["id"] + + if branch in ("main", "master"): + command.extend(["--prod"]) + else: + command.extend(["--alias", f"br-{branch[0:23]}-{sha[0:10]}"]) + context_suffix = " (push)" + else: + # TODO + pass + + +print("Running", command) +proc = subprocess.run(command, capture_output=True) + +output = proc.stdout.decode() +assert proc.returncode == 0, (output, proc.stderr.decode()) +print(output) + +m = re.search("https://[^ ]*--[^ ]*netlify.app", output) +assert m +netlify_site_url = m.group(0) +target_url = f"{netlify_site_url}/index.xhtml" + +print("Published to", netlify_site_url) + + +def send_status() -> None: + statuses_url = github_event["repository"]["statuses_url"].format(sha=sha) + + payload = { + "state": "success", + "context": f"Dashboard{context_suffix}", + "description": "Table of all test results", + "target_url": target_url, + } + request = urllib.request.Request( + statuses_url, + data=json.dumps(payload).encode(), + headers={ + "Authorization": f'token {os.environ["GITHUB_TOKEN"]}', + "Content-Type": "text/json", + "Accept": "application/vnd.github+json", + }, + ) + + response = urllib.request.urlopen(request) + + assert response.status == 201, response.read() + + +send_status() + + +def send_pr_comment() -> None: + comments_url = github_event["pull_request"]["_links"]["comments"]["href"] + + payload = { + "body": f"[Test results]({target_url})", + } + request = urllib.request.Request( + comments_url, + data=json.dumps(payload).encode(), + headers={ + "Authorization": f'token {os.environ["GITHUB_TOKEN"]}', + "Content-Type": "text/json", + "Accept": "application/vnd.github+json", + }, + ) + + response = urllib.request.urlopen(request) + + assert response.status == 201, response.read() + + +if is_pull_request: + send_pr_comment() diff --git a/.github/workflows/test-devel.yml b/.github/workflows/test-devel.yml index ab1db60..3a9139e 100644 --- a/.github/workflows/test-devel.yml +++ b/.github/workflows/test-devel.yml @@ -3,28 +3,32 @@ jobs: build-anope: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - name: Create directories run: cd ~/; mkdir -p .local/ go/ - - name: Cache Anope - uses: actions/cache@v2 + - name: Cache dependencies + uses: actions/cache@v3 with: - key: 3-${{ runner.os }}-anope-2.0.9 + key: 3-${{ runner.os }}-anope-devel path: '~/.cache - ${{ github.workspace }}/anope + ${ github.workspace }/anope ' + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 - name: Checkout Anope - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: anope ref: 2.0.9 repository: anope/anope - name: Build Anope - run: |- + run: | cd $GITHUB_WORKSPACE/anope/ cp $GITHUB_WORKSPACE/data/anope/* . CFLAGS=-O0 ./Config -quick @@ -33,18 +37,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-anope path: ~/artefacts-*.tar.gz retention-days: 1 build-bahamut: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-bahamut-devel path: '~/.cache @@ -52,13 +56,13 @@ jobs: ${ github.workspace }/Bahamut ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Bahamut - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: Bahamut ref: master @@ -66,7 +70,12 @@ jobs: - name: Build Bahamut run: | cd $GITHUB_WORKSPACE/Bahamut/ - patch src/s_user.c < $GITHUB_WORKSPACE/bahamut_localhost.patch + patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch + patch src/s_bsd.c < $GITHUB_WORKSPACE/patches/bahamut_mainloop.patch + + # <= v2.2.2 + patch -p1 < $GITHUB_WORKSPACE/patches/bahamut_ubuntu22.patch || true + echo "#undef THROTTLE_ENABLE" >> include/config.h libtoolize --force aclocal @@ -81,18 +90,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-bahamut.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-bahamut path: ~/artefacts-*.tar.gz retention-days: 1 build-hybrid: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-hybrid-devel path: '~/.cache @@ -100,13 +109,13 @@ jobs: ${ github.workspace }/ircd-hybrid ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Hybrid - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ircd-hybrid ref: 8.2.x @@ -120,23 +129,23 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-hybrid.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-hybrid path: ~/artefacts-*.tar.gz retention-days: 1 build-inspircd: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout InspIRCd - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: inspircd ref: master @@ -144,26 +153,30 @@ jobs: - name: Build InspIRCd run: | cd $GITHUB_WORKSPACE/inspircd/ - patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch + + # Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP + patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true + wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp ./configure --prefix=$HOME/.local/inspircd --development - make -j 4 + + CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4 make install - name: Make artefact tarball run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-inspircd path: ~/artefacts-*.tar.gz retention-days: 1 build-ngircd: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-ngircd-devel path: '~/.cache @@ -171,13 +184,13 @@ jobs: ${ github.workspace }/ngircd ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout ngircd - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ngircd ref: master @@ -185,6 +198,7 @@ jobs: - name: Build ngircd run: | cd $GITHUB_WORKSPACE/ngircd + patch src/ngircd/client.c < $GITHUB_WORKSPACE/patches/ngircd_whowas_delay.patch ./autogen.sh ./configure --prefix=$HOME/.local/ make -j 4 @@ -192,18 +206,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-ngircd.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-ngircd path: ~/artefacts-*.tar.gz retention-days: 1 build-plexus4: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-plexus4-devel path: '~/.cache @@ -211,11 +225,11 @@ jobs: ${ github.workspace }/placeholder ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: clone run: 'curl https://gitlab.com/rizon/plexus4/-/archive/master/plexus4-master.tar.gz | tar -zx @@ -234,18 +248,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-plexus4.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-plexus4 path: ~/artefacts-*.tar.gz retention-days: 1 build-solanum: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-solanum-devel path: '~/.cache @@ -253,13 +267,13 @@ jobs: ${ github.workspace }/solanum ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Solanum - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: solanum ref: main @@ -274,18 +288,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-solanum.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-solanum path: ~/artefacts-*.tar.gz retention-days: 1 build-unrealircd: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-unrealircd-devel path: '~/.cache @@ -293,13 +307,13 @@ jobs: ${ github.workspace }/unrealircd ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout UnrealIRCd 6 - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: unrealircd ref: unreal60_dev @@ -315,21 +329,23 @@ jobs: CFLAGS="-O0 -march=x86-64" CXXFLAGS="$CFLAGS" ./Config -quick make -j 4 make install + # Prevent download of geoIP database on first startup + sed -i 's/loadmodule "geoip_classic";//' ~/.local/unrealircd/conf/modules.default.conf - name: Make artefact tarball run: cd ~; tar -czf artefacts-unrealircd.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-unrealircd path: ~/artefacts-*.tar.gz retention-days: 1 build-unrealircd-5: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-unrealircd-5-devel path: '~/.cache @@ -337,13 +353,13 @@ jobs: ${ github.workspace }/unrealircd ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout UnrealIRCd 5 - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: unrealircd ref: unreal52 @@ -359,17 +375,19 @@ jobs: CFLAGS="-O0 -march=x86-64" CXXFLAGS="$CFLAGS" ./Config -quick make -j 4 make install + # Prevent download of geoIP database on first startup + sed -i 's/loadmodule "geoip_classic";//' ~/.local/unrealircd/conf/modules.default.conf - name: Make artefact tarball run: cd ~; tar -czf artefacts-unrealircd-5.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-unrealircd-5 path: ~/artefacts-*.tar.gz retention-days: 1 publish-test-results: if: success() || failure() - name: Publish Unit Tests Results + name: Publish Dashboard needs: - test-bahamut - test-bahamut-anope @@ -380,63 +398,63 @@ jobs: - test-inspircd-anope - test-ircu2 - test-limnoria + - test-nefarious - test-ngircd - test-ngircd-anope - test-ngircd-atheme - test-plexus4 + - test-sable - test-solanum - test-sopel + - test-thelounge - test-unrealircd - test-unrealircd-5 - test-unrealircd-anope - test-unrealircd-atheme - runs-on: ubuntu-latest + - test-unrealircd-dlk + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: path: artifacts - - if: github.event_name == 'pull_request' - name: Publish Unit Test Results - uses: actions/github-script@v4 - with: - result-encoding: string - script: | - let body = ''; - const options = {}; - options.listeners = { - stdout: (data) => { - body += data.toString(); - } - }; - await exec.exec('bash', ['-c', 'shopt -s globstar; python3 report.py artifacts/**/*.xml'], options); - github.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body, - }); - return body; + - name: Install dashboard dependencies + run: |- + python -m pip install --upgrade pip + pip install defusedxml docutils -r requirements.txt + - name: Generate dashboard + run: |- + shopt -s globstar + python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml + echo '/ /index.xhtml' > dashboard/_redirects + - name: Install netlify-cli + run: npm i -g netlify-cli + - env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + name: Deploy to Netlify + run: ./.github/deploy_to_netlify.py test-bahamut: needs: - build-bahamut - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-bahamut path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -444,37 +462,38 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make bahamut + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results bahamut (devel) + name: pytest-results_bahamut_devel path: pytest.xml test-bahamut-anope: needs: - build-bahamut - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-bahamut path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -482,31 +501,32 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make bahamut-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results bahamut-anope (devel) + name: pytest-results_bahamut-anope_devel path: pytest.xml test-bahamut-atheme: needs: - build-bahamut - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-bahamut path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -514,76 +534,78 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make bahamut-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results bahamut-atheme (devel) + name: pytest-results_bahamut-atheme_devel path: pytest.xml test-ergo: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Ergo - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ergo ref: master repository: ergochat/ergo - uses: actions/setup-go@v2 with: - go-version: ^1.17.0 + go-version: ^1.21.0 - run: go version - name: Build Ergo run: | cd $GITHUB_WORKSPACE/ergo/ make build make install - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/go/sbin:~/go/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/go/sbin:~/go/bin:~/go:$PATH make ergo + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ergo (devel) + name: pytest-results_ergo_devel path: pytest.xml test-hybrid: needs: - build-hybrid - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-hybrid path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -591,93 +613,96 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make hybrid + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results hybrid (devel) + name: pytest-results_hybrid_devel path: pytest.xml test-inspircd: needs: - build-inspircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd (devel) + name: pytest-results_inspircd_devel path: pytest.xml test-inspircd-anope: needs: - build-inspircd - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH make + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd-anope (devel) + name: pytest-results_inspircd-anope_devel path: pytest.xml test-ircu2: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout ircu2 - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ircu2 ref: u2_10_12_branch @@ -690,8 +715,8 @@ jobs: ./configure --prefix=$HOME/.local/ --with-maxcon=1024 --enable-debug make -j 4 make install - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -699,26 +724,27 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make ircu2 + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ircu2 (devel) + name: pytest-results_ircu2_devel path: pytest.xml test-limnoria: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Install dependencies run: pip install git+https://github.com/ProgVal/Limnoria.git@testing cryptography pyxmpp2-scram - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -726,139 +752,181 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make limnoria + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results limnoria (devel) + name: pytest-results_limnoria_devel path: pytest.xml - test-ngircd: - needs: - - build-ngircd - runs-on: ubuntu-latest + test-nefarious: + needs: [] + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 - - name: Download build artefacts - uses: actions/download-artifact@v2 + python-version: 3.11 + - name: Checkout nefarious + uses: actions/checkout@v3 with: - name: installed-ngircd - path: '~' - - name: Unpack artefacts - run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + path: nefarious + ref: master + repository: evilnet/nefarious2 + - name: Build nefarious + run: | + cd $GITHUB_WORKSPACE/nefarious + ./configure --prefix=$HOME/.local/ --enable-debug + make -j 4 + make install + cp $GITHUB_WORKSPACE/data/nefarious/* $HOME/.local/lib + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:$PATH - make ngircd + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make + nefarious + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ngircd (devel) + name: pytest-results_nefarious_devel + path: pytest.xml + test-ngircd: + needs: + - build-ngircd + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Download build artefacts + uses: actions/download-artifact@v3 + with: + name: installed-ngircd + path: '~' + - name: Unpack artefacts + run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime + - name: Install irctest dependencies + run: |- + python -m pip install --upgrade pip + pip install pytest pytest-xdist -r requirements.txt + - name: Test with pytest + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:~/.local/:$PATH + make ngircd + timeout-minutes: 30 + - if: always() + name: Publish results + uses: actions/upload-artifact@v3 + with: + name: pytest-results_ngircd_devel path: pytest.xml test-ngircd-anope: needs: - build-ngircd - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-ngircd path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:$PATH make + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:~/.local/:$PATH make ngircd-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ngircd-anope (devel) + name: pytest-results_ngircd-anope_devel path: pytest.xml test-ngircd-atheme: needs: - build-ngircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-ngircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:~/.local/:$PATH make ngircd-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ngircd-atheme (devel) + name: pytest-results_ngircd-atheme_devel path: pytest.xml test-plexus4: needs: - build-plexus4 - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-plexus4 path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -866,31 +934,79 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make plexus4 + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results plexus4 (devel) + name: pytest-results_plexus4_devel + path: pytest.xml + test-sable: + needs: [] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Checkout Sable + uses: actions/checkout@v3 + with: + path: sable + ref: master + repository: Libera-Chat/sable + - name: Install rust toolchain + uses: actions-rs/toolchain@v1 + with: + override: true + profile: minimal + toolchain: nightly + - name: Enable Cargo cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + workspaces: sable -> target + - run: rustc --version + - name: Build Sable + run: | + cd $GITHUB_WORKSPACE/sable/ + cargo build + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime + - name: Install irctest dependencies + run: |- + python -m pip install --upgrade pip + pip install pytest pytest-xdist -r requirements.txt + - name: Test with pytest + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH + make sable + timeout-minutes: 30 + - if: always() + name: Publish results + uses: actions/upload-artifact@v3 + with: + name: pytest-results_sable_devel path: pytest.xml test-solanum: needs: - build-solanum - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-solanum path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -898,25 +1014,26 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make solanum + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results solanum (devel) + name: pytest-results_solanum_devel path: pytest.xml test-sopel: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Install dependencies run: pip install git+https://github.com/sopel-irc/sopel.git - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -924,145 +1041,234 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make sopel + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results sopel (devel) + name: pytest-results_sopel_devel path: pytest.xml - test-unrealircd: - needs: - - build-unrealircd - runs-on: ubuntu-latest + test-thelounge: + needs: [] + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 - - name: Download build artefacts - uses: actions/download-artifact@v2 + python-version: 3.11 + - name: Checkout TheLounge + uses: actions/checkout@v3 with: - name: installed-unrealircd - path: '~' - - name: Unpack artefacts - run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + path: thelounge + ref: master + repository: thelounge/thelounge + - name: Build TheLounge + run: | + cd $GITHUB_WORKSPACE/thelounge + yarn install + NODE_ENV=production yarn build + mkdir -p ~/.local/bin/ + ln -s $(pwd)/index.js ~/.local/bin/thelounge + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH - make unrealircd + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make + thelounge + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd (devel) + name: pytest-results_thelounge_devel + path: pytest.xml + test-unrealircd: + needs: + - build-unrealircd + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Download build artefacts + uses: actions/download-artifact@v3 + with: + name: installed-unrealircd + path: '~' + - name: Unpack artefacts + run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime + - name: Install irctest dependencies + run: |- + python -m pip install --upgrade pip + pip install pytest pytest-xdist -r requirements.txt + - name: Test with pytest + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH + make unrealircd + timeout-minutes: 30 + - if: always() + name: Publish results + uses: actions/upload-artifact@v3 + with: + name: pytest-results_unrealircd_devel path: pytest.xml test-unrealircd-5: needs: - build-unrealircd-5 - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-unrealircd-5 path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH make unrealircd-5 + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd-5 (devel) + name: pytest-results_unrealircd-5_devel path: pytest.xml test-unrealircd-anope: needs: - build-unrealircd - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-unrealircd path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH make + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH make unrealircd-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd-anope (devel) + name: pytest-results_unrealircd-anope_devel path: pytest.xml test-unrealircd-atheme: needs: - build-unrealircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-unrealircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH make unrealircd-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd-atheme (devel) + name: pytest-results_unrealircd-atheme_devel + path: pytest.xml + test-unrealircd-dlk: + needs: + - build-unrealircd + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Download build artefacts + uses: actions/download-artifact@v3 + with: + name: installed-unrealircd + path: '~' + - name: Unpack artefacts + run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; + - name: Checkout Dlk + uses: actions/checkout@v3 + with: + path: Dlk-Services + ref: main + repository: DalekIRC/Dalek-Services + - name: Build Dlk + run: | + pip install pifpaf + wget -q https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar + wget -q https://wordpress.org/latest.zip -O wordpress-latest.zip + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime + - name: Install irctest dependencies + run: |- + python -m pip install --upgrade pip + pip install pytest pytest-xdist -r requirements.txt + - name: Test with pytest + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH + IRCTEST_DLK_PATH="${{ github.workspace }}/Dlk-Services" IRCTEST_WP_CLI_PATH="${{ + github.workspace }}/wp-cli.phar" IRCTEST_WP_ZIP_PATH="${{ github.workspace + }}/wordpress-latest.zip" make unrealircd-dlk + timeout-minutes: 30 + - if: always() + name: Publish results + uses: actions/upload-artifact@v3 + with: + name: pytest-results_unrealircd-dlk_devel path: pytest.xml name: irctest with devel versions 'on': diff --git a/.github/workflows/test-devel_release.yml b/.github/workflows/test-devel_release.yml index 1cebe0e..69fce16 100644 --- a/.github/workflows/test-devel_release.yml +++ b/.github/workflows/test-devel_release.yml @@ -3,28 +3,32 @@ jobs: build-anope: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - name: Create directories run: cd ~/; mkdir -p .local/ go/ - - name: Cache Anope - uses: actions/cache@v2 + - name: Cache dependencies + uses: actions/cache@v3 with: - key: 3-${{ runner.os }}-anope-2.0.9 + key: 3-${{ runner.os }}-anope-devel_release path: '~/.cache - ${{ github.workspace }}/anope + ${ github.workspace }/anope ' + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 - name: Checkout Anope - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: anope ref: 2.0.9 repository: anope/anope - name: Build Anope - run: |- + run: | cd $GITHUB_WORKSPACE/anope/ cp $GITHUB_WORKSPACE/data/anope/* . CFLAGS=-O0 ./Config -quick @@ -33,23 +37,23 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-anope path: ~/artefacts-*.tar.gz retention-days: 1 build-inspircd: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout InspIRCd - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: inspircd ref: insp3 @@ -57,155 +61,158 @@ jobs: - name: Build InspIRCd run: | cd $GITHUB_WORKSPACE/inspircd/ - patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch + + # Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP + patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true + wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp ./configure --prefix=$HOME/.local/inspircd --development - make -j 4 + + CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4 make install - name: Make artefact tarball run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-inspircd path: ~/artefacts-*.tar.gz retention-days: 1 publish-test-results: if: success() || failure() - name: Publish Unit Tests Results + name: Publish Dashboard needs: - test-inspircd - test-inspircd-anope - test-inspircd-atheme - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: path: artifacts - - if: github.event_name == 'pull_request' - name: Publish Unit Test Results - uses: actions/github-script@v4 - with: - result-encoding: string - script: | - let body = ''; - const options = {}; - options.listeners = { - stdout: (data) => { - body += data.toString(); - } - }; - await exec.exec('bash', ['-c', 'shopt -s globstar; python3 report.py artifacts/**/*.xml'], options); - github.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body, - }); - return body; + - name: Install dashboard dependencies + run: |- + python -m pip install --upgrade pip + pip install defusedxml docutils -r requirements.txt + - name: Generate dashboard + run: |- + shopt -s globstar + python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml + echo '/ /index.xhtml' > dashboard/_redirects + - name: Install netlify-cli + run: npm i -g netlify-cli + - env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + name: Deploy to Netlify + run: ./.github/deploy_to_netlify.py test-inspircd: needs: - build-inspircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd (devel_release) + name: pytest-results_inspircd_devel_release path: pytest.xml test-inspircd-anope: needs: - build-inspircd - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH make + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd-anope (devel_release) + name: pytest-results_inspircd-anope_devel_release path: pytest.xml test-inspircd-atheme: needs: - build-inspircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd-atheme (devel_release) + name: pytest-results_inspircd-atheme_devel_release path: pytest.xml name: irctest with devel_release versions 'on': diff --git a/.github/workflows/test-stable.yml b/.github/workflows/test-stable.yml index d6019ad..9e7f63d 100644 --- a/.github/workflows/test-stable.yml +++ b/.github/workflows/test-stable.yml @@ -3,28 +3,32 @@ jobs: build-anope: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - name: Create directories run: cd ~/; mkdir -p .local/ go/ - - name: Cache Anope - uses: actions/cache@v2 + - name: Cache dependencies + uses: actions/cache@v3 with: - key: 3-${{ runner.os }}-anope-2.0.9 + key: 3-${{ runner.os }}-anope-stable path: '~/.cache - ${{ github.workspace }}/anope + ${ github.workspace }/anope ' + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 - name: Checkout Anope - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: anope ref: 2.0.9 repository: anope/anope - name: Build Anope - run: |- + run: | cd $GITHUB_WORKSPACE/anope/ cp $GITHUB_WORKSPACE/data/anope/* . CFLAGS=-O0 ./Config -quick @@ -33,18 +37,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-anope.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-anope path: ~/artefacts-*.tar.gz retention-days: 1 build-bahamut: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-bahamut-stable path: '~/.cache @@ -52,21 +56,26 @@ jobs: ${ github.workspace }/Bahamut ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Bahamut - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: Bahamut - ref: v2.2.0 + ref: v2.2.1 repository: DALnet/Bahamut - name: Build Bahamut run: | cd $GITHUB_WORKSPACE/Bahamut/ - patch src/s_user.c < $GITHUB_WORKSPACE/bahamut_localhost.patch + patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch + patch src/s_bsd.c < $GITHUB_WORKSPACE/patches/bahamut_mainloop.patch + + # <= v2.2.2 + patch -p1 < $GITHUB_WORKSPACE/patches/bahamut_ubuntu22.patch || true + echo "#undef THROTTLE_ENABLE" >> include/config.h libtoolize --force aclocal @@ -81,18 +90,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-bahamut.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-bahamut path: ~/artefacts-*.tar.gz retention-days: 1 build-charybdis: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-charybdis-stable path: '~/.cache @@ -100,13 +109,13 @@ jobs: ${ github.workspace }/charybdis ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Charybdis - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: charybdis ref: charybdis-4.1.2 @@ -114,6 +123,7 @@ jobs: - name: Build Charybdis run: | cd $GITHUB_WORKSPACE/charybdis/ + patch -p1 < $GITHUB_WORKSPACE/patches/charybdis_ubuntu22.patch ./autogen.sh ./configure --prefix=$HOME/.local/ make -j 4 @@ -121,18 +131,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-charybdis.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-charybdis path: ~/artefacts-*.tar.gz retention-days: 1 build-hybrid: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-hybrid-stable path: '~/.cache @@ -140,16 +150,16 @@ jobs: ${ github.workspace }/ircd-hybrid ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Hybrid - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ircd-hybrid - ref: 8.2.38 + ref: 8.2.39 repository: ircd-hybrid/ircd-hybrid - name: Build Hybrid run: | @@ -160,50 +170,54 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-hybrid.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-hybrid path: ~/artefacts-*.tar.gz retention-days: 1 build-inspircd: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout InspIRCd - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: inspircd - ref: v3.10.0 + ref: v3.15.0 repository: inspircd/inspircd - name: Build InspIRCd run: | cd $GITHUB_WORKSPACE/inspircd/ - patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch + + # Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP + patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true + wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp ./configure --prefix=$HOME/.local/inspircd --development - make -j 4 + + CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4 make install - name: Make artefact tarball run: cd ~; tar -czf artefacts-inspircd.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-inspircd path: ~/artefacts-*.tar.gz retention-days: 1 build-ngircd: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-ngircd-stable path: '~/.cache @@ -211,20 +225,21 @@ jobs: ${ github.workspace }/ngircd ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout ngircd - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ngircd - ref: rel-26.1 + ref: 0714466af88d71d6c395629cd7fb624b099507d4 repository: ngircd/ngircd - name: Build ngircd run: | cd $GITHUB_WORKSPACE/ngircd + patch src/ngircd/client.c < $GITHUB_WORKSPACE/patches/ngircd_whowas_delay.patch ./autogen.sh ./configure --prefix=$HOME/.local/ make -j 4 @@ -232,18 +247,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-ngircd.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-ngircd path: ~/artefacts-*.tar.gz retention-days: 1 build-plexus4: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-plexus4-stable path: '~/.cache @@ -251,16 +266,16 @@ jobs: ${ github.workspace }/placeholder ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: clone - run: 'curl https://gitlab.com/rizon/plexus4/-/archive/403a967e3677a2a8420b504f451e7557259e0790/plexus4-403a967e3677a2a8420b504f451e7557259e0790.tar.gz - | tar -zx + run: 'curl https://gitlab.com/rizon/plexus4/-/archive/20211115_0-611/plexus4-20211115_0-611.tar + | tar -x - mv plexus4* plexus4' + mv plexus* plexus4' - name: build run: 'cd $GITHUB_WORKSPACE/plexus4 @@ -274,18 +289,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-plexus4.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-plexus4 path: ~/artefacts-*.tar.gz retention-days: 1 build-solanum: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-solanum-stable path: '~/.cache @@ -293,16 +308,16 @@ jobs: ${ github.workspace }/solanum ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Solanum - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: solanum - ref: e370888264da666a1bd9faac86cd5f2aa06084f4 + ref: 492d560ee13e71dc35403fd676e58c2d5bdcf2a9 repository: solanum-ircd/solanum - name: Build Solanum run: | @@ -314,18 +329,18 @@ jobs: - name: Make artefact tarball run: cd ~; tar -czf artefacts-solanum.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-solanum path: ~/artefacts-*.tar.gz retention-days: 1 build-unrealircd: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-unrealircd-stable path: '~/.cache @@ -333,16 +348,16 @@ jobs: ${ github.workspace }/unrealircd ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout UnrealIRCd 6 - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: unrealircd - ref: d77f42e4bef388ae344256eeef9a8000345ae381 + ref: da3c1c654481a33035b9c703957e1c25d0158259 repository: unrealircd/unrealircd - name: Build UnrealIRCd 6 run: | @@ -355,21 +370,23 @@ jobs: CFLAGS="-O0 -march=x86-64" CXXFLAGS="$CFLAGS" ./Config -quick make -j 4 make install + # Prevent download of geoIP database on first startup + sed -i 's/loadmodule "geoip_classic";//' ~/.local/unrealircd/conf/modules.default.conf - name: Make artefact tarball run: cd ~; tar -czf artefacts-unrealircd.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-unrealircd path: ~/artefacts-*.tar.gz retention-days: 1 build-unrealircd-5: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Create directories run: cd ~/; mkdir -p .local/ go/ - name: Cache dependencies - uses: actions/cache@v2 + uses: actions/cache@v3 with: key: 3-${{ runner.os }}-unrealircd-5-stable path: '~/.cache @@ -377,16 +394,16 @@ jobs: ${ github.workspace }/unrealircd ' - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout UnrealIRCd 5 - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: unrealircd - ref: 94993a03ca8d3c193c0295c33af39270c3f9d27d + ref: 6604856973f713a494f83d38992d7d61ce6b9db4 repository: unrealircd/unrealircd - name: Build UnrealIRCd 5 run: | @@ -399,17 +416,19 @@ jobs: CFLAGS="-O0 -march=x86-64" CXXFLAGS="$CFLAGS" ./Config -quick make -j 4 make install + # Prevent download of geoIP database on first startup + sed -i 's/loadmodule "geoip_classic";//' ~/.local/unrealircd/conf/modules.default.conf - name: Make artefact tarball run: cd ~; tar -czf artefacts-unrealircd-5.tar.gz .local/ go/ - name: Upload build artefacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: installed-unrealircd-5 path: ~/artefacts-*.tar.gz retention-days: 1 publish-test-results: if: success() || failure() - name: Publish Unit Tests Results + name: Publish Dashboard needs: - test-bahamut - test-bahamut-anope @@ -423,63 +442,62 @@ jobs: - test-irc2 - test-ircu2 - test-limnoria + - test-nefarious - test-ngircd - test-ngircd-anope - test-ngircd-atheme - test-plexus4 + - test-sable - test-solanum - test-sopel + - test-thelounge - test-unrealircd - test-unrealircd-5 - test-unrealircd-anope - test-unrealircd-atheme - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Download Artifacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: path: artifacts - - if: github.event_name == 'pull_request' - name: Publish Unit Test Results - uses: actions/github-script@v4 - with: - result-encoding: string - script: | - let body = ''; - const options = {}; - options.listeners = { - stdout: (data) => { - body += data.toString(); - } - }; - await exec.exec('bash', ['-c', 'shopt -s globstar; python3 report.py artifacts/**/*.xml'], options); - github.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body, - }); - return body; + - name: Install dashboard dependencies + run: |- + python -m pip install --upgrade pip + pip install defusedxml docutils -r requirements.txt + - name: Generate dashboard + run: |- + shopt -s globstar + python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml + echo '/ /index.xhtml' > dashboard/_redirects + - name: Install netlify-cli + run: npm i -g netlify-cli + - env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} + NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} + name: Deploy to Netlify + run: ./.github/deploy_to_netlify.py test-bahamut: needs: - build-bahamut - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-bahamut path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -487,37 +505,38 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make bahamut + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results bahamut (stable) + name: pytest-results_bahamut_stable path: pytest.xml test-bahamut-anope: needs: - build-bahamut - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-bahamut path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -525,31 +544,32 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make bahamut-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results bahamut-anope (stable) + name: pytest-results_bahamut-anope_stable path: pytest.xml test-bahamut-atheme: needs: - build-bahamut - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-bahamut path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -557,31 +577,32 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make bahamut-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results bahamut-atheme (stable) + name: pytest-results_bahamut-atheme_stable path: pytest.xml test-charybdis: needs: - build-charybdis - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-charybdis path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -589,76 +610,78 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make charybdis + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results charybdis (stable) + name: pytest-results_charybdis_stable path: pytest.xml test-ergo: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout Ergo - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ergo ref: irctest_stable repository: ergochat/ergo - uses: actions/setup-go@v2 with: - go-version: ^1.17.0 + go-version: ^1.21.0 - run: go version - name: Build Ergo run: | cd $GITHUB_WORKSPACE/ergo/ make build make install - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/go/sbin:~/go/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/go/sbin:~/go/bin:~/go:$PATH make ergo + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ergo (stable) + name: pytest-results_ergo_stable path: pytest.xml test-hybrid: needs: - build-hybrid - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-hybrid path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -666,155 +689,154 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make hybrid + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results hybrid (stable) + name: pytest-results_hybrid_stable path: pytest.xml test-inspircd: needs: - build-inspircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd (stable) + name: pytest-results_inspircd_stable path: pytest.xml test-inspircd-anope: needs: - build-inspircd - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH make + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd-anope (stable) + name: pytest-results_inspircd-anope_stable path: pytest.xml test-inspircd-atheme: needs: - build-inspircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-inspircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/inspircd/sbin:~/.local/inspircd/bin:~/.local/inspircd:$PATH make inspircd-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results inspircd-atheme (stable) + name: pytest-results_inspircd-atheme_stable path: pytest.xml test-irc2: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 - - name: Get source code - run: curl http://ftp.irc.org/ftp/irc/server/irc2.11.2p3.tgz | tar -zx - - name: Configure - run: 'cd $GITHUB_WORKSPACE/irc2.11.2p3 - + python-version: 3.11 + - name: Checkout irc2 + uses: actions/checkout@v3 + with: + path: irc2.11.2p3 + ref: 59649f24c3a5c27bad5648b48774f27475bccfd3 + repository: irc-archive/irc2-mirror + - name: Build irc2 + run: | + # Configure + cd $GITHUB_WORKSPACE/irc2.11.2p3 ./configure --prefix=$HOME/.local/ - cd x86* - echo "#define CMDLINE_CONFIG/" >> config.h - echo "#define DEFAULT_SPLIT_USERS 0" >> config.h - echo "#define DEFAULT_SPLIT_SERVERS 0" >> config.h - #echo "#undef LIST_ALIS_NOTE" >> config.h - # TODO: find a better way to make it not fork... + echo "#define fork() (0)" >> config.h - echo "#define fork() (0)" >> config.h' - - name: Compile and install - run: 'cd $GITHUB_WORKSPACE/irc2.11.2p3/x86* - + # Compile and install + cd $GITHUB_WORKSPACE/irc2.11.2p3/x86* make -j 4 all - make install - mkdir -p $HOME/.local/bin - - cp $HOME/.local/sbin/ircd $HOME/.local/bin/ircd' - - name: Install Atheme - run: sudo apt-get install atheme-services + cp $HOME/.local/sbin/ircd $HOME/.local/bin/ircd + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -822,23 +844,24 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make irc2 + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results irc2 (stable) + name: pytest-results_irc2_stable path: pytest.xml test-ircu2: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Checkout ircu2 - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: path: ircu2 ref: u2.10.12.19 @@ -851,8 +874,8 @@ jobs: ./configure --prefix=$HOME/.local/ --with-maxcon=1024 --enable-debug make -j 4 make install - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -860,25 +883,26 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make ircu2 + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ircu2 (stable) + name: pytest-results_ircu2_stable path: pytest.xml test-limnoria: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Install dependencies - run: pip install limnoria==2021.10.09 cryptography pyxmpp2-scram - - name: Install Atheme - run: sudo apt-get install atheme-services + run: pip install limnoria==2023.5.27 cryptography pyxmpp2-scram + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -886,139 +910,181 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make limnoria + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results limnoria (stable) + name: pytest-results_limnoria_stable path: pytest.xml - test-ngircd: - needs: - - build-ngircd - runs-on: ubuntu-latest + test-nefarious: + needs: [] + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 - - name: Download build artefacts - uses: actions/download-artifact@v2 + python-version: 3.11 + - name: Checkout nefarious + uses: actions/checkout@v3 with: - name: installed-ngircd - path: '~' - - name: Unpack artefacts - run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + path: nefarious + ref: 985704168ecada12d9e53b46df6087ef9d9fb40b + repository: evilnet/nefarious2 + - name: Build nefarious + run: | + cd $GITHUB_WORKSPACE/nefarious + ./configure --prefix=$HOME/.local/ --enable-debug + make -j 4 + make install + cp $GITHUB_WORKSPACE/data/nefarious/* $HOME/.local/lib + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:$PATH - make ngircd + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make + nefarious + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ngircd (stable) + name: pytest-results_nefarious_stable + path: pytest.xml + test-ngircd: + needs: + - build-ngircd + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Download build artefacts + uses: actions/download-artifact@v3 + with: + name: installed-ngircd + path: '~' + - name: Unpack artefacts + run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime + - name: Install irctest dependencies + run: |- + python -m pip install --upgrade pip + pip install pytest pytest-xdist -r requirements.txt + - name: Test with pytest + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:~/.local/:$PATH + make ngircd + timeout-minutes: 30 + - if: always() + name: Publish results + uses: actions/upload-artifact@v3 + with: + name: pytest-results_ngircd_stable path: pytest.xml test-ngircd-anope: needs: - build-ngircd - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-ngircd path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:$PATH make + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:~/.local/:$PATH make ngircd-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ngircd-anope (stable) + name: pytest-results_ngircd-anope_stable path: pytest.xml test-ngircd-atheme: needs: - build-ngircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-ngircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local//sbin:~/.local//bin:~/.local/:$PATH make ngircd-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results ngircd-atheme (stable) + name: pytest-results_ngircd-atheme_stable path: pytest.xml test-plexus4: needs: - build-plexus4 - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-plexus4 path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -1026,31 +1092,79 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make plexus4 + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results plexus4 (stable) + name: pytest-results_plexus4_stable + path: pytest.xml + test-sable: + needs: [] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Checkout Sable + uses: actions/checkout@v3 + with: + path: sable + ref: ff1179512a79eba57ca468a5f83af84ecce08a5b + repository: Libera-Chat/sable + - name: Install rust toolchain + uses: actions-rs/toolchain@v1 + with: + override: true + profile: minimal + toolchain: nightly + - name: Enable Cargo cache + uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: true + workspaces: sable -> target + - run: rustc --version + - name: Build Sable + run: | + cd $GITHUB_WORKSPACE/sable/ + cargo build + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime + - name: Install irctest dependencies + run: |- + python -m pip install --upgrade pip + pip install pytest pytest-xdist -r requirements.txt + - name: Test with pytest + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=$GITHUB_WORKSPACE/sable/target/debug/sbin:$GITHUB_WORKSPACE/sable/target/debug/bin:$GITHUB_WORKSPACE/sable/target/debug:$PATH + make sable + timeout-minutes: 30 + - if: always() + name: Publish results + uses: actions/upload-artifact@v3 + with: + name: pytest-results_sable_stable path: pytest.xml test-solanum: needs: - build-solanum - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-solanum path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -1058,25 +1172,26 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make solanum + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results solanum (stable) + name: pytest-results_solanum_stable path: pytest.xml test-sopel: needs: [] - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Install dependencies - run: pip install sopel==7.1.1 - - name: Install Atheme - run: sudo apt-get install atheme-services + run: pip install sopel==7.1.8 + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip @@ -1084,145 +1199,188 @@ jobs: - name: Test with pytest run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make sopel + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results sopel (stable) + name: pytest-results_sopel_stable path: pytest.xml - test-unrealircd: - needs: - - build-unrealircd - runs-on: ubuntu-latest + test-thelounge: + needs: [] + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 - - name: Download build artefacts - uses: actions/download-artifact@v2 + python-version: 3.11 + - name: Checkout TheLounge + uses: actions/checkout@v3 with: - name: installed-unrealircd - path: '~' - - name: Unpack artefacts - run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + path: thelounge + ref: v4.4.0 + repository: thelounge/thelounge + - name: Build TheLounge + run: | + cd $GITHUB_WORKSPACE/thelounge + yarn install + NODE_ENV=production yarn build + mkdir -p ~/.local/bin/ + ln -s $(pwd)/index.js ~/.local/bin/thelounge + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH - make unrealircd + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH make + thelounge + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd (stable) + name: pytest-results_thelounge_stable + path: pytest.xml + test-unrealircd: + needs: + - build-unrealircd + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 + with: + python-version: 3.11 + - name: Download build artefacts + uses: actions/download-artifact@v3 + with: + name: installed-unrealircd + path: '~' + - name: Unpack artefacts + run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime + - name: Install irctest dependencies + run: |- + python -m pip install --upgrade pip + pip install pytest pytest-xdist -r requirements.txt + - name: Test with pytest + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH + make unrealircd + timeout-minutes: 30 + - if: always() + name: Publish results + uses: actions/upload-artifact@v3 + with: + name: pytest-results_unrealircd_stable path: pytest.xml test-unrealircd-5: needs: - build-unrealircd-5 - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-unrealircd-5 path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH make unrealircd-5 + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd-5 (stable) + name: pytest-results_unrealircd-5_stable path: pytest.xml test-unrealircd-anope: needs: - build-unrealircd - build-anope - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-unrealircd path: '~' - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-anope path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH make + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH make unrealircd-anope + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd-anope (stable) + name: pytest-results_unrealircd-anope_stable path: pytest.xml test-unrealircd-atheme: needs: - build-unrealircd - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.7 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python 3.11 + uses: actions/setup-python@v4 with: - python-version: 3.7 + python-version: 3.11 - name: Download build artefacts - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: installed-unrealircd path: '~' - name: Unpack artefacts run: cd ~; find -name 'artefacts-*.tar.gz' -exec tar -xzf '{}' \; - - name: Install Atheme - run: sudo apt-get install atheme-services + - name: Install system dependencies + run: sudo apt-get install atheme-services faketime - name: Install irctest dependencies run: |- python -m pip install --upgrade pip pip install pytest pytest-xdist -r requirements.txt - name: Test with pytest - run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:$PATH + run: PYTEST_ARGS='--junit-xml pytest.xml' PATH=$HOME/.local/bin:$PATH PATH=~/.local/unrealircd/sbin:~/.local/unrealircd/bin:~/.local/unrealircd:$PATH make unrealircd-atheme + timeout-minutes: 30 - if: always() name: Publish results - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: - name: pytest results unrealircd-atheme (stable) + name: pytest-results_unrealircd-atheme_stable path: pytest.xml name: irctest with stable versions 'on': diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 00ff787..ad82e7e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,22 +2,23 @@ exclude: ^irctest/scram repos: - repo: https://github.com/psf/black - rev: 20.8b1 + rev: 23.1.0 hooks: - id: black language_version: python3 - repo: https://github.com/PyCQA/isort - rev: 5.5.2 + rev: 5.11.5 hooks: - id: isort - - repo: https://gitlab.com/pycqa/flake8 - rev: 3.8.3 + - repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 hooks: - id: flake8 - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.812 + rev: v1.0.1 hooks: - id: mypy + additional_dependencies: [types-PyYAML, types-docutils] diff --git a/Makefile b/Makefile index 4b76e4d..e7a68ec 100644 --- a/Makefile +++ b/Makefile @@ -7,108 +7,56 @@ PYTEST_ARGS ?= # Will be appended at the end of the -k argument to pytest EXTRA_SELECTORS ?= -# testPlainLarge fails because it doesn't handle split AUTHENTICATE (reported on IRC) -ANOPE_SELECTORS := \ - and not testPlainLarge - -# buffering tests cannot pass because of issues with UTF-8 handling: https://github.com/DALnet/bahamut/issues/196 -# mask tests in test_who.py fail because they are not implemented. -# some HelpTestCase::*[HELP] tests fail because Bahamut forwards /HELP to HelpServ (but not /HELPOP) BAHAMUT_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ and not IRCv3 \ - and not buffering \ - and not (testWho and not whois and mask) \ - and not testWhoStar \ - and (not HelpTestCase or HELPOP) \ $(EXTRA_SELECTORS) -# testQuitErrors is very flaky -# AccountTagTestCase.testInvite fails because https://github.com/solanum-ircd/solanum/issues/166 -# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker. -# testWhoisNumerics[oper] fails because charybdis uses RPL_WHOISSPECIAL instead of RPL_WHOISOPERATOR CHARYBDIS_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not testQuitErrors \ - and not testKickDefaultComment \ - and not (AccountTagTestCase and testInvite) \ - and not (testWhoisNumerics and oper) \ $(EXTRA_SELECTORS) -# testInfoNosuchserver does not apply to Ergo: Ergo ignores the optional argument ERGO_SELECTORS := \ not deprecated \ - and not testInfoNosuchserver \ $(EXTRA_SELECTORS) -# testInviteUnoppedModern is the only strict test that Hybrid fails HYBRID_SELECTORS := \ not Ergo \ - and not testInviteUnoppedModern \ and not deprecated \ $(EXTRA_SELECTORS) -# testNoticeNonexistentChannel fails because of https://github.com/inspircd/inspircd/issues/1849 -# testBotPrivateMessage and testBotChannelMessage fail because https://github.com/inspircd/inspircd/pull/1910 is not released yet -# testNamesInvalidChannel and testNamesNonexistingChannel fail because https://github.com/inspircd/inspircd/pull/1922 is not released yet. INSPIRCD_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not testNoticeNonexistentChannel \ - and not testBotPrivateMessage and not testBotChannelMessage \ - and not testNamesInvalidChannel and not testNamesNonexistingChannel \ $(EXTRA_SELECTORS) -# buffering tests fail because ircu2 discards the whole buffer on long lines (TODO: refine how we exclude these tests) -# testQuit and testQuitErrors fail because ircu2 does not send ERROR or QUIT -# lusers "full" tests fail because they depend on Modern behavior, not just RFC2812 -# statusmsg tests fail because STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG target -# testKeyValidation[empty] fails because ircu2 returns ERR_NEEDMOREPARAMS on empty keys: https://github.com/UndernetIRC/ircu2/issues/13 -# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker. -# testEmptyRealname fails because it uses a default value instead of ERR_NEEDMOREPARAMS. -# HelpTestCase fails because it returns NOTICEs instead of numerics IRCU2_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not buffering \ - and not testQuit \ - and not (lusers and full) \ - and not statusmsg \ - and not (testKeyValidation and empty) \ - and not testKickDefaultComment \ - and not testEmptyRealname \ - and not HelpTestCase \ $(EXTRA_SELECTORS) -# same justification as ircu2 +NEFARIOUS_SELECTORS := \ + not Ergo \ + and not deprecated \ + and not strict \ + $(EXTRA_SELECTORS) + SNIRCD_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not buffering \ - and not testQuit \ - and not (lusers and full) \ - and not statusmsg \ $(EXTRA_SELECTORS) -# testListEmpty and testListOne fails because irc2 deprecated LIST -# testKickDefaultComment fails because it uses the nick of the kickee rather than the kicker. -# testWallopsPrivileges fails because it ignores the command instead of replying ERR_UNKNOWNCOMMAND -# HelpTestCase fails because it returns NOTICEs instead of numerics IRC2_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not testListEmpty and not testListOne \ - and not testKickDefaultComment \ - and not testWallopsPrivileges \ - and not HelpTestCase \ $(EXTRA_SELECTORS) MAMMON_SELECTORS := \ @@ -117,28 +65,14 @@ MAMMON_SELECTORS := \ and not strict \ $(EXTRA_SELECTORS) -# testKeyValidation[spaces] and testKeyValidation[empty] fail because ngIRCd does not validate them https://github.com/ngircd/ngircd/issues/290 -# testStarNick: wat -# testEmptyRealname fails because it uses a default value instead of ERR_NEEDMOREPARAMS. -# chathistory tests fail because they need nicks longer than 9 chars -# HelpTestCase::*[HELP] fails because it returns NOTICEs instead of numerics NGIRCD_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not (testKeyValidation and (spaces or empty)) \ - and not testStarNick \ - and not testEmptyRealname \ - and not chathistory \ - and (not HelpTestCase or HELPOP) \ $(EXTRA_SELECTORS) -# testInviteUnoppedModern is the only strict test that Plexus4 fails -# testInviteInviteOnlyModern fails because Plexus4 allows non-op to invite if (and only if) the channel is not invite-only PLEXUS4_SELECTORS := \ not Ergo \ - and not testInviteUnoppedModern \ - and not testInviteInviteOnlyModern \ and not deprecated \ $(EXTRA_SELECTORS) @@ -149,51 +83,47 @@ LIMNORIA_SELECTORS := \ (foo or not foo) \ $(EXTRA_SELECTORS) -# testQuitErrors is too flaky for CI -# testKickDefaultComment fails because solanum uses the nick of the kickee rather than the kicker. +SABLE_SELECTORS := \ + not Ergo \ + and not deprecated \ + and not strict \ + and not whowas and not list and not lusers and not userhost and not time and not info \ + $(EXTRA_SELECTORS) + SOLANUM_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not testQuitErrors \ - and not testKickDefaultComment \ $(EXTRA_SELECTORS) +# Same as Limnoria SOPEL_SELECTORS := \ - not testPlainNotAvailable \ + (foo or not foo) \ + $(EXTRA_SELECTORS) + +# TheLounge can actually pass all the test so there is none to exclude. +# `(foo or not foo)` serves as a `true` value so it doesn't break when +# $(EXTRA_SELECTORS) is non-empty +THELOUNGE_SELECTORS := \ + (foo or not foo) \ $(EXTRA_SELECTORS) -# testNoticeNonexistentChannel fails: https://bugs.unrealircd.org/view.php?id=5949 -# regressions::testTagCap fails: https://bugs.unrealircd.org/view.php?id=5948 -# messages::testLineTooLong fails: https://bugs.unrealircd.org/view.php?id=5947 -# testCapRemovalByClient and testNakWhole fail pending https://github.com/unrealircd/unrealircd/pull/148 # Tests marked with arbitrary_client_tags can't pass because Unreal whitelists which tags it relays # Tests marked with react_tag can't pass because Unreal blocks +draft/react https://github.com/unrealircd/unrealircd/pull/149 # Tests marked with private_chathistory can't pass because Unreal does not implement CHATHISTORY for DMs -# testChathistory[BETWEEN] fails: https://bugs.unrealircd.org/view.php?id=5952 -# testChathistory[AROUND] fails: https://bugs.unrealircd.org/view.php?id=5953 -# testWhoAllOpers fails because Unreal skips results when the mask is too broad -# HELP and HELPOP tests fail because Unreal uses custom numerics https://github.com/unrealircd/unrealircd/pull/184 + UNREALIRCD_SELECTORS := \ not Ergo \ and not deprecated \ and not strict \ - and not testNoticeNonexistentChannel \ - and not (regressions.py and testTagCap) \ - and not (messages.py and testLineTooLong) \ - and not (cap.py and (testCapRemovalByClient or testNakWhole)) \ - and not (account_tag.py and testInvite) \ and not arbitrary_client_tags \ and not react_tag \ and not private_chathistory \ - and not (testChathistory and (between or around)) \ - and not testWhoAllOpers \ - and not HelpTestCase \ $(EXTRA_SELECTORS) -.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon limnoria sopel solanum unrealircd +.PHONY: all flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd -all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon limnoria sopel solanum unrealircd +all: flakes bahamut charybdis ergo inspircd ircu2 snircd irc2 mammon nefarious limnoria sable sopel solanum unrealircd flakes: find irctest/ -name "*.py" -not -path "irctest/scram/*" -print0 | xargs -0 pyflakes3 @@ -202,7 +132,8 @@ bahamut: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.bahamut \ -m 'not services' \ - -n 10 \ + -n 4 \ + -vv -s \ -k '$(BAHAMUT_SELECTORS)' bahamut-atheme: @@ -210,7 +141,6 @@ bahamut-atheme: --controller=irctest.controllers.bahamut \ --services-controller=irctest.controllers.atheme_services \ -m 'services' \ - -n 10 \ -k '$(BAHAMUT_SELECTORS)' bahamut-anope: @@ -218,8 +148,7 @@ bahamut-anope: --controller=irctest.controllers.bahamut \ --services-controller=irctest.controllers.anope_services \ -m 'services' \ - -n 10 \ - -k '$(BAHAMUT_SELECTORS) $(ANOPE_SELECTORS)' + -k '$(BAHAMUT_SELECTORS)' charybdis: $(PYTEST) $(PYTEST_ARGS) \ @@ -256,27 +185,34 @@ inspircd-anope: --controller=irctest.controllers.inspircd \ --services-controller=irctest.controllers.anope_services \ -m 'services' \ - -k '$(INSPIRCD_SELECTORS) $(ANOPE_SELECTORS)' + -k '$(INSPIRCD_SELECTORS)' ircu2: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.ircu2 \ -m 'not services and not IRCv3' \ - -n 10 \ + -n 4 \ -k '$(IRCU2_SELECTORS)' +nefarious: + $(PYTEST) $(PYTEST_ARGS) \ + --controller=irctest.controllers.nefarious \ + -m 'not services' \ + -n 4 \ + -k '$(NEFARIOUS_SELECTORS)' + snircd: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.snircd \ -m 'not services and not IRCv3' \ - -n 10 \ + -n 4 \ -k '$(SNIRCD_SELECTORS)' irc2: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.irc2 \ -m 'not services and not IRCv3' \ - -n 10 \ + -n 4 \ -k '$(IRC2_SELECTORS)' limnoria: @@ -299,7 +235,7 @@ ngircd: $(PYTEST) $(PYTEST_ARGS) \ --controller irctest.controllers.ngircd \ -m 'not services' \ - -n 10 \ + -n 4 \ -k "$(NGIRCD_SELECTORS)" ngircd-anope: @@ -316,6 +252,13 @@ ngircd-atheme: -m 'services' \ -k "$(NGIRCD_SELECTORS)" +sable: + $(PYTEST) $(PYTEST_ARGS) \ + --controller=irctest.controllers.sable \ + -n 20 \ + -m 'not services' \ + -k '$(SABLE_SELECTORS)' + solanum: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.solanum \ @@ -327,6 +270,11 @@ sopel: --controller=irctest.controllers.sopel \ -k '$(SOPEL_SELECTORS)' +thelounge: + $(PYTEST) $(PYTEST_ARGS) \ + --controller=irctest.controllers.thelounge \ + -k '$(THELOUNGE_SELECTORS)' + unrealircd: $(PYTEST) $(PYTEST_ARGS) \ --controller=irctest.controllers.unrealircd \ @@ -347,4 +295,11 @@ unrealircd-anope: --controller=irctest.controllers.unrealircd \ --services-controller=irctest.controllers.anope_services \ -m 'services' \ - -k '$(UNREALIRCD_SELECTORS) $(ANOPE_SELECTORS)' + -k '$(UNREALIRCD_SELECTORS)' + +unrealircd-dlk: + pifpaf run mysql -- $(PYTEST) $(PYTEST_ARGS) \ + --controller=irctest.controllers.unrealircd \ + --services-controller=irctest.controllers.dlk_services \ + -m 'services' \ + -k '$(UNREALIRCD_SELECTORS)' diff --git a/README.md b/README.md index da10bb7..5bca8d6 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ have no side effect. Install irctest and dependencies: ``` +sudo apt install faketime # Optional, but greatly speeds up irctest/server_tests/list.py cd ~ git clone https://github.com/ProgVal/irctest.git cd irctest pip3 install --user -r requirements.txt -python3 setup.py install --user ``` Add `~/.local/bin/` (and/or `~/go/bin/` for Ergo) @@ -110,8 +110,11 @@ cd /tmp/ git clone https://github.com/inspircd/inspircd.git cd inspircd -# optional, makes tests run considerably faster -patch src/inspircd.cpp < ~/irctest/inspircd_mainloop.patch +# Optional, makes tests run considerably faster. Pick one depending on the InspIRCd version: +# on Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21: +patch src/inspircd.cpp < ~/irctest/patches/inspircd_mainloop.patch +# on Insp3 >= 3.17.0 and Insp4 >= 4.0.0a22: +export CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP # third-party module, used in named-modes tests because the spec is not implemented upstream wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp diff --git a/conftest.py b/conftest.py index 0939bcf..b06379a 100644 --- a/conftest.py +++ b/conftest.py @@ -106,13 +106,10 @@ def pytest_collection_modifyitems(session, config, items): assert isinstance(item, _pytest.python.Function) # unittest-style test functions have the node of UnitTest class as parent - assert isinstance( - item.parent, - ( - _pytest.python.Class, # pytest >= 7.0.0 - _pytest.python.Instance, # pytest < 7.0.0 - ), - ) + if tuple(map(int, _pytest.__version__.split("."))) >= (7,): + assert isinstance(item.parent, _pytest.python.Class) + else: + assert isinstance(item.parent, _pytest.python.Instance) # and that node references the UnitTest class assert issubclass(item.parent.cls, _IrcTestCase) diff --git a/data/nefarious/ircd.pem b/data/nefarious/ircd.pem new file mode 100644 index 0000000..7f59f3d --- /dev/null +++ b/data/nefarious/ircd.pem @@ -0,0 +1,83 @@ +-----BEGIN PRIVATE KEY----- +MIIJRAIBADANBgkqhkiG9w0BAQEFAASCCS4wggkqAgEAAoICAQDT0URxi7/l7ZGe +tkPv9Yh8h2s9BpbAR4Wq8sakgqETWg/nE/JQM5dPxroVbtZWWQXuJEFsgBKbASLa +/eg5cyJv4Uu5WIZpG1LxdPEEIOSMWjzoAGwoLxbTRGrS7qNXsknB9RwDuq8lPQiK +kiAahg1Cn1vRrQ4cRrG+AkQWpRHJEDoLjCSo8IcAsKAZlw/eGtAcmeNvkr5AujEw +XjIwx2FoDyKaNGRH5Z7gLWvCKBNxQuJuMTzh8guLqdGbE4hH3rqyICbW5DGPaOZL +LErWuJ7kEhLZG2HDW5JaXOr0QfFYAA8pl9/qCuFMdoxRUKRcYBoxoMmz6dlsmipN +7vIj+TT6TemwcAT25pwMJIVS4WC4+BZilNH2eWKD9hZA8Kq7FDPu+1rxOJaLbE/b +vpK8jZeRdqFzE1eBCgPkw8D8V0r7J18d+DsmgOe2kRycaia/t9M4rhqe0FXjX1X1 +lzQ52grxgc28Ejd1fGQXIJmdTh4BqKqTzxup0izS7dgFP1Ezm6Z4O+wklpL5uQF2 +Ex4X6QEj76iCxH+J/01/cvbxMe3iuGXECbO/y1FIrg7FKzZSrQo4aP63lS7Y7aq0 +t2t6kOS83ebhnpgHClgFs8/C3ayzYBBtbK63PYthwO8Rt6WamCIZFF5tA3XoI4Ak +fZcWD18loZai+QznVzbLNINf++rTwwIDAQABAoICAQCs1tT3piZHU2yAyo9zLbJa +kxGxcT/v1CzBSmtG8ATJyrKxRzhxszdj9HABbzjcqrXJFbKA+5yy+OFdOtSUlFtk +Wb21lwPOnmo29sp4KPL1h+itEzMuMwZ4DBry1aFZvPSsnPpoHJwwUbY3hHdHzVzi +oTCGTqT188Wzmxu+MqHppCEJLSj45ZPzvyxU1UwwW0a4H+ZTM7WlEYlzw1lHLlpQ +VBFTLS8q77aNjOKiQptiz0X+zpS0dhJvu3l7BhwtMRS8prmqnffG4r0QWCsVPP8C +cbEJkWtbwswQikN6Xpi1yw6UTQZ8brZa810aOSh07EJTfrU35rjxAndEspbJPd+4 +Zz6fKNnRA7A4fFap2hF4TSP/UF12b4ulQ8FfcMMTFszWko5M6fWFuCeWfNbyXML5 +fmn+NmSOboj7LkDqbpxtyaIVXXb2Y3F6A2fNllm/mxaGrRoEGNaH3o0qBgWRzJJB +TDSvIQtJddzL+iMaqxz4ufXAREJernZmPa3vlkVGLINNQUC9JLrB5eFjLzPiZN2U +8RgQ9YX5tjoJ+DtPWuMFDiCS1ZE20/UBOEYTeqIVuKdK3AjJDMFSjg8fRvsWRqZe +zsHv6tCtIFZFxYRxtrRGTUPQF+1QD6zBjYxZZk1B4n3uYBGVQFM/LnNHUxRnJBx1 +PunD4ICOY97xd2hcPmGiCQKCAQEA8NCXYaHzhv6fg95H/iMuJVcOCKrJ5rVr4poG +SD0KZtS7SLzUYat8WcuoSubh5Eb2hHtrsnLrSOTnwQUO61f4gCRm2sEqHYsOAd7+ +mNe1jfil0UBVqqL9GBcGYJkc5+DHgUlJQaxMV+4YLt8fD0KfZEnHaDAYX3kUdz+p +be//YAKv+JmxWcUdBF60AUWPjbCJT/1pfJeY8nEBFiYtlYKKN24+4OiRdJ2yRGzt +ZtNHaWy5EFF70yVgPX5MGQ7Z2JpejzK+lt+9nG4h1uJ4M2X4YrGVrRCn1W8jwqm/ +bXest3E6wkkLoWDm9EaeYj00DUgMOviPyP4ckyxilG+Fny4JbwKCAQEA4SyUV03X +KEoL5sOD69sLu3TpnIQz73u9an9W/f2f7rsGwmCcR9RdYGV29ltwfBvOm0FnPQio +GliN+3PTWAL6bb8VYo2IR53VKpVHKSQUlzDOD9PGObXw1CT/+0zoMP7FBA4dTJDf +xQ63AQNpSCGdwbxZygPWzLV5O1WxMeXhnQRL1EBvMyJ52od0+HbajDXg5mNiBKNQ +AtVhB9pEu47Blu/KBqWjfh/GeBLPZB7MHmGNBYbBGGskKRLG4sIbwShs9cx8UM0/ +9dxXkX2d8hjnSD/0ZBh54HHUaEvKAKfpz1L8AC0ll7biCAy0CZK23AoZu/KT8zJ+ +qvz3AuJcW3lo7QKCAQEAzfGltNJawPUamBzNttKBUV+s2c6tkkdO92C/xKGnNp/x +dtg+TTTpyKV5zGy9fIsPoecnCFptS06vwAvCYZQ/Kd93stcFXHSiSwlY9H9tfffK +XzTEzoRLLIHsa0omRUufcrqpEqf2NjChr9wS5OsWAx9xkHGpNmUHEqB4FlPsM0C5 +G0LdQCdplGYlTP0fMo5qL+VJhErle1kXE8kcrMMRzyvSTGe4lWGTph79vDUt2kQn +1IPLAJzzPEO5cqiXtzz1Z0N/aOn5b0FkYTAWmeY30LeMiJA46Df+/ihLVKPHKq6E +EMmFT8LeYMPQCbXLwRv/kaMm3D4tU9PejpD9Vk95swKCAQAtULBlxXeIVyaAAVba +L1H0Hroo0n41MtzSwt+568G05JSep5yr4/QKw0CmoY5Im7v/iLEDGmviKXIhaZTd +wHOvhGYEWGFVsFDG6hXRFL7EEoFVtBPPZ2sY9n1BkJ+lxI/XmhORZhJycNypaotU +hddets4HFrCyr86+/ybS2OWHmOa9x13Zl5WYQexrWFfxIaKqGtQOBOPEPjbxwp5U +dI1HF+i7X7hAWJqzbW2pQ31mm9EqjIztoho73diCp/e37q/G46kdBcFadEZ3NCWG +JDbfVmeTgU19usq5Vo9HhINMQvIOAwfuuVJRtmTBDHKaY7n8FfxqU/4j4RbA0Ncv +XYadAoIBAQC7yh4/UZCGhklUhhk/667OfchWvWGriCSaYGmQPdMzxjnIjAvvIUe9 +riOTBSZXYYLmZHsmY/jK7KMGB3AsLTypSa9+ddAWqWn2dvOYyxNiAaSJK/RZfA9A +ocVfvvkhOfNAYIF+A+fyJ2pznsDkBf9tPkhN7kovl+mr/e25qZb1d09377770Pi7 +thzEi+JLrRgYVLrCrPi2j4l7/Va/UaAPz+Dtu2GCT9vXgnhZtpb8R1kTViZFryTv +k+qbNYJzVm61Vit9mVAGe+WuzhlclJnN6LIZGG3zYHIulRAJrH1XDauHZfHzCKgi +FnMesy4thDMH/MhUfRtbylZTq45gtvCA +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIFazCCA1OgAwIBAgIUYHD08+9S32VTD9IEsr2Oe1dH3VEwDQYJKoZIhvcNAQEL +BQAwRTELMAkGA1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoM +GEludGVybmV0IFdpZGdpdHMgUHR5IEx0ZDAeFw0yMjA0MDQxODE2NTZaFw0yMzA0 +MDQxODE2NTZaMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEw +HwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwggIiMA0GCSqGSIb3DQEB +AQUAA4ICDwAwggIKAoICAQDT0URxi7/l7ZGetkPv9Yh8h2s9BpbAR4Wq8sakgqET +Wg/nE/JQM5dPxroVbtZWWQXuJEFsgBKbASLa/eg5cyJv4Uu5WIZpG1LxdPEEIOSM +WjzoAGwoLxbTRGrS7qNXsknB9RwDuq8lPQiKkiAahg1Cn1vRrQ4cRrG+AkQWpRHJ +EDoLjCSo8IcAsKAZlw/eGtAcmeNvkr5AujEwXjIwx2FoDyKaNGRH5Z7gLWvCKBNx +QuJuMTzh8guLqdGbE4hH3rqyICbW5DGPaOZLLErWuJ7kEhLZG2HDW5JaXOr0QfFY +AA8pl9/qCuFMdoxRUKRcYBoxoMmz6dlsmipN7vIj+TT6TemwcAT25pwMJIVS4WC4 ++BZilNH2eWKD9hZA8Kq7FDPu+1rxOJaLbE/bvpK8jZeRdqFzE1eBCgPkw8D8V0r7 +J18d+DsmgOe2kRycaia/t9M4rhqe0FXjX1X1lzQ52grxgc28Ejd1fGQXIJmdTh4B +qKqTzxup0izS7dgFP1Ezm6Z4O+wklpL5uQF2Ex4X6QEj76iCxH+J/01/cvbxMe3i +uGXECbO/y1FIrg7FKzZSrQo4aP63lS7Y7aq0t2t6kOS83ebhnpgHClgFs8/C3ayz +YBBtbK63PYthwO8Rt6WamCIZFF5tA3XoI4AkfZcWD18loZai+QznVzbLNINf++rT +wwIDAQABo1MwUTAdBgNVHQ4EFgQU+9eHi2eqy0f3fDS0GjqkijGDDocwHwYDVR0j +BBgwFoAU+9eHi2eqy0f3fDS0GjqkijGDDocwDwYDVR0TAQH/BAUwAwEB/zANBgkq +hkiG9w0BAQsFAAOCAgEAAJXO3qUc/PW75pI2dt1cKv20VqozkfEf7P0eeVisCDxn +1p3QhVgI2lEe9kzdHp7t42g5xLkUhQEVmBcKm9xbl+k2D1X0+T8px1x6ZiWfbhXL +ptc/qCIXjPCgVN3s+Kasii3hHkZxKGZz/ySmBmfDJZjQZtbZzQWpvvX6SD4s7sjo +gWbZW3qvQ0bFTGdD1IjKYGaxK6aSrNkAIutiAX4RczJ1QSwb9Z2EIen+ABAvOZS9 +xv3LiiidWcuOT7WzXEa4QvOslCEkAF+jj6mGYB7NWtly0kj4AEPvI4IoYTi9dohS +CA0zd1DTfjRwpAnT5P4sj4mpjLyRBumeeVGpCZhUxfKpFjIB2AnlgxrU+LPq5c9R +ZZ9Q5oeLxjRPjpqBeWwgnbjXstQCL9g0U7SsEemsv+zmvG5COhAmG5Wce/65ILlg +450H4bcn1ul0xvxz9hat6tqEZry3HcNE/CGDT+tXuhHpqOXkY1/c78C0QbWjWodR +tCvlXW00a+7TlEhNr4XBNdqtIQfYS9K9yiVVNfZLPEsN/SA3BGXmrr+du1/E4Ria +CkVpmBdJsVu5eMaUj1arsCqI4fwHzljtojJe/pCzZBVkOaSWQEQ+LL4iVnMas68m +qyshtNf4KNiM55OQmyTiFHMTIxCtdEcHaR3mUxR7GrIhc/bxyxUUBtMAuUX0Kjs= +-----END CERTIFICATE----- diff --git a/data/unreal/config.settings b/data/unreal/config.settings index e4d15b1..7d16ac4 100644 --- a/data/unreal/config.settings +++ b/data/unreal/config.settings @@ -19,6 +19,10 @@ SHOWLISTMODES="1" NOOPEROVERRIDE="" OPEROVERRIDEVERIFY="" GENCERTIFICATE="1" -EXTRAPARA="" + +# Use system argon to avoid getting SIGILLed if the build machine has a more recent +# CPU than the one running the tests. +EXTRAPARA="--with-system-argon2" + ADVANCED="" diff --git a/irctest/basecontrollers.py b/irctest/basecontrollers.py index b1464ac..9dda06a 100644 --- a/irctest/basecontrollers.py +++ b/irctest/basecontrollers.py @@ -1,18 +1,23 @@ from __future__ import annotations +import contextlib import dataclasses +import json import os +from pathlib import Path import shutil import socket import subprocess import tempfile +import textwrap import time -from typing import IO, Any, Callable, Dict, List, Optional, Set, Tuple, Type +from typing import IO, Any, Callable, Dict, Iterator, List, Optional, Set, Tuple, Type import irctest from . import authentication, tls from .client_mock import ClientMock +from .irc_utils.filelock import FileLock from .irc_utils.junkdrawer import find_hostname_and_port from .irc_utils.message_parser import Message from .runner import NotImplementedByController @@ -54,17 +59,47 @@ class _BaseController: supports_sts: bool supported_sasl_mechanisms: Set[str] + proc: Optional[subprocess.Popen] + _used_ports_path = Path(tempfile.gettempdir()) / "irctest_ports.json" + _port_lock = FileLock(Path(tempfile.gettempdir()) / "irctest_ports.json.lock") + def __init__(self, test_config: TestCaseControllerConfig): self.test_config = test_config self.proc = None + self._own_ports: Set[Tuple[str, int]] = set() + + @contextlib.contextmanager + def _used_ports(self) -> Iterator[Set[Tuple[str, int]]]: + with self._port_lock: + if not self._used_ports_path.exists(): + self._used_ports_path.write_text("[]") + used_ports = { + (h, p) for (h, p) in json.loads(self._used_ports_path.read_text()) + } + yield used_ports + self._used_ports_path.write_text(json.dumps(list(used_ports))) + + def get_hostname_and_port(self) -> Tuple[str, int]: + with self._used_ports() as used_ports: + while True: + (hostname, port) = find_hostname_and_port() + if (hostname, port) not in used_ports: + # double-checking in self._used_ports to prevent collisions + # between controllers starting at the same time. + break + + used_ports.add((hostname, port)) + self._own_ports.add((hostname, port)) + + return (hostname, port) def check_is_alive(self) -> None: assert self.proc self.proc.poll() if self.proc.returncode is not None: - raise ProcessStopped() + raise ProcessStopped(f"process returned {self.proc.returncode}") def kill_proc(self) -> None: """Terminates the controlled process, waits for it to exit, and @@ -82,12 +117,17 @@ class _BaseController: if self.proc: self.kill_proc() + with self._used_ports() as used_ports: + for hostname, port in list(self._own_ports): + used_ports.remove((hostname, port)) + self._own_ports.remove((hostname, port)) + class DirectoryBasedController(_BaseController): """Helper for controllers whose software configuration is based on an arbitrary directory.""" - directory: Optional[str] + directory: Optional[Path] def __init__(self, test_config: TestCaseControllerConfig): super().__init__(test_config) @@ -110,22 +150,21 @@ class DirectoryBasedController(_BaseController): """Open a file in the configuration directory.""" assert self.directory if os.sep in name: - dir_ = os.path.join(self.directory, os.path.dirname(name)) - if not os.path.isdir(dir_): - os.makedirs(dir_) - assert os.path.isdir(dir_) - return open(os.path.join(self.directory, name), mode) + dir_ = self.directory / os.path.dirname(name) + dir_.mkdir(parents=True, exist_ok=True) + assert dir_.is_dir() + return (self.directory / name).open(mode) def create_config(self) -> None: if not self.directory: - self.directory = tempfile.mkdtemp() + self.directory = Path(tempfile.mkdtemp()) def gen_ssl(self) -> None: assert self.directory - self.csr_path = os.path.join(self.directory, "ssl.csr") - self.key_path = os.path.join(self.directory, "ssl.key") - self.pem_path = os.path.join(self.directory, "ssl.pem") - self.dh_path = os.path.join(self.directory, "dh.pem") + self.csr_path = self.directory / "ssl.csr" + self.key_path = self.directory / "ssl.key" + self.pem_path = self.directory / "ssl.pem" + self.dh_path = self.directory / "dh.pem" subprocess.check_output( [ self.openssl_bin, @@ -156,10 +195,18 @@ class DirectoryBasedController(_BaseController): ], stderr=subprocess.DEVNULL, ) - subprocess.check_output( - [self.openssl_bin, "dhparam", "-out", self.dh_path, "128"], - stderr=subprocess.DEVNULL, - ) + with self.dh_path.open("w") as fd: + fd.write( + textwrap.dedent( + """ + -----BEGIN DH PARAMETERS----- + MIGHAoGBAJICSyQAiLj1fw8b5xELcnpqBQ+wvOyKgim4IetWOgZnRQFkTgOeoRZD + HksACRFJL/EqHxDKcy/2Ghwr2axhNxSJ+UOBmraP3WfodV/fCDPnZ+XnI9fjHsIr + rjisPMqomjXeiTB1UeAHvLUmCK4yx6lpAJsCYwJjsqkycUfHiy1bAgEC + -----END DH PARAMETERS----- + """ + ) + ) class BaseClientController(_BaseController): @@ -188,9 +235,16 @@ class BaseServerController(_BaseController): extban_mute_char: Optional[str] = None """Character used for the 'mute' extban""" nickserv = "NickServ" + sync_sleep_time = 0.0 + """How many seconds to sleep before clients synchronously get messages. - def get_hostname_and_port(self) -> Tuple[str, int]: - return find_hostname_and_port() + This can be 0 for servers answering all commands in order (all but Sable as of + this writing), as irctest emits a PING, waits for a PONG, and captures all messages + between the two.""" + + def __init__(self, *args: Any, **kwargs: Any): + super().__init__(*args, **kwargs) + self.faketime_enabled = False def run( self, @@ -200,8 +254,7 @@ class BaseServerController(_BaseController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]], - invalid_metadata_keys: Optional[Set[str]], + faketime: Optional[str], ) -> None: raise NotImplementedError() @@ -217,6 +270,7 @@ class BaseServerController(_BaseController): raise NotImplementedByController("account registration") def wait_for_port(self) -> None: + started_at = time.time() while not self.port_open: self.check_is_alive() time.sleep(self._port_wait_interval) @@ -235,15 +289,20 @@ class BaseServerController(_BaseController): time.sleep(0.01) c.send(b" ") # Triggers BrokenPipeError - except BrokenPipeError: + except (BrokenPipeError, ConnectionResetError): # ircu2 cuts the connection without a message if registration # is not complete. pass + except socket.timeout: + # irc2 just keeps it open + pass c.close() self.port_open = True - except Exception: - continue + except ConnectionRefusedError: + if time.time() - started_at >= 60: + # waited for 60 seconds, giving up + raise def wait_for_services(self) -> None: assert self.services_controller @@ -284,25 +343,36 @@ class BaseServicesController(_BaseController): c.connect(self.server_controller.hostname, self.server_controller.port) c.sendLine("NICK chkNS") c.sendLine("USER chk chk chk chk") - for msg in c.getMessages(synchronize=False): - if msg.command == "PING": - # Hi Unreal - c.sendLine("PONG :" + msg.params[0]) - c.getMessages() + time.sleep(self.server_controller.sync_sleep_time) + got_end_of_motd = False + while not got_end_of_motd: + for msg in c.getMessages(synchronize=False): + if msg.command == "PING": + # Hi Unreal + c.sendLine("PONG :" + msg.params[0]) + if msg.command in ("376", "422"): # RPL_ENDOFMOTD / ERR_NOMOTD + got_end_of_motd = True - timeout = time.time() + 5 + timeout = time.time() + 10 while True: - c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :HELP") - msgs = self.getNickServResponse(c) + c.sendLine(f"PRIVMSG {self.server_controller.nickserv} :help") + + msgs = self.getNickServResponse(c, timeout=1) for msg in msgs: if msg.command == "401": # NickServ not available yet pass + elif msg.command in ("MODE", "221"): # RPL_UMODEIS + pass elif msg.command == "NOTICE": - # NickServ is available - assert "nickserv" in (msg.prefix or "").lower(), msg - print("breaking") - break + assert msg.prefix is not None + if "!" not in msg.prefix and "." in msg.prefix: + # Server notice + pass + else: + # NickServ is available + assert "nickserv" in (msg.prefix or "").lower(), msg + break else: assert False, f"unexpected reply from NickServ: {msg}" else: @@ -319,11 +389,12 @@ class BaseServicesController(_BaseController): c.disconnect() self.services_up = True - def getNickServResponse(self, client: Any) -> List[Message]: + def getNickServResponse(self, client: Any, timeout: int = 0) -> List[Message]: """Wrapper aroung getMessages() that waits longer, because NickServ is queried asynchronously.""" msgs: List[Message] = [] - while not msgs: + start_time = time.time() + while not msgs and (not timeout or start_time + timeout > time.time()): time.sleep(0.05) msgs = client.getMessages() return msgs diff --git a/irctest/cases.py b/irctest/cases.py index 49a1b81..30b1aed 100644 --- a/irctest/cases.py +++ b/irctest/cases.py @@ -69,6 +69,30 @@ TController = TypeVar("TController", bound=basecontrollers._BaseController) T = TypeVar("T") +def retry(f: TCallable) -> TCallable: + """Retry the function if it raises ConnectionClosed; as a workaround for flaky + connection, such as:: + + 1: connects to server. + 1 -> S: NICK foo + 1 -> S: USER username * * :Realname + S -> 1: :My.Little.Server NOTICE * :*** Found your hostname (cached) + S -> 1: :My.Little.Server NOTICE * :*** Checking Ident + S -> 1: :My.Little.Server NOTICE * :*** No Ident response + S -> 1: ERROR :Closing Link: cpu-pool.com (Use a different port) + """ + + @functools.wraps(f) + def newf(*args, **kwargs): # type: ignore + try: + return f(*args, **kwargs) + except ConnectionClosed: + time.sleep(1) + return f(*args, **kwargs) + + return newf # type: ignore + + class ChannelJoinException(Exception): def __init__(self, code: str, params: List[str]): super().__init__(f"Failed to join channel ({code}): {params}") @@ -149,7 +173,7 @@ class _IrcTestCase(Generic[TController]): ) -> Optional[str]: """Returns an error message if the message doesn't match the given arguments, or None if it matches.""" - for (key, value) in kwargs.items(): + for key, value in kwargs.items(): if getattr(msg, key) != value: fail_msg = ( fail_msg or "expected {param} to be {expects}, got {got}: {msg}" @@ -327,8 +351,8 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]): nick: Optional[str] = None user: Optional[List[str]] = None server: socket.socket - protocol_version = Optional[str] - acked_capabilities = Optional[Set[str]] + protocol_version: Optional[str] + acked_capabilities: Optional[Set[str]] __new__ = object.__new__ # pytest won't collect Generic[] subclasses otherwise @@ -424,7 +448,9 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]): print("{:.3f} S: {}".format(time.time(), line.strip())) def readCapLs( - self, auth: Optional[Authentication] = None, tls_config: tls.TlsConfig = None + self, + auth: Optional[Authentication] = None, + tls_config: Optional[tls.TlsConfig] = None, ) -> None: (hostname, port) = self.server.getsockname() self.controller.run( @@ -434,9 +460,9 @@ class BaseClientTestCase(_IrcTestCase[basecontrollers.BaseClientController]): m = self.getMessage() self.assertEqual(m.command, "CAP", "First message is not CAP LS.") if m.params == ["LS"]: - self.protocol_version = 301 + self.protocol_version = "301" elif m.params == ["LS", "302"]: - self.protocol_version = 302 + self.protocol_version = "302" elif m.params == ["END"]: self.protocol_version = None else: @@ -503,11 +529,15 @@ class BaseServerTestCase( password: Optional[str] = None ssl = False - valid_metadata_keys: Set[str] = set() - invalid_metadata_keys: Set[str] = set() server_support: Optional[Dict[str, Optional[str]]] run_services = False + faketime: Optional[str] = None + """If not None and the controller supports it and libfaketime is available, + runs the server using faketime and this value set as the $FAKETIME env variable. + Tests must check ``self.controller.faketime_enabled`` is True before + relying on this.""" + __new__ = object.__new__ # pytest won't collect Generic[] subclasses otherwise def setUp(self) -> None: @@ -518,10 +548,9 @@ class BaseServerTestCase( self.hostname, self.port, password=self.password, - valid_metadata_keys=self.valid_metadata_keys, - invalid_metadata_keys=self.invalid_metadata_keys, ssl=self.ssl, run_services=self.run_services, + faketime=self.faketime, ) self.clients: Dict[TClientName, client_mock.ClientMock] = {} @@ -539,13 +568,10 @@ class BaseServerTestCase( if self.run_services: self.controller.wait_for_services() if not name: - new_name: int = ( - max( - [int(name) for name in self.clients if isinstance(name, (int, str))] - + [0] - ) - + 1 - ) + used_ids: List[int] = [ + int(name) for name in self.clients if isinstance(name, (int, str)) + ] + new_name = max(used_ids + [0]) + 1 name = cast(TClientName, new_name) show_io = show_io if show_io is not None else self.show_io self.clients[name] = client_mock.ClientMock(name=name, show_io=show_io) @@ -559,9 +585,13 @@ class BaseServerTestCase( del self.clients[name] def getMessages(self, client: TClientName, **kwargs: Any) -> List[Message]: + if kwargs.get("synchronize", True): + time.sleep(self.controller.sync_sleep_time) return self.clients[client].getMessages(**kwargs) def getMessage(self, client: TClientName, **kwargs: Any) -> Message: + if kwargs.get("synchronize", True): + time.sleep(self.controller.sync_sleep_time) return self.clients[client].getMessage(**kwargs) def getRegistrationMessage(self, client: TClientName) -> Message: @@ -647,10 +677,21 @@ class BaseServerTestCase( else: raise + def authenticateClient( + self, client: TClientName, account: str, password: str + ) -> None: + self.sendLine(client, "AUTHENTICATE PLAIN") + m = self.getRegistrationMessage(client) + self.assertMessageMatch(m, command="AUTHENTICATE", params=["+"]) + self.sendLine(client, sasl_plain_blob(account, password)) + m = self.getRegistrationMessage(client) + self.assertIn(m.command, ["900", "903"], str(m)) + + @retry def connectClient( self, nick: str, - name: TClientName = None, + name: Optional[TClientName] = None, capabilities: Optional[List[str]] = None, skip_if_cap_nak: bool = False, show_io: Optional[bool] = None, @@ -665,17 +706,12 @@ class BaseServerTestCase( client = self.addClient(name, show_io=show_io) if capabilities: self.sendLine(client, "CAP LS 302") - m = self.getRegistrationMessage(client) + self.getCapLs(client) self.requestCapabilities(client, capabilities, skip_if_cap_nak) if password is not None: if "sasl" not in (capabilities or ()): raise ValueError("Used 'password' option without sasl capbilitiy") - self.sendLine(client, "AUTHENTICATE PLAIN") - m = self.getRegistrationMessage(client) - self.assertMessageMatch(m, command="AUTHENTICATE", params=["+"]) - self.sendLine(client, sasl_plain_blob(account or nick, password)) - m = self.getRegistrationMessage(client) - self.assertIn(m.command, ["900", "903"], str(m)) + self.authenticateClient(client, account or nick, password) self.sendLine(client, "NICK {}".format(nick)) self.sendLine(client, "USER %s * * :Realname" % (ident,)) @@ -700,6 +736,12 @@ class BaseServerTestCase( self.server_support[param] = None welcome.append(m) + self.targmax: Dict[str, Optional[str]] = dict( # type: ignore[assignment] + item.split(":", 1) + for item in (self.server_support.get("TARGMAX") or "").split(",") + if item + ) + return welcome def joinClient(self, client: TClientName, channel: str) -> None: @@ -730,50 +772,58 @@ class BaseServerTestCase( raise ChannelJoinException(msg.command, msg.params) -_TSelf = TypeVar("_TSelf", bound="OptionalityHelper") +_TSelf = TypeVar("_TSelf", bound="_IrcTestCase") _TReturn = TypeVar("_TReturn") -class OptionalityHelper(Generic[TController]): - controller: TController - - def checkSaslSupport(self) -> None: - if self.controller.supported_sasl_mechanisms: - return - raise runner.NotImplementedByController("SASL") - - def checkMechanismSupport(self, mechanism: str) -> None: - if mechanism in self.controller.supported_sasl_mechanisms: - return - raise runner.OptionalSaslMechanismNotSupported(mechanism) - - @staticmethod - def skipUnlessHasMechanism( - mech: str, - ) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]: - # Just a function returning a function that takes functions and - # returns functions, nothing to see here. - # If Python didn't have such an awful syntax for callables, it would be: - # str -> ((TSelf -> TReturn) -> (TSelf -> TReturn)) - def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]: - @functools.wraps(f) - def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn: - self.checkMechanismSupport(mech) - return f(self, *args, **kwargs) - - return newf - - return decorator - - @staticmethod - def skipUnlessHasSasl(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]: +def skipUnlessHasMechanism( + mech: str, +) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]: + # Just a function returning a function that takes functions and + # returns functions, nothing to see here. + # If Python didn't have such an awful syntax for callables, it would be: + # str -> ((TSelf -> TReturn) -> (TSelf -> TReturn)) + def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]: @functools.wraps(f) def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn: - self.checkSaslSupport() + if mech not in self.controller.supported_sasl_mechanisms: + raise runner.OptionalSaslMechanismNotSupported(mech) return f(self, *args, **kwargs) return newf + return decorator + + +def xfailIf( + condition: Callable[..., bool], reason: str +) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]: + # Works about the same as skipUnlessHasMechanism + def decorator(f: Callable[..., _TReturn]) -> Callable[..., _TReturn]: + @functools.wraps(f) + def newf(self: _TSelf, *args: Any, **kwargs: Any) -> _TReturn: + if condition(self, *args, **kwargs): + try: + return f(self, *args, **kwargs) + except Exception: + pytest.xfail(reason) + assert False # make mypy happy + else: + return f(self, *args, **kwargs) + + return newf + + return decorator + + +def xfailIfSoftware( + names: List[str], reason: str +) -> Callable[[Callable[..., _TReturn]], Callable[..., _TReturn]]: + def pred(testcase: _IrcTestCase, *args: Any, **kwargs: Any) -> bool: + return testcase.controller.software_name in names + + return xfailIf(pred, reason) + def mark_services(cls: TClass) -> TClass: cls.run_services = True diff --git a/irctest/client_tests/cap.py b/irctest/client_tests/cap.py index 5361945..03aa1a1 100644 --- a/irctest/client_tests/cap.py +++ b/irctest/client_tests/cap.py @@ -1,3 +1,5 @@ +"""Format of ``CAP LS`` sent by IRCv3 clients.""" + from irctest import cases from irctest.irc_utils.message_parser import Message diff --git a/irctest/client_tests/sasl.py b/irctest/client_tests/sasl.py index c157059..8e38305 100644 --- a/irctest/client_tests/sasl.py +++ b/irctest/client_tests/sasl.py @@ -1,3 +1,8 @@ +"""SASL authentication from clients, for all known mechanisms. + +For now, only `SASLv3.1 `_ +is tested, not `SASLv3.2 `_.""" + import base64 import pytest @@ -34,8 +39,8 @@ class IdentityHash: return self._data -class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") +class SaslTestCase(cases.BaseClientTestCase): + @cases.skipUnlessHasMechanism("PLAIN") def testPlain(self): """Test PLAIN authentication with correct username/password.""" auth = authentication.Authentication( @@ -55,7 +60,8 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): m = self.negotiateCapabilities(["sasl"], False) self.assertEqual(m, Message({}, None, "CAP", ["END"])) - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") + @cases.xfailIfSoftware(["Sopel"], "Sopel requests SASL PLAIN even if not available") def testPlainNotAvailable(self): """`sasl=EXTERNAL` is advertized, whereas the client is configured to use PLAIN. @@ -84,8 +90,9 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): m = self.getMessage() self.assertMessageMatch(m, command="CAP") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") - def testPlainLarge(self): + @pytest.mark.parametrize("pattern", ["barbaz", "éèà"]) + @cases.skipUnlessHasMechanism("PLAIN") + def testPlainLarge(self, pattern): """Test the client splits large AUTHENTICATE messages whose payload is not a multiple of 400. @@ -94,10 +101,10 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): auth = authentication.Authentication( mechanisms=[authentication.Mechanisms.plain], username="foo", - password="bar" * 200, + password=pattern * 100, ) authstring = base64.b64encode( - b"\x00".join([b"foo", b"foo", b"bar" * 200]) + b"\x00".join([b"foo", b"foo", pattern.encode() * 100]) ).decode() m = self.negotiateCapabilities(["sasl"], auth=auth) self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"])) @@ -113,8 +120,9 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): m = self.negotiateCapabilities(["sasl"], False) self.assertEqual(m, Message({}, None, "CAP", ["END"])) - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") - def testPlainLargeMultiple(self): + @cases.skipUnlessHasMechanism("PLAIN") + @pytest.mark.parametrize("pattern", ["quux", "éè"]) + def testPlainLargeMultiple(self, pattern): """Test the client splits large AUTHENTICATE messages whose payload is a multiple of 400. @@ -123,10 +131,10 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): auth = authentication.Authentication( mechanisms=[authentication.Mechanisms.plain], username="foo", - password="quux" * 148, + password=pattern * 148, ) authstring = base64.b64encode( - b"\x00".join([b"foo", b"foo", b"quux" * 148]) + b"\x00".join([b"foo", b"foo", pattern.encode() * 148]) ).decode() m = self.negotiateCapabilities(["sasl"], auth=auth) self.assertEqual(m, Message({}, None, "AUTHENTICATE", ["PLAIN"])) @@ -143,7 +151,7 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): self.assertEqual(m, Message({}, None, "CAP", ["END"])) @pytest.mark.skipif(ecdsa is None, reason="python3-ecdsa is not available") - @cases.OptionalityHelper.skipUnlessHasMechanism("ECDSA-NIST256P-CHALLENGE") + @cases.skipUnlessHasMechanism("ECDSA-NIST256P-CHALLENGE") def testEcdsa(self): """Test ECDSA authentication.""" auth = authentication.Authentication( @@ -177,7 +185,7 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): m = self.negotiateCapabilities(["sasl"], False) self.assertEqual(m, Message({}, None, "CAP", ["END"])) - @cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256") + @cases.skipUnlessHasMechanism("SCRAM-SHA-256") def testScram(self): """Test SCRAM-SHA-256 authentication.""" auth = authentication.Authentication( @@ -219,8 +227,8 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): self.assertEqual(m.command, "AUTHENTICATE", m) self.assertEqual(m.params, ["+"], m) - @cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256") - def testScramBadPassword(self): + @cases.skipUnlessHasMechanism("SCRAM-SHA-256") + def testScramBadPassword(self, server_fakes_success=False, fake_response=None): """Test SCRAM-SHA-256 authentication with a bad password.""" auth = authentication.Authentication( mechanisms=[authentication.Mechanisms.scram_sha_256], @@ -253,9 +261,39 @@ class SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): with self.assertRaises(scram.NotAuthorizedException): authenticator.response(msg) + if server_fakes_success: + self.sendLine(f"AUTHENTICATE :{fake_response}") -class Irc302SaslTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + m = self.getMessage() + while m.command == "PING": + self.sendLine(f"PONG server. {m.params[-1]}") + m = self.getMessage() + self.assertMessageMatch( + m, + command="AUTHENTICATE", + params=["*"], + fail_msg="Client did not abort: {msg}", + ) + + @cases.skipUnlessHasMechanism("SCRAM-SHA-256") + @pytest.mark.parametrize( + "fake_response", + [ + "", + "AAAA", + "dj1ubU1mM1FIV2NKUWk5cE1ndHFLU0tQclZueUk2c3FOTzZJN3BFLzBveUdjPQ==", + ], + ) + def testScramMaliciousServer(self, fake_response): + """Test SCRAM-SHA-256 authentication to a server which pretends to know + the password""" + self.testScramBadPassword( + server_fakes_success=True, fake_response=fake_response + ) + + +class Irc302SaslTestCase(cases.BaseClientTestCase): + @cases.skipUnlessHasMechanism("PLAIN") def testPlainNotAvailable(self): """Test the client does not try to authenticate using a mechanism the server does not advertise. diff --git a/irctest/client_tests/tls.py b/irctest/client_tests/tls.py index e24e421..7172a96 100644 --- a/irctest/client_tests/tls.py +++ b/irctest/client_tests/tls.py @@ -1,3 +1,5 @@ +"""Clients should validate certificates; either with a CA or fingerprints.""" + import socket import ssl @@ -138,7 +140,7 @@ class TlsTestCase(cases.BaseClientTestCase): self.getMessage() -class StsTestCase(cases.BaseClientTestCase, cases.OptionalityHelper): +class StsTestCase(cases.BaseClientTestCase): def setUp(self): super().setUp() self.insecure_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) diff --git a/irctest/controllers/anope_services.py b/irctest/controllers/anope_services.py index f620dd9..bf577bf 100644 --- a/irctest/controllers/anope_services.py +++ b/irctest/controllers/anope_services.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path import shutil import subprocess from typing import Type @@ -73,6 +73,8 @@ module {{ name = "ns_cert" }} class AnopeController(BaseServicesController, DirectoryBasedController): """Collaborator for server controllers that rely on Anope""" + software_name = "Anope" + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: self.create_config() @@ -99,14 +101,11 @@ class AnopeController(BaseServicesController, DirectoryBasedController): pass assert self.directory + services_path = shutil.which("services") + assert services_path # Config and code need to be in the same directory, *obviously* - os.symlink( - os.path.join( - os.path.dirname(shutil.which("services")), "..", "lib" # type: ignore - ), - os.path.join(self.directory, "lib"), - ) + (self.directory / "lib").symlink_to(Path(services_path).parent.parent / "lib") self.proc = subprocess.Popen( [ diff --git a/irctest/controllers/atheme_services.py b/irctest/controllers/atheme_services.py index 8994677..aa9ec22 100644 --- a/irctest/controllers/atheme_services.py +++ b/irctest/controllers/atheme_services.py @@ -1,4 +1,3 @@ -import os import subprocess from typing import Optional, Type @@ -56,6 +55,8 @@ saslserv {{ class AthemeController(BaseServicesController, DirectoryBasedController): """Mixin for server controllers that rely on Atheme""" + software_name = "Atheme" + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: self.create_config() @@ -79,11 +80,11 @@ class AthemeController(BaseServicesController, DirectoryBasedController): "atheme-services", "-n", # don't fork "-c", - os.path.join(self.directory, "services.conf"), + self.directory / "services.conf", "-l", f"/tmp/services-{server_port}.log", "-p", - os.path.join(self.directory, "services.pid"), + self.directory / "services.pid", "-D", self.directory, ], diff --git a/irctest/controllers/bahamut.py b/irctest/controllers/bahamut.py index 01cd4dd..06a3661 100644 --- a/irctest/controllers/bahamut.py +++ b/irctest/controllers/bahamut.py @@ -1,14 +1,9 @@ -import os +from pathlib import Path import shutil import subprocess from typing import Optional, Set, Type -from irctest.basecontrollers import ( - BaseServerController, - DirectoryBasedController, - NotImplementedByController, -) -from irctest.irc_utils.junkdrawer import find_hostname_and_port +from irctest.basecontrollers import BaseServerController, DirectoryBasedController TEMPLATE_CONFIG = """ global {{ @@ -80,6 +75,19 @@ oper {{ """ +def initialize_entropy(directory: Path) -> None: + # https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/include/dh.h#L35-L38 + nb_rand_bytes = 512 // 8 + # https://github.com/DALnet/bahamut/blob/7fc039d403f66a954225c5dc4ad1fe683aedd794/src/dh.c#L186 + entropy_file_size = nb_rand_bytes * 4 + + # Not actually random; but we don't care. + entropy = b"\x00" * entropy_file_size + + with (directory / ".ircd.entropy").open("wb") as fd: + fd.write(entropy) + + class BahamutController(BaseServerController, DirectoryBasedController): software_name = "Bahamut" supported_sasl_mechanisms: Set[str] = set() @@ -99,20 +107,14 @@ class BahamutController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, - restricted_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) assert self.proc is None self.port = port self.hostname = hostname self.create_config() - (unused_hostname, unused_port) = find_hostname_and_port() - (services_hostname, services_port) = find_hostname_and_port() + (unused_hostname, unused_port) = self.get_hostname_and_port() + (services_hostname, services_port) = self.get_hostname_and_port() password_field = "passwd {};".format(password) if password else "" @@ -120,9 +122,14 @@ class BahamutController(BaseServerController, DirectoryBasedController): assert self.directory + # Bahamut reads some bytes from /dev/urandom on startup, which causes + # GitHub Actions to sometimes freeze and timeout. + # This initializes the entropy file so Bahamut does not need to do it itself. + initialize_entropy(self.directory) + # they are hardcoded... thankfully Bahamut reads them from the CWD. - shutil.copy(self.pem_path, os.path.join(self.directory, "ircd.crt")) - shutil.copy(self.key_path, os.path.join(self.directory, "ircd.key")) + shutil.copy(self.pem_path, self.directory / "ircd.crt") + shutil.copy(self.key_path, self.directory / "ircd.key") with self.open_file("server.conf") as fd: fd.write( @@ -136,15 +143,21 @@ class BahamutController(BaseServerController, DirectoryBasedController): # pem_path=self.pem_path, ) ) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ - # "strace", "-f", "-e", "file", + *faketime_cmd, "ircd", "-t", # don't fork "-f", - os.path.join(self.directory, "server.conf"), + self.directory / "server.conf", ], - # stdout=subprocess.DEVNULL, ) if run_services: diff --git a/irctest/controllers/base_hybrid.py b/irctest/controllers/base_hybrid.py index f91229a..38d7073 100644 --- a/irctest/controllers/base_hybrid.py +++ b/irctest/controllers/base_hybrid.py @@ -1,13 +1,8 @@ -import os +import shutil import subprocess -from typing import Optional, Set +from typing import Optional -from irctest.basecontrollers import ( - BaseServerController, - DirectoryBasedController, - NotImplementedByController, -) -from irctest.irc_utils.junkdrawer import find_hostname_and_port +from irctest.basecontrollers import BaseServerController, DirectoryBasedController TEMPLATE_SSL_CONFIG = """ ssl_private_key = "{key_path}"; @@ -41,18 +36,13 @@ class BaseHybridController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) assert self.proc is None self.port = port self.hostname = hostname self.create_config() - (services_hostname, services_port) = find_hostname_and_port() + (services_hostname, services_port) = self.get_hostname_and_port() password_field = 'password = "{}";'.format(password) if password else "" if ssl: self.gen_ssl() @@ -73,14 +63,22 @@ class BaseHybridController(BaseServerController, DirectoryBasedController): ) ) assert self.directory + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ + *faketime_cmd, self.binary_name, "-foreground", "-configfile", - os.path.join(self.directory, "server.conf"), + self.directory / "server.conf", "-pidfile", - os.path.join(self.directory, "server.pid"), + self.directory / "server.pid", ], # stderr=subprocess.DEVNULL, ) diff --git a/irctest/controllers/charybdis.py b/irctest/controllers/charybdis.py index 489d36b..e07685e 100644 --- a/irctest/controllers/charybdis.py +++ b/irctest/controllers/charybdis.py @@ -12,6 +12,9 @@ serverinfo {{ general {{ throttle_count = 100; # We need to connect lots of clients quickly + # disable throttling for LIST and similar: + pace_wait_simple = 0 second; + pace_wait = 0 second; sasl_service = "SaslServ"; }}; diff --git a/irctest/controllers/dlk_services.py b/irctest/controllers/dlk_services.py new file mode 100644 index 0000000..9934354 --- /dev/null +++ b/irctest/controllers/dlk_services.py @@ -0,0 +1,245 @@ +import os +from pathlib import Path +import secrets +import subprocess +from typing import Optional, Type + +import irctest +from irctest.basecontrollers import BaseServicesController, DirectoryBasedController +import irctest.cases +import irctest.runner + +TEMPLATE_DLK_CONFIG = """\ +info {{ + SID "00A"; + network-name "testnetwork"; + services-name "services.example.org"; + admin-email "admin@example.org"; +}} + +link {{ + hostname "{server_hostname}"; + port "{server_port}"; + password "password"; +}} + +log {{ + debug "yes"; +}} + +sql {{ + port "3306"; + username "pifpaf"; + password "pifpaf"; + database "pifpaf"; + sockfile "{mysql_socket}"; + prefix "{dlk_prefix}"; +}} + +wordpress {{ + prefix "{wp_prefix}"; +}} + +""" + +TEMPLATE_DLK_WP_CONFIG = """ + "{wp_prefix}", + + + "default_avatar" => "https://valware.uk/wp-content/plugins/ultimate-member/assets/img/default_avatar.jpg", + "forumschan" => "#DLK-Support", + +]; +""" + +TEMPLATE_WP_CONFIG = """ +define( 'DB_NAME', 'pifpaf' ); +define( 'DB_USER', 'pifpaf' ); +define( 'DB_PASSWORD', 'pifpaf' ); +define( 'DB_HOST', 'localhost:{mysql_socket}' ); +define( 'DB_CHARSET', 'utf8' ); +define( 'DB_COLLATE', '' ); + +define( 'AUTH_KEY', 'put your unique phrase here' ); +define( 'SECURE_AUTH_KEY', 'put your unique phrase here' ); +define( 'LOGGED_IN_KEY', 'put your unique phrase here' ); +define( 'NONCE_KEY', 'put your unique phrase here' ); +define( 'AUTH_SALT', 'put your unique phrase here' ); +define( 'SECURE_AUTH_SALT', 'put your unique phrase here' ); +define( 'LOGGED_IN_SALT', 'put your unique phrase here' ); +define( 'NONCE_SALT', 'put your unique phrase here' ); + +$table_prefix = '{wp_prefix}'; + +define( 'WP_DEBUG', false ); + +if (!defined('ABSPATH')) {{ + define( 'ABSPATH', '{wp_path}' ); +}} + +/* That's all, stop editing! Happy publishing. */ + +/** Absolute path to the WordPress directory. */ + + +/** Sets up WordPress vars and included files. */ +require_once ABSPATH . 'wp-settings.php'; +""" + + +class DlkController(BaseServicesController, DirectoryBasedController): + """Mixin for server controllers that rely on DLK""" + + software_name = "Dlk-Services" + + def run_sql(self, sql: str) -> None: + mysql_socket = os.environ["PIFPAF_MYSQL_SOCKET"] + subprocess.run( + ["mysql", "-S", mysql_socket, "pifpaf"], + input=sql.encode(), + check=True, + ) + + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: + self.create_config() + + if protocol == "unreal4": + protocol = "unreal5" + assert protocol in ("unreal5",), protocol + + mysql_socket = os.environ["PIFPAF_MYSQL_SOCKET"] + + assert self.directory + + try: + self.wp_cli_path = Path(os.environ["IRCTEST_WP_CLI_PATH"]) + if not self.wp_cli_path.is_file(): + raise KeyError() + except KeyError: + raise RuntimeError( + "$IRCTEST_WP_CLI_PATH must be set to a WP-CLI executable (eg. " + "downloaded from )" + ) from None + + try: + self.dlk_path = Path(os.environ["IRCTEST_DLK_PATH"]) + if not self.dlk_path.is_dir(): + raise KeyError() + except KeyError: + raise RuntimeError("$IRCTEST_DLK_PATH is not set") from None + self.dlk_path = self.dlk_path.resolve() + + # Unpack a fresh Wordpress install in the temporary directory. + # In theory we could have a common Wordpress install and only wp-config.php + # in the temporary directory; but wp-cli assumes wp-config.php must be + # in a Wordpress directory, and fails in various places if it isn't. + # Rather than symlinking everything to make it work, let's just copy + # the whole code, it's not that big. + try: + wp_zip_path = Path(os.environ["IRCTEST_WP_ZIP_PATH"]) + if not wp_zip_path.is_file(): + raise KeyError() + except KeyError: + raise RuntimeError( + "$IRCTEST_WP_ZIP_PATH must be set to a Wordpress source zipball " + "(eg. downloaded from )" + ) from None + subprocess.run( + ["unzip", wp_zip_path, "-d", self.directory], stdout=subprocess.DEVNULL + ) + self.wp_path = self.directory / "wordpress" + + rand_hex = secrets.token_hex(6) + self.wp_prefix = f"wp{rand_hex}_" + self.dlk_prefix = f"dlk{rand_hex}_" + template_vars = dict( + protocol=protocol, + server_hostname=server_hostname, + server_port=server_port, + mysql_socket=mysql_socket, + wp_path=self.wp_path, + wp_prefix=self.wp_prefix, + dlk_prefix=self.dlk_prefix, + ) + + # Configure Wordpress + wp_config_path = self.directory / "wp-config.php" + with open(wp_config_path, "w") as fd: + fd.write(TEMPLATE_WP_CONFIG.format(**template_vars)) + + subprocess.run( + [ + "php", + self.wp_cli_path, + "core", + "install", + "--url=http://localhost/", + "--title=irctest site", + "--admin_user=adminuser", + "--admin_email=adminuser@example.org", + f"--path={self.wp_path}", + ], + check=True, + ) + + # Configure Dlk + dlk_log_dir = self.directory / "logs" + dlk_conf_dir = self.directory / "conf" + dlk_conf_path = dlk_conf_dir / "dalek.conf" + os.mkdir(dlk_conf_dir) + with open(dlk_conf_path, "w") as fd: + fd.write(TEMPLATE_DLK_CONFIG.format(**template_vars)) + dlk_wp_config_path = dlk_conf_dir / "wordpress.conf" + with open(dlk_wp_config_path, "w") as fd: + fd.write(TEMPLATE_DLK_WP_CONFIG.format(**template_vars)) + (dlk_conf_dir / "modules.conf").symlink_to(self.dlk_path / "conf/modules.conf") + + self.proc = subprocess.Popen( + [ + "php", + "src/dalek", + ], + cwd=self.dlk_path, + env={ + **os.environ, + "DALEK_CONF_DIR": str(dlk_conf_dir), + "DALEK_LOG_DIR": str(dlk_log_dir), + }, + ) + + def terminate(self) -> None: + super().terminate() + + def kill(self) -> None: + super().kill() + + def registerUser( + self, + case: irctest.cases.BaseServerTestCase, + username: str, + password: Optional[str] = None, + ) -> None: + assert password + subprocess.run( + [ + "php", + self.wp_cli_path, + "user", + "create", + username, + f"{username}@example.org", + f"--user_pass={password}", + f"--path={self.wp_path}", + ], + check=True, + ) + + +def get_irctest_controller_class() -> Type[DlkController]: + return DlkController diff --git a/irctest/controllers/ergo.py b/irctest/controllers/ergo.py index d6e80ab..7a75c87 100644 --- a/irctest/controllers/ergo.py +++ b/irctest/controllers/ergo.py @@ -1,14 +1,11 @@ import copy import json import os +import shutil import subprocess -from typing import Any, Dict, Optional, Set, Type, Union +from typing import Any, Dict, Optional, Type, Union -from irctest.basecontrollers import ( - BaseServerController, - DirectoryBasedController, - NotImplementedByController, -) +from irctest.basecontrollers import BaseServerController, DirectoryBasedController from irctest.cases import BaseServerTestCase BASE_CONFIG = { @@ -129,7 +126,7 @@ def hash_password(password: Union[str, bytes]) -> str: ["ergo", "genpasswd"], stdin=subprocess.PIPE, stdout=subprocess.PIPE ) out, _ = p.communicate(input_) - return out.decode("utf-8") + return out.decode("utf-8").strip() class ErgoController(BaseServerController, DirectoryBasedController): @@ -152,16 +149,9 @@ class ErgoController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, - restricted_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], config: Optional[Any] = None, ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) - self.create_config() if config is None: config = copy.deepcopy(BASE_CONFIG) @@ -183,27 +173,32 @@ class ErgoController(BaseServerController, DirectoryBasedController): bind_address = "127.0.0.1:%s" % (port,) listener_conf = None # plaintext if ssl: - self.key_path = os.path.join(self.directory, "ssl.key") - self.pem_path = os.path.join(self.directory, "ssl.pem") + self.key_path = self.directory / "ssl.key" + self.pem_path = self.directory / "ssl.pem" listener_conf = {"tls": {"cert": self.pem_path, "key": self.key_path}} config["server"]["listeners"][bind_address] = listener_conf # type: ignore - config["datastore"]["path"] = os.path.join( # type: ignore - self.directory, "ircd.db" - ) + config["datastore"]["path"] = str(self.directory / "ircd.db") # type: ignore if password is not None: config["server"]["password"] = hash_password(password) # type: ignore assert self.proc is None - self._config_path = os.path.join(self.directory, "server.yml") + self._config_path = self.directory / "server.yml" self._config = config self._write_config() subprocess.call(["ergo", "initdb", "--conf", self._config_path, "--quiet"]) subprocess.call(["ergo", "mkcerts", "--conf", self._config_path, "--quiet"]) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( - ["ergo", "run", "--conf", self._config_path, "--quiet"] + [*faketime_cmd, "ergo", "run", "--conf", self._config_path, "--quiet"] ) def wait_for_services(self) -> None: @@ -216,9 +211,6 @@ class ErgoController(BaseServerController, DirectoryBasedController): username: str, password: Optional[str] = None, ) -> None: - # XXX: Move this somewhere else when - # https://github.com/ircv3/ircv3-specifications/pull/152 becomes - # part of the specification if not case.run_services: # Ergo does not actually need this, but other controllers do, so we # are checking it here as well for tests that aren't tested with other diff --git a/irctest/controllers/external_server.py b/irctest/controllers/external_server.py index 5ecbae0..021e3bd 100644 --- a/irctest/controllers/external_server.py +++ b/irctest/controllers/external_server.py @@ -1,5 +1,5 @@ import os -from typing import Optional, Set, Tuple, Type +from typing import Optional, Tuple, Type from irctest.basecontrollers import BaseServerController @@ -39,9 +39,7 @@ class ExternalServerController(BaseServerController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, - restricted_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: pass diff --git a/irctest/controllers/inspircd.py b/irctest/controllers/inspircd.py index c7d60c0..d78e1fc 100644 --- a/irctest/controllers/inspircd.py +++ b/irctest/controllers/inspircd.py @@ -1,13 +1,9 @@ -import os +import functools +import shutil import subprocess -from typing import Optional, Set, Type +from typing import Optional, Type -from irctest.basecontrollers import ( - BaseServerController, - DirectoryBasedController, - NotImplementedByController, -) -from irctest.irc_utils.junkdrawer import find_hostname_and_port +from irctest.basecontrollers import BaseServerController, DirectoryBasedController TEMPLATE_CONFIG = """ # Clients: @@ -59,8 +55,10 @@ TEMPLATE_CONFIG = """ target="services.example.org"> # Protocol: + + @@ -76,15 +74,16 @@ TEMPLATE_CONFIG = """ # for testing mute extbans # For multi-prefix + # For userhost-in-names # HELP/HELPOP # for the HELP alias - - + + # Misc: - + """ TEMPLATE_SSL_CONFIG = """ @@ -93,6 +92,17 @@ TEMPLATE_SSL_CONFIG = """ """ +@functools.lru_cache() +def installed_version() -> int: + output = subprocess.check_output(["inspircd", "--version"], universal_newlines=True) + if output.startswith("InspIRCd-3"): + return 3 + if output.startswith("InspIRCd-4"): + return 4 + else: + assert False, f"unexpected version: {output}" + + class InspircdController(BaseServerController, DirectoryBasedController): software_name = "InspIRCd" supported_sasl_mechanisms = {"PLAIN"} @@ -112,19 +122,13 @@ class InspircdController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, - restricted_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str] = None, ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) assert self.proc is None self.port = port self.hostname = hostname self.create_config() - (services_hostname, services_port) = find_hostname_and_port() + (services_hostname, services_port) = self.get_hostname_and_port() password_field = 'password="{}"'.format(password) if password else "" @@ -136,6 +140,13 @@ class InspircdController(BaseServerController, DirectoryBasedController): else: ssl_config = "" + if installed_version() == 3: + help_module_name = "helpop" + elif installed_version() == 4: + help_module_name = "help" + else: + assert False, f"unexpected version: {installed_version()}" + with self.open_file("server.conf") as fd: fd.write( TEMPLATE_CONFIG.format( @@ -145,15 +156,24 @@ class InspircdController(BaseServerController, DirectoryBasedController): services_port=services_port, password_field=password_field, ssl_config=ssl_config, + help_module_name=help_module_name, ) ) assert self.directory + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ + *faketime_cmd, "inspircd", "--nofork", "--config", - os.path.join(self.directory, "server.conf"), + self.directory / "server.conf", ], stdout=subprocess.DEVNULL, ) diff --git a/irctest/controllers/irc2.py b/irctest/controllers/irc2.py index 56defc8..ac3cde2 100644 --- a/irctest/controllers/irc2.py +++ b/irctest/controllers/irc2.py @@ -1,6 +1,6 @@ -import os +import shutil import subprocess -from typing import Optional, Set, Type +from typing import Optional, Type from irctest.basecontrollers import ( BaseServerController, @@ -10,7 +10,7 @@ from irctest.basecontrollers import ( TEMPLATE_CONFIG = """ # M:::::: -M:My.Little.Server:{hostname}:Somewhere:{port}:0042: +M:My.Little.Server:{hostname}:test server:{port}:0042: # A:::::: A:Organization, IRC dept.:Daemon :Client Server::IRCnet: @@ -29,8 +29,8 @@ O:*:operpassword:operuser:::: """ -class Ircu2Controller(BaseServerController, DirectoryBasedController): - binary_name: str +class Irc2Controller(BaseServerController, DirectoryBasedController): + software_name = "irc2" services_protocol: str supports_sts = False @@ -49,13 +49,8 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) if ssl: raise NotImplementedByController("TLS") if run_services: @@ -66,7 +61,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController): self.create_config() password_field = password if password else "" assert self.directory - pidfile = os.path.join(self.directory, "ircd.pid") + pidfile = self.directory / "ircd.pid" with self.open_file("server.conf") as fd: fd.write( TEMPLATE_CONFIG.format( @@ -76,18 +71,26 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController): pidfile=pidfile, ) ) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ + *faketime_cmd, "ircd", "-s", # no iauth "-p", "on", "-f", - os.path.join(self.directory, "server.conf"), + self.directory / "server.conf", ], # stderr=subprocess.DEVNULL, ) -def get_irctest_controller_class() -> Type[Ircu2Controller]: - return Ircu2Controller +def get_irctest_controller_class() -> Type[Irc2Controller]: + return Irc2Controller diff --git a/irctest/controllers/ircu2.py b/irctest/controllers/ircu2.py index 592cfd2..15124c7 100644 --- a/irctest/controllers/ircu2.py +++ b/irctest/controllers/ircu2.py @@ -1,6 +1,6 @@ -import os +import shutil import subprocess -from typing import Optional, Set, Type +from typing import Optional, Type from irctest.basecontrollers import ( BaseServerController, @@ -51,6 +51,7 @@ features {{ class Ircu2Controller(BaseServerController, DirectoryBasedController): + software_name = "ircu2" supports_sts = False extban_mute_char = None @@ -67,13 +68,8 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) if ssl: raise NotImplementedByController("TLS") if run_services: @@ -84,7 +80,7 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController): self.create_config() password_field = 'password = "{}";'.format(password) if password else "" assert self.directory - pidfile = os.path.join(self.directory, "ircd.pid") + pidfile = self.directory / "ircd.pid" with self.open_file("server.conf") as fd: fd.write( TEMPLATE_CONFIG.format( @@ -94,12 +90,20 @@ class Ircu2Controller(BaseServerController, DirectoryBasedController): pidfile=pidfile, ) ) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ + *faketime_cmd, "ircd", "-n", # don't detach "-f", - os.path.join(self.directory, "server.conf"), + self.directory / "server.conf", "-x", "DEBUG", ], diff --git a/irctest/controllers/limnoria.py b/irctest/controllers/limnoria.py index 78b005f..b56c921 100644 --- a/irctest/controllers/limnoria.py +++ b/irctest/controllers/limnoria.py @@ -1,4 +1,3 @@ -import os import subprocess from typing import Optional, Type @@ -55,13 +54,19 @@ class LimnoriaController(BaseClientController, DirectoryBasedController): # Runs a client with the config given as arguments assert self.proc is None self.create_config() + + username = password = "" + mechanisms = "" if auth: mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms) if auth.ecdsa_key: with self.open_file("ecdsa_key.pem") as fd: fd.write(auth.ecdsa_key) - else: - mechanisms = "" + + if auth.username: + username = auth.username.encode("unicode_escape").decode() + if auth.password: + password = auth.password.encode("unicode_escape").decode() with self.open_file("bot.conf") as fd: fd.write( TEMPLATE_CONFIG.format( @@ -69,8 +74,8 @@ class LimnoriaController(BaseClientController, DirectoryBasedController): loglevel="CRITICAL", hostname=hostname, port=port, - username=auth.username if auth else "", - password=auth.password if auth else "", + username=username, + password=password, mechanisms=mechanisms.lower(), enable_tls=tls_config.enable if tls_config else "False", trusted_fingerprints=" ".join(tls_config.trusted_fingerprints) @@ -79,9 +84,7 @@ class LimnoriaController(BaseClientController, DirectoryBasedController): ) ) assert self.directory - self.proc = subprocess.Popen( - ["supybot", os.path.join(self.directory, "bot.conf")] - ) + self.proc = subprocess.Popen(["supybot", self.directory / "bot.conf"]) def get_irctest_controller_class() -> Type[LimnoriaController]: diff --git a/irctest/controllers/mammon.py b/irctest/controllers/mammon.py index c437139..8c22776 100644 --- a/irctest/controllers/mammon.py +++ b/irctest/controllers/mammon.py @@ -1,4 +1,4 @@ -import os +import shutil import subprocess from typing import Optional, Set, Type @@ -33,10 +33,10 @@ extensions: - mammon.ext.ircv3.sasl - mammon.ext.misc.nopost metadata: - restricted_keys: -{restricted_keys} + restricted_keys: [] whitelist: -{authorized_keys} + - display-name + - avatar monitor: limit: 20 motd: @@ -89,9 +89,7 @@ class MammonController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, - restricted_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: if password is not None: raise NotImplementedByController("PASS command") @@ -106,19 +104,25 @@ class MammonController(BaseServerController, DirectoryBasedController): directory=self.directory, hostname=hostname, port=port, - authorized_keys=make_list(valid_metadata_keys or set()), - restricted_keys=make_list(restricted_metadata_keys or set()), ) ) # with self.open_file('server.yml', 'r') as fd: # print(fd.read()) assert self.directory + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ + *faketime_cmd, "mammond", "--nofork", # '--debug', "--config", - os.path.join(self.directory, "server.yml"), + self.directory / "server.yml", ] ) diff --git a/irctest/controllers/nefarious.py b/irctest/controllers/nefarious.py new file mode 100644 index 0000000..379039d --- /dev/null +++ b/irctest/controllers/nefarious.py @@ -0,0 +1,11 @@ +from typing import Type + +from .ircu2 import Ircu2Controller + + +class NefariousController(Ircu2Controller): + software_name = "Nefarious" + + +def get_irctest_controller_class() -> Type[NefariousController]: + return NefariousController diff --git a/irctest/controllers/ngircd.py b/irctest/controllers/ngircd.py index 5d0d845..0bcd0ca 100644 --- a/irctest/controllers/ngircd.py +++ b/irctest/controllers/ngircd.py @@ -1,18 +1,13 @@ -import os +import shutil import subprocess from typing import Optional, Set, Type -from irctest.basecontrollers import ( - BaseServerController, - DirectoryBasedController, - NotImplementedByController, -) -from irctest.irc_utils.junkdrawer import find_hostname_and_port +from irctest.basecontrollers import BaseServerController, DirectoryBasedController TEMPLATE_CONFIG = """ [Global] Name = My.Little.Server - Info = ExampleNET Server + Info = test server Bind = {hostname} Ports = {port} AdminInfo1 = Bob Smith @@ -26,6 +21,9 @@ TEMPLATE_CONFIG = """ Passive = yes # don't connect to it ServiceMask = *Serv +[Options] + MorePrivacy = no # by default, always replies to WHOWAS with ERR_WASNOSUCHNICK + [Operator] Name = operuser Password = operpassword @@ -50,19 +48,13 @@ class NgircdController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, - restricted_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) assert self.proc is None self.port = port self.hostname = hostname self.create_config() - (unused_hostname, unused_port) = find_hostname_and_port() + (unused_hostname, unused_port) = self.get_hostname_and_port() password_field = "Password = {}".format(password) if password else "" @@ -78,6 +70,7 @@ class NgircdController(BaseServerController, DirectoryBasedController): fd.write("\n") assert self.directory + with self.open_file("server.conf") as fd: fd.write( TEMPLATE_CONFIG.format( @@ -88,15 +81,23 @@ class NgircdController(BaseServerController, DirectoryBasedController): password_field=password_field, key_path=self.key_path, pem_path=self.pem_path, - empty_file=os.path.join(self.directory, "empty.txt"), + empty_file=self.directory / "empty.txt", ) ) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ + *faketime_cmd, "ngircd", "--nodaemon", "--config", - os.path.join(self.directory, "server.conf"), + self.directory / "server.conf", ], # stdout=subprocess.DEVNULL, ) diff --git a/irctest/controllers/plexus4.py b/irctest/controllers/plexus4.py index 395b4e4..a968e56 100644 --- a/irctest/controllers/plexus4.py +++ b/irctest/controllers/plexus4.py @@ -74,7 +74,7 @@ operator {{ class Plexus4Controller(BaseHybridController): - software_name = "Hybrid" + software_name = "Plexus4" binary_name = "ircd" services_protocol = "plexus" diff --git a/irctest/controllers/sable.py b/irctest/controllers/sable.py new file mode 100644 index 0000000..cdd07ba --- /dev/null +++ b/irctest/controllers/sable.py @@ -0,0 +1,481 @@ +import os +from pathlib import Path +import shutil +import signal +import subprocess +import tempfile +import time +from typing import Optional, Type + +from irctest.basecontrollers import ( + BaseServerController, + BaseServicesController, + DirectoryBasedController, + NotImplementedByController, +) +from irctest.cases import BaseServerTestCase +from irctest.exceptions import NoMessageException +from irctest.patma import ANYSTR + +GEN_CERTS = """ +mkdir -p useless_openssl_data/ + +cat > openssl.cnf < useless_openssl_data/serial + +# Generate CA +openssl req -x509 -nodes -newkey rsa:2048 -batch \ + -subj "/CN=Test CA" \ + -outform PEM -out ca_cert.pem \ + -keyout ca_cert.key + +for server in $*; do + openssl genrsa -traditional \ + -out $server.key \ + 2048 + openssl req -nodes -batch -new \ + -addext "subjectAltName = DNS:$server" \ + -key $server.key \ + -outform PEM -out server_$server.req + openssl ca -config openssl.cnf -days 3650 -md sha512 -batch \ + -subj /CN=$server \ + -keyfile ca_cert.key -cert ca_cert.pem \ + -in server_$server.req \ + -out $server.pem + openssl x509 -sha1 -in $server.pem -fingerprint -noout \ + | sed "s/.*=//" | sed "s/://g" | tr '[:upper:]' '[:lower:]' > $server.pem.sha1 +done + +rm -r useless_openssl_data/ +""" + +_certs_dir = None + + +def certs_dir() -> Path: + global _certs_dir + if _certs_dir is None: + certs_dir = tempfile.TemporaryDirectory() + (Path(certs_dir.name) / "gen_certs.sh").write_text(GEN_CERTS) + subprocess.run( + ["bash", "gen_certs.sh", "My.Little.Server", "My.Little.Services"], + cwd=certs_dir.name, + check=True, + ) + _certs_dir = certs_dir + return Path(_certs_dir.name) + + +NETWORK_CONFIG = """ +{ + "fanout": 1, + "ca_file": "%(certs_dir)s/ca_cert.pem", + + "peers": [ + { "name": "My.Little.Services", "address": "%(services_hostname)s:%(services_port)s", "fingerprint": "%(services_cert_sha1)s" }, + { "name": "My.Little.Server", "address": "%(server1_hostname)s:%(server1_port)s", "fingerprint": "%(server1_cert_sha1)s" } + ] +} +""" + +NETWORK_CONFIG_CONFIG = """ +{ + "opers": [ + { + "name": "operuser", + // echo -n "operpassword" | openssl passwd -6 -stdin + "hash": "$6$z5yA.OfGliDoi/R2$BgSsguS6bxAsPSCygDisgDw5JZuo5.88eU3Hyc7/4OaNpeKIxWGjOggeHzOl0xLiZg1vfwxXjOTFN14wG5vNI." + } + ], + + "alias_users": [ + { + "nick": "ChanServ", + "user": "ChanServ", + "host": "services.", + "realname": "Channel services compatibility layer", + "command_alias": "CS" + }, + { + "nick": "NickServ", + "user": "NickServ", + "host": "services.", + "realname": "Account services compatibility layer", + "command_alias": "NS" + } + ], + + "default_roles": { + "builtin:op": [ + "always_send", + "op_self", "op_grant", "voice_self", "voice_grant", + "receive_op", "receive_voice", "receive_opmod", + "topic", "kick", "set_simple_mode", "set_key", + "rename", + "ban_view", "ban_add", "ban_remove_any", + "quiet_view", "quiet_add", "quiet_remove_any", + "exempt_view", "exempt_add", "exempt_remove_any", + "invite_self", "invite_other", + "invex_view", "invex_add", "invex_remove_any" + ], + "builtin:voice": [ + "always_send", + "voice_self", + "receive_voice", + "ban_view", "quiet_view" + ], + "builtin:all": [ + "ban_view", "quiet_view" + ] + }, + + "debug_mode": true +} +""" + +SERVER_CONFIG = """ +{ + "server_id": 1, + "server_name": "My.Little.Server", + + "management": { + "address": "%(server1_management_hostname)s:%(server1_management_port)s", + "client_ca": "%(certs_dir)s/ca_cert.pem", + "authorised_fingerprints": [ + { "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" }, + ], + }, + + "server": { + "listeners": [ + { "address": "%(c2s_hostname)s:%(c2s_port)s" }, + ], + }, + + "event_log": { + "event_expiry": 300, // five minutes, for local testing + }, + + "tls_config": { + "key_file": "%(certs_dir)s/My.Little.Server.key", + "cert_file": "%(certs_dir)s/My.Little.Server.pem", + }, + + "node_config": { + "listen_addr": "%(server1_hostname)s:%(server1_port)s", + "cert_file": "%(certs_dir)s/My.Little.Server.pem", + "key_file": "%(certs_dir)s/My.Little.Server.key", + }, + + "log": { + "dir": "log/server1/", + + "module-levels": { + "": "debug", + "sable_ircd": "trace", + }, + + "targets": [ + { + "target": "stdout", + "level": "trace", + "modules": [ "sable", "audit", "client_listener" ], + }, + ], + }, +} +""" + +SERVICES_CONFIG = """ +{ + "server_id": 99, + "server_name": "My.Little.Services", + + "management": { + "address": "%(services_management_hostname)s:%(services_management_port)s", + "client_ca": "%(certs_dir)s/ca_cert.pem", + "authorised_fingerprints": [ + { "name": "user1", "fingerprint": "435bc6db9f22e84ba5d9652432154617c9509370" } + ] + }, + + "server": { + "database": "test_database.json", + "default_roles": { + "builtin:founder": [ + "founder", "access_view", "access_edit", "role_view", "role_edit", + "op_self", "op_grant", + "voice_self", "voice_grant", + "always_send", + "invite_self", "invite_other", + "receive_op", "receive_voice", "receive_opmod", + "topic", "kick", "set_simple_mode", "set_key", + "rename", + "ban_view", "ban_add", "ban_remove_any", + "quiet_view", "quiet_add", "quiet_remove_any", + "exempt_view", "exempt_add", "exempt_remove_any", + "invex_view", "invex_add", "invex_remove_any" + ], + "builtin:op": [ + "always_send", + "receive_op", "receive_voice", "receive_opmod", + "topic", "kick", "set_simple_mode", "set_key", + "rename", + "ban_view", "ban_add", "ban_remove_any", + "quiet_view", "quiet_add", "quiet_remove_any", + "exempt_view", "exempt_add", "exempt_remove_any", + "invex_view", "invex_add", "invex_remove_any" + ], + "builtin:voice": [ + "always_send", "voice_self", "receive_voice" + ] + } + }, + + "event_log": { + "event_expiry": 300, // five minutes, for local testing + }, + + "tls_config": { + "key_file": "%(certs_dir)s/My.Little.Services.key", + "cert_file": "%(certs_dir)s/My.Little.Services.pem" + }, + + "node_config": { + "listen_addr": "%(services_hostname)s:%(services_port)s", + "cert_file": "%(certs_dir)s/My.Little.Services.pem", + "key_file": "%(certs_dir)s/My.Little.Services.key" + }, + + "log": { + "dir": "log/services/", + + "module-levels": { + "": "debug" + }, + + "targets": [ + { + "target": "stdout", + "level": "debug", + "modules": [ "sable_services" ] + } + ] + } +} +""" + + +class SableController(BaseServerController, DirectoryBasedController): + software_name = "Sable" + supported_sasl_mechanisms = {"PLAIN"} + sync_sleep_time = 0.1 + """Sable processes commands very quickly, but responses for commands changing the + state may be sent after later commands for messages which don't.""" + + def run( + self, + hostname: str, + port: int, + *, + password: Optional[str], + ssl: bool, + run_services: bool, + faketime: Optional[str], + ) -> None: + if password is not None: + raise NotImplementedByController("PASS command") + if ssl: + raise NotImplementedByController("SSL") + assert self.proc is None + self.port = port + self.create_config() + + assert self.directory + + (self.directory / "configs").mkdir() + + c2s_hostname = hostname + c2s_port = port + del hostname, port + # base controller expects this to check for NickServ presence itself + self.hostname = c2s_hostname + self.port = c2s_port + + (server1_hostname, server1_port) = self.get_hostname_and_port() + (services_hostname, services_port) = self.get_hostname_and_port() + + # Sable requires inbound connections to match the configured hostname, + # so we can't configure 0.0.0.0 + server1_hostname = services_hostname = "127.0.0.1" + + ( + server1_management_hostname, + server1_management_port, + ) = self.get_hostname_and_port() + ( + services_management_hostname, + services_management_port, + ) = self.get_hostname_and_port() + + self.template_vars = dict( + certs_dir=certs_dir(), + c2s_hostname=c2s_hostname, + c2s_port=c2s_port, + server1_hostname=server1_hostname, + server1_port=server1_port, + server1_cert_sha1=(certs_dir() / "My.Little.Server.pem.sha1") + .read_text() + .strip(), + server1_management_hostname=server1_management_hostname, + server1_management_port=server1_management_port, + services_hostname=services_hostname, + services_port=services_port, + services_cert_sha1=(certs_dir() / "My.Little.Services.pem.sha1") + .read_text() + .strip(), + services_management_hostname=services_management_hostname, + services_management_port=services_management_port, + ) + + with self.open_file("configs/network.conf") as fd: + fd.write(NETWORK_CONFIG % self.template_vars) + with self.open_file("configs/network_config.conf") as fd: + fd.write(NETWORK_CONFIG_CONFIG % self.template_vars) + with self.open_file("configs/server1.conf") as fd: + fd.write(SERVER_CONFIG % self.template_vars) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + + self.proc = subprocess.Popen( + [ + *faketime_cmd, + "sable_ircd", + "--foreground", + "--server-conf", + self.directory / "configs/server1.conf", + "--network-conf", + self.directory / "configs/network.conf", + "--bootstrap-network", + self.directory / "configs/network_config.conf", + ], + cwd=self.directory, + preexec_fn=os.setsid, + ) + self.pgroup_id = os.getpgid(self.proc.pid) + + if run_services: + self.services_controller = SableServicesController(self.test_config, self) + self.services_controller.run( + protocol="sable", + server_hostname=services_hostname, + server_port=services_port, + ) + + def kill_proc(self) -> None: + os.killpg(self.pgroup_id, signal.SIGKILL) + super().kill_proc() + + def registerUser( + self, + case: BaseServerTestCase, # type: ignore + username: str, + password: Optional[str] = None, + ) -> None: + # XXX: Move this somewhere else when + # https://github.com/ircv3/ircv3-specifications/pull/152 becomes + # part of the specification + if not case.run_services: + raise ValueError( + "Attempted to register a nick, but `run_services` it not True." + ) + assert password + client = case.addClient(show_io=True) + case.sendLine(client, "NICK " + username) + case.sendLine(client, "USER r e g :user") + while case.getRegistrationMessage(client).command != "001": + pass + case.getMessages(client) + case.sendLine( + client, + f"REGISTER * * {password}", + ) + for _ in range(100): + time.sleep(0.1) + try: + msg = case.getMessage(client) + except NoMessageException: + continue + case.assertMessageMatch( + msg, command="REGISTER", params=["SUCCESS", username, ANYSTR] + ) + break + else: + raise NoMessageException() + case.sendLine(client, "QUIT") + case.assertDisconnected(client) + + +class SableServicesController(BaseServicesController): + server_controller: SableController + software_name = "Sable Services" + + def run(self, protocol: str, server_hostname: str, server_port: int) -> None: + assert protocol == "sable" + assert self.server_controller.directory is not None + + with self.server_controller.open_file("configs/services.conf") as fd: + fd.write(SERVICES_CONFIG % self.server_controller.template_vars) + + self.proc = subprocess.Popen( + [ + "sable_services", + "--foreground", + "--server-conf", + self.server_controller.directory / "configs/services.conf", + "--network-conf", + self.server_controller.directory / "configs/network.conf", + ], + cwd=self.server_controller.directory, + preexec_fn=os.setsid, + ) + self.pgroup_id = os.getpgid(self.proc.pid) + + def kill_proc(self) -> None: + os.killpg(self.pgroup_id, signal.SIGKILL) + super().kill_proc() + + +def get_irctest_controller_class() -> Type[SableController]: + return SableController diff --git a/irctest/controllers/snircd.py b/irctest/controllers/snircd.py index 7fa9acc..1a8b2fa 100644 --- a/irctest/controllers/snircd.py +++ b/irctest/controllers/snircd.py @@ -1,6 +1,6 @@ -import os +import shutil import subprocess -from typing import Optional, Set, Type +from typing import Optional, Type from irctest.basecontrollers import ( BaseServerController, @@ -67,13 +67,8 @@ class SnircdController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) if ssl: raise NotImplementedByController("TLS") if run_services: @@ -84,7 +79,7 @@ class SnircdController(BaseServerController, DirectoryBasedController): self.create_config() password_field = 'password = "{}";'.format(password) if password else "" assert self.directory - pidfile = os.path.join(self.directory, "ircd.pid") + pidfile = self.directory / "ircd.pid" with self.open_file("server.conf") as fd: fd.write( TEMPLATE_CONFIG.format( @@ -94,12 +89,20 @@ class SnircdController(BaseServerController, DirectoryBasedController): pidfile=pidfile, ) ) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + self.proc = subprocess.Popen( [ + *faketime_cmd, "ircd", "-n", # don't detach "-f", - os.path.join(self.directory, "server.conf"), + self.directory / "server.conf", "-x", "DEBUG", ], diff --git a/irctest/controllers/sopel.py b/irctest/controllers/sopel.py index 7304315..b6d2443 100644 --- a/irctest/controllers/sopel.py +++ b/irctest/controllers/sopel.py @@ -1,4 +1,4 @@ -import os +from pathlib import Path import subprocess import tempfile from typing import Optional, TextIO, Type, cast @@ -38,14 +38,14 @@ class SopelController(BaseClientController): super().kill() if self.filename: try: - os.unlink(os.path.join(os.path.expanduser("~/.sopel/"), self.filename)) - except OSError: #  File does not exist + (Path("~/.sopel/").expanduser() / self.filename).unlink() + except OSError: # File does not exist pass def open_file(self, filename: str, mode: str = "a") -> TextIO: - dir_path = os.path.expanduser("~/.sopel/") - os.makedirs(dir_path, exist_ok=True) - return cast(TextIO, open(os.path.join(dir_path, filename), mode)) + dir_path = Path("~/.sopel/").expanduser() + dir_path.mkdir(parents=True, exist_ok=True) + return cast(TextIO, (dir_path / filename).open(mode)) def create_config(self) -> None: with self.open_file(self.filename): @@ -73,7 +73,7 @@ class SopelController(BaseClientController): auth_method="auth_method = sasl" if auth else "", ) ) - self.proc = subprocess.Popen(["sopel", "--quiet", "-c", self.filename]) + self.proc = subprocess.Popen(["sopel", "-c", self.filename]) def get_irctest_controller_class() -> Type[SopelController]: diff --git a/irctest/controllers/thelounge.py b/irctest/controllers/thelounge.py new file mode 100644 index 0000000..7992243 --- /dev/null +++ b/irctest/controllers/thelounge.py @@ -0,0 +1,106 @@ +import json +import os +import subprocess +from typing import Optional, Type + +from irctest import authentication, tls +from irctest.basecontrollers import ( + BaseClientController, + DirectoryBasedController, + NotImplementedByController, +) + +TEMPLATE_CONFIG = """ +"use strict"; + +module.exports = {config}; +""" + + +class TheLoungeController(BaseClientController, DirectoryBasedController): + software_name = "TheLounge" + supported_sasl_mechanisms = { + "PLAIN", + "ECDSA-NIST256P-CHALLENGE", + "SCRAM-SHA-256", + "EXTERNAL", + } + supports_sts = True + + def create_config(self) -> None: + super().create_config() + with self.open_file("bot.conf"): + pass + with self.open_file("conf/users.conf"): + pass + + def run( + self, + hostname: str, + port: int, + auth: Optional[authentication.Authentication], + tls_config: Optional[tls.TlsConfig] = None, + ) -> None: + if tls_config is None: + tls_config = tls.TlsConfig(enable=False, trusted_fingerprints=[]) + if tls_config and tls_config.trusted_fingerprints: + raise NotImplementedByController("Trusted fingerprints.") + if auth and any( + mech.to_string().startswith(("SCRAM-", "ECDSA-")) + for mech in auth.mechanisms + ): + raise NotImplementedByController("ecdsa") + if auth and auth.password and len(auth.password) > 300: + # https://github.com/thelounge/thelounge/pull/4480 + # Note that The Lounge truncates on 300 characters, not bytes. + raise NotImplementedByController("Passwords longer than 300 chars") + # Runs a client with the config given as arguments + assert self.proc is None + self.create_config() + if auth: + mechanisms = " ".join(mech.to_string() for mech in auth.mechanisms) + if auth.ecdsa_key: + with self.open_file("ecdsa_key.pem") as fd: + fd.write(auth.ecdsa_key) + else: + mechanisms = "" + + assert self.directory + with self.open_file("config.js") as fd: + fd.write( + TEMPLATE_CONFIG.format( + config=json.dumps( + dict( + public=False, + host=f"unix:{self.directory}/sock", # prevents binding + ) + ) + ) + ) + with self.open_file("users/testuser.json") as fd: + json.dump( + dict( + networks=[ + dict( + name="testnet", + host=hostname, + port=port, + tls=tls_config.enable if tls_config else "False", + sasl=mechanisms.lower(), + saslAccount=auth.username if auth else "", + saslPassword=auth.password if auth else "", + ) + ] + ), + fd, + ) + with self.open_file("users/testuser.json", "r") as fd: + print("config", json.load(fd)["networks"][0]["saslPassword"]) + self.proc = subprocess.Popen( + [os.environ.get("THELOUNGE_BIN", "thelounge"), "start"], + env={**os.environ, "THELOUNGE_HOME": str(self.directory)}, + ) + + +def get_irctest_controller_class() -> Type[TheLoungeController]: + return TheLoungeController diff --git a/irctest/controllers/unrealircd.py b/irctest/controllers/unrealircd.py index 5e8b103..923d0d8 100644 --- a/irctest/controllers/unrealircd.py +++ b/irctest/controllers/unrealircd.py @@ -1,15 +1,13 @@ +import contextlib +import fcntl import functools -import os +from pathlib import Path +import shutil import subprocess import textwrap -from typing import Optional, Set, Type +from typing import Callable, ContextManager, Iterator, Optional, Type -from irctest.basecontrollers import ( - BaseServerController, - DirectoryBasedController, - NotImplementedByController, -) -from irctest.irc_utils.junkdrawer import find_hostname_and_port +from irctest.basecontrollers import BaseServerController, DirectoryBasedController TEMPLATE_CONFIG = """ include "modules.default.conf"; @@ -19,7 +17,7 @@ include "help/help.conf"; me {{ name "My.Little.Server"; - info "ExampleNET Server"; + info "test server"; sid "001"; }} admin {{ @@ -97,6 +95,9 @@ set {{ }} }} modes-on-join "+H 100:1d"; // Enables CHATHISTORY + + {set_v6only} + }} tld {{ @@ -106,14 +107,65 @@ tld {{ rules "{empty_file}"; }} +files {{ + tunefile "{empty_file}"; +}} + oper "operuser" {{ - password = "operpassword"; + password "operpassword"; mask *; class clients; operclass netadmin; }} """ +SET_V6ONLY = """ +// Remove RPL_WHOISSPECIAL used to advertise security groups +whois-details { + security-groups { everyone none; self none; oper none; } +} + +plaintext-policy { + server warn; // https://www.unrealircd.org/docs/FAQ#server-requires-tls + oper warn; // https://www.unrealircd.org/docs/FAQ#oper-requires-tls +} + +anti-flood { + everyone { + connect-flood 255:10; + } +} +""" + + +def _filelock(path: Path) -> Callable[[], ContextManager]: + """Alternative to :cls:`multiprocessing.Lock` that works with pytest-xdist""" + + @contextlib.contextmanager + def f() -> Iterator[None]: + with open(path, "a") as fd: + fcntl.flock(fd, fcntl.LOCK_EX) + yield + + return f + + +_UNREALIRCD_BIN = shutil.which("unrealircd") +if _UNREALIRCD_BIN: + _UNREALIRCD_PREFIX = Path(_UNREALIRCD_BIN).parent.parent + + # Try to keep that lock file specific to this Unrealircd instance + _LOCK_PATH = _UNREALIRCD_PREFIX / "irctest-unrealircd-startstop.lock" +else: + # unrealircd not found; we are probably going to crash later anyway... + _LOCK_PATH = Path("/tmp/irctest-unrealircd-startstop.lock") + +_STARTSTOP_LOCK = _filelock(_LOCK_PATH) +""" +Unreal cleans its tmp/ directory after each run, which prevents +multiple processes from starting/stopping at the same time. +""" + @functools.lru_cache() def installed_version() -> int: @@ -132,6 +184,7 @@ class UnrealircdController(BaseServerController, DirectoryBasedController): supports_sts = False extban_mute_char = "quiet" if installed_version() >= 6 else "q" + software_version = installed_version() def create_config(self) -> None: super().create_config() @@ -146,23 +199,33 @@ class UnrealircdController(BaseServerController, DirectoryBasedController): password: Optional[str], ssl: bool, run_services: bool, - valid_metadata_keys: Optional[Set[str]] = None, - invalid_metadata_keys: Optional[Set[str]] = None, - restricted_metadata_keys: Optional[Set[str]] = None, + faketime: Optional[str], ) -> None: - if valid_metadata_keys or invalid_metadata_keys: - raise NotImplementedByController( - "Defining valid and invalid METADATA keys." - ) assert self.proc is None self.port = port self.hostname = hostname self.create_config() - (unused_hostname, unused_port) = find_hostname_and_port() - (services_hostname, services_port) = find_hostname_and_port() + + if installed_version() >= 6: + extras = textwrap.dedent( + """ + include "snomasks.default.conf"; + loadmodule "cloak_md5"; + """ + ) + set_v6only = SET_V6ONLY + else: + extras = "" + set_v6only = "" + + with self.open_file("empty.txt") as fd: + fd.write("\n") password_field = 'password "{}";'.format(password) if password else "" + (services_hostname, services_port) = self.get_hostname_and_port() + (unused_hostname, unused_port) = self.get_hostname_and_port() + self.gen_ssl() if ssl: (tls_hostname, tls_port) = (hostname, port) @@ -171,20 +234,8 @@ class UnrealircdController(BaseServerController, DirectoryBasedController): # Unreal refuses to start without TLS enabled (tls_hostname, tls_port) = (unused_hostname, unused_port) - if installed_version() >= 6: - extras = textwrap.dedent( - """ - include "snomasks.default.conf"; - loadmodule "cloak_md5"; - """ - ) - else: - extras = "" - - with self.open_file("empty.txt") as fd: - fd.write("\n") - assert self.directory + with self.open_file("unrealircd.conf") as fd: fd.write( TEMPLATE_CONFIG.format( @@ -197,23 +248,33 @@ class UnrealircdController(BaseServerController, DirectoryBasedController): password_field=password_field, key_path=self.key_path, pem_path=self.pem_path, - empty_file=os.path.join(self.directory, "empty.txt"), + empty_file=self.directory / "empty.txt", + set_v6only=set_v6only, extras=extras, ) ) - self.proc = subprocess.Popen( - [ - "unrealircd", - "-t", - "-F", # BOOT_NOFORK - "-f", - os.path.join(self.directory, "unrealircd.conf"), - ], - # stdout=subprocess.DEVNULL, - ) + + if faketime and shutil.which("faketime"): + faketime_cmd = ["faketime", "-f", faketime] + self.faketime_enabled = True + else: + faketime_cmd = [] + + with _STARTSTOP_LOCK(): + self.proc = subprocess.Popen( + [ + *faketime_cmd, + "unrealircd", + "-t", + "-F", # BOOT_NOFORK + "-f", + self.directory / "unrealircd.conf", + ], + # stdout=subprocess.DEVNULL, + ) + self.wait_for_port() if run_services: - self.wait_for_port() self.services_controller = self.services_controller_class( self.test_config, self ) @@ -223,6 +284,14 @@ class UnrealircdController(BaseServerController, DirectoryBasedController): server_port=services_port, ) + def kill_proc(self) -> None: + assert self.proc + + with _STARTSTOP_LOCK(): + self.proc.kill() + self.proc.wait(5) # wait for it to actually die + self.proc = None + def get_irctest_controller_class() -> Type[UnrealircdController]: return UnrealircdController diff --git a/irctest/dashboard/format.py b/irctest/dashboard/format.py new file mode 100644 index 0000000..f7908e4 --- /dev/null +++ b/irctest/dashboard/format.py @@ -0,0 +1,465 @@ +import base64 +import dataclasses +import gzip +import hashlib +import importlib +from pathlib import Path +import re +import sys +from typing import ( + IO, + Callable, + Dict, + Iterable, + Iterator, + List, + Optional, + Tuple, + TypeVar, + Union, +) +import xml.etree.ElementTree as ET + +from defusedxml.ElementTree import parse as parse_xml +import docutils.core + +from .shortxml import Namespace + +NETLIFY_CHAR_BLACKLIST = frozenset('":<>|*?\r\n#') +"""Characters not allowed in output filenames""" + + +HTML = Namespace("http://www.w3.org/1999/xhtml") + + +@dataclasses.dataclass +class CaseResult: + module_name: str + class_name: str + test_name: str + job: str + success: bool + skipped: bool + system_out: Optional[str] + details: Optional[str] = None + type: Optional[str] = None + message: Optional[str] = None + + def output_filename(self) -> str: + test_name = self.test_name + if len(test_name) > 50 or set(test_name) & NETLIFY_CHAR_BLACKLIST: + # File name too long or otherwise invalid. This should be good enough: + m = re.match(r"(?P\w+?)\[(?P.+)\]", test_name) + assert m, "File name is too long but has no parameter." + test_name = f'{m.group("function_name")}[{md5sum(m.group("params"))}]' + return f"{self.job}_{self.module_name}.{self.class_name}.{test_name}.txt" + + +TK = TypeVar("TK") +TV = TypeVar("TV") + + +def md5sum(text: str) -> str: + return base64.urlsafe_b64encode(hashlib.md5(text.encode()).digest()).decode() + + +def group_by(list_: Iterable[TV], key: Callable[[TV], TK]) -> Dict[TK, List[TV]]: + groups: Dict[TK, List[TV]] = {} + for value in list_: + groups.setdefault(key(value), []).append(value) + + return groups + + +def iter_job_results(job_file_name: Path, job: ET.ElementTree) -> Iterator[CaseResult]: + (suite,) = job.getroot() + for case in suite: + if "name" not in case.attrib: + continue + + success = True + skipped = False + details = None + system_out = None + extra: Dict[str, str] = {} + for child in case: + if child.tag == "skipped": + success = True + skipped = True + details = None + extra = child.attrib + elif child.tag in ("failure", "error"): + success = False + skipped = False + details = child.text + extra = child.attrib + elif child.tag == "system-out": + assert ( + system_out is None + # for some reason, skipped tests have two system-out; + # and the second one contains test teardown + or child.text.startswith(system_out.rstrip()) + ), ("Duplicate system-out tag", repr(system_out), repr(child.text)) + system_out = child.text + else: + assert False, child + + (module_name, class_name) = case.attrib["classname"].rsplit(".", 1) + m = re.match( + r"(.*/)?pytest[ -]results[ _](?P.*)" + r"[ _][(]?(stable|release|devel|devel_release)[)]?/pytest.xml(.gz)?", + str(job_file_name), + ) + assert m, job_file_name + yield CaseResult( + module_name=module_name, + class_name=class_name, + test_name=case.attrib["name"], + job=m.group("name"), + success=success, + skipped=skipped, + details=details, + system_out=system_out, + **extra, + ) + + +def rst_to_element(s: str) -> ET.Element: + html = docutils.core.publish_parts(s, writer_name="xhtml")["html_body"] + + # Force the HTML namespace on all elements produced by docutils, which are + # unqualified + tree_builder = ET.TreeBuilder( + element_factory=lambda tag, attrib: ET.Element( + "{%s}%s" % (HTML.uri, tag), + {"{%s}%s" % (HTML.uri, k): v for (k, v) in attrib.items()}, + ) + ) + parser = ET.XMLParser(target=tree_builder) + + htmltree = ET.fromstring(html, parser=parser) + return htmltree + + +def docstring(obj: object) -> Optional[ET.Element]: + if obj.__doc__ is None: + return None + + return rst_to_element(obj.__doc__) + + +def build_job_html(job: str, results: List[CaseResult]) -> ET.Element: + jobs = sorted({result.job for result in results}) + + table = build_test_table(jobs, results, "job-results test-matrix") + + return HTML.html( + HTML.head( + HTML.title(job), + HTML.link(rel="stylesheet", type="text/css", href="./style.css"), + ), + HTML.body( + HTML.h1(job), + table, + ), + ) + + +def build_module_html( + jobs: List[str], results: List[CaseResult], module_name: str +) -> ET.Element: + module = importlib.import_module(module_name) + + table = build_test_table(jobs, results, "module-results test-matrix") + + return HTML.html( + HTML.head( + HTML.title(module_name), + HTML.link(rel="stylesheet", type="text/css", href="./style.css"), + ), + HTML.body( + HTML.h1(module_name), + docstring(module), + table, + ), + ) + + +def build_test_table( + jobs: List[str], results: List[CaseResult], class_: str +) -> ET.Element: + multiple_modules = len({r.module_name for r in results}) > 1 + results_by_module_and_class = group_by( + results, lambda r: (r.module_name, r.class_name) + ) + + job_row = HTML.tr( + HTML.th(), # column of case name + [HTML.th(HTML.div(HTML.span(job)), class_="job-name") for job in jobs], + ) + + rows = [] + + for (module_name, class_name), class_results in sorted( + results_by_module_and_class.items() + ): + if multiple_modules: + # if the page shows classes from various modules, use the fully-qualified + # name in order to disambiguate and be clearer (eg. show + # "irctest.server_tests.extended_join.MetadataTestCase" instead of just + # "MetadataTestCase" which looks like it's about IRCv3's METADATA spec. + qualified_class_name = f"{module_name}.{class_name}" + else: + # otherwise, it's not needed, so let's not display it + qualified_class_name = class_name + + module = importlib.import_module(module_name) + + # Header row: class name + row_anchor = f"{qualified_class_name}" + rows.append( + HTML.tr( + HTML.th( + HTML.h2( + HTML.a( + qualified_class_name, + href=f"#{row_anchor}", + id=row_anchor, + ), + ), + docstring(getattr(module, class_name)), + colspan=str(len(jobs) + 1), + ) + ) + ) + + # Header row: one column for each implementation + rows.append(job_row) + + # One row for each test: + results_by_test = group_by(class_results, key=lambda r: r.test_name) + for test_name, test_results in sorted(results_by_test.items()): + row_anchor = f"{qualified_class_name}.{test_name}" + if len(row_anchor) >= 50: + # Too long; give up on generating readable URL + # TODO: only hash test parameter + row_anchor = md5sum(row_anchor) + + row = HTML.tr( + HTML.th(HTML.a(test_name, href=f"#{row_anchor}"), class_="test-name"), + id=row_anchor, + ) + rows.append(row) + + results_by_job = group_by(test_results, key=lambda r: r.job) + for job_name in jobs: + try: + (result,) = results_by_job[job_name] + except KeyError: + row.append(HTML.td("d", class_="deselected")) + continue + + text: Union[str, None, ET.Element] + attrib = {} + + if result.skipped: + attrib["class"] = "skipped" + if result.type == "pytest.skip": + text = "s" + elif result.type == "pytest.xfail": + text = "X" + attrib["class"] = "expected-failure" + else: + text = result.type + elif result.success: + attrib["class"] = "success" + if result.type: + # dead code? + text = result.type + else: + text = "." + else: + attrib["class"] = "failure" + if result.type: + # dead code? + text = result.type + else: + text = "f" + + if result.system_out: + # There is a log file; link to it. + text = HTML.a(text or "?", href=f"./{result.output_filename()}") + else: + text = text or "?" + if result.message: + attrib["title"] = result.message + + row.append(HTML.td(text, attrib)) + + return HTML.table(*rows, class_=class_) + + +def write_html_pages( + output_dir: Path, results: List[CaseResult] +) -> List[Tuple[str, str, str]]: + """Returns the list of (module_name, file_name).""" + output_dir.mkdir(parents=True, exist_ok=True) + results_by_module = group_by(results, lambda r: r.module_name) + + # used as columns + jobs = list(sorted({r.job for r in results})) + + job_categories = {} + for job in jobs: + is_client = any( + "client_tests" in result.module_name and result.job == job + for result in results + ) + is_server = any( + "server_tests" in result.module_name and result.job == job + for result in results + ) + assert is_client != is_server, (job, is_client, is_server) + if job.endswith(("-atheme", "-anope", "-dlk")): + assert is_server + job_categories[job] = "server-with-services" + elif is_server: + job_categories[job] = "server" # with or without services + else: + assert is_client + job_categories[job] = "client" + + pages = [] + + for module_name, module_results in sorted(results_by_module.items()): + # Filter out client jobs if this is a server test module, and vice versa + module_categories = { + job_categories[result.job] + for result in results + if result.module_name == module_name and not result.skipped + } + + module_jobs = [job for job in jobs if job_categories[job] in module_categories] + + root = build_module_html(module_jobs, module_results, module_name) + file_name = f"{module_name}.xhtml" + write_xml_file(output_dir / file_name, root) + pages.append(("module", module_name, file_name)) + + for category in ("server", "client"): + for job in [job for job in job_categories if job_categories[job] == category]: + job_results = [ + result + for result in results + if result.job == job or result.job.startswith(job + "-") + ] + root = build_job_html(job, job_results) + file_name = f"{job}.xhtml" + write_xml_file(output_dir / file_name, root) + pages.append(("job", job, file_name)) + + return pages + + +def write_test_outputs(output_dir: Path, results: List[CaseResult]) -> None: + """Writes stdout files of each test.""" + for result in results: + if result.system_out is None: + continue + output_file = output_dir / result.output_filename() + with output_file.open("wt") as fd: + fd.write(result.system_out) + + +def write_html_index(output_dir: Path, pages: List[Tuple[str, str, str]]) -> None: + module_pages = [] + job_pages = [] + for page_type, title, file_name in sorted(pages): + if page_type == "module": + module_pages.append((title, file_name)) + elif page_type == "job": + job_pages.append((title, file_name)) + else: + assert False, page_type + + page = HTML.html( + HTML.head( + HTML.title("irctest dashboard"), + HTML.link(rel="stylesheet", type="text/css", href="./style.css"), + ), + HTML.body( + HTML.h1("irctest dashboard"), + HTML.h2("Tests by command/specification"), + HTML.dl( + [ + ( + HTML.dt(HTML.a(module_name, href=f"./{file_name}")), + HTML.dd(docstring(importlib.import_module(module_name))), + ) + for module_name, file_name in sorted(module_pages) + ], + class_="module-index", + ), + HTML.h2("Tests by implementation"), + HTML.ul( + [ + HTML.li(HTML.a(job, href=f"./{file_name}")) + for job, file_name in sorted(job_pages) + ], + class_="job-index", + ), + ), + ) + + write_xml_file(output_dir / "index.xhtml", page) + + +def write_assets(output_dir: Path) -> None: + css_path = output_dir / "style.css" + source_css_path = Path(__file__).parent / "style.css" + with css_path.open("wt") as fd: + with source_css_path.open() as source_fd: + fd.write(source_fd.read()) + + +def write_xml_file(filename: Path, root: ET.Element) -> None: + # Serialize + if sys.version_info >= (3, 8): + s = ET.tostring(root, default_namespace=HTML.uri) + else: + # default_namespace not supported + s = ET.tostring(root) + + with filename.open("wb") as fd: + fd.write(s) + + +def parse_xml_file(filename: Path) -> ET.ElementTree: + fd: IO + if filename.suffix == ".gz": + with gzip.open(filename, "rb") as fd: # type: ignore + return parse_xml(fd) # type: ignore + else: + with open(filename) as fd: + return parse_xml(fd) # type: ignore + + +def main(output_path: Path, filenames: List[Path]) -> int: + results = [ + result + for filename in filenames + for result in iter_job_results(filename, parse_xml_file(filename)) + ] + + pages = write_html_pages(output_path, results) + + write_html_index(output_path, pages) + write_test_outputs(output_path, results) + write_assets(output_path) + + return 0 + + +if __name__ == "__main__": + (_, output_path, *filenames) = sys.argv + exit(main(Path(output_path), list(map(Path, filenames)))) diff --git a/irctest/dashboard/github_download.py b/irctest/dashboard/github_download.py new file mode 100644 index 0000000..65620f2 --- /dev/null +++ b/irctest/dashboard/github_download.py @@ -0,0 +1,87 @@ +import dataclasses +import gzip +import io +import json +from pathlib import Path +import sys +from typing import Iterator +import urllib.parse +import urllib.request +import zipfile + + +@dataclasses.dataclass +class Artifact: + repo: str + run_id: int + name: str + download_url: str + + @property + def public_download_url(self) -> str: + # GitHub API is not available publicly for artifacts, we need to use + # a third-party proxy to access it... + name = urllib.parse.quote(self.name) + return f"https://nightly.link/{repo}/actions/runs/{self.run_id}/{name}.zip" + + +def iter_run_artifacts(repo: str, run_id: int) -> Iterator[Artifact]: + request = urllib.request.Request( + f"https://api.github.com/repos/{repo}/actions/runs/{run_id}/artifacts" + "?per_page=100", + headers={"Accept": "application/vnd.github.v3+json"}, + ) + + response = urllib.request.urlopen(request) + + for artifact in json.load(response)["artifacts"]: + if not artifact["name"].startswith(("pytest-results_", "pytest results ")): + continue + if artifact["expired"]: + continue + yield Artifact( + repo=repo, + run_id=run_id, + name=artifact["name"], + download_url=artifact["archive_download_url"], + ) + + +def download_artifact(output_name: Path, url: str) -> None: + if output_name.exists(): + return + response = urllib.request.urlopen(url) + archive_bytes = response.read() # Can't stream it, it's a ZIP + with zipfile.ZipFile(io.BytesIO(archive_bytes)) as archive: + with archive.open("pytest.xml") as input_fd: + pytest_xml = input_fd.read() + + tmp_output_path = output_name.with_suffix(".tmp") + with gzip.open(tmp_output_path, "wb") as output_fd: + output_fd.write(pytest_xml) + + # Atomically write to the output path, so that we don't write partial files in case + # the download process is interrupted + tmp_output_path.rename(output_name) + + +def main(output_dir: Path, repo: str, run_id: int) -> int: + output_dir.mkdir(parents=True, exist_ok=True) + run_path = output_dir / str(run_id) + run_path.mkdir(exist_ok=True) + + for artifact in iter_run_artifacts(repo, run_id): + artifact_path = run_path / artifact.name / "pytest.xml.gz" + artifact_path.parent.mkdir(exist_ok=True) + try: + download_artifact(artifact_path, artifact.download_url) + except Exception: + download_artifact(artifact_path, artifact.public_download_url) + print("downloaded", artifact.name) + + return 0 + + +if __name__ == "__main__": + (_, output_path, repo, run_id) = sys.argv + exit(main(Path(output_path), repo, int(run_id))) diff --git a/irctest/dashboard/shortxml.py b/irctest/dashboard/shortxml.py new file mode 100644 index 0000000..ee9856d --- /dev/null +++ b/irctest/dashboard/shortxml.py @@ -0,0 +1,126 @@ +# Copyright (c) 2023 Valentin Lorentz +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +"""This module allows writing XML ASTs in a way that is more concise than the default +:mod:`xml.etree.ElementTree` interface. + +For example: + +.. code-block:: python + + from .shortxml import Namespace + + HTML = Namespace("http://www.w3.org/1999/xhtml") + + page = HTML.html( + HTML.head( + HTML.title("irctest dashboard"), + HTML.link(rel="stylesheet", type="text/css", href="./style.css"), + ), + HTML.body( + HTML.h1("irctest dashboard"), + HTML.h2("Tests by command/specification"), + HTML.dl( + [ + ( # elements can be arbitrarily nested in lists + HTML.dt(HTML.a(title, href=f"./{title}.xhtml")), + HTML.dd(defintion), + ) + for title, definition in sorted(definitions) + ], + class_="module-index", + ), + HTML.h2("Tests by implementation"), + HTML.ul( + [ + HTML.li(HTML.a(job, href=f"./{file_name}")) + for job, file_name in sorted(job_pages) + ], + class_="job-index", + ), + ), + ) + + print(ET.tostring(page, default_namespace=HTML.uri)) + + +Attributes can be passed either as dictionaries or as kwargs, and can be mixed +with child elements. +Trailing underscores are stripped from attributes, which allows passing reserved +Python keywords (eg. ``class_`` instead of ``class``) + +Attributes are always qualified, and share the namespace of the element they are +attached to. + +Mixed content (elements containing both text and child elements) is not supported. +""" + +from typing import Dict, Sequence, Union +import xml.etree.ElementTree as ET + + +def _namespacify(ns: str, s: str) -> str: + return "{%s}%s" % (ns, s) + + +_Children = Union[None, Dict[str, str], ET.Element, Sequence["_Children"]] + + +class ElementFactory: + def __init__(self, namespace: str, tag: str): + self._tag = _namespacify(namespace, tag) + self._namespace = namespace + + def __call__(self, *args: Union[str, _Children], **kwargs: str) -> ET.Element: + e = ET.Element(self._tag) + + attributes = {k.rstrip("_"): v for (k, v) in kwargs.items()} + children = [*args, attributes] + + if args and isinstance(children[0], str): + e.text = children[0] + children.pop(0) + + for child in children: + self._append_child(e, child) + + return e + + def _append_child(self, e: ET.Element, child: _Children) -> None: + if isinstance(child, ET.Element): + e.append(child) + elif child is None: + pass + elif isinstance(child, dict): + for k, v in child.items(): + e.set(_namespacify(self._namespace, k), str(v)) + elif isinstance(child, str): + raise ValueError("Mixed content is not supported") + else: + for grandchild in child: + self._append_child(e, grandchild) + + +class Namespace: + def __init__(self, uri: str): + self.uri = uri + + def __getattr__(self, tag: str) -> ElementFactory: + return ElementFactory(self.uri, tag) diff --git a/irctest/dashboard/style.css b/irctest/dashboard/style.css new file mode 100644 index 0000000..9a9c982 --- /dev/null +++ b/irctest/dashboard/style.css @@ -0,0 +1,67 @@ +@media (prefers-color-scheme: dark) { + body { + background-color: #121212; + color: rgba(255, 255, 255, 0.87); + } + a { + filter: invert(0.85) hue-rotate(180deg); + } +} + +dl.module-index { + column-width: 40em; /* Magic constant for 2 columns on average laptop/desktop */ +} + +/* Only 1px solid border between cells */ +table.test-matrix { + border-spacing: 0; + border-collapse: collapse; +} +table.test-matrix td { + text-align: center; + border: 1px solid grey; +} + +/* Make link take the whole cell */ +table.test-matrix td a { + display: block; + margin: 0; + padding: 0; + width: 100%; + height: 100%; + color: black; + text-decoration: none; +} + +/* Test matrix colors */ +table.test-matrix .deselected { + background-color: grey; +} +table.test-matrix .success { + background-color: green; +} +table.test-matrix .skipped { + background-color: yellow; +} +table.test-matrix .failure { + background-color: red; +} +table.test-matrix .expected-failure { + background-color: orange; +} + +/* Rotate headers, thanks to https://css-tricks.com/rotated-table-column-headers/ */ +table.module-results th.job-name { + height: 140px; + white-space: nowrap; +} +table.module-results th.job-name > div { + transform: + translate(28px, 50px) + rotate(315deg); + width: 40px; +} +table.module-results th.job-name > div > span { + border-bottom: 1px solid grey; + padding-left: 0px; +} diff --git a/irctest/irc_utils/filelock.py b/irctest/irc_utils/filelock.py new file mode 100644 index 0000000..247dac9 --- /dev/null +++ b/irctest/irc_utils/filelock.py @@ -0,0 +1,19 @@ +""" +Compatibility layer for filelock ( https://pypi.org/project/filelock/ ); +commonly packaged by Linux distributions but might not be available +in some environments. +""" + +import os +from typing import ContextManager + +if os.getenv("PYTEST_XDIST_WORKER"): + # running under pytest-xdist; filelock is required for reliability + from filelock import FileLock +else: + # normal test execution, no port races + import contextlib + from typing import Any + + def FileLock(*args: Any, **kwargs: Any) -> ContextManager[None]: + return contextlib.nullcontext() diff --git a/irctest/irc_utils/junkdrawer.py b/irctest/irc_utils/junkdrawer.py index ecbf83f..b34de13 100644 --- a/irctest/irc_utils/junkdrawer.py +++ b/irctest/irc_utils/junkdrawer.py @@ -13,7 +13,7 @@ def ircv3_timestamp_to_unixtime(timestamp: str) -> float: def random_name(base: str) -> str: - return base + "-" + secrets.token_hex(8) + return base + "-" + secrets.token_hex(5) def find_hostname_and_port() -> Tuple[str, int]: diff --git a/irctest/numerics.py b/irctest/numerics.py index 0424891..77f3206 100644 --- a/irctest/numerics.py +++ b/irctest/numerics.py @@ -66,6 +66,7 @@ RPL_WHOISIDLE = "317" RPL_ENDOFWHOIS = "318" RPL_WHOISCHANNELS = "319" RPL_WHOISSPECIAL = "320" +RPL_LISTSTART = "321" RPL_LIST = "322" RPL_LISTEND = "323" RPL_CHANNELMODEIS = "324" @@ -86,6 +87,7 @@ RPL_ENDOFEXCEPTLIST = "349" RPL_VERSION = "351" RPL_WHOREPLY = "352" RPL_NAMREPLY = "353" +RPL_WHOSPCRPL = "354" RPL_LINKS = "364" RPL_ENDOFLINKS = "365" RPL_ENDOFNAMES = "366" @@ -140,6 +142,7 @@ ERR_USERONCHANNEL = "443" ERR_NOLOGIN = "444" ERR_SUMMONDISABLED = "445" ERR_USERSDISABLED = "446" +ERR_FORBIDDENCHANNEL = "448" ERR_NOTREGISTERED = "451" ERR_NEEDMOREPARAMS = "461" ERR_ALREADYREGISTRED = "462" diff --git a/irctest/patma.py b/irctest/patma.py index 5fc99df..42fa4ae 100644 --- a/irctest/patma.py +++ b/irctest/patma.py @@ -13,18 +13,18 @@ class Operator: pass -class AnyStr(Operator): +class _AnyStr(Operator): """Wildcard matching any string""" def __repr__(self) -> str: - return "AnyStr" + return "ANYSTR" -class AnyOptStr(Operator): +class _AnyOptStr(Operator): """Wildcard matching any string as well as None""" def __repr__(self) -> str: - return "AnyOptStr" + return "ANYOPTSTR" @dataclasses.dataclass(frozen=True) @@ -59,13 +59,16 @@ class RemainingKeys(Operator): key: Operator def __repr__(self) -> str: - return f"Keys({self.key!r})" + return f"RemainingKeys({self.key!r})" -ANYSTR = AnyStr() +ANYSTR = _AnyStr() """Singleton, spares two characters""" -ANYDICT = {RemainingKeys(ANYSTR): AnyOptStr()} +ANYOPTSTR = _AnyOptStr() +"""Singleton, spares two characters""" + +ANYDICT = {RemainingKeys(ANYSTR): ANYOPTSTR} """Matches any dictionary; useful to compare tags dict, eg. `match_dict(got_tags, {"label": "foo", **ANYDICT})`""" @@ -77,9 +80,11 @@ class ListRemainder: def __repr__(self) -> str: if self.min_length: - return f"*ListRemainder({self.item!r}, min_length={self.min_length})" + return f"ListRemainder({self.item!r}, min_length={self.min_length})" + elif self.item is ANYSTR: + return "*ANYLIST" else: - return f"*ListRemainder({self.item!r})" + return f"ListRemainder({self.item!r})" ANYLIST = [ListRemainder(ANYSTR)] @@ -87,9 +92,9 @@ ANYLIST = [ListRemainder(ANYSTR)] def match_string(got: Optional[str], expected: Union[str, Operator, None]) -> bool: - if isinstance(expected, AnyOptStr): + if isinstance(expected, _AnyOptStr): return True - elif isinstance(expected, AnyStr) and got is not None: + elif isinstance(expected, _AnyStr) and got is not None: return True elif isinstance(expected, StrRe): if got is None or not re.match(expected.regexp, got): @@ -147,21 +152,23 @@ def match_dict( # Set to not-None if we find a Keys() operator in the dict keys remaining_keys_wildcard = None - for (expected_key, expected_value) in expected.items(): + for expected_key, expected_value in expected.items(): if isinstance(expected_key, RemainingKeys): remaining_keys_wildcard = (expected_key.key, expected_value) - elif isinstance(expected_key, Operator): - raise NotImplementedError(f"Unsupported operator: {expected_key}") else: - if expected_key not in got: - return False - got_value = got.pop(expected_key) - if not match_string(got_value, expected_value): + for key in got: + if match_string(key, expected_key) and match_string( + got[key], expected_value + ): + got.pop(key) + break + else: + # Found no (key, value) pair matching the request return False if remaining_keys_wildcard: (expected_key, expected_value) = remaining_keys_wildcard - for (key, value) in got.items(): + for key, value in got.items(): if not match_string(key, expected_key): return False if not match_string(value, expected_value): diff --git a/irctest/self_tests/cases.py b/irctest/self_tests/cases.py index 9279bdb..6f8920b 100644 --- a/irctest/self_tests/cases.py +++ b/irctest/self_tests/cases.py @@ -1,3 +1,5 @@ +"""Internal checks of assertion implementations.""" + from typing import Dict, List, Tuple import pytest @@ -7,8 +9,8 @@ from irctest.irc_utils.message_parser import parse_message from irctest.patma import ( ANYDICT, ANYLIST, + ANYOPTSTR, ANYSTR, - AnyOptStr, ListRemainder, NotStrRe, RemainingKeys, @@ -16,7 +18,7 @@ from irctest.patma import ( ) # fmt: off -MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ +MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str], List[str]]] = [ ( # the specification: dict( @@ -36,6 +38,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ [ "PRIVMSG #chan hello2", "PRIVMSG #chan2 hello", + ], + # and they each error with: + [ + "expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']", + "expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']", ] ), ( @@ -58,6 +65,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ [ "PRIVMSG #chan :hi", "PRIVMSG #chan2 hello", + ], + # and they each error with: + [ + "expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan', 'hi']", + "expected params to match ['#chan', StrRe(r'hello.*')], got ['#chan2', 'hello']", ] ), ( @@ -76,6 +88,12 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ "PRIVMSG #chan :hi", ":foo2!baz@qux PRIVMSG #chan hello", "@tag1=bar :foo2!baz@qux PRIVMSG #chan :hello", + ], + # and they each error with: + [ + "expected nick to be foo, got None instead", + "expected nick to be foo, got foo2 instead", + "expected nick to be foo, got foo2 instead", ] ), ( @@ -96,6 +114,13 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ "@tag1=value1 PRIVMSG #chan :hello", "PRIVMSG #chan hello", ":foo!baz@qux PRIVMSG #chan hello", + ], + # and they each error with: + [ + "expected tags to match {'tag1': 'bar'}, got {'tag1': 'bar', 'tag2': ''}", + "expected tags to match {'tag1': 'bar'}, got {'tag1': 'value1'}", + "expected tags to match {'tag1': 'bar'}, got {}", + "expected tags to match {'tag1': 'bar'}, got {}", ] ), ( @@ -116,6 +141,12 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ "@tag1=bar;tag2= PRIVMSG #chan :hello", "PRIVMSG #chan hello", ":foo!baz@qux PRIVMSG #chan hello", + ], + # and they each error with: + [ + "expected tags to match {'tag1': ANYSTR}, got {'tag1': 'bar', 'tag2': ''}", + "expected tags to match {'tag1': ANYSTR}, got {}", + "expected tags to match {'tag1': ANYSTR}, got {}", ] ), ( @@ -138,12 +169,53 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ "PRIVMSG #chan hello2", "PRIVMSG #chan2 hello", ":foo!baz@qux PRIVMSG #chan hello", + ], + # and they each error with: + [ + "expected command to be PRIVMSG, got PRIVMG", + "expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}", + "expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']", + "expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']", + "expected tags to match {'tag1': 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}", ] ), ( # the specification: dict( - tags={"tag1": "bar", RemainingKeys(NotStrRe("tag2")): AnyOptStr()}, + tags={StrRe("tag[12]"): "bar", **ANYDICT}, + command="PRIVMSG", + params=["#chan", "hello"], + ), + # matches: + [ + "@tag1=bar PRIVMSG #chan :hello", + "@tag1=bar;tag2= PRIVMSG #chan :hello", + "@tag1=bar :foo!baz@qux PRIVMSG #chan :hello", + "@tag2=bar PRIVMSG #chan :hello", + "@tag1=bar;tag2= PRIVMSG #chan :hello", + "@tag1=;tag2=bar PRIVMSG #chan :hello", + ], + # and does not match: + [ + "PRIVMG #chan :hello", + "@tag1=value1 PRIVMSG #chan :hello", + "PRIVMSG #chan hello2", + "PRIVMSG #chan2 hello", + ":foo!baz@qux PRIVMSG #chan hello", + ], + # and they each error with: + [ + "expected command to be PRIVMSG, got PRIVMG", + "expected tags to match {StrRe(r'tag[12]'): 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {'tag1': 'value1'}", + "expected params to match ['#chan', 'hello'], got ['#chan', 'hello2']", + "expected params to match ['#chan', 'hello'], got ['#chan2', 'hello']", + "expected tags to match {StrRe(r'tag[12]'): 'bar', RemainingKeys(ANYSTR): ANYOPTSTR}, got {}", + ] + ), + ( + # the specification: + dict( + tags={"tag1": "bar", RemainingKeys(NotStrRe("tag2")): ANYOPTSTR}, command="PRIVMSG", params=["#chan", "hello"], ), @@ -159,6 +231,13 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ "@tag1=value1 PRIVMSG #chan :hello", "@tag1=bar;tag2= PRIVMSG #chan :hello", "@tag1=bar;tag2=baz PRIVMSG #chan :hello", + ], + # and they each error with: + [ + "expected command to be PRIVMSG, got PRIVMG", + "expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'value1'}", + "expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': ''}", + "expected tags to match {'tag1': 'bar', RemainingKeys(NotStrRe(r'tag2')): ANYOPTSTR}, got {'tag1': 'bar', 'tag2': 'baz'}", ] ), ( @@ -176,6 +255,11 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ [ "005 nick", "005 nick BAR=2", + ], + # and they each error with: + [ + "expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick']", + "expected params to match ['nick', 'FOO=1', *ANYLIST], got ['nick', 'BAR=2']", ] ), ( @@ -193,6 +277,10 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ # and does not match: [ "005 nick", + ], + # and they each error with: + [ + "expected params to match ['nick', ListRemainder(ANYSTR, min_length=1)], got ['nick']", ] ), ( @@ -211,6 +299,30 @@ MESSAGE_SPECS: List[Tuple[Dict, List[str], List[str]]] = [ [ "005 nick", "005 nick foo=1", + ], + # and they each error with: + [ + "expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick']", + "expected params to match ['nick', ListRemainder(StrRe(r'[A-Z]+=.*'), min_length=1)], got ['nick', 'foo=1']", + ] + ), + ( + # the specification: + dict( + command="PING", + params=["abc"] + ), + # matches: + [ + "PING abc", + ], + # and does not match: + [ + "PONG def" + ], + # and they each error with: + [ + "expected command to be PING, got PONG" ] ), ] @@ -222,7 +334,7 @@ class IrcTestCaseTestCase(cases._IrcTestCase): "spec,msg", [ pytest.param(spec, msg, id=f"{spec}-{msg}") - for (spec, positive_matches, _) in MESSAGE_SPECS + for (spec, positive_matches, _, _) in MESSAGE_SPECS for msg in positive_matches ], ) @@ -235,7 +347,7 @@ class IrcTestCaseTestCase(cases._IrcTestCase): "spec,msg", [ pytest.param(spec, msg, id=f"{spec}-{msg}") - for (spec, _, negative_matches) in MESSAGE_SPECS + for (spec, _, negative_matches, _) in MESSAGE_SPECS for msg in negative_matches ], ) @@ -244,3 +356,14 @@ class IrcTestCaseTestCase(cases._IrcTestCase): assert not self.messageEqual(parse_message(msg), **spec), msg with pytest.raises(AssertionError): self.assertMessageMatch(parse_message(msg), **spec), msg + + @pytest.mark.parametrize( + "spec,msg,error_string", + [ + pytest.param(spec, msg, error_string, id=error_string) + for (spec, _, negative_matches, error_stringgexps) in MESSAGE_SPECS + for (msg, error_string) in zip(negative_matches, error_stringgexps) + ], + ) + def test_message_matching_negative_message(self, spec, msg, error_string): + self.assertIn(error_string, self.messageDiffers(parse_message(msg), **spec)) diff --git a/irctest/server_tests/register_verify.py b/irctest/server_tests/account_registration.py similarity index 96% rename from irctest/server_tests/register_verify.py rename to irctest/server_tests/account_registration.py index 805d373..6283e8c 100644 --- a/irctest/server_tests/register_verify.py +++ b/irctest/server_tests/account_registration.py @@ -1,9 +1,15 @@ +""" +`Draft IRCv3 account-registration +`_ +""" + from irctest import cases from irctest.patma import ANYSTR REGISTER_CAP_NAME = "draft/account-registration" +@cases.mark_services @cases.mark_specifications("IRCv3") class RegisterBeforeConnectTestCase(cases.BaseServerTestCase): @staticmethod @@ -28,6 +34,7 @@ class RegisterBeforeConnectTestCase(cases.BaseServerTestCase): self.assertMessageMatch(register_response, params=["SUCCESS", ANYSTR, ANYSTR]) +@cases.mark_services @cases.mark_specifications("IRCv3") class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase): @staticmethod @@ -55,6 +62,7 @@ class RegisterBeforeConnectDisallowedTestCase(cases.BaseServerTestCase): ) +@cases.mark_services @cases.mark_specifications("IRCv3") class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase): @staticmethod @@ -105,6 +113,7 @@ class RegisterEmailVerifiedTestCase(cases.BaseServerTestCase): ) +@cases.mark_services @cases.mark_specifications("IRCv3", "Ergo") class RegisterNoLandGrabsTestCase(cases.BaseServerTestCase): @staticmethod diff --git a/irctest/server_tests/account_tag.py b/irctest/server_tests/account_tag.py index ea909db..45cd942 100644 --- a/irctest/server_tests/account_tag.py +++ b/irctest/server_tests/account_tag.py @@ -1,12 +1,12 @@ """ - +`IRCv3 account-tag `_ """ from irctest import cases @cases.mark_services -class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class AccountTagTestCase(cases.BaseServerTestCase): def connectRegisteredClient(self, nick): self.addClient() self.sendLine(2, "CAP LS 302") @@ -40,7 +40,7 @@ class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.skipToWelcome(2) @cases.mark_capabilities("account-tag") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") def testPrivmsg(self): self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True) self.getMessages(1) @@ -54,7 +54,10 @@ class AccountTagTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ) @cases.mark_capabilities("account-tag") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") + @cases.xfailIfSoftware( + ["Charybdis"], "https://github.com/solanum-ircd/solanum/issues/166" + ) def testInvite(self): self.connectClient("foo", capabilities=["account-tag"], skip_if_cap_nak=True) self.getMessages(1) diff --git a/irctest/server_tests/away.py b/irctest/server_tests/away.py index 7b24a08..36841d6 100644 --- a/irctest/server_tests/away.py +++ b/irctest/server_tests/away.py @@ -1,12 +1,24 @@ +""" +AWAY command (`RFC 2812 `__, +`Modern `__) +""" + from irctest import cases -from irctest.numerics import RPL_AWAY, RPL_NOWAWAY, RPL_UNAWAY, RPL_USERHOST -from irctest.patma import StrRe +from irctest.numerics import ( + RPL_AWAY, + RPL_NOWAWAY, + RPL_UNAWAY, + RPL_USERHOST, + RPL_WHOISUSER, +) +from irctest.patma import ANYSTR, StrRe class AwayTestCase(cases.BaseServerTestCase): @cases.mark_specifications("RFC2812", "Modern") def testAway(self): self.connectClient("bar") + self.getMessages(1) self.sendLine(1, "AWAY :I'm not here right now") replies = self.getMessages(1) self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies]) @@ -18,6 +30,7 @@ class AwayTestCase(cases.BaseServerTestCase): command=RPL_AWAY, params=["qux", "bar", "I'm not here right now"], ) + self.getMessages(1) self.sendLine(1, "AWAY") replies = self.getMessages(1) @@ -32,23 +45,27 @@ class AwayTestCase(cases.BaseServerTestCase): """ "The server acknowledges the change in away status by returning the `RPL_NOWAWAY` and `RPL_UNAWAY` numerics." - -- https://github.com/ircdocs/modern-irc/pull/100 + -- https://modern.ircdocs.horse/#away-message """ self.connectClient("bar") self.sendLine(1, "AWAY :I'm not here right now") - replies = self.getMessages(1) - self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies]) + self.assertMessageMatch( + self.getMessage(1), command=RPL_NOWAWAY, params=["bar", ANYSTR] + ) + self.assertEqual(self.getMessages(1), []) self.sendLine(1, "AWAY") - replies = self.getMessages(1) - self.assertIn(RPL_UNAWAY, [msg.command for msg in replies]) + self.assertMessageMatch( + self.getMessage(1), command=RPL_UNAWAY, params=["bar", ANYSTR] + ) + self.assertEqual(self.getMessages(1), []) @cases.mark_specifications("Modern") def testAwayPrivmsg(self): """ "Servers SHOULD notify clients when a user they're interacting with is away when relevant" - -- https://github.com/ircdocs/modern-irc/pull/100 + -- https://modern.ircdocs.horse/#away-message " :" -- https://modern.ircdocs.horse/#rplaway-301 @@ -75,7 +92,7 @@ class AwayTestCase(cases.BaseServerTestCase): """ "Servers SHOULD notify clients when a user they're interacting with is away when relevant" - -- https://github.com/ircdocs/modern-irc/pull/100 + -- https://modern.ircdocs.horse/#away-message " :" -- https://modern.ircdocs.horse/#rplaway-301 @@ -113,7 +130,7 @@ class AwayTestCase(cases.BaseServerTestCase): """ "Servers SHOULD notify clients when a user they're interacting with is away when relevant" - -- https://github.com/ircdocs/modern-irc/pull/100 + -- https://modern.ircdocs.horse/#away-message " :" -- https://modern.ircdocs.horse/#rplaway-301 @@ -134,3 +151,33 @@ class AwayTestCase(cases.BaseServerTestCase): self.assertMessageMatch( self.getMessage(2), command=RPL_USERHOST, params=["qux", StrRe(r"bar=-.*")] ) + + @cases.mark_specifications("Modern") + def testAwayEmptyMessage(self): + """ + "If [AWAY] is sent with a nonempty parameter (the 'away message') + then the user is set to be away. If this command is sent with no + parameters, or with the empty string as the parameter, the user is no + longer away." + -- https://modern.ircdocs.horse/#away-message + """ + self.connectClient("bar", name="bar") + self.connectClient("qux", name="qux") + + self.sendLine("bar", "AWAY :I'm not here right now") + replies = self.getMessages("bar") + self.assertIn(RPL_NOWAWAY, [msg.command for msg in replies]) + self.sendLine("qux", "WHOIS bar") + replies = self.getMessages("qux") + self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies]) + self.assertIn(RPL_AWAY, [msg.command for msg in replies]) + + # empty final parameter to AWAY is treated the same as no parameter, + # i.e., the client is considered to be no longer away + self.sendLine("bar", "AWAY :") + replies = self.getMessages("bar") + self.assertIn(RPL_UNAWAY, [msg.command for msg in replies]) + self.sendLine("qux", "WHOIS bar") + replies = self.getMessages("qux") + self.assertIn(RPL_WHOISUSER, [msg.command for msg in replies]) + self.assertNotIn(RPL_AWAY, [msg.command for msg in replies]) diff --git a/irctest/server_tests/away_notify.py b/irctest/server_tests/away_notify.py index 6d3b8d6..0efd021 100644 --- a/irctest/server_tests/away_notify.py +++ b/irctest/server_tests/away_notify.py @@ -1,11 +1,13 @@ """ - +`IRCv3 away-notify `_ """ from irctest import cases +from irctest.numerics import RPL_NOWAWAY, RPL_UNAWAY +from irctest.patma import ANYSTR, StrRe -class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class AwayNotifyTestCase(cases.BaseServerTestCase): @cases.mark_capabilities("away-notify") def testAwayNotify(self): """Basic away-notify test.""" @@ -20,13 +22,28 @@ class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.getMessages(1) self.sendLine(2, "AWAY :i'm going away") - self.getMessages(2) + self.assertMessageMatch( + self.getMessage(2), command=RPL_NOWAWAY, params=["bar", ANYSTR] + ) + self.assertEqual(self.getMessages(2), []) awayNotify = self.getMessage(1) - self.assertMessageMatch(awayNotify, command="AWAY", params=["i'm going away"]) - self.assertTrue( - awayNotify.prefix.startswith("bar!"), - "Unexpected away-notify source: %s" % (awayNotify.prefix,), + self.assertMessageMatch( + awayNotify, + prefix=StrRe("bar!.*"), + command="AWAY", + params=["i'm going away"], + ) + + self.sendLine(2, "AWAY") + self.assertMessageMatch( + self.getMessage(2), command=RPL_UNAWAY, params=["bar", ANYSTR] + ) + self.assertEqual(self.getMessages(2), []) + + awayNotify = self.getMessage(1) + self.assertMessageMatch( + awayNotify, prefix=StrRe("bar!.*"), command="AWAY", params=[] ) @cases.mark_capabilities("away-notify") @@ -45,7 +62,11 @@ class AwayNotifyTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.getMessages(2) self.joinChannel(2, "#chan") - self.getMessages(2) + self.assertNotIn( + "AWAY", + [m.command for m in self.getMessages(2)], + "joining user got their own away status when they joined", + ) messages = [msg for msg in self.getMessages(1) if msg.command == "AWAY"] self.assertEqual( diff --git a/irctest/server_tests/bot_mode.py b/irctest/server_tests/bot_mode.py index 161aaf4..0c550ba 100644 --- a/irctest/server_tests/bot_mode.py +++ b/irctest/server_tests/bot_mode.py @@ -1,6 +1,5 @@ """ -Draft bot mode specification, as defined in - +`IRCv3 bot mode `_ """ from irctest import cases, runner @@ -68,6 +67,10 @@ class BotModeTestCase(cases.BaseServerTestCase): message, command=RPL_WHOISBOT, params=["usernick", "botnick", ANYSTR] ) + @cases.xfailIfSoftware( + ["InspIRCd"], + "Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910", + ) def testBotPrivateMessage(self): self._initBot() @@ -82,9 +85,13 @@ class BotModeTestCase(cases.BaseServerTestCase): self.getMessage("user"), command="PRIVMSG", params=["usernick", "beep boop"], - tags={"draft/bot": None, **ANYDICT}, + tags={StrRe("(draft/)?bot"): None, **ANYDICT}, ) + @cases.xfailIfSoftware( + ["InspIRCd"], + "Uses only vendor tags for now: https://github.com/inspircd/inspircd/pull/1910", + ) def testBotChannelMessage(self): self._initBot() @@ -104,7 +111,7 @@ class BotModeTestCase(cases.BaseServerTestCase): self.getMessage("user"), command="PRIVMSG", params=["#chan", "beep boop"], - tags={"draft/bot": None, **ANYDICT}, + tags={StrRe("(draft/)?bot"): None, **ANYDICT}, ) def testBotWhox(self): diff --git a/irctest/server_tests/bouncer.py b/irctest/server_tests/bouncer.py index 4daed8d..c1cdc9f 100644 --- a/irctest/server_tests/bouncer.py +++ b/irctest/server_tests/bouncer.py @@ -1,3 +1,9 @@ +""" +`Ergo `_-specific tests of +`multiclient features +`_ +""" + from irctest import cases from irctest.irc_utils.sasl import sasl_plain_blob from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME diff --git a/irctest/server_tests/buffering.py b/irctest/server_tests/buffering.py index 321e04b..53e9f9a 100644 --- a/irctest/server_tests/buffering.py +++ b/irctest/server_tests/buffering.py @@ -1,5 +1,7 @@ -"""Sends packets with various length to check the server reassembles them -correctly. Also checks truncation""" +""" +Sends packets with various length to check the server reassembles them +correctly. Also checks truncation. +""" import socket import time @@ -30,6 +32,16 @@ def _sendBytePerByte(self, line): class BufferingTestCase(cases.BaseServerTestCase): + @cases.xfailIfSoftware( + ["Bahamut"], + "cannot pass because of issues with UTF-8 handling: " + "https://github.com/DALnet/bahamut/issues/196", + ) + @cases.xfailIfSoftware( + ["ircu2", "Nefarious", "snircd"], + "ircu2 discards the whole buffer on long lines " + "(TODO: refine how we exclude these tests)", + ) @pytest.mark.parametrize( "sender_function,colon", [ diff --git a/irctest/server_tests/cap.py b/irctest/server_tests/cap.py index b4fd31d..a1d3dff 100644 --- a/irctest/server_tests/cap.py +++ b/irctest/server_tests/cap.py @@ -1,9 +1,35 @@ +""" +`IRCv3 Capability negotiation +`_ +""" + from irctest import cases -from irctest.patma import ANYSTR +from irctest.patma import ANYSTR, StrRe from irctest.runner import CapabilityNotSupported, ImplementationChoice -class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class CapTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("IRCv3") + def testInvalidCapSubcommand(self): + """“If no capabilities are active, an empty parameter must be sent.” + -- + """ # noqa + self.addClient() + self.sendLine(1, "CAP NOTACOMMAND") + self.sendLine(1, "PING test123") + m = self.getRegistrationMessage(1) + self.assertTrue( + self.messageDiffers(m, command="PONG", params=[ANYSTR, "test123"]), + "Sending “CAP NOTACOMMAND” as first message got no reply", + ) + self.assertMessageMatch( + m, + command="410", + params=["*", "NOTACOMMAND", ANYSTR], + fail_msg="Sending “CAP NOTACOMMAND” as first message got a reply " + "that is not ERR_INVALIDCAPCMD: {msg}", + ) + @cases.mark_specifications("IRCv3") def testNoReq(self): """Test the server handles gracefully clients which do not send @@ -18,12 +44,210 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.getCapLs(1) self.sendLine(1, "USER foo foo foo :foo") self.sendLine(1, "NICK foo") + + # Make sure the server didn't send anything yet + self.sendLine(1, "CAP LS 302") + self.getCapLs(1) + self.sendLine(1, "CAP END") m = self.getRegistrationMessage(1) self.assertMessageMatch( m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}." ) + @cases.mark_specifications("IRCv3") + @cases.xfailIfSoftware( + ["Sable"], + "does not support multi-prefix", + ) + def testReqOne(self): + """Tests requesting a single capability""" + self.addClient(1) + self.sendLine(1, "CAP LS") + self.getCapLs(1) + self.sendLine(1, "USER foo foo foo :foo") + self.sendLine(1, "NICK foo") + self.sendLine(1, "CAP REQ :multi-prefix") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "ACK", StrRe("multi-prefix ?")], + fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.", + ) + + self.sendLine(1, "CAP LIST") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "LIST", StrRe("multi-prefix ?")], + fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.", + ) + + self.sendLine(1, "CAP END") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}." + ) + + @cases.mark_specifications("IRCv3") + @cases.xfailIfSoftware( + ["ngIRCd", "Sable"], + "does not support userhost-in-names", + ) + def testReqTwo(self): + """Tests requesting two capabilities at once""" + self.addClient(1) + self.sendLine(1, "CAP LS") + self.getCapLs(1) + self.sendLine(1, "USER foo foo foo :foo") + self.sendLine(1, "NICK foo") + self.sendLine(1, "CAP REQ :multi-prefix userhost-in-names") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "ACK", StrRe("multi-prefix userhost-in-names ?")], + fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.", + ) + + self.sendLine(1, "CAP LIST") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ + ANYSTR, + "LIST", + StrRe( + "(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?" + ), + ], + fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.", + ) + + self.sendLine(1, "CAP END") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}." + ) + + @cases.mark_specifications("IRCv3") + @cases.xfailIfSoftware( + ["ngIRCd", "Sable"], + "does not support userhost-in-names", + ) + def testReqOneThenOne(self): + """Tests requesting two capabilities in different messages""" + self.addClient(1) + self.sendLine(1, "CAP LS") + self.getCapLs(1) + self.sendLine(1, "USER foo foo foo :foo") + self.sendLine(1, "NICK foo") + + self.sendLine(1, "CAP REQ :multi-prefix") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "ACK", StrRe("multi-prefix ?")], + fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.", + ) + + self.sendLine(1, "CAP REQ :userhost-in-names") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")], + fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.", + ) + + self.sendLine(1, "CAP LIST") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ + ANYSTR, + "LIST", + StrRe( + "(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?" + ), + ], + fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.", + ) + + self.sendLine(1, "CAP END") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}." + ) + + @cases.mark_specifications("IRCv3") + @cases.xfailIfSoftware( + ["ngIRCd", "Sable"], + "does not support userhost-in-names", + ) + def testReqPostRegistration(self): + """Tests requesting more capabilities after CAP END""" + self.addClient(1) + self.sendLine(1, "CAP LS") + self.getCapLs(1) + self.sendLine(1, "USER foo foo foo :foo") + self.sendLine(1, "NICK foo") + + self.sendLine(1, "CAP REQ :multi-prefix") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "ACK", StrRe("multi-prefix ?")], + fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.", + ) + + self.sendLine(1, "CAP LIST") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "LIST", StrRe("multi-prefix ?")], + fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.", + ) + + self.sendLine(1, "CAP END") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, command="001", fail_msg="Expected 001 after sending CAP END, got {msg}." + ) + + self.getMessages(1) + + self.sendLine(1, "CAP REQ :userhost-in-names") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ANYSTR, "ACK", StrRe("userhost-in-names ?")], + fail_msg="Expected CAP ACK after sending CAP REQ, got {msg}.", + ) + + self.sendLine(1, "CAP LIST") + m = self.getMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=[ + ANYSTR, + "LIST", + StrRe( + "(multi-prefix userhost-in-names|userhost-in-names multi-prefix) ?" + ), + ], + fail_msg="Expected CAP LIST after sending CAP LIST, got {msg}.", + ) + @cases.mark_specifications("IRCv3") def testReqUnavailable(self): """Test the server handles gracefully clients which request @@ -40,7 +264,7 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.assertMessageMatch( m, command="CAP", - params=[ANYSTR, "NAK", "foo"], + params=[ANYSTR, "NAK", StrRe("foo ?")], fail_msg="Expected CAP NAK after requesting non-existing " "capability, got {msg}.", ) @@ -80,7 +304,8 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): """ # noqa self.addClient(1) self.sendLine(1, "CAP LS 302") - self.assertIn("multi-prefix", self.getCapLs(1)) + if "multi-prefix" not in self.getCapLs(1): + raise CapabilityNotSupported("multi-prefix") self.sendLine(1, "CAP REQ :foo multi-prefix bar") m = self.getRegistrationMessage(1) self.assertMessageMatch( @@ -114,7 +339,7 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.assertMessageMatch( m, command="CAP", - params=[ANYSTR, "ACK", "multi-prefix"], + params=[ANYSTR, "ACK", StrRe("multi-prefix ?")], fail_msg="Expected “CAP ACK :multi-prefix” after " "sending “CAP REQ :multi-prefix”, but got {msg}.", ) @@ -127,8 +352,13 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.addClient(1) self.connectClient("sender") self.sendLine(1, "CAP LS 302") - m = self.getRegistrationMessage(1) - if not ({cap1, cap2} <= set(m.params[2].split())): + caps = set() + while True: + m = self.getRegistrationMessage(1) + caps.update(m.params[-1].split()) + if m.params[2] != "*": + break + if not ({cap1, cap2} <= caps): raise CapabilityNotSupported(f"{cap1} or {cap2}") self.sendLine(1, f"CAP REQ :{cap1} {cap2}") self.sendLine(1, "nick bar") @@ -154,17 +384,19 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): m = self.getMessage(1) self.assertIn("time", m.tags, m) - # remove the server-time cap + # remove the multi-prefix cap self.sendLine(1, f"CAP REQ :-{cap2}") m = self.getMessage(1) # Must be either ACK or NAK - if self.messageDiffers(m, command="CAP", params=[ANYSTR, "ACK", f"-{cap2}"]): + if self.messageDiffers( + m, command="CAP", params=[ANYSTR, "ACK", StrRe(f"-{cap2} ?")] + ): self.assertMessageMatch( - m, command="CAP", params=[ANYSTR, "NAK", f"-{cap2}"] + m, command="CAP", params=[ANYSTR, "NAK", StrRe(f"-{cap2} ?")] ) raise ImplementationChoice(f"Does not support CAP REQ -{cap2}") - # server-time should be disabled + # multi-prefix should be disabled self.sendLine(1, "CAP LIST") messages = self.getMessages(1) cap_list = [m for m in messages if m.command == "CAP"][0] @@ -172,3 +404,88 @@ class CapTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): enabled_caps.discard("cap-notify") # implicitly added by some impls self.assertEqual(enabled_caps, {cap1}) self.assertNotIn("time", cap_list.tags) + + @cases.mark_specifications("IRCv3") + def testIrc301CapLs(self): + """ + Current version: + + "The LS subcommand is used to list the capabilities supported by the server. + The client should send an LS subcommand with no other arguments to solicit + a list of all capabilities." + + "If a client has not indicated support for CAP LS 302 features, + the server MUST NOT send these new features to the client." + -- + + Before the v3.1 / v3.2 merge: + + IRCv3.1: “The LS subcommand is used to list the capabilities + supported by the server. The client should send an LS subcommand with + no other arguments to solicit a list of all capabilities.” + -- + + IRCv3.2: “Servers MUST NOT send messages described by this document if + the client only supports version 3.1.” + -- + """ # noqa + self.addClient() + self.sendLine(1, "CAP LS") + m = self.getRegistrationMessage(1) + self.assertNotEqual( + m.params[2], + "*", + m, + fail_msg="Server replied with multi-line CAP LS to a " + "“CAP LS” (ie. IRCv3.1) request: {msg}", + ) + self.assertFalse( + any("=" in cap for cap in m.params[2].split()), + "Server replied with a name-value capability in " + "CAP LS reply as a response to “CAP LS” (ie. IRCv3.1) " + "request: {}".format(m), + ) + + @cases.mark_specifications("IRCv3") + def testEmptyCapList(self): + """“If no capabilities are active, an empty parameter must be sent.” + -- + """ # noqa + self.addClient() + self.sendLine(1, "CAP LIST") + m = self.getRegistrationMessage(1) + self.assertMessageMatch( + m, + command="CAP", + params=["*", "LIST", ""], + fail_msg="Sending “CAP LIST” as first message got a reply " + "that is not “CAP * LIST :”: {msg}", + ) + + @cases.mark_specifications("IRCv3") + def testNoMultiline301Response(self): + """ + Current version: "If the client supports CAP version 302, the server MAY send + multiple lines in response to CAP LS and CAP LIST." This should be read as + disallowing multiline responses to pre-302 clients. + -- + """ # noqa + self.check301ResponsePreRegistration("bar", "CAP LS") + self.check301ResponsePreRegistration("qux", "CAP LS 301") + self.check301ResponsePostRegistration("baz", "CAP LS") + self.check301ResponsePostRegistration("bat", "CAP LS 301") + + def check301ResponsePreRegistration(self, nick, cap_ls): + self.addClient(nick) + self.sendLine(nick, cap_ls) + self.sendLine(nick, "NICK " + nick) + self.sendLine(nick, "USER u s e r") + self.sendLine(nick, "CAP END") + responses = [msg for msg in self.skipToWelcome(nick) if msg.command == "CAP"] + self.assertLessEqual(len(responses), 1, responses) + + def check301ResponsePostRegistration(self, nick, cap_ls): + self.connectClient(nick, name=nick) + self.sendLine(nick, cap_ls) + responses = [msg for msg in self.getMessages(nick) if msg.command == "CAP"] + self.assertLessEqual(len(responses), 1, responses) diff --git a/irctest/server_tests/channel.py b/irctest/server_tests/channel.py index 5fead80..d2bc763 100644 --- a/irctest/server_tests/channel.py +++ b/irctest/server_tests/channel.py @@ -1,3 +1,7 @@ +""" +Channel casemapping +""" + import pytest from irctest import cases, client_mock, runner @@ -18,7 +22,7 @@ class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase): self.connectClient("foo") self.connectClient("bar") if self.server_support["CASEMAPPING"] != casemapping: - raise runner.NotImplementedByController( + raise runner.ImplementationChoice( "Casemapping {} not implemented".format(casemapping) ) self.joinClient(1, name1) @@ -43,7 +47,7 @@ class ChannelCaseSensitivityTestCase(cases.BaseServerTestCase): self.connectClient("foo") self.connectClient("bar") if self.server_support["CASEMAPPING"] != casemapping: - raise runner.NotImplementedByController( + raise runner.ImplementationChoice( "Casemapping {} not implemented".format(casemapping) ) self.joinClient(1, name1) diff --git a/irctest/server_tests/channel_forward.py b/irctest/server_tests/channel_forward.py index 9ce7312..11b284d 100644 --- a/irctest/server_tests/channel_forward.py +++ b/irctest/server_tests/channel_forward.py @@ -1,3 +1,9 @@ +""" +`Ergo `_-specific tests of channel forwarding + +TODO: Should be extended to other servers, once a specification is written. +""" + from irctest import cases from irctest.numerics import ERR_CHANOPRIVSNEEDED, ERR_INVALIDMODEPARAM, ERR_LINKCHANNEL diff --git a/irctest/server_tests/channel_rename.py b/irctest/server_tests/channel_rename.py index d22d950..5bcdbfe 100644 --- a/irctest/server_tests/channel_rename.py +++ b/irctest/server_tests/channel_rename.py @@ -1,24 +1,22 @@ +""" +`Draft IRCv3 channel-rename `_ +""" + from irctest import cases from irctest.numerics import ERR_CHANOPRIVSNEEDED -MODERN_CAPS = [ - "server-time", - "message-tags", - "batch", - "labeled-response", - "echo-message", - "account-tag", -] RENAME_CAP = "draft/channel-rename" +@cases.mark_specifications("IRCv3") class ChannelRenameTestCase(cases.BaseServerTestCase): """Basic tests for channel-rename.""" - @cases.mark_specifications("Ergo") def testChannelRename(self): - self.connectClient("bar", name="bar", capabilities=MODERN_CAPS + [RENAME_CAP]) - self.connectClient("baz", name="baz", capabilities=MODERN_CAPS) + self.connectClient( + "bar", name="bar", capabilities=[RENAME_CAP], skip_if_cap_nak=True + ) + self.connectClient("baz", name="baz") self.joinChannel("bar", "#bar") self.joinChannel("baz", "#bar") self.getMessages("bar") diff --git a/irctest/server_tests/chathistory.py b/irctest/server_tests/chathistory.py index a387e6f..cb88bb1 100644 --- a/irctest/server_tests/chathistory.py +++ b/irctest/server_tests/chathistory.py @@ -1,11 +1,16 @@ +""" +`IRCv3 draft chathistory `_ +""" + +import functools import secrets import time import pytest -from irctest import cases +from irctest import cases, runner from irctest.irc_utils.junkdrawer import random_name -from irctest.patma import ANYSTR +from irctest.patma import ANYSTR, StrRe CHATHISTORY_CAP = "draft/chathistory" EVENT_PLAYBACK_CAP = "draft/event-playback" @@ -16,35 +21,44 @@ SUBCOMMANDS = ["LATEST", "BEFORE", "AFTER", "BETWEEN", "AROUND"] MYSQL_PASSWORD = "" -def validate_chathistory_batch(msgs): - batch_tag = None - closed_batch_tag = None - result = [] - for msg in msgs: - if msg.command == "BATCH": - batch_param = msg.params[0] - if batch_tag is None and batch_param[0] == "+": - batch_tag = batch_param[1:] - elif batch_param[0] == "-": - closed_batch_tag = batch_param[1:] - elif ( - msg.command == "PRIVMSG" - and batch_tag is not None - and msg.tags.get("batch") == batch_tag - ): - if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific - result.append(msg.to_history_message()) - assert batch_tag == closed_batch_tag - return result +def skip_ngircd(f): + @functools.wraps(f) + def newf(self, *args, **kwargs): + if self.controller.software_name == "ngIRCd": + raise runner.OptionalExtensionNotSupported("nicks longer 9 characters") + return f(self, *args, **kwargs) + + return newf @cases.mark_specifications("IRCv3") @cases.mark_services class ChathistoryTestCase(cases.BaseServerTestCase): + def validate_chathistory_batch(self, msgs, target): + (start, *inner_msgs, end) = msgs + + self.assertMessageMatch( + start, command="BATCH", params=[StrRe(r"\+.*"), "chathistory", target] + ) + batch_tag = start.params[0][1:] + self.assertMessageMatch(end, command="BATCH", params=["-" + batch_tag]) + + result = [] + for msg in inner_msgs: + if ( + msg.command in ("PRIVMSG", "TOPIC") + and batch_tag is not None + and msg.tags.get("batch") == batch_tag + ): + if not msg.prefix.startswith("HistServ!"): # FIXME: ergo-specific + result.append(msg.to_history_message()) + return result + @staticmethod def config() -> cases.TestCaseControllerConfig: return cases.TestCaseControllerConfig(chathistory=True) + @skip_ngircd def testInvalidTargets(self): bar, pw = random_name("bar"), random_name("pw") self.controller.registerUser(self, bar, pw) @@ -90,6 +104,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): ) @pytest.mark.private_chathistory + @skip_ngircd def testMessagesToSelf(self): bar, pw = random_name("bar"), random_name("pw") self.controller.registerUser(self, bar, pw) @@ -162,7 +177,19 @@ class ChathistoryTestCase(cases.BaseServerTestCase): self.assertEqual(len(set(msg.time for msg in echo_messages)), num_messages) @pytest.mark.parametrize("subcommand", SUBCOMMANDS) + @skip_ngircd def testChathistory(self, subcommand): + if subcommand == "BETWEEN" and self.controller.software_name == "UnrealIRCd": + pytest.xfail( + "CHATHISTORY BETWEEN does not apply bounds correct " + "https://bugs.unrealircd.org/view.php?id=5952" + ) + if subcommand == "AROUND" and self.controller.software_name == "UnrealIRCd": + pytest.xfail( + "CHATHISTORY AROUND excludes 'central' messages " + "https://bugs.unrealircd.org/view.php?id=5953" + ) + self.connectClient( "bar", capabilities=[ @@ -193,7 +220,49 @@ class ChathistoryTestCase(cases.BaseServerTestCase): self.validate_echo_messages(NUM_MESSAGES, echo_messages) self.validate_chathistory(subcommand, echo_messages, 1, chname) + @skip_ngircd + def testChathistoryNoEventPlayback(self): + """Tests that non-messages don't appear in the chat history when event-playback + is not enabled.""" + + self.connectClient( + "bar", + capabilities=[ + "message-tags", + "server-time", + "echo-message", + "batch", + "labeled-response", + "sasl", + CHATHISTORY_CAP, + ], + skip_if_cap_nak=True, + ) + chname = "#chan" + secrets.token_hex(12) + self.joinChannel(1, chname) + self.getMessages(1) + self.getMessages(1) + + NUM_MESSAGES = 10 + echo_messages = [] + for i in range(NUM_MESSAGES): + self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i)) + self.getMessages(1) + self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i)) + echo_messages.extend( + msg.to_history_message() for msg in self.getMessages(1) + ) + time.sleep(0.002) + + self.validate_echo_messages(NUM_MESSAGES, echo_messages) + self.sendLine(1, "CHATHISTORY LATEST %s * 100" % chname) + (batch_open, *messages, batch_close) = self.getMessages(1) + self.assertMessageMatch(batch_open, command="BATCH") + self.assertMessageMatch(batch_close, command="BATCH") + self.assertEqual([msg for msg in messages if msg.command != "PRIVMSG"], []) + @pytest.mark.parametrize("subcommand", SUBCOMMANDS) + @skip_ngircd def testChathistoryEventPlayback(self, subcommand): self.connectClient( "bar", @@ -216,20 +285,27 @@ class ChathistoryTestCase(cases.BaseServerTestCase): NUM_MESSAGES = 10 echo_messages = [] for i in range(NUM_MESSAGES): + self.sendLine(1, "TOPIC %s :this is topic %d" % (chname, i)) + echo_messages.extend( + msg.to_history_message() for msg in self.getMessages(1) + ) + time.sleep(0.002) + self.sendLine(1, "PRIVMSG %s :this is message %d" % (chname, i)) echo_messages.extend( msg.to_history_message() for msg in self.getMessages(1) ) time.sleep(0.002) - self.validate_echo_messages(NUM_MESSAGES, echo_messages) + self.validate_echo_messages(NUM_MESSAGES * 2, echo_messages) self.validate_chathistory(subcommand, echo_messages, 1, chname) @pytest.mark.parametrize("subcommand", SUBCOMMANDS) @pytest.mark.private_chathistory + @skip_ngircd def testChathistoryDMs(self, subcommand): - c1 = "foo" + secrets.token_hex(12) - c2 = "bar" + secrets.token_hex(12) + c1 = random_name("foo") + c2 = random_name("bar") self.controller.registerUser(self, c1, "sesame1") self.controller.registerUser(self, c2, "sesame2") self.connectClient( @@ -277,11 +353,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase): ) time.sleep(0.002) + self.getMessages(1) + self.getMessages(2) + self.validate_echo_messages(NUM_MESSAGES, echo_messages) self.validate_chathistory(subcommand, echo_messages, 1, c2) self.validate_chathistory(subcommand, echo_messages, 2, c1) - c3 = "baz" + secrets.token_hex(12) + c3 = random_name("baz") self.connectClient( c3, capabilities=[ @@ -370,15 +449,15 @@ class ChathistoryTestCase(cases.BaseServerTestCase): def _validate_chathistory_LATEST(self, echo_messages, user, chname): INCLUSIVE_LIMIT = len(echo_messages) * 2 self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, INCLUSIVE_LIMIT)) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages, result) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 5)) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[-5:], result) self.sendLine(user, "CHATHISTORY LATEST %s * %d" % (chname, 1)) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[-1:], result) self.sendLine( @@ -386,7 +465,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY LATEST %s msgid=%s %d" % (chname, echo_messages[4].msgid, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[5:], result) self.sendLine( @@ -394,7 +473,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY LATEST %s timestamp=%s %d" % (chname, echo_messages[4].time, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[5:], result) def _validate_chathistory_BEFORE(self, echo_messages, user, chname): @@ -404,7 +483,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY BEFORE %s msgid=%s %d" % (chname, echo_messages[6].msgid, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[:6], result) self.sendLine( @@ -412,7 +491,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY BEFORE %s timestamp=%s %d" % (chname, echo_messages[6].time, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[:6], result) self.sendLine( @@ -420,7 +499,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY BEFORE %s timestamp=%s %d" % (chname, echo_messages[6].time, 2), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[4:6], result) def _validate_chathistory_AFTER(self, echo_messages, user, chname): @@ -430,7 +509,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY AFTER %s msgid=%s %d" % (chname, echo_messages[3].msgid, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[4:], result) self.sendLine( @@ -438,14 +517,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[4:], result) self.sendLine( user, "CHATHISTORY AFTER %s timestamp=%s %d" % (chname, echo_messages[3].time, 3), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[4:7], result) def _validate_chathistory_BETWEEN(self, echo_messages, user, chname): @@ -461,7 +540,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): INCLUSIVE_LIMIT, ), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( @@ -474,7 +553,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): INCLUSIVE_LIMIT, ), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[1:-1], result) # BETWEEN forwards and backwards with a limit, should get @@ -484,7 +563,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" % (chname, echo_messages[0].msgid, echo_messages[-1].msgid, 3), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[1:4], result) self.sendLine( @@ -492,7 +571,7 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY BETWEEN %s msgid=%s msgid=%s %d" % (chname, echo_messages[-1].msgid, echo_messages[0].msgid, 3), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[-4:-1], result) # same stuff again but with timestamps @@ -501,28 +580,28 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[0].time, echo_messages[-1].time, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( user, "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[-1].time, echo_messages[0].time, INCLUSIVE_LIMIT), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[1:-1], result) self.sendLine( user, "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[0].time, echo_messages[-1].time, 3), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[1:4], result) self.sendLine( user, "CHATHISTORY BETWEEN %s timestamp=%s timestamp=%s %d" % (chname, echo_messages[-1].time, echo_messages[0].time, 3), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[-4:-1], result) def _validate_chathistory_AROUND(self, echo_messages, user, chname): @@ -530,14 +609,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase): user, "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 1), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual([echo_messages[7]], result) self.sendLine( user, "CHATHISTORY AROUND %s msgid=%s %d" % (chname, echo_messages[7].msgid, 3), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertEqual(echo_messages[6:9], result) self.sendLine( @@ -545,13 +624,14 @@ class ChathistoryTestCase(cases.BaseServerTestCase): "CHATHISTORY AROUND %s timestamp=%s %d" % (chname, echo_messages[7].time, 3), ) - result = validate_chathistory_batch(self.getMessages(user)) + result = self.validate_chathistory_batch(self.getMessages(user), chname) self.assertIn(echo_messages[7], result) @pytest.mark.arbitrary_client_tags + @skip_ngircd def testChathistoryTagmsg(self): - c1 = "foo" + secrets.token_hex(12) - c2 = "bar" + secrets.token_hex(12) + c1 = random_name("foo") + c2 = random_name("bar") chname = "#chan" + secrets.token_hex(12) self.controller.registerUser(self, c1, "sesame1") self.controller.registerUser(self, c2, "sesame2") @@ -647,10 +727,11 @@ class ChathistoryTestCase(cases.BaseServerTestCase): @pytest.mark.arbitrary_client_tags @pytest.mark.private_chathistory + @skip_ngircd def testChathistoryDMClientOnlyTags(self): # regression test for Ergo #1411 - c1 = "foo" + secrets.token_hex(12) - c2 = "bar" + secrets.token_hex(12) + c1 = random_name("foo") + c2 = random_name("bar") self.controller.registerUser(self, c1, "sesame1") self.controller.registerUser(self, c2, "sesame2") self.connectClient( diff --git a/irctest/server_tests/chmodes/auditorium.py b/irctest/server_tests/chmodes/auditorium.py index 8cd74a3..bd328cb 100644 --- a/irctest/server_tests/chmodes/auditorium.py +++ b/irctest/server_tests/chmodes/auditorium.py @@ -1,3 +1,9 @@ +""" +`Ergo `_-specific tests of auditorium mode + +TODO: Should be extended to other servers, once a specification is written. +""" + import math import time diff --git a/irctest/server_tests/chmodes/ban.py b/irctest/server_tests/chmodes/ban.py index 22cdebc..d0f4829 100644 --- a/irctest/server_tests/chmodes/ban.py +++ b/irctest/server_tests/chmodes/ban.py @@ -1,4 +1,12 @@ -from irctest import cases +""" +Channel ban (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +and ban exception (`Modern `__) +""" + +from irctest import cases, runner from irctest.numerics import ERR_BANNEDFROMCHAN, RPL_BANLIST, RPL_ENDOFBANLIST from irctest.patma import ANYSTR, StrRe @@ -26,7 +34,7 @@ class BanModeTestCase(cases.BaseServerTestCase): @cases.mark_specifications("Modern") def testBanList(self): - """https://github.com/ircdocs/modern-irc/pull/125""" + """`RPL_BANLIST `_""" self.connectClient("chanop") self.joinChannel(1, "#chan") self.getMessages(1) @@ -70,6 +78,65 @@ class BanModeTestCase(cases.BaseServerTestCase): ], ) + @cases.mark_specifications("Modern") + def testBanException(self): + """`Exception mode `_ and checked against + `ISUPPORT CHANMODES `_""" + self.connectClient("chanop", name="chanop") + + if "EXCEPTS" in self.server_support: + mode = self.server_support["EXCEPTS"] or "e" + if "CHANMODES" in self.server_support: + self.assertIn( + mode, + self.server_support["CHANMODES"], + fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is missing " + "from 'CHANMODES={list}'", + ) + self.assertIn( + mode, + self.server_support["CHANMODES"].split(",")[0], + fail_msg="ISUPPORT EXCEPTS is present, but '{item}' is not " + "in group A", + ) + else: + mode = "e" + if "CHANMODES" in self.server_support: + if "e" not in self.server_support["CHANMODES"]: + raise runner.OptionalExtensionNotSupported( + "Ban exception (or mode letter is not +e)" + ) + self.assertIn( + mode, + self.server_support["CHANMODES"].split(",")[0], + fail_msg="Mode +e (assumed to be ban exception) is present, " + "but 'e' is not in group A", + ) + else: + raise runner.OptionalExtensionNotSupported("ISUPPORT CHANMODES") + + self.sendLine("chanop", "JOIN #chan") + self.getMessages("chanop") + self.sendLine("chanop", "MODE #chan +b ba*!*@*") + self.getMessages("chanop") + + # banned client cannot join + self.connectClient("Bar", name="bar") + self.sendLine("bar", "JOIN #chan") + self.assertMessageMatch(self.getMessage("bar"), command=ERR_BANNEDFROMCHAN) + + # chanop sets exception + self.sendLine("chanop", "MODE #chan +e *ar!*@*") + self.assertMessageMatch(self.getMessage("chanop"), command="MODE") + + # client can now join + self.sendLine("bar", "JOIN #chan") + self.assertMessageMatch(self.getMessage("bar"), command="JOIN") + + # TODO: Add testBanExceptionList, once the numerics are specified in Modern + @cases.mark_specifications("Ergo") def testCaseInsensitive(self): """Some clients allow unsetting modes if their argument matches diff --git a/irctest/server_tests/chmodes/ergo.py b/irctest/server_tests/chmodes/ergo.py index eef9d11..ae4d26d 100644 --- a/irctest/server_tests/chmodes/ergo.py +++ b/irctest/server_tests/chmodes/ergo.py @@ -1,3 +1,7 @@ +""" +Various Ergo-specific channel modes +""" + from irctest import cases from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED diff --git a/irctest/server_tests/chmodes/key.py b/irctest/server_tests/chmodes/key.py index eae1ab3..26d60da 100644 --- a/irctest/server_tests/chmodes/key.py +++ b/irctest/server_tests/chmodes/key.py @@ -1,3 +1,10 @@ +""" +Channel key (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + import pytest from irctest import cases @@ -20,10 +27,16 @@ class KeyTestCase(cases.BaseServerTestCase): self.connectClient("qux") self.getMessages(2) + # JOIN with a missing key MUST receive ERR_BADCHANNELKEY: self.sendLine(2, "JOIN #chan") - reply = self.getMessages(2) - self.assertNotIn("JOIN", {msg.command for msg in reply}) - self.assertIn(ERR_BADCHANNELKEY, {msg.command for msg in reply}) + reply_cmds = {msg.command for msg in self.getMessages(2)} + self.assertNotIn("JOIN", reply_cmds) + self.assertIn(ERR_BADCHANNELKEY, reply_cmds) + # similarly for JOIN with an incorrect key: + self.sendLine(2, "JOIN #chan bees") + reply_cmds = {msg.command for msg in self.getMessages(2)} + self.assertNotIn("JOIN", reply_cmds) + self.assertIn(ERR_BADCHANNELKEY, reply_cmds) self.sendLine(2, "JOIN #chan beer") reply = self.getMessages(2) @@ -31,8 +44,8 @@ class KeyTestCase(cases.BaseServerTestCase): @pytest.mark.parametrize( "key", - ["passphrase with spaces", "long" * 100, ""], - ids=["spaces", "long", "empty"], + ["passphrase with spaces", "long" * 100, "", " "], + ids=["spaces", "long", "empty", "only-space"], ) @cases.mark_specifications("RFC2812", "Modern") def testKeyValidation(self, key): @@ -57,6 +70,23 @@ class KeyTestCase(cases.BaseServerTestCase): -- https://modern.ircdocs.horse/#key-channel-mode -- https://github.com/ircdocs/modern-irc/pull/111 """ + if key == "" and self.controller.software_name in ( + "ircu2", + "Nefarious", + "snircd", + ): + pytest.xfail( + "ircu2 returns ERR_NEEDMOREPARAMS on empty keys: " + "https://github.com/UndernetIRC/ircu2/issues/13" + ) + if (key == "" or " " in key) and self.controller.software_name == "ngIRCd": + pytest.xfail( + "ngIRCd does not validate channel keys: " + "https://github.com/ngircd/ngircd/issues/290" + ) + if key == " " and self.controller.software_name == "irc2": + pytest.xfail("irc2 rewrites non-empty keys that contain only spaces") + self.connectClient("bar") self.joinChannel(1, "#chan") self.sendLine(1, f"MODE #chan +k :{key}") diff --git a/irctest/server_tests/chmodes/moderated.py b/irctest/server_tests/chmodes/moderated.py index e403dfe..d5b7b26 100644 --- a/irctest/server_tests/chmodes/moderated.py +++ b/irctest/server_tests/chmodes/moderated.py @@ -1,3 +1,9 @@ +""" +Channel moderation mode (`RFC 2812 +`__, +`Modern `__) +""" + from irctest import cases from irctest.numerics import ERR_CANNOTSENDTOCHAN diff --git a/irctest/server_tests/chmodes/mute_extban.py b/irctest/server_tests/chmodes/mute_extban.py index a17b0b4..cd8e131 100644 --- a/irctest/server_tests/chmodes/mute_extban.py +++ b/irctest/server_tests/chmodes/mute_extban.py @@ -1,3 +1,7 @@ +""" +Mute extban, currently no specifications or ways to discover it. +""" + from irctest import cases, runner from irctest.numerics import ERR_CANNOTSENDTOCHAN, ERR_CHANOPRIVSNEEDED from irctest.patma import ANYLIST, StrRe @@ -194,7 +198,7 @@ class MuteExtbanTestCase(cases.BaseServerTestCase): self.getMessages(client) # +e grants an exemption to +b - self.sendLine("chanop", f"MODE #chan +e {prefix}{self.char()}:*!~evan@*") + self.sendLine("chanop", f"MODE #chan +e {prefix}{self.char()}:*!*evan@*") replies = {msg.command for msg in self.getMessages("chanop")} self.assertIn("MODE", replies) self.assertNotIn(ERR_CHANOPRIVSNEEDED, replies) diff --git a/irctest/server_tests/chmodes/no_ctcp.py b/irctest/server_tests/chmodes/no_ctcp.py new file mode 100644 index 0000000..d01cadb --- /dev/null +++ b/irctest/server_tests/chmodes/no_ctcp.py @@ -0,0 +1,31 @@ +from irctest import cases +from irctest.numerics import ERR_CANNOTSENDTOCHAN + + +class NoCTCPChannelModeTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("Ergo") + def testNoCTCPChannelMode(self): + """Test Ergo's +C channel mode that blocks CTCPs.""" + self.connectClient("bar") + self.joinChannel(1, "#chan") + self.sendLine(1, "MODE #chan +C") + self.getMessages(1) + + self.connectClient("qux") + self.joinChannel(2, "#chan") + self.getMessages(2) + + self.sendLine(1, "PRIVMSG #chan :\x01ACTION hi\x01") + self.getMessages(1) + ms = self.getMessages(2) + self.assertEqual(len(ms), 1) + self.assertMessageMatch( + ms[0], command="PRIVMSG", params=["#chan", "\x01ACTION hi\x01"] + ) + + self.sendLine(1, "PRIVMSG #chan :\x01PING 1473523796 918320\x01") + ms = self.getMessages(1) + self.assertEqual(len(ms), 1) + self.assertMessageMatch(ms[0], command=ERR_CANNOTSENDTOCHAN) + ms = self.getMessages(2) + self.assertEqual(ms, []) diff --git a/irctest/server_tests/chmodes/no_external.py b/irctest/server_tests/chmodes/no_external.py new file mode 100644 index 0000000..4858c04 --- /dev/null +++ b/irctest/server_tests/chmodes/no_external.py @@ -0,0 +1,38 @@ +""" +Channel "no external messages" mode (`RFC 1459 +`__, +`Modern `__) +""" + +from irctest import cases +from irctest.numerics import ERR_CANNOTSENDTOCHAN + + +class NoExternalMessagesTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("RFC1459", "Modern") + def testNoExternalMessagesMode(self): + # test the +n channel mode + self.connectClient("chanop", name="chanop") + self.joinChannel("chanop", "#chan") + self.sendLine("chanop", "MODE #chan +n") + self.getMessages("chanop") + + self.connectClient("baz", name="baz") + # this message should be suppressed completely by +n + self.sendLine("baz", "PRIVMSG #chan :hi from baz") + replies = self.getMessages("baz") + reply_cmds = {reply.command for reply in replies} + self.assertIn(ERR_CANNOTSENDTOCHAN, reply_cmds) + self.assertEqual(self.getMessages("chanop"), []) + + # set the channel to -n: baz should be able to send now + self.sendLine("chanop", "MODE #chan -n") + replies = self.getMessages("chanop") + modeLines = [line for line in replies if line.command == "MODE"] + self.assertMessageMatch(modeLines[0], command="MODE", params=["#chan", "-n"]) + self.sendLine("baz", "PRIVMSG #chan :hi again from baz") + self.getMessages("baz") + relays = self.getMessages("chanop") + self.assertMessageMatch( + relays[0], command="PRIVMSG", params=["#chan", "hi again from baz"] + ) diff --git a/irctest/server_tests/chmodes/secret.py b/irctest/server_tests/chmodes/secret.py new file mode 100644 index 0000000..30f7759 --- /dev/null +++ b/irctest/server_tests/chmodes/secret.py @@ -0,0 +1,62 @@ +""" +Channel secrecy mode (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + +from irctest import cases +from irctest.numerics import RPL_LIST + + +class SecretChannelTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("RFC1459", "Modern") + def testSecretChannelListCommand(self): + """ + + + "Likewise, secret channels are not listed + at all unless the client is a member of the channel in question." + + + "A channel that is set to secret will not show up in responses to + the LIST or NAMES command unless the client sending the command is + joined to the channel." + """ + + def get_listed_channels(replies): + channels = set() + for reply in replies: + # skip pseudo-channels (&SERVER, &NOTICES) listed by ngircd + # and ircu: + if reply.command == RPL_LIST and reply.params[1].startswith("#"): + channels.add(reply.params[1]) + return channels + + # test that a silent channel is shown in list if the user is in the channel. + self.connectClient("first", name="first") + self.joinChannel("first", "#gen") + self.getMessages("first") + self.sendLine("first", "MODE #gen +s") + # run command LIST + self.sendLine("first", "LIST") + replies = self.getMessages("first") + self.assertEqual(get_listed_channels(replies), {"#gen"}) + + # test that another client would not see the secret + # channel. + self.connectClient("second", name="second") + self.getMessages("second") + self.sendLine("second", "LIST") + replies = self.getMessages("second") + # RPL_LIST 322 should NOT be present this time. + self.assertEqual(get_listed_channels(replies), set()) + + # Second client will join the secret channel + # and call command LIST. The channel SHOULD + # appear this time. + self.joinChannel("second", "#gen") + self.sendLine("second", "LIST") + replies = self.getMessages("second") + # Should be only one line with command RPL_LIST + self.assertEqual(get_listed_channels(replies), {"#gen"}) diff --git a/irctest/server_tests/confusables.py b/irctest/server_tests/confusables.py index 223d84b..480ece2 100644 --- a/irctest/server_tests/confusables.py +++ b/irctest/server_tests/confusables.py @@ -1,3 +1,8 @@ +""" +`Ergo `_-specific tests for nick collisions based on Unicode +confusable characters +""" + from irctest import cases from irctest.numerics import ERR_NICKNAMEINUSE, RPL_WELCOME diff --git a/irctest/server_tests/connection_registration.py b/irctest/server_tests/connection_registration.py index 6a1df18..a3d5038 100644 --- a/irctest/server_tests/connection_registration.py +++ b/irctest/server_tests/connection_registration.py @@ -1,11 +1,15 @@ """ Tests section 4.1 of RFC 1459. + +TODO: cross-reference Modern and RFC 2812 too """ +import time + from irctest import cases from irctest.client_mock import ConnectionClosed -from irctest.numerics import ERR_NEEDMOREPARAMS +from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_PASSWDMISMATCH from irctest.patma import ANYSTR, StrRe @@ -36,8 +40,14 @@ class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase): m.command, "001", msg="Got 001 after NICK+USER but missing PASS" ) - @cases.mark_specifications("RFC1459", "RFC2812") + @cases.mark_specifications("Modern") def testWrongPassword(self): + """ + "If the password supplied does not match the password expected by the server, + then the server SHOULD send ERR_PASSWDMISMATCH and MUST close the connection + with ERROR." + -- https://github.com/ircdocs/modern-irc/pull/172 + """ self.addClient() self.sendLine(1, "PASS {}".format(self.password + "garbage")) self.sendLine(1, "NICK foo") @@ -46,6 +56,13 @@ class PasswordedConnectionRegistrationTestCase(cases.BaseServerTestCase): self.assertNotEqual( m.command, "001", msg="Got 001 after NICK+USER but incorrect PASS" ) + self.assertIn(m.command, {ERR_PASSWDMISMATCH, "ERROR"}) + + if m.command == "ERR_PASSWDMISMATCH": + m = self.getRegistrationMessage(1) + self.assertEqual( + m.command, "ERROR", msg="ERR_PASSWDMISMATCH not followed by ERROR." + ) @cases.mark_specifications("RFC1459", "RFC2812", strict=True) def testPassAfterNickuser(self): @@ -82,6 +99,10 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase): self.getMessages(1) @cases.mark_specifications("RFC2812") + @cases.xfailIfSoftware(["Charybdis", "Solanum"], "very flaky") + @cases.xfailIfSoftware( + ["ircu2", "Nefarious", "snircd"], "ircu2 does not send ERROR" + ) def testQuitErrors(self): """“A client session is terminated with a quit message. The server acknowledges this by sending an ERROR message to the client.” @@ -114,7 +135,7 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase): self.assertNotEqual( m.command, "001", - "Received 001 after registering with the nick of a " "registered user.", + "Received 001 after registering with the nick of a registered user.", ) def testEarlyNickCollision(self): @@ -162,6 +183,10 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase): "neither got 001.", ) + @cases.xfailIfSoftware( + ["ircu2", "Nefarious", "ngIRCd"], + "uses a default value instead of ERR_NEEDMOREPARAMS", + ) def testEmptyRealname(self): """ Syntax: @@ -184,59 +209,57 @@ class ConnectionRegistrationTestCase(cases.BaseServerTestCase): params=[StrRe(r"(\*|foo)"), "USER", ANYSTR], ) - @cases.mark_specifications("IRCv3") - def testIrc301CapLs(self): - """ - Current version: - - "The LS subcommand is used to list the capabilities supported by the server. - The client should send an LS subcommand with no other arguments to solicit - a list of all capabilities." - - "If a client has not indicated support for CAP LS 302 features, - the server MUST NOT send these new features to the client." - -- - - Before the v3.1 / v3.2 merge: - - IRCv3.1: “The LS subcommand is used to list the capabilities - supported by the server. The client should send an LS subcommand with - no other arguments to solicit a list of all capabilities.” - -- - - IRCv3.2: “Servers MUST NOT send messages described by this document if - the client only supports version 3.1.” - -- - """ # noqa + def testNonutf8Realname(self): self.addClient() - self.sendLine(1, "CAP LS") - m = self.getRegistrationMessage(1) - self.assertNotEqual( - m.params[2], - "*", - m, - fail_msg="Server replied with multi-line CAP LS to a " - "“CAP LS” (ie. IRCv3.1) request: {msg}", - ) - self.assertFalse( - any("=" in cap for cap in m.params[2].split()), - "Server replied with a name-value capability in " - "CAP LS reply as a response to “CAP LS” (ie. IRCv3.1) " - "request: {}".format(m), - ) + self.sendLine(1, "NICK foo") + line = b"USER username * * :i\xe8rc\xe9\r\n" + print("1 -> S (repr): " + repr(line)) + self.clients[1].conn.sendall(line) + for _ in range(10): + time.sleep(1) + d = self.clients[1].conn.recv(10000) + self.assertTrue(d, "Server closed connection") + print("S -> 1 (repr): " + repr(d)) + if b" 001 " in d: + break + if b"ERROR " in d or b" FAIL " in d: + # Rejected; nothing more to test. + return + for line in d.split(b"\r\n"): + if line.startswith(b"PING "): + line = line.replace(b"PING", b"PONG") + b"\r\n" + print("1 -> S (repr): " + repr(line)) + self.clients[1].conn.sendall(line) + else: + self.assertTrue(False, "stuck waiting") + self.sendLine(1, "WHOIS foo") + time.sleep(3) # for ngIRCd + d = self.clients[1].conn.recv(10000) + print("S -> 1 (repr): " + repr(d)) + self.assertIn(b"username", d) - @cases.mark_specifications("IRCv3") - def testEmptyCapList(self): - """“If no capabilities are active, an empty parameter must be sent.” - -- - """ # noqa + def testNonutf8Username(self): self.addClient() - self.sendLine(1, "CAP LIST") - m = self.getRegistrationMessage(1) - self.assertMessageMatch( - m, - command="CAP", - params=["*", "LIST", ""], - fail_msg="Sending “CAP LIST” as first message got a reply " - "that is not “CAP * LIST :”: {msg}", - ) + self.sendLine(1, "NICK foo") + self.sendLine(1, "USER 😊😊😊😊😊😊😊😊😊😊 * * :realname") + for _ in range(10): + time.sleep(1) + d = self.clients[1].conn.recv(10000) + self.assertTrue(d, "Server closed connection") + print("S -> 1 (repr): " + repr(d)) + if b" 001 " in d: + break + if b" 468" in d or b"ERROR " in d: + # Rejected; nothing more to test. + return + for line in d.split(b"\r\n"): + if line.startswith(b"PING "): + line = line.replace(b"PING", b"PONG") + b"\r\n" + print("1 -> S (repr): " + repr(line)) + self.clients[1].conn.sendall(line) + else: + self.assertTrue(False, "stuck waiting") + self.sendLine(1, "WHOIS foo") + d = self.clients[1].conn.recv(10000) + print("S -> 1 (repr): " + repr(d)) + self.assertIn(b"realname", d) diff --git a/irctest/server_tests/echo_message.py b/irctest/server_tests/echo_message.py index f183316..cdfcb8c 100644 --- a/irctest/server_tests/echo_message.py +++ b/irctest/server_tests/echo_message.py @@ -1,11 +1,10 @@ """ - +`IRCv3 echo-message `_ """ import pytest from irctest import cases -from irctest.basecontrollers import NotImplementedByController from irctest.irc_utils.junkdrawer import random_name from irctest.patma import ANYDICT @@ -23,36 +22,20 @@ class EchoMessageTestCase(cases.BaseServerTestCase): @cases.mark_capabilities("echo-message") def testEchoMessage(self, command, solo, server_time): """""" - self.addClient() - self.sendLine(1, "CAP LS 302") - capabilities = self.getCapLs(1) - if "echo-message" not in capabilities: - raise NotImplementedByController("echo-message") - if server_time and "server-time" not in capabilities: - raise NotImplementedByController("server-time") + capabilities = ["server-time"] if server_time else [] - # TODO: check also without this - self.sendLine( - 1, - "CAP REQ :echo-message{}".format(" server-time" if server_time else ""), + self.connectClient( + "baz", + capabilities=["echo-message", *capabilities], + skip_if_cap_nak=True, ) - self.getRegistrationMessage(1) - # TODO: Remove this one the trailing space issue is fixed in Charybdis - # and Mammon: - # self.assertMessageMatch(m, command='CAP', - # params=['*', 'ACK', 'echo-message'] + - # (['server-time'] if server_time else []), - # fail_msg='Did not ACK advertised capabilities: {msg}') - self.sendLine(1, "USER f * * :foo") - self.sendLine(1, "NICK baz") - self.sendLine(1, "CAP END") - self.skipToWelcome(1) - self.getMessages(1) self.sendLine(1, "JOIN #chan") + # Synchronize + self.getMessages(1) + if not solo: - capabilities = ["server-time"] if server_time else None self.connectClient("qux", capabilities=capabilities) self.sendLine(2, "JOIN #chan") diff --git a/irctest/server_tests/ergo/services.py b/irctest/server_tests/ergo/services.py index eb00ab2..ef877bd 100644 --- a/irctest/server_tests/ergo/services.py +++ b/irctest/server_tests/ergo/services.py @@ -1,3 +1,7 @@ +""" +`Ergo `-specific tests of NickServ. +""" + from irctest import cases from irctest.numerics import RPL_YOUREOPER diff --git a/irctest/server_tests/extended_join.py b/irctest/server_tests/extended_join.py index 7adf811..2438f87 100644 --- a/irctest/server_tests/extended_join.py +++ b/irctest/server_tests/extended_join.py @@ -1,12 +1,12 @@ """ - +`IRCv3 extended-join `_ """ from irctest import cases @cases.mark_services -class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class MetadataTestCase(cases.BaseServerTestCase): def connectRegisteredClient(self, nick): self.addClient() self.sendLine(2, "CAP LS 302") @@ -50,7 +50,7 @@ class MetadataTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ) @cases.mark_capabilities("extended-join") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") def testLoggedIn(self): self.connectClient("foo", capabilities=["extended-join"], skip_if_cap_nak=True) self.joinChannel(1, "#chan") diff --git a/irctest/server_tests/help.py b/irctest/server_tests/help.py index c5acc65..32475fa 100644 --- a/irctest/server_tests/help.py +++ b/irctest/server_tests/help.py @@ -1,7 +1,8 @@ """ -The HELP and HELPOP command. +The HELP and HELPOP command (`Modern `__) """ +import functools import re import pytest @@ -17,6 +18,30 @@ from irctest.numerics import ( from irctest.patma import ANYSTR, StrRe +def with_xfails(f): + @functools.wraps(f) + def newf(self, command, *args, **kwargs): + if command == "HELP" and self.controller.software_name == "Bahamut": + raise runner.ImplementationChoice( + "fail because Bahamut forwards /HELP to HelpServ (but not /HELPOP)" + ) + + if self.controller.software_name in ("irc2", "ircu2", "ngIRCd"): + raise runner.ImplementationChoice( + "numerics in reply to /HELP and /HELPOP (uses NOTICE instead)" + ) + + if self.controller.software_name == "UnrealIRCd": + raise runner.ImplementationChoice( + "fails because Unreal uses custom numerics " + "https://github.com/unrealircd/unrealircd/pull/184" + ) + + return f(self, command, *args, **kwargs) + + return newf + + class HelpTestCase(cases.BaseServerTestCase): def _assertValidHelp(self, messages, subject): if subject != ANYSTR: @@ -46,6 +71,7 @@ class HelpTestCase(cases.BaseServerTestCase): @pytest.mark.parametrize("command", ["HELP", "HELPOP"]) @cases.mark_specifications("Modern") + @with_xfails def testHelpNoArg(self, command): self.connectClient("nick") self.sendLine(1, f"{command}") @@ -59,6 +85,7 @@ class HelpTestCase(cases.BaseServerTestCase): @pytest.mark.parametrize("command", ["HELP", "HELPOP"]) @cases.mark_specifications("Modern") + @with_xfails def testHelpPrivmsg(self, command): self.connectClient("nick") self.sendLine(1, f"{command} PRIVMSG") @@ -71,6 +98,7 @@ class HelpTestCase(cases.BaseServerTestCase): @pytest.mark.parametrize("command", ["HELP", "HELPOP"]) @cases.mark_specifications("Modern") + @with_xfails def testHelpUnknownSubject(self, command): self.connectClient("nick") self.sendLine(1, f"{command} THISISNOTACOMMAND") diff --git a/irctest/server_tests/info.py b/irctest/server_tests/info.py index db0d9db..d1a2613 100644 --- a/irctest/server_tests/info.py +++ b/irctest/server_tests/info.py @@ -1,5 +1,8 @@ """ -The INFO command. +The INFO command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) """ import pytest @@ -84,6 +87,9 @@ class InfoTestCase(cases.BaseServerTestCase): @pytest.mark.parametrize("target", ["invalid.server.example", "invalidserver"]) @cases.mark_specifications("RFC1459", "RFC2812", deprecated=True) + @cases.xfailIfSoftware( + ["Ergo"], "does not apply to Ergo, which ignores the optional argument" + ) def testInfoNosuchserver(self, target): """ diff --git a/irctest/server_tests/invite.py b/irctest/server_tests/invite.py index 935780b..5b6c43a 100644 --- a/irctest/server_tests/invite.py +++ b/irctest/server_tests/invite.py @@ -1,10 +1,18 @@ +""" +The INVITE command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + import pytest -from irctest import cases +from irctest import cases, runner from irctest.numerics import ( ERR_BANNEDFROMCHAN, ERR_CHANOPRIVSNEEDED, ERR_INVITEONLYCHAN, + ERR_NEEDMOREPARAMS, ERR_NOSUCHNICK, ERR_NOTONCHANNEL, ERR_USERONCHANNEL, @@ -110,7 +118,7 @@ class InviteTestCase(cases.BaseServerTestCase): "got this instead: {msg}", ) - def _testInvite(self, opped, invite_only, modern): + def _testInvite(self, opped, invite_only): """ "Only the user inviting and the user being invited will receive notification of the invitation." @@ -163,23 +171,14 @@ class InviteTestCase(cases.BaseServerTestCase): ) self.sendLine(1, "INVITE bar #chan") - if modern: - self.assertMessageMatch( - self.getMessage(1), - command=RPL_INVITING, - params=["foo", "bar", "#chan"], - fail_msg=f"After “foo” invited “bar” to a channel, “foo” should have " - f"received “{RPL_INVITING} foo #chan bar” but got this instead: " - f"{{msg}}", - ) - else: - self.assertMessageMatch( - self.getMessage(1), - command=RPL_INVITING, - params=["#chan", "bar"], - fail_msg=f"After “foo” invited “bar” to a channel, “foo” should have " - f"received “{RPL_INVITING} #chan bar” but got this instead: {{msg}}", - ) + self.assertMessageMatch( + self.getMessage(1), + command=RPL_INVITING, + params=["foo", "bar", "#chan"], + fail_msg=f"After “foo” invited “bar” to a channel, “foo” should have " + f"received “{RPL_INVITING} foo #chan bar” but got this instead: " + f"{{msg}}", + ) messages = self.getMessages(2) self.assertNotEqual( @@ -197,24 +196,17 @@ class InviteTestCase(cases.BaseServerTestCase): ) @pytest.mark.parametrize("invite_only", [True, False]) - @cases.mark_specifications("Modern") - def testInviteModern(self, invite_only): - self._testInvite(opped=True, invite_only=invite_only, modern=True) + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testInvite(self, invite_only): + self._testInvite(opped=True, invite_only=invite_only) - @pytest.mark.parametrize("invite_only", [True, False]) - @cases.mark_specifications("RFC1459", "RFC2812", deprecated=True) - def testInviteRfc(self, invite_only): - self._testInvite(opped=True, invite_only=invite_only, modern=False) - - @cases.mark_specifications("Modern", strict=True) - def testInviteUnoppedModern(self): + @cases.mark_specifications("RFC1459", "RFC2812", "Modern", strict=True) + @cases.xfailIfSoftware( + ["Hybrid", "Plexus4"], "the only strict test that Hybrid fails" + ) + def testInviteUnopped(self): """Tests invites from unopped users on not-invite-only chans.""" - self._testInvite(opped=False, invite_only=False, modern=True) - - @cases.mark_specifications("RFC1459", "RFC2812", deprecated=True, strict=True) - def testInviteUnoppedRfc(self, opped, invite_only): - """Tests invites from unopped users on not-invite-only chans.""" - self._testInvite(opped=False, invite_only=False, modern=False) + self._testInvite(opped=False, invite_only=False) @cases.mark_specifications("RFC2812", "Modern") def testInviteNoNotificationForOtherMembers(self): @@ -248,7 +240,13 @@ class InviteTestCase(cases.BaseServerTestCase): "were notified: {got}", ) - def _testInviteInviteOnly(self, modern): + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + @cases.xfailIfSoftware( + ["Plexus4"], + "Plexus4 allows non-op to invite if (and only if) the channel is not " + "invite-only", + ) + def testInviteInviteOnly(self): """ "To invite a user to a channel which is invite only (MODE +i), the client sending the invite must be recognised as being a @@ -288,35 +286,17 @@ class InviteTestCase(cases.BaseServerTestCase): ) self.sendLine(1, "INVITE bar #chan") - if modern: - self.assertMessageMatch( - self.getMessage(1), - command=ERR_CHANOPRIVSNEEDED, - params=["foo", "#chan", ANYSTR], - fail_msg=f"After “foo” invited “bar” to a channel to an invite-only " - f"channel without being opped, “foo” should have received " - f"“{ERR_CHANOPRIVSNEEDED} foo #chan :*” but got this instead: {{msg}}", - ) - else: - self.assertMessageMatch( - self.getMessage(1), - command=ERR_CHANOPRIVSNEEDED, - params=["#chan", ANYSTR], - fail_msg=f"After “foo” invited “bar” to a channel to an invite-only " - f"channel without being opped, “foo” should have received " - f"“{ERR_CHANOPRIVSNEEDED} #chan :*” but got this instead: {{msg}}", - ) - - @cases.mark_specifications("Modern") - def testInviteInviteOnlyModern(self): - self._testInviteInviteOnly(modern=True) - - @cases.mark_specifications("RFC1459", "RFC2812", deprecated=True) - def testInviteInviteOnlyRfc(self): - self._testInviteInviteOnly(modern=False) + self.assertMessageMatch( + self.getMessage(1), + command=ERR_CHANOPRIVSNEEDED, + params=["foo", "#chan", ANYSTR], + fail_msg=f"After “foo” invited “bar” to a channel to an invite-only " + f"channel without being opped, “foo” should have received " + f"“{ERR_CHANOPRIVSNEEDED} foo #chan :*” but got this instead: {{msg}}", + ) @cases.mark_specifications("RFC2812", "Modern") - def _testInviteOnlyFromUsersInChannel(self, modern): + def testInviteOnlyFromUsersInChannel(self): """ "if the channel exists, only members of the channel are allowed to invite other users" @@ -349,26 +329,15 @@ class InviteTestCase(cases.BaseServerTestCase): self.getMessages(3) self.sendLine(1, "INVITE bar #chan") - if modern: - self.assertMessageMatch( - self.getMessage(1), - command=ERR_NOTONCHANNEL, - params=["foo", "#chan", ANYSTR], - fail_msg=f"After “foo” invited “bar” to a channel it is not on " - f"#chan, “foo” should have received " - f"“ERR_NOTONCHANNEL ({ERR_NOTONCHANNEL}) foo #chan :*” but " - f"got this instead: {{msg}}", - ) - else: - self.assertMessageMatch( - self.getMessage(1), - command=ERR_NOTONCHANNEL, - params=["#chan", ANYSTR], - fail_msg=f"After “foo” invited “bar” to a channel it is not on " - f"#chan, “foo” should have received " - f"“ERR_NOTONCHANNEL ({ERR_NOTONCHANNEL}) #chan :*” but " - f"got this instead: {{msg}}", - ) + self.assertMessageMatch( + self.getMessage(1), + command=ERR_NOTONCHANNEL, + params=["foo", "#chan", ANYSTR], + fail_msg=f"After “foo” invited “bar” to a channel it is not on " + f"#chan, “foo” should have received " + f"“ERR_NOTONCHANNEL ({ERR_NOTONCHANNEL}) foo #chan :*” but " + f"got this instead: {{msg}}", + ) messages = self.getMessages(2) self.assertEqual( @@ -378,14 +347,6 @@ class InviteTestCase(cases.BaseServerTestCase): "not in #chan, “bar” received something.", ) - @cases.mark_specifications("Modern") - def testInviteOnlyFromUsersInChannelModern(self): - self._testInviteOnlyFromUsersInChannel(modern=True) - - @cases.mark_specifications("RFC2812", deprecated=True) - def testInviteOnlyFromUsersInChannelRfc(self): - self._testInviteOnlyFromUsersInChannel(modern=False) - @cases.mark_specifications("Modern") def testInviteAlreadyInChannel(self): """ @@ -399,8 +360,8 @@ class InviteTestCase(cases.BaseServerTestCase): self.getMessages(2) self.sendLine(1, "JOIN #chan") - self.sendLine(2, "JOIN #chan") self.getMessages(1) + self.sendLine(2, "JOIN #chan") self.getMessages(2) self.getMessages(1) @@ -412,6 +373,87 @@ class InviteTestCase(cases.BaseServerTestCase): params=["foo", "bar", "#chan", ANYSTR], ) + @cases.mark_specifications("RFC2812", "Modern") + @cases.xfailIfSoftware( + ["ircu2"], + "Uses 346/347 instead of 336/337 to reply to INVITE " + "https://github.com/UndernetIRC/ircu2/pull/20", + ) + def testInviteList(self): + self.connectClient("foo") + self.connectClient("bar") + self.getMessages(1) + self.getMessages(2) + + self.sendLine(1, "JOIN #chan") + self.getMessages(1) + + self.sendLine(1, "INVITE bar #chan") + self.getMessages(1) + self.getMessages(2) + + self.sendLine(2, "INVITE") + m = self.getMessage(2) + if m.command == ERR_NEEDMOREPARAMS: + raise runner.OptionalExtensionNotSupported("INVITE with no parameter") + if m.command != "337": + # Hybrid always sends an empty list; so skip this. + self.assertMessageMatch( + m, + command="336", + params=["bar", "#chan"], + ) + m = self.getMessage(2) + self.assertMessageMatch( + m, + command="337", + params=["bar", ANYSTR], + ) + + @cases.mark_isupport("INVEX") + @cases.mark_specifications("Modern") + def testInvexList(self): + self.connectClient("foo") + self.getMessages(1) + + if "INVEX" in self.server_support: + invex = self.server_support.get("INVEX") or "I" + else: + raise runner.IsupportTokenNotSupported("INVEX") + + self.sendLine(1, "JOIN #chan") + self.getMessages(1) + + self.sendLine(1, f"MODE #chan +{invex} bar!*@*") + self.getMessages(1) + + self.sendLine(1, f"MODE #chan +{invex}") + m = self.getMessage(1) + if len(m.params) == 3: + # Old format + self.assertMessageMatch( + m, + command="346", + params=["foo", "#chan", "bar!*@*"], + ) + else: + self.assertMessageMatch( + m, + command="346", + params=[ + "foo", + "#chan", + "bar!*@*", + StrRe("foo(!.*@.*)?"), + StrRe("[0-9]+"), + ], + ) + self.assertMessageMatch( + self.getMessage(1), + command="347", + params=["foo", "#chan", ANYSTR], + ) + @cases.mark_specifications("Ergo") def testInviteExemptsFromBan(self): # regression test for ergochat/ergo#1876; diff --git a/irctest/server_tests/isupport.py b/irctest/server_tests/isupport.py index 4a68a1a..ae0ac18 100644 --- a/irctest/server_tests/isupport.py +++ b/irctest/server_tests/isupport.py @@ -1,9 +1,72 @@ +""" +RPL_ISUPPORT: `format `__ +and various `tokens `__ +""" + import re from irctest import cases, runner class IsupportTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("Modern") + @cases.mark_isupport("PREFIX") + def testPrefix(self): + """https://modern.ircdocs.horse/#prefix-parameter""" + self.connectClient("foo") + + if "PREFIX" not in self.server_support: + raise runner.IsupportTokenNotSupported("PREFIX") + + if self.server_support["PREFIX"] == "": + # "The value is OPTIONAL and when it is not specified indicates that no + # prefixes are supported." + return + + m = re.match( + r"\((?P[a-zA-Z]+)\)(?P\S+)", self.server_support["PREFIX"] + ) + self.assertTrue( + m, + f"PREFIX={self.server_support['PREFIX']} does not have the expected " + f"format.", + ) + + modes = m.group("modes") + prefixes = m.group("prefixes") + + # "There is a one-to-one mapping between prefixes and channel modes." + self.assertEqual( + len(modes), len(prefixes), "Mismatched length of prefix and channel modes." + ) + + # "The prefixes in this parameter are in descending order, from the prefix + # that gives the most privileges to the prefix that gives the least." + self.assertLess(modes.index("o"), modes.index("v"), "'o' is not before 'v'") + if "h" in modes: + self.assertLess(modes.index("o"), modes.index("h"), "'o' is not before 'h'") + self.assertLess(modes.index("h"), modes.index("v"), "'h' is not before 'v'") + if "q" in modes: + self.assertLess(modes.index("q"), modes.index("o"), "'q' is not before 'o'") + + # Not technically in the spec, but it would be very confusing not to follow + # these conventions. + mode_to_prefix = dict(zip(modes, prefixes)) + self.assertEqual(mode_to_prefix["o"], "@", "Prefix char for mode +o is not @") + self.assertEqual(mode_to_prefix["v"], "+", "Prefix char for mode +v is not +") + if "h" in modes: + self.assertEqual( + mode_to_prefix["h"], "%", "Prefix char for mode +h is not %" + ) + if "q" in modes: + self.assertEqual( + mode_to_prefix["q"], "~", "Prefix char for mode +q is not ~" + ) + if "a" in modes: + self.assertEqual( + mode_to_prefix["a"], "&", "Prefix char for mode +a is not &" + ) + @cases.mark_specifications("Modern", "ircdocs") @cases.mark_isupport("TARGMAX") def testTargmax(self): @@ -17,7 +80,7 @@ class IsupportTestCase(cases.BaseServerTestCase): self.connectClient("foo") if "TARGMAX" not in self.server_support: - raise runner.NotImplementedByController("TARGMAX") + raise runner.IsupportTokenNotSupported("TARGMAX") parts = self.server_support["TARGMAX"].split(",") for part in parts: diff --git a/irctest/server_tests/join.py b/irctest/server_tests/join.py index 673d403..833f9c1 100644 --- a/irctest/server_tests/join.py +++ b/irctest/server_tests/join.py @@ -1,5 +1,30 @@ -from irctest import cases +""" +The JOIN command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + +from irctest import cases, runner from irctest.irc_utils import ambiguities +from irctest.numerics import ( + ERR_BADCHANMASK, + ERR_FORBIDDENCHANNEL, + ERR_NOSUCHCHANNEL, + RPL_ENDOFNAMES, + RPL_NAMREPLY, +) +from irctest.patma import ANYSTR, StrRe + +ERR_BADCHANNAME = "479" # Hybrid only, and conflicts with others + + +JOIN_ERROR_NUMERICS = { + ERR_BADCHANMASK, + ERR_NOSUCHCHANNEL, + ERR_FORBIDDENCHANNEL, + ERR_BADCHANNAME, +} class JoinTestCase(cases.BaseServerTestCase): @@ -19,13 +44,22 @@ class JoinTestCase(cases.BaseServerTestCase): self.connectClient("foo") self.sendLine(1, "JOIN #chan") received_commands = {m.command for m in self.getMessages(1)} - expected_commands = {"353", "366"} # RPL_NAMREPLY # RPL_ENDOFNAMES - self.assertTrue( - expected_commands.issubset(received_commands), + expected_commands = {RPL_NAMREPLY, RPL_ENDOFNAMES, "JOIN"} + acceptable_commands = expected_commands | {"MODE"} + self.assertLessEqual( # set inclusion + expected_commands, + received_commands, "Server sent {} commands, but at least {} were expected.".format( received_commands, expected_commands ), ) + self.assertLessEqual( # ditto + received_commands, + acceptable_commands, + "Server sent {} commands, but only {} were expected.".format( + received_commands, acceptable_commands + ), + ) @cases.mark_specifications("RFC2812") def testJoinNamreply(self): @@ -110,3 +144,95 @@ class JoinTestCase(cases.BaseServerTestCase): '"foo" with an optional "+" or "@" prefix, but got: ' "{msg}", ) + + def testJoinPartiallyInvalid(self): + """TODO: specify this in Modern""" + self.connectClient("foo") + if int(self.targmax.get("JOIN") or "4") < 2: + raise runner.OptionalExtensionNotSupported("multi-channel JOIN") + + self.sendLine(1, "JOIN #valid,inv@lid") + messages = self.getMessages(1) + received_commands = {m.command for m in messages} + expected_commands = {RPL_NAMREPLY, RPL_ENDOFNAMES, "JOIN"} + acceptable_commands = expected_commands | JOIN_ERROR_NUMERICS | {"MODE"} + self.assertLessEqual( + expected_commands, + received_commands, + "Server sent {} commands, but at least {} were expected.".format( + received_commands, expected_commands + ), + ) + self.assertLessEqual( + received_commands, + acceptable_commands, + "Server sent {} commands, but only {} were expected.".format( + received_commands, acceptable_commands + ), + ) + + nb_errors = 0 + for m in messages: + if m.command in JOIN_ERROR_NUMERICS: + nb_errors += 1 + self.assertMessageMatch(m, params=["foo", "inv@lid", ANYSTR]) + + self.assertEqual( + nb_errors, + 1, + fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', " + "got {got}", + ) + + @cases.mark_capabilities("batch", "labeled-response") + def testJoinPartiallyInvalidLabeledResponse(self): + """TODO: specify this in Modern""" + self.connectClient( + "foo", capabilities=["batch", "labeled-response"], skip_if_cap_nak=True + ) + if int(self.targmax.get("JOIN") or "4") < 2: + raise runner.OptionalExtensionNotSupported("multi-channel JOIN") + + self.sendLine(1, "@label=label1 JOIN #valid,inv@lid") + messages = self.getMessages(1) + + first_msg = messages.pop(0) + last_msg = messages.pop(-1) + self.assertMessageMatch( + first_msg, command="BATCH", params=[StrRe(r"\+.*"), "labeled-response"] + ) + batch_id = first_msg.params[0][1:] + self.assertMessageMatch(last_msg, command="BATCH", params=["-" + batch_id]) + + received_commands = {m.command for m in messages} + expected_commands = {RPL_NAMREPLY, RPL_ENDOFNAMES, "JOIN"} + acceptable_commands = expected_commands | JOIN_ERROR_NUMERICS | {"MODE"} + self.assertLessEqual( + expected_commands, + received_commands, + "Server sent {} commands, but at least {} were expected.".format( + received_commands, expected_commands + ), + ) + self.assertLessEqual( + received_commands, + acceptable_commands, + "Server sent {} commands, but only {} were expected.".format( + received_commands, acceptable_commands + ), + ) + + nb_errors = 0 + for m in messages: + self.assertIn("batch", m.tags) + self.assertEqual(m.tags["batch"], batch_id) + if m.command in JOIN_ERROR_NUMERICS: + nb_errors += 1 + self.assertMessageMatch(m, params=["foo", "inv@lid", ANYSTR]) + + self.assertEqual( + nb_errors, + 1, + fail_msg="Expected 1 error when joining channels '#valid' and 'inv@lid', " + "got {got}", + ) diff --git a/irctest/server_tests/kick.py b/irctest/server_tests/kick.py index aaafcd5..ac511de 100644 --- a/irctest/server_tests/kick.py +++ b/irctest/server_tests/kick.py @@ -1,3 +1,10 @@ +""" +The KICK command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + import pytest from irctest import cases, client_mock, runner @@ -89,6 +96,10 @@ class KickTestCase(cases.BaseServerTestCase): self.assertMessageMatch(m3, command="KICK", params=["#chan", "bar", ANYSTR]) @cases.mark_specifications("RFC2812") + @cases.xfailIfSoftware( + ["Charybdis", "ircu2", "irc2", "Solanum"], + "uses the nick of the kickee rather than the kicker.", + ) def testKickDefaultComment(self): """ "If a "comment" is @@ -219,13 +230,8 @@ class KickTestCase(cases.BaseServerTestCase): self.connectClient("qux") self.joinChannel(4, "#chan") - targmax = dict( - item.split(":", 1) - for item in self.server_support.get("TARGMAX", "").split(",") - if item - ) - if targmax.get("KICK", "1") == "1": - raise runner.NotImplementedByController("Multi-target KICK") + if self.targmax.get("KICK", "1") == "1": + raise runner.OptionalExtensionNotSupported("Multi-target KICK") # TODO: check foo is an operator diff --git a/irctest/server_tests/labeled_responses.py b/irctest/server_tests/labeled_responses.py index acef9b9..b6e8bd9 100644 --- a/irctest/server_tests/labeled_responses.py +++ b/irctest/server_tests/labeled_responses.py @@ -1,8 +1,8 @@ """ +`IRCv3 labeled-response `_ + This specification is a little hard to test because all labels are optional; so there may be many false positives. - - """ import re @@ -11,10 +11,11 @@ import pytest from irctest import cases from irctest.numerics import ERR_UNKNOWNCOMMAND -from irctest.patma import ANYDICT, AnyOptStr, NotStrRe, RemainingKeys, StrRe +from irctest.patma import ANYDICT, ANYOPTSTR, NotStrRe, RemainingKeys, StrRe +from irctest.runner import OptionalExtensionNotSupported -class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class LabeledResponsesTestCase(cases.BaseServerTestCase): @cases.mark_capabilities("echo-message", "batch", "labeled-response") def testLabeledPrivmsgResponsesToMultipleClients(self): self.connectClient( @@ -22,7 +23,10 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper capabilities=["echo-message", "batch", "labeled-response"], skip_if_cap_nak=True, ) + if int(self.targmax.get("PRIVMSG", "1") or "4") < 3: + raise OptionalExtensionNotSupported("PRIVMSG to multiple targets") self.getMessages(1) + self.connectClient( "bar", capabilities=["echo-message", "batch", "labeled-response"], @@ -299,7 +303,7 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper tags={ "+draft/reply": msgid, "+draft/react": "l😃l", - RemainingKeys(NotStrRe("label")): AnyOptStr(), + RemainingKeys(NotStrRe("label")): ANYOPTSTR, }, ) self.assertNotIn( @@ -367,7 +371,7 @@ class LabeledResponsesTestCase(cases.BaseServerTestCase, cases.OptionalityHelper tags={ "+draft/reply": msgid, "+draft/react": "l😃l", - RemainingKeys(NotStrRe("label")): AnyOptStr(), + RemainingKeys(NotStrRe("label")): ANYOPTSTR, }, fail_msg="No TAGMSG received by the target after sending one out", ) diff --git a/irctest/server_tests/links.py b/irctest/server_tests/links.py new file mode 100644 index 0000000..4a77554 --- /dev/null +++ b/irctest/server_tests/links.py @@ -0,0 +1,136 @@ +from irctest import cases, runner +from irctest.numerics import ERR_UNKNOWNCOMMAND, RPL_ENDOFLINKS, RPL_LINKS +from irctest.patma import ANYSTR, StrRe + + +class LinksTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testLinksSingleServer(self): + """ + Only testing the parameter-less case. + + https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.3 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.5 + https://github.com/ircdocs/modern-irc/pull/175 + + " + 364 RPL_LINKS + " : " + 365 RPL_ENDOFLINKS + " :End of /LINKS list" + + - In replying to the LINKS message, a server must send + replies back using the RPL_LINKS numeric and mark the + end of the list using an RPL_ENDOFLINKS reply. + " + -- https://datatracker.ietf.org/doc/html/rfc1459#page-51 + -- https://datatracker.ietf.org/doc/html/rfc2812#page-48 + + RPL_LINKS: " * : " + RPL_ENDOFLINKS: " * :End of /LINKS list" + -- https://github.com/ircdocs/modern-irc/pull/175/files + """ + self.connectClient("nick") + self.sendLine(1, "LINKS") + messages = self.getMessages(1) + if messages[0].command == ERR_UNKNOWNCOMMAND: + raise runner.OptionalCommandNotSupported("LINKS") + + # Ignore '/LINKS has been disabled' from ircu2 + messages = [m for m in messages if m.command != "NOTICE"] + + self.assertMessageMatch( + messages.pop(-1), + command=RPL_ENDOFLINKS, + params=["nick", "*", ANYSTR], + ) + + if not messages: + # This server probably redacts links + return + + self.assertMessageMatch( + messages[0], + command=RPL_LINKS, + params=[ + "nick", + "My.Little.Server", + "My.Little.Server", + StrRe("0 (0042 )?test server"), + ], + ) + + +@cases.mark_services +class ServicesLinksTestCase(cases.BaseServerTestCase): + # On every IRCd but Ergo, services are linked. + # Ergo does not implement LINKS at all, so this test is skipped. + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testLinksWithServices(self): + """ + Only testing the parameter-less case. + + https://datatracker.ietf.org/doc/html/rfc1459#section-4.3.3 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.4.5 + + " + 364 RPL_LINKS + " : " + 365 RPL_ENDOFLINKS + " :End of /LINKS list" + + - In replying to the LINKS message, a server must send + replies back using the RPL_LINKS numeric and mark the + end of the list using an RPL_ENDOFLINKS reply. + " + -- https://datatracker.ietf.org/doc/html/rfc1459#page-51 + -- https://datatracker.ietf.org/doc/html/rfc2812#page-48 + + RPL_LINKS: " * : " + RPL_ENDOFLINKS: " * :End of /LINKS list" + -- https://github.com/ircdocs/modern-irc/pull/175/files + """ + self.connectClient("nick") + self.sendLine(1, "LINKS") + messages = self.getMessages(1) + + if messages[0].command == ERR_UNKNOWNCOMMAND: + raise runner.OptionalCommandNotSupported("LINKS") + + # Ignore '/LINKS has been disabled' from ircu2 + messages = [m for m in messages if m.command != "NOTICE"] + + self.assertMessageMatch( + messages.pop(-1), + command=RPL_ENDOFLINKS, + params=["nick", "*", ANYSTR], + ) + + if not messages: + # This server redacts links + return + + messages.sort(key=lambda m: m.params[-1]) + + self.assertMessageMatch( + messages.pop(0), + command=RPL_LINKS, + params=[ + "nick", + "My.Little.Server", + "My.Little.Server", + StrRe("0 (0042 )?test server"), + ], + ) + self.assertMessageMatch( + messages.pop(0), + command=RPL_LINKS, + params=[ + "nick", + "services.example.org", + "My.Little.Server", + StrRe("1 .+"), # SID instead of description for Anope... + ], + ) + + self.assertEqual(messages, []) diff --git a/irctest/server_tests/list.py b/irctest/server_tests/list.py index 0310903..2063acb 100644 --- a/irctest/server_tests/list.py +++ b/irctest/server_tests/list.py @@ -1,40 +1,71 @@ -from irctest import cases +""" +The LIST command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + +import time + +from irctest import cases, runner +from irctest.numerics import RPL_LIST, RPL_LISTEND, RPL_LISTSTART -class ListTestCase(cases.BaseServerTestCase): +class _BasedListTestCase(cases.BaseServerTestCase): + def _parseChanList(self, client): + channels = set() + while True: + m = self.getMessage(client) + if m.command == RPL_LISTEND: + break + if m.command == RPL_LIST: + if m.params[1].startswith("&"): + # skip local pseudo-channels listed by ngircd and ircu + continue + channels.add(m.params[1]) + + return channels + + +class ListTestCase(_BasedListTestCase): @cases.mark_specifications("RFC1459", "RFC2812") + @cases.xfailIfSoftware(["irc2"], "irc2 deprecated LIST") def testListEmpty(self): """ + """ self.connectClient("foo") self.connectClient("bar") self.getMessages(1) self.sendLine(2, "LIST") m = self.getMessage(2) - if m.command == "321": - # skip RPL_LISTSTART + if m.command == RPL_LISTSTART: + # skip m = self.getMessage(2) - while m.command == "322" and m.params[1] == "&SERVER": - # ngircd adds this pseudo-channel + # skip local pseudo-channels listed by ngircd and ircu + while m.command == RPL_LIST and m.params[1].startswith("&"): m = self.getMessage(2) self.assertNotEqual( m.command, - "322", # RPL_LIST + RPL_LIST, "LIST response gives (at least) one channel, whereas there " "is none.", ) self.assertMessageMatch( m, - command="323", # RPL_LISTEND + command=RPL_LISTEND, fail_msg="Second reply to LIST is not 322 (RPL_LIST) " "or 323 (RPL_LISTEND), or but: {msg}", ) @cases.mark_specifications("RFC1459", "RFC2812") + @cases.xfailIfSoftware(["irc2"], "irc2 deprecated LIST") def testListOne(self): """When a channel exists, LIST should get it in a reply. + + """ self.connectClient("foo") self.connectClient("bar") @@ -42,34 +73,331 @@ class ListTestCase(cases.BaseServerTestCase): self.getMessages(1) self.sendLine(2, "LIST") m = self.getMessage(2) - if m.command == "321": - # skip RPL_LISTSTART + if m.command == RPL_LISTSTART: + # skip m = self.getMessage(2) self.assertNotEqual( m.command, - "323", # RPL_LISTEND + RPL_LISTEND, fail_msg="LIST response ended (ie. 323, aka RPL_LISTEND) " "without listing any channel, whereas there is one.", ) self.assertMessageMatch( m, - command="322", # RPL_LIST + command=RPL_LIST, fail_msg="Second reply to LIST is not 322 (RPL_LIST), " "nor 323 (RPL_LISTEND) but: {msg}", ) m = self.getMessage(2) - while m.command == "322" and m.params[1] == "&SERVER": - # ngircd adds this pseudo-channel + # skip local pseudo-channels listed by ngircd and ircu + while m.command == RPL_LIST and m.params[1].startswith("&"): m = self.getMessage(2) self.assertNotEqual( m.command, - "322", # RPL_LIST + RPL_LIST, fail_msg="LIST response gives (at least) two channels, " "whereas there is only one.", ) self.assertMessageMatch( m, - command="323", # RPL_LISTEND + command=RPL_LISTEND, fail_msg="Third reply to LIST is not 322 (RPL_LIST) " "or 323 (RPL_LISTEND), or but: {msg}", ) + + @cases.mark_specifications("RFC1459", "RFC2812") + @cases.xfailIfSoftware( + ["Charybdis", "Solanum"], + "Charybdis and Solanum insert ERR_NOSUCHNICK reply in LIST", + ) + def testListNonexistent(self): + """LIST on a nonexistent channel does not send an error + response. + + + """ + self.connectClient("bar") + self.sendLine(1, "LIST #nonexistent") + responses = {msg.command for msg in self.getMessages(1)} + # successful response MUST include RPL_LISTEND: + self.assertIn(RPL_LISTEND, responses) + # and MUST NOT include RPL_LIST (since there is no matching channel) + # or any error numerics: + self.assertLessEqual(responses, {RPL_LISTSTART, RPL_LISTEND}) + + @cases.mark_isupport("ELIST") + @cases.mark_specifications("Modern") + def testListMask(self): + """ + "M: Searching based on mask." + -- + -- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8 + """ + self.connectClient("foo") + + if "M" not in self.server_support.get("ELIST", ""): + raise runner.OptionalExtensionNotSupported("ELIST=M") + + self.connectClient("bar") + self.sendLine(1, "JOIN #chan1") + self.getMessages(1) + self.sendLine(1, "JOIN #chan2") + self.getMessages(1) + + self.sendLine(2, "LIST *an1") + self.assertEqual(self._parseChanList(2), {"#chan1"}) + + self.sendLine(2, "LIST *an2") + self.assertEqual(self._parseChanList(2), {"#chan2"}) + + self.sendLine(2, "LIST #c*n2") + self.assertEqual(self._parseChanList(2), {"#chan2"}) + + self.sendLine(2, "LIST *an3") + self.assertEqual(self._parseChanList(2), set()) + + self.sendLine(2, "LIST #ch*") + self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"}) + + @cases.mark_isupport("ELIST") + @cases.mark_specifications("Modern") + def testListNotMask(self): + """ + " N: Searching based on a non-matching mask. i.e., the opposite of M." + -- + -- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8 + """ + self.connectClient("foo") + + if "N" not in self.server_support.get("ELIST", ""): + raise runner.OptionalExtensionNotSupported("ELIST=N") + + self.sendLine(1, "JOIN #chan1") + self.getMessages(1) + self.sendLine(1, "JOIN #chan2") + self.getMessages(1) + + self.connectClient("bar") + + self.sendLine(2, "LIST !*an1") + self.assertEqual(self._parseChanList(2), {"#chan2"}) + + self.sendLine(2, "LIST !*an2") + self.assertEqual(self._parseChanList(2), {"#chan1"}) + + self.sendLine(2, "LIST !#c*n2") + self.assertEqual(self._parseChanList(2), {"#chan1"}) + + self.sendLine(2, "LIST !*an3") + self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"}) + + self.sendLine(2, "LIST !#ch*") + self.assertEqual(self._parseChanList(2), set()) + + @cases.mark_isupport("ELIST") + @cases.mark_specifications("Modern") + def testListUsers(self): + """ + "U: Searching based on user count within the channel, via the "val" modifiers to search for a channel that has less or more than val users, + respectively." + -- + -- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8 + """ + self.connectClient("foo") + + if "U" not in self.server_support.get("ELIST", ""): + raise runner.OptionalExtensionNotSupported("ELIST=U") + + self.sendLine(1, "JOIN #chan1") + self.getMessages(1) + self.sendLine(1, "JOIN #chan2") + self.getMessages(1) + + self.connectClient("bar") + self.sendLine(2, "JOIN #chan2") + self.getMessages(2) + + self.connectClient("baz") + + self.sendLine(3, "LIST >0") + self.assertEqual(self._parseChanList(3), {"#chan1", "#chan2"}) + + self.sendLine(3, "LIST <1") + self.assertEqual(self._parseChanList(3), set()) + + self.sendLine(3, "LIST <100") + self.assertEqual(self._parseChanList(3), {"#chan1", "#chan2"}) + + self.sendLine(3, "LIST >1") + self.assertEqual(self._parseChanList(3), {"#chan2"}) + + self.sendLine(3, "LIST <2") + self.assertEqual(self._parseChanList(3), {"#chan1"}) + + self.sendLine(3, "LIST <100") + self.assertEqual(self._parseChanList(3), {"#chan1", "#chan2"}) + + +class FaketimeListTestCase(_BasedListTestCase): + faketime = "+1y x30" # for every wall clock second, 1 minute passed for the server + + def _sleep_minutes(self, n): + for _ in range(n): + if self.controller.faketime_enabled: + # From the server's point of view, 1 min will pass + time.sleep(2) + else: + time.sleep(60) + + # reply to pings + self.getMessages(1) + self.getMessages(2) + + @cases.mark_isupport("ELIST") + @cases.mark_specifications("Modern") + @cases.xfailIfSoftware( + ["Plexus4", "Hybrid"], + "Hybrid and Plexus4 filter on ELIST=C with the opposite meaning", + ) + @cases.xfailIf( + lambda self: bool( + self.controller.software_name == "UnrealIRCd" + and self.controller.software_version == 5 + ), + "UnrealIRCd <6.0.3 filters on ELIST=C with the opposite meaning", + ) + def testListCreationTime(self): + """ + " C: Searching based on channel creation time, via the "Cval" + modifiers to search for a channel creation time that is higher or lower + than val." + -- + -- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8 + + Unfortunately, this is ambiguous, because "val" is a time delta (in minutes), + not a timestamp. + + On InspIRCd and Charybdis/Solanum, "C minutes ago + + On UnrealIRCd, Plexus, and Hybrid, it is interpreted as "the channel's creation + time is a timestamp lower than minutes ago" (ie. the exact opposite) + + "C: Searching based on channel creation time, via the "Cval" + modifiers to search for a channel that was created either less than `val` + minutes ago, or more than `val` minutes ago, respectively" + -- https://github.com/ircdocs/modern-irc/pull/171 + """ + self.connectClient("foo") + + if "C" not in self.server_support.get("ELIST", ""): + raise runner.OptionalExtensionNotSupported("ELIST=C") + + self.connectClient("bar") + self.sendLine(1, "JOIN #chan1") + self.getMessages(1) + + # Helps debugging + self.sendLine(1, "TIME") + self.getMessages(1) + + self._sleep_minutes(2) + + # Helps debugging + self.sendLine(1, "TIME") + self.getMessages(1) + + self.sendLine(1, "JOIN #chan2") + self.getMessages(1) + + self._sleep_minutes(1) + + self.sendLine(2, "LIST C>2") + self.assertEqual(self._parseChanList(2), {"#chan1"}) + + self.sendLine(2, "LIST C<2") + self.assertEqual(self._parseChanList(2), {"#chan2"}) + + self.sendLine(2, "LIST C<0") + if self.controller.software_name == "InspIRCd": + self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"}) + else: + self.assertEqual(self._parseChanList(2), set()) + + self.sendLine(2, "LIST C>0") + self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"}) + + self.sendLine(2, "LIST C<10") + self.assertEqual(self._parseChanList(2), {"#chan1", "#chan2"}) + + @cases.mark_isupport("ELIST") + @cases.mark_specifications("Modern") + @cases.xfailIf( + lambda self: bool( + self.controller.software_name == "UnrealIRCd" + and self.controller.software_version == 5 + ), + "UnrealIRCd <6.0.3 advertises ELIST=T but does not implement it", + ) + def testListTopicTime(self): + """ + "T: Searching based on topic time, via the "Tval" + modifiers to search for a topic time that is lower or higher than + val respectively." + -- + -- https://datatracker.ietf.org/doc/html/draft-hardy-irc-isupport-00#section-4.8 + + See testListCreationTime's docstring for comments on this. + + "T: Searching based on topic set time, via the "Tval" modifiers + to search for a topic time that was set less than `val` minutes ago, or more + than `val` minutes ago, respectively." + -- https://github.com/ircdocs/modern-irc/pull/171 + """ + self.connectClient("foo") + + if "T" not in self.server_support.get("ELIST", ""): + raise runner.OptionalExtensionNotSupported("ELIST=T") + + self.connectClient("bar") + self.sendLine(1, "JOIN #chan1") + self.sendLine(1, "JOIN #chan2") + self.getMessages(1) + + self.sendLine(1, "TOPIC #chan1 :First channel") + self.getMessages(1) + + # Helps debugging + self.sendLine(1, "TIME") + self.getMessages(1) + + self._sleep_minutes(2) + + # Helps debugging + self.sendLine(1, "TIME") + self.getMessages(1) + + self.sendLine(1, "TOPIC #chan2 :Second channel") + self.getMessages(1) + + self._sleep_minutes(1) + + self.sendLine(1, "LIST T>2") + self.assertEqual(self._parseChanList(1), {"#chan1"}) + + self.sendLine(1, "LIST T<2") + self.assertEqual(self._parseChanList(1), {"#chan2"}) + + self.sendLine(1, "LIST T<0") + if self.controller.software_name == "InspIRCd": + # Insp internally represents "LIST T>0" like "LIST" + self.assertEqual(self._parseChanList(1), {"#chan1", "#chan2"}) + else: + self.assertEqual(self._parseChanList(1), set()) + + self.sendLine(1, "LIST T>0") + self.assertEqual(self._parseChanList(1), {"#chan1", "#chan2"}) + + self.sendLine(1, "LIST T<10") + self.assertEqual(self._parseChanList(1), {"#chan1", "#chan2"}) diff --git a/irctest/server_tests/lusers.py b/irctest/server_tests/lusers.py index 58f3601..786c582 100644 --- a/irctest/server_tests/lusers.py +++ b/irctest/server_tests/lusers.py @@ -1,3 +1,11 @@ +""" +The LUSERS command (`RFC 2812 +`__, +`Modern `__), +which provides statistics on user counts. +""" + + from dataclasses import dataclass import re from typing import Optional @@ -145,6 +153,10 @@ class BasicLusersTestCase(LusersTestCase): self.getLusers("bar", True) @cases.mark_specifications("Modern") + @cases.xfailIfSoftware( + ["ircu2", "Nefarious", "snircd"], + "test depends on Modern behavior, not just RFC2812", + ) def testLusersFull(self): self.connectClient("bar", name="bar") lusers = self.getLusers("bar", False) @@ -162,10 +174,22 @@ class BasicLusersTestCase(LusersTestCase): class LusersUnregisteredTestCase(LusersTestCase): @cases.mark_specifications("RFC2812") + @cases.xfailIfSoftware( + ["Nefarious"], + "Nefarious doesn't seem to distinguish unregistered users from normal ones", + ) def testLusersRfc2812(self): self.doLusersTest(True) @cases.mark_specifications("Modern") + @cases.xfailIfSoftware( + ["Nefarious"], + "Nefarious doesn't seem to distinguish unregistered users from normal ones", + ) + @cases.xfailIfSoftware( + ["ircu2", "Nefarious", "snircd"], + "test depends on Modern behavior, not just RFC2812", + ) def testLusersFull(self): self.doLusersTest(False) @@ -229,6 +253,10 @@ class LusersUnregisteredDefaultInvisibleTestCase(LusersUnregisteredTestCase): ) @cases.mark_specifications("Ergo") + @cases.xfailIfSoftware( + ["Nefarious"], + "Nefarious doesn't seem to distinguish unregistered users from normal ones", + ) def testLusers(self): self.doLusersTest(False) lusers = self.getLusers("bar", False) diff --git a/irctest/server_tests/message_tags.py b/irctest/server_tests/message_tags.py index 9d7ba14..b5e8071 100644 --- a/irctest/server_tests/message_tags.py +++ b/irctest/server_tests/message_tags.py @@ -1,5 +1,5 @@ """ -https://ircv3.net/specs/extensions/message-tags.html +`IRCv3 message-tags `_ """ import pytest @@ -10,7 +10,7 @@ from irctest.numerics import ERR_INPUTTOOLONG from irctest.patma import ANYDICT, ANYSTR, StrRe -class MessageTagsTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class MessageTagsTestCase(cases.BaseServerTestCase): @pytest.mark.arbitrary_client_tags @cases.mark_capabilities("message-tags") def testBasic(self): diff --git a/irctest/server_tests/messages.py b/irctest/server_tests/messages.py index 7f26025..520fd13 100644 --- a/irctest/server_tests/messages.py +++ b/irctest/server_tests/messages.py @@ -1,10 +1,10 @@ """ -Section 3.2 of RFC 2812 - +The PRIVMSG and NOTICE commands. """ from irctest import cases from irctest.numerics import ERR_INPUTTOOLONG +from irctest.patma import ANYSTR class PrivmsgTestCase(cases.BaseServerTestCase): @@ -13,6 +13,7 @@ class PrivmsgTestCase(cases.BaseServerTestCase): """""" self.connectClient("foo") self.sendLine(1, "JOIN #chan") + self.getMessages(1) # synchronize self.connectClient("bar") self.sendLine(2, "JOIN #chan") self.getMessages(2) # synchronize @@ -33,6 +34,48 @@ class PrivmsgTestCase(cases.BaseServerTestCase): # ERR_NOSUCHNICK, ERR_NOSUCHCHANNEL, or ERR_CANNOTSENDTOCHAN self.assertIn(msg.command, ("401", "403", "404")) + @cases.mark_specifications("RFC1459", "RFC2812") + def testPrivmsgToUser(self): + """""" + self.connectClient("foo") + self.connectClient("bar") + self.sendLine(1, "PRIVMSG bar :hey there!") + self.getMessages(1) + pms = [msg for msg in self.getMessages(2) if msg.command == "PRIVMSG"] + self.assertEqual(len(pms), 1) + self.assertMessageMatch(pms[0], command="PRIVMSG", params=["bar", "hey there!"]) + + @cases.mark_specifications("RFC1459", "RFC2812") + def testPrivmsgNonexistentUser(self): + """""" + self.connectClient("foo") + self.sendLine(1, "PRIVMSG bar :hey there!") + msg = self.getMessage(1) + # ERR_NOSUCHNICK: 401 :No such nick + self.assertMessageMatch(msg, command="401", params=["foo", "bar", ANYSTR]) + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + @cases.xfailIfSoftware( + ["irc2"], + "replies with ERR_NEEDMOREPARAMS instead of ERR_NOTEXTTOSEND", + ) + def testEmptyPrivmsg(self): + self.connectClient("foo") + self.sendLine(1, "JOIN #chan") + self.getMessages(1) # synchronize + self.connectClient("bar") + self.sendLine(2, "JOIN #chan") + self.getMessages(2) # synchronize + self.getMessages(1) # synchronize + self.sendLine(1, "PRIVMSG #chan :") + + self.assertMessageMatch( + self.getMessage(1), + command="412", # ERR_NOTEXTTOSEND + params=["foo", ANYSTR], + ) + self.assertEqual(self.getMessages(2), []) + class NoticeTestCase(cases.BaseServerTestCase): @cases.mark_specifications("RFC1459", "RFC2812") @@ -52,6 +95,15 @@ class NoticeTestCase(cases.BaseServerTestCase): ) @cases.mark_specifications("RFC1459", "RFC2812") + @cases.xfailIfSoftware( + ["InspIRCd"], + "replies with ERR_NOSUCHCHANNEL to NOTICE to non-existent channels", + ) + @cases.xfailIfSoftware( + ["UnrealIRCd"], + "replies with ERR_NOSUCHCHANNEL to NOTICE to non-existent channels: " + "https://bugs.unrealircd.org/view.php?id=5949", + ) def testNoticeNonexistentChannel(self): """ "automatic replies must never be @@ -72,6 +124,14 @@ class NoticeTestCase(cases.BaseServerTestCase): class TagsTestCase(cases.BaseServerTestCase): @cases.mark_capabilities("message-tags") + @cases.xfailIf( + lambda self: bool( + self.controller.software_name == "UnrealIRCd" + and self.controller.software_version == 5 + ), + "UnrealIRCd <6.0.7 dropped messages with excessively large tags: " + "https://bugs.unrealircd.org/view.php?id=5947", + ) def testLineTooLong(self): self.connectClient("bar", capabilities=["message-tags"], skip_if_cap_nak=True) self.connectClient( diff --git a/irctest/server_tests/metadata.py b/irctest/server_tests/metadata.py index 6317c5e..555b6b4 100644 --- a/irctest/server_tests/metadata.py +++ b/irctest/server_tests/metadata.py @@ -1,14 +1,13 @@ """ -Tests METADATA features. - +`Deprecated IRCv3 Metadata `_ """ from irctest import cases class MetadataTestCase(cases.BaseServerTestCase): - valid_metadata_keys = {"valid_key1", "valid_key2"} - invalid_metadata_keys = {"invalid_key1", "invalid_key2"} + valid_metadata_keys = {"display-name", "avatar"} + invalid_metadata_keys = {"indisplay-name", "inavatar"} @cases.mark_specifications("IRCv3", deprecated=True) def testInIsupport(self): @@ -37,7 +36,7 @@ class MetadataTestCase(cases.BaseServerTestCase): def testGetOneUnsetValid(self): """""" self.connectClient("foo") - self.sendLine(1, "METADATA * GET valid_key1") + self.sendLine(1, "METADATA * GET display-name") m = self.getMessage(1) self.assertMessageMatch( m, @@ -53,7 +52,7 @@ class MetadataTestCase(cases.BaseServerTestCase): -- """ self.connectClient("foo") - self.sendLine(1, "METADATA * GET valid_key1 valid_key2") + self.sendLine(1, "METADATA * GET display-name avatar") m = self.getMessage(1) self.assertMessageMatch( m, @@ -63,10 +62,10 @@ class MetadataTestCase(cases.BaseServerTestCase): ) self.assertEqual( m.params[1], - "valid_key1", + "display-name", m, - fail_msg="Response to “METADATA * GET valid_key1 valid_key2” " - "did not respond to valid_key1 first: {msg}", + fail_msg="Response to “METADATA * GET display-name avatar” " + "did not respond to display-name first: {msg}", ) m = self.getMessage(1) self.assertMessageMatch( @@ -77,10 +76,10 @@ class MetadataTestCase(cases.BaseServerTestCase): ) self.assertEqual( m.params[1], - "valid_key2", + "avatar", m, - fail_msg="Response to “METADATA * GET valid_key1 valid_key2” " - "did not respond to valid_key2 as second response: {msg}", + fail_msg="Response to “METADATA * GET display-name avatar” " + "did not respond to avatar as second response: {msg}", ) @cases.mark_specifications("IRCv3", deprecated=True) @@ -136,7 +135,7 @@ class MetadataTestCase(cases.BaseServerTestCase): ) self.assertEqual( m.params[1], - "valid_key1", + "display-name", m, fail_msg="Second param of 761 after setting “{expects}” to " "“{}” is not “{expects}”: {msg}.", @@ -191,7 +190,7 @@ class MetadataTestCase(cases.BaseServerTestCase): def testSetGetValid(self): """""" self.connectClient("foo") - self.assertSetGetValue("*", "valid_key1", "myvalue") + self.assertSetGetValue("*", "display-name", "myvalue") @cases.mark_specifications("IRCv3", deprecated=True) def testSetGetZeroCharInValue(self): @@ -199,7 +198,7 @@ class MetadataTestCase(cases.BaseServerTestCase): -- """ self.connectClient("foo") - self.assertSetGetValue("*", "valid_key1", "zero->\0<-zero", "zero->\\0<-zero") + self.assertSetGetValue("*", "display-name", "zero->\0<-zero", "zero->\\0<-zero") @cases.mark_specifications("IRCv3", deprecated=True) def testSetGetHeartInValue(self): @@ -210,7 +209,7 @@ class MetadataTestCase(cases.BaseServerTestCase): self.connectClient("foo") self.assertSetGetValue( "*", - "valid_key1", + "display-name", "->{}<-".format(heart), "zero->{}<-zero".format(heart.encode()), ) @@ -224,7 +223,7 @@ class MetadataTestCase(cases.BaseServerTestCase): # Sending directly because it is not valid UTF-8 so Python would # not like it self.clients[1].conn.sendall( - b"METADATA * SET valid_key1 " b":invalid UTF-8 ->\xc3<-\r\n" + b"METADATA * SET display-name " b":invalid UTF-8 ->\xc3<-\r\n" ) commands = {m.command for m in self.getMessages(1)} self.assertNotIn( @@ -234,7 +233,7 @@ class MetadataTestCase(cases.BaseServerTestCase): "UTF-8 was answered with 761 (RPL_KEYVALUE)", ) self.clients[1].conn.sendall( - b"METADATA * SET valid_key1 " b":invalid UTF-8: \xc3\r\n" + b"METADATA * SET display-name " b":invalid UTF-8: \xc3\r\n" ) commands = {m.command for m in self.getMessages(1)} self.assertNotIn( diff --git a/irctest/server_tests/monitor.py b/irctest/server_tests/monitor.py index 56e0306..f5bd808 100644 --- a/irctest/server_tests/monitor.py +++ b/irctest/server_tests/monitor.py @@ -1,9 +1,11 @@ """ - +`IRCv3 MONITOR `_ +and `IRCv3 extended-monitor` `_ """ -from irctest import cases -from irctest.basecontrollers import NotImplementedByController +import pytest + +from irctest import cases, runner from irctest.client_mock import NoMessageException from irctest.numerics import ( RPL_ENDOFMONLIST, @@ -14,10 +16,10 @@ from irctest.numerics import ( from irctest.patma import ANYSTR, StrRe -class MonitorTestCase(cases.BaseServerTestCase): +class _BaseMonitorTestCase(cases.BaseServerTestCase): def check_server_support(self): if "MONITOR" not in self.server_support: - raise NotImplementedByController("MONITOR") + raise runner.IsupportTokenNotSupported("MONITOR") def assertMononline(self, client, nick, m=None): if not m: @@ -43,6 +45,8 @@ class MonitorTestCase(cases.BaseServerTestCase): extra_format=(nick,), ) + +class MonitorTestCase(_BaseMonitorTestCase): @cases.mark_specifications("IRCv3") @cases.mark_isupport("MONITOR") def testMonitorOneDisconnected(self): @@ -245,6 +249,23 @@ class MonitorTestCase(cases.BaseServerTestCase): extra_format=(messages,), ) + @cases.mark_specifications("IRCv3") + @cases.mark_isupport("MONITOR") + def testMonitorClear(self): + """“Clears the list of targets being monitored. No output will be returned + for use of this command.“ + -- + """ + self.connectClient("foo") + self.check_server_support() + self.sendLine(1, "MONITOR + bar") + self.getMessages(1) + + self.sendLine(1, "MONITOR C") + self.sendLine(1, "MONITOR L") + m = self.getMessage(1) + self.assertEqual(m.command, RPL_ENDOFMONLIST) + @cases.mark_specifications("IRCv3") @cases.mark_isupport("MONITOR") def testMonitorList(self): @@ -280,6 +301,35 @@ class MonitorTestCase(cases.BaseServerTestCase): self.sendLine(1, "MONITOR L") checkMonitorSubjects(self.getMessages(1), "bar", {"bazbat"}) + @cases.mark_specifications("IRCv3") + @cases.mark_isupport("MONITOR") + def testMonitorStatus(self): + """“Outputs for each target in the list being monitored, whether + the client is online or offline. All targets that are online will + be sent using RPL_MONONLINE, all targets that are offline will be + sent using RPL_MONOFFLINE.“ + -- + """ + self.connectClient("foo") + self.check_server_support() + self.connectClient("bar") + self.sendLine(1, "MONITOR + bar,baz") + self.getMessages(1) + + self.sendLine(1, "MONITOR S") + msgs = self.getMessages(1) + self.assertEqual( + len(msgs), + 2, + fail_msg="Expected one RPL_MONONLINE (730) and one RPL_MONOFFLINE (731), got: {}", + extra_format=(msgs,), + ) + + msgs.sort(key=lambda m: m.command) + + self.assertMononline(1, "bar", m=msgs[0]) + self.assertMonoffline(1, "baz", m=msgs[1]) + @cases.mark_specifications("IRCv3") @cases.mark_isupport("MONITOR") def testNickChange(self): @@ -296,10 +346,11 @@ class MonitorTestCase(cases.BaseServerTestCase): self.sendLine(2, "NICK qux") self.getMessages(2) mononline = self.getMessages(1)[0] - self.assertEqual(mononline.command, RPL_MONONLINE) - self.assertEqual(len(mononline.params), 2, mononline.params) - self.assertIn(mononline.params[0], ("bar", "*")) - self.assertEqual(mononline.params[1].split("!")[0], "qux") + self.assertMessageMatch( + mononline, + command=RPL_MONONLINE, + params=[StrRe(r"(bar|\*)"), StrRe("qux(!.*)?")], + ) # no numerics for a case change self.sendLine(2, "NICK QUX") @@ -310,7 +361,246 @@ class MonitorTestCase(cases.BaseServerTestCase): self.getMessages(2) monoffline = self.getMessages(1)[0] # should get RPL_MONOFFLINE with the current unfolded nick - self.assertEqual(monoffline.command, RPL_MONOFFLINE) - self.assertEqual(len(monoffline.params), 2, monoffline.params) - self.assertIn(monoffline.params[0], ("bar", "*")) - self.assertEqual(monoffline.params[1].split("!")[0], "QUX") + self.assertMessageMatch( + monoffline, + command=RPL_MONOFFLINE, + params=[StrRe(r"(bar|\*)"), "QUX"], + ) + + +class _BaseExtendedMonitorTestCase(_BaseMonitorTestCase): + def _setupExtendedMonitor(self, monitor_before_connect, watcher_caps, watched_caps): + """Tests https://ircv3.net/specs/extensions/extended-monitor.html""" + self.connectClient( + "foo", + capabilities=["extended-monitor", *watcher_caps], + skip_if_cap_nak=True, + ) + + if monitor_before_connect: + self.sendLine(1, "MONITOR + bar") + self.getMessages(1) + self.connectClient("bar", capabilities=watched_caps, skip_if_cap_nak=True) + self.getMessages(2) + else: + self.connectClient("bar", capabilities=watched_caps, skip_if_cap_nak=True) + self.getMessages(2) + self.sendLine(1, "MONITOR + bar") + + self.assertMononline(1, "bar") + self.assertEqual(self.getMessages(1), []) + + +class ExtendedMonitorTestCase(_BaseExtendedMonitorTestCase): + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("extended-monitor", "away-notify") + @pytest.mark.parametrize( + "monitor_before_connect,cap", + [ + pytest.param( + monitor_before_connect, + cap, + id=("monitor_before_connect" if monitor_before_connect else "") + + "-" + + ("with-cap" if cap else ""), + ) + for monitor_before_connect in [True, False] + for cap in [True, False] + ], + ) + def testExtendedMonitorAway(self, monitor_before_connect, cap): + """Tests https://ircv3.net/specs/extensions/extended-monitor.html + with https://ircv3.net/specs/extensions/away-notify + """ + if cap: + self._setupExtendedMonitor( + monitor_before_connect, ["away-notify"], ["away-notify"] + ) + else: + self._setupExtendedMonitor(monitor_before_connect, ["away-notify"], []) + + self.sendLine(2, "AWAY :afk") + self.getMessages(2) + self.assertMessageMatch( + self.getMessage(1), nick="bar", command="AWAY", params=["afk"] + ) + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") + + self.sendLine(2, "AWAY") + self.getMessages(2) + self.assertMessageMatch( + self.getMessage(1), nick="bar", command="AWAY", params=[] + ) + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") + + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("extended-monitor", "away-notify") + @pytest.mark.parametrize( + "monitor_before_connect,cap", + [ + pytest.param( + monitor_before_connect, + cap, + id=("monitor_before_connect" if monitor_before_connect else "") + + "-" + + ("with-cap" if cap else ""), + ) + for monitor_before_connect in [True, False] + for cap in [True, False] + ], + ) + def testExtendedMonitorAwayNoCap(self, monitor_before_connect, cap): + """Tests https://ircv3.net/specs/extensions/extended-monitor.html + does nothing when ``away-notify`` is not enabled by the watcher + """ + if cap: + self._setupExtendedMonitor(monitor_before_connect, [], ["away-notify"]) + else: + self._setupExtendedMonitor(monitor_before_connect, [], []) + + self.sendLine(2, "AWAY :afk") + self.getMessages(2) + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") + + self.sendLine(2, "AWAY") + self.getMessages(2) + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") + + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("extended-monitor", "setname") + @pytest.mark.parametrize("monitor_before_connect", [True, False]) + def testExtendedMonitorSetName(self, monitor_before_connect): + """Tests https://ircv3.net/specs/extensions/extended-monitor.html + with https://ircv3.net/specs/extensions/setname + """ + self._setupExtendedMonitor(monitor_before_connect, ["setname"], ["setname"]) + + self.sendLine(2, "SETNAME :new name") + self.getMessages(2) + self.assertMessageMatch( + self.getMessage(1), nick="bar", command="SETNAME", params=["new name"] + ) + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") + + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("extended-monitor", "setname") + @pytest.mark.parametrize("monitor_before_connect", [True, False]) + def testExtendedMonitorSetNameNoCap(self, monitor_before_connect): + """Tests https://ircv3.net/specs/extensions/extended-monitor.html + does nothing when ``setname`` is not enabled by the watcher + """ + self._setupExtendedMonitor(monitor_before_connect, [], ["setname"]) + + self.sendLine(2, "SETNAME :new name") + self.getMessages(2) + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") + + +@cases.mark_services +class AuthenticatedExtendedMonitorTestCase(_BaseExtendedMonitorTestCase): + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("extended-monitor", "account-notify") + @pytest.mark.parametrize( + "monitor_before_connect,cap", + [ + pytest.param( + monitor_before_connect, + cap, + id=("monitor_before_connect" if monitor_before_connect else "") + + "-" + + ("with-cap" if cap else ""), + ) + for monitor_before_connect in [True, False] + for cap in [True, False] + ], + ) + def testExtendedMonitorAccountNotify(self, monitor_before_connect, cap): + """Tests https://ircv3.net/specs/extensions/extended-monitor.html + does nothing when ``account-notify`` is not enabled by the watcher + """ + self.controller.registerUser(self, "jilles", "sesame") + + if cap: + self._setupExtendedMonitor( + monitor_before_connect, + ["account-notify"], + ["account-notify", "sasl", "cap-notify"], + ) + else: + self._setupExtendedMonitor( + monitor_before_connect, ["account-notify"], ["sasl", "cap-notify"] + ) + + self.sendLine(2, "AUTHENTICATE PLAIN") + m = self.getRegistrationMessage(2) + self.assertMessageMatch( + m, + command="AUTHENTICATE", + params=["+"], + fail_msg="Sent “AUTHENTICATE PLAIN”, server should have " + "replied with “AUTHENTICATE +”, but instead sent: {msg}", + ) + self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=") + m = self.getRegistrationMessage(2) + self.assertMessageMatch( + m, + command="900", + fail_msg="Did not send 900 after correct SASL authentication.", + ) + self.getMessages(2) + + self.assertMessageMatch( + self.getMessage(1), nick="bar", command="ACCOUNT", params=["jilles"] + ) + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") + + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("extended-monitor", "account-notify") + @pytest.mark.parametrize( + "monitor_before_connect,cap", + [ + pytest.param( + monitor_before_connect, + cap, + id=("monitor_before_connect" if monitor_before_connect else "") + + "-" + + ("with-cap" if cap else ""), + ) + for monitor_before_connect in [True, False] + for cap in [True, False] + ], + ) + def testExtendedMonitorAccountNotifyNoCap(self, monitor_before_connect, cap): + """Tests https://ircv3.net/specs/extensions/extended-monitor.html + does nothing when ``account-notify`` is not enabled by the watcher + """ + self.controller.registerUser(self, "jilles", "sesame") + + if cap: + self._setupExtendedMonitor( + monitor_before_connect, [], ["account-notify", "sasl", "cap-notify"] + ) + else: + self._setupExtendedMonitor( + monitor_before_connect, [], ["sasl", "cap-notify"] + ) + + self.sendLine(2, "AUTHENTICATE PLAIN") + m = self.getRegistrationMessage(2) + self.assertMessageMatch( + m, + command="AUTHENTICATE", + params=["+"], + fail_msg="Sent “AUTHENTICATE PLAIN”, server should have " + "replied with “AUTHENTICATE +”, but instead sent: {msg}", + ) + self.sendLine(2, "AUTHENTICATE amlsbGVzAGppbGxlcwBzZXNhbWU=") + m = self.getRegistrationMessage(2) + self.assertMessageMatch( + m, + command="900", + fail_msg="Did not send 900 after correct SASL authentication.", + ) + self.getMessages(2) + + self.assertEqual(self.getMessages(1), [], "watcher got unexpected messages") diff --git a/irctest/server_tests/multi_prefix.py b/irctest/server_tests/multi_prefix.py index 02e7aa9..ed57b9c 100644 --- a/irctest/server_tests/multi_prefix.py +++ b/irctest/server_tests/multi_prefix.py @@ -1,6 +1,5 @@ """ -Tests multi-prefix. - +`IRCv3 multi-prefix `_ """ from irctest import cases @@ -16,7 +15,7 @@ class MultiPrefixTestCase(cases.BaseServerTestCase): These prefixes MUST be in order of ‘rank’, from highest to lowest. """ - self.connectClient("foo", capabilities=["multi-prefix"]) + self.connectClient("foo", capabilities=["multi-prefix"], skip_if_cap_nak=True) self.joinChannel(1, "#chan") self.sendLine(1, "MODE #chan +v foo") self.getMessages(1) diff --git a/irctest/server_tests/multiline.py b/irctest/server_tests/multiline.py index 7e872df..8c397ea 100644 --- a/irctest/server_tests/multiline.py +++ b/irctest/server_tests/multiline.py @@ -1,5 +1,5 @@ """ -draft/multiline +`Draft IRCv3 multiline `_ """ from irctest import cases @@ -12,7 +12,7 @@ CONCAT_TAG = "draft/multiline-concat" base_caps = ["message-tags", "batch", "echo-message", "server-time", "labeled-response"] -class MultilineTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class MultilineTestCase(cases.BaseServerTestCase): @cases.mark_capabilities("draft/multiline") def testBasic(self): self.connectClient( diff --git a/irctest/server_tests/names.py b/irctest/server_tests/names.py index 628b2ac..f45731a 100644 --- a/irctest/server_tests/names.py +++ b/irctest/server_tests/names.py @@ -1,9 +1,114 @@ -from irctest import cases -from irctest.numerics import RPL_ENDOFNAMES -from irctest.patma import ANYSTR +""" +The NAMES command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + +from irctest import cases, runner +from irctest.numerics import RPL_ENDOFNAMES, RPL_NAMREPLY +from irctest.patma import ANYSTR, StrRe class NamesTestCase(cases.BaseServerTestCase): + def _testNames(self, symbol): + self.connectClient("nick1") + self.sendLine(1, "JOIN #chan") + self.getMessages(1) + self.connectClient("nick2") + self.sendLine(2, "JOIN #chan") + self.getMessages(2) + self.getMessages(1) + + self.sendLine(1, "NAMES #chan") + + # TODO: It is technically allowed to have one line for each; + # but noone does that. + self.assertMessageMatch( + self.getMessage(1), + command=RPL_NAMREPLY, + params=[ + "nick1", + *(["="] if symbol else []), + "#chan", + StrRe("(nick2 @nick1|@nick1 nick2)"), + ], + ) + + self.assertMessageMatch( + self.getMessage(1), + command=RPL_ENDOFNAMES, + params=["nick1", "#chan", ANYSTR], + ) + + @cases.mark_specifications("RFC1459", deprecated=True) + def testNames1459(self): + """ + https://modern.ircdocs.horse/#names-message + https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNames(symbol=False) + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testNames2812(self): + """ + https://modern.ircdocs.horse/#names-message + https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNames(symbol=True) + + def _testNamesMultipleChannels(self, symbol): + self.connectClient("nick1") + + if self.targmax.get("NAMES", "1") == "1": + raise runner.OptionalExtensionNotSupported("Multi-target NAMES") + + self.sendLine(1, "JOIN #chan1") + self.sendLine(1, "JOIN #chan2") + self.getMessages(1) + + self.sendLine(1, "NAMES #chan1,#chan2") + + # TODO: order is unspecified + self.assertMessageMatch( + self.getMessage(1), + command=RPL_NAMREPLY, + params=["nick1", *(["="] if symbol else []), "#chan1", "@nick1"], + ) + self.assertMessageMatch( + self.getMessage(1), + command=RPL_NAMREPLY, + params=["nick1", *(["="] if symbol else []), "#chan2", "@nick1"], + ) + + self.assertMessageMatch( + self.getMessage(1), + command=RPL_ENDOFNAMES, + params=["nick1", "#chan1,#chan2", ANYSTR], + ) + + @cases.mark_isupport("TARGMAX") + @cases.mark_specifications("RFC1459", deprecated=True) + def testNamesMultipleChannels1459(self): + """ + https://modern.ircdocs.horse/#names-message + https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNamesMultipleChannels(symbol=False) + + @cases.mark_isupport("TARGMAX") + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testNamesMultipleChannels2812(self): + """ + https://modern.ircdocs.horse/#names-message + https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNamesMultipleChannels(symbol=True) + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") def testNamesInvalidChannel(self): """ @@ -47,3 +152,101 @@ class NamesTestCase(cases.BaseServerTestCase): command=RPL_ENDOFNAMES, params=["foo", "#nonexisting", ANYSTR], ) + + def _testNamesNoArgumentPublic(self, symbol): + self.connectClient("nick1") + self.getMessages(1) + self.sendLine(1, "JOIN #chan1") + self.connectClient("nick2") + self.sendLine(2, "JOIN #chan2") + self.sendLine(2, "MODE #chan2 -sp") + self.getMessages(1) + self.getMessages(2) + + self.sendLine(1, "NAMES") + + # TODO: order is unspecified + self.assertMessageMatch( + self.getMessage(1), + command=RPL_NAMREPLY, + params=["nick1", *(["="] if symbol else []), "#chan1", "@nick1"], + ) + self.assertMessageMatch( + self.getMessage(1), + command=RPL_NAMREPLY, + params=["nick1", *(["="] if symbol else []), "#chan2", "@nick2"], + ) + + self.assertMessageMatch( + self.getMessage(1), + command=RPL_ENDOFNAMES, + params=["nick1", ANYSTR, ANYSTR], + ) + + @cases.mark_specifications("RFC1459", deprecated=True) + def testNamesNoArgumentPublic1459(self): + """ + "If no parameter is given, a list of all channels and their + occupants is returned." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNamesNoArgumentPublic(symbol=False) + + @cases.mark_specifications("RFC2812", deprecated=True) + def testNamesNoArgumentPublic2812(self): + """ + "If no parameter is given, a list of all channels and their + occupants is returned." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNamesNoArgumentPublic(symbol=True) + + def _testNamesNoArgumentPrivate(self, symbol): + self.connectClient("nick1") + self.getMessages(1) + self.sendLine(1, "JOIN #chan1") + self.connectClient("nick2") + self.sendLine(2, "JOIN #chan2") + self.sendLine(2, "MODE #chan2 +sp") + self.getMessages(1) + self.getMessages(2) + + self.sendLine(1, "NAMES") + + self.assertMessageMatch( + self.getMessage(1), + command=RPL_NAMREPLY, + params=["nick1", *(["="] if symbol else []), "#chan1", "@nick1"], + ) + + self.assertMessageMatch( + self.getMessage(1), + command=RPL_ENDOFNAMES, + params=["nick1", ANYSTR, ANYSTR], + ) + + @cases.mark_specifications("RFC1459", deprecated=True) + def testNamesNoArgumentPrivate1459(self): + """ + "If no parameter is given, a list of all channels and their + occupants is returned. At the end of this list, a list of users who + are visible but either not on any channel or not on a visible channel + are listed as being on `channel' "*"." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNamesNoArgumentPrivate(symbol=False) + + @cases.mark_specifications("RFC2812", deprecated=True) + def testNamesNoArgumentPrivate2812(self): + """ + "If no parameter is given, a list of all channels and their + occupants is returned. At the end of this list, a list of users who + are visible but either not on any channel or not on a visible channel + are listed as being on `channel' "*"." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.2.5 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.2.5 + """ + self._testNamesNoArgumentPrivate(symbol=True) diff --git a/irctest/server_tests/part.py b/irctest/server_tests/part.py index acc2f86..4ee5055 100644 --- a/irctest/server_tests/part.py +++ b/irctest/server_tests/part.py @@ -1,6 +1,16 @@ +""" +The PART command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) + +TODO: cross-reference Modern +""" + import time from irctest import cases +from irctest.numerics import RPL_NAMREPLY class PartTestCase(cases.BaseServerTestCase): @@ -75,6 +85,12 @@ class PartTestCase(cases.BaseServerTestCase): self.getMessages(1) self.getMessages(2) + self.sendLine(2, "PRIVMSG #chan :hi everyone") + self.getMessages(2) + self.assertMessageMatch( + self.getMessage(1), command="PRIVMSG", params=["#chan", "hi everyone"] + ) + self.sendLine(1, "PART #chan") # both the PART'ing client and the other channel member should receive # a PART line: @@ -83,6 +99,21 @@ class PartTestCase(cases.BaseServerTestCase): m = self.getMessage(2) self.assertMessageMatch(m, command="PART") + self.sendLine(2, "PRIVMSG #chan :hi again everyone") + self.getMessages(2) + # client 1 has PART'ed and should not receive channel messages: + self.assertEqual(self.getMessages(1), []) + + # client 1 should no longer appear in NAMES responses: + names = set() + self.sendLine(2, "NAMES #chan") + for reply in self.getMessages(2): + if reply.command != RPL_NAMREPLY: + continue + names.update(reply.params[-1].replace("@", "").split()) + self.assertNotIn("bar", names) + self.assertIn("baz", names) + @cases.mark_specifications("RFC2812") def testBasicPartRfc2812(self): """ diff --git a/irctest/server_tests/pingpong.py b/irctest/server_tests/pingpong.py index 33dc4aa..0924d6b 100644 --- a/irctest/server_tests/pingpong.py +++ b/irctest/server_tests/pingpong.py @@ -1,3 +1,7 @@ +""" +The PING and PONG commands +""" + from irctest import cases from irctest.numerics import ERR_NEEDMOREPARAMS, ERR_NOORIGIN from irctest.patma import ANYSTR diff --git a/irctest/server_tests/quit.py b/irctest/server_tests/quit.py index 8a7200d..10069bd 100644 --- a/irctest/server_tests/quit.py +++ b/irctest/server_tests/quit.py @@ -1,12 +1,21 @@ +""" +The QUITcommand (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) + +TODO: cross-reference RFC 1459 and Modern +""" + import time from irctest import cases -from irctest.numerics import ERR_CANNOTSENDTOCHAN from irctest.patma import StrRe class ChannelQuitTestCase(cases.BaseServerTestCase): @cases.mark_specifications("RFC2812") + @cases.xfailIfSoftware(["ircu2", "Nefarious", "snircd"], "ircu2 does not echo QUIT") def testQuit(self): """“Once a user has joined a channel, he receives information about all commands his server receives affecting the channel. This @@ -30,31 +39,3 @@ class ChannelQuitTestCase(cases.BaseServerTestCase): m = self.getMessage(1) self.assertMessageMatch(m, command="QUIT", params=[StrRe(".*qux out.*")]) self.assertTrue(m.prefix.startswith("qux")) # nickmask of quitter - - -class NoCTCPTestCase(cases.BaseServerTestCase): - @cases.mark_specifications("Ergo") - def testQuit(self): - self.connectClient("bar") - self.joinChannel(1, "#chan") - self.sendLine(1, "MODE #chan +C") - self.getMessages(1) - - self.connectClient("qux") - self.joinChannel(2, "#chan") - self.getMessages(2) - - self.sendLine(1, "PRIVMSG #chan :\x01ACTION hi\x01") - self.getMessages(1) - ms = self.getMessages(2) - self.assertEqual(len(ms), 1) - self.assertMessageMatch( - ms[0], command="PRIVMSG", params=["#chan", "\x01ACTION hi\x01"] - ) - - self.sendLine(1, "PRIVMSG #chan :\x01PING 1473523796 918320\x01") - ms = self.getMessages(1) - self.assertEqual(len(ms), 1) - self.assertMessageMatch(ms[0], command=ERR_CANNOTSENDTOCHAN) - ms = self.getMessages(2) - self.assertEqual(ms, []) diff --git a/irctest/server_tests/readq.py b/irctest/server_tests/readq.py index 4e8182d..05eeeed 100644 --- a/irctest/server_tests/readq.py +++ b/irctest/server_tests/readq.py @@ -1,9 +1,12 @@ +""" +`Ergo `_-specific tests of responses to DoS attacks +using long lines. +""" + from irctest import cases class ReadqTestCase(cases.BaseServerTestCase): - """Test responses to DoS attacks using long lines.""" - @cases.mark_specifications("Ergo") @cases.mark_capabilities("message-tags") def testReadqTags(self): diff --git a/irctest/server_tests/regressions.py b/irctest/server_tests/regressions.py index 0361b3c..1f11ff5 100644 --- a/irctest/server_tests/regressions.py +++ b/irctest/server_tests/regressions.py @@ -1,11 +1,14 @@ """ -Regression tests for bugs in oragono. +Regression tests for bugs in `Ergo `_. """ -import time - -from irctest import cases -from irctest.numerics import ERR_ERRONEUSNICKNAME, ERR_NICKNAMEINUSE, RPL_WELCOME +from irctest import cases, runner +from irctest.numerics import ( + ERR_ERRONEUSNICKNAME, + ERR_NICKNAMEINUSE, + RPL_HELLO, + RPL_WELCOME, +) from irctest.patma import ANYDICT @@ -57,6 +60,12 @@ class RegressionsTestCase(cases.BaseServerTestCase): @cases.mark_capabilities("message-tags", "batch", "echo-message", "server-time") def testTagCap(self): + if self.controller.software_name == "UnrealIRCd": + raise runner.ImplementationChoice( + "Arbitrary +draft/reply values (TODO: adapt this test to use real " + "values so their pass Unreal's validation) " + "https://bugs.unrealircd.org/view.php?id=5948" + ) # regression test for oragono #754 self.connectClient( "alice", @@ -99,13 +108,13 @@ class RegressionsTestCase(cases.BaseServerTestCase): ) @cases.mark_specifications("RFC1459") + @cases.xfailIfSoftware(["ngIRCd"], "wat") def testStarNick(self): self.addClient(1) self.sendLine(1, "NICK *") self.sendLine(1, "USER u s e r") replies = {"NOTICE"} - time.sleep(2) # give time to slow servers, like irc2 to reply - while replies == {"NOTICE"}: + while replies <= {"NOTICE", RPL_HELLO}: replies = set(msg.command for msg in self.getMessages(1, synchronize=False)) self.assertIn(ERR_ERRONEUSNICKNAME, replies) self.assertNotIn(RPL_WELCOME, replies) diff --git a/irctest/server_tests/relaymsg.py b/irctest/server_tests/relaymsg.py index 07df252..18af038 100644 --- a/irctest/server_tests/relaymsg.py +++ b/irctest/server_tests/relaymsg.py @@ -1,3 +1,7 @@ +""" +RELAYMSG command of `Ergo `_ +""" + from irctest import cases from irctest.irc_utils.junkdrawer import random_name from irctest.patma import ANYSTR diff --git a/irctest/server_tests/roleplay.py b/irctest/server_tests/roleplay.py index 6ccfd68..2387ced 100644 --- a/irctest/server_tests/roleplay.py +++ b/irctest/server_tests/roleplay.py @@ -1,3 +1,7 @@ +""" +Roleplay features of `Ergo `_ +""" + from irctest import cases from irctest.irc_utils.junkdrawer import random_name from irctest.numerics import ERR_CANNOTSENDRP diff --git a/irctest/server_tests/sasl.py b/irctest/server_tests/sasl.py index f289ca5..600f959 100644 --- a/irctest/server_tests/sasl.py +++ b/irctest/server_tests/sasl.py @@ -12,9 +12,9 @@ class RegistrationTestCase(cases.BaseServerTestCase): @cases.mark_services -class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class SaslTestCase(cases.BaseServerTestCase): @cases.mark_specifications("IRCv3") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") def testPlain(self): """PLAIN authentication with correct username/password.""" self.controller.registerUser(self, "foo", "sesame") @@ -54,7 +54,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ) @cases.mark_specifications("IRCv3") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") def testPlainNonAscii(self): password = "é" * 100 authstring = base64.b64encode( @@ -82,7 +82,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ) @cases.mark_specifications("IRCv3") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") def testPlainNoAuthzid(self): """“message = [authzid] UTF8NUL authcid UTF8NUL passwd @@ -170,7 +170,22 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ) @cases.mark_specifications("IRCv3") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") + @cases.xfailIf( + lambda self: ( + self.controller.services_controller is not None + and self.controller.services_controller.software_name == "Anope" + ), + "Anope does not handle split AUTHENTICATE (reported on IRC)", + ) + @cases.xfailIf( + lambda self: ( + self.controller.services_controller is not None + and self.controller.services_controller.software_name == "Dlk-Services" + ), + "Dlk does not handle split AUTHENTICATE " + "https://github.com/DalekIRC/Dalek-Services/issues/28", + ) def testPlainLarge(self): """Test the client splits large AUTHENTICATE messages whose payload is not a multiple of 400. @@ -232,7 +247,14 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): # message's length too big for it to be valid. @cases.mark_specifications("IRCv3") - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") + @cases.xfailIf( + lambda self: ( + self.controller.services_controller is not None + and self.controller.services_controller.software_name == "Anope" + ), + "Anope does not handle split AUTHENTICATE (reported on IRC)", + ) def testPlainLargeEquals400(self): """Test the client splits large AUTHENTICATE messages whose payload is not a multiple of 400. @@ -277,7 +299,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): # message's length too big for it to be valid. @cases.mark_specifications("IRCv3") - @cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256") + @cases.skipUnlessHasMechanism("SCRAM-SHA-256") def testScramSha256Success(self): self.controller.registerUser(self, "Scramtest", "sesame") @@ -333,7 +355,7 @@ class SaslTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): self.confirmSuccessfulAuth() @cases.mark_specifications("IRCv3") - @cases.OptionalityHelper.skipUnlessHasMechanism("SCRAM-SHA-256") + @cases.skipUnlessHasMechanism("SCRAM-SHA-256") def testScramSha256Failure(self): self.controller.registerUser(self, "Scramtest", "sesame") diff --git a/irctest/server_tests/setname.py b/irctest/server_tests/setname.py new file mode 100644 index 0000000..e328c95 --- /dev/null +++ b/irctest/server_tests/setname.py @@ -0,0 +1,65 @@ +""" +`IRCv3 SETNAME`_ +""" + +from irctest import cases +from irctest.numerics import RPL_WHOISUSER + + +class SetnameMessageTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("setname") + def testSetnameMessage(self): + self.connectClient("foo", capabilities=["setname"], skip_if_cap_nak=True) + + self.sendLine(1, "SETNAME bar") + self.assertMessageMatch( + self.getMessage(1), + command="SETNAME", + params=["bar"], + ) + + self.sendLine(1, "WHOIS foo") + whoisuser = [m for m in self.getMessages(1) if m.command == RPL_WHOISUSER][0] + self.assertEqual(whoisuser.params[-1], "bar") + + @cases.mark_specifications("IRCv3") + @cases.mark_capabilities("setname") + def testSetnameChannel(self): + """“[Servers] MUST send the server-to-client version of the + SETNAME message to all clients in common channels, as well as + to the client from which it originated, to confirm the change + has occurred. + + The SETNAME message MUST NOT be sent to clients which do not + have the setname capability negotiated.“ + """ + self.connectClient("foo", capabilities=["setname"], skip_if_cap_nak=True) + self.connectClient("bar", capabilities=["setname"], skip_if_cap_nak=True) + self.connectClient("baz") + + self.joinChannel(1, "#chan") + self.joinChannel(2, "#chan") + self.joinChannel(3, "#chan") + self.getMessages(1) + self.getMessages(2) + self.getMessages(3) + + self.sendLine(1, "SETNAME qux") + self.assertMessageMatch( + self.getMessage(1), + command="SETNAME", + params=["qux"], + ) + + self.assertMessageMatch( + self.getMessage(2), + command="SETNAME", + params=["qux"], + ) + + self.assertEqual( + self.getMessages(3), + [], + "Got SETNAME response when it was not negotiated", + ) diff --git a/irctest/server_tests/statusmsg.py b/irctest/server_tests/statusmsg.py index 7a07a2b..1a1910d 100644 --- a/irctest/server_tests/statusmsg.py +++ b/irctest/server_tests/statusmsg.py @@ -1,3 +1,10 @@ +""" +STATUSMSG ISUPPORT token and related PRIVMSG (`Modern +`__) + +TODO: cross-reference Modern +""" + from irctest import cases, runner from irctest.numerics import RPL_NAMREPLY @@ -10,6 +17,11 @@ class StatusmsgTestCase(cases.BaseServerTestCase): self.assertEqual(self.server_support["STATUSMSG"], "~&@%+") @cases.mark_isupport("STATUSMSG") + @cases.xfailIfSoftware( + ["ircu2", "Nefarious", "snircd"], + "STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG " + "target (only for WALLCOPS/WALLCHOPS/...)", + ) def testStatusmsgFromOp(self): """Test that STATUSMSG are sent to the intended recipients, with the intended prefixes.""" @@ -61,6 +73,11 @@ class StatusmsgTestCase(cases.BaseServerTestCase): self.assertEqual(len(unprivilegedMessages), 0) @cases.mark_isupport("STATUSMSG") + @cases.xfailIfSoftware( + ["ircu2", "Nefarious", "snircd"], + "STATUSMSG is present in ISUPPORT, but it not actually supported as PRIVMSG " + "target (only for WALLCOPS/WALLCHOPS/...)", + ) def testStatusmsgFromRegular(self): """Test that STATUSMSG are sent to the intended recipients, with the intended prefixes.""" diff --git a/irctest/server_tests/time.py b/irctest/server_tests/time.py new file mode 100644 index 0000000..4481c33 --- /dev/null +++ b/irctest/server_tests/time.py @@ -0,0 +1,48 @@ +import math +import time + +from irctest import cases +from irctest.numerics import RPL_TIME +from irctest.patma import ANYSTR, StrRe + + +class TimeTestCase(cases.BaseServerTestCase): + def testTime(self): + self.connectClient("user") + + time_before = math.floor(time.time()) + self.sendLine(1, "TIME") + + msg = self.getMessage(1) + + time_after = math.ceil(time.time()) + + if len(msg.params) == 5: + # ircu2, snircd + self.assertMessageMatch( + msg, + command=RPL_TIME, + params=["user", "My.Little.Server", StrRe("[0-9]+"), "0", ANYSTR], + ) + self.assertIn( + int(msg.params[2]), + range(time_before, time_after + 1), + "Timestamp not in expected range", + ) + elif len(msg.params) == 4: + # bahamut + self.assertMessageMatch( + msg, + command=RPL_TIME, + params=["user", "My.Little.Server", StrRe("[0-9]+"), ANYSTR], + ) + self.assertIn( + int(msg.params[2]), + range(time_before, time_after + 1), + "Timestamp not in expected range", + ) + else: + # Common case + self.assertMessageMatch( + msg, command=RPL_TIME, params=["user", "My.Little.Server", ANYSTR] + ) diff --git a/irctest/server_tests/topic.py b/irctest/server_tests/topic.py index 4d83604..d001fe6 100644 --- a/irctest/server_tests/topic.py +++ b/irctest/server_tests/topic.py @@ -1,16 +1,39 @@ +""" +The TOPIC command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) +""" + from irctest import cases, client_mock, runner from irctest.numerics import ERR_CHANOPRIVSNEEDED, RPL_NOTOPIC, RPL_TOPIC, RPL_TOPICTIME class TopicTestCase(cases.BaseServerTestCase): @cases.mark_specifications("RFC1459", "RFC2812") - def testTopic(self): + def testTopicRfc(self): """“Once a user has joined a channel, he receives information about all commands his server receives affecting the channel. This includes […] TOPIC” -- and """ + self._testTopic(assert_echo=False) + + @cases.mark_specifications("Modern") + def testTopicModern(self): + """ "If the topic of a channel is changed or cleared, every client in that + channel (including the author of the topic change) will receive a TOPIC command + with the new topic as argument (or an empty argument if the topic was cleared) + alerting them to how the topic has changed. + + Clients joining the channel in the future will receive a RPL_TOPIC numeric (or + lack thereof) accordingly." + -- https://modern.ircdocs.horse/#topic-message + """ + self._testTopic(assert_echo=True) + + def _testTopic(self, assert_echo: bool): self.connectClient("foo") self.joinChannel(1, "#chan") @@ -34,6 +57,7 @@ class TopicTestCase(cases.BaseServerTestCase): ) self.assertMessageMatch(m, command="TOPIC") except client_mock.NoMessageException: + self.assertFalse(assert_echo, "TOPIC was not echoed back to the author") # The RFCs do not say TOPIC must be echoed pass m = self.getMessage(2) diff --git a/irctest/server_tests/umodes/registeredonly.py b/irctest/server_tests/umodes/registeredonly.py new file mode 100644 index 0000000..1b63d2c --- /dev/null +++ b/irctest/server_tests/umodes/registeredonly.py @@ -0,0 +1,124 @@ +""" +Test the registered-only DM user mode (commonly +R). +""" + +from irctest import cases +from irctest.numerics import ERR_NEEDREGGEDNICK + + +@cases.mark_services +class RegisteredOnlyUmodeTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("Ergo") + def testRegisteredOnlyUserMode(self): + """Test the +R registered-only mode.""" + self.controller.registerUser(self, "evan", "sesame") + self.controller.registerUser(self, "carmen", "pink") + + self.connectClient( + "evan", + name="evan", + account="evan", + password="sesame", + capabilities=["sasl"], + ) + self.connectClient("shivaram", name="shivaram") + self.sendLine("evan", "MODE evan +R") + self.assertMessageMatch( + self.getMessage("evan"), command="MODE", params=["evan", "+R"] + ) + + # this DM should be blocked by +R registered-only + self.getMessages("shivaram") + self.sendLine("shivaram", "PRIVMSG evan :hey there") + self.assertMessageMatch( + self.getMessage("shivaram"), + command=ERR_NEEDREGGEDNICK, + ) + self.assertEqual(self.getMessages("evan"), []) + + self.connectClient( + "carmen", + name="carmen", + account="carmen", + password="pink", + capabilities=["sasl"], + ) + self.getMessages("evan") + self.sendLine("carmen", "PRIVMSG evan :hey there") + self.assertEqual(self.getMessages("carmen"), []) + # this message should go through fine: + self.assertMessageMatch( + self.getMessage("evan"), + command="PRIVMSG", + params=["evan", "hey there"], + ) + + @cases.mark_specifications("Ergo") + def testRegisteredOnlyUserModeAcceptCommand(self): + """Test that the ACCEPT command can authorize another user + to send the accept-er direct messages, overriding the + +R registered-only mode.""" + self.controller.registerUser(self, "evan", "sesame") + self.connectClient( + "evan", + name="evan", + account="evan", + password="sesame", + capabilities=["sasl"], + ) + self.connectClient("shivaram", name="shivaram") + self.sendLine("evan", "MODE evan +R") + self.assertMessageMatch( + self.getMessage("evan"), command="MODE", params=["evan", "+R"] + ) + self.sendLine("evan", "ACCEPT shivaram") + self.getMessages("evan") + + self.sendLine("shivaram", "PRIVMSG evan :hey there") + self.assertEqual(self.getMessages("shivaram"), []) + self.assertMessageMatch( + self.getMessage("evan"), + command="PRIVMSG", + params=["evan", "hey there"], + ) + + self.sendLine("evan", "ACCEPT -shivaram") + self.getMessages("evan") + self.sendLine("shivaram", "PRIVMSG evan :how's it going") + self.assertMessageMatch( + self.getMessage("shivaram"), + command=ERR_NEEDREGGEDNICK, + ) + self.assertEqual(self.getMessages("evan"), []) + + @cases.mark_specifications("Ergo") + def testRegisteredOnlyUserModeAutoAcceptOnDM(self): + """Test that sending someone a DM automatically authorizes them to + reply, overriding the +R registered-only mode.""" + self.controller.registerUser(self, "evan", "sesame") + self.connectClient( + "evan", + name="evan", + account="evan", + password="sesame", + capabilities=["sasl"], + ) + self.connectClient("shivaram", name="shivaram") + self.sendLine("evan", "MODE evan +R") + self.assertMessageMatch( + self.getMessage("evan"), command="MODE", params=["evan", "+R"] + ) + self.sendLine("evan", "PRIVMSG shivaram :hey there") + self.getMessages("evan") + self.assertMessageMatch( + self.getMessage("shivaram"), + command="PRIVMSG", + params=["shivaram", "hey there"], + ) + self.sendLine("shivaram", "PRIVMSG evan :how's it going") + self.assertEqual(self.getMessages("shivaram"), []) + self.assertMessageMatch( + self.getMessage("evan"), + command="PRIVMSG", + params=["evan", "how's it going"], + ) diff --git a/irctest/server_tests/utf8.py b/irctest/server_tests/utf8.py index 46c838b..12ac129 100644 --- a/irctest/server_tests/utf8.py +++ b/irctest/server_tests/utf8.py @@ -1,29 +1,21 @@ -from irctest import cases +""" +`Ergo `_-specific tests of non-Unicode filtering + +`_ +""" + +from irctest import cases, runner from irctest.patma import ANYSTR -class Utf8TestCase(cases.BaseServerTestCase, cases.OptionalityHelper): +class Utf8TestCase(cases.BaseServerTestCase): @cases.mark_specifications("Ergo") - def testUtf8Validation(self): + def testNonUtf8Filtering(self): self.connectClient( "bar", capabilities=["batch", "echo-message", "labeled-response"], ) self.joinChannel(1, "#qux") - self.sendLine(1, "PRIVMSG #qux hi") - ms = self.getMessages(1) - self.assertMessageMatch( - [m for m in ms if m.command == "PRIVMSG"][0], params=["#qux", "hi"] - ) - - self.sendLine(1, b"PRIVMSG #qux hi\xaa") - self.assertMessageMatch( - self.getMessage(1), - command="FAIL", - params=["PRIVMSG", "INVALID_UTF8", ANYSTR], - tags={}, - ) - self.sendLine(1, b"@label=xyz PRIVMSG #qux hi\xaa") self.assertMessageMatch( self.getMessage(1), @@ -31,3 +23,61 @@ class Utf8TestCase(cases.BaseServerTestCase, cases.OptionalityHelper): params=["PRIVMSG", "INVALID_UTF8", ANYSTR], tags={"label": "xyz"}, ) + + @cases.mark_isupport("UTF8ONLY") + def testUtf8Validation(self): + self.connectClient("foo") + self.connectClient("bar") + + if "UTF8ONLY" not in self.server_support: + raise runner.IsupportTokenNotSupported("UTF8ONLY") + + self.sendLine(1, "PRIVMSG bar hi") + self.getMessages(1) # synchronize + ms = self.getMessages(2) + self.assertMessageMatch( + [m for m in ms if m.command == "PRIVMSG"][0], params=["bar", "hi"] + ) + + self.sendLine(1, b"PRIVMSG bar hi\xaa") + + m = self.getMessage(1) + assert m.command in ("FAIL", "WARN", "ERROR") + + if m.command in ("FAIL", "WARN"): + self.assertMessageMatch(m, params=["PRIVMSG", "INVALID_UTF8", ANYSTR]) + + def testNonutf8Realname(self): + self.connectClient("foo") + if "UTF8ONLY" not in self.server_support: + raise runner.IsupportTokenNotSupported("UTF8ONLY") + + self.addClient() + self.sendLine(2, "NICK foo") + self.clients[2].conn.sendall(b"USER username * * :i\xe8rc\xe9\r\n") + + d = self.clients[2].conn.recv(1024) + if b" FAIL " in d or b" 468 " in d: # ERR_INVALIDUSERNAME + return # nothing more to test + self.assertIn(b" 001 ", d) + + self.sendLine(2, "WHOIS foo") + self.getMessages(2) + + def testNonutf8Username(self): + self.connectClient("foo") + if "UTF8ONLY" not in self.server_support: + raise runner.IsupportTokenNotSupported("UTF8ONLY") + + self.addClient() + self.sendLine(2, "NICK foo") + self.sendLine(2, "USER 😊😊😊😊😊😊😊😊😊😊 * * :realname") + m = self.getRegistrationMessage(2) + if m.command in ("FAIL", "468"): # ERR_INVALIDUSERNAME + return # nothing more to test + self.assertMessageMatch( + m, + command="001", + ) + self.sendLine(2, "WHOIS foo") + self.getMessages(2) diff --git a/irctest/server_tests/wallops.py b/irctest/server_tests/wallops.py index 2d5fb69..d23dc58 100644 --- a/irctest/server_tests/wallops.py +++ b/irctest/server_tests/wallops.py @@ -1,3 +1,9 @@ +""" +The WALLOPS command (`RFC 2812 +`__, +`Modern `__) +""" + from irctest import cases, runner from irctest.numerics import ERR_NOPRIVILEGES, ERR_UNKNOWNCOMMAND, RPL_YOUREOPER from irctest.patma import ANYSTR, StrRe @@ -38,7 +44,7 @@ class WallopsTestCase(cases.BaseServerTestCase): messages = self.getMessages(1) if ERR_UNKNOWNCOMMAND in (message.command for message in messages): - raise runner.NotImplementedByController("WALLOPS") + raise runner.OptionalCommandNotSupported("WALLOPS") for message in messages: self.assertMessageMatch( message, @@ -60,6 +66,9 @@ class WallopsTestCase(cases.BaseServerTestCase): ) @cases.mark_specifications("Modern") + @cases.xfailIfSoftware( + ["irc2"], "irc2 ignores the command instead of replying ERR_UNKNOWNCOMMAND" + ) def testWallopsPrivileges(self): """ https://github.com/ircdocs/modern-irc/pull/118 @@ -68,7 +77,7 @@ class WallopsTestCase(cases.BaseServerTestCase): self.sendLine(1, "WALLOPS :hi everyone") message = self.getMessage(1) if message.command == ERR_UNKNOWNCOMMAND: - raise runner.NotImplementedByController("WALLOPS") + raise runner.OptionalCommandNotSupported("WALLOPS") self.assertMessageMatch( message, command=ERR_NOPRIVILEGES, params=["nick1", ANYSTR] ) diff --git a/irctest/server_tests/who.py b/irctest/server_tests/who.py index bfb8cb4..0e4b280 100644 --- a/irctest/server_tests/who.py +++ b/irctest/server_tests/who.py @@ -1,9 +1,16 @@ +""" +The WHO command (`Modern `__) +and `IRCv3 WHOX `_ + +TODO: cross-reference RFC 1459 and RFC 2812 +""" + import re import pytest -from irctest import cases -from irctest.numerics import RPL_ENDOFWHO, RPL_WHOREPLY, RPL_YOUREOPER +from irctest import cases, runner +from irctest.numerics import RPL_ENDOFWHO, RPL_WHOREPLY, RPL_WHOSPCRPL, RPL_YOUREOPER from irctest.patma import ANYSTR, InsensitiveStr, StrRe @@ -15,16 +22,23 @@ def realname_regexp(realname): ) -class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): - def _init(self): +class BaseWhoTestCase: + def _init(self, auth=False): self.nick = "coolNick" self.username = "myusernam" # may be truncated if longer than this self.realname = "My UniqueReal Name" self.addClient() + if auth: + self.controller.registerUser(self, "coolAcct", "sesame") + self.requestCapabilities(1, ["sasl"], skip_if_cap_nak=True) + self.authenticateClient(1, "coolAcct", "sesame") self.sendLine(1, f"NICK {self.nick}") self.sendLine(1, f"USER {self.username} 0 * :{self.realname}") + if auth: + self.sendLine(1, "CAP END") self.skipToWelcome(1) + self.getMessages(1) self.sendLine(1, "JOIN #chan") self.getMessages(1) @@ -69,8 +83,13 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ], ) + +class WhoTestCase(BaseWhoTestCase, cases.BaseServerTestCase): @cases.mark_specifications("Modern") def testWhoStar(self): + if self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") + self._init() self.sendLine(2, "WHO *") @@ -99,6 +118,9 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ) @cases.mark_specifications("Modern") def testWhoNick(self, mask): + if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") + self._init() self.sendLine(2, f"WHO {mask}") @@ -126,6 +148,9 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ids=["username", "realname-mask", "hostname"], ) def testWhoUsernameRealName(self, mask): + if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") + self._init() self.sendLine(2, f"WHO :{mask}") @@ -176,6 +201,9 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): ) @cases.mark_specifications("Modern") def testWhoNickAway(self, mask): + if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") + self._init() self.sendLine(1, "AWAY :be right back") @@ -200,8 +228,16 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): @pytest.mark.parametrize( "mask", ["coolNick", "coolnick", "coolni*"], ids=["exact", "casefolded", "mask"] ) + @cases.xfailIfSoftware( + ["Sable"], + "Sable does not advertise oper status in WHO: " + "https://github.com/Libera-Chat/sable/pull/77", + ) @cases.mark_specifications("Modern") def testWhoNickOper(self, mask): + if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") + self._init() self.sendLine(1, "OPER operuser operpassword") @@ -231,8 +267,16 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): @pytest.mark.parametrize( "mask", ["coolNick", "coolnick", "coolni*"], ids=["exact", "casefolded", "mask"] ) + @cases.xfailIfSoftware( + ["Sable"], + "Sable does not advertise oper status in WHO: " + "https://github.com/Libera-Chat/sable/pull/77", + ) @cases.mark_specifications("Modern") def testWhoNickAwayAndOper(self, mask): + if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") + self._init() self.sendLine(1, "OPER operuser operpassword") @@ -264,14 +308,10 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): @pytest.mark.parametrize("mask", ["#chan", "#CHAN"], ids=["exact", "casefolded"]) @cases.mark_specifications("Modern") def testWhoChan(self, mask): - self._init() + if "*" in mask and self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") - self.sendLine(1, "OPER operuser operpassword") - self.assertIn( - RPL_YOUREOPER, - [m.command for m in self.getMessages(1)], - fail_msg="OPER failed", - ) + self._init() self.sendLine(1, "AWAY :be right back") self.getMessages(1) @@ -298,7 +338,7 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): StrRe(host_re), "My.Little.Server", "coolNick", - "G*@", + "G@", StrRe(realname_regexp(self.realname)), ], ) @@ -323,3 +363,298 @@ class WhoTestCase(cases.BaseServerTestCase, cases.OptionalityHelper): command=RPL_ENDOFWHO, params=["otherNick", InsensitiveStr(mask), ANYSTR], ) + + @cases.mark_specifications("Modern") + def testWhoMultiChan(self): + """ + When WHO <#chan> is sent, the second parameter of RPL_WHOREPLY must + be ``#chan``. See discussion on Modern: + + """ + self._init() + + self.sendLine(1, "JOIN #otherchan") + self.getMessages(1) + + self.sendLine(2, "JOIN #otherchan") + self.getMessages(2) + + for chan in ["#chan", "#otherchan"]: + self.sendLine(2, f"WHO {chan}") + messages = self.getMessages(2) + + self.assertEqual(len(messages), 3, "Unexpected number of messages") + + (*replies, end) = messages + + # Get them in deterministic order + replies.sort(key=lambda msg: msg.params[5]) + + self.assertMessageMatch( + replies[0], + command=RPL_WHOREPLY, + params=[ + "otherNick", + chan, + ANYSTR, + ANYSTR, + "My.Little.Server", + "coolNick", + ANYSTR, + ANYSTR, + ], + ) + + self.assertMessageMatch( + replies[1], + command=RPL_WHOREPLY, + params=[ + "otherNick", + chan, + ANYSTR, + ANYSTR, + "My.Little.Server", + "otherNick", + ANYSTR, + ANYSTR, + ], + ) + + self.assertMessageMatch( + end, + command=RPL_ENDOFWHO, + params=["otherNick", InsensitiveStr(chan), ANYSTR], + ) + + @cases.mark_specifications("Modern") + def testWhoNickNotExists(self): + """ + When WHO is sent with a non-existing nickname, the server must reply + with a single RPL_ENDOFWHO. See: + + """ + + self._init() + + self.sendLine(2, "WHO idontexist") + (end,) = self.getMessages(2) + + self.assertMessageMatch( + end, + command=RPL_ENDOFWHO, + params=["otherNick", InsensitiveStr("idontexist"), ANYSTR], + ) + + @cases.mark_specifications("IRCv3") + @cases.mark_isupport("WHOX") + def testWhoxFull(self): + """https://github.com/ircv3/ircv3-specifications/pull/482""" + self._testWhoxFull("%tcuihsnfdlaor,123") + + @cases.mark_specifications("IRCv3") + @cases.mark_isupport("WHOX") + def testWhoxFullReversed(self): + """https://github.com/ircv3/ircv3-specifications/pull/482""" + self._testWhoxFull("%" + "".join(reversed("tcuihsnfdlaor")) + ",123") + + def _testWhoxFull(self, chars): + self._init() + if "WHOX" not in self.server_support: + raise runner.IsupportTokenNotSupported("WHOX") + + self.sendLine(2, f"WHO coolNick {chars}") + messages = self.getMessages(2) + + self.assertEqual(len(messages), 2, "Unexpected number of messages") + + (reply, end) = messages + + self.assertMessageMatch( + reply, + command=RPL_WHOSPCRPL, + params=[ + "otherNick", + "123", + StrRe(r"(#chan|\*)"), + StrRe("~?myusernam"), + ANYSTR, + ANYSTR, + "My.Little.Server", + "coolNick", + StrRe("H@?"), + ANYSTR, # hopcount + StrRe("[0-9]"), # seconds idle + "0", # account name + ANYSTR, # op level + "My UniqueReal Name", + ], + ) + + self.assertMessageMatch( + end, + command=RPL_ENDOFWHO, + params=["otherNick", InsensitiveStr("coolNick"), ANYSTR], + ) + + @pytest.mark.parametrize("char", "cuihsnfdlaor") + @cases.xfailIf( + lambda self, char: bool( + char == "l" and self.controller.software_name == "ircu2" + ), + "https://github.com/UndernetIRC/ircu2/commit/17c539103abbd0055b2297e17854cd0756c85d62", + ) + @cases.xfailIf( + lambda self, char: bool( + char == "l" and self.controller.software_name == "Nefarious" + ), + "https://github.com/evilnet/nefarious2/pull/73", + ) + def testWhoxOneChar(self, char): + self._init() + if "WHOX" not in self.server_support: + raise runner.IsupportTokenNotSupported("WHOX") + + self.sendLine(2, f"WHO coolNick %{char}") + messages = self.getMessages(2) + + self.assertEqual(len(messages), 2, "Unexpected number of messages") + + (reply, end) = messages + + self.assertMessageMatch( + reply, + command=RPL_WHOSPCRPL, + params=[ + "otherNick", + StrRe(".+"), + ], + ) + + self.assertMessageMatch( + end, + command=RPL_ENDOFWHO, + params=["otherNick", InsensitiveStr("coolNick"), ANYSTR], + ) + + def testWhoxToken(self): + """https://github.com/ircv3/ircv3-specifications/pull/482""" + self._init() + if "WHOX" not in self.server_support: + raise runner.IsupportTokenNotSupported("WHOX") + + self.sendLine(2, "WHO coolNick %tn,321") + messages = self.getMessages(2) + + self.assertEqual(len(messages), 2, "Unexpected number of messages") + + (reply, end) = messages + + self.assertMessageMatch( + reply, + command=RPL_WHOSPCRPL, + params=[ + "otherNick", + "321", + "coolNick", + ], + ) + + self.assertMessageMatch( + end, + command=RPL_ENDOFWHO, + params=["otherNick", InsensitiveStr("coolNick"), ANYSTR], + ) + + +@cases.mark_services +class WhoServicesTestCase(BaseWhoTestCase, cases.BaseServerTestCase): + @cases.mark_specifications("IRCv3") + @cases.mark_isupport("WHOX") + def testWhoxAccount(self): + self._init(auth=True) + if "WHOX" not in self.server_support: + raise runner.IsupportTokenNotSupported("WHOX") + + self.sendLine(2, "WHO coolNick %na") + messages = self.getMessages(2) + + self.assertEqual(len(messages), 2, "Unexpected number of messages") + + (reply, end) = messages + + self.assertMessageMatch( + reply, + command=RPL_WHOSPCRPL, + params=[ + "otherNick", + "coolNick", + "coolAcct", + ], + ) + + self.assertMessageMatch( + end, + command=RPL_ENDOFWHO, + params=["otherNick", InsensitiveStr("coolNick"), ANYSTR], + ) + + @cases.mark_specifications("IRCv3") + @cases.mark_isupport("WHOX") + def testWhoxNoAccount(self): + self._init(auth=False) + if "WHOX" not in self.server_support: + raise runner.IsupportTokenNotSupported("WHOX") + + self.sendLine(2, "WHO coolNick %na") + messages = self.getMessages(2) + + self.assertEqual(len(messages), 2, "Unexpected number of messages") + + (reply, end) = messages + + self.assertMessageMatch( + reply, + command=RPL_WHOSPCRPL, + params=[ + "otherNick", + "coolNick", + "0", + ], + ) + + self.assertMessageMatch( + end, + command=RPL_ENDOFWHO, + params=["otherNick", InsensitiveStr("coolNick"), ANYSTR], + ) + + +class WhoInvisibleTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("Modern") + def testWhoInvisible(self): + if self.controller.software_name in ("Bahamut", "Sable"): + raise runner.OptionalExtensionNotSupported("WHO mask") + + self.connectClient("evan", name="evan") + self.sendLine("evan", "MODE evan +i") + self.getMessages("evan") + + self.connectClient("shivaram", name="shivaram") + self.getMessages("shivaram") + self.sendLine("shivaram", "WHO eva*") + reply_cmds = {msg.command for msg in self.getMessages("shivaram")} + self.assertEqual(reply_cmds, {RPL_ENDOFWHO}) + + # invisibility should not be respected for plain nicknames, only for masks: + self.sendLine("shivaram", "WHO evan") + replies = self.getMessages("shivaram") + reply_cmds = {msg.command for msg in replies} + self.assertEqual(reply_cmds, {RPL_WHOREPLY, RPL_ENDOFWHO}) + + # invisibility should not be respected if the users share a channel + self.joinChannel("evan", "#test") + self.joinChannel("shivaram", "#test") + self.sendLine("shivaram", "WHO eva*") + replies = self.getMessages("shivaram") + reply_cmds = {msg.command for msg in replies} + self.assertEqual(reply_cmds, {RPL_WHOREPLY, RPL_ENDOFWHO}) diff --git a/irctest/server_tests/whois.py b/irctest/server_tests/whois.py index b948e88..51b7ad9 100644 --- a/irctest/server_tests/whois.py +++ b/irctest/server_tests/whois.py @@ -1,3 +1,9 @@ +""" +The WHOIS command (`Modern `__) + +TODO: cross-reference RFC 1459 and RFC 2812 +""" + import pytest from irctest import cases @@ -23,6 +29,9 @@ from irctest.patma import ANYSTR, StrRe class _WhoisTestMixin(cases.BaseServerTestCase): def _testWhoisNumerics(self, authenticate, away, oper): + if oper and self.controller.software_name == "Charybdis": + pytest.xfail("charybdis uses RPL_WHOISSPECIAL instead of RPL_WHOISOPERATOR") + if authenticate: self.connectClient("nick1") self.controller.registerUser(self, "val", "sesame") @@ -62,7 +71,10 @@ class _WhoisTestMixin(cases.BaseServerTestCase): last_message, command=RPL_ENDOFWHOIS, params=["nick1", "nick2", ANYSTR], - fail_msg=f"Last message was not RPL_ENDOFWHOIS ({RPL_ENDOFWHOIS})", + fail_msg=( + f"Expected RPL_ENDOFWHOIS ({RPL_ENDOFWHOIS}) as last message, " + f"got {{msg}}" + ), ) unexpected_messages = [] @@ -87,6 +99,12 @@ class _WhoisTestMixin(cases.BaseServerTestCase): ], ) elif m.command == RPL_WHOISSPECIAL: + services_controller = self.controller.services_controller + if ( + services_controller is not None + and services_controller.software_name == "Dlk-Services" + ): + continue # Technically allowed, but it's a bad style to use this without # explicit configuration by the operators. assert False, "RPL_WHOISSPECIAL in use with default configuration" @@ -158,7 +176,7 @@ class _WhoisTestMixin(cases.BaseServerTestCase): ) -class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase, cases.OptionalityHelper): +class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase): @pytest.mark.parametrize( "server", ["", "My.Little.Server", "coolNick"], @@ -177,18 +195,26 @@ class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase, cases.Optionality self.connectClient("otherNick") self.getMessages(2) - self.sendLine(2, f"WHOIS {server} coolnick") + self.sendLine(2, f"WHOIS {server} {nick}") messages = self.getMessages(2) whois_user = messages[0] - self.assertEqual(whois_user.command, RPL_WHOISUSER) - # " * :" - self.assertEqual(whois_user.params[1], nick) - self.assertIn(whois_user.params[2], ("~" + username, username)) + self.assertMessageMatch( + whois_user, + command=RPL_WHOISUSER, + # " * :" + params=[ + "otherNick", + nick, + StrRe("~?" + username), + ANYSTR, + ANYSTR, + realname, + ], + ) # dumb regression test for oragono/oragono#355: self.assertNotIn( whois_user.params[3], [nick, username, "~" + username, realname] ) - self.assertEqual(whois_user.params[5], realname) @pytest.mark.parametrize( "away,oper", @@ -200,15 +226,13 @@ class WhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase, cases.Optionality """Tests all numerics are in the exhaustive list defined in the Modern spec. """ - self._testWhoisNumerics(authenticate=False, away=away, oper=oper) + self._testWhoisNumerics(oper=oper, authenticate=False, away=away) @cases.mark_services -class ServicesWhoisTestCase( - _WhoisTestMixin, cases.BaseServerTestCase, cases.OptionalityHelper -): +class ServicesWhoisTestCase(_WhoisTestMixin, cases.BaseServerTestCase): @pytest.mark.parametrize("oper", [False, True], ids=["normal", "oper"]) - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") @cases.mark_specifications("Modern") def testWhoisNumerics(self, oper): """Tests all numerics are in the exhaustive list defined in the Modern spec, @@ -291,7 +315,7 @@ class ServicesWhoisTestCase( "RPL_WHOISCHANNELS should be sent for a non-invisible nick", ) - @cases.OptionalityHelper.skipUnlessHasMechanism("PLAIN") + @cases.skipUnlessHasMechanism("PLAIN") @cases.mark_specifications("ircdocs") def testWhoisAccount(self): """Test numeric 330, RPL_WHOISACCOUNT. diff --git a/irctest/server_tests/whowas.py b/irctest/server_tests/whowas.py new file mode 100644 index 0000000..5b5d926 --- /dev/null +++ b/irctest/server_tests/whowas.py @@ -0,0 +1,454 @@ +""" +The WHOSWAS command (`RFC 1459 +`__, +`RFC 2812 `__, +`Modern `__) + +TODO: cross-reference Modern +""" + + +import pytest + +from irctest import cases, runner +from irctest.exceptions import ConnectionClosed +from irctest.numerics import ( + ERR_NEEDMOREPARAMS, + ERR_NONICKNAMEGIVEN, + ERR_WASNOSUCHNICK, + RPL_ENDOFWHOWAS, + RPL_WHOISACTUALLY, + RPL_WHOISSERVER, + RPL_WHOWASUSER, +) +from irctest.patma import ANYSTR, StrRe + + +class WhowasTestCase(cases.BaseServerTestCase): + @cases.mark_specifications("RFC1459", "RFC2812") + def testWhowasNumerics(self): + """ + https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + """ + self.connectClient("nick1") + + self.connectClient("nick2") + self.sendLine(2, "QUIT :bye") + try: + self.getMessages(2) + except ConnectionClosed: + pass + + self.sendLine(1, "WHOWAS nick2") + + messages = [] + for _ in range(10): + messages.extend(self.getMessages(1)) + if RPL_ENDOFWHOWAS in (m.command for m in messages): + break + + last_message = messages.pop() + + self.assertMessageMatch( + last_message, + command=RPL_ENDOFWHOWAS, + params=["nick1", "nick2", ANYSTR], + fail_msg=f"Last message was not RPL_ENDOFWHOWAS ({RPL_ENDOFWHOWAS})", + ) + + unexpected_messages = [] + + # Straight from the RFCs + for m in messages: + if m.command == RPL_WHOWASUSER: + host_re = "[0-9A-Za-z_:.-]+" + self.assertMessageMatch( + m, + params=[ + "nick1", + "nick2", + StrRe("~?username"), + StrRe(host_re), + "*", + "Realname", + ], + ) + elif m.command == RPL_WHOISSERVER: + self.assertMessageMatch( + m, params=["nick1", "nick2", "My.Little.Server", ANYSTR] + ) + elif m.command == RPL_WHOISACTUALLY: + # Technically not allowed by the RFCs, but Solanum uses it. + # Not checking the syntax here; WhoisTestCase does it. + pass + else: + unexpected_messages.append(m) + + self.assertEqual( + unexpected_messages, [], fail_msg="Unexpected numeric messages: {got}" + ) + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testWhowasEnd(self): + """ + "At the end of all reply batches, there must be RPL_ENDOFWHOWAS" + -- https://datatracker.ietf.org/doc/html/rfc1459#page-50 + -- https://datatracker.ietf.org/doc/html/rfc2812#page-45 + + "Servers MUST reply with either ERR_WASNOSUCHNICK or [...], + both followed with RPL_ENDOFWHOWAS" + -- https://modern.ircdocs.horse/#whowas-message + """ + self.connectClient("nick1") + + self.connectClient("nick2") + self.sendLine(2, "QUIT :bye") + try: + self.getMessages(2) + except ConnectionClosed: + pass + + self.sendLine(1, "WHOWAS nick2") + + messages = [] + for _ in range(10): + messages.extend(self.getMessages(1)) + if RPL_ENDOFWHOWAS in (m.command for m in messages): + break + + last_message = messages.pop() + + self.assertMessageMatch( + last_message, + command=RPL_ENDOFWHOWAS, + params=["nick1", "nick2", ANYSTR], + fail_msg=f"Last message was not RPL_ENDOFWHOWAS ({RPL_ENDOFWHOWAS})", + ) + + def _testWhowasMultiple(self, second_result, whowas_command): + """ + "The history is searched backward, returning the most recent entry first." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + """ + # TODO: this test assumes the order is always: RPL_WHOWASUSER, then + # optional RPL_WHOISACTUALLY, then RPL_WHOISSERVER; but the RFCs + # don't specify the order. + self.connectClient("nick1") + + self.connectClient("nick2", ident="ident2") + self.sendLine(2, "QUIT :bye") + try: + self.getMessages(2) + except ConnectionClosed: + pass + + self.connectClient("nick2", ident="ident3") + self.sendLine(3, "QUIT :bye") + try: + self.getMessages(3) + except ConnectionClosed: + pass + + self.sendLine(1, whowas_command) + + messages = self.getMessages(1) + + # nick2 with ident3 + self.assertMessageMatch( + messages.pop(0), + command=RPL_WHOWASUSER, + params=[ + "nick1", + "nick2", + StrRe("~?ident3"), + ANYSTR, + "*", + "Realname", + ], + ) + while messages[0].command in (RPL_WHOISACTUALLY, RPL_WHOISSERVER): + # don't care + messages.pop(0) + + if second_result: + # nick2 with ident2 + self.assertMessageMatch( + messages.pop(0), + command=RPL_WHOWASUSER, + params=[ + "nick1", + "nick2", + StrRe("~?ident2"), + ANYSTR, + "*", + "Realname", + ], + ) + if messages[0].command == RPL_WHOISACTUALLY: + # don't care + messages.pop(0) + while messages[0].command in (RPL_WHOISACTUALLY, RPL_WHOISSERVER): + # don't care + messages.pop(0) + + self.assertMessageMatch( + messages.pop(0), + command=RPL_ENDOFWHOWAS, + params=["nick1", "nick2", ANYSTR], + fail_msg=f"Last message was not RPL_ENDOFWHOWAS ({RPL_ENDOFWHOWAS})", + ) + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testWhowasMultiple(self): + """ + "The history is searched backward, returning the most recent entry first." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + -- https://modern.ircdocs.horse/#whowas-message + """ + self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2") + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testWhowasCount1(self): + """ + "If there are multiple entries, up to replies will be returned" + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + -- https://modern.ircdocs.horse/#whowas-message + """ + self._testWhowasMultiple(second_result=False, whowas_command="WHOWAS nick2 1") + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testWhowasCount2(self): + """ + "If there are multiple entries, up to replies will be returned" + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + -- https://modern.ircdocs.horse/#whowas-message + """ + self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 2") + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + def testWhowasCountNegative(self): + """ + "If a non-positive number is passed as being , then a full search + is done." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + + "If given, SHOULD be a positive number. Otherwise, a full search + "is done. + -- https://modern.ircdocs.horse/#whowas-message + """ + self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 -1") + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + @cases.xfailIfSoftware( + ["ircu2"], "Fix not released yet: https://github.com/UndernetIRC/ircu2/pull/19" + ) + def testWhowasCountZero(self): + """ + "If a non-positive number is passed as being , then a full search + is done." + -- https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + + "If given, SHOULD be a positive number. Otherwise, a full search + "is done. + -- https://modern.ircdocs.horse/#whowas-message + """ + self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS nick2 0") + + @cases.mark_specifications("RFC2812", deprecated=True) + def testWhowasWildcard(self): + """ + "Wildcards are allowed in the parameter." + -- https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + -- https://modern.ircdocs.horse/#whowas-message + """ + if self.controller.software_name == "Bahamut": + raise runner.OptionalExtensionNotSupported("WHOWAS mask") + + self._testWhowasMultiple(second_result=True, whowas_command="WHOWAS *ck2") + + @cases.mark_specifications("RFC1459", "RFC2812", deprecated=True) + def testWhowasNoParamRfc(self): + """ + https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + + and: + + "At the end of all reply batches, there must be RPL_ENDOFWHOWAS + (even if there was only one reply and it was an error)." + -- https://datatracker.ietf.org/doc/html/rfc1459#page-50 + -- https://datatracker.ietf.org/doc/html/rfc2812#page-45 + """ + # But no one seems to follow this. Most implementations use ERR_NEEDMOREPARAMS + # instead of ERR_NONICKNAMEGIVEN; and I couldn't find any that returns + # RPL_ENDOFWHOWAS either way. + self.connectClient("nick1") + + self.sendLine(1, "WHOWAS") + + self.assertMessageMatch( + self.getMessage(1), + command=ERR_NONICKNAMEGIVEN, + params=["nick1", ANYSTR], + ) + + self.assertMessageMatch( + self.getMessage(1), + command=RPL_ENDOFWHOWAS, + params=["nick1", "nick2", ANYSTR], + ) + + @cases.mark_specifications("Modern") + def testWhowasNoParamModern(self): + """ + "If the `` argument is missing, they SHOULD send a single reply, using + either ERR_NONICKNAMEGIVEN or ERR_NEEDMOREPARAMS" + -- https://modern.ircdocs.horse/#whowas-message + """ + # But no one seems to follow this. Most implementations use ERR_NEEDMOREPARAMS + # instead of ERR_NONICKNAMEGIVEN; and I couldn't find any that returns + # RPL_ENDOFWHOWAS either way. + self.connectClient("nick1") + + self.sendLine(1, "WHOWAS") + + m = self.getMessage(1) + if m.command == ERR_NONICKNAMEGIVEN: + self.assertMessageMatch( + m, + command=ERR_NONICKNAMEGIVEN, + params=["nick1", ANYSTR], + ) + else: + self.assertMessageMatch( + m, + command=ERR_NEEDMOREPARAMS, + params=["nick1", "WHOWAS", ANYSTR], + ) + + @cases.mark_specifications("RFC1459", "RFC2812", "Modern") + @cases.xfailIfSoftware( + ["Charybdis"], + "fails because of a typo (solved in " + "https://github.com/solanum-ircd/solanum/commit/" + "08b7b6bd7e60a760ad47b58cbe8075b45d66166f)", + ) + def testWhowasNoSuchNick(self): + """ + https://datatracker.ietf.org/doc/html/rfc1459#section-4.5.3 + https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + -- https://modern.ircdocs.horse/#whowas-message + + and: + + "At the end of all reply batches, there must be RPL_ENDOFWHOWAS + (even if there was only one reply and it was an error)." + -- https://datatracker.ietf.org/doc/html/rfc1459#page-50 + -- https://datatracker.ietf.org/doc/html/rfc2812#page-45 + + and: + + "Servers MUST reply with either ERR_WASNOSUCHNICK or [...], + both followed with RPL_ENDOFWHOWAS" + -- https://modern.ircdocs.horse/#whowas-message + """ + self.connectClient("nick1") + + self.sendLine(1, "WHOWAS nick2") + + self.assertMessageMatch( + self.getMessage(1), + command=ERR_WASNOSUCHNICK, + params=["nick1", "nick2", ANYSTR], + ) + + self.assertMessageMatch( + self.getMessage(1), + command=RPL_ENDOFWHOWAS, + params=["nick1", "nick2", ANYSTR], + ) + + @cases.mark_specifications("RFC2812") + @cases.mark_isupport("TARGMAX") + @pytest.mark.parametrize("targets", ["nick2,nick3", "nick3,nick2"]) + def testWhowasMultiTarget(self, targets): + """ + https://datatracker.ietf.org/doc/html/rfc2812#section-3.6.3 + """ + if self.controller.software_name == "Bahamut": + pytest.xfail( + "Bahamut returns entries in query order instead of chronological order" + ) + + self.connectClient("nick1") + + if self.targmax.get("WHOWAS", "1") == "1": + raise runner.OptionalExtensionNotSupported("Multi-target WHOWAS") + + self.connectClient("nick2", ident="ident2") + self.sendLine(2, "QUIT :bye") + try: + self.getMessages(2) + except ConnectionClosed: + pass + + self.connectClient("nick3", ident="ident3") + self.sendLine(3, "QUIT :bye") + try: + self.getMessages(3) + except ConnectionClosed: + pass + + self.sendLine(1, f"WHOWAS {targets}") + + messages = self.getMessages(1) + + self.assertMessageMatch( + messages.pop(0), + command=RPL_WHOWASUSER, + params=[ + "nick1", + "nick3", + StrRe("~?ident3"), + ANYSTR, + "*", + "Realname", + ], + ) + while messages[0].command in (RPL_WHOISACTUALLY, RPL_WHOISSERVER): + # don't care + messages.pop(0) + + # nick2 with ident2 + self.assertMessageMatch( + messages.pop(0), + command=RPL_WHOWASUSER, + params=[ + "nick1", + "nick2", + StrRe("~?ident2"), + ANYSTR, + "*", + "Realname", + ], + ) + if messages[0].command == RPL_WHOISACTUALLY: + # don't care + messages.pop(0) + while messages[0].command in (RPL_WHOISACTUALLY, RPL_WHOISSERVER): + # don't care + messages.pop(0) + + self.assertMessageMatch( + messages.pop(0), + command=RPL_ENDOFWHOWAS, + params=["nick1", targets, ANYSTR], + fail_msg=f"Last message was not RPL_ENDOFWHOWAS ({RPL_ENDOFWHOWAS})", + ) diff --git a/irctest/server_tests/znc_playback.py b/irctest/server_tests/znc_playback.py index a5ac61f..ca00efd 100644 --- a/irctest/server_tests/znc_playback.py +++ b/irctest/server_tests/znc_playback.py @@ -1,3 +1,7 @@ +""" +`Ergo `_-specific tests of ZNC-like message playback +""" + import time from irctest import cases diff --git a/irctest/specifications.py b/irctest/specifications.py index bd070d1..d4ce38b 100644 --- a/irctest/specifications.py +++ b/irctest/specifications.py @@ -27,17 +27,20 @@ class Specifications(enum.Enum): @enum.unique class Capabilities(enum.Enum): + ACCOUNT_NOTIFY = "account-notify" ACCOUNT_TAG = "account-tag" AWAY_NOTIFY = "away-notify" BATCH = "batch" ECHO_MESSAGE = "echo-message" EXTENDED_JOIN = "extended-join" + EXTENDED_MONITOR = "extended-monitor" LABELED_RESPONSE = "labeled-response" MESSAGE_TAGS = "message-tags" MULTILINE = "draft/multiline" MULTI_PREFIX = "multi-prefix" NAMED_MODES = "draft/named-modes" SERVER_TIME = "server-time" + SETNAME = "setname" STS = "sts" @classmethod @@ -51,9 +54,14 @@ class Capabilities(enum.Enum): @enum.unique class IsupportTokens(enum.Enum): BOT = "BOT" + ELIST = "ELIST" + INVEX = "INVEX" + PREFIX = "PREFIX" MONITOR = "MONITOR" STATUSMSG = "STATUSMSG" TARGMAX = "TARGMAX" + UTF8ONLY = "UTF8ONLY" + WHOX = "WHOX" @classmethod def from_name(cls, name: str) -> IsupportTokens: diff --git a/make_workflows.py b/make_workflows.py index 4a74f90..8ffa6be 100644 --- a/make_workflows.py +++ b/make_workflows.py @@ -10,7 +10,6 @@ and keep them in sync. import enum import pathlib -import textwrap import yaml @@ -66,7 +65,7 @@ def get_install_steps(*, software_config, software_id, version_flavor): install_steps = [ { "name": f"Checkout {name}", - "uses": "actions/checkout@v2", + "uses": "actions/checkout@v3", "with": { "repository": software_config["repository"], "ref": ref, @@ -95,7 +94,7 @@ def get_build_job(*, software_config, software_id, version_flavor): cache = [ { "name": "Cache dependencies", - "uses": "actions/cache@v2", + "uses": "actions/cache@v3", "with": { "path": f"~/.cache\n${{ github.workspace }}/{path}\n", "key": "3-${{ runner.os }}-" @@ -117,18 +116,18 @@ def get_build_job(*, software_config, software_id, version_flavor): return None return { - "runs-on": "ubuntu-latest", + "runs-on": "ubuntu-22.04", "steps": [ { "name": "Create directories", "run": "cd ~/; mkdir -p .local/ go/", }, *cache, - {"uses": "actions/checkout@v2"}, + {"uses": "actions/checkout@v3"}, { - "name": "Set up Python 3.7", - "uses": "actions/setup-python@v2", - "with": {"python-version": 3.7}, + "name": "Set up Python 3.11", + "uses": "actions/setup-python@v4", + "with": {"python-version": 3.11}, }, *install_steps, *upload_steps(software_id), @@ -145,17 +144,14 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs): downloads = [] install_steps = [] for software_id in test_config.get("software", []): - if software_id == "anope": - # TODO: don't hardcode anope here - software_config = {"separate_build_job": True} - else: - software_config = config["software"][software_id] + software_config = config["software"][software_id] - env += test_config.get("env", {}).get(version_flavor.value, "") + " " + env += software_config.get("env", "") + " " if "prefix" in software_config: env += ( f"PATH={software_config['prefix']}/sbin" f":{software_config['prefix']}/bin" + f":{software_config['prefix']}" f":$PATH " ) @@ -164,7 +160,7 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs): downloads.append( { "name": "Download build artefacts", - "uses": "actions/download-artifact@v2", + "uses": "actions/download-artifact@v3", "with": {"name": f"installed-{software_id}", "path": "~"}, } ) @@ -196,21 +192,21 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs): unpack = [] return { - "runs-on": "ubuntu-latest", + "runs-on": "ubuntu-22.04", "needs": needs, "steps": [ - {"uses": "actions/checkout@v2"}, + {"uses": "actions/checkout@v3"}, { - "name": "Set up Python 3.7", - "uses": "actions/setup-python@v2", - "with": {"python-version": 3.7}, + "name": "Set up Python 3.11", + "uses": "actions/setup-python@v4", + "with": {"python-version": 3.11}, }, *downloads, *unpack, *install_steps, { - "name": "Install Atheme", - "run": "sudo apt-get install atheme-services", + "name": "Install system dependencies", + "run": "sudo apt-get install atheme-services faketime", }, { "name": "Install irctest dependencies", @@ -226,6 +222,7 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs): }, { "name": "Test with pytest", + "timeout-minutes": 30, "run": ( f"PYTEST_ARGS='--junit-xml pytest.xml' " f"PATH=$HOME/.local/bin:$PATH " @@ -235,9 +232,9 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs): { "name": "Publish results", "if": "always()", - "uses": "actions/upload-artifact@v2", + "uses": "actions/upload-artifact@v3", "with": { - "name": f"pytest results {test_id} ({version_flavor.value})", + "name": f"pytest-results_{test_id}_{version_flavor.value}", "path": "pytest.xml", }, }, @@ -245,47 +242,6 @@ def get_test_job(*, config, test_config, test_id, version_flavor, jobs): } -def get_build_job_anope(): - return { - "runs-on": "ubuntu-latest", - "steps": [ - {"uses": "actions/checkout@v2"}, - { - "name": "Create directories", - "run": "cd ~/; mkdir -p .local/ go/", - }, - { - "name": "Cache Anope", - "uses": "actions/cache@v2", - "with": { - "path": "~/.cache\n${{ github.workspace }}/anope\n", - "key": "3-${{ runner.os }}-anope-2.0.9", - }, - }, - { - "name": "Checkout Anope", - "uses": "actions/checkout@v2", - "with": { - "repository": "anope/anope", - "ref": "2.0.9", - "path": "anope", - }, - }, - { - "name": "Build Anope", - "run": script( - "cd $GITHUB_WORKSPACE/anope/", - "cp $GITHUB_WORKSPACE/data/anope/* .", - "CFLAGS=-O0 ./Config -quick", - "make -C build -j 4", - "make -C build install", - ), - }, - *upload_steps("anope"), - ], - } - - def upload_steps(software_id): """Make a tarball (to preserve permissions) and upload""" return [ @@ -295,7 +251,7 @@ def upload_steps(software_id): }, { "name": "Upload build artefacts", - "uses": "actions/upload-artifact@v2", + "uses": "actions/upload-artifact@v3", "with": { "name": f"installed-{software_id}", "path": "~/artefacts-*.tar.gz", @@ -308,7 +264,6 @@ def upload_steps(software_id): def generate_workflow(config: dict, version_flavor: VersionFlavor): - on: dict if version_flavor == VersionFlavor.STABLE: on = {"push": None, "pull_request": None} @@ -326,7 +281,6 @@ def generate_workflow(config: dict, version_flavor: VersionFlavor): } jobs = {} - jobs["build-anope"] = get_build_job_anope() for software_id in config["software"]: software_config = config["software"][software_id] @@ -351,46 +305,45 @@ def generate_workflow(config: dict, version_flavor: VersionFlavor): jobs[f"test-{test_id}"] = test_job jobs["publish-test-results"] = { - "name": "Publish Unit Tests Results", + "name": "Publish Dashboard", "needs": sorted({f"test-{test_id}" for test_id in config["tests"]} & set(jobs)), - "runs-on": "ubuntu-latest", + "runs-on": "ubuntu-22.04", # the build-and-test job might be skipped, we don't need to run # this job then "if": "success() || failure()", "steps": [ - {"uses": "actions/checkout@v2"}, + {"uses": "actions/checkout@v3"}, { "name": "Download Artifacts", - "uses": "actions/download-artifact@v2", + "uses": "actions/download-artifact@v3", "with": {"path": "artifacts"}, }, { - "name": "Publish Unit Test Results", - "uses": "actions/github-script@v4", - "if": "github.event_name == 'pull_request'", - "with": { - "result-encoding": "string", - "script": script( - textwrap.dedent( - """\ - let body = ''; - const options = {}; - options.listeners = { - stdout: (data) => { - body += data.toString(); - } - }; - await exec.exec('bash', ['-c', 'shopt -s globstar; python3 report.py artifacts/**/*.xml'], options); - github.issues.createComment({ - issue_number: context.issue.number, - owner: context.repo.owner, - repo: context.repo.repo, - body: body, - }); - return body; - """ - ) - ), + "name": "Install dashboard dependencies", + "run": script( + "python -m pip install --upgrade pip", + "pip install defusedxml docutils -r requirements.txt", + ), + }, + { + "name": "Generate dashboard", + "run": script( + "shopt -s globstar", + "python3 -m irctest.dashboard.format dashboard/ artifacts/**/*.xml", + "echo '/ /index.xhtml' > dashboard/_redirects", + ), + }, + { + "name": "Install netlify-cli", + "run": "npm i -g netlify-cli", + }, + { + "name": "Deploy to Netlify", + "run": "./.github/deploy_to_netlify.py", + "env": { + "NETLIFY_SITE_ID": "${{ secrets.NETLIFY_SITE_ID }}", + "NETLIFY_AUTH_TOKEN": "${{ secrets.NETLIFY_AUTH_TOKEN }}", + "GITHUB_TOKEN": "${{ secrets.GITHUB_TOKEN }}", }, }, ], diff --git a/mypy.ini b/mypy.ini index 984a93a..840ecdc 100644 --- a/mypy.ini +++ b/mypy.ini @@ -12,6 +12,12 @@ disallow_untyped_defs = False [mypy-irctest.client_tests.*] disallow_untyped_defs = False +[mypy-irctest.self_tests.*] +disallow_untyped_defs = False + +[mypy-defusedxml.*] +ignore_missing_imports = True + [mypy-ecdsa] ignore_missing_imports = True diff --git a/bahamut_localhost.patch b/patches/bahamut_localhost.patch similarity index 100% rename from bahamut_localhost.patch rename to patches/bahamut_localhost.patch diff --git a/patches/bahamut_mainloop.patch b/patches/bahamut_mainloop.patch new file mode 100644 index 0000000..0e0f992 --- /dev/null +++ b/patches/bahamut_mainloop.patch @@ -0,0 +1,15 @@ +Lower Bahamut's delay between processing incoming commands + +diff --git a/src/s_bsd.c b/src/s_bsd.c +index fcc1d02..951fd8c 100644 +--- a/src/s_bsd.c ++++ b/src/s_bsd.c +@@ -1458,7 +1458,7 @@ int do_client_queue(aClient *cptr) + int dolen = 0, done; + + while (SBufLength(&cptr->recvQ) && !NoNewLine(cptr) && +- ((cptr->status < STAT_UNKNOWN) || (cptr->since - timeofday < 10) || ++ ((cptr->status < STAT_UNKNOWN) || (cptr->since - timeofday < 20) || + IsNegoServer(cptr))) + { + /* If it's become registered as a server, just parse the whole block */ diff --git a/patches/bahamut_ubuntu22.patch b/patches/bahamut_ubuntu22.patch new file mode 100644 index 0000000..a80f290 --- /dev/null +++ b/patches/bahamut_ubuntu22.patch @@ -0,0 +1,342 @@ +From 42b67ff7218877934abed2a738e164c0dea171b0 Mon Sep 17 00:00:00 2001 +From: "Ned T. Crigler" +Date: Sun, 26 Feb 2023 17:42:29 -0800 +Subject: [PATCH 1/2] Fix compilation on Ubuntu 22.04 + +Starting with glibc 2.34 "The symbols __dn_comp, __dn_expand, +__dn_skipname, __res_dnok, __res_hnok, __res_mailok, __res_mkquery, +__res_nmkquery, __res_nquery, __res_nquerydomain, __res_nsearch, +__res_nsend, __res_ownok, __res_query, __res_querydomain, __res_search, +__res_send formerly in libresolv have been renamed and no longer have a +__ prefix. They are now available in libc." +https://sourceware.org/pipermail/libc-alpha/2021-August/129718.html + +The hex_to_string array in include/dh.h also conflicts with OpenSSL, +which OpenSSL 3.0 now complains about. +--- + configure.in | 4 ++-- + include/dh.h | 2 +- + include/resolv.h | 6 +++++- + src/dh.c | 2 +- + 4 files changed, 9 insertions(+), 5 deletions(-) + +diff --git a/configure.in b/configure.in +index e76dee88..11720419 100644 +--- a/configure.in ++++ b/configure.in +@@ -374,8 +374,7 @@ AC_C_INLINE + dnl Checks for libraries. + dnl Replace `main' with a function in -lnsl: + AC_CHECK_LIB(nsl, gethostbyname) +-AC_CHECK_FUNC(res_mkquery,, AC_CHECK_LIB(resolv, res_mkquery)) +-AC_CHECK_FUNC(__res_mkquery,, AC_CHECK_LIB(resolv, __res_mkquery)) ++AC_SEARCH_LIBS([res_mkquery],[resolv],,AC_SEARCH_LIBS([__res_mkquery],[resolv])) + AC_CHECK_LIB(socket, socket, zlib) + AC_CHECK_FUNC(crypt,, AC_CHECK_LIB(descrypt, crypt,,AC_CHECK_LIB(crypt, crypt,,))) + +@@ -406,6 +405,7 @@ AC_CHECK_FUNCS([strcasecmp strchr strdup strerror strncasecmp strrchr strtol]) + AC_CHECK_FUNCS([strtoul index strerror strtoken strtok inet_addr inet_netof]) + AC_CHECK_FUNCS([inet_aton gettimeofday lrand48 sigaction bzero bcmp bcopy]) + AC_CHECK_FUNCS([dn_skipname __dn_skipname getrusage times break]) ++AC_CHECK_FUNCS([res_init __res_init res_mkquery __res_mkquery dn_expand __dn_expand]) + + dnl check for various OSes + +diff --git a/include/dh.h b/include/dh.h +index 1ca6996a..1817ce1e 100644 +--- a/include/dh.h ++++ b/include/dh.h +@@ -45,7 +45,7 @@ struct session_info + static BIGNUM *ircd_prime; + static BIGNUM *ircd_generator; + +-static char *hex_to_string[256] = ++static char *dh_hex_to_string[256] = + { + "00", "01", "02", "03", "04", "05", "06", "07", + "08", "09", "0a", "0b", "0c", "0d", "0e", "0f", +diff --git a/include/resolv.h b/include/resolv.h +index b5a8aaa1..5b042d43 100644 +--- a/include/resolv.h ++++ b/include/resolv.h +@@ -106,9 +106,13 @@ extern struct state _res; + + extern char *p_cdname(), *p_rr(), *p_type(), *p_class(), *p_time(); + +-#if ((__GNU_LIBRARY__ == 6) && (__GLIBC__ >=2) && (__GLIBC_MINOR__ >= 2)) ++#if !defined(HAVE_RES_INIT) && defined(HAVE___RES_INIT) + #define res_init __res_init ++#endif ++#if !defined(HAVE_RES_MKQUERY) && defined(HAVE___RES_MKQUERY) + #define res_mkquery __res_mkquery ++#endif ++#if !defined(HAVE_DN_EXPAND) && defined(HAVE___DN_EXPAND) + #define dn_expand __dn_expand + #endif + +diff --git a/src/dh.c b/src/dh.c +index cb065a4f..4b5da282 100644 +--- a/src/dh.c ++++ b/src/dh.c +@@ -223,7 +223,7 @@ static void create_prime() + + for(i = 0; i < PRIME_BYTES; i++) + { +- char *x = hex_to_string[dh_prime_1024[i]]; ++ char *x = dh_hex_to_string[dh_prime_1024[i]]; + while(*x) + buf[bufpos++] = *x++; + } + +From 135ebbea4c30e23228d00af762fa7da7ca5016bd Mon Sep 17 00:00:00 2001 +From: "Ned T. Crigler" +Date: Mon, 22 May 2023 15:31:54 -0700 +Subject: [PATCH 2/2] Update the dh code to work with OpenSSL 3.0 + +--- + include/dh.h | 8 ++++ + src/dh.c | 120 ++++++++++++++++++++++++++++++++++++++++++++++++--- + 2 files changed, 123 insertions(+), 5 deletions(-) + +diff --git a/include/dh.h b/include/dh.h +index 1817ce1e..705e6dee 100644 +--- a/include/dh.h ++++ b/include/dh.h +@@ -22,7 +22,11 @@ extern void rc4_destroystate(void *a); + + struct session_info + { ++#if OPENSSL_VERSION_NUMBER < 0x30000000L + DH *dh; ++#else ++ EVP_PKEY *dh; ++#endif + unsigned char *session_shared; + size_t session_shared_length; + }; +@@ -45,6 +49,10 @@ struct session_info + static BIGNUM *ircd_prime; + static BIGNUM *ircd_generator; + ++#if OPENSSL_VERSION_NUMBER >= 0x30000000L ++static EVP_PKEY *ircd_prime_ossl3; ++#endif ++ + static char *dh_hex_to_string[256] = + { + "00", "01", "02", "03", "04", "05", "06", "07", +diff --git a/src/dh.c b/src/dh.c +index 4b5da282..f74d2d76 100644 +--- a/src/dh.c ++++ b/src/dh.c +@@ -36,6 +36,11 @@ + #include + #include "libcrypto-compat.h" + ++#if OPENSSL_VERSION_NUMBER >= 0x30000000L ++#include ++#include ++#endif ++ + #include "memcount.h" + + #define DH_HEADER +@@ -215,7 +220,7 @@ static int init_random() + return 0; + } + +-static void create_prime() ++static int create_prime() + { + char buf[PRIME_BYTES_HEX]; + int i; +@@ -233,6 +238,34 @@ static void create_prime() + BN_hex2bn(&ircd_prime, buf); + ircd_generator = BN_new(); + BN_set_word(ircd_generator, dh_gen_1024); ++ ++#if OPENSSL_VERSION_NUMBER >= 0x30000000L ++ OSSL_PARAM_BLD *paramBuild = NULL; ++ OSSL_PARAM *param = NULL; ++ EVP_PKEY_CTX *primeCtx = NULL; ++ ++ if(!(paramBuild = OSSL_PARAM_BLD_new()) || ++ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_P, ircd_prime) || ++ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_G, ircd_generator) || ++ !(param = OSSL_PARAM_BLD_to_param(paramBuild)) || ++ !(primeCtx = EVP_PKEY_CTX_new_from_name(NULL, "DHX", NULL)) || ++ EVP_PKEY_fromdata_init(primeCtx) <= 0 || ++ EVP_PKEY_fromdata(primeCtx, &ircd_prime_ossl3, ++ EVP_PKEY_KEY_PARAMETERS, param) <= 0 || ++ 1) ++ { ++ if(primeCtx) ++ EVP_PKEY_CTX_free(primeCtx); ++ if(param) ++ OSSL_PARAM_free(param); ++ if(paramBuild) ++ OSSL_PARAM_BLD_free(paramBuild); ++ } ++ ++ if(!ircd_prime_ossl3) ++ return -1; ++#endif ++ return 0; + } + + int dh_init() +@@ -241,8 +274,7 @@ int dh_init() + ERR_load_crypto_strings(); + #endif + +- create_prime(); +- if(init_random() == -1) ++ if(create_prime() == -1 || init_random() == -1) + return -1; + return 0; + } +@@ -250,7 +282,7 @@ int dh_init() + int dh_generate_shared(void *session, char *public_key) + { + BIGNUM *tmp; +- int len; ++ size_t len; + struct session_info *si = (struct session_info *) session; + + if(verify_is_hex(public_key) == 0 || !si || si->session_shared) +@@ -261,13 +293,55 @@ int dh_generate_shared(void *session, char *public_key) + if(!tmp) + return 0; + ++#if OPENSSL_VERSION_NUMBER < 0x30000000L + si->session_shared_length = DH_size(si->dh); + si->session_shared = (unsigned char *) malloc(DH_size(si->dh)); + len = DH_compute_key(si->session_shared, tmp, si->dh); ++#else ++ OSSL_PARAM_BLD *paramBuild = NULL; ++ OSSL_PARAM *param = NULL; ++ EVP_PKEY_CTX *peerPubKeyCtx = NULL; ++ EVP_PKEY *peerPubKey = NULL; ++ EVP_PKEY_CTX *deriveCtx = NULL; ++ ++ len = -1; ++ if(!(paramBuild = OSSL_PARAM_BLD_new()) || ++ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_P, ircd_prime) || ++ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_FFC_G, ircd_generator) || ++ !OSSL_PARAM_BLD_push_BN(paramBuild, OSSL_PKEY_PARAM_PUB_KEY, tmp) || ++ !(param = OSSL_PARAM_BLD_to_param(paramBuild)) || ++ !(peerPubKeyCtx = EVP_PKEY_CTX_new_from_name(NULL, "DHX", NULL)) || ++ EVP_PKEY_fromdata_init(peerPubKeyCtx) <= 0 || ++ EVP_PKEY_fromdata(peerPubKeyCtx, &peerPubKey, ++ EVP_PKEY_PUBLIC_KEY, param) <= 0 || ++ !(deriveCtx = EVP_PKEY_CTX_new(si->dh, NULL)) || ++ EVP_PKEY_derive_init(deriveCtx) <= 0 || ++ EVP_PKEY_derive_set_peer(deriveCtx, peerPubKey) <= 0 || ++ EVP_PKEY_derive(deriveCtx, NULL, &len) <= 0 || ++ !(si->session_shared = malloc(len)) || ++ EVP_PKEY_derive(deriveCtx, si->session_shared, &len) <= 0 || ++ 1) ++ { ++ if(deriveCtx) ++ EVP_PKEY_CTX_free(deriveCtx); ++ if(peerPubKey) ++ EVP_PKEY_free(peerPubKey); ++ if(peerPubKeyCtx) ++ EVP_PKEY_CTX_free(peerPubKeyCtx); ++ if(param) ++ OSSL_PARAM_free(param); ++ if(paramBuild) ++ OSSL_PARAM_BLD_free(paramBuild); ++ } ++#endif + BN_free(tmp); + +- if(len < 0) ++ if(len == -1 || !si->session_shared) ++ { ++ if(si->session_shared) ++ free(si->session_shared); + return 0; ++ } + + si->session_shared_length = len; + +@@ -284,6 +358,7 @@ void *dh_start_session() + + memset(si, 0, sizeof(struct session_info)); + ++#if OPENSSL_VERSION_NUMBER < 0x30000000L + si->dh = DH_new(); + if(si->dh == NULL) + return NULL; +@@ -304,7 +379,23 @@ void *dh_start_session() + MyFree(si); + return NULL; + } ++#else ++ EVP_PKEY_CTX *keyGenCtx = NULL; + ++ if(!(keyGenCtx = EVP_PKEY_CTX_new_from_pkey(NULL, ircd_prime_ossl3, NULL)) || ++ EVP_PKEY_keygen_init(keyGenCtx) <= 0 || ++ EVP_PKEY_generate(keyGenCtx, &si->dh) <= 0 || ++ 1) ++ { ++ if(keyGenCtx) ++ EVP_PKEY_CTX_free(keyGenCtx); ++ } ++ if(!si->dh) ++ { ++ MyFree(si); ++ return NULL; ++ } ++#endif + return (void *) si; + } + +@@ -312,6 +403,7 @@ void dh_end_session(void *session) + { + struct session_info *si = (struct session_info *) session; + ++#if OPENSSL_VERSION_NUMBER < 0x30000000L + if(si->dh) + { + DH_free(si->dh); +@@ -324,6 +416,13 @@ void dh_end_session(void *session) + free(si->session_shared); + si->session_shared = NULL; + } ++#else ++ if(si->dh) ++ { ++ EVP_PKEY_free(si->dh); ++ si->dh = NULL; ++ } ++#endif + + MyFree(si); + } +@@ -333,6 +432,7 @@ char *dh_get_s_public(char *buf, size_t maxlen, void *session) + struct session_info *si = (struct session_info *) session; + char *tmp; + ++#if OPENSSL_VERSION_NUMBER < 0x30000000L + if(!si || !si->dh) + return NULL; + +@@ -343,6 +443,16 @@ char *dh_get_s_public(char *buf, size_t maxlen, void *session) + return NULL; + + tmp = BN_bn2hex(pub_key); ++#else ++ BIGNUM *pub_key = NULL; ++ ++ if(!si || !si->dh) ++ return NULL; ++ if(!EVP_PKEY_get_bn_param(si->dh, OSSL_PKEY_PARAM_PUB_KEY, &pub_key)) ++ return NULL; ++ tmp = BN_bn2hex(pub_key); ++ BN_free(pub_key); ++#endif + if(!tmp) + return NULL; + diff --git a/patches/charybdis_ubuntu22.patch b/patches/charybdis_ubuntu22.patch new file mode 100644 index 0000000..fe93952 --- /dev/null +++ b/patches/charybdis_ubuntu22.patch @@ -0,0 +1,23 @@ +From fa5d445e5e2af735378a1219d2a200ee8aef6561 Mon Sep 17 00:00:00 2001 +From: Sadie Powell +Date: Sun, 25 Jun 2023 21:50:42 +0100 +Subject: [PATCH] Fix Charybdis on Ubuntu 22.04. + +--- + librb/include/rb_lib.h | 2 ++ + 1 file changed, 2 insertions(+) + +diff --git a/librb/include/rb_lib.h b/librb/include/rb_lib.h +index c02dff68..0dd9c378 100644 +--- a/librb/include/rb_lib.h ++++ b/librb/include/rb_lib.h +@@ -258,4 +258,6 @@ pid_t rb_getpid(void); + #include + #include + ++#include ++ + #endif +-- +2.34.1 + diff --git a/inspircd_mainloop.patch b/patches/inspircd_mainloop.patch similarity index 100% rename from inspircd_mainloop.patch rename to patches/inspircd_mainloop.patch diff --git a/patches/ngircd_whowas_delay.patch b/patches/ngircd_whowas_delay.patch new file mode 100644 index 0000000..80e322e --- /dev/null +++ b/patches/ngircd_whowas_delay.patch @@ -0,0 +1,19 @@ +ngIRCd skips WHOWAS entries for users that were connected for less +than 30 seconds. + +To avoid waiting 30s in every WHOWAS test, we need to remove this. + +diff --git a/src/ngircd/client.c b/src/ngircd/client.c +index 67c02604..66e8e540 100644 +--- a/src/ngircd/client.c ++++ b/src/ngircd/client.c +@@ -1490,9 +1490,6 @@ Client_RegisterWhowas( CLIENT *Client ) + return; + + now = time(NULL); +- /* Don't register clients that were connected less than 30 seconds. */ +- if( now - Client->starttime < 30 ) +- return; + + slot = Last_Whowas + 1; + if( slot >= MAX_WHOWAS || slot < 0 ) slot = 0; diff --git a/pytest.ini b/pytest.ini index 979044c..6e0d983 100644 --- a/pytest.ini +++ b/pytest.ini @@ -18,23 +18,34 @@ markers = private_chathistory # capabilities + account-notify account-tag away-notify batch echo-message extended-join + extended-monitor labeled-response message-tags draft/multiline multi-prefix draft/named-modes server-time + setname sts # isupport tokens BOT + ELIST + INVEX MONITOR + PREFIX STATUSMSG TARGMAX + UTF8ONLY + WHOX python_classes = *TestCase Test* + +# Include stdout in pytest.xml files used by the dashboard. +junit_logging = system-out diff --git a/report.py b/report.py index 744fb7c..0c626a3 100644 --- a/report.py +++ b/report.py @@ -42,7 +42,7 @@ def partial_compaction(d): # tests separate compacted_d = {} successes = [] - for (k, v) in d.items(): + for k, v in d.items(): if isinstance(v, CompactedResult) and v.success and v.nb_skipped == 0: successes.append((k, v)) else: diff --git a/requirements.txt b/requirements.txt index fe27660..a608954 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ +pytest + # The following dependencies are actually optional: ecdsa -pytest +filelock diff --git a/workflows.yml b/workflows.yml index b5e4a97..700e5d0 100644 --- a/workflows.yml +++ b/workflows.yml @@ -18,6 +18,7 @@ software: separate_build_job: true build_script: | cd $GITHUB_WORKSPACE/charybdis/ + patch -p1 < $GITHUB_WORKSPACE/patches/charybdis_ubuntu22.patch ./autogen.sh ./configure --prefix=$HOME/.local/ make -j 4 @@ -27,7 +28,7 @@ software: name: Hybrid repository: ircd-hybrid/ircd-hybrid refs: - stable: "8.2.38" + stable: "8.2.39" release: null devel: "8.2.x" devel_release: null @@ -47,8 +48,8 @@ software: stable: - name: clone run: |- - curl https://gitlab.com/rizon/plexus4/-/archive/403a967e3677a2a8420b504f451e7557259e0790/plexus4-403a967e3677a2a8420b504f451e7557259e0790.tar.gz | tar -zx - mv plexus4* plexus4 + curl https://gitlab.com/rizon/plexus4/-/archive/20211115_0-611/plexus4-20211115_0-611.tar | tar -x + mv plexus* plexus4 - name: build run: |- cd $GITHUB_WORKSPACE/plexus4 @@ -77,7 +78,7 @@ software: refs: # Actually Solanum doesn't have releases; so we just bump this # commit hash from time to time - stable: e370888264da666a1bd9faac86cd5f2aa06084f4 + stable: 492d560ee13e71dc35403fd676e58c2d5bdcf2a9 release: null devel: main devel_release: null @@ -96,7 +97,7 @@ software: name: Bahamut repository: DALnet/Bahamut refs: - stable: "v2.2.0" + stable: "v2.2.1" release: null devel: "master" devel_release: null @@ -104,7 +105,12 @@ software: separate_build_job: true build_script: | cd $GITHUB_WORKSPACE/Bahamut/ - patch src/s_user.c < $GITHUB_WORKSPACE/bahamut_localhost.patch + patch src/s_user.c < $GITHUB_WORKSPACE/patches/bahamut_localhost.patch + patch src/s_bsd.c < $GITHUB_WORKSPACE/patches/bahamut_mainloop.patch + + # <= v2.2.2 + patch -p1 < $GITHUB_WORKSPACE/patches/bahamut_ubuntu22.patch || true + echo "#undef THROTTLE_ENABLE" >> include/config.h libtoolize --force aclocal @@ -130,7 +136,7 @@ software: pre_deps: - uses: actions/setup-go@v2 with: - go-version: '^1.17.0' + go-version: '^1.21.0' - run: go version separate_build_job: false build_script: | @@ -142,7 +148,7 @@ software: name: InspIRCd repository: inspircd/inspircd refs: &inspircd_refs - stable: v3.10.0 + stable: v3.15.0 release: null devel: master devel_release: insp3 @@ -152,40 +158,44 @@ software: separate_build_job: true build_script: &inspircd_build_script | cd $GITHUB_WORKSPACE/inspircd/ - patch src/inspircd.cpp < $GITHUB_WORKSPACE/inspircd_mainloop.patch + + # Insp3 <= 3.16.0 and Insp4 <= 4.0.0a21 don't support -DINSPIRCD_UNLIMITED_MAINLOOP + patch src/inspircd.cpp < $GITHUB_WORKSPACE/patches/inspircd_mainloop.patch || true + wget https://raw.githubusercontent.com/progval/inspircd-contrib/namedmodes/4.0/m_ircv3_namedmodes.cpp -O src/modules/m_ircv3_namedmodes.cpp ./configure --prefix=$HOME/.local/inspircd --development - make -j 4 + + CXXFLAGS=-DINSPIRCD_UNLIMITED_MAINLOOP make -j 4 make install irc2: name: irc2 separate_build_job: false - install_steps: - stable: - - name: Get source code - run: |- - curl http://ftp.irc.org/ftp/irc/server/irc2.11.2p3.tgz | tar -zx - - name: Configure - run: |- - cd $GITHUB_WORKSPACE/irc2.11.2p3 - ./configure --prefix=$HOME/.local/ - cd x86* - echo "#define CMDLINE_CONFIG/" >> config.h - echo "#define DEFAULT_SPLIT_USERS 0" >> config.h - echo "#define DEFAULT_SPLIT_SERVERS 0" >> config.h - #echo "#undef LIST_ALIS_NOTE" >> config.h - # TODO: find a better way to make it not fork... - echo "#define fork() (0)" >> config.h - - name: Compile and install - run: |- - cd $GITHUB_WORKSPACE/irc2.11.2p3/x86* - make -j 4 all - make install - mkdir -p $HOME/.local/bin - cp $HOME/.local/sbin/ircd $HOME/.local/bin/ircd + repository : irc-archive/irc2-mirror + path: irc2.11.2p3 + cache: true + refs: + stable: 59649f24c3a5c27bad5648b48774f27475bccfd3 # irc2.11.2p3 release: null devel: null devel_release: null + build_script: | + # Configure + cd $GITHUB_WORKSPACE/irc2.11.2p3 + ./configure --prefix=$HOME/.local/ + cd x86* + echo "#define CMDLINE_CONFIG/" >> config.h + echo "#define DEFAULT_SPLIT_USERS 0" >> config.h + echo "#define DEFAULT_SPLIT_SERVERS 0" >> config.h + #echo "#undef LIST_ALIS_NOTE" >> config.h + # TODO: find a better way to make it not fork... + echo "#define fork() (0)" >> config.h + + # Compile and install + cd $GITHUB_WORKSPACE/irc2.11.2p3/x86* + make -j 4 all + make install + mkdir -p $HOME/.local/bin + cp $HOME/.local/sbin/ircd $HOME/.local/bin/ircd ircu2: name: ircu2 @@ -205,11 +215,28 @@ software: make -j 4 make install + nefarious: + name: nefarious + repository: evilnet/nefarious2 + refs: + stable: "985704168ecada12d9e53b46df6087ef9d9fb40b" + release: null + devel: "master" + devel_release: null + path: nefarious + separate_build_job: false + build_script: | + cd $GITHUB_WORKSPACE/nefarious + ./configure --prefix=$HOME/.local/ --enable-debug + make -j 4 + make install + cp $GITHUB_WORKSPACE/data/nefarious/* $HOME/.local/lib + ngircd: name: ngircd repository: ngircd/ngircd refs: - stable: rel-26.1 + stable: 0714466af88d71d6c395629cd7fb624b099507d4 # two years ahead of rel-26.1 release: null devel: master devel_release: null @@ -218,11 +245,40 @@ software: separate_build_job: true build_script: | cd $GITHUB_WORKSPACE/ngircd + patch src/ngircd/client.c < $GITHUB_WORKSPACE/patches/ngircd_whowas_delay.patch ./autogen.sh ./configure --prefix=$HOME/.local/ make -j 4 make install + sable: + name: Sable + repository: Libera-Chat/sable + refs: + stable: ff1179512a79eba57ca468a5f83af84ecce08a5b + release: null + devel: master + devel_release: null + path: sable + prefix: "$GITHUB_WORKSPACE/sable/target/debug" + pre_deps: + - name: Install rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly + profile: minimal + override: true + - name: Enable Cargo cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: "sable -> target" + cache-on-failure: true + - run: rustc --version + separate_build_job: false + build_script: | + cd $GITHUB_WORKSPACE/sable/ + cargo build + snircd: name: snircd repository: quakenet/snircd @@ -250,8 +306,8 @@ software: name: UnrealIRCd 6 repository: unrealircd/unrealircd refs: - stable: d77f42e4bef388ae344256eeef9a8000345ae381 # 6.0.0 + 2 commits - release: 893bf864f6c616e891d84916d27e342c252f31aaa # 6.0.0 + stable: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7 + release: da3c1c654481a33035b9c703957e1c25d0158259 # 6.0.7 devel: unreal60_dev devel_release: null path: unrealircd @@ -267,12 +323,14 @@ software: CFLAGS="-O0 -march=x86-64" CXXFLAGS="$CFLAGS" ./Config -quick make -j 4 make install + # Prevent download of geoIP database on first startup + sed -i 's/loadmodule "geoip_classic";//' ~/.local/unrealircd/conf/modules.default.conf unrealircd-5: name: UnrealIRCd 5 repository: unrealircd/unrealircd refs: - stable: 94993a03ca8d3c193c0295c33af39270c3f9d27d # 5.2.1-rc1 + stable: 6604856973f713a494f83d38992d7d61ce6b9db4 # 5.2.4 release: null devel: unreal52 devel_release: @@ -281,6 +339,47 @@ software: separate_build_job: true build_script: *unrealircd_build_script + + ############################# + # Services: + + anope: + name: Anope + repository: anope/anope + separate_build_job: true + path: anope + refs: + stable: "2.0.9" + release: "2.0.9" + devel: "2.0.9" + devel_release: "2.0.9" + build_script: | + cd $GITHUB_WORKSPACE/anope/ + cp $GITHUB_WORKSPACE/data/anope/* . + CFLAGS=-O0 ./Config -quick + make -C build -j 4 + make -C build install + + dlk: + name: Dlk + repository: DalekIRC/Dalek-Services + separate_build_job: false + path: Dlk-Services + refs: + stable: null # disabled because flaky, and hard to debug with all the PHP 8 warnings + release: &dlk_stable "6db51ea03f039c48fd20427c04cec8ff98df7878" + devel: "main" + devel_release: *dlk_stable + build_script: | + pip install pifpaf + wget -q https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar + wget -q https://wordpress.org/latest.zip -O wordpress-latest.zip + env: >- + IRCTEST_DLK_PATH="${{ github.workspace }}/Dlk-Services" + IRCTEST_WP_CLI_PATH="${{ github.workspace }}/wp-cli.phar" + IRCTEST_WP_ZIP_PATH="${{ github.workspace }}/wordpress-latest.zip" + + ############################# # Clients: @@ -290,7 +389,7 @@ software: install_steps: stable: - name: Install dependencies - run: pip install limnoria==2021.10.09 cryptography pyxmpp2-scram + run: pip install limnoria==2023.5.27 cryptography pyxmpp2-scram release: - name: Install dependencies run: pip install limnoria cryptography pyxmpp2-scram @@ -305,7 +404,7 @@ software: install_steps: stable: - name: Install dependencies - run: pip install sopel==7.1.1 + run: pip install sopel==7.1.8 release: - name: Install dependencies run: pip install sopel @@ -314,6 +413,23 @@ software: run: pip install git+https://github.com/sopel-irc/sopel.git devel_release: null + thelounge: + name: TheLounge + repository: thelounge/thelounge + separate_build_job: false + refs: + stable: "v4.4.0" + release: "v4.4.0" + devel: "master" + devel_release: null + path: thelounge + build_script: | + cd $GITHUB_WORKSPACE/thelounge + yarn install + NODE_ENV=production yarn build + mkdir -p ~/.local/bin/ + ln -s $(pwd)/index.js ~/.local/bin/thelounge + tests: bahamut: software: [bahamut] @@ -358,16 +474,22 @@ tests: plexus4: software: [plexus4, anope] - # doesn't build because it can't find liblex for some reason - #snircd: - # software: [snircd] - irc2: software: [irc2] ircu2: software: [ircu2] + nefarious: + software: [nefarious] + + sable: + software: [sable] + + # doesn't build because it can't find liblex for some reason + #snircd: + # software: [snircd] + unrealircd-5: software: [unrealircd-5] @@ -380,9 +502,15 @@ tests: unrealircd-anope: software: [unrealircd, anope] + unrealircd-dlk: + software: [unrealircd, dlk] + limnoria: software: [limnoria] sopel: software: [sopel] + + thelounge: + software: [thelounge]