From 06a29ff8fdae158f7762dc8a0cf7cd9d8a358ddf Mon Sep 17 00:00:00 2001 From: Paul Lodge Date: Wed, 7 Oct 2026 12:43:57 +0200 Subject: [PATCH 1/3] Add mod_brotli compression tests Co-Authored-By: Claude Opus 4.6 --- test/modules/brotli/__init__.py | 0 test/modules/brotli/conftest.py | 32 +++++++ test/modules/brotli/env.py | 25 ++++++ test/modules/brotli/htdocs/test1/greeting.txt | 1 + test/modules/brotli/htdocs/test1/lorem.txt | 19 ++++ test/modules/brotli/test_001_compression.py | 89 +++++++++++++++++++ 6 files changed, 166 insertions(+) create mode 100644 test/modules/brotli/__init__.py create mode 100644 test/modules/brotli/conftest.py create mode 100644 test/modules/brotli/env.py create mode 100644 test/modules/brotli/htdocs/test1/greeting.txt create mode 100644 test/modules/brotli/htdocs/test1/lorem.txt create mode 100644 test/modules/brotli/test_001_compression.py diff --git a/test/modules/brotli/__init__.py b/test/modules/brotli/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/modules/brotli/conftest.py b/test/modules/brotli/conftest.py new file mode 100644 index 00000000000..7caeef9549c --- /dev/null +++ b/test/modules/brotli/conftest.py @@ -0,0 +1,32 @@ +import logging +import os +import sys + +import pytest + +from .env import BrotliTestEnv + +sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) + + +def pytest_report_header(config, start_path): + env = BrotliTestEnv() + return f"brotli [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]" + + +@pytest.fixture(scope="package") +def env(pytestconfig) -> BrotliTestEnv: + logging.getLogger('').setLevel(level=logging.INFO) + env = BrotliTestEnv(pytestconfig=pytestconfig) + if not env.has_shared_module("brotli"): + pytest.skip("mod_brotli not available") + env.setup_httpd() + env.apache_access_log_clear() + env.httpd_error_log.clear_log() + return env + + +@pytest.fixture(autouse=True, scope="package") +def _stop_package_scope(env): + yield + assert env.apache_stop() == 0 diff --git a/test/modules/brotli/env.py b/test/modules/brotli/env.py new file mode 100644 index 00000000000..c87e513323a --- /dev/null +++ b/test/modules/brotli/env.py @@ -0,0 +1,25 @@ +import inspect +import logging +import os + +from pyhttpd.env import HttpdTestEnv, HttpdTestSetup + +log = logging.getLogger(__name__) + + +class BrotliTestSetup(HttpdTestSetup): + + def __init__(self, env: 'HttpdTestEnv'): + super().__init__(env=env) + self.add_source_dir(os.path.dirname(inspect.getfile(BrotliTestSetup))) + self.add_modules(["brotli"]) + + +class BrotliTestEnv(HttpdTestEnv): + + def __init__(self, pytestconfig=None): + super().__init__(pytestconfig=pytestconfig) + self.add_httpd_log_modules(["brotli", "core"]) + + def setup_httpd(self, setup: HttpdTestSetup = None): + super().setup_httpd(setup=BrotliTestSetup(env=self)) diff --git a/test/modules/brotli/htdocs/test1/greeting.txt b/test/modules/brotli/htdocs/test1/greeting.txt new file mode 100644 index 00000000000..512c014af03 --- /dev/null +++ b/test/modules/brotli/htdocs/test1/greeting.txt @@ -0,0 +1 @@ +Good morning, fellow! \ No newline at end of file diff --git a/test/modules/brotli/htdocs/test1/lorem.txt b/test/modules/brotli/htdocs/test1/lorem.txt new file mode 100644 index 00000000000..41266f0adb0 --- /dev/null +++ b/test/modules/brotli/htdocs/test1/lorem.txt @@ -0,0 +1,19 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod +tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim +veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea +commodo consequat. Duis aute irure dolor in reprehenderit in voluptate +velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat +cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id +est laborum. + +Curabitur pretium tincidunt lacus. Nulla gravida orci a odio. Nullam varius, +turpis et commodo pharetra, est eros bibendum elit, nec luctus magna felis +sollicitudin mauris. Integer in mauris eu nibh euismod gravida. Duis ac +tellus et risus vulputate vehicula. Donec lobortis risus a elit. Etiam +tempor. Ut ullamcorper, ligula ut dictum pharetra, nisi nunc fringilla +magna, in commodo elit erat nec turpis. Ut pharetra augue nec augue. +Nam elit agna, endrerit sit amet, tincidunt ac, viverra sed, nulla. +Donec porta diam eu massa. Quisque diam lorem, interdum vitae, dapibus +ac, scelerisque vitae, pede. Donec eget tellus non erat lacinia fermentum. +Donec in velit vel ipsum auctor pulvinar. Class aptent taciti sociosqu ad +litora torquent per conubia nostra, per inceptos hymenaeos. diff --git a/test/modules/brotli/test_001_compression.py b/test/modules/brotli/test_001_compression.py new file mode 100644 index 00000000000..89d3066bebc --- /dev/null +++ b/test/modules/brotli/test_001_compression.py @@ -0,0 +1,89 @@ +import os + +import pytest + +from pyhttpd.conf import HttpdConf + + +class TestBrotliCompression: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + conf = HttpdConf(env, extras={ + f"test1.{env.http_tld}": """ + + AddOutputFilterByType BROTLI_COMPRESS text/plain text/html text/css + + """, + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + # Request a small text file with Accept-Encoding: br. + # Verify Content-Encoding: br header is present. + def test_brotli_001_01(self, env): + url = env.mkurl("http", "test1", "/greeting.txt") + r = env.curl_get(url, options=[ + "-H", "Accept-Encoding: br", + ]) + assert r.response, "no response" + assert r.response["status"] == 200 + assert r.response["header"].get("content-encoding") == "br", \ + f"expected Content-Encoding: br, got: {r.response['header']}" + raw_body = r.response["body"] + assert len(raw_body) > 0, "compressed body should not be empty" + original = b"Good morning, fellow!" + assert len(raw_body) != len(original) or raw_body != original, \ + "body should be compressed, not plain text" + + # Request a larger text file; verify brotli compression is applied. + def test_brotli_001_02(self, env): + url = env.mkurl("http", "test1", "/lorem.txt") + r = env.curl_get(url, options=[ + "-H", "Accept-Encoding: br", + ]) + assert r.response, "no response" + assert r.response["status"] == 200 + assert r.response["header"].get("content-encoding") == "br", \ + f"expected Content-Encoding: br, got: {r.response['header']}" + lorem_path = os.path.join(env.server_dir, "htdocs", "test1", + "lorem.txt") + orig_size = os.path.getsize(lorem_path) + assert len(r.response["body"]) < orig_size, \ + "compressed response should be smaller than the original" + + # When the client does not advertise Accept-Encoding: br the response + # must be served uncompressed -- no Content-Encoding header. + def test_brotli_001_03(self, env): + url = env.mkurl("http", "test1", "/greeting.txt") + r = env.curl_get(url) + assert r.response, "no response" + assert r.response["status"] == 200 + assert "content-encoding" not in r.response["header"], \ + f"unexpected Content-Encoding present: " \ + f"{r.response['header'].get('content-encoding')}" + body = r.response["body"].decode() + assert body.strip() == "Good morning, fellow!" + + # Binary / non-matching content types must not be compressed. + def test_brotli_001_04(self, env): + bin_dir = os.path.join(env.server_dir, "htdocs", "test1") + bin_path = os.path.join(bin_dir, "pixel.png") + with open(bin_path, "wb") as f: + f.write( + b'\x89PNG\r\n\x1a\n' + b'\x00\x00\x00\rIHDR\x00\x00\x00\x01' + b'\x00\x00\x00\x01\x08\x02\x00\x00\x00\x90wS\xde' + b'\x00\x00\x00\x0cIDATx' + b'\x9cc\xf8\x0f\x00\x00\x01\x01\x00\x05' + b'\x18\xd8N\x00\x00\x00\x00IEND\xaeB`\x82' + ) + url = env.mkurl("http", "test1", "/pixel.png") + r = env.curl_get(url, options=[ + "-H", "Accept-Encoding: br", + ]) + assert r.response, "no response" + assert r.response["status"] == 200 + assert r.response["header"].get("content-encoding") != "br", \ + "binary content should not be brotli-compressed" From 2132afdeaa2dc90939663751c734ab3e9b46411d Mon Sep 17 00:00:00 2001 From: Paul Lodge Date: Wed, 11 Nov 2026 13:11:00 +0100 Subject: [PATCH 2/3] Refactored so that the tests are now in the correct module --- test/modules/brotli/__init__.py | 0 test/modules/brotli/conftest.py | 32 ------------------- test/modules/brotli/env.py | 25 --------------- test/modules/filters/env.py | 4 +-- .../htdocs/test1/greeting.txt | 0 .../htdocs/test1/lorem.txt | 0 .../test_004_brotli_compression.py} | 8 ++--- 7 files changed, 6 insertions(+), 63 deletions(-) delete mode 100644 test/modules/brotli/__init__.py delete mode 100644 test/modules/brotli/conftest.py delete mode 100644 test/modules/brotli/env.py rename test/modules/{brotli => filters}/htdocs/test1/greeting.txt (100%) rename test/modules/{brotli => filters}/htdocs/test1/lorem.txt (100%) rename test/modules/{brotli/test_001_compression.py => filters/test_004_brotli_compression.py} (93%) diff --git a/test/modules/brotli/__init__.py b/test/modules/brotli/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/test/modules/brotli/conftest.py b/test/modules/brotli/conftest.py deleted file mode 100644 index 7caeef9549c..00000000000 --- a/test/modules/brotli/conftest.py +++ /dev/null @@ -1,32 +0,0 @@ -import logging -import os -import sys - -import pytest - -from .env import BrotliTestEnv - -sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) - - -def pytest_report_header(config, start_path): - env = BrotliTestEnv() - return f"brotli [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]" - - -@pytest.fixture(scope="package") -def env(pytestconfig) -> BrotliTestEnv: - logging.getLogger('').setLevel(level=logging.INFO) - env = BrotliTestEnv(pytestconfig=pytestconfig) - if not env.has_shared_module("brotli"): - pytest.skip("mod_brotli not available") - env.setup_httpd() - env.apache_access_log_clear() - env.httpd_error_log.clear_log() - return env - - -@pytest.fixture(autouse=True, scope="package") -def _stop_package_scope(env): - yield - assert env.apache_stop() == 0 diff --git a/test/modules/brotli/env.py b/test/modules/brotli/env.py deleted file mode 100644 index c87e513323a..00000000000 --- a/test/modules/brotli/env.py +++ /dev/null @@ -1,25 +0,0 @@ -import inspect -import logging -import os - -from pyhttpd.env import HttpdTestEnv, HttpdTestSetup - -log = logging.getLogger(__name__) - - -class BrotliTestSetup(HttpdTestSetup): - - def __init__(self, env: 'HttpdTestEnv'): - super().__init__(env=env) - self.add_source_dir(os.path.dirname(inspect.getfile(BrotliTestSetup))) - self.add_modules(["brotli"]) - - -class BrotliTestEnv(HttpdTestEnv): - - def __init__(self, pytestconfig=None): - super().__init__(pytestconfig=pytestconfig) - self.add_httpd_log_modules(["brotli", "core"]) - - def setup_httpd(self, setup: HttpdTestSetup = None): - super().setup_httpd(setup=BrotliTestSetup(env=self)) diff --git a/test/modules/filters/env.py b/test/modules/filters/env.py index 0c2c5fbdbf5..28496c30d99 100644 --- a/test/modules/filters/env.py +++ b/test/modules/filters/env.py @@ -12,7 +12,7 @@ class FiltersTestSetup(HttpdTestSetup): def __init__(self, env: 'HttpdTestEnv'): super().__init__(env=env) self.add_source_dir(os.path.dirname(inspect.getfile(FiltersTestSetup))) - self.add_modules(["substitute", "sed", "buffer"]) + self.add_modules(["substitute", "sed", "buffer", "brotli"]) self.add_cgi_module() @@ -20,7 +20,7 @@ class FiltersTestEnv(HttpdTestEnv): def __init__(self, pytestconfig=None): super().__init__(pytestconfig=pytestconfig) - self.add_httpd_log_modules(["substitute", "sed", "core"]) + self.add_httpd_log_modules(["substitute", "sed", "buffer", "brotli", "core"]) def setup_httpd(self, setup: HttpdTestSetup = None): super().setup_httpd(setup=FiltersTestSetup(env=self)) diff --git a/test/modules/brotli/htdocs/test1/greeting.txt b/test/modules/filters/htdocs/test1/greeting.txt similarity index 100% rename from test/modules/brotli/htdocs/test1/greeting.txt rename to test/modules/filters/htdocs/test1/greeting.txt diff --git a/test/modules/brotli/htdocs/test1/lorem.txt b/test/modules/filters/htdocs/test1/lorem.txt similarity index 100% rename from test/modules/brotli/htdocs/test1/lorem.txt rename to test/modules/filters/htdocs/test1/lorem.txt diff --git a/test/modules/brotli/test_001_compression.py b/test/modules/filters/test_004_brotli_compression.py similarity index 93% rename from test/modules/brotli/test_001_compression.py rename to test/modules/filters/test_004_brotli_compression.py index 89d3066bebc..f02865f2aad 100644 --- a/test/modules/brotli/test_001_compression.py +++ b/test/modules/filters/test_004_brotli_compression.py @@ -22,7 +22,7 @@ def _class_scope(self, env): # Request a small text file with Accept-Encoding: br. # Verify Content-Encoding: br header is present. - def test_brotli_001_01(self, env): + def test_brotli_004_01_br_header_present(self, env): url = env.mkurl("http", "test1", "/greeting.txt") r = env.curl_get(url, options=[ "-H", "Accept-Encoding: br", @@ -38,7 +38,7 @@ def test_brotli_001_01(self, env): "body should be compressed, not plain text" # Request a larger text file; verify brotli compression is applied. - def test_brotli_001_02(self, env): + def test_brotli_004_02_large_file(self, env): url = env.mkurl("http", "test1", "/lorem.txt") r = env.curl_get(url, options=[ "-H", "Accept-Encoding: br", @@ -55,7 +55,7 @@ def test_brotli_001_02(self, env): # When the client does not advertise Accept-Encoding: br the response # must be served uncompressed -- no Content-Encoding header. - def test_brotli_001_03(self, env): + def test_brotli_004_03_response_uncompressd(self, env): url = env.mkurl("http", "test1", "/greeting.txt") r = env.curl_get(url) assert r.response, "no response" @@ -67,7 +67,7 @@ def test_brotli_001_03(self, env): assert body.strip() == "Good morning, fellow!" # Binary / non-matching content types must not be compressed. - def test_brotli_001_04(self, env): + def test_brotli_004_04_binary_no_match_no_compression(self, env): bin_dir = os.path.join(env.server_dir, "htdocs", "test1") bin_path = os.path.join(bin_dir, "pixel.png") with open(bin_path, "wb") as f: From f6acf7ecafda9411a16f51719ed6ebde0398c4ef Mon Sep 17 00:00:00 2001 From: Paul Lodge Date: Thu, 12 Nov 2026 11:00:10 +0100 Subject: [PATCH 3/3] Removed build-win.bat updates for brotli, moved to another PR --- test/pyhttpd/build-win.bat | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/test/pyhttpd/build-win.bat b/test/pyhttpd/build-win.bat index cbe901054b2..94b7e42ca6c 100644 --- a/test/pyhttpd/build-win.bat +++ b/test/pyhttpd/build-win.bat @@ -301,7 +301,7 @@ if NOT EXIST vcpkg\ ( ) @REM For some reason using the manifest doesn't install the default-features -vcpkg.exe install --triplet %VCPKG_TRIPLET% apr[private-headers] apr-util pcre2 openssl nghttp2 curl libxml2 jansson brotli +vcpkg.exe install --triplet %VCPKG_TRIPLET% apr[private-headers] apr-util pcre2 openssl nghttp2 curl libxml2 jansson POPD @@ -329,9 +329,7 @@ cmake "%HTTPD_SRC%" -B . ^ -DNGHTTP2_INCLUDE_DIR=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/include ^ -DAPR_INCLUDE_DIR=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/include ^ -DJANSSON_INCLUDE_DIR=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/include ^ - -DBROTLI_INCLUDE_DIR=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/include ^ "-DAPR_LIBRARIES=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/libapr-1.lib;%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/libaprutil-1.lib" ^ - "-DBROTLI_LIBRARIES=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/brotlienc.lib;%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/brotlidec.lib" ^ -DNGHTTP2_LIBRARIES=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/nghttp2.lib ^ -DJANSSON_LIBRARIES=%CWD%/vcpkg/installed/%VCPKG_TRIPLET%/lib/jansson.lib ^ -DENABLE_MIME_MAGIC=A ^