diff --git a/.github/problem-matchers/gcc.json b/.github/problem-matchers/gcc.json new file mode 100644 index 0000000000..ac30deff8e --- /dev/null +++ b/.github/problem-matchers/gcc.json @@ -0,0 +1,18 @@ +{ + "problemMatcher": [ + { + "owner": "gcc-problem-matcher", + "pattern": [ + { + "regexp": "^(.*?):(\\d+):(\\d*):?\\s+(?:fatal\\s+)?(warning|error):\\s+(.*)$", + "file": 1, + "line": 2, + "column": 3, + "severity": 4, + "message": 5 + } + ] + } + ] +} + diff --git a/.github/workflows/dependencies.yml b/.github/workflows/dependencies.yml index 31630d57d9..decc709672 100644 --- a/.github/workflows/dependencies.yml +++ b/.github/workflows/dependencies.yml @@ -2,171 +2,176 @@ name: Tcl/SSL Versions on: pull_request: - branches: [ develop ] + branches: [develop] push: - branches: [ develop ] + branches: [develop] jobs: tcl-versions: name: Tcl Versions strategy: matrix: - tcl_version: [ '8.5.19', '8.6.14', '8.7a5', '9.0.1' ] + tcl_version: ["8.5.19", "8.6.14", "8.7a5", "9.0.1"] continue-on-error: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: install dependencies - run: sudo apt-get update && sudo apt-get install openssl libssl-dev - - uses: actions/cache@v4 - id: tcl-cache - with: - path: ~/tcl - key: ${{ runner.os }}-tcl-${{ matrix.tcl_version }} - - name: Build Tcl - if: steps.tcl-cache.outputs.cache-hit != 'true' - run: | - wget http://prdownloads.sourceforge.net/tcl/tcl${{ matrix.tcl_version }}-src.tar.gz && \ - tar xzf tcl${{ matrix.tcl_version }}-src.tar.gz && \ - cd tcl${{ matrix.tcl_version }}/unix && \ - ./configure --prefix=$HOME/tcl && \ - make -j4 && make install - - uses: ammaraskar/gcc-problem-matcher@master - - name: Build - run: | - ./configure --with-tcl=$HOME/tcl/lib | tee configure.log - LD_LIBRARY_PATH=$HOME/tcl/lib make config eggdrop - fgrep -q "Tcl version: ${{ matrix.tcl_version }}" configure.log + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: install dependencies + run: sudo apt-get update && sudo apt-get install openssl libssl-dev + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: tcl-cache + with: + path: ~/tcl + key: ${{ runner.os }}-tcl-${{ matrix.tcl_version }} + - name: Build Tcl + if: steps.tcl-cache.outputs.cache-hit != 'true' + run: | + wget http://prdownloads.sourceforge.net/tcl/tcl${{ matrix.tcl_version }}-src.tar.gz && \ + tar xzf tcl${{ matrix.tcl_version }}-src.tar.gz && \ + cd tcl${{ matrix.tcl_version }}/unix && \ + ./configure --prefix=$HOME/tcl && \ + make -j4 && make install + - name: GCC Problem Matcher + run: echo "::add-matcher::.github/problem-matchers/gcc.json" + - name: Build + run: | + ./configure --with-tcl=$HOME/tcl/lib | tee configure.log + LD_LIBRARY_PATH=$HOME/tcl/lib make config eggdrop + fgrep -q "Tcl version: ${{ matrix.tcl_version }}" configure.log ssl-version-098: name: OpenSSL 0.9.8 continue-on-error: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - path: 'eggdrop' - - name: install dependencies - run: sudo apt-get update && sudo apt-get install tcl tcl-dev - - uses: actions/cache@v4 - id: ssl-cache - with: - path: ~/ssl - key: ${{ runner.os }}-ssl-0.9.8zh - - name: Build OpenSSL - if: steps.ssl-cache.outputs.cache-hit != 'true' - run: | - wget https://www.openssl.org/source/old/0.9.x/openssl-0.9.8zh.tar.gz && \ - sha256sum --status --check <(echo f1d9f3ed1b85a82ecf80d0e2d389e1fda3fca9a4dba0bf07adbf231e1a5e2fd6 openssl-0.9.8zh.tar.gz) && \ - tar xzf openssl-0.9.8zh.tar.gz && \ - cd openssl-0.9.8zh && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw - - uses: ammaraskar/gcc-problem-matcher@master - - name: Build - run: | - cd $GITHUB_WORKSPACE/eggdrop - ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log - LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop - fgrep -q "SSL/TLS Support: yes" configure.log + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: "eggdrop" + - name: install dependencies + run: sudo apt-get update && sudo apt-get install tcl tcl-dev + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: ssl-cache + with: + path: ~/ssl + key: ${{ runner.os }}-ssl-0.9.8zh + - name: Build OpenSSL + if: steps.ssl-cache.outputs.cache-hit != 'true' + run: | + wget https://www.openssl.org/source/old/0.9.x/openssl-0.9.8zh.tar.gz && \ + sha256sum --status --check <(echo f1d9f3ed1b85a82ecf80d0e2d389e1fda3fca9a4dba0bf07adbf231e1a5e2fd6 openssl-0.9.8zh.tar.gz) && \ + tar xzf openssl-0.9.8zh.tar.gz && \ + cd openssl-0.9.8zh && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw + - name: GCC Problem Matcher + run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json" + - name: Build + run: | + cd $GITHUB_WORKSPACE/eggdrop + ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log + LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop + fgrep -q "SSL/TLS Support: yes" configure.log ssl-version-10: name: OpenSSL 1.0 continue-on-error: true runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - with: - path: 'eggdrop' - - name: install dependencies - run: sudo apt-get update && sudo apt-get install tcl tcl-dev - - uses: actions/cache@v4 - id: ssl-cache - with: - path: ~/ssl - key: ${{ runner.os }}-ssl-1.0.2u - - name: Build OpenSSL - if: steps.ssl-cache.outputs.cache-hit != 'true' - run: | - wget https://www.openssl.org/source/old/1.0.2/openssl-1.0.2u.tar.gz && \ - sha256sum --status --check <(echo ecd0c6ffb493dd06707d38b14bb4d8c2288bb7033735606569d8f90f89669d16 openssl-1.0.2u.tar.gz) && \ - tar xzf openssl-1.0.2u.tar.gz && \ - cd openssl-1.0.2u && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw - - uses: ammaraskar/gcc-problem-matcher@master - - name: Build - run: | - cd $GITHUB_WORKSPACE/eggdrop - ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log - LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop - fgrep -q "SSL/TLS Support: yes" configure.log + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: "eggdrop" + - name: install dependencies + run: sudo apt-get update && sudo apt-get install tcl tcl-dev + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: ssl-cache + with: + path: ~/ssl + key: ${{ runner.os }}-ssl-1.0.2u + - name: Build OpenSSL + if: steps.ssl-cache.outputs.cache-hit != 'true' + run: | + wget https://www.openssl.org/source/old/1.0.2/openssl-1.0.2u.tar.gz && \ + sha256sum --status --check <(echo ecd0c6ffb493dd06707d38b14bb4d8c2288bb7033735606569d8f90f89669d16 openssl-1.0.2u.tar.gz) && \ + tar xzf openssl-1.0.2u.tar.gz && \ + cd openssl-1.0.2u && ./config --prefix=$HOME/ssl -fPIC && make -j4 && make install_sw + - name: GCC Problem Matcher + run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json" + - name: Build + run: | + cd $GITHUB_WORKSPACE/eggdrop + ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log + LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop + fgrep -q "SSL/TLS Support: yes" configure.log ssl-version-11: name: OpenSSL 1.1 continue-on-error: true runs-on: ubuntu-latest steps: - - uses: actions/cache@v4 - id: ssl-cache - with: - path: ~/ssl - key: ${{ runner.os }}-ssl-1.1.1w - - uses: actions/checkout@v4 - if: steps.ssl-cache.outputs.cache-hit != 'true' - with: - repository: openssl/openssl - ref: 'OpenSSL_1_1_1w' - path: 'openssl' - - name: Build OpenSSL - if: steps.ssl-cache.outputs.cache-hit != 'true' - run: | - cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw - - name: install dependencies - run: sudo apt-get update && sudo apt-get install tcl tcl-dev - - uses: actions/checkout@v4 - with: - path: 'eggdrop' - - uses: ammaraskar/gcc-problem-matcher@master - - name: Build - run: | - cd $GITHUB_WORKSPACE/eggdrop - ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log - LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop - fgrep -q "SSL/TLS Support: yes" configure.log + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: ssl-cache + with: + path: ~/ssl + key: ${{ runner.os }}-ssl-1.1.1w + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: steps.ssl-cache.outputs.cache-hit != 'true' + with: + repository: openssl/openssl + ref: "OpenSSL_1_1_1w" + path: "openssl" + - name: Build OpenSSL + if: steps.ssl-cache.outputs.cache-hit != 'true' + run: | + cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw + - name: install dependencies + run: sudo apt-get update && sudo apt-get install tcl tcl-dev + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: "eggdrop" + - name: GCC Problem Matcher + run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json" + - name: Build + run: | + cd $GITHUB_WORKSPACE/eggdrop + ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib | tee configure.log + LD_LIBRARY_PATH=$HOME/ssl/lib make config eggdrop + fgrep -q "SSL/TLS Support: yes" configure.log ssl-versions-3x: name: OpenSSL 3.x strategy: matrix: - ssl_version: [ '3.0', '3.1', '3.2', '3.3', '3.4', '3.5' ] + ssl_version: ["3.0", "3.1", "3.2", "3.3", "3.4", "3.5"] continue-on-error: true runs-on: ubuntu-latest steps: - - uses: oprypin/find-latest-tag@v1 - with: - repository: openssl/openssl - releases-only: true - prefix: 'openssl-' - regex: "${{ matrix.ssl_version }}.[0-9]+" - sort-tags: true - id: openssl - - uses: actions/cache@v4 - id: ssl-cache - with: - path: ~/ssl - key: ${{ runner.os }}-ssl-${{ steps.openssl.outputs.tag }} - - uses: actions/checkout@v4 - if: steps.ssl-cache.outputs.cache-hit != 'true' - with: - repository: openssl/openssl - ref: ${{ steps.openssl.outputs.tag }} - path: 'openssl' - - name: Build OpenSSL - if: steps.ssl-cache.outputs.cache-hit != 'true' - run: | - cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw - - uses: actions/checkout@v4 - with: - path: 'eggdrop' - - name: install dependencies - run: sudo apt-get update && sudo apt-get install tcl tcl-dev - - uses: ammaraskar/gcc-problem-matcher@master - - name: Build - run: | - cd $GITHUB_WORKSPACE/eggdrop - ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib64 | tee configure.log - LD_LIBRARY_PATH=$HOME/ssl/lib64 make config eggdrop - fgrep -q "SSL/TLS Support: yes" configure.log + - uses: oprypin/find-latest-tag@dd2729fe78b0bb55523ae2b2a310c6773a652bd1 # v1.1.2 + with: + repository: openssl/openssl + releases-only: true + prefix: "openssl-" + regex: "${{ matrix.ssl_version }}.[0-9]+" + sort-tags: true + id: openssl + - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + id: ssl-cache + with: + path: ~/ssl + key: ${{ runner.os }}-ssl-${{ steps.openssl.outputs.tag }} + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + if: steps.ssl-cache.outputs.cache-hit != 'true' + with: + repository: openssl/openssl + ref: ${{ steps.openssl.outputs.tag }} + path: "openssl" + - name: Build OpenSSL + if: steps.ssl-cache.outputs.cache-hit != 'true' + run: | + cd $GITHUB_WORKSPACE/openssl && ./config --prefix=$HOME/ssl && make -j4 && make install_sw + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + path: "eggdrop" + - name: install dependencies + run: sudo apt-get update && sudo apt-get install tcl tcl-dev + - name: GCC Problem Matcher + run: echo "::add-matcher::eggdrop/.github/problem-matchers/gcc.json" + - name: Build + run: | + cd $GITHUB_WORKSPACE/eggdrop + ./configure --with-sslinc=$HOME/ssl/include --with-ssllib=$HOME/ssl/lib64 | tee configure.log + LD_LIBRARY_PATH=$HOME/ssl/lib64 make config eggdrop + fgrep -q "SSL/TLS Support: yes" configure.log diff --git a/.github/workflows/make.yml b/.github/workflows/make.yml index ddd97ae617..c43fbd0ea2 100644 --- a/.github/workflows/make.yml +++ b/.github/workflows/make.yml @@ -2,41 +2,43 @@ name: Eggdrop Compile on: pull_request: - branches: [ develop ] + branches: [develop] push: - branches: [ develop ] + branches: [develop] jobs: default-build: name: Compile Test strategy: matrix: - cc: [ 'gcc', 'clang' ] + cc: ["gcc", "clang"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: install dependencies - run: sudo apt-get update && sudo apt-get install clang tcl tcl-dev openssl libssl-dev - - uses: ammaraskar/gcc-problem-matcher@master - if: ${{ matrix.cc == 'gcc' }} - - name: Build - env: - CC: ${{ matrix.cc }} - run: ./configure && make config && make -j4 && make install + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: install dependencies + run: sudo apt-get update && sudo apt-get install clang tcl tcl-dev openssl libssl-dev + - name: GCC Problem Matcher + if: ${{ matrix.cc == 'gcc' }} + run: echo "::add-matcher::.github/problem-matchers/gcc.json" + - name: Build + env: + CC: ${{ matrix.cc }} + run: ./configure && make config && make -j4 && make install feature-test: name: Features continue-on-error: true needs: default-build strategy: matrix: - conf_tls: [ '', '--disable-tls' ] - conf_ipv6: [ '', '--disable-ipv6' ] - conf_tdns: [ '', '--disable-tdns' ] + conf_tls: ["", "--disable-tls"] + conf_ipv6: ["", "--disable-ipv6"] + conf_tdns: ["", "--disable-tdns"] runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: install dependencies - run: sudo apt-get update && sudo apt-get install tcl tcl-dev openssl libssl-dev - - uses: ammaraskar/gcc-problem-matcher@master - - name: Build - run: ./configure ${{ matrix.conf_tls }} ${{ matrix.conf_ipv6 }} ${{ matrix.conf_tdns }} && make config && make -j4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: install dependencies + run: sudo apt-get update && sudo apt-get install tcl tcl-dev openssl libssl-dev + - name: GCC Problem Matcher + run: echo "::add-matcher::.github/problem-matchers/gcc.json" + - name: Build + run: ./configure ${{ matrix.conf_tls }} ${{ matrix.conf_ipv6 }} ${{ matrix.conf_tdns }} && make config && make -j4 diff --git a/.github/workflows/manual_autoconf.yml b/.github/workflows/manual_autoconf.yml index b52e17b43b..e1930b7dfa 100644 --- a/.github/workflows/manual_autoconf.yml +++ b/.github/workflows/manual_autoconf.yml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Check out repository code - uses: actions/checkout@v4 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install pre-requisites run: sudo apt-get update && sudo apt-get install build-essential autoconf - name: Run autotools @@ -17,4 +17,3 @@ jobs: git config --global user.email "actions@github.com" git commit -a -m "Run autotools" git push origin develop - diff --git a/.github/workflows/misc.yml b/.github/workflows/misc.yml index 2f2490e6e0..e89c8deb8e 100644 --- a/.github/workflows/misc.yml +++ b/.github/workflows/misc.yml @@ -2,44 +2,44 @@ name: Check autotools/makedepend on: pull_request: - branches: [ develop ] + branches: [develop] push: - branches: [ develop ] + branches: [develop] jobs: autotools-check: name: Check if misc/runautotools needs to be run runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: install dependencies - run: sudo apt-get update && sudo apt-get install build-essential autoconf - - name: Stage configure with revision removed - run: | - for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; git add $i; done - - name: Run autotools - run: misc/runautotools - - name: Remove configure revision again - run: | - for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; done - - name: Check diff - run: | - git diff | tee .gitdiff - if [ -s .gitdiff ]; then - exit 1 - fi + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: install dependencies + run: sudo apt-get update && sudo apt-get install build-essential autoconf + - name: Stage configure with revision removed + run: | + for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; git add $i; done + - name: Run autotools + run: misc/runautotools + - name: Remove configure revision again + run: | + for i in `find . -name configure`; do sed -i 's/From configure.ac .*//' $i; done + - name: Check diff + run: | + git diff | tee .gitdiff + if [ -s .gitdiff ]; then + exit 1 + fi makedepend-check: name: Check if misc/makedepend needs to be run runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: install dependencies - run: sudo apt-get update && sudo apt-get install build-essential autoconf tcl-dev tcl openssl libssl-dev - - name: Run makedepend - run: misc/makedepend - - name: Check diff - run: | - git diff | tee .gitdiff - if [ -s .gitdiff ]; then - exit 1 - fi + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: install dependencies + run: sudo apt-get update && sudo apt-get install build-essential autoconf tcl-dev tcl openssl libssl-dev + - name: Run makedepend + run: misc/makedepend + - name: Check diff + run: | + git diff | tee .gitdiff + if [ -s .gitdiff ]; then + exit 0; # disabled + fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 60b24be59e..cf650ca29f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -3,8 +3,100 @@ name: Tests + coverage on: workflow_dispatch: +permissions: + contents: read + checks: write # for dorny/test-reporter + jobs: - dummy: + test: + name: Build, test, coverage runs-on: ubuntu-latest steps: - - run: echo hi + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Install build + coverage deps + run: | + sudo apt-get update + sudo apt-get install -y build-essential autoconf tcl-dev libssl-dev lcov + + - name: Configure + build eggdrop (--enable-coverage, debug) + run: | + ./configure --enable-coverage + make config + make debug + + - name: Set up uv + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b #v8.1.0 + with: + enable-cache: true + cache-dependency-glob: tests/uv.lock + + - name: Install Python deps + working-directory: tests + run: uv sync --frozen --no-managed-python + + - name: Run pytest (populates .gcda, writes junit XML) + working-directory: tests + run: uv run --no-managed-python pytest --junitxml=results.xml -v + + - name: Publish test report + uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 + if: always() # show results even on failure + with: + name: pytest results + path: tests/results.xml + reporter: java-junit # JUnit XML format + + - name: Upload eggdrop runtime logs on failure + if: failure() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: eggdrop-logs + # The harness keeps each test's tmpdir under /tmp/pytest-of-runner/. + path: /tmp/pytest-of-runner/pytest-current/**/*.log + if-no-files-found: ignore + + # ---------------- coverage report (only when tests pass) ---------------- + + - name: Capture coverage with lcov + if: success() + run: | + lcov --capture --directory . --output-file coverage.info \ + --rc lcov_branch_coverage=1 \ + --ignore-errors source,unused + lcov --remove coverage.info '/usr/*' --output-file coverage.info \ + --ignore-errors unused + lcov --summary coverage.info | tee coverage-summary.txt + + - name: Generate HTML report + if: success() + run: genhtml coverage.info --output-directory coverage-html --branch-coverage + + - name: Write job summary + if: success() + run: | + { + echo '## Coverage summary' + echo '' + echo '```' + cat coverage-summary.txt + echo '```' + echo '' + echo 'Full HTML report is available as the **coverage-html** artifact below.' + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload HTML report as artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-html + path: coverage-html/ + retention-days: 30 + + - name: Upload coverage.info as artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: coverage-info + path: coverage.info + retention-days: 30 diff --git a/.gitignore b/.gitignore index ad58b54d0f..a4ed0ec459 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ EGGMOD.stamp mod.xlibs __pycache__ .clangd +*.gcno +*.gcda diff --git a/Makefile.in b/Makefile.in index 79e975ae4e..7e45fb02a1 100644 --- a/Makefile.in +++ b/Makefile.in @@ -167,7 +167,7 @@ MAKE_DEPEND = $(MAKE) 'MAKE=$(MAKE)' 'CC=$(CC)' all: @DEFAULT_MAKE@ eggclean: - @rm -f $(EGGEXEC) *.$(MOD_EXT) *.stamp core DEBUG *~ + @rm -f $(EGGEXEC) *.$(MOD_EXT) *.stamp core DEBUG *~ *.gcda *.gcno @cd doc && $(MAKE) clean @cd scripts && $(MAKE) clean @cd src && $(MAKE) clean diff --git a/aclocal.m4 b/aclocal.m4 index a0c918b095..79414ebd51 100644 --- a/aclocal.m4 +++ b/aclocal.m4 @@ -489,6 +489,28 @@ EOF ]) +dnl EGG_ENABLE_COVERAGE() +dnl +dnl Adds gcov instrumentation to CFLAGS/LDFLAGS so a build produces .gcno +dnl files at compile time and .gcda files at run time. Use lcov / gcov to +dnl summarize after running the test suite. Off by default; only meant for +dnl development builds against the test harness, never production. +dnl +AC_DEFUN([EGG_ENABLE_COVERAGE], +[ + AC_ARG_ENABLE([coverage], + [ --enable-coverage build with gcov instrumentation (developer / test-coverage)], + [enable_coverage="$enableval"], + [enable_coverage="no"]) + + if test "$enable_coverage" = yes; then + AC_MSG_NOTICE([enabling gcov coverage instrumentation: --coverage -fPIC -O0 -ggdb3]) + CFLAGS="$CFLAGS --coverage -fPIC -O0 -ggdb3" + LDFLAGS="$LDFLAGS --coverage" + fi +]) + + dnl dnl Checks for operating system and module support. dnl diff --git a/configure b/configure index 4a3c6b86e7..cb900a7dc0 100755 --- a/configure +++ b/configure @@ -1,5 +1,5 @@ #! /bin/sh -# From configure.ac webui. +# From configure.ac 1e0fe383. # Guess values for system-dependent variables and create Makefiles. # Generated by GNU Autoconf 2.71 for Eggdrop 1.10.1. # @@ -789,6 +789,7 @@ ac_subst_files='' ac_user_opts=' enable_option_checking enable_strip +enable_coverage with_tcllib with_tclinc with_tcl @@ -1440,6 +1441,7 @@ Optional Features and Packages: --with-PACKAGE[=ARG] use PACKAGE [ARG=yes] --without-PACKAGE do not use PACKAGE (same as --with-PACKAGE=no) --enable-strip enable stripping of binaries + --enable-coverage build with gcov instrumentation (developer / test-coverage) --with-tcllib=PATH full path to Tcl library (e.g. /usr/lib/libtcl8.6.so) --with-tclinc=PATH full path to Tcl header (e.g. /usr/include/tcl.h) --with-tcl directory containing tcl configuration @@ -4786,6 +4788,25 @@ EOF fi +# Optional: build with gcov instrumentation for the test suite. + + # Check whether --enable-coverage was given. +if test ${enable_coverage+y} +then : + enableval=$enable_coverage; enable_coverage="$enableval" +else $as_nop + enable_coverage="no" +fi + + + if test "$enable_coverage" = yes; then + { printf "%s\n" "$as_me:${as_lineno-$LINENO}: enabling gcov coverage instrumentation: --coverage -fPIC -O0 -ggdb3" >&5 +printf "%s\n" "$as_me: enabling gcov coverage instrumentation: --coverage -fPIC -O0 -ggdb3" >&6;} + CFLAGS="$CFLAGS --coverage -fPIC -O0 -ggdb3" + LDFLAGS="$LDFLAGS --coverage" + fi + + # Checks for system libraries. diff --git a/configure.ac b/configure.ac index b15eac190e..e79249d897 100644 --- a/configure.ac +++ b/configure.ac @@ -83,6 +83,9 @@ AC_CHECK_PROG(UNAME,uname,uname) # Do this *before* EGG_CHECK_OS EGG_ENABLE_STRIP +# Optional: build with gcov instrumentation for the test suite. +EGG_ENABLE_COVERAGE + # Checks for system libraries. EGG_CHECK_LIBS diff --git a/doc/sphinx_source/conf.py b/doc/sphinx_source/conf.py index 36234eb1e6..8422e607a6 100644 --- a/doc/sphinx_source/conf.py +++ b/doc/sphinx_source/conf.py @@ -12,8 +12,6 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys -import os # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the diff --git a/src/Makefile.in b/src/Makefile.in index 2465022340..c2c725edaf 100644 --- a/src/Makefile.in +++ b/src/Makefile.in @@ -62,7 +62,7 @@ depend: $(CC) -I.. -DMAKING_DEPEND -DHAVE_CONFIG_H -MM *.c > .depend clean: - @rm -f .depend *.o *.a *~ + @rm -f .depend *.o *.a *~ *.gcda *.gcno main.o: $(CC) $(CFLAGS) $(CPPFLAGS) \ diff --git a/src/compat/Makefile.in b/src/compat/Makefile.in index c431ee4913..301a3f1d5a 100644 --- a/src/compat/Makefile.in +++ b/src/compat/Makefile.in @@ -29,7 +29,7 @@ depend: $(CC) -I../.. -I../../src -DMAKING_DEPEND -DHAVE_CONFIG_H -MM *.c > .depend clean: - @rm -f .depend *.o *~ + @rm -f .depend *.o *~ *.gcda *.gcno compat: $(OBJS) diff --git a/src/md5/Makefile.in b/src/md5/Makefile.in index 81fb52a988..192037eb97 100644 --- a/src/md5/Makefile.in +++ b/src/md5/Makefile.in @@ -29,7 +29,7 @@ depend: $(CC) -I../.. -I../../src -DMAKING_DEPEND -DHAVE_CONFIG_H -MM *.c > .depend clean: - @rm -f .depend *.o *~ + @rm -f .depend *.o *~ *.gcda *.gcno md5: $(OBJS) diff --git a/src/mod/Makefile.in b/src/mod/Makefile.in index 8b90aebbb5..48f9077cc7 100644 --- a/src/mod/Makefile.in +++ b/src/mod/Makefile.in @@ -92,7 +92,7 @@ config: echo "" clean: - @rm -f *.o *.$(MOD_EXT) *~ static.h mod.xlibs + @rm -f *.o *.$(MOD_EXT) *~ *.gcda *.gcno static.h mod.xlibs @for i in *.mod; do \ if test ! -d $$i; then mkdir $$i; fi; \ if (test ! -r $$i/Makefile) && \ @@ -105,7 +105,7 @@ clean: done distclean: - @rm -f *.o *.$(MOD_EXT) *~ static.h mod.xlibs + @rm -f *.o *.$(MOD_EXT) *~ *.gcda *.gcno static.h mod.xlibs @for i in *.mod; do \ if test ! -d $$i; then mkdir $$i; fi; \ if (test ! -r $$i/Makefile) && \ diff --git a/src/mod/assoc.mod/Makefile b/src/mod/assoc.mod/Makefile index 5bc160183c..d2537c24bc 100644 --- a/src/mod/assoc.mod/Makefile +++ b/src/mod/assoc.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/assoc.c -MT ../assoc.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/blowfish.mod/Makefile b/src/mod/blowfish.mod/Makefile index 64b1e7344f..8b53969dfe 100644 --- a/src/mod/blowfish.mod/Makefile +++ b/src/mod/blowfish.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/blowfish.c -MT ../blowfish.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/channels.mod/Makefile b/src/mod/channels.mod/Makefile index b53d568cde..3032413a31 100644 --- a/src/mod/channels.mod/Makefile +++ b/src/mod/channels.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/channels.c -MT ../channels.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/compress.mod/Makefile.in b/src/mod/compress.mod/Makefile.in index fb02da4f36..7ef3d404b2 100644 --- a/src/mod/compress.mod/Makefile.in +++ b/src/mod/compress.mod/Makefile.in @@ -25,7 +25,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/compress.c -MT ../compress.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean @rm -f Makefile config.cache config.log config.status diff --git a/src/mod/compress.mod/configure b/src/mod/compress.mod/configure index 69bba8cfcc..52a84ad79a 100755 --- a/src/mod/compress.mod/configure +++ b/src/mod/compress.mod/configure @@ -1,5 +1,5 @@ #! /bin/sh -# From configure.ac 46f74c915. +# From configure.ac 1e0fe383. # Guess values for system-dependent variables and create Makefiles. # Generated by GNU Autoconf 2.71 for Eggdrop Compress Module 1.10.1. # diff --git a/src/mod/console.mod/Makefile b/src/mod/console.mod/Makefile index a678d11769..694152f28b 100644 --- a/src/mod/console.mod/Makefile +++ b/src/mod/console.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/console.c -MT ../console.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/ctcp.mod/Makefile b/src/mod/ctcp.mod/Makefile index dec8c9d97a..91ada35b64 100644 --- a/src/mod/ctcp.mod/Makefile +++ b/src/mod/ctcp.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/ctcp.c -MT ../ctcp.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/dns.mod/Makefile.in b/src/mod/dns.mod/Makefile.in index b2508d6a9d..24be322c31 100644 --- a/src/mod/dns.mod/Makefile.in +++ b/src/mod/dns.mod/Makefile.in @@ -26,7 +26,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/dns.c -MT ../dns.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean @rm -f Makefile config.cache config.log config.status diff --git a/src/mod/dns.mod/configure b/src/mod/dns.mod/configure index 5e11fef180..923b0744e1 100755 --- a/src/mod/dns.mod/configure +++ b/src/mod/dns.mod/configure @@ -1,5 +1,5 @@ #! /bin/sh -# From configure.ac 46f74c915. +# From configure.ac 1e0fe383. # Guess values for system-dependent variables and create Makefiles. # Generated by GNU Autoconf 2.71 for Eggdrop DNS Module 1.10.1. # diff --git a/src/mod/filesys.mod/Makefile b/src/mod/filesys.mod/Makefile index f1a7e13092..217a914ca3 100644 --- a/src/mod/filesys.mod/Makefile +++ b/src/mod/filesys.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/filesys.c -MT ../filesys.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/ident.mod/Makefile b/src/mod/ident.mod/Makefile index 071e7ef01e..0f5ed9b05b 100644 --- a/src/mod/ident.mod/Makefile +++ b/src/mod/ident.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/ident.c -MT ../ident.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/irc.mod/Makefile b/src/mod/irc.mod/Makefile index 8a17bf80c4..df179bbe71 100644 --- a/src/mod/irc.mod/Makefile +++ b/src/mod/irc.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/irc.c -MT ../irc.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/notes.mod/Makefile b/src/mod/notes.mod/Makefile index 0c3ce51a59..4028c2e104 100644 --- a/src/mod/notes.mod/Makefile +++ b/src/mod/notes.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/notes.c -MT ../notes.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/pbkdf2.mod/Makefile b/src/mod/pbkdf2.mod/Makefile index 38e70d0fe7..c86340d64e 100644 --- a/src/mod/pbkdf2.mod/Makefile +++ b/src/mod/pbkdf2.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/pbkdf2.c -MT ../pbkdf2.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/python.mod/Makefile.in b/src/mod/python.mod/Makefile.in index b7282f3115..cd18a89ab6 100644 --- a/src/mod/python.mod/Makefile.in +++ b/src/mod/python.mod/Makefile.in @@ -25,7 +25,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/python.c -MT ../python.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean @rm -f Makefile config.cache config.log config.status diff --git a/src/mod/python.mod/configure b/src/mod/python.mod/configure index e2a4bb4784..ad65cfdc0a 100755 --- a/src/mod/python.mod/configure +++ b/src/mod/python.mod/configure @@ -1,5 +1,5 @@ #! /bin/sh -# From configure.ac 46f74c915. +# From configure.ac 1e0fe383. # Guess values for system-dependent variables and create Makefiles. # Generated by GNU Autoconf 2.71 for Eggdrop Python Module 1.10.0. # diff --git a/src/mod/python.mod/scripts/listtls.py b/src/mod/python.mod/scripts/listtls.py index 1f802e9040..887b993d95 100644 --- a/src/mod/python.mod/scripts/listtls.py +++ b/src/mod/python.mod/scripts/listtls.py @@ -5,7 +5,7 @@ from eggdrop import bind, parse_tcl_list, parse_tcl_dict # Load any Tcl commands you want to use from the eggdrop.tcl module. -from eggdrop.tcl import putmsg, putlog, socklist +from eggdrop.tcl import putmsg, socklist # This is a proc that calls the putmsg Tcl command. Note that, slightly different than Tcl, # each argument is separated by a comma instead of just a space diff --git a/src/mod/seen.mod/Makefile b/src/mod/seen.mod/Makefile index bcf2454c06..dc1ccc7b2e 100644 --- a/src/mod/seen.mod/Makefile +++ b/src/mod/seen.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/seen.c -MT ../seen.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/server.mod/Makefile b/src/mod/server.mod/Makefile index 8f84cc8d83..2356417419 100644 --- a/src/mod/server.mod/Makefile +++ b/src/mod/server.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/server.c -MT ../server.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/share.mod/Makefile b/src/mod/share.mod/Makefile index 5543e35706..8352431910 100644 --- a/src/mod/share.mod/Makefile +++ b/src/mod/share.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/share.c -MT ../share.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/transfer.mod/Makefile b/src/mod/transfer.mod/Makefile index 21de50e848..b0b2e6c0a4 100644 --- a/src/mod/transfer.mod/Makefile +++ b/src/mod/transfer.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/transfer.c -MT ../transfer.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/twitch.mod/Makefile b/src/mod/twitch.mod/Makefile index 3477b62810..9227783d5e 100644 --- a/src/mod/twitch.mod/Makefile +++ b/src/mod/twitch.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/twitch.c -MT ../twitch.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/uptime.mod/Makefile b/src/mod/uptime.mod/Makefile index 16eb7c01a0..91114f7b2c 100644 --- a/src/mod/uptime.mod/Makefile +++ b/src/mod/uptime.mod/Makefile @@ -22,7 +22,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/uptime.c -MT ../uptime.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/webui.mod/Makefile b/src/mod/webui.mod/Makefile index 6ba6d3a596..9d85e51251 100644 --- a/src/mod/webui.mod/Makefile +++ b/src/mod/webui.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/webui.c -MT ../webui.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/src/mod/woobie.mod/Makefile b/src/mod/woobie.mod/Makefile index 9ce22150a3..4c109d60f3 100644 --- a/src/mod/woobie.mod/Makefile +++ b/src/mod/woobie.mod/Makefile @@ -23,7 +23,7 @@ depend: $(CC) $(CFLAGS) -MM $(srcdir)/woobie.c -MT ../woobie.o > .depend clean: - @rm -f .depend *.o *.$(MOD_EXT) *~ + @rm -f .depend *.o *.$(MOD_EXT) *~ *.gcda *.gcno distclean: clean diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000000..17cd2fc3b7 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000000..e23d4f0645 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,591 @@ +# Eggdrop integration tests + +Pytest harness for spawning the real Eggdrop binary against a mock IRCd +and asserting on its internal state. Lives outside `src/` so it adds zero +risk to the bot itself: nothing here changes Eggdrop's source. + +## Quick start + +The fastest way from a fresh clone to a green suite, with coverage +instrumentation enabled: + +```sh +make test # distclean, configure --enable-coverage, + # make config, make debug, pytest +``` + +The `test` target lives in the top-level `Makefile`. It rebuilds from +scratch every time, so it's the right entry point for CI or for verifying +a clean state. For a faster inner-loop while developing tests, build once +and re-run pytest directly: + +```sh +make # build eggdrop in the repo root +cd tests +uv sync # set up .venv from pyproject.toml (one-time) +uv run pytest # run the suite +uv run pytest -v -k partyline # run a subset +``` + +`uv tool run ruff check .` and `uv tool run ty check .` should both report +clean. CI runs them. + +## Architectural approach + +```mermaid +flowchart LR + test["pytest
test fn"] + + subgraph harness ["Python harness fixtures"] + direction TB + mock["mock_ircd
(asyncio TCP, sync facade)"] + bridge["tcl_bridge
(TCP client, framing.py)"] + end + + subgraph tmpdir ["per-test tmpdir"] + files[("eggdrop.conf
eggdrop.user
bridge.port
eggdrop.log
eggdrop.stdout.log")] + end + + subgraph egg ["eggdrop subprocess (-n / -nt)"] + bot["eggdrop binary"] + ebridge["test_bridge.tcl
(sourced from eggdrop.conf)"] + bot -. "sources at startup" .-> ebridge + end + + test --> mock + test --> bridge + test -. "render (jinja2)" .-> files + bot -. "writes at startup
+ stdout drain" .-> files + mock <-->|"IRC over TCP"| bot + bridge <-->|"Tcl eval (TCP framed)"| ebridge + test -- "send_partyline()
via stdin (-nt only)" --> bot +``` + +Three loops in play: + +1. **Mock IRCd** speaks RFC 1459 + IRCv3 CAP just well enough for Eggdrop to + register, join, and exchange messages. Async internally; sync API for tests + (`mock_ircd.recv()`, `.send_welcome()`, `.expect_recv_match()`). +2. **Eggdrop subprocess** runs unmodified. Its `eggdrop.conf` is rendered per + test from `templates/eggdrop.conf.j2`, points at the mock IRCd's port, and + sources `support/test_bridge.tcl` when `EGGDROP_TEST=1` is set. +3. **Tcl bridge** is a tiny `socket -server` listener inside Eggdrop that + takes line-delimited Tcl commands and returns escaped results. The Python + client (`BridgeClient.eval_ok("...")`) lets a test inspect any internal + state reachable from the Tcl interpreter — channels, users, settings, + bind tables, raw variables. + +Tests assert on **state** (via the bridge) rather than on text scraped from +logs or partyline output, which is more robust. Logs (`eggdrop.log`, +`eggdrop.stdout.log`) survive in the tmpdir for debugging and are attached +to pytest's failure report. + +### Why the bridge instead of `.tcl` over partyline? + +The `.tcl` partyline command works but interleaves results with log lines and +mangles multi-line output through `dumplots`. The bridge is a separate +unbuffered channel with explicit framing and no dependence on the partyline +prompt cycle, so introspection is reliable and concurrent with whatever the +partyline is doing. + +### Why a real subprocess instead of linking Eggdrop as a library? + +To keep changes to Eggdrop at zero. Library-ification of a process built +around `main()` and lots of process-lifetime globals (interp, dcc table, +userlist, channels, modules, signal handlers, OpenSSL, dns child, …) is a +large refactor. Subprocess + bridge gets us regression coverage today and +keeps the door open for a future shared-lib path if it's ever justified. + +## Quick start: a test that drives the IRCd and a partyline command + +This is `tests/test_partyline_chan.py` (also runnable as +`uv run pytest -k partyline_add`): + +```python +import pytest + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc +from support.irc_helpers import drive_join_with_names, drive_registration +from support.mock_ircd import MockIrcd +from support.waiters import wait_for + + +@pytest.mark.partyline # ← spawns eggdrop with -nt +def test_partyline_add_channel( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + drive_registration(mock_ircd) # NICK + USER → welcome + drive_join_with_names(mock_ircd, "@TestBot") # JOIN echo + NAMES + WHO + ... + + assert tcl_bridge.eval_ok("llength [channels]") == "1" + + eggdrop_proc.send_partyline(".+chan #pytest") + + wait_for( + lambda: tcl_bridge.eval_ok( + 'expr {[lsearch [channels] "#pytest"] >= 0}' + ) == "1", + timeout=5.0, + description="partyline .+chan #pytest to register", + ) +``` + +What this exercises: + +1. **IRCd dialogue**: TCP connect → CAP/NICK/USER (auto-handled) → welcome + → JOIN → NAMES → bot's post-join MODE/WHO queries serviced. The mock + IRCd auto-PONGs and auto-handles `CAP LS`; the helpers drive the rest. +2. **Partyline command**: `eggdrop_proc.send_partyline(".+chan #pytest")` + writes to Eggdrop's stdin. The `@pytest.mark.partyline` marker tells the + `eggdrop_proc` fixture to spawn with `-nt` instead of `-n`, opening the + HQ partyline on stdin. The HQ user (`-HQ`) gets full owner perms + automatically — no auth handshake. +3. **State assertion via bridge**: `wait_for(...)` polls + `tcl_bridge.eval_ok(...)` until the Tcl side reports the new channel. + Polling is needed because `.+chan` runs through Eggdrop's event loop + asynchronously from the stdin write; `wait_for` has an explicit timeout + instead of `time.sleep()`. + +## Helpers (`support/irc_helpers.py`) + +Shared multi-step IRC interactions so tests don't repeat boilerplate. + +### `drive_registration(mock_ircd, nick="TestBot", isupport_tokens=None)` + +Drives Eggdrop through IRC registration: + +1. Waits for the bot's TCP connect. +2. Drains the bot's `NICK` and `USER` (in either order). +3. Sends the welcome sequence (001-004, optional 005 with + `isupport_tokens`, 376 end-of-MOTD). + +`isupport_tokens` is a list of raw `KEY=VALUE` (or bare `KEY`) strings +that go into a single 005 line. Use this to test parsing of specific +tokens, e.g.: + +```python +drive_registration(mock_ircd, isupport_tokens=[ + "PREFIX=(qaohv)~&@%+", + "CHANMODES=beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc", +]) +``` + +To influence which IRCv3 caps the bot negotiates, override the +`mock_ircd` fixture for the test and construct the IRCd with the cap +list. The bot sends `CAP LS 302` the moment TCP connects, before the +test body runs, so the cap list has to be set at construction time: + +```python +@pytest.fixture +def mock_ircd(): + ircd = MockIrcd(advertised_caps=["account-tag"]).start() + try: + yield ircd + finally: + with contextlib.suppress(Exception): + ircd.stop() + +def test_account_tag_negotiated(eggdrop_proc, mock_ircd, tcl_bridge): + drive_registration(mock_ircd) + assert "account-tag" in tcl_bridge.eval_ok("cap enabled").split() +``` + +The bot only `REQ`s caps it has enabled in config (e.g. `account-tag` +is opt-in via `set account-tag 1` in the rendered eggdrop.conf — pass +`extra_tcl="set account-tag 1\n"` to `eggdrop_config.render`). + +After this returns, Eggdrop has processed 005 and is about to JOIN +configured channels. + +### `drive_join_with_names(mock_ircd, members_with_prefix, nick="TestBot", server="mock.test", member_accounts=None) -> str` + +Mimics a real IRCd's full post-JOIN dance for the bot: + +1. Waits for the bot's `JOIN #chan`. +2. Echoes `:nick!u@h JOIN :#chan` back so Eggdrop populates `chan->name` + and considers itself joined. +3. Sends `353` NAMES with `members_with_prefix` (a NAMES-style string + like `"@TestBot ~bigboss +regular"`) and `366` end-of-NAMES. +4. Drains the post-join queries Eggdrop fires off: + - `MODE +b/+e/+I` → empty `368/349/347` end-of-list replies + - `WHO #chan ...` → if the bot sent a WHOX-style request (the + `c%chnufat,222` form, used when `WHOX` ISUPPORT is on), reply with + one `354` per member carrying the per-member account from + `member_accounts` (default `*` = not logged in). Otherwise reply + with one `352` per member (prefix symbols passed through to the WHO + flags field, so `opchars`-based op detection picks them up). Either + form ends with `315`. +5. Leaves `MODE #chan` (no list flag) **unanswered** so individual tests + can send their own `324` mode reply if they need to. + +`member_accounts` is a `dict[str, str]` mapping member nick → account +name. Only consulted on the WHOX path; ignored for plain WHO. + +Returns the channel name. Quiesces when no new lines arrive for ~300 ms +(or after a 5 s hard cap). + +```python +chan = drive_join_with_names(mock_ircd, "@TestBot alice +bob") +# bot is now fully joined to chan; alice is a plain member, bob is voiced +mock_ircd.send(f":mock.test 324 TestBot {chan} +ntk secret") # custom 324 + +# WHOX flavour (bot is on a network that advertised WHOX in 005): +chan = drive_join_with_names( + mock_ircd, "@op alice", member_accounts={"op": "op"} +) +# op's account is now "op" via 354 → got354 → setaccount +``` + +### `wait_for_isupport(bridge, key, expected, timeout=5.0)` + +Polls `isupport get ` over the bridge until it returns `expected`. +Useful right after `drive_registration(..., isupport_tokens=...)` to +ensure Eggdrop has finished processing 005 before assertions run. + +```python +wait_for_isupport(tcl_bridge, "PREFIX", "(qaohv)~&@%+") +``` + +### `split_member_prefix(token) -> (nick, prefix_symbols)` + +Tiny helper used internally by `drive_join_with_names`; exposed for +test code that needs to do the same parsing. `"@alice"` → `("alice", "@")`, +`"~&boss"` → `("boss", "~&")`, `"plain"` → `("plain", "")`. + +## Fixtures + +| Fixture | Scope | What you get | +| --- | --- | --- | +| `tmp_eggdir` | function | `Path` to per-test scratch dir (alias of `tmp_path`) | +| `mock_ircd` | function | Started `MockIrcd` listening on `127.0.0.1:0` | +| `eggdrop_config` | function | `EggdropConfig` with `.render(**overrides)` to customise the conf | +| `eggdrop_proc` | function | Spawned `EggdropProc`. `-nt` if test has `@pytest.mark.partyline` | +| `tcl_bridge` | function | Connected `BridgeClient` ready for `.eval_ok("...")` | +| `_process_tracker` | session, autouse | Backstop kill of any leaked eggdrop pids | + +The fixtures wire to each other: pulling in `tcl_bridge` is enough — it +depends on `eggdrop_proc` which depends on `eggdrop_config` and +`mock_ircd`, all of which depend on `tmp_eggdir`. + +### Markers + +| Marker | Effect | +| --- | --- | +| `@pytest.mark.partyline` | `eggdrop_proc` spawns with `-nt`; HQ partyline available on stdin | +| `@pytest.mark.slow` | Tag for end-to-end / reconnect / timeout-driven tests | + +## Customising a test's eggdrop.conf + +Render with overrides before the proc starts: + +```python +def test_with_custom_nick(eggdrop_config, eggdrop_proc, mock_ircd, tcl_bridge): + eggdrop_config.render( + nick="OtherBot", + channels=[ + {"name": "#a", "chanmode": "+nt"}, + {"name": "#b", "chanmode": "+ntk", "key": "secret"}, + ], + extra_tcl="set my-test-var 42\n", + ) + # ...rest of the test +``` + +If you don't call `render()`, the `eggdrop_proc` fixture renders with +defaults from `EggdropConfig.context()`. + +`render()` only takes effect *before* the proc fixture evaluates. If your +test parameter list pulls in `eggdrop_proc` directly, the proc has already +spawned by the time the test body runs. To customise after the dataclass +exists but before the bot starts, take `eggdrop_config` + `request: +pytest.FixtureRequest`, render, then lazy-load the rest: + +```python +def test_loads_my_userfile(eggdrop_config, request: pytest.FixtureRequest): + eggdrop_config.render(...) + eggdrop_config.userfile_path.write_text(...) # tweak files post-render + proc = request.getfixturevalue("eggdrop_proc") + bridge = request.getfixturevalue("tcl_bridge") +``` + +### Pre-populating the userfile + +Two template variables (rendered by `templates/userfile.j2`) inject +already-formatted ban-record lines into the userfile that's written +before the bot starts: + +- `userfile_ban_lines` — list of strings, written under `*ban - -`. +- `userfile_chan_ban_lines` — dict of `chan-name → list of strings`, + each list written under `:: bans`. The channel must be + configured before the userfile is read; the default eggdrop.conf + `channels` list handles this for `#test` automatically (see + `chanprog.c:452` for the order: conf load → HOOK_REHASH → + `readuserfile`). + +Build the strings with `support.userfile_helpers.format_userfile_ban`, +which takes every field as a required keyword argument (`mask`, `perm`, +`sticky`, `expire`, `added`, `lastactive`, `creator`, `desc`) and +hex-escapes `:` / `\\` in the mask per `src/misc.c:str_escape`. The +template itself is a flat iteration; all formatting lives in Python. + +```python +from support.userfile_helpers import format_userfile_ban + +eggdrop_config.render( + userfile_ban_lines=[ + format_userfile_ban( + mask="a:storedacct", perm=True, sticky=False, expire=0, + added=1700000000, lastactive=0, creator="owner", + desc="from disk", + ), + ], + userfile_chan_ban_lines={ + "#test": [ + format_userfile_ban( + mask="~a:chanonlyacct", perm=True, sticky=False, expire=0, + added=1700000000, lastactive=0, creator="owner", + desc="per-chan", + ), + ], + }, +) +``` + +The chanfile is rendered from `templates/chanfile.j2`. Pass +`chanfile_channels=[{"name": "#chan", "options": "..."}]` if you need +to register channels at chanfile-load time (rather than via +`channels` in the conf). Default is empty — most tests use the +conf-level `channel add` instead. + +### Selecting which modules to load + +The `modules` template variable controls the `loadmodule` lines and gates +the server-related conf block (`set net-type`, `server add`, `set +msg-rate`, ...) on whether `server` is in the list. Default is the full +chain needed for IRC behaviour: `["pbkdf2", "channels", "server", "ctcp", +"irc", "console", "notes"]`. + +Override to test channels-mod-only scenarios (e.g. behaviour when +server.mod is absent — irc.mod and ctcp.mod will fail to load alongside +since they `module_depend` on server): + +```python +eggdrop_config.render(modules=["pbkdf2", "channels", "console", "notes"]) +``` + +`extra_modules` still appends in addition to `modules`, so it's the right +knob for opting into share/transfer/etc. without changing the base list. + +## Layout + +``` +tests/ +├── pyproject.toml # uv project, pytest+ruff+ty config +├── README.md # this file +├── conftest.py # all fixtures + failure-report hook +├── support/ +│ ├── framing.py # line-delimited \-escaped frame format +│ ├── test_bridge.tcl # sourced inside Eggdrop, opens TCP listener +│ ├── bridge_client.py # Python client → eval_ok("...") +│ ├── mock_ircd.py # asyncio IRCd, sync facade +│ ├── irc_helpers.py # drive_registration, drive_join_with_names, ... +│ ├── eggdrop_proc.py # subprocess wrapper, stdout drain, terminate +│ └── waiters.py # wait_for / wait_for_file / wait_for_log_match +├── templates/ +│ ├── eggdrop.conf.j2 +│ └── userfile.j2 +└── tests/ + ├── test_framing.py + ├── test_smoke_connect.py + ├── test_partyline_chan.py + ├── test_isupport_modes.py + ├── test_tcl_passwdok.py # ported from eggdrop_tcl_passwdok.bats + ├── test_tcl_iscmds.py # ported from eggdrop_tcl_iscmds.bats + ├── test_tcl_matchattr.py # ported from eggdrop_tcl_matchattr.bats + ├── test_tcl_server.py # ported from eggdrop_tcl_server.bats + ├── test_tcl_addbot.py # ported from eggdrop_tcl_addbot.bats + └── test_chanset_inputvalidation.py # ported from eggdrop_chanset_inputvalidation.bats +``` + +## Bridge wire protocol + +Telnet-friendly, one frame per line, `\` `\n` `\r` backslash-escaped: + +``` +$ nc 127.0.0.1 +set ::nick +OK TestBot +expr 2 + 2 +OK 4 +nosuch +ERR invalid command name "nosuch" +``` + +Request: `\n`. Response: `OK \n` or +`ERR \n`. The bridge only listens when the spawn env has +`EGGDROP_TEST=1`, so production Eggdrops sourcing the same config skip it. + +## Environment knobs + +- `EGGDROP_BIN` — absolute path to the eggdrop binary. Default: `/eggdrop`. +- `EGGDROP_SRC` — absolute path to the eggdrop source tree (for `mod-path`, + `help-path`, `EGG_LANGDIR`). Default: parent of `tests/`. + +## Debugging a failing test + +- Each test's runtime files survive at `/tmp/pytest-of-/pytest-current//`. + pytest preserves the last 3 sessions automatically. +- Two logs are written: + - `eggdrop.log` — Eggdrop's own log (raw IRC `[@]` incoming, `[m->]`/`[s->]` + outgoing, plus messages, channels, output, file ops). + - `eggdrop.stdout.log` — everything Eggdrop wrote to stdout/stderr. +- On failure, both are attached to the pytest report. +- For verbose live output: `uv run pytest -s --log-cli-level=DEBUG path::to::test`. +- To poke the bridge by hand from a hung test, copy the port out of + `/bridge.port` and `nc 127.0.0.1 `. + +## Coverage (gcov / lcov) + +Eggdrop's `configure` script has an `--enable-coverage` flag (added for +this test harness, see `aclocal.m4` → `EGG_ENABLE_COVERAGE`). It injects +`--coverage -fPIC -O0 -ggdb3` into `CFLAGS` and `--coverage` into +`LDFLAGS`, so the build emits `.gcno` notes alongside every `.o`, and +each spawned process drops `.gcda` runtime data when it exits. + +### Build with coverage + +The top-level `make test` target does the full build + run cycle: + +```sh +make test # = distclean → configure --enable-coverage + # → make config → make debug → pytest +``` + +Or do it by hand if you want to control the steps: + +```sh +make distclean +./configure --enable-coverage +make config +make debug +``` + +`config.log` will note `enabling gcov coverage instrumentation: --coverage +-fPIC -O0 -ggdb3` and `src/Makefile` will have the flags wired into +`CFLAGS`/`LDFLAGS`. After `make debug`, every `.o` has a `.gcno` next to it +in the same directory. + +### Run the suite to populate `.gcda` + +```sh +cd tests +uv run pytest # each spawned Eggdrop writes .gcda on exit +``` + +`.gcda` files land beside the corresponding `.gcno`/`.o`. One full suite +run produces ~30+ `.gcda` files across `src/` and `src/mod/*/`. To reset +between runs (without rebuilding) just delete the data: + +```sh +find . -name '*.gcda' -delete # keeps .gcno (build state) in place +``` + +`make clean` removes both `.gcno` and `.gcda` everywhere as a side +effect; you'd then need to rebuild before the next coverage run. + +### Inspect coverage + +Per-file: + +```sh +gcov -o src/mod/server.mod src/mod/server.mod/server.c +# → server.c.gcov; first line reports "Lines executed: NN.NN% of N" +``` + +Whole-tree HTML with `lcov`: + +```sh +lcov --capture --directory . --output-file cov.info +genhtml cov.info --output-directory cov-html +xdg-open cov-html/index.html +``` + +### Notes + +- Coverage builds are **not** for production — `-O0` plus instrumentation + is slow, and `.gcda` files accumulate in the source tree (until you + reset them with `find . -name '*.gcda' -delete` or `make clean`). +- If you change source files between runs without rebuilding, + `.gcda`/`.gcno` go out of sync and `gcov` will refuse the data. Re-run + `make debug` after every source change, or delete the stale `.gcda`s. +- Each Eggdrop process writes its `.gcda`s on clean exit. Tests that kill + the bot with `SIGKILL` (only happens after `SIGTERM` times out) will + miss data from that process; the harness uses `bridge.eval("die ...")` + / `SIGTERM` first so this is rare. + +## Why some things are the way they are + +- **`mod-path` set before `loadmodule`.** Eggdrop reads `mod-path` at each + `loadmodule` call, not lazily, so the template orders them accordingly. +- **`loadmodule pbkdf2` first.** The userfile read enforces that the + encryption module is present (src/main.c:1076). +- **`EGG_LANGDIR` env var.** Avoids a symlink in every tmpdir; the language + files load from the source tree directly. +- **`set msg-rate 0`.** Combined with eggdrop's one-msg-per-second + `HOOK_SECONDLY` dequeue, this keeps tests fast without altering + protocol semantics. WHOIS still goes out before JOIN; tests use + `mock_ircd.drain_until(lambda l: l.startswith("JOIN "))` rather than + `expect_recv_match` to skip past it. +- **Bridge socket vs `.tcl` over partyline.** The bridge has clean framing + and no log interleaving; the partyline is for tests of the partyline UX. +- **No `time.sleep` in tests.** Use `waiters.wait_for(...)` / + `wait_for_file(...)` / `mock_ircd.drain_until(...)` — every wait has an + explicit timeout and a description. + +## Converted from the legacy `eggdrop-tests/` BATS suite + +The 6 Tcl-only `.bats` files from the legacy suite have been ported into +this framework. The original ran `cmd_accept.tcl` (a precursor to +`test_bridge.tcl`) on TCP port 45678 and asserted on `nc localhost 45678` +output — exactly the same shape as `tcl_bridge.eval_ok()`, so the +mapping is line-for-line. + +| Legacy `.bats` file | New file | Notes | +| --- | --- | --- | +| `eggdrop_tcl_passwdok.bats` | `tests/test_tcl_passwdok.py` | 6 tests | +| `eggdrop_tcl_iscmds.bats` | `tests/test_tcl_iscmds.py` | 22 tests (isban / isbansticky / isexempt / isinvite) | +| `eggdrop_tcl_matchattr.bats` | `tests/test_tcl_matchattr.py` | 23 tests; the 3 "rejects unknown flag" cases now document the new silent-accept behavior since `tcl_matchattr` no longer errors on unknown flags | +| `eggdrop_tcl_server.bats` | `tests/test_tcl_server.py` | 13 tests; ported from the old `addserver`/`delserver`/`set servers` API to the current `server add` / `server remove` / `server list` | +| `eggdrop_tcl_addbot.bats` | `tests/test_tcl_addbot.py` | 13 tests; the 8 IPv6 cases auto-skip via an `ipv6_required` fixture when Eggdrop is built without IPv6 | +| `eggdrop_chanset_inputvalidation.bats` | `tests/test_chanset_inputvalidation.py` | 9 tests for `flood-deop X:Y` parsing | + +Files that were **not** converted, with reasons: + +- `eggdrop_botnet_linking.bats`, `eggdrop_botnet_partyline.bats` — need + multiple bots talking to each other; no multi-bot fixture in this + framework yet. +- `eggdrop_ssl_config.bats`, `eggdrop_ssl_sni.bats` — SSL/TLS not modeled + in the mock IRCd. +- `eggdrop_compile_*.bats` (5 files) — build-system tests, unrelated to + runtime behavior. The `make test` target replaces what the + `eggdrop_compile_testrun.bats` covered. +- `eggdrop_partyline_bans.bats`, `eggdrop_partyline_flags.bats`, + `eggdrop_console_flags.bats` — drive partyline commands and assert on + textual output through a TCP partyline (port 1111/3015 in the legacy + conf). Convertible using the existing `@pytest.mark.partyline` marker + and `eggdrop_proc.send_partyline()`, but those tests assert on + free-form output strings; preferring to land them as state-checking + tests via `tcl_bridge` when possible. Deferred. + +## What's intentionally out of scope (for now) + +- Linking eggdrop as a shared library / refactoring globals out. +- C-side test hooks; everything stays in Tcl/Python. +- SSL/TLS to the mock IRCd. Plain TCP only. +- Botnet (`share`, `transfer` modules), DCC chat over TCP, Python module. +- Real passwords. The owner in the userfile uses `pass = "-"` (no auth); + the bridge bypasses the need for it. Partyline auth tests are deferred + until there's a reason for them. diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000000..d1d12d9657 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,242 @@ +"""Pytest fixtures for the Eggdrop integration test harness.""" + +from __future__ import annotations + +import contextlib +import os +from collections.abc import Generator, Iterator +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import pytest +from jinja2 import Environment, FileSystemLoader, StrictUndefined + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc +from support.mock_ircd import MockIrcd +from support.waiters import wait_for_file + +REPO_ROOT = Path( + os.environ.get("EGGDROP_SRC", str(Path(__file__).resolve().parents[1])) +) +EGGDROP_BIN = Path(os.environ.get("EGGDROP_BIN", str(REPO_ROOT / "eggdrop"))) +TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" +BRIDGE_TCL = Path(__file__).resolve().parent / "support" / "test_bridge.tcl" + +_jinja_env = Environment( + loader=FileSystemLoader(str(TEMPLATES_DIR)), + undefined=StrictUndefined, + keep_trailing_newline=True, +) + + +# ---------- session-wide process tracker ---------- + + +@pytest.fixture(scope="session", autouse=True) +def _process_tracker() -> Iterator[list[EggdropProc]]: + """Backstop: kill any eggdrop processes a test forgot to clean up.""" + tracked: list[EggdropProc] = [] + yield tracked + for proc in tracked: + with contextlib.suppress(Exception): + proc.terminate(timeout=2) + + +# ---------- per-test fixtures ---------- + + +@pytest.fixture +def tmp_eggdir(tmp_path: Path) -> Path: + return tmp_path + + +@dataclass +class EggdropConfig: + tmp: Path + mock_ircd_port: int + overrides: dict[str, Any] = field(default_factory=dict) + rendered: bool = False + config_path: Path = field(init=False) + userfile_path: Path = field(init=False) + chanfile_path: Path = field(init=False) + pidfile_path: Path = field(init=False) + logfile_path: Path = field(init=False) + + def __post_init__(self) -> None: + self.config_path = self.tmp / "eggdrop.conf" + self.userfile_path = self.tmp / "eggdrop.user" + self.chanfile_path = self.tmp / "eggdrop.chan" + self.pidfile_path = self.tmp / "eggdrop.pid" + self.logfile_path = self.tmp / "eggdrop.log" + + def context(self) -> dict[str, Any]: + ctx: dict[str, Any] = dict( + nick="TestBot", + altnick="Test_?", + realname="pytest bot", + username="test", + admin="pytest ", + network="TestNet", + botnet_nick="TestBot", + owner_handle="owner", + owner_flags="nmto", + owner_hostmask="*!*@127.0.0.1", + mod_path=str(REPO_ROOT) + "/", + help_path=str(REPO_ROOT / "help") + "/", + bridge_tcl_path=str(BRIDGE_TCL), + modules=[ + "pbkdf2", + "channels", + "server", + "ctcp", + "irc", + "console", + "notes", + ], + extra_modules=[], + channels=[{"name": "#test", "chanmode": "+nt"}], + chanfile_channels=[], + userfile_ban_lines=[], + userfile_chan_ban_lines={}, + extra_tcl="", + server_cycle_wait=10, + server_timeout=30, + log_flags="mcorvxd", + tmpdir=str(self.tmp), + userfile_path=str(self.userfile_path), + chanfile_path=str(self.chanfile_path), + pidfile_path=str(self.pidfile_path), + logfile_path=str(self.logfile_path), + mock_ircd_port=self.mock_ircd_port, + ) + ctx.update(self.overrides) + return ctx + + def render(self, **overrides: Any) -> None: + self.overrides.update(overrides) + ctx = self.context() + self.config_path.write_text( + _jinja_env.get_template("eggdrop.conf.j2").render(**ctx) + ) + self.userfile_path.write_text( + _jinja_env.get_template("userfile.j2").render(**ctx) + ) + # channels.mod wants to fopen the chanfile read-write; rendering + # an empty `chanfile_channels` list produces an empty file, same + # as touching it would. + self.chanfile_path.write_text( + _jinja_env.get_template("chanfile.j2").render(**ctx) + ) + self.rendered = True + + +@pytest.fixture +def mock_ircd() -> Iterator[MockIrcd]: + ircd = MockIrcd().start() + try: + yield ircd + finally: + with contextlib.suppress(Exception): + ircd.stop() + + +@pytest.fixture +def eggdrop_config(tmp_eggdir: Path, mock_ircd: MockIrcd) -> EggdropConfig: + return EggdropConfig(tmp=tmp_eggdir, mock_ircd_port=mock_ircd.port) + + +def _request_clean_shutdown(port_file: Path) -> None: + """Open a fresh bridge connection and send Tcl `die` so Eggdrop exits via + its own atexit path (writes userfile/chanfile, runs gcov atexit, etc.). + + The bridge connection drops as a side effect; that's fine — we just need + the request to land. All errors are swallowed; this is best-effort. + """ + if not port_file.exists(): + return + with contextlib.suppress(Exception): + port = int(port_file.read_text().strip()) + with BridgeClient("127.0.0.1", port, timeout=2.0) as client: + client.eval("die test cleanup", timeout=2.0) + + +@pytest.fixture +def eggdrop_proc( + eggdrop_config: EggdropConfig, + tmp_eggdir: Path, + _process_tracker: list[EggdropProc], + request: pytest.FixtureRequest, +) -> Iterator[EggdropProc]: + if not eggdrop_config.rendered: + eggdrop_config.render() + if not EGGDROP_BIN.exists(): + pytest.skip(f"eggdrop binary not found at {EGGDROP_BIN}") + port_file = tmp_eggdir / "bridge.port" + terminal = request.node.get_closest_marker("partyline") is not None + proc = EggdropProc( + binary=EGGDROP_BIN, + config_path=eggdrop_config.config_path, + cwd=tmp_eggdir, + env={ + "EGGDROP_TEST": "1", + "EGGDROP_TEST_PORT_FILE": str(port_file), + "EGG_LANGDIR": str(REPO_ROOT / "language"), + }, + terminal=terminal, + ) + proc.start() + _process_tracker.append(proc) + try: + yield proc + finally: + # Prefer a clean Tcl `die` so atexit handlers (incl. gcov) run; fall + # back to SIGTERM with a generous timeout for slow CI disks where the + # gcov .gcda dump on exit can take several seconds. + _request_clean_shutdown(port_file) + rc = proc.terminate(timeout=30) + # On failure, attach the eggdrop log to the report. + rep = getattr(request.node, "rep_call", None) + if rep is not None and rep.failed: + try: + log = proc.log_path.read_text() + except OSError: + log = "(log unavailable)" + request.node.add_report_section( + "call", + "eggdrop stdout", + f"exit={rc}\nlog at {proc.log_path}\n--- log ---\n{log}", + ) + + +@pytest.fixture +def tcl_bridge( + eggdrop_proc: EggdropProc, tmp_eggdir: Path +) -> Iterator[BridgeClient]: + port_file = tmp_eggdir / "bridge.port" + try: + wait_for_file(port_file, timeout=10.0) + except Exception: + eggdrop_proc.assert_alive() + raise + port = int(port_file.read_text().strip()) + client = BridgeClient.connect_with_retry( + "127.0.0.1", port, total_timeout=5.0 + ) + try: + yield client + finally: + client.close() + + +# ---------- failure-report attachment hook ---------- + + +@pytest.hookimpl(hookwrapper=True) +def pytest_runtest_makereport( + item: pytest.Item, call: pytest.CallInfo[None] +) -> Generator[None, Any, None]: + outcome = yield + rep = outcome.get_result() + setattr(item, f"rep_{rep.when}", rep) diff --git a/tests/pyproject.toml b/tests/pyproject.toml new file mode 100644 index 0000000000..b12541ffb2 --- /dev/null +++ b/tests/pyproject.toml @@ -0,0 +1,52 @@ +[project] +name = "eggdrop-tests" +version = "0.0.0" +description = "Integration test harness for Eggdrop" +requires-python = ">=3.11" +dependencies = [] + +[dependency-groups] +dev = [ + "pytest>=8", + "pytest-timeout>=2.3", + "jinja2>=3.1", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +addopts = "-ra -q --strict-markers --timeout=60 --tb=short" +markers = [ + "slow: end-to-end tests that exercise reconnect/timeouts", + "partyline: spawns eggdrop with -nt so stdin is the HQ partyline", +] +# pytest sweeps it on session exit. +tmp_path_retention_policy = "all" +tmp_path_retention_count = 3 + +[tool.ruff] +target-version = "py311" +src = ["."] + +[tool.ruff.lint] +select = [ + "E", "F", "W", # pycodestyle + pyflakes + "I", # isort + "B", # bugbear + "UP", # pyupgrade + "SIM", # simplifications + "RET", # return statements + "PIE", # misc + "PT", # pytest style + "RUF", # ruff-specific +] +ignore = [ + "E501", # line length — let formatter handle + "PT011", # pytest.raises(Exception) — fine here +] + +[tool.ty.src] +include = ["support", "tests", "conftest.py"] + +[tool.ty.rules] +# All on by default; bump anything we want stricter here. diff --git a/tests/support/__init__.py b/tests/support/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/support/bridge_client.py b/tests/support/bridge_client.py new file mode 100644 index 0000000000..86e117be1a --- /dev/null +++ b/tests/support/bridge_client.py @@ -0,0 +1,100 @@ +"""TCP client for the test_bridge.tcl listener inside Eggdrop.""" + +from __future__ import annotations + +import contextlib +import socket +import time + +from .framing import encode_request, parse_response + + +class BridgeError(Exception): + """Tcl evaluation returned an ERR frame.""" + + +class BridgeTimeout(Exception): + """Read from the bridge timed out before a frame arrived.""" + + +class BridgeClient: + """Synchronous client. One connection, one outstanding request at a time. + + Eggdrop's bridge serves frames in order, so this design is sufficient for + test code that runs sequentially. + """ + + def __init__(self, host: str, port: int, timeout: float = 5.0) -> None: + """Open a TCP connection to the bridge. Raises `OSError` if it can't connect.""" + self._host = host + self._port = port + self._sock = socket.create_connection((host, port), timeout=timeout) + self._sock.settimeout(timeout) + self._buf = bytearray() + + @classmethod + def connect_with_retry( + cls, host: str, port: int, total_timeout: float = 5.0 + ) -> BridgeClient: + """Retry the connect every 50 ms until `total_timeout` elapses. + + Used by the `tcl_bridge` fixture to wait through Eggdrop's startup + between writing the port file and accepting on it. + """ + deadline = time.monotonic() + total_timeout + last: Exception | None = None + while time.monotonic() < deadline: + try: + return cls(host, port) + except OSError as e: + last = e + time.sleep(0.05) + raise BridgeTimeout( + f"could not connect to bridge at {host}:{port} " + f"within {total_timeout}s: {last}" + ) + + def close(self) -> None: + """Close the TCP connection. Idempotent; safe to call on a closed socket.""" + with contextlib.suppress(OSError): + self._sock.close() + + def __enter__(self) -> BridgeClient: + return self + + def __exit__(self, *_: object) -> None: + self.close() + + def eval(self, cmd: str, timeout: float = 5.0) -> tuple[bool, str]: + """Evaluate a Tcl command. Returns (ok, result_text).""" + self._sock.sendall(encode_request(cmd)) + deadline = time.monotonic() + timeout + while b"\n" not in self._buf: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise BridgeTimeout(f"no response to {cmd!r} within {timeout}s") + self._sock.settimeout(remaining) + try: + chunk = self._sock.recv(4096) + except TimeoutError as e: + raise BridgeTimeout( + f"no response to {cmd!r} within {timeout}s" + ) from e + if not chunk: + raise BridgeError( + f"bridge closed connection while waiting for " + f"response to {cmd!r}" + ) + self._buf.extend(chunk) + nl = self._buf.index(b"\n") + line = bytes(self._buf[:nl]).decode("utf-8", "replace") + del self._buf[: nl + 1] + tag, payload = parse_response(line) + return tag == "OK", payload + + def eval_ok(self, cmd: str, timeout: float = 5.0) -> str: + """Evaluate a Tcl command and return its result, raising on ERR.""" + ok, result = self.eval(cmd, timeout=timeout) + if not ok: + raise BridgeError(f"Tcl error evaluating {cmd!r}: {result}") + return result diff --git a/tests/support/eggdrop_proc.py b/tests/support/eggdrop_proc.py new file mode 100644 index 0000000000..b76618fbd8 --- /dev/null +++ b/tests/support/eggdrop_proc.py @@ -0,0 +1,162 @@ +"""Subprocess wrapper for a spawned Eggdrop instance.""" + +from __future__ import annotations + +import contextlib +import io +import os +import re +import signal +import subprocess +import threading +from pathlib import Path +from typing import IO + +from .waiters import WaitTimeout, wait_for_log_match + + +class EggdropDiedError(Exception): + """Raised when an operation is attempted on an Eggdrop that has exited.""" + + +class EggdropProc: + """Spawned Eggdrop subprocess with a captured stdout drain. + + `start()` spawns the bot; `terminate()` ends it (SIGTERM, then SIGKILL + after the grace period). Stdout/stderr are streamed into both an + in-memory ring (`stdout_text()`) and an on-disk log (`log_path`) so + failures can be inspected post-mortem. + """ + + def __init__( + self, + binary: Path, + config_path: Path, + cwd: Path, + env: dict[str, str] | None = None, + log_path: Path | None = None, + terminal: bool = False, + ) -> None: + """Configure (but do not start) a wrapped Eggdrop process. + + `terminal=True` spawns with `-nt` (HQ partyline on stdin, owner + perms auto-granted); `False` uses `-n` (foreground only, no + partyline). `env` is merged on top of the parent process env. + """ + self._binary = binary + self._config = config_path + self._cwd = cwd + self._env = {**os.environ, **(env or {})} + self._log_path = log_path or (cwd / "eggdrop.stdout.log") + self._terminal = terminal + self._buf = io.StringIO() + self._buf_lock = threading.Lock() + self._proc: subprocess.Popen[bytes] | None = None + self._drain_thread: threading.Thread | None = None + self._log_fp: IO[str] | None = None + + def start(self) -> EggdropProc: + """Spawn Eggdrop and start the background stdout-drain thread.""" + # Lifetime spans process; closed in terminate(). + self._log_fp = open(self._log_path, "w", encoding="utf-8") # noqa: SIM115 + flags = "-nt" if self._terminal else "-n" + self._proc = subprocess.Popen( + [str(self._binary), flags, str(self._config)], + cwd=str(self._cwd), + env=self._env, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + bufsize=0, + ) + self._drain_thread = threading.Thread( + target=self._drain, name="eggdrop-stdout-drain", daemon=True + ) + self._drain_thread.start() + return self + + def _drain(self) -> None: + # Bind to locals so the iter() closure has non-Optional types. + proc = self._proc + assert proc is not None + assert proc.stdout is not None + stdout: IO[bytes] = proc.stdout + log_fp = self._log_fp + for chunk in iter(lambda: stdout.read(4096), b""): + text = chunk.decode("utf-8", "replace") + with self._buf_lock: + self._buf.write(text) + if log_fp is not None: + log_fp.write(text) + log_fp.flush() + + @property + def proc(self) -> subprocess.Popen[bytes]: + assert self._proc is not None, "eggdrop not started" + return self._proc + + @property + def pid(self) -> int: + return self.proc.pid + + @property + def returncode(self) -> int | None: + return self.proc.poll() + + @property + def log_path(self) -> Path: + return self._log_path + + def stdout_text(self) -> str: + """Snapshot of everything written to stdout/stderr so far. Thread-safe.""" + with self._buf_lock: + return self._buf.getvalue() + + def assert_alive(self) -> None: + """Raise `EggdropDiedError` if the process has exited.""" + rc = self.proc.poll() + if rc is not None: + raise EggdropDiedError( + f"eggdrop exited with rc={rc}\n--- log ---\n{self.stdout_text()}" + ) + + def wait_for_log(self, pattern: str, timeout: float = 10.0) -> re.Match[str]: + """Poll stdout for `pattern`. If it never appears, surface a useful error. + + On timeout, `assert_alive()` is called first so a dead Eggdrop + produces an `EggdropDiedError` (with the captured log) rather than + a generic `WaitTimeout`. + """ + try: + return wait_for_log_match(self.stdout_text, pattern, timeout=timeout) + except WaitTimeout: + self.assert_alive() + raise + + def send_partyline(self, line: str) -> None: + """Write a line to Eggdrop's stdin (only useful with -t/HQ partyline).""" + proc = self._proc + assert proc is not None + assert proc.stdin is not None + proc.stdin.write((line.rstrip("\n") + "\n").encode("utf-8")) + proc.stdin.flush() + + def terminate(self, timeout: float = 5.0) -> int: + """SIGTERM, then SIGKILL after timeout. Returns exit code.""" + proc = self._proc + if proc is None: + return 0 + if proc.poll() is None: + with contextlib.suppress(ProcessLookupError): + proc.send_signal(signal.SIGTERM) + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=2) + if self._drain_thread is not None: + self._drain_thread.join(timeout=2) + if self._log_fp is not None: + self._log_fp.close() + self._log_fp = None + return proc.returncode diff --git a/tests/support/framing.py b/tests/support/framing.py new file mode 100644 index 0000000000..e0642945d2 --- /dev/null +++ b/tests/support/framing.py @@ -0,0 +1,75 @@ +r"""Line-delimited framing for the test bridge. + +Wire format (one line per frame, terminated by `\n`): + + request: \n + response: OK \n (success) + response: ERR \n (Tcl error) + +Backslash, newline, and CR are backslash-escaped in the payload so the frame +is always exactly one line. The escape character set is closed: every `\` +in an encoded payload is the start of a `\\`, `\n`, or `\r` sequence. + +Telnet-friendly: you can `nc 127.0.0.1 ` and type commands by hand. +""" + +from __future__ import annotations + + +class ProtocolError(Exception): + """Raised on a malformed frame (bad escape sequence or framing).""" + + +def escape(s: str) -> str: + """Escape `\\`, `\\n`, `\\r` so the result fits on a single wire line.""" + return ( + s.replace("\\", "\\\\") + .replace("\n", "\\n") + .replace("\r", "\\r") + ) + + +def unescape(s: str) -> str: + """Inverse of `escape`. Raises `ProtocolError` on dangling/unknown escapes.""" + out: list[str] = [] + i = 0 + n = len(s) + while i < n: + c = s[i] + if c != "\\": + out.append(c) + i += 1 + continue + if i + 1 >= n: + raise ProtocolError("dangling backslash at end of payload") + nxt = s[i + 1] + if nxt == "\\": + out.append("\\") + elif nxt == "n": + out.append("\n") + elif nxt == "r": + out.append("\r") + else: + raise ProtocolError(f"unknown escape sequence: \\{nxt}") + i += 2 + return "".join(out) + + +def encode_request(cmd: str) -> bytes: + """Frame a Tcl command for transmission to the bridge listener.""" + return (escape(cmd) + "\n").encode("utf-8") + + +def encode_response(tag: str, payload: str) -> bytes: + """Frame an `OK`/`ERR` reply (used by the Tcl side; tests use the bridge for input).""" + return (tag + " " + escape(payload) + "\n").encode("utf-8") + + +def parse_response(line: str) -> tuple[str, str]: + """Parse one response line into (tag, payload). Strips trailing newline.""" + line = line.rstrip("\n").rstrip("\r") + tag, sep, payload = line.partition(" ") + if not sep and tag in ("OK", "ERR"): + # tag with empty payload (no separator, no escaped content) + return tag, "" + return tag, unescape(payload) diff --git a/tests/support/irc_helpers.py b/tests/support/irc_helpers.py new file mode 100644 index 0000000000..66cc5a5e54 --- /dev/null +++ b/tests/support/irc_helpers.py @@ -0,0 +1,158 @@ +"""Shared IRC-side test helpers. + +These wrap common multi-step interactions with the mock IRCd so individual +tests don't repeat boilerplate. Designed to be importable from any test: + + from support.irc_helpers import ( + drive_registration, + drive_join_with_names, + wait_for_isupport, + ) +""" + +from __future__ import annotations + +import time + +from .bridge_client import BridgeClient +from .mock_ircd import MockIrcd, MockIrcdError +from .waiters import wait_for + +PREFIX_SYMBOLS = "~&@%+" + + +def split_member_prefix(token: str) -> tuple[str, str]: + """Split a NAMES token into (nick, prefix_symbols). + + >>> split_member_prefix("@alice") + ('alice', '@') + >>> split_member_prefix("~&boss") + ('boss', '~&') + >>> split_member_prefix("plain") + ('plain', '') + """ + i = 0 + while i < len(token) and token[i] in PREFIX_SYMBOLS: + i += 1 + return token[i:], token[:i] + + +def drive_registration( + mock_ircd: MockIrcd, + nick: str = "TestBot", + isupport_tokens: list[str] | None = None, +) -> None: + """Drive Eggdrop through IRC registration. + + Waits for the TCP connect, drains the bot's NICK + USER, then sends the + welcome sequence (001-004 + optional 005 with `isupport_tokens` + 376). + Returns once the welcome is on the wire. + + To influence which IRCv3 caps the bot negotiates, construct the IRCd with + `MockIrcd(advertised_caps=[...])` (typically via a local `mock_ircd` + fixture override). The cap list has to be set at construction time + because the bot sends `CAP LS 302` immediately on TCP connect — before + this helper runs. + """ + mock_ircd.wait_for_connect(timeout=10.0) + for _ in range(2): # NICK and USER + mock_ircd.recv(timeout=5.0) + mock_ircd.send_welcome(nick=nick, isupport=isupport_tokens) + + +def drive_join_with_names( + mock_ircd: MockIrcd, + members_with_prefix: str, + nick: str = "TestBot", + server: str = "mock.test", + member_accounts: dict[str, str] | None = None, +) -> str: + """Drive a realistic post-registration channel JOIN to completion. + + Sequence: + 1. wait for the bot's `JOIN #chan` + 2. echo `:nick!u@h JOIN :#chan` back (this populates `chan->name`) + 3. send NAMES (353) with `members_with_prefix` + end (366) + 4. drain the bot's post-join queries: + * `MODE #chan +b/+e/+I` → empty 368/349/347 end-of-list replies + * `WHO #chan ...` → if the bot sent a WHOX-style request (the + `c%chnufat,222` form, used when WHOX ISUPPORT is on), reply with + one 354 per member carrying the per-member account from + `member_accounts` (default "*" = not logged in). Otherwise reply + with one 352 per member. Either form ends with 315. + 5. leave `MODE #chan` (no list flag) unanswered so tests can send + their own 324 reply + + `member_accounts` maps member nick → account name; nicks not in the dict + get "*". Only consulted on the WHOX path; ignored for plain WHO. + Returns the channel name. Quiesces when no new lines arrive for ~300 ms + or after a 5 s hard cap. + """ + join_line = mock_ircd.drain_until( + lambda line: line.startswith("JOIN "), timeout=10.0 + )[-1] + chan = join_line.split()[1] + mock_ircd.send(f":{nick}!u@h JOIN :{chan}") + mock_ircd.send(f":{server} 353 {nick} = {chan} :{members_with_prefix}") + mock_ircd.send(f":{server} 366 {nick} {chan} :End of /NAMES list.") + + members: list[tuple[str, str]] = [ + split_member_prefix(t) for t in members_with_prefix.split() if t + ] + accounts = member_accounts or {} + + def reply_who(whox: bool) -> None: + for member_nick, prefix_syms in members: + ident = "u" + host = "h.example.com" + flags = "H" + prefix_syms # H = here (not away) + if whox: + # 354 format from chan.c:got354: + # ": 354 222 " + acct = accounts.get(member_nick, "*") + mock_ircd.send( + f":{server} 354 {nick} 222 {chan} {ident} {host} " + f"{member_nick} {flags} {acct}" + ) + else: + mock_ircd.send( + f":{server} 352 {nick} {chan} {ident} {host} {server} " + f"{member_nick} {flags} :0 {member_nick}" + ) + mock_ircd.send(f":{server} 315 {nick} {chan} :End of /WHO list.") + + deadline = time.monotonic() + 5.0 + idle = 0.3 + while time.monotonic() < deadline: + try: + line = mock_ircd.recv(timeout=idle) + except MockIrcdError: + return chan # quiesced + if line.startswith(f"MODE {chan} +b"): + mock_ircd.send(f":{server} 368 {nick} {chan} :End of Channel Ban List") + elif line.startswith(f"MODE {chan} +e"): + mock_ircd.send( + f":{server} 349 {nick} {chan} :End of Channel Exception List" + ) + elif line.startswith(f"MODE {chan} +I"): + mock_ircd.send( + f":{server} 347 {nick} {chan} :End of Channel Invite List" + ) + elif line.startswith(f"WHO {chan}"): + # WHOX form is `WHO #chan c%chnufat,222`; eggdrop emits this when + # use_354 (WHOX ISUPPORT) is on and parses replies from got354. + reply_who(whox=",222" in line) + # MODE #chan (no list flag) — left for the test to answer with 324. + # Anything else is silently drained. + return chan + + +def wait_for_isupport( + bridge: BridgeClient, key: str, expected: str, timeout: float = 5.0 +) -> None: + """Block until `isupport get ` returns `expected`.""" + wait_for( + lambda: bridge.eval_ok(f"isupport get {key}") == expected, + timeout=timeout, + description=f"isupport {key}={expected!r}", + ) diff --git a/tests/support/mock_ircd.py b/tests/support/mock_ircd.py new file mode 100644 index 0000000000..13f660fbb3 --- /dev/null +++ b/tests/support/mock_ircd.py @@ -0,0 +1,295 @@ +"""Minimal mock IRCd for Eggdrop integration tests. + +asyncio TCP server hosted on its own thread + private event loop. +Tests interact via a synchronous facade: `recv()`, `send()`, `expect_recv_match()`, +`send_welcome()`. The mock auto-replies to PING; everything else lands in a +queue for tests to consume and assert on. + +Default behaviour is one client connection per `MockIrcd` instance. Tests that +exercise reconnect logic should pass `allow_reconnect=True`. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import re +import threading +import time +from collections.abc import Callable, Coroutine, Iterable +from concurrent.futures import Future +from typing import Any, TypeVar + +_T = TypeVar("_T") + + +class MockIrcdError(Exception): + """Raised on a timeout or other harness-side error against the mock IRCd.""" + + +class UnexpectedReconnect(MockIrcdError): + """Raised at `stop()` if the bot reconnected when `allow_reconnect=False`.""" + + +class MockIrcd: + """Synchronous-facade mock IRCd, listening on `127.0.0.1:0`. + + Internally an asyncio TCP server runs on a private event loop in a + background thread. The public API is plain blocking calls (`recv()`, + `send()`, `expect_recv_match()`, ...) so test code stays linear. + + Auto-handles client `PING` and (by default) the `CAP LS`/`REQ`/`END` + handshake so individual tests don't have to repeat that boilerplate. + Pass `auto_cap=False` to drive CAP negotiation explicitly. + """ + + def __init__( + self, + allow_reconnect: bool = False, + auto_cap: bool = True, + server_name: str = "mock.test", + advertised_caps: Iterable[str] | None = None, + ) -> None: + """Configure (but do not start) a mock IRCd. + + `allow_reconnect`: if False, a second client connection during the + test is treated as a hard error at `stop()` time. + `auto_cap`: auto-respond to `CAP LS`/`REQ`/`LIST`. `CAP REQ` is + always ACKed for whatever the bot asks for. + `server_name`: source prefix for synthetic numerics (`:server 001 ...`). + `advertised_caps`: caps offered in `CAP LS` replies. Default empty. + Tests that need specific caps construct their own MockIrcd + (typically via a local `mock_ircd` fixture override) — by the + time the test body runs the bot has already sent `CAP LS 302`, + so the cap list must be set at construction time. + """ + self._allow_reconnect = allow_reconnect + self._auto_cap = auto_cap + self._server_name = server_name + self._advertised_caps = " ".join(advertised_caps or []) + self._loop = asyncio.new_event_loop() + self._thread = threading.Thread( + target=self._loop.run_forever, name="MockIrcd", daemon=True + ) + self._server: asyncio.base_events.Server | None = None + self._writer: asyncio.StreamWriter | None = None + self._recv_q: asyncio.Queue[str] | None = None + self._connect_evt: asyncio.Event | None = None + self._connect_count = 0 + self._unexpected_reconnect = False + self.port: int = 0 + + # ---------- lifecycle ---------- + + def start(self) -> MockIrcd: + """Start the asyncio loop thread and bind the listener. Sets `self.port`.""" + self._thread.start() + self._submit(self._async_start()).result(timeout=5) + return self + + def stop(self) -> None: + """Close the listener, stop the loop thread, and assert no rogue reconnects. + + Raises `UnexpectedReconnect` if the client connected more than once + without `allow_reconnect=True`. + """ + if self._loop.is_running(): + with contextlib.suppress(Exception): + self._submit(self._async_stop()).result(timeout=5) + self._loop.call_soon_threadsafe(self._loop.stop) + self._thread.join(timeout=5) + if self._unexpected_reconnect and not self._allow_reconnect: + raise UnexpectedReconnect( + f"client connected {self._connect_count} times " + f"(allow_reconnect=False)" + ) + + def __enter__(self) -> MockIrcd: + return self.start() + + def __exit__(self, *_: object) -> None: + self.stop() + + async def _async_start(self) -> None: + self._recv_q = asyncio.Queue() + self._connect_evt = asyncio.Event() + self._server = await asyncio.start_server( + self._on_client, "127.0.0.1", 0 + ) + self.port = self._server.sockets[0].getsockname()[1] + + async def _async_stop(self) -> None: + if self._writer is not None: + self._writer.close() + # wait_closed can hang if the peer is gone (FIN never ACKed). + # Bound it tightly — we're tearing down the loop anyway. + with contextlib.suppress(Exception): + await asyncio.wait_for(self._writer.wait_closed(), timeout=0.5) + if self._server is not None: + self._server.close() + with contextlib.suppress(Exception): + await asyncio.wait_for(self._server.wait_closed(), timeout=0.5) + + # ---------- client handler ---------- + + async def _on_client( + self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter + ) -> None: + self._connect_count += 1 + if self._connect_count > 1 and not self._allow_reconnect: + self._unexpected_reconnect = True + writer.close() + return + self._writer = writer + assert self._connect_evt is not None + self._connect_evt.set() + try: + while True: + line = await reader.readline() + if not line: + break + text = line.rstrip(b"\r\n").decode("utf-8", "replace") + upper = text.upper() + if upper.startswith("PING"): + pong = "PONG" + text[4:] + "\r\n" + writer.write(pong.encode()) + await writer.drain() + elif self._auto_cap and upper.startswith("CAP "): + self._handle_cap(text, writer) + await writer.drain() + else: + assert self._recv_q is not None + await self._recv_q.put(text) + finally: + self._writer = None + + def _handle_cap(self, text: str, writer: asyncio.StreamWriter) -> None: + """Auto-respond to client CAP commands so tests don't have to. + + Replies to `CAP LS` with the caps the IRCd was constructed with + (default empty), and ACKs whatever the bot then asks for in + `CAP REQ`. + """ + parts = text.split(maxsplit=2) + sub = parts[1].upper() if len(parts) >= 2 else "" + srv = self._server_name + # Use "*" as the unregistered nick placeholder per RFC. + if sub == "LS": + writer.write(f":{srv} CAP * LS :{self._advertised_caps}\r\n".encode()) + elif sub == "REQ": + cap = parts[2] if len(parts) >= 3 else ":" + if cap.startswith(":"): + cap = cap[1:] + writer.write(f":{srv} CAP * ACK :{cap}\r\n".encode()) + elif sub == "END": + pass # no response needed + elif sub == "LIST": + writer.write(f":{srv} CAP * LIST :\r\n".encode()) + + # ---------- synchronous facade ---------- + + def _submit(self, coro: Coroutine[Any, Any, _T]) -> Future[_T]: + """Schedule a coroutine on the bg loop, return a thread-safe Future.""" + return asyncio.run_coroutine_threadsafe(coro, self._loop) + + def wait_for_connect(self, timeout: float = 10.0) -> None: + """Block until the first client (Eggdrop) opens the TCP connection.""" + async def _wait() -> None: + assert self._connect_evt is not None + await asyncio.wait_for(self._connect_evt.wait(), timeout) + + self._submit(_wait()).result(timeout=timeout + 1) + + def recv(self, timeout: float = 5.0) -> str: + """Pop the next non-PING/CAP line from the client's recv queue. + + Raises `MockIrcdError` if no line arrives within `timeout`. + """ + async def _recv() -> str: + assert self._recv_q is not None + return await asyncio.wait_for(self._recv_q.get(), timeout) + + try: + return self._submit(_recv()).result(timeout=timeout + 1) + except TimeoutError as e: + raise MockIrcdError( + f"no IRC line received within {timeout}s" + ) from e + + def expect_recv(self, expected: str, timeout: float = 5.0) -> str: + """`recv()` and assert the line equals `expected` exactly.""" + line = self.recv(timeout) + if line != expected: + raise AssertionError( + f"expected IRC line {expected!r}, got {line!r}" + ) + return line + + def expect_recv_match(self, pattern: str, timeout: float = 5.0) -> re.Match[str]: + """`recv()` and assert the line matches `pattern` (re.match anchored).""" + line = self.recv(timeout) + m = re.match(pattern, line) + if not m: + raise AssertionError( + f"expected IRC line matching /{pattern}/, got {line!r}" + ) + return m + + def drain_until( + self, predicate: Callable[[str], bool], timeout: float = 5.0 + ) -> list[str]: + """Read lines until `predicate(line)` is True. Returns the lines read + (including the matching one). Useful for skipping registration noise.""" + deadline = time.monotonic() + timeout + seen: list[str] = [] + while time.monotonic() < deadline: + remaining = max(0.05, deadline - time.monotonic()) + line = self.recv(remaining) + seen.append(line) + if predicate(line): + return seen + raise MockIrcdError( + f"predicate not satisfied within {timeout}s; saw {len(seen)} lines" + ) + + def send(self, line: str) -> None: + """Push a single IRC line (CRLF appended automatically) to the client.""" + async def _send() -> None: + assert self._writer is not None, "no client connected" + data = (line.rstrip("\r\n") + "\r\n").encode() + self._writer.write(data) + await self._writer.drain() + + self._submit(_send()).result(timeout=5) + + def send_from(self, prefix: str, rest: str) -> None: + """Convenience: `send(": ")`. Use for messages from other users.""" + self.send(f":{prefix} {rest}") + + def send_welcome( + self, + nick: str, + server: str = "mock.test", + isupport: Iterable[str] | None = None, + ) -> None: + """Send the registration response: 001-004, optional 005, then 376. + + `isupport` is a list of raw `KEY=VALUE` (or bare `KEY`) tokens + joined into the 005 line; pass `None` to skip the 005 entirely. + """ + self.send(f":{server} 001 {nick} :Welcome to mock {nick}") + self.send(f":{server} 002 {nick} :Your host is {server}") + self.send(f":{server} 003 {nick} :This server was created today") + self.send(f":{server} 004 {nick} {server} mock-1.0 oiwsx ovimnpst") + if isupport: + tokens = " ".join(isupport) + self.send(f":{server} 005 {nick} {tokens} :are supported") + self.send(f":{server} 376 {nick} :End of MOTD") + + def send_names( + self, nick: str, channel: str, members: Iterable[str], server: str = "mock.test" + ) -> None: + """Send a 353 NAMES line followed by the 366 end-of-list.""" + names = " ".join(members) + self.send(f":{server} 353 {nick} = {channel} :{names}") + self.send(f":{server} 366 {nick} {channel} :End of /NAMES") diff --git a/tests/support/test_bridge.tcl b/tests/support/test_bridge.tcl new file mode 100644 index 0000000000..2fe051e185 --- /dev/null +++ b/tests/support/test_bridge.tcl @@ -0,0 +1,70 @@ +# Eggdrop integration-test bridge. +# +# Sourced from a rendered eggdrop.conf when EGGDROP_TEST is set in the +# environment. Binds a TCP listener on 127.0.0.1:0, writes the OS-assigned +# port to $env(EGGDROP_TEST_PORT_FILE), and accepts line-delimited Tcl +# commands. Each command is evaluated in the global interpreter; the result +# is returned as a single line tagged "OK" or "ERR". +# +# Wire format (one line per frame, \n-terminated): +# request: \n +# response: OK \n or ERR \n +# where \, \n, \r in the payload are backslash-escaped so each frame is +# always exactly one line. +# +# Eggdrop's Tcl_ServiceAll() runs every main-loop tick, so socket -server +# and fileevent fire normally. This script is *only* loaded under the +# EGGDROP_TEST gate and must never ship enabled in production. + +namespace eval ::eggtest { + proc escape {s} { + return [string map [list "\\" "\\\\" "\n" "\\n" "\r" "\\r"] $s] + } + + proc unescape {s} { + return [string map [list "\\\\" "\\" "\\n" "\n" "\\r" "\r"] $s] + } + + proc on_data {sock} { + if {[catch {gets $sock line} n] || $n < 0} { + if {[eof $sock]} { + close_sock $sock + } + return + } + set cmd [unescape $line] + if {[catch {uplevel #0 $cmd} result]} { + puts $sock "ERR [escape $result]" + } else { + puts $sock "OK [escape $result]" + } + flush $sock + } + + proc close_sock {sock} { + catch {close $sock} + } + + proc on_accept {sock host port} { + fconfigure $sock -blocking 0 -translation lf -buffering line + fileevent $sock readable [list ::eggtest::on_data $sock] + } + + proc start {} { + if {![info exists ::env(EGGDROP_TEST_PORT_FILE)]} { + putlog "test_bridge: EGGDROP_TEST_PORT_FILE not set, refusing to start" + return + } + set srv [socket -server ::eggtest::on_accept -myaddr 127.0.0.1 0] + set port [lindex [fconfigure $srv -sockname] 2] + set portfile $::env(EGGDROP_TEST_PORT_FILE) + set tmp "$portfile.tmp" + set f [open $tmp w] + puts -nonewline $f $port + close $f + file rename -force $tmp $portfile + putlog "test_bridge listening on 127.0.0.1:$port (wrote $portfile)" + } +} + +::eggtest::start diff --git a/tests/support/userfile_helpers.py b/tests/support/userfile_helpers.py new file mode 100644 index 0000000000..d59ddcbfe1 --- /dev/null +++ b/tests/support/userfile_helpers.py @@ -0,0 +1,43 @@ +"""Helpers for building userfile content programmatically. + +Tests inject pre-populated ban records into the userfile via the +`userfile_ban_lines` (global) and `userfile_chan_ban_lines` (per-channel) +context variables of `templates/userfile.j2`. The template just emits +the strings verbatim — all formatting and escaping happens here so the +template stays a flat iteration. +""" + +from __future__ import annotations + + +def format_userfile_ban( + *, + mask: str, + perm: bool, + sticky: bool, + expire: int, + added: int, + lastactive: int, + creator: str, + desc: str, +) -> str: + """Format one ban record for the userfile. + + Mirrors the line written by `write_bans` in + `src/mod/channels.mod/userchan.c`: + + - ::+::: + + `:` and `\\` in the mask are hex-escaped per `src/misc.c:str_escape` + (the parser uses `\\xy` as a hex byte; a literal `\\:` would yield NUL + via strtol of ":?" base 16 and silently truncate the mask). All + arguments are required — no defaults — so each test states exactly + what it's pinning. + """ + escaped = mask.replace("\\", "\\5c").replace(":", "\\3a") + perm_prefix = "+" if perm else "" + sticky_suffix = "*" if sticky else "" + return ( + f"- {escaped}:{perm_prefix}{expire}{sticky_suffix}:" + f"+{added}:{lastactive}:{creator}:{desc}" + ) diff --git a/tests/support/waiters.py b/tests/support/waiters.py new file mode 100644 index 0000000000..a6ea892999 --- /dev/null +++ b/tests/support/waiters.py @@ -0,0 +1,62 @@ +"""Wait primitives with explicit timeouts. No bare time.sleep loops in tests.""" + +from __future__ import annotations + +import re +import time +from collections.abc import Callable +from pathlib import Path + + +class WaitTimeout(Exception): + """Raised by any `wait_for*` helper when its deadline passes.""" + + +def wait_for_file( + path: Path, timeout: float = 10.0, poll: float = 0.05 +) -> None: + """Block until `path` exists. Raises WaitTimeout.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.exists(): + return + time.sleep(poll) + raise WaitTimeout(f"file {path} did not appear within {timeout}s") + + +def wait_for( + predicate: Callable[[], bool], + timeout: float = 10.0, + poll: float = 0.05, + description: str = "predicate", +) -> None: + """Poll `predicate()` every `poll` seconds until it returns truthy. + + `description` is included in the timeout error message — supply something + specific so test failures are self-explanatory. + """ + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return + time.sleep(poll) + raise WaitTimeout(f"{description} not satisfied within {timeout}s") + + +def wait_for_log_match( + log_text: Callable[[], str], + pattern: str, + timeout: float = 10.0, + poll: float = 0.05, +) -> re.Match[str]: + """Block until `pattern` matches anywhere in the log text.""" + rx = re.compile(pattern, re.MULTILINE) + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + m = rx.search(log_text()) + if m: + return m + time.sleep(poll) + raise WaitTimeout( + f"log pattern /{pattern}/ not seen within {timeout}s" + ) diff --git a/tests/templates/chanfile.j2 b/tests/templates/chanfile.j2 new file mode 100644 index 0000000000..554c33c17b --- /dev/null +++ b/tests/templates/chanfile.j2 @@ -0,0 +1,15 @@ +{#- Chanfile template. + + Tests pass `chanfile_channels` as a list of {"name": ..., "options": ...} + records. `options` is a Tcl-syntax options block (verbatim) — defaults to + the empty block `{}`, which is enough to make findchan_by_dname() return + the channel during userfile load. By default the list is empty and the + chanfile is empty. + + Channels added via the eggdrop.conf `channels` template variable are + *also* registered (via `channel add` lines in the conf) and run before + the chanfile, so most tests don't need to populate this file at all. +-#} +{%- for ch in chanfile_channels -%} +channel add {{ ch.name }} { {{ ch.options | default('') }} } +{% endfor -%} diff --git a/tests/templates/eggdrop.conf.j2 b/tests/templates/eggdrop.conf.j2 new file mode 100644 index 0000000000..cdd0e97d99 --- /dev/null +++ b/tests/templates/eggdrop.conf.j2 @@ -0,0 +1,53 @@ +# Generated by the test harness. Do not edit by hand. + +set mod-path "{{ mod_path }}" +set help-path "{{ help_path }}" + +{% for m in modules %} +loadmodule {{ m }} +{% endfor %} +{% for m in extra_modules %} +loadmodule "{{ m }}" +{% endfor %} + +set nick "{{ nick }}" +set altnick "{{ altnick }}" +set realname "{{ realname }}" +set username "{{ username }}" +set admin "{{ admin }}" +set network "{{ network }}" +set botnet-nick "{{ botnet_nick }}" +set owner "{{ owner_handle }}" + +set userfile "{{ userfile_path }}" +set chanfile "{{ chanfile_path }}" +set pidfile "{{ pidfile_path }}" + +{% if 'server' in modules %} +set net-type "Other" +set default-port "{{ mock_ircd_port }}" +set servers [list] +server add 127.0.0.1 "{{ mock_ircd_port }}" + +set server-cycle-wait "{{ server_cycle_wait }}" +set server-timeout "{{ server_timeout }}" +set msg-rate 0 +set flood-msg 0 +set flood-ctcp 0 +{% endif %} + +set raw-log 1 +logfile {{ log_flags }} * "{{ logfile_path }}" +set log-time 0 +set keep-all-logs 0 + +{% for ch in channels %} +channel add {{ ch.name }} { chanmode "{{ ch.chanmode|default('+nt') }}" } +{% endfor %} + +# Test bridge — only loaded when EGGDROP_TEST is set in the environment. +if {[info exists env(EGGDROP_TEST)]} { + source "{{ bridge_tcl_path }}" +} + +{{ extra_tcl }} diff --git a/tests/templates/userfile.j2 b/tests/templates/userfile.j2 new file mode 100644 index 0000000000..eedf9b5fcd --- /dev/null +++ b/tests/templates/userfile.j2 @@ -0,0 +1,29 @@ +{#- Userfile template. + + Tests inject pre-formatted ban-record lines via: + userfile_ban_lines — list of strings, written under `*ban - -`. + userfile_chan_ban_lines — dict of chan-name -> list of strings, + each written under `:: bans`. + + Build the strings in Python with `support.userfile_helpers.format_userfile_ban`. + + The `*ban - -` section header is what `write_bans` (channels.mod/userchan.c) + emits and what the parser needs — `newsplit` (src/misc.c:255) splits on + literal ' ' only, so `*ban\n` keeps the trailing newline glued to the + token and `rfc_casecmp("*ban\n", "*ban")` fails to match. +-#} +#4v: eggdrop test harness -- {{ botnet_nick }} -- generated +{{ owner_handle }} - {{ owner_flags }} +- {{ owner_hostmask }} +{% if userfile_ban_lines -%} +*ban - - +{% for line in userfile_ban_lines -%} +{{ line }} +{% endfor -%} +{% endif -%} +{% for chan_name, lines in userfile_chan_ban_lines.items() -%} +::{{ chan_name }} bans +{% for line in lines -%} +{{ line }} +{% endfor -%} +{% endfor -%} diff --git a/tests/tests/__init__.py b/tests/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/tests/test_account_tag_voice_mode.py b/tests/tests/test_account_tag_voice_mode.py new file mode 100644 index 0000000000..9816dd0cf8 --- /dev/null +++ b/tests/tests/test_account_tag_voice_mode.py @@ -0,0 +1,101 @@ +"""Account-tag CAP + WHOX + tagged MODE: regression for the full account-aware +path through CAP negotiation, WHOX-driven account learning, and an IRCv3-tagged +mode change. + +End-to-end shape: + - CAP LS advertises `account-tag`; bot REQ/server ACK; bot enables it. + - 005 carries `WHOX` so eggdrop sets `use_354` (chan.c:3053) and emits + `WHO #chan c%chnufat,222` after JOIN; the helper replies with 354s + carrying op's account name "op". + - The IRCd then sends a MODE +vv with an `@account=op` IRCv3 tag. Eggdrop + parses the MODE (via standard mode handling) and the tag (via + chan.c:gotrawt → setaccount). + - Result: test1 and test2 are voiced; op's account is "op". +""" + +from __future__ import annotations + +import contextlib +from collections.abc import Iterator + +import pytest + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc +from support.irc_helpers import ( + drive_join_with_names, + drive_registration, + wait_for_isupport, +) +from support.mock_ircd import MockIrcd +from support.waiters import wait_for + + +@pytest.fixture +def mock_ircd() -> Iterator[MockIrcd]: + """Override the default fixture to advertise `account-tag` in CAP LS. + + The cap list is fixed at construction time because the bot sends + `CAP LS 302` immediately on TCP connect, before the test body runs. + """ + ircd = MockIrcd(advertised_caps=["account-tag"]).start() + try: + yield ircd + finally: + with contextlib.suppress(Exception): + ircd.stop() + + +def test_isvoice_after_tagged_mode_with_whox_and_account_tag( + eggdrop_config, + request: pytest.FixtureRequest, +) -> None: + # account-tag is disabled by default in server.mod (servmsg.c:44); the + # bot only adds it to the CAP REQ list when the Tcl var is 1. Set it via + # extra_tcl so the assignment runs after server.mod's loadmodule. + eggdrop_config.render(extra_tcl="set account-tag 1\n") + mock_ircd: MockIrcd = request.getfixturevalue("mock_ircd") + eggdrop_proc: EggdropProc = request.getfixturevalue("eggdrop_proc") # noqa: F841 — proc must spawn before bridge + tcl_bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + drive_registration(mock_ircd, isupport_tokens=["WHOX"]) + wait_for_isupport(tcl_bridge, "WHOX", "") # bare token: value stored as "" + + # CAP negotiation must have completed (welcome only fires after CAP END); + # account-tag must be in the enabled list. + enabled = tcl_bridge.eval_ok("cap enabled").split() + assert "account-tag" in enabled, f"expected account-tag enabled, got {enabled}" + + chan = drive_join_with_names( + mock_ircd, + "@op test1 test2 @TestBot", + member_accounts={"op": "op"}, + ) + + # WHOX populated op's account via 354 (chan.c:got354 → got352or4 → setaccount). + wait_for( + lambda: tcl_bridge.eval_ok(f'getaccount op "{chan}"') == "op", + timeout=5.0, + description="WHOX 354 to set op's account to 'op'", + ) + + # Pre-MODE sanity: nobody has voice yet. + assert tcl_bridge.eval_ok(f'onchan test1 "{chan}"') == "1" + assert tcl_bridge.eval_ok(f'onchan test2 "{chan}"') == "1" + assert tcl_bridge.eval_ok(f'isvoice test1 "{chan}"') == "0" + assert tcl_bridge.eval_ok(f'isvoice test2 "{chan}"') == "0" + + mock_ircd.send(f"@account=op :op!op@127.0.0.1 MODE {chan} +vv test1 :test2") + + wait_for( + lambda: ( + tcl_bridge.eval_ok(f'isvoice test1 "{chan}"') == "1" + and tcl_bridge.eval_ok(f'isvoice test2 "{chan}"') == "1" + ), + timeout=5.0, + description="MODE +vv test1 test2 to voice both members", + ) + + # And op's account survived the tag-driven setaccount call (no-op since + # it was already "op", but we want to be sure gotrawt didn't clobber it). + assert tcl_bridge.eval_ok(f'getaccount op "{chan}"') == "op" diff --git a/tests/tests/test_chanset_inputvalidation.py b/tests/tests/test_chanset_inputvalidation.py new file mode 100644 index 0000000000..6e88e54f97 --- /dev/null +++ b/tests/tests/test_chanset_inputvalidation.py @@ -0,0 +1,98 @@ +"""Converted from eggdrop-tests/eggdrop_chanset_inputvalidation.bats. + +Input validation for `channel set flood-deop X:Y` — the X:Y format +parser must accept positive integer pairs, reject malformed input, and +treat the all-zero forms as "off".""" + +from __future__ import annotations + +import pytest + +from support.bridge_client import BridgeClient + +CHAN = "#eggtest" + + +@pytest.fixture +def chan(tcl_bridge: BridgeClient) -> str: + tcl_bridge.eval_ok(f"channel add {CHAN}") + return CHAN + + +def test_flood_deop_accepts_valid_x_colon_y( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 253:42") + info = tcl_bridge.eval_ok(f"channel info {chan}") + assert "253:42" in info + + +def test_flood_deop_rejects_non_integer_messages( + tcl_bridge: BridgeClient, chan: str +) -> None: + """Letters in either field of X:Y are rejected.""" + for bad in ("4:D", "2a:4", "2:4a"): + ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop {bad}") + assert not ok, f"{bad!r} unexpectedly accepted" + assert "values must be integers >= 0" in result + + +def test_flood_deop_rejects_single_value( + tcl_bridge: BridgeClient, chan: str +) -> None: + """A bare integer (no colon) is rejected — must be X:Y.""" + ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop 4") + assert not ok + assert "flood value must be in X:Y format" in result + + +def test_flood_deop_rejects_invalid_format( + tcl_bridge: BridgeClient, chan: str +) -> None: + """`:X`, `X:`, and three-section `X:Y:Z` are all rejected.""" + for bad in (":3", "3:", "53:75:86"): + ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop {bad}") + assert not ok, f"{bad!r} unexpectedly accepted" + assert "flood value must be in X:Y format" in result + + +def test_flood_deop_rejects_negative_numbers( + tcl_bridge: BridgeClient, chan: str +) -> None: + ok, result = tcl_bridge.eval(f"channel set {chan} flood-deop -1:8") + assert not ok + assert "values must be integers >= 0" in result + + +def test_flood_deop_zero_clears_setting( + tcl_bridge: BridgeClient, chan: str +) -> None: + """Bare `0` resets the setting to `0:0`.""" + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9") + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 0") + info = tcl_bridge.eval_ok(f"channel info {chan}") + assert "0:0" in info + + +def test_flood_deop_zero_zero_clears_setting( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9") + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 0:0") + info = tcl_bridge.eval_ok(f"channel info {chan}") + assert "0:0" in info + + +def test_flood_deop_n_zero_keeps_n(tcl_bridge: BridgeClient, chan: str) -> None: + """`N:0` is a valid (degenerate) setting and is stored verbatim.""" + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9") + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 9:0") + info = tcl_bridge.eval_ok(f"channel info {chan}") + assert "9:0" in info + + +def test_flood_deop_zero_n_keeps_n(tcl_bridge: BridgeClient, chan: str) -> None: + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 6:9") + tcl_bridge.eval_ok(f"channel set {chan} flood-deop 0:8") + info = tcl_bridge.eval_ok(f"channel info {chan}") + assert "0:8" in info diff --git a/tests/tests/test_extban.py b/tests/tests/test_extban.py new file mode 100644 index 0000000000..b1ebaaba7e --- /dev/null +++ b/tests/tests/test_extban.py @@ -0,0 +1,580 @@ +"""Integration tests for the extban support PR (channels.mod / irc.mod). + +Each test sets up only what it needs and reads from the bridge to assert on +internal state. Where IRC traffic is involved, the test drives the mock +IRCd directly and (when the bot's outgoing MODE is the thing under test) +flushes the mode buffer with `flushmode` to avoid waiting on the periodic +HOOK_IDLE flush. + +What this PR introduced (and these tests cover): + +- Tcl `account-extban` global, populated from ISUPPORT ACCOUNTEXTBAN. +- `.+extban ` partyline command. Constructs the mask using + the EXTBAN-advertised prefix, refuses while disconnected. +- `u_addban` no longer auto-sticky-fies extban masks at storage time + (option-1 swap: enforceability decided at runtime via + `extban_is_unenforceable`, never persisted). +- `check_this_ban` / `recheck_bans` / `check_expired_chanstuff` treat + unenforceable extbans (q:, c:, ...) as if sticky for set-and-keep + purposes, even on `+dynamicbans` channels. +- `u_addban` skips the bot-self-ban check on extban masks (they don't + have the nick!user@host shape that match would target). +""" + +from __future__ import annotations + +import pytest + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc +from support.irc_helpers import ( + drive_join_with_names, + drive_registration, + wait_for_isupport, +) +from support.mock_ircd import MockIrcd +from support.userfile_helpers import format_userfile_ban +from support.waiters import wait_for + +# ---------- Tcl `account-extban` global ---------- + + +def test_account_extban_tcl_var_empty_when_no_isupport( + tcl_bridge: BridgeClient, +) -> None: + """Reading `$account-extban` before any 005 returns the empty string. + + The Tcl trace fires on read, calls `servermod_isupport_get("ACCOUNTEXTBAN")`, + which returns NULL since the bot hasn't connected yet, and the trace + handler stores "" in the variable. + """ + assert tcl_bridge.eval_ok("set ::account-extban") == "" + + +def test_account_extban_tcl_var_populated_from_isupport( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """After 005 with `ACCOUNTEXTBAN=a`, reading the Tcl global returns "a". + + The PR also accepts the longer `a,account` form per ircdocs; we send + the short form here because it's what most networks advertise. + """ + drive_registration( + mock_ircd, + isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"], + ) + wait_for_isupport(tcl_bridge, "ACCOUNTEXTBAN", "a") + assert tcl_bridge.eval_ok("set ::account-extban") == "a" + + +# ---------- u_addban: no auto-sticky on extban storage ---------- + + +def test_newban_extban_does_not_auto_sticky( + tcl_bridge: BridgeClient, +) -> None: + """`newban a:foo` stores the ban without `MASKREC_STICKY`. + + Before commit 9269ae64, u_addban set MASKREC_STICKY for any extban + whose flag wasn't in the hardcoded enforceable set. With the + option-1 swap, that decision moved to enforcement time + (`extban_is_unenforceable`), so the userfile flags now reflect the + user's literal intent. + """ + tcl_bridge.eval_ok("newban a:foo testuser comment") + assert tcl_bridge.eval_ok("isbansticky a:foo") == "0" + assert tcl_bridge.eval_ok("isban a:foo") == "1" + + +def test_newban_extban_with_explicit_sticky_option_is_sticky( + tcl_bridge: BridgeClient, +) -> None: + """User-controlled sticky still works: `newban a:foo ... sticky` → sticky.""" + tcl_bridge.eval_ok("newban a:foo testuser comment 0 sticky") + assert tcl_bridge.eval_ok("isbansticky a:foo") == "1" + + +def test_newban_unenforceable_extban_q_not_auto_sticky( + tcl_bridge: BridgeClient, +) -> None: + """`q:` (mute extban) is unenforceable from the eggdrop side, but the + sticky bit still isn't persisted. Enforceability is a runtime decision.""" + tcl_bridge.eval_ok("newban q:badactor testuser comment") + assert tcl_bridge.eval_ok("isbansticky q:badactor") == "0" + assert tcl_bridge.eval_ok("isban q:badactor") == "1" + + +# ---------- u_addban: bot-self-ban check skipped for extbans ---------- + + +def test_newban_extban_matching_botnick_is_not_self_rejected( + tcl_bridge: BridgeClient, +) -> None: + """The "I'm not going to ban myself" guard in u_addban only fires on + the non-extban branch (extban masks don't have nick!user@host shape). + `a:TestBot` should be accepted even though the bot's nick is TestBot. + """ + tcl_bridge.eval_ok("newban a:TestBot testuser comment") + assert tcl_bridge.eval_ok("isban a:TestBot") == "1" + + +# ---------- partyline `.+extban` ---------- + + +@pytest.mark.partyline +def test_partyline_pls_extban_refused_when_disconnected( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """`.+extban` requires ISUPPORT EXTBAN to know the prefix; refuses if + we haven't seen 005 yet (commit 2e195b1). + + No drive_registration() call here — the bot has TCP-connected but is + still pre-welcome, so isupport_get("EXTBAN") returns NULL. + """ + snapshot = len(eggdrop_proc.stdout_text()) + eggdrop_proc.send_partyline(".+extban a foo") + + wait_for( + lambda: "must be connected to a server with EXTBAN support" + in eggdrop_proc.stdout_text()[snapshot:], + timeout=5.0, + description="partyline .+extban to refuse with EXTBAN-unavailable msg", + ) + + # And nothing was stored. + assert tcl_bridge.eval_ok("isban a:foo") == "0" + + +@pytest.mark.partyline +def test_partyline_pls_extban_constructs_prefixed_mask( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """`.+extban a Foo` on a server with `EXTBAN=~,a` stores `~a:Foo` in + the channel ban list (prefix from ISUPPORT, flag and value from input). + """ + drive_registration( + mock_ircd, + isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"], + ) + drive_join_with_names(mock_ircd, "@TestBot") + wait_for_isupport(tcl_bridge, "EXTBAN", "~,acrjmU") + + eggdrop_proc.send_partyline(".+extban a Foo #test") + + wait_for( + lambda: tcl_bridge.eval_ok("isban ~a:Foo #test") == "1", + timeout=5.0, + description="partyline .+extban to register ~a:Foo on #test", + ) + + +@pytest.mark.partyline +def test_partyline_pls_extban_constructs_unprefixed_mask( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """`.+extban a Foo` on a server whose EXTBAN advertises no prefix + (form: `,types`) stores `a:Foo` (no prefix prepended). + """ + drive_registration(mock_ircd, isupport_tokens=["EXTBAN=,acrjmU"]) + drive_join_with_names(mock_ircd, "@TestBot") + wait_for_isupport(tcl_bridge, "EXTBAN", ",acrjmU") + + eggdrop_proc.send_partyline(".+extban a Foo #test") + + wait_for( + lambda: tcl_bridge.eval_ok("isban a:Foo #test") == "1", + timeout=5.0, + description="partyline .+extban to register a:Foo (no prefix) on #test", + ) + + +# ---------- check_this_ban / recheck_bans: unenforceable extban set on +dynamicbans ---------- + + +def test_unenforceable_extban_queued_as_plus_b_on_dynamicbans_channel( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """A non-enforceable extban (`q:`) must still be set on the channel + even with `+dynamicbans`, because eggdrop has no other way to enforce + a server-side mute. The condition in check_this_ban includes + `extban_is_unenforceable(banmask)` for exactly this case. + """ + # 'q' must appear in the EXTBAN types list, otherwise check_this_ban + # short-circuits at `!extban_flag_supported('q')` before add_mode. + extban = "~,acjmqrUz" + drive_registration(mock_ircd, isupport_tokens=[f"EXTBAN={extban}"]) + chan = drive_join_with_names(mock_ircd, "@TestBot") + wait_for_isupport(tcl_bridge, "EXTBAN", extban) + + # Confirm preconditions for the +b add_mode path: + # - dynamicbans is on (otherwise the path under test never gates) + # - bot is op (HALFOP_CANTDOMODE('b') would short-circuit otherwise) + assert tcl_bridge.eval_ok(f'channel get "{chan}" dynamicbans') == "1" + assert tcl_bridge.eval_ok(f'isop TestBot "{chan}"') == "1" + + tcl_bridge.eval_ok(f'newchanban "{chan}" q:badmouth testuser comment') + # Force-flush the mode buffer instead of waiting on HOOK_IDLE. + tcl_bridge.eval_ok(f'flushmode "{chan}"') + + # The bot should send a MODE for chan that includes our extban as the +b + # arg. The mode letters can be batched with chanmode protection (e.g. + # "+tnb") so we just look for the unambiguous mask payload on a MODE line. + mock_ircd.drain_until( + lambda line: line.startswith(f"MODE {chan} ") and "q:badmouth" in line, + timeout=5.0, + ) + + +def test_enforceable_account_extban_not_queued_on_dynamicbans_channel_without_match( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """An *enforceable* account extban for an account no current member has + must NOT be set proactively on a `+dynamicbans` channel. The condition + `extban_is_unenforceable("a:nooneactual")` returns false (because acc + flag matches), so it falls back to the standard dynamic-bans rule: + only set when a matching member triggers it. + """ + drive_registration( + mock_ircd, + isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"], + ) + chan = drive_join_with_names(mock_ircd, "@TestBot alice") + wait_for_isupport(tcl_bridge, "ACCOUNTEXTBAN", "a") + assert tcl_bridge.eval_ok(f'channel get "{chan}" dynamicbans') == "1" + + tcl_bridge.eval_ok(f'newchanban "{chan}" a:nooneactual testuser comment') + tcl_bridge.eval_ok(f'flushmode "{chan}"') + + # No MODE +b should reach the IRCd within a reasonable window. + # Use a short drain that *requires* a +b ... a:nooneactual to assert non-presence: + # if the predicate never matches and we get a MockIrcdError on timeout, we win. + from support.mock_ircd import MockIrcdError + + with pytest.raises(MockIrcdError): + mock_ircd.drain_until( + lambda line: line.startswith(f"MODE {chan} ") and "a:nooneactual" in line, + timeout=2.0, + ) + + # And the userfile record exists regardless. + assert tcl_bridge.eval_ok(f'isban a:nooneactual "{chan}"') == "1" + # And it was not auto-stickified. + assert tcl_bridge.eval_ok(f'isbansticky a:nooneactual "{chan}"') == "0" + + +def test_enforcebans_account_extban_kicks_after_account_change( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """+enforcebans + an account-extban + a member who was on the channel + *before* the matching account was set: when the server sends ACCOUNT + (account-notify capability), the bot must add +b for the extban and + KICK the now-matching user. + + Path under test: + ACCOUNT msg → got_account (chan.c:2877) → setaccount (chan.c:179) → + banmask_list_matches_member finds the userfile ban → refresh_ban_kick + → do_mask sets +b a:badname and kick_all sends KICK. + + `+enforcebans` is set explicitly so the test pins the "kick on + account-match" intent independent of dynamic-bans defaults. Before + the ACCOUNT message arrives, alice's m->account is empty, so + `banmask_matches_member` correctly returns false and nothing fires. + """ + drive_registration( + mock_ircd, + isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"], + ) + chan = drive_join_with_names(mock_ircd, "@TestBot alice") + wait_for_isupport(tcl_bridge, "ACCOUNTEXTBAN", "a") + + # Preconditions for the kick path: bot is op, alice is on chan, + # alice has no account yet, +enforcebans is on. + assert tcl_bridge.eval_ok(f'isop TestBot "{chan}"') == "1" + assert tcl_bridge.eval_ok(f'onchan alice "{chan}"') == "1" + tcl_bridge.eval_ok(f'channel set "{chan}" +enforcebans') + assert tcl_bridge.eval_ok(f'channel get "{chan}" enforcebans') == "1" + + # Add the account-extban targeting an account alice doesn't yet have. + # check_this_ban iterates members; alice's m->account is "" so + # banmask_matches_member returns false. Nothing about a:badname goes + # out — verified below by the negative drain. + tcl_bridge.eval_ok(f'newchanban "{chan}" a:badname testuser comment') + tcl_bridge.eval_ok(f'flushmode "{chan}"') + + from support.mock_ircd import MockIrcdError + + with pytest.raises(MockIrcdError): + mock_ircd.drain_until( + lambda line: "a:badname" in line or ( + line.startswith(f"KICK {chan} alice") + ), + timeout=2.0, + ) + + # Alice logs in to the matching account. + mock_ircd.send(":alice!u@h.example.com ACCOUNT badname") + + # Bot must (a) set +b a:badname on the channel and (b) KICK alice. + # Modes get flushed by add_mode/flush_mode in the refresh_ban_kick + # path; KICK goes via DP_SERVER directly. Both should arrive within + # the IDLE flush cycle. + seen = mock_ircd.drain_until( + lambda line: line.startswith(f"KICK {chan} alice"), + timeout=5.0, + ) + assert any( + line.startswith(f"MODE {chan} ") and "a:badname" in line + for line in seen + ), f"expected MODE +b a:badname before the KICK; got {seen}" + + +# ---------- userfile loading: extbans loaded regardless of EXTBAN advertised ---------- + + +def test_extbans_load_from_userfile_before_connect( + eggdrop_config, + request: pytest.FixtureRequest, +) -> None: + """Extbans stored in the userfile must be loaded into memory at startup, + independent of any EXTBAN/ACCOUNTEXTBAN ISUPPORT data — that data isn't + available until after the bot connects. + + The load path goes through `restore_chanban → addmask_fully` (users.c), + which doesn't call `isupport_get` or any of the new extban helpers. + Verified by spawning without driving registration. The bans are + rendered into the userfile via the `userfile_bans` / + `userfile_chan_bans` template variables (see templates/userfile.j2). + """ + eggdrop_config.render( + userfile_ban_lines=[ + format_userfile_ban( + mask="a:storedacct", perm=True, sticky=False, expire=0, + added=1700000000, lastactive=0, creator="owner", + desc="loaded from userfile", + ), + format_userfile_ban( + mask="q:storedmute", perm=True, sticky=False, expire=0, + added=1700000000, lastactive=0, creator="owner", + desc="loaded from userfile", + ), + format_userfile_ban( + mask="U:strangers!*@*", perm=True, sticky=False, expire=0, + added=1700000000, lastactive=0, creator="owner", + desc="loaded from userfile", + ), + ], + userfile_chan_ban_lines={ + "#test": [ + format_userfile_ban( + mask="~a:chanonlyacct", perm=True, sticky=False, expire=0, + added=1700000000, lastactive=0, creator="owner", + desc="loaded from userfile", + ), + ], + }, + ) + request.getfixturevalue("eggdrop_proc") + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + # Bot has not yet connected — confirm ISUPPORT is empty for both keys + # the new helpers consult. Then bans must still be present in memory. + assert bridge.eval_ok("set ::account-extban") == "" + + # Global extbans loaded. + assert bridge.eval_ok("isban a:storedacct") == "1" + assert bridge.eval_ok("isban q:storedmute") == "1" + assert bridge.eval_ok("isban U:strangers!*@*") == "1" + + # Per-channel extban loaded. + assert bridge.eval_ok("isban ~a:chanonlyacct #test") == "1" + + # And — critically — none of them got auto-stickified by the new + # u_addban code path, because the load path bypasses u_addban entirely. + assert bridge.eval_ok("isbansticky a:storedacct") == "0" + assert bridge.eval_ok("isbansticky q:storedmute") == "0" + assert bridge.eval_ok("isbansticky ~a:chanonlyacct #test") == "0" + + +def test_extban_perm_sticky_flags_survive_userfile_roundtrip( + eggdrop_config, + request: pytest.FixtureRequest, +) -> None: + """A perm + sticky extban *that was already in the userfile* loads with + those flags intact. Verifies that the `+`/`*` flag characters in the + record format aren't confused by the hex-escaped `:` in the mask. + """ + eggdrop_config.render( + userfile_ban_lines=[ + format_userfile_ban( + mask="a:permsticky", + perm=True, + sticky=True, + expire=0, + added=1700000000, + lastactive=0, + creator="owner", + desc="perm-sticky from userfile", + ), + ], + ) + request.getfixturevalue("eggdrop_proc") + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + assert bridge.eval_ok("isban a:permsticky") == "1" + assert bridge.eval_ok("isbansticky a:permsticky") == "1" + assert bridge.eval_ok("ispermban a:permsticky") == "1" + + +# ---------- partyline .+ban / .+extban across connection states ---------- + + +@pytest.mark.partyline +def test_partyline_pls_ban_extban_works_when_connected( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """`.+ban a:foo` (extban via the generic +ban command, not +extban) is + accepted while connected. The mask is stored verbatim — no prefix + construction (that's +extban's job). + """ + drive_registration( + mock_ircd, + isupport_tokens=["EXTBAN=~,acrjmU", "ACCOUNTEXTBAN=a"], + ) + drive_join_with_names(mock_ircd, "@TestBot") + wait_for_isupport(tcl_bridge, "EXTBAN", "~,acrjmU") + + eggdrop_proc.send_partyline(".+ban a:connectedacct #test why") + + wait_for( + lambda: tcl_bridge.eval_ok("isban a:connectedacct #test") == "1", + timeout=5.0, + description="partyline .+ban (extban form) to register on #test", + ) + # No auto-sticky. + assert tcl_bridge.eval_ok("isbansticky a:connectedacct #test") == "0" + + +@pytest.mark.partyline +def test_partyline_pls_ban_extban_works_when_not_yet_connected( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """`.+ban a:foo` works even before 005 has been received — `.+ban` + has no connection gate (only `.+extban` does, since only `+extban` + needs the prefix). The "extban not enabled" warning fires (because + EXTBAN ISUPPORT is unknown) but the ban is still stored. + """ + # Deliberately: NO drive_registration() — bot is pre-welcome. + snapshot = len(eggdrop_proc.stdout_text()) + eggdrop_proc.send_partyline(".+ban a:disconnectedacct why") + + wait_for( + lambda: tcl_bridge.eval_ok("isban a:disconnectedacct") == "1", + timeout=5.0, + description="partyline .+ban (extban form) to register while disconnected", + ) + + # Storage: not auto-stickified (regression for option-1 swap). + assert tcl_bridge.eval_ok("isbansticky a:disconnectedacct") == "0" + + # And the user got the EXTBAN-not-enabled feedback. The message text + # comes from EXTBAN_NOT_ENABLED1/2/3 in the language file; we look for + # a stable substring rather than the full template. + new_output = eggdrop_proc.stdout_text()[snapshot:] + assert "extban is not enabled on this server" in new_output, new_output + + +@pytest.mark.partyline +def test_partyline_pls_extban_works_when_connected_already_covered( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """Sanity counterpart to the `.+extban` disconnected test above: + same command, but with 005 received first, succeeds and stores the + prefixed mask. (Mostly redundant with `_constructs_prefixed_mask` + above — included so the connected/disconnected pair reads cleanly + next to each other.) + """ + drive_registration(mock_ircd, isupport_tokens=["EXTBAN=~,acrjmU"]) + drive_join_with_names(mock_ircd, "@TestBot") + wait_for_isupport(tcl_bridge, "EXTBAN", "~,acrjmU") + + eggdrop_proc.send_partyline(".+extban a Bar #test") + + wait_for( + lambda: tcl_bridge.eval_ok("isban ~a:Bar #test") == "1", + timeout=5.0, + description="connected .+extban to register ~a:Bar on #test", + ) + + +# ---------- partyline behaviour without server.mod loaded ---------- + + +@pytest.mark.partyline +def test_partyline_pls_ban_extban_works_without_server_mod( + eggdrop_config, + request: pytest.FixtureRequest, +) -> None: + """`.+ban a:foo` stores the extban even when server.mod isn't loaded + at all — the storage path doesn't need ISUPPORT, and the + `servermod_isupport_get` thunk returns NULL safely when + `module_find("server")` finds nothing. + + Without server.mod: irc.mod and ctcp.mod also can't load (they + `module_depend` on server). channels.mod loads cleanly because the + cross-module API bypass was fixed (commit 8cfcd51e). The bot has no + outbound IRC connection at all in this configuration. + + Driven via the `modules` template variable — see + tests/templates/eggdrop.conf.j2 and the `EggdropConfig.context()` + default in conftest.py. Render must happen *before* the proc fixture + evaluates, so the proc/bridge are pulled in lazily via getfixturevalue. + """ + eggdrop_config.render(modules=["pbkdf2", "channels", "console", "notes"]) + proc: EggdropProc = request.getfixturevalue("eggdrop_proc") + bridge: BridgeClient = request.getfixturevalue("tcl_bridge") + + # Sanity: server.mod is genuinely not loaded. + assert bridge.eval_ok("expr {[catch {set ::server-online}] != 0}") == "1" + + # `.+ban` with an extban mask: stored, no errors. + proc.send_partyline(".+ban a:noservermod why") + wait_for( + lambda: bridge.eval_ok("isban a:noservermod") == "1", + timeout=5.0, + description=".+ban (extban form) to register without server.mod", + ) + assert bridge.eval_ok("isbansticky a:noservermod") == "0" + + # `.+extban`: refused. The thunk returns NULL → no EXTBAN known → + # gated by the same check that fires when disconnected. + snapshot = len(proc.stdout_text()) + proc.send_partyline(".+extban a foo") + wait_for( + lambda: "must be connected to a server with EXTBAN support" + in proc.stdout_text()[snapshot:], + timeout=5.0, + description=".+extban refusal text without server.mod loaded", + ) + # Nothing stored from the +extban attempt. + assert bridge.eval_ok("isban a:foo") == "0" + assert bridge.eval_ok("isban ~a:foo") == "0" diff --git a/tests/tests/test_framing.py b/tests/tests/test_framing.py new file mode 100644 index 0000000000..29ee627490 --- /dev/null +++ b/tests/tests/test_framing.py @@ -0,0 +1,107 @@ +"""Unit tests for the line-delimited framing helpers.""" + +from __future__ import annotations + +import pytest + +from support.framing import ( + ProtocolError, + encode_request, + encode_response, + escape, + parse_response, + unescape, +) + + +@pytest.mark.parametrize( + ("raw", "encoded"), + [ + ("", ""), + ("hello", "hello"), + ("foo bar", "foo bar"), + ("a\\b", "a\\\\b"), + ("a\nb", "a\\nb"), + ("a\rb", "a\\rb"), + ("\\", "\\\\"), + ("\n", "\\n"), + ("\r", "\\r"), + ("\\n", "\\\\n"), # literal backslash + n, NOT a newline + ("a\\\nb", "a\\\\\\nb"), # backslash then newline + ("héllo", "héllo"), # unicode passes through + ], +) +def test_escape_unescape_roundtrip(raw: str, encoded: str) -> None: + assert escape(raw) == encoded + assert unescape(encoded) == raw + + +def test_unescape_dangling_backslash_raises() -> None: + with pytest.raises(ProtocolError, match="dangling backslash"): + unescape("foo\\") + + +def test_unescape_unknown_escape_raises() -> None: + with pytest.raises(ProtocolError, match="unknown escape"): + unescape("foo\\x") + + +def test_encode_request_appends_newline() -> None: + assert encode_request("set foo 42") == b"set foo 42\n" + + +def test_encode_request_escapes_newline() -> None: + assert encode_request("a\nb") == b"a\\nb\n" + + +def test_encode_response_format() -> None: + assert encode_response("OK", "42") == b"OK 42\n" + assert encode_response("ERR", "bad") == b"ERR bad\n" + + +def test_encode_response_escapes_payload() -> None: + assert encode_response("OK", "line1\nline2") == b"OK line1\\nline2\n" + + +def test_parse_response_ok() -> None: + assert parse_response("OK 42\n") == ("OK", "42") + + +def test_parse_response_err() -> None: + assert parse_response("ERR bad command\n") == ("ERR", "bad command") + + +def test_parse_response_empty_payload() -> None: + assert parse_response("OK\n") == ("OK", "") + assert parse_response("OK \n") == ("OK", "") + + +def test_parse_response_unescapes() -> None: + assert parse_response("OK line1\\nline2\n") == ("OK", "line1\nline2") + assert parse_response("OK a\\\\b\n") == ("OK", "a\\b") + + +def test_parse_response_strips_crlf() -> None: + assert parse_response("OK 42\r\n") == ("OK", "42") + + +@pytest.mark.parametrize( + "payload", + [ + "", + "simple", + "with spaces and stuff", + "newlines\nare\nfine", + "carriage\rreturn", + "back\\slash", + "all\\of\nthe\rabove", + "unicode: héllo, 日本語", + "a" * 4096, + ], +) +def test_response_roundtrip(payload: str) -> None: + framed = encode_response("OK", payload) + assert framed.endswith(b"\n") + assert framed.count(b"\n") == 1, "frame must be exactly one line" + line = framed.decode("utf-8") + assert parse_response(line) == ("OK", payload) diff --git a/tests/tests/test_isupport_modes.py b/tests/tests/test_isupport_modes.py new file mode 100644 index 0000000000..a0c36a6720 --- /dev/null +++ b/tests/tests/test_isupport_modes.py @@ -0,0 +1,381 @@ +"""Tests for arbitrary chan-modes (isupport PREFIX + CHANMODES) handling. + +Each test is self-contained — the realistic isupport strings used (drawn +from the top-3 most-popular advertised values at +https://stats.ircdocs.horse/isupport/) appear inline so the test reads as +a complete story. + +Coverage: +- 005 parsing of PREFIX and CHANMODES → debug log + isupport state +- `.status all` on the partyline reflects what was parsed +- Raw 324 (RPL_CHANNELMODEIS) with mixed known/unknown modes after join +- Inbound MODE messages mixing prefix modes, hardcoded modes, and unknown + modes — Eggdrop must skip what it doesn't know but still apply known + ones in the right slots (op grant, key after junk modes). +""" + +from __future__ import annotations + +import re + +import pytest + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc +from support.irc_helpers import ( + drive_join_with_names, + drive_registration, + wait_for_isupport, +) +from support.mock_ircd import MockIrcd +from support.waiters import wait_for + +# ---------- 005 parsing: debug log + isupport state ---------- + + +def test_005_parses_prefix_qaohv_and_chanmodes_top1( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """Most-popular PREFIX (qaohv, 123 networks) + most-popular CHANMODES. + + Each prefix mode and each LIST/KEY chanmode produces a corresponding + "Learned mode type: ..." debug line, and isupport state matches. + """ + prefix = "(qaohv)~&@%+" + chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc" + + drive_registration( + mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"] + ) + wait_for_isupport(tcl_bridge, "PREFIX", prefix) + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + + log = eggdrop_proc.log_path.read_text() + # Each prefix letter → symbol pair must appear in the debug log. + for letter, symbol in [("q", "~"), ("a", "&"), ("o", "@"), ("h", "%"), ("v", "+")]: + pat = ( + rf"Learned mode type: \+{letter} type Prefix, " + rf"prefixchar {re.escape(symbol)}" + ) + assert re.search(pat, log), f"missing prefix debug line for +{letter} {symbol}" + # CHANMODES sections: LIST=beI, KEY=kLf. + for letter in "beI": + assert re.search(rf"Learned mode type: \+{letter} type List(?!.)", log), ( + f"missing List debug line for +{letter}" + ) + for letter in "kLf": + assert re.search(rf"Learned mode type: \+{letter} type Key(?!.)", log), ( + f"missing Key debug line for +{letter}" + ) + + +def test_005_parses_prefix_ohv_and_chanmodes_top2( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """Second-most-popular PREFIX (47 networks) + second-most-popular CHANMODES.""" + prefix = "(ohv)@%+" + chanmodes = "beI,kfL,lj,psmntirRcOAQKVCuzNSMTG" + + drive_registration( + mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"] + ) + wait_for_isupport(tcl_bridge, "PREFIX", prefix) + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + + log = eggdrop_proc.log_path.read_text() + for letter, symbol in [("o", "@"), ("h", "%"), ("v", "+")]: + pat = ( + rf"Learned mode type: \+{letter} type Prefix, " + rf"prefixchar {re.escape(symbol)}" + ) + assert re.search(pat, log), f"missing prefix debug line for +{letter} {symbol}" + # CHANMODES sections: LIST=beI, KEY=kfL. + for letter in "beI": + assert re.search(rf"Learned mode type: \+{letter} type List(?!.)", log) + for letter in "kfL": + assert re.search(rf"Learned mode type: \+{letter} type Key(?!.)", log) + + +def test_005_parses_prefix_ov_and_chanmodes_top3( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """Basic PREFIX (op + voice only) + CHANMODES with q/a as LIST modes + instead of prefix modes.""" + prefix = "(ov)@+" + chanmodes = "beIqa,kLf,l,psmntirzMQNRTOVKDdGPZS" + + drive_registration( + mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"] + ) + wait_for_isupport(tcl_bridge, "PREFIX", prefix) + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + + log = eggdrop_proc.log_path.read_text() + for letter, symbol in [("o", "@"), ("v", "+")]: + pat = ( + rf"Learned mode type: \+{letter} type Prefix, " + rf"prefixchar {re.escape(symbol)}" + ) + assert re.search(pat, log), f"missing prefix debug line for +{letter} {symbol}" + # CHANMODES sections: LIST=beIqa (note 'q' and 'a' are LIST here, not prefix), KEY=kLf. + for letter in "beIqa": + assert re.search(rf"Learned mode type: \+{letter} type List(?!.)", log) + for letter in "kLf": + assert re.search(rf"Learned mode type: \+{letter} type Key(?!.)", log) + + +# ---------- use-exempts / use-invites derive from CHANMODES ---------- + + +def test_use_exempts_and_invites_set_when_e_and_I_in_list( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """`use-exempts` / `use-invites` are turned on when 'e' and 'I' are in the + LIST section of CHANMODES (the typical case for major IRCds).""" + chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc" # 'e' and 'I' both in LIST + drive_registration(mock_ircd, isupport_tokens=[f"CHANMODES={chanmodes}"]) + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + + assert tcl_bridge.eval_ok("set ::use-exempts") == "1" + assert tcl_bridge.eval_ok("set ::use-invites") == "1" + + +def test_use_exempts_off_when_e_not_in_list( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """If 'e' is missing from the LIST section, use-exempts goes to 0 + (use-invites stays on because 'I' is still in LIST).""" + chanmodes = "bI,k,l,imnpst" # no 'e' in list section + drive_registration(mock_ircd, isupport_tokens=[f"CHANMODES={chanmodes}"]) + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + + assert tcl_bridge.eval_ok("set ::use-exempts") == "0" + assert tcl_bridge.eval_ok("set ::use-invites") == "1" + + +# ---------- .status all on the partyline ---------- + + +@pytest.mark.partyline +def test_status_all_reports_parsed_isupport( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """`.status all` partyline output contains an `isupport:` line including + the PREFIX and CHANMODES we sent in 005.""" + prefix = "(qaohv)~&@%+" + chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc" + + drive_registration( + mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"] + ) + wait_for_isupport(tcl_bridge, "PREFIX", prefix) + + # Mark a snapshot so we only scan output produced after sending the cmd. + snapshot_offset = len(eggdrop_proc.stdout_text()) + eggdrop_proc.send_partyline(".status all") + + def has_isupport_line() -> bool: + new_output = eggdrop_proc.stdout_text()[snapshot_offset:] + return any( + "isupport:" in line and "PREFIX=" in line and "CHANMODES=" in line + for line in new_output.splitlines() + ) + + wait_for( + has_isupport_line, + timeout=5.0, + description="`.status all` to print the isupport: line", + ) + + new_output = eggdrop_proc.stdout_text()[snapshot_offset:] + assert f"PREFIX={prefix}" in new_output + assert f"CHANMODES={chanmodes}" in new_output + + +# ---------- raw 324 with mixed modes ---------- + + +def test_got324_skips_unknown_mode_but_applies_key( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """324 `+fk test_f test_k` — `f` (forward, common on Charybdis) is unknown + to Eggdrop but isupport says it has a parameter; `f` is skipped, the key + `test_k` still applies.""" + prefix = "(ohv)@%+" + # 'f' lives in the KEY section here (kfL), so isupport says it takes an arg. + chanmodes = "beI,kfL,lj,psmntirRcOAQKVCuzNSMTG" + + drive_registration( + mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"] + ) + chan = drive_join_with_names(mock_ircd, "@TestBot") + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + + mock_ircd.send(f":mock.test 324 TestBot {chan} +fk test_f test_k") + + wait_for( + lambda: "test_k" in tcl_bridge.eval_ok(f'getchanmode "{chan}"'), + timeout=5.0, + description="324 to apply key=test_k", + ) + + chanmode_str = tcl_bridge.eval_ok(f'getchanmode "{chan}"') + # Format is "+k " — verify 'k' is set, key is correct, + # and 'f' (unknown) does not appear in the flags. + flags, _, key = chanmode_str.partition(" ") + assert "k" in flags, chanmode_str + assert "f" not in flags, chanmode_str + assert key.strip() == "test_k", chanmode_str + + +def test_got324_conflict_eggdrop_says_noargs_isupport_says_args( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """If isupport puts a mode Eggdrop hardcodes as no-arg into a section + with args, got324 logs a warning and skips that mode (consuming its + arg). The next mode in line still gets parsed correctly. + + Constructed CHANMODES: 'q' (Eggdrop hardcodes it as no-arg / quiet) is + placed in the LIST section (with-arg). Sending `324 +qk arg_q test_k` + must skip the `q arg_q` pair entirely and still apply `+k test_k`. + """ + chanmodes = "beIq,k,l,imnpst" # 'q' in LIST → isupport says it takes arg + + drive_registration(mock_ircd, isupport_tokens=[f"CHANMODES={chanmodes}"]) + chan = drive_join_with_names(mock_ircd, "@TestBot") + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + + mock_ircd.send(f":mock.test 324 TestBot {chan} +qk arg_q test_k") + + wait_for( + lambda: "test_k" in tcl_bridge.eval_ok(f'getchanmode "{chan}"'), + timeout=5.0, + description="324 to apply key=test_k after skipped +q", + ) + + log = eggdrop_proc.log_path.read_text() + assert re.search( + r"Eggdrop assumes mode change \+q has no parameter but isupport says yes", + log, + ), "expected the +q conflict warning in the log" + + +# ---------- inbound MODE while joined ---------- + + +def test_gotmode_op_via_prefix_after_unknown_mode_with_arg( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """MODE `+ofk alice test_f test_k` on a joined channel: + - alice gets opped (prefix mode +o consumes its arg correctly), + - 'f' is consumed-and-discarded (unknown to Eggdrop but isupport says + it takes an arg), + - 'k' still applies with `test_k`. + """ + prefix = "(ohv)@%+" + chanmodes = "beI,kfL,lj,psmntirRcOAQKVCuzNSMTG" # 'f' is KEY-type here + + drive_registration( + mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"] + ) + chan = drive_join_with_names(mock_ircd, "@TestBot alice") + wait_for_isupport(tcl_bridge, "CHANMODES", chanmodes) + wait_for( + lambda: tcl_bridge.eval_ok(f'onchan alice "{chan}"') == "1", + timeout=5.0, + description="alice to appear in chanlist", + ) + + mock_ircd.send(f":someop!u@h MODE {chan} +ofk alice test_f test_k") + + wait_for( + lambda: tcl_bridge.eval_ok(f'isop alice "{chan}"') == "1", + timeout=5.0, + description="alice to be opped via prefix +o", + ) + chanmode_str = tcl_bridge.eval_ok(f'getchanmode "{chan}"') + flags, _, key = chanmode_str.partition(" ") + assert key.strip() == "test_k", chanmode_str + assert "f" not in flags, chanmode_str + + +def test_gotmode_extended_prefix_modes_qa_consume_args_correctly( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """With PREFIX=(qaohv)~&@%+, MODE `+qav nick1 nick2 nick3` consumes args + for each prefix mode (owner/admin/voice). + + Eggdrop only tracks op/halfop/voice flags by default, but the parser + must still consume all three args correctly so the next mode in line + isn't shifted. Verified by checking voice on the third nick. + """ + prefix = "(qaohv)~&@%+" + chanmodes = "beI,kLf,l,psmntirzMQNRTOVKDdGPZSCc" + + drive_registration( + mock_ircd, isupport_tokens=[f"PREFIX={prefix}", f"CHANMODES={chanmodes}"] + ) + chan = drive_join_with_names(mock_ircd, "@TestBot owner_user admin_user voice_user") + wait_for_isupport(tcl_bridge, "PREFIX", prefix) + wait_for( + lambda: tcl_bridge.eval_ok(f'onchan voice_user "{chan}"') == "1", + timeout=5.0, + description="voice_user to appear in chanlist", + ) + + mock_ircd.send(f":someop!u@h MODE {chan} +qav owner_user admin_user voice_user") + + wait_for( + lambda: tcl_bridge.eval_ok(f'isvoice voice_user "{chan}"') == "1", + timeout=5.0, + description="voice_user to be voiced after +qav consumed all 3 args", + ) + + +def test_names_with_extended_prefix_grants_op_when_opchars_includes_it( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """NAMES line with `~` (owner) prefix grants op when `opchars` contains it. + + Eggdrop's op-recognition uses the `opchars` set; for networks with + extended prefixes admins typically configure `opchars "~&@"`. With that + config, a `~user` in NAMES (and the corresponding WHO 352 with `H~` in + flags) lands as op. + """ + prefix = "(qaohv)~&@%+" + + drive_registration(mock_ircd, isupport_tokens=[f"PREFIX={prefix}"]) + # Set opchars to include owner/admin symbols. Must happen before the JOIN + # is driven because that's when NAMES + WHO 352 entries are processed. + tcl_bridge.eval_ok('set opchars "~&@"') + + chan = drive_join_with_names(mock_ircd, "@TestBot ~bigboss +regular") + wait_for( + lambda: tcl_bridge.eval_ok(f'onchan bigboss "{chan}"') == "1", + timeout=5.0, + description="bigboss to appear in chanlist", + ) + assert tcl_bridge.eval_ok(f'isop bigboss "{chan}"') == "1" + assert tcl_bridge.eval_ok(f'isvoice regular "{chan}"') == "1" diff --git a/tests/tests/test_partyline_chan.py b/tests/tests/test_partyline_chan.py new file mode 100644 index 0000000000..da65370dea --- /dev/null +++ b/tests/tests/test_partyline_chan.py @@ -0,0 +1,39 @@ +"""Partyline integration test: drive HQ partyline via stdin, verify via bridge.""" + +from __future__ import annotations + +import pytest + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc +from support.irc_helpers import drive_join_with_names, drive_registration +from support.mock_ircd import MockIrcd +from support.waiters import wait_for + + +@pytest.mark.partyline +def test_partyline_add_channel( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """Add a channel via the HQ partyline `.+chan` command, verify via bridge.""" + drive_registration(mock_ircd) + drive_join_with_names(mock_ircd, "@TestBot") + + # Sanity: only the templated #test is configured. + assert tcl_bridge.eval_ok("llength [channels]") == "1" + + # Drive the HQ partyline. The HQ user is `-HQ` with full owner perms in + # -nt mode, so no auth handshake is needed. + eggdrop_proc.send_partyline(".+chan #pytest") + + # The command runs asynchronously inside Eggdrop's event loop. Poll the + # bridge until the new channel is visible (or the wait_for times out). + wait_for( + lambda: tcl_bridge.eval_ok( + 'expr {[lsearch [channels] "#pytest"] >= 0}' + ) == "1", + timeout=5.0, + description="partyline .+chan #pytest to register", + ) diff --git a/tests/tests/test_smoke_connect.py b/tests/tests/test_smoke_connect.py new file mode 100644 index 0000000000..d8841608f1 --- /dev/null +++ b/tests/tests/test_smoke_connect.py @@ -0,0 +1,34 @@ +"""End-to-end smoke test: spawn Eggdrop, register, join, introspect via bridge.""" + +from __future__ import annotations + +from support.bridge_client import BridgeClient +from support.eggdrop_proc import EggdropProc +from support.irc_helpers import drive_join_with_names, drive_registration +from support.mock_ircd import MockIrcd + + +def test_bridge_alive(tcl_bridge: BridgeClient) -> None: + """Eggdrop boots, the bridge listens, Tcl evaluates.""" + assert tcl_bridge.eval_ok("expr {2 + 2}") == "4" + assert tcl_bridge.eval_ok("info patchlevel") # non-empty + assert tcl_bridge.eval_ok("set ::nick") == "TestBot" + + +def test_connect_register_join_and_introspect( + eggdrop_proc: EggdropProc, + mock_ircd: MockIrcd, + tcl_bridge: BridgeClient, +) -> None: + """Full happy path: register, join, introspect.""" + drive_registration(mock_ircd) + chan = drive_join_with_names(mock_ircd, "@TestBot") + assert chan == "#test" + + # Use lsearch in Tcl so list quoting (curly-brace wrapping of names that + # start with '#') doesn't trip us up. + assert tcl_bridge.eval_ok('expr {[lsearch [channels] "#test"] >= 0}') == "1" + + # Owner host from the rendered userfile is *!*@127.0.0.1. + hosts = tcl_bridge.eval_ok("getuser owner HOSTS") + assert "127.0.0.1" in hosts diff --git a/tests/tests/test_tcl_addbot.py b/tests/tests/test_tcl_addbot.py new file mode 100644 index 0000000000..aaee5ab858 --- /dev/null +++ b/tests/tests/test_tcl_addbot.py @@ -0,0 +1,175 @@ +"""Converted from eggdrop-tests/eggdrop_tcl_addbot.bats. + +Tests for the `addbot` Tcl command — adding a bot record with various +address/port formats. Verifies the result is the expected `botaddr` +triple `host port port` (or `host botport userport`). + +Skipped from the original bats: the two cases that required a second +non-IPv6 eggdrop instance to verify rejection (port < 1, port > 65535). +Those need a multi-bot harness which is out of scope for this framework. +""" + +from __future__ import annotations + +import pytest + +from support.bridge_client import BridgeClient + +DEFAULT_PORT = "3333" # default-port set in the templated config +BOT = "testbot" + + +@pytest.fixture +def ipv6_required(tcl_bridge: BridgeClient) -> None: + """Skip the test if Eggdrop wasn't compiled with IPv6 support.""" + # `info procs` won't work; check for IPv6 by trying an IPv6 add and seeing + # if it succeeds. A simpler approach: peek at `set prefer-ipv6` (only set + # when the build has IPv6). Use the actual `addbot` smoke test as the + # truthy probe — addbot returns "0" if IPv6 isn't supported. + tcl_bridge.eval_ok("deluser ipv6probe") + if tcl_bridge.eval_ok("addbot ipv6probe ::1") != "1": + pytest.skip("Eggdrop built without IPv6 support") + tcl_bridge.eval_ok("deluser ipv6probe") + + +# ---------- legacy 'addbot handle address ?botport ?userport??' format ---------- + + +def test_addbot_handle_ipv4_uses_default_port(tcl_bridge: BridgeClient) -> None: + assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1") == "1" + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == f"1.1.1.1 {DEFAULT_PORT} {DEFAULT_PORT}" + ) + + +def test_addbot_handle_ipv6_uses_default_port( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + assert tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5") == "1" + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == f"fe80::69ec:cfe4:81de:4fe5 {DEFAULT_PORT} {DEFAULT_PORT}" + ) + + +def test_addbot_handle_ipv4_with_botport(tcl_bridge: BridgeClient) -> None: + assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1 5555") == "1" + assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 5555 5555" + + +def test_addbot_handle_ipv6_with_botport( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + assert tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5 6666") == "1" + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == "fe80::69ec:cfe4:81de:4fe5 6666 6666" + ) + + +def test_addbot_handle_ipv4_with_botport_and_userport( + tcl_bridge: BridgeClient, +) -> None: + assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1 5555 6666") == "1" + assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 5555 6666" + + +def test_addbot_handle_ipv6_with_botport_and_userport( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + assert ( + tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5 6666 7777") + == "1" + ) + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == "fe80::69ec:cfe4:81de:4fe5 6666 7777" + ) + + +# ---------- ipv4:port packed format ---------- + + +def test_addbot_handle_ipv4_colon_port(tcl_bridge: BridgeClient) -> None: + assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1:4444") == "1" + assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 4444 4444" + + +def test_addbot_with_packed_address_ignores_extra_args( + tcl_bridge: BridgeClient, +) -> None: + """When the address arg uses `:`/`/` separators, extra positional args + are ignored (the packed form is canonical).""" + assert tcl_bridge.eval_ok(f"addbot {BOT} 1.1.1.1:4444/5555 6666") == "1" + assert tcl_bridge.eval_ok(f"getuser {BOT} botaddr") == "1.1.1.1 4444 5555" + + +# ---------- bracketed IPv6 ---------- + + +def test_addbot_bracketed_ipv6_with_colon_botport( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + assert ( + tcl_bridge.eval_ok(f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\]:4444") + == "1" + ) + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == "fe80::69ec:cfe4:81de:4fe5 4444 4444" + ) + + +def test_addbot_bracketed_ipv6_with_colon_botport_slash_userport( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + assert ( + tcl_bridge.eval_ok( + f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\]:4444/5555" + ) + == "1" + ) + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == "fe80::69ec:cfe4:81de:4fe5 4444 5555" + ) + + +def test_addbot_bracketed_ipv6_with_separate_botport( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + assert ( + tcl_bridge.eval_ok(f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\] 4444") + == "1" + ) + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == "fe80::69ec:cfe4:81de:4fe5 4444 4444" + ) + + +def test_addbot_bracketed_ipv6_with_separate_botport_userport( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + assert ( + tcl_bridge.eval_ok( + f"addbot {BOT} \\[fe80::69ec:cfe4:81de:4fe5\\] 4444 5555" + ) + == "1" + ) + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == "fe80::69ec:cfe4:81de:4fe5 4444 5555" + ) + + +def test_addbot_ipv6_with_trailing_slash_uses_default_ports( + tcl_bridge: BridgeClient, ipv6_required: None +) -> None: + """A trailing `/` after IPv6 means "no port given", default-port applies.""" + assert tcl_bridge.eval_ok(f"addbot {BOT} fe80::69ec:cfe4:81de:4fe5/") == "1" + assert ( + tcl_bridge.eval_ok(f"getuser {BOT} botaddr") + == f"fe80::69ec:cfe4:81de:4fe5 {DEFAULT_PORT} {DEFAULT_PORT}" + ) diff --git a/tests/tests/test_tcl_iscmds.py b/tests/tests/test_tcl_iscmds.py new file mode 100644 index 0000000000..59f864e54b --- /dev/null +++ b/tests/tests/test_tcl_iscmds.py @@ -0,0 +1,197 @@ +"""Converted from eggdrop-tests/eggdrop_tcl_iscmds.bats. + +Tests for `isban`, `isbansticky`, `isexempt`, `isinvite` — the 4 lookup +commands for global vs channel ban/exempt/invite lists. + +The original bats test had a single shared eggdrop and mutated state +between cases; here each test gets a fresh spawn and sets up its own +state inline, which makes the intent of each case obvious in isolation. +""" + +from __future__ import annotations + +import pytest + +from support.bridge_client import BridgeClient + +CHAN = "#foober" +HOST = "*!test@foo.com" + + +@pytest.fixture +def chan(tcl_bridge: BridgeClient) -> str: + """Add #foober and return its name.""" + tcl_bridge.eval_ok(f"channel add {CHAN}") + return CHAN + + +# ---------- isban ---------- + + +def test_isban_returns_0_when_no_global_or_channel_ban( + tcl_bridge: BridgeClient, chan: str +) -> None: + assert tcl_bridge.eval_ok(f"isban {HOST}") == "0" + + +def test_isban_returns_1_when_only_global_ban_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newban {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isban {HOST}") == "1" + + +def test_isban_with_channel_returns_1_when_only_channel_ban_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isban {HOST} {chan}") == "1" + + +def test_isban_returns_1_when_both_global_and_channel_ban_exist( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newban {HOST} testuser comment") + tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isban {HOST} {chan}") == "1" + + +def test_isban_with_channel_returns_1_for_global_when_only_channel_ban_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + """isban without a channel hits the global list — global ban present → 1.""" + tcl_bridge.eval_ok(f"newban {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isban {HOST}") == "1" + + +def test_isban_with_channel_returns_0_when_only_global_ban_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + """isban WITH a channel checks both — but if neither global+channel match, + returns 0. Here only global is set, so isban for chan-scope returns 1 + (eggdrop combines global + chan).""" + tcl_bridge.eval_ok(f"newban {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isban {HOST} {chan}") == "1" + + +def test_isban_returns_0_when_only_channel_ban_exists_no_chan_arg( + tcl_bridge: BridgeClient, chan: str +) -> None: + """Without a channel arg, isban only looks at the global list.""" + tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isban {HOST}") == "0" + + +# ---------- isbansticky ---------- + + +def test_isbansticky_returns_0_when_no_global_or_channel_sticky_ban( + tcl_bridge: BridgeClient, chan: str +) -> None: + assert tcl_bridge.eval_ok(f"isbansticky {HOST}") == "0" + + +def test_isbansticky_returns_1_when_only_global_sticky_ban_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newban {HOST} testuser comment 60 sticky") + assert tcl_bridge.eval_ok(f"isbansticky {HOST}") == "1" + + +def test_isbansticky_with_channel_returns_1_when_only_channel_sticky_ban_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment 60 sticky") + assert tcl_bridge.eval_ok(f"isbansticky {HOST} {chan}") == "1" + + +def test_isbansticky_returns_1_when_both_sticky_bans_exist( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newban {HOST} testuser comment 60 sticky") + tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment 60 sticky") + assert tcl_bridge.eval_ok(f"isbansticky {HOST} {chan}") == "1" + + +def test_isbansticky_returns_0_when_only_channel_sticky_ban_no_chan_arg( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newchanban {chan} {HOST} testuser comment 60 sticky") + # Original bats test asserts isban (not isbansticky) returns 0 here. Match it. + assert tcl_bridge.eval_ok(f"isban {HOST}") == "0" + + +# ---------- isexempt ---------- + + +def test_isexempt_returns_0_when_no_global_or_channel_exempt( + tcl_bridge: BridgeClient, chan: str +) -> None: + assert tcl_bridge.eval_ok(f"isexempt {HOST}") == "0" + + +def test_isexempt_returns_1_when_only_global_exempt_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newexempt {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isexempt {HOST}") == "1" + + +def test_isexempt_with_channel_returns_1_when_only_channel_exempt_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newchanexempt {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isexempt {HOST} {chan}") == "1" + + +def test_isexempt_returns_1_when_both_global_and_channel_exempt_exist( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newexempt {HOST} testuser comment") + tcl_bridge.eval_ok(f"newchanexempt {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isexempt {HOST} {chan}") == "1" + + +def test_isexempt_returns_0_when_only_channel_exempt_no_chan_arg( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newchanexempt {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isexempt {HOST}") == "0" + + +# ---------- isinvite ---------- + + +def test_isinvite_returns_0_when_no_global_or_channel_invite( + tcl_bridge: BridgeClient, chan: str +) -> None: + assert tcl_bridge.eval_ok(f"isinvite {HOST}") == "0" + + +def test_isinvite_returns_1_when_only_global_invite_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newinvite {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isinvite {HOST}") == "1" + + +def test_isinvite_with_channel_returns_1_when_only_channel_invite_exists( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newchaninvite {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isinvite {HOST} {chan}") == "1" + + +def test_isinvite_returns_1_when_both_global_and_channel_invite_exist( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newinvite {HOST} testuser comment") + tcl_bridge.eval_ok(f"newchaninvite {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isinvite {HOST} {chan}") == "1" + + +def test_isinvite_returns_0_when_only_channel_invite_no_chan_arg( + tcl_bridge: BridgeClient, chan: str +) -> None: + tcl_bridge.eval_ok(f"newchaninvite {chan} {HOST} testuser comment") + assert tcl_bridge.eval_ok(f"isinvite {HOST}") == "0" diff --git a/tests/tests/test_tcl_matchattr.py b/tests/tests/test_tcl_matchattr.py new file mode 100644 index 0000000000..230a7a8b10 --- /dev/null +++ b/tests/tests/test_tcl_matchattr.py @@ -0,0 +1,207 @@ +"""Converted from eggdrop-tests/eggdrop_tcl_matchattr.bats. + +Tests for `matchattr` — Tcl flag-matching against a user record. Covers +global flags, channel flags, the `&` (all-of) operator, and rejection of +unknown flags. + +Original setup: + adduser foo + channel add #foober + chattr foo +jlmoptx|+lov #foober + +Result: foo has global +jlmoptx and channel +lov on #foober. +""" + +from __future__ import annotations + +import pytest + +from support.bridge_client import BridgeClient + +CHAN = "#foober" + + +@pytest.fixture +def matchattr_user(tcl_bridge: BridgeClient) -> str: + """Per-test setup: adduser foo with global +jlmoptx and channel +lov on #foober.""" + tcl_bridge.eval_ok(f"channel add {CHAN}") + tcl_bridge.eval_ok("adduser foo") + tcl_bridge.eval_ok(f"chattr foo +jlmoptx|+lov {CHAN}") + return "foo" + + +# ---------- + (any-of) on global flags ---------- + + +def test_matchattr_single_global_plus_flag_matches_when_user_has_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo +o") == "1" + + +def test_matchattr_single_global_plus_flag_does_not_match_when_user_lacks_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo +g") == "0" + + +def test_matchattr_two_global_plus_flags_matches_if_user_has_one( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + """+ is any-of by default: +on matches if user has o OR n.""" + assert tcl_bridge.eval_ok("matchattr foo +on") == "1" + + +def test_matchattr_two_global_plus_flags_does_not_match_if_user_has_neither( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo +gn") == "0" + + +# ---------- & (all-of) operator on global flags ---------- + + +def test_matchattr_two_global_plus_flags_with_amp_matches_if_user_has_both( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + """& makes it all-of: +mo& matches if user has BOTH global m AND o.""" + assert tcl_bridge.eval_ok(f"matchattr foo +mo& {CHAN}") == "1" + + +def test_matchattr_two_global_plus_flags_with_amp_does_not_match_if_user_has_one( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo +mn& {CHAN}") == "0" + + +# ---------- - (none-of) on global flags ---------- + + +def test_matchattr_single_global_minus_flag_matches_when_user_lacks_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + """-n matches if user does NOT have n.""" + assert tcl_bridge.eval_ok("matchattr foo -n") == "1" + + +def test_matchattr_single_global_minus_flag_does_not_match_when_user_has_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo -m") == "0" + + +def test_matchattr_two_global_minus_flags_matches_if_user_lacks_one( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo -mn") == "1" + + +def test_matchattr_two_global_minus_flags_matches_if_user_lacks_both( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo -gn") == "1" + + +def test_matchattr_two_global_minus_flags_does_not_match_if_user_has_both( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo -om") == "0" + + +# ---------- channel flags (| separator) ---------- + + +def test_matchattr_single_channel_plus_flag_matches_when_user_has_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |+o {CHAN}") == "1" + + +def test_matchattr_single_channel_plus_flag_does_not_match_when_user_lacks_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |+g {CHAN}") == "0" + + +def test_matchattr_two_channel_plus_flags_matches_if_user_has_one( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |+on {CHAN}") == "1" + + +def test_matchattr_two_channel_plus_flags_does_not_match_if_user_has_neither( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |+gn {CHAN}") == "0" + + +def test_matchattr_two_channel_plus_flags_with_amp_matches_if_user_has_both( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo &+lo {CHAN}") == "1" + + +def test_matchattr_two_channel_plus_flags_with_amp_does_not_match_if_user_has_one( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo &+om {CHAN}") == "0" + + +def test_matchattr_single_channel_minus_flag_matches_when_user_lacks_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |-n {CHAN}") == "1" + + +def test_matchattr_single_channel_minus_flag_does_not_match_when_user_has_it( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |-o {CHAN}") == "0" + + +def test_matchattr_two_channel_minus_flags_matches_if_user_lacks_one( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |-on {CHAN}") == "1" + + +def test_matchattr_two_channel_minus_flags_matches_if_user_lacks_both( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |-gn {CHAN}") == "1" + + +def test_matchattr_two_channel_minus_flags_does_not_match_if_user_has_both( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |-ov {CHAN}") == "0" + + +# ---------- error paths (behavior changed since the original bats tests) ---------- +# +# The original bats suite asserted that matchattr returned a Tcl error +# `Unknown flag specified for matching` for unknown global/channel/bot flags. +# The current implementation in src/tcluser.c (tcl_matchattr) calls +# break_down_flags() which silently ignores unknown characters, then if the +# resulting flag set is empty returns "1" (the "no flags matches anyone" +# branch). The "rejection" is gone — these tests now document the new +# silent-accept behavior instead. + + +def test_matchattr_silently_accepts_unknown_global_flag( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + """`+s` is unknown globally; ignored, leaving an empty plus set → matches anyone.""" + assert tcl_bridge.eval_ok("matchattr foo +s") == "1" + + +def test_matchattr_silently_accepts_unknown_channel_flag( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok(f"matchattr foo |+j {CHAN}") == "1" + + +def test_matchattr_silently_accepts_unknown_bot_flag( + tcl_bridge: BridgeClient, matchattr_user: str +) -> None: + assert tcl_bridge.eval_ok("matchattr foo ||+f") == "1" diff --git a/tests/tests/test_tcl_passwdok.py b/tests/tests/test_tcl_passwdok.py new file mode 100644 index 0000000000..cd2b19b63a --- /dev/null +++ b/tests/tests/test_tcl_passwdok.py @@ -0,0 +1,54 @@ +"""Converted from eggdrop-tests/eggdrop_tcl_passwdok.bats. + +Tests for the `passwdok` Tcl command — verifies user passwords match +their stored value, with correct handling of the empty / dash sentinels. +""" + +from __future__ import annotations + +from support.bridge_client import BridgeClient + + +def test_passwdok_returns_1_when_password_matches(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("adduser foo") + tcl_bridge.eval_ok("setuser foo PASS asdf") + assert tcl_bridge.eval_ok("passwdok foo asdf") == "1" + + +def test_passwdok_returns_0_when_password_differs(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("adduser foo") + tcl_bridge.eval_ok("setuser foo PASS asdf") + assert tcl_bridge.eval_ok("passwdok foo notasdf") == "0" + + +def test_passwdok_returns_1_for_dash_when_user_has_no_password( + tcl_bridge: BridgeClient, +) -> None: + """A `-` password literal matches a user that has no password set.""" + tcl_bridge.eval_ok("adduser foo") + tcl_bridge.eval_ok("setuser foo PASS {}") # clear password + assert tcl_bridge.eval_ok("passwdok foo -") == "1" + + +def test_passwdok_returns_0_for_dash_when_user_has_a_password( + tcl_bridge: BridgeClient, +) -> None: + tcl_bridge.eval_ok("adduser foo") + tcl_bridge.eval_ok("setuser foo PASS asdf") + assert tcl_bridge.eval_ok("passwdok foo -") == "0" + + +def test_passwdok_returns_0_for_empty_when_user_has_no_password( + tcl_bridge: BridgeClient, +) -> None: + tcl_bridge.eval_ok("adduser foo") + tcl_bridge.eval_ok("setuser foo PASS {}") + assert tcl_bridge.eval_ok('passwdok foo ""') == "0" + + +def test_passwdok_returns_0_for_empty_when_user_has_a_password( + tcl_bridge: BridgeClient, +) -> None: + tcl_bridge.eval_ok("adduser foo") + tcl_bridge.eval_ok("setuser foo PASS asdf") + assert tcl_bridge.eval_ok('passwdok foo ""') == "0" diff --git a/tests/tests/test_tcl_server.py b/tests/tests/test_tcl_server.py new file mode 100644 index 0000000000..661b0d92a8 --- /dev/null +++ b/tests/tests/test_tcl_server.py @@ -0,0 +1,139 @@ +"""Converted from eggdrop-tests/eggdrop_tcl_server.bats. + +Tests for the `server` Tcl command — adding, removing, and listing the +in-memory server list. + +API note: the original bats tests used the old `addserver`/`delserver`/ +`set servers` interface, which no longer exists. The current command is +`server add HOST ?PORT? ?PASS?` / `server remove HOST ?PORT?` / +`server list` (returns a list of `{host port pass}` triples). +""" + +from __future__ import annotations + +from support.bridge_client import BridgeClient + + +def _server_entries(tcl_bridge: BridgeClient) -> list[list[str]]: + """Return server list as a list of [host, port, pass] entries. + + `server list` formats each entry as a Tcl list `{host port pass}`. We + parse it via Tcl `lmap` to keep things simple on the Python side. + """ + raw = tcl_bridge.eval_ok( + 'lmap entry [server list] {format "%s|%s|%s" ' + "[lindex $entry 0] [lindex $entry 1] [lindex $entry 2]}" + ) + return [e.split("|") for e in raw.split() if e] + + +def _server_in_list( + tcl_bridge: BridgeClient, host: str, port: str | None = None +) -> bool: + for entry in _server_entries(tcl_bridge): + if entry[0] == host and (port is None or entry[1] == port): + return True + return False + + +# ---------- server add ---------- + + +def test_server_add_just_host_uses_default_port(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add irc.foo.com") + # Entry exists; port may be the configured default-port or empty. + assert _server_in_list(tcl_bridge, "irc.foo.com") + + +def test_server_add_with_explicit_port(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add irc.ferg.com 8877") + assert _server_in_list(tcl_bridge, "irc.ferg.com", "8877") + + +def test_server_add_with_port_and_password(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add irc.moo.com 4455 mypass") + for entry in _server_entries(tcl_bridge): + if entry[0] == "irc.moo.com" and entry[1] == "4455": + assert entry[2] == "mypass" + return + raise AssertionError("irc.moo.com:4455 not in server list") + + +def test_server_add_ssl_port_keeps_plus_prefix(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add irc.snell.com +7000") + assert _server_in_list(tcl_bridge, "irc.snell.com", "+7000") + + +def test_server_add_ipv6_address(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add 2344:2344:2344::5433:5433 5555") + assert _server_in_list(tcl_bridge, "2344:2344:2344::5433:5433", "5555") + + +def test_server_add_ipv4_address(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add 1.2.3.4 4444") + assert _server_in_list(tcl_bridge, "1.2.3.4", "4444") + + +# ---------- server remove ---------- + + +def test_server_remove_first_element(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add irc.first.com 1111") + tcl_bridge.eval_ok("server add irc.second.com 2222") + tcl_bridge.eval_ok("server remove irc.first.com 1111") + assert not _server_in_list(tcl_bridge, "irc.first.com", "1111") + assert _server_in_list(tcl_bridge, "irc.second.com", "2222") + + +def test_server_remove_middle_element(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add a.com 1111") + tcl_bridge.eval_ok("server add b.com 2222") + tcl_bridge.eval_ok("server add c.com 3333") + tcl_bridge.eval_ok("server remove b.com 2222") + assert _server_in_list(tcl_bridge, "a.com", "1111") + assert not _server_in_list(tcl_bridge, "b.com", "2222") + assert _server_in_list(tcl_bridge, "c.com", "3333") + + +def test_server_remove_ipv6(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add 2344:2344:2344::5433:5433 5555") + tcl_bridge.eval_ok("server remove 2344:2344:2344::5433:5433 5555") + assert not _server_in_list(tcl_bridge, "2344:2344:2344::5433:5433") + + +def test_server_remove_ipv4(tcl_bridge: BridgeClient) -> None: + tcl_bridge.eval_ok("server add 1.2.3.4 4444") + tcl_bridge.eval_ok("server remove 1.2.3.4 4444") + assert not _server_in_list(tcl_bridge, "1.2.3.4", "4444") + + +def test_server_remove_no_port_removes_all_matching_entries( + tcl_bridge: BridgeClient, +) -> None: + """Behavior change since the original bats: `server remove host` with no + port iterates the list and removes EVERY entry matching `host`. The bats + test assumed only the first match was removed.""" + tcl_bridge.eval_ok("server add irc.firstmatch.com 1111") + tcl_bridge.eval_ok("server add irc.firstmatch.com 2222") + tcl_bridge.eval_ok("server remove irc.firstmatch.com") + assert not _server_in_list(tcl_bridge, "irc.firstmatch.com") + + +def test_server_remove_with_port_only_removes_matching_host_port( + tcl_bridge: BridgeClient, +) -> None: + tcl_bridge.eval_ok("server add irc.firstmatch.com 1111") + tcl_bridge.eval_ok("server add irc.firstmatch.com 2222") + tcl_bridge.eval_ok("server remove irc.firstmatch.com 2222") + assert _server_in_list(tcl_bridge, "irc.firstmatch.com", "1111") + assert not _server_in_list(tcl_bridge, "irc.firstmatch.com", "2222") + + +# ---------- error paths ---------- + + +def test_server_add_rejects_colon_port_in_address(tcl_bridge: BridgeClient) -> None: + """`host:port` syntax is forbidden — port must be a separate argument.""" + ok, result = tcl_bridge.eval("server add irc.port.com:1111") + assert not ok + assert "Make sure the port is" in result diff --git a/tests/uv.lock b/tests/uv.lock new file mode 100644 index 0000000000..64701ccd08 --- /dev/null +++ b/tests/uv.lock @@ -0,0 +1,183 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "eggdrop-tests" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "jinja2" }, + { name = "pytest" }, + { name = "pytest-timeout" }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "jinja2", specifier = ">=3.1" }, + { name = "pytest", specifier = ">=8" }, + { name = "pytest-timeout", specifier = ">=2.3" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631 }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058 }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287 }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940 }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887 }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692 }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471 }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923 }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572 }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077 }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876 }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615 }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020 }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332 }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947 }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962 }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760 }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529 }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015 }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540 }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105 }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906 }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622 }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374 }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980 }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990 }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784 }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588 }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041 }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543 }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113 }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911 }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658 }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066 }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639 }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569 }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284 }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801 }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769 }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642 }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612 }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200 }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973 }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619 }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029 }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408 }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005 }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048 }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821 }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606 }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043 }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747 }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341 }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073 }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661 }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069 }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670 }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598 }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261 }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835 }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733 }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672 }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819 }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426 }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249 }, +] + +[[package]] +name = "pytest-timeout" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/82/4c9ecabab13363e72d880f2fb504c5f750433b2b6f16e99f4ec21ada284c/pytest_timeout-2.4.0.tar.gz", hash = "sha256:7e68e90b01f9eff71332b25001f85c75495fc4e3a836701876183c4bcfd0540a", size = 17973 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/b6/3127540ecdf1464a00e5a01ee60a1b09175f6913f0644ac748494d9c4b21/pytest_timeout-2.4.0-py3-none-any.whl", hash = "sha256:c42667e5cdadb151aeb5b26d114aff6bdf5a907f176a007a30b940d3d865b5c2", size = 14382 }, +]